diff --git a/platforms/__common.sh b/platforms/__common.sh index 60b501ab53..a63c95e3bd 100644 --- a/platforms/__common.sh +++ b/platforms/__common.sh @@ -148,6 +148,50 @@ getSdkFromImage() { buildPlatform() { local versionFile="$1" local funcToCall="$2" + + # When VERSIONS_TO_BUILD_OVERRIDE is set (comma-separated list of versions), + # only build those specific versions and skip blob existence checks entirely. + # This allows force-building specific SDK versions without rebuilding everything. + if [ -n "$VERSIONS_TO_BUILD_OVERRIDE" ]; then + echo "VERSIONS_TO_BUILD_OVERRIDE is set: $VERSIONS_TO_BUILD_OVERRIDE" + echo "Building only specified versions, skipping storage account checks." + export OVERWRITE_EXISTING_SDKS="true" + + # Build a lookup set of requested versions + IFS=',' read -ra _requested_versions <<< "$VERSIONS_TO_BUILD_OVERRIDE" + declare -A _force_set + for _v in "${_requested_versions[@]}"; do + _v="$(echo "$_v" | xargs)" + [ -n "$_v" ] && _force_set["$_v"]=1 + done + + # Read the version file but only invoke the build function for matching versions. + # This preserves extra args (e.g. GPG keys, SHAs) that some platforms need. + while IFS= read -r VERSION_INFO || [[ -n $VERSION_INFO ]]; do + VERSION_INFO="$(echo -e "${VERSION_INFO}" | sed -e 's/^[[:space:]]*//')" + if [ -z "$VERSION_INFO" ] || [[ $VERSION_INFO = \#* ]]; then + continue + fi + + IFS=',' read -ra VERSION_INFO_PARTS <<< "$VERSION_INFO" + lineVersion="$(echo -e "${VERSION_INFO_PARTS[0]}" | sed -e 's/^[[:space:]]*//')" + + if [ -z "${_force_set[$lineVersion]:-}" ]; then + continue + fi + + echo "Force-building version: $lineVersion" + versionArgs=() + for arg in "${VERSION_INFO_PARTS[@]}"; do + arg="$(echo -e "${arg}" | sed -e 's/^[[:space:]]*//')" + versionArgs+=("$arg") + done + + $funcToCall "${versionArgs[@]}" + done < "$versionFile" + return + fi + while IFS= read -r VERSION_INFO || [[ -n $VERSION_INFO ]] do # remove all whitespace before first character diff --git a/platforms/nodejs/Dockerfile b/platforms/nodejs/Dockerfile index 8e860f31a0..60a02e170f 100644 --- a/platforms/nodejs/Dockerfile +++ b/platforms/nodejs/Dockerfile @@ -2,6 +2,8 @@ ARG OS_FLAVOR FROM mcr.microsoft.com/mirror/docker/library/buildpack-deps:${OS_FLAVOR} ARG OS_FLAVOR ENV OS_FLAVOR=$OS_FLAVOR +ARG VERSIONS_TO_BUILD_OVERRIDE="" +ENV VERSIONS_TO_BUILD_OVERRIDE=$VERSIONS_TO_BUILD_OVERRIDE RUN apt-get update \ && apt-get install -y --no-install-recommends \ diff --git a/platforms/php/Dockerfile b/platforms/php/Dockerfile index 82f3df6d86..bc400a3a43 100644 --- a/platforms/php/Dockerfile +++ b/platforms/php/Dockerfile @@ -2,6 +2,8 @@ ARG OS_FLAVOR FROM mcr.microsoft.com/mirror/docker/library/buildpack-deps:${OS_FLAVOR} AS php-buildpack-prereqs ARG OS_FLAVOR ENV OS_FLAVOR=$OS_FLAVOR +ARG VERSIONS_TO_BUILD_OVERRIDE="" +ENV VERSIONS_TO_BUILD_OVERRIDE=$VERSIONS_TO_BUILD_OVERRIDE COPY platforms/php/prereqs /php COPY platforms/php/prereqs/build.sh /tmp/ COPY images/receiveGpgKeys.sh /tmp/receiveGpgKeys.sh diff --git a/platforms/php/composer/Dockerfile b/platforms/php/composer/Dockerfile index a747039039..03d51cdd8e 100644 --- a/platforms/php/composer/Dockerfile +++ b/platforms/php/composer/Dockerfile @@ -2,6 +2,8 @@ ARG OS_FLAVOR FROM mcr.microsoft.com/mirror/docker/library/buildpack-deps:${OS_FLAVOR} AS php-buildpack-prereqs ARG OS_FLAVOR ENV OS_FLAVOR=$OS_FLAVOR +ARG VERSIONS_TO_BUILD_OVERRIDE="" +ENV VERSIONS_TO_BUILD_OVERRIDE=$VERSIONS_TO_BUILD_OVERRIDE COPY platforms/php/prereqs /php # COPY build/__phpVersions.sh /php/ COPY platforms/php/prereqs/build.sh /tmp/ diff --git a/platforms/python/Dockerfile b/platforms/python/Dockerfile index 0a837c0cb1..ef5603f788 100644 --- a/platforms/python/Dockerfile +++ b/platforms/python/Dockerfile @@ -3,6 +3,8 @@ ARG OS_FLAVOR FROM mcr.microsoft.com/mirror/docker/library/buildpack-deps:${OS_FLAVOR} ARG OS_FLAVOR ENV OS_FLAVOR=$OS_FLAVOR +ARG VERSIONS_TO_BUILD_OVERRIDE="" +ENV VERSIONS_TO_BUILD_OVERRIDE=$VERSIONS_TO_BUILD_OVERRIDE # COPY build/__pythonVersions.sh /tmp/ diff --git a/src/BuildScriptGenerator.Common/SdkImageRepositoryHelper.cs b/src/BuildScriptGenerator.Common/SdkImageRepositoryHelper.cs new file mode 100644 index 0000000000..40a8832b66 --- /dev/null +++ b/src/BuildScriptGenerator.Common/SdkImageRepositoryHelper.cs @@ -0,0 +1,21 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +namespace Microsoft.Oryx.BuildScriptGenerator.Common +{ + public static class SdkImageRepositoryHelper + { + /// + /// Maps a platform name to its OCI SDK image repository path. + /// e.g. "nodejs" → "oryx/nodejs-sdk", "php" → "oryx/php-sdk". + /// Final image ref: mcr.microsoft.com/oryx/nodejs-sdk:bookworm-20.20.2 + /// + public static string GetSdkImageRepository(string platformName, string prefix = null) + { + prefix = string.IsNullOrEmpty(prefix) ? SdkStorageConstants.DefaultAcrSdkRepositoryPrefix : prefix; + return $"{prefix}/{platformName}-sdk"; + } + } +} \ No newline at end of file diff --git a/src/BuildScriptGenerator.Common/SdkStorageConstants.cs b/src/BuildScriptGenerator.Common/SdkStorageConstants.cs index 92c1ba0920..59858b9507 100644 --- a/src/BuildScriptGenerator.Common/SdkStorageConstants.cs +++ b/src/BuildScriptGenerator.Common/SdkStorageConstants.cs @@ -21,5 +21,9 @@ public static class SdkStorageConstants public const string DotnetRuntimeVersionMetadataName = "Dotnet_runtime_version"; public const string LegacyDotnetRuntimeVersionMetadataName = "Runtime_version"; public const string OsTypeMetadataName = "Os_type"; + + // OCI image based SDK distribution constants + public const string DefaultAcrSdkRegistryUrl = "https://mcr.microsoft.com"; + public const string DefaultAcrSdkRepositoryPrefix = "oryx"; } } \ No newline at end of file diff --git a/src/BuildScriptGenerator/AcrSdkProvider.cs b/src/BuildScriptGenerator/AcrSdkProvider.cs new file mode 100644 index 0000000000..ab81a0ef83 --- /dev/null +++ b/src/BuildScriptGenerator/AcrSdkProvider.cs @@ -0,0 +1,212 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator.Common; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + /// + /// Fetches SDK tarballs directly from an OCI container registry. + /// SDK images are single-layer FROM scratch images containing a single + /// .tar.gz SDK file. The OCI layer blob is a tar archive of the image + /// filesystem, so this provider downloads the layer, extracts the inner SDK + /// tarball from it, and caches it locally. + /// + /// + /// Makes direct HTTP calls to the registry (no Unix socket). + /// See for the socket-based variant. + /// + public class AcrSdkProvider : IAcrSdkProvider + { + private readonly ILogger logger; + private readonly IStandardOutputWriter outputWriter; + private readonly BuildScriptGeneratorOptions options; + private readonly OciRegistryClient ociClient; + + public AcrSdkProvider( + IStandardOutputWriter outputWriter, + ILogger logger, + IOptions options, + OciRegistryClient ociClient) + { + this.logger = logger; + this.outputWriter = outputWriter; + this.options = options.Value; + this.ociClient = ociClient; + } + + /// + public async Task RequestSdkFromAcrAsync(string platformName, string version, string debianFlavor, string runtimeVersion = null) + { + if (string.IsNullOrEmpty(platformName)) + { + throw new ArgumentException("Platform name cannot be null or empty.", nameof(platformName)); + } + + if (string.IsNullOrEmpty(version)) + { + throw new ArgumentException("Version cannot be null or empty.", nameof(version)); + } + + if (string.IsNullOrEmpty(debianFlavor)) + { + debianFlavor = this.options.DebianFlavor ?? "bookworm"; + } + + var repository = SdkImageRepositoryHelper.GetSdkImageRepository(platformName, this.options.OryxAcrSdkRepositoryPrefix); + var tag = string.IsNullOrEmpty(runtimeVersion) + ? $"{debianFlavor}-{version}" + : $"{debianFlavor}-{version}_{runtimeVersion}"; + var blobName = $"{platformName}-{debianFlavor}-{version}.tar.gz"; + + this.logger.LogInformation( + "Requesting SDK from ACR: {Repository}:{Tag}", + repository, + tag); + this.outputWriter.WriteLine( + $"Requesting SDK from ACR: {repository}:{tag}"); + + // Download to the writable dynamic install directory, NOT /var/OryxSdks (read-only external mount). + var downloadDir = Path.Combine(this.options.DynamicInstallRootDir, platformName); + var tarballPath = Path.Combine(downloadDir, blobName); + var digestPath = Path.Combine(downloadDir, $".{blobName}.digest"); + + try + { + // Get image manifest + var remoteDigest = await this.ociClient.GetManifestDigestAsync(repository, tag); + + // Check if cached tarball is still fresh + if (File.Exists(tarballPath) && File.Exists(digestPath) && remoteDigest != null) + { + var localDigest = File.ReadAllText(digestPath).Trim(); + if (string.Equals(localDigest, remoteDigest, StringComparison.OrdinalIgnoreCase)) + { + this.logger.LogInformation( + "SDK cache is fresh (digest match): {FilePath}", + tarballPath); + this.outputWriter.WriteLine( + $"SDK tarball already cached and fresh at {tarballPath}"); + return true; + } + + this.logger.LogInformation( + "SDK cache is stale (digest mismatch). Re-downloading."); + } + + // Get manifest → extract single layer digest + var manifest = await this.ociClient.GetManifestAsync(repository, tag); + var layerDigest = OciRegistryClient.GetFirstLayerDigest(manifest); + + if (string.IsNullOrEmpty(layerDigest)) + { + this.logger.LogWarning( + "No layer found in manifest for {Repository}:{Tag}", + repository, + tag); + this.outputWriter.WriteLine($"No layer found in ACR manifest for {platformName} {version}."); + return false; + } + + Directory.CreateDirectory(downloadDir); + + // 2. Download the OCI layer blob to a temp file. + // The layer is a tar archive of the image filesystem (not the SDK tarball itself). + var layerTempPath = Path.Combine(downloadDir, $".layer-{Guid.NewGuid():N}.tmp"); + try + { + var downloadSuccess = await this.ociClient.DownloadLayerBlobAsync( + repository, + layerDigest, + layerTempPath); + + if (!downloadSuccess) + { + this.logger.LogWarning( + "ACR SDK pull failed digest verification: {Repository}:{Tag}", + repository, + tag); + this.outputWriter.WriteLine( + $"Failed to pull SDK from ACR (digest mismatch): {platformName} {version}"); + return false; + } + + // 3. Extract the inner SDK .tar.gz from the layer tar. + // The image is FROM scratch with a single COPY of the SDK tarball, + // so the layer contains the .tar.gz as a top-level entry. + this.ExtractFileFromTar(layerTempPath, tarballPath, blobName); + } + finally + { + // Always clean up the temporary layer file + if (File.Exists(layerTempPath)) + { + File.Delete(layerTempPath); + } + } + + this.logger.LogInformation( + "Successfully pulled SDK from ACR: {Repository}:{Tag} → {FilePath}", + repository, + tag, + tarballPath); + this.outputWriter.WriteLine( + $"Successfully pulled SDK from ACR: {platformName} {version}"); + + // Write manifest digest sidecar for future freshness checks + if (!string.IsNullOrEmpty(remoteDigest)) + { + File.WriteAllText(digestPath, remoteDigest); + } + + return true; + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Error pulling SDK from ACR: {Repository}:{Tag}", + repository, + tag); + this.outputWriter.WriteLine( + $"Error pulling SDK from ACR: {platformName} {version}: {ex.Message}"); + return false; + } + } + + /// + /// Extracts the expected SDK .tar.gz file from an OCI layer tar archive. + /// OCI layers use media type "application/vnd.docker.image.rootfs.diff.tar.gzip", + /// so the blob must be decompressed before reading tar entries. + /// + private void ExtractFileFromTar(string layerPath, string outputPath, string expectedFileName) + { + using (var stream = File.OpenRead(layerPath)) + using (var gzipStream = new GZipStream(stream, CompressionMode.Decompress)) + using (var tarReader = new TarReader(gzipStream)) + { + TarEntry entry; + while ((entry = tarReader.GetNextEntry()) != null) + { + var name = entry.Name.TrimStart('.', '/'); + if (entry.DataStream != null && name.Equals(expectedFileName, StringComparison.OrdinalIgnoreCase)) + { + entry.ExtractToFile(outputPath, overwrite: true); + return; + } + } + } + + throw new InvalidOperationException($"Expected entry '{expectedFileName}' not found in OCI layer: {layerPath}"); + } + } +} diff --git a/src/BuildScriptGenerator/AcrVersionProviderBase.cs b/src/BuildScriptGenerator/AcrVersionProviderBase.cs new file mode 100644 index 0000000000..242a3b184b --- /dev/null +++ b/src/BuildScriptGenerator/AcrVersionProviderBase.cs @@ -0,0 +1,95 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator.Common; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + /// + /// Base class for ACR-based SDK version providers. Parallel to + /// but discovers versions via OCI Distribution API (tag listing) instead of + /// Azure Blob Storage listing with XML metadata. + /// Default versions come from local per-flavor constants rather than ACR image labels. + /// + public class AcrVersionProviderBase + { + private readonly ILogger logger; + private readonly string debianFlavor; + private readonly string repositoryPrefix; + + public AcrVersionProviderBase( + IOptions commonOptions, + OciRegistryClient ociClient, + ILoggerFactory loggerFactory) + { + var options = commonOptions.Value; + this.logger = loggerFactory.CreateLogger(this.GetType()); + this.debianFlavor = options.DebianFlavor; + this.repositoryPrefix = options.OryxAcrSdkRepositoryPrefix; + this.OciClient = ociClient; + } + + protected OciRegistryClient OciClient { get; } + + /// + /// Lists available versions for a platform from ACR tags and resolves the default + /// version from the supplied per-flavor dictionary. + /// + protected PlatformVersionInfo GetAvailableVersionsFromAcr( + string platformName, + Dictionary defaultVersionPerFlavor) + { + var repository = SdkImageRepositoryHelper.GetSdkImageRepository(platformName, this.repositoryPrefix); + + this.logger.LogDebug("Getting available versions for {platformName} from ACR repository {repository}.", platformName, repository); + + var allTags = this.GetTags(repository); + var supportedVersions = this.FilterVersionTags(allTags); + + string defaultVersion = null; + if (defaultVersionPerFlavor != null && + !string.IsNullOrEmpty(this.debianFlavor) && + defaultVersionPerFlavor.TryGetValue(this.debianFlavor, out var version)) + { + defaultVersion = version; + } + + this.logger.LogDebug( + "Found {count} versions for {platformName} on ACR (default: {default}).", + supportedVersions.Count, + platformName, + defaultVersion ?? "none"); + + return PlatformVersionInfo.CreateAvailableOnAcr(supportedVersions, defaultVersion); + } + + private List GetTags(string repository) + { + try + { + return this.OciClient.GetAllTagsAsync(repository).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Failed to get tags from ACR for {repository}.", repository); + throw; + } + } + + private List FilterVersionTags(List allTags) + { + var prefix = $"{this.debianFlavor}-"; + return allTags + .Where(t => t.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + .Select(t => t.Substring(prefix.Length)) + .ToList(); + } + } +} diff --git a/src/BuildScriptGenerator/BuildScriptGenerator.csproj b/src/BuildScriptGenerator/BuildScriptGenerator.csproj index fe68cd3216..9151e7a866 100644 --- a/src/BuildScriptGenerator/BuildScriptGenerator.csproj +++ b/src/BuildScriptGenerator/BuildScriptGenerator.csproj @@ -22,11 +22,11 @@ - - - - - + + + + + diff --git a/src/BuildScriptGenerator/BuildScriptGeneratorServiceCollectionExtensions.cs b/src/BuildScriptGenerator/BuildScriptGeneratorServiceCollectionExtensions.cs index dc63ad3c89..fb966a870b 100644 --- a/src/BuildScriptGenerator/BuildScriptGeneratorServiceCollectionExtensions.cs +++ b/src/BuildScriptGenerator/BuildScriptGeneratorServiceCollectionExtensions.cs @@ -9,6 +9,9 @@ using System.Net.Http.Headers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator.Common; using Microsoft.Oryx.Detector; using Polly; using Polly.Extensions.Http; @@ -41,11 +44,33 @@ public static IServiceCollection AddBuildScriptGeneratorServices(this IServiceCo services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(sp => + { + var opts = sp.GetRequiredService>().Value; + var registryUrl = string.IsNullOrEmpty(opts.OryxAcrSdkRegistryUrl) + ? SdkStorageConstants.DefaultAcrSdkRegistryUrl + : opts.OryxAcrSdkRegistryUrl; + return new OciRegistryClient( + registryUrl, + sp.GetRequiredService(), + sp.GetRequiredService()); + }); services.AddHttpClient("general", httpClient => { // NOTE: Setting user agent is required to avoid receiving 403 Forbidden response. httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("oryx", "1.0")); - }).AddPolicyHandler(GetRetryPolicy()); + }) + .RedactLoggedHeaders(header => header.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) + .AddPolicyHandler(GetRetryPolicy()); + + services.AddHttpClient("acr", httpClient => + { + httpClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("oryx", "1.0")); + }) + .RedactLoggedHeaders(header => header.Equals("Authorization", StringComparison.OrdinalIgnoreCase)) + .AddPolicyHandler(GetAcrRetryPolicy()); // Add all checkers (platform-dependent + platform-independent) foreach (Type type in typeof(BuildScriptGeneratorServiceCollectionExtensions).Assembly.GetTypes()) @@ -68,5 +93,16 @@ private static IAsyncPolicy GetRetryPolicy() retryCount: 6, retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); } + + private static IAsyncPolicy GetAcrRetryPolicy() + { + // ACR-specific: only retry transient errors, NOT 404s. + // Missing OCI tags are deterministic + return HttpPolicyExtensions + .HandleTransientHttpError() + .WaitAndRetryAsync( + retryCount: 3, + retryAttempt => TimeSpan.FromSeconds(Math.Pow(2, retryAttempt))); + } } } diff --git a/src/BuildScriptGenerator/Contracts/IAcrSdkProvider.cs b/src/BuildScriptGenerator/Contracts/IAcrSdkProvider.cs new file mode 100644 index 0000000000..e0eeee91b1 --- /dev/null +++ b/src/BuildScriptGenerator/Contracts/IAcrSdkProvider.cs @@ -0,0 +1,36 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Threading.Tasks; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + /// + /// Fetches SDK tarballs directly from an OCI container registry. + /// SDK images are single-layer FROM scratch images where + /// the layer IS the SDK tarball. + /// + /// + /// Gated by the ORYX_ENABLE_ACR_SDK_PROVIDER feature flag. + /// Alternative to (blob storage via socket). + /// + public interface IAcrSdkProvider + { + /// + /// Pulls an SDK image from the registry and saves the tarball to + /// the dynamic install directory (writable by Oryx). + /// + /// The platform name (e.g., "nodejs", "python", "dotnet", "php"). + /// The SDK version (e.g., "20.19.3"). + /// The Debian flavor (e.g., "bookworm", "bullseye"). + /// + /// Optional runtime version for platforms whose ACR tags encode both SDK and runtime + /// versions (e.g., .NET tags use "{osFlavor}-{sdkVersion}_{runtimeVersion}"). + /// When provided, the tag is constructed as "{debianFlavor}-{version}_{runtimeVersion}". + /// + /// True if the SDK tarball was successfully downloaded. + Task RequestSdkFromAcrAsync(string platformName, string version, string debianFlavor, string runtimeVersion = null); + } +} diff --git a/src/BuildScriptGenerator/Contracts/IExternalAcrSdkProvider.cs b/src/BuildScriptGenerator/Contracts/IExternalAcrSdkProvider.cs new file mode 100644 index 0000000000..a67613e75c --- /dev/null +++ b/src/BuildScriptGenerator/Contracts/IExternalAcrSdkProvider.cs @@ -0,0 +1,30 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Threading.Tasks; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + /// + /// Pulls SDK tarballs from ACR via an external host over a Unix socket. + /// This is the ACR equivalent of (blob storage via socket). + /// + /// + /// Flow: Oryx → Unix socket → external host → ACR. + /// Gated by the ORYX_ENABLE_EXTERNAL_ACR_SDK_PROVIDER feature flag. + /// Version discovery is handled separately by . + /// + public interface IExternalAcrSdkProvider + { + /// + /// Pulls an SDK tarball from ACR via the external provider and places it in the local cache. + /// + /// The platform name (e.g., "python", "nodejs", "dotnet", "php"). + /// The SDK version. + /// The Debian flavor (e.g., "bookworm", "bullseye"). + /// True if the SDK was successfully pulled and cached. + Task RequestSdkAsync(string platformName, string version, string debianFlavor); + } +} diff --git a/src/BuildScriptGenerator/Contracts/IExternalSdkProvider.cs b/src/BuildScriptGenerator/Contracts/IExternalSdkProvider.cs index 143be2b025..e8d62a7513 100644 --- a/src/BuildScriptGenerator/Contracts/IExternalSdkProvider.cs +++ b/src/BuildScriptGenerator/Contracts/IExternalSdkProvider.cs @@ -17,7 +17,7 @@ public interface IExternalSdkProvider /// /// The directory where SDKs are cached by the external provider. /// - public const string ExternalSdksStorageDir = "/var/OryxSdksCache"; + public const string ExternalSdksStorageDir = "/var/OryxSdks"; /// /// Gets all metadata for a specific platform from the external SDK provider. diff --git a/src/BuildScriptGenerator/DefaultPlatformsInformationProvider.cs b/src/BuildScriptGenerator/DefaultPlatformsInformationProvider.cs index 8c11d5634e..e722453e47 100644 --- a/src/BuildScriptGenerator/DefaultPlatformsInformationProvider.cs +++ b/src/BuildScriptGenerator/DefaultPlatformsInformationProvider.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.Oryx.BuildScriptGenerator @@ -16,15 +17,18 @@ public class DefaultPlatformsInformationProvider { private readonly IEnumerable platforms; private readonly IStandardOutputWriter outputWriter; + private readonly ILogger logger; private readonly BuildScriptGeneratorOptions commonOptions; public DefaultPlatformsInformationProvider( IEnumerable platforms, IStandardOutputWriter outputWriter, + ILogger logger, IOptions commonOptions) { this.platforms = platforms; this.outputWriter = outputWriter; + this.logger = logger; this.commonOptions = commonOptions.Value; } @@ -39,6 +43,19 @@ public IEnumerable GetPlatformsInfo(RepositoryContext context) this.outputWriter.WriteLine($"Primary SDK Storage URL: {this.commonOptions.OryxSdkStorageBaseUrl}"); this.outputWriter.WriteLine($"Backup SDK Storage URL: {this.commonOptions.OryxSdkStorageBackupBaseUrl}"); + this.outputWriter.WriteLine($"ACR SDK Registry URL: {this.commonOptions.OryxAcrSdkRegistryUrl ?? "(not set)"}"); + + // Log SDK provider status and resolution priority + this.outputWriter.WriteLine("SDK provider status:"); + this.outputWriter.WriteLine($" External ACR SDK provider: {(this.commonOptions.EnableExternalAcrSdkProvider ? "Enabled" : "Disabled")}"); + this.outputWriter.WriteLine($" External SDK provider: {(this.commonOptions.EnableExternalSdkProvider ? "Enabled" : "Disabled")}"); + this.outputWriter.WriteLine($" Direct ACR SDK provider: {(this.commonOptions.EnableAcrSdkProvider ? "Enabled" : "Disabled")}"); + this.outputWriter.WriteLine($" Blob SDK provider: Enabled"); + + if (this.commonOptions.EnableExternalAcrSdkProvider && !string.IsNullOrEmpty(this.commonOptions.PlatformName)) + { + this.outputWriter.WriteLine($"External ACR SDK provider is enabled. Only using user-specified platform: {this.commonOptions.PlatformName}"); + } // Try detecting ALL platforms since in some scenarios this is required. // For example, in case of a multi-platform app like ASP.NET Core + NodeJs, we might need to dynamically @@ -47,18 +64,10 @@ public IEnumerable GetPlatformsInfo(RepositoryContext context) // build environment is setup with detected platforms' sdks. this.outputWriter.WriteLine("Detecting platforms..."); - if (this.commonOptions.EnableExternalSdkProvider) - { - this.outputWriter.WriteLine("External SDK provider is enabled."); - } - foreach (var platform in this.platforms) { - // Check if a platform is enabled or not - if (!platform.IsEnabled(context)) + if (!this.ShouldDetectPlatform(platform, context)) { - this.outputWriter.WriteLine( - $"Platform '{platform.Name}' has been disabled, so skipping detection for it."); continue; } @@ -92,5 +101,27 @@ public IEnumerable GetPlatformsInfo(RepositoryContext context) return platformInfos; } + + private bool ShouldDetectPlatform(IProgrammingPlatform platform, RepositoryContext context) + { + // Check if a platform is enabled or not + if (!platform.IsEnabled(context)) + { + this.outputWriter.WriteLine( + $"Platform '{platform.Name}' has been disabled, so skipping detection for it."); + return false; + } + + if (this.commonOptions.EnableExternalAcrSdkProvider && !string.IsNullOrEmpty(this.commonOptions.PlatformName) && platform.Name != this.commonOptions.PlatformName) + { + this.logger.LogDebug( + "Skipping detection for platform '{PlatformName}' because External ACR SDK provider is enabled and only user provided platform '{UserPlatform}' is considered.", + platform.Name, + this.commonOptions.PlatformName); + return false; + } + + return true; + } } } diff --git a/src/BuildScriptGenerator/DotNetCore/DotnetCoreConstants.cs b/src/BuildScriptGenerator/DotNetCore/DotnetCoreConstants.cs index 6b00b4bdd9..91f640b627 100644 --- a/src/BuildScriptGenerator/DotNetCore/DotnetCoreConstants.cs +++ b/src/BuildScriptGenerator/DotNetCore/DotnetCoreConstants.cs @@ -3,6 +3,8 @@ // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- +using System.Collections.Generic; + namespace Microsoft.Oryx.BuildScriptGenerator.DotNetCore { public static class DotNetCoreConstants @@ -57,5 +59,18 @@ public static class DotNetCoreConstants public const string InstallBlazorWebAssemblyAOTWorkloadCommand = "dotnet workload install wasm-tools"; public static readonly string DefaultDotNetCoreSdkVersionsInstallDir = $"/opt/{PlatformName}"; + + /// + /// Default .NET major.minor runtime version per OS flavor, matching platforms/dotnet/versions/*/defaultVersion.txt. + /// + public static readonly Dictionary DefaultVersionPerFlavor = new Dictionary + { + { "bookworm", "8.0" }, + { "bullseye", "6.0" }, + { "buster", "6.0" }, + { "focal-scm", "6.0" }, + { "noble", "10.0" }, + { "stretch", "6.0" }, + }; } } \ No newline at end of file diff --git a/src/BuildScriptGenerator/DotNetCore/DotnetCorePlatform.cs b/src/BuildScriptGenerator/DotNetCore/DotnetCorePlatform.cs index afeb21c9f3..1d84617159 100644 --- a/src/BuildScriptGenerator/DotNetCore/DotnetCorePlatform.cs +++ b/src/BuildScriptGenerator/DotNetCore/DotnetCorePlatform.cs @@ -28,6 +28,7 @@ namespace Microsoft.Oryx.BuildScriptGenerator.DotNetCore internal class DotNetCorePlatform : IProgrammingPlatform { private readonly IDotNetCoreVersionProvider versionProvider; + private readonly DotNetCoreExternalAcrVersionProvider externalAcrVersionProvider; private readonly ILogger logger; private readonly IDotNetCorePlatformDetector detector; private readonly DotNetCoreScriptGeneratorOptions dotNetCoreScriptGeneratorOptions; @@ -35,7 +36,10 @@ internal class DotNetCorePlatform : IProgrammingPlatform private readonly DotNetCorePlatformInstaller platformInstaller; private readonly GlobalJsonSdkResolver globalJsonSdkResolver; private readonly IExternalSdkProvider externalSdkProvider; + private readonly IExternalAcrSdkProvider externalAcrSdkProvider; + private readonly IAcrSdkProvider acrSdkProvider; private readonly TelemetryClient telemetryClient; + private readonly IStandardOutputWriter outputWriter; /// /// Initializes a new instance of the class. @@ -49,6 +53,7 @@ internal class DotNetCorePlatform : IProgrammingPlatform /// The . public DotNetCorePlatform( IDotNetCoreVersionProvider versionProvider, + DotNetCoreExternalAcrVersionProvider externalAcrVersionProvider, ILogger logger, IDotNetCorePlatformDetector detector, IOptions commonOptions, @@ -56,9 +61,13 @@ public DotNetCorePlatform( DotNetCorePlatformInstaller platformInstaller, GlobalJsonSdkResolver globalJsonSdkResolver, IExternalSdkProvider externalSdkProvider, - TelemetryClient telemetryClient) + IExternalAcrSdkProvider externalAcrSdkProvider, + IAcrSdkProvider acrSdkProvider, + TelemetryClient telemetryClient, + IStandardOutputWriter outputWriter) { this.versionProvider = versionProvider; + this.externalAcrVersionProvider = externalAcrVersionProvider; this.logger = logger; this.detector = detector; this.dotNetCoreScriptGeneratorOptions = dotNetCoreScriptGeneratorOptions.Value; @@ -66,7 +75,10 @@ public DotNetCorePlatform( this.platformInstaller = platformInstaller; this.globalJsonSdkResolver = globalJsonSdkResolver; this.externalSdkProvider = externalSdkProvider; + this.externalAcrSdkProvider = externalAcrSdkProvider; + this.acrSdkProvider = acrSdkProvider; this.telemetryClient = telemetryClient; + this.outputWriter = outputWriter; } /// @@ -147,7 +159,7 @@ public BuildScriptSnippet GenerateBashBuildScriptSnippet( installBlazorWebAssemblyAOTWorkloadCommand = DotNetCoreConstants.InstallBlazorWebAssemblyAOTWorkloadCommand; manifestFileProperties[ManifestFilePropertyKeys.Frameworks] = "blazor"; this.logger.LogInformation("Detected the following frameworks: blazor"); - Console.WriteLine("Detected the following frameworks: blazor"); + this.outputWriter.WriteLine("Detected the following frameworks: blazor"); } var templateProperties = new DotNetCoreBashBuildSnippetProperties @@ -233,55 +245,62 @@ public string GetInstallerScriptSnippet( $"'{typeof(DotNetCorePlatformDetectorResult)}' but got '{detectorResult.GetType()}'."); } - string installationScriptSnippet = null; - if (this.commonOptions.EnableDynamicInstall) + if (!this.commonOptions.EnableDynamicInstall) { - this.logger.LogDebug("Dynamic install is enabled."); + this.logger.LogDebug("Dynamic install is not enabled."); + return null; + } + + this.logger.LogDebug("Dynamic install is enabled."); + + var sdkVersion = dotNetCorePlatformDetectorResult.SdkVersion; + + if (this.platformInstaller.IsVersionAlreadyInstalled(sdkVersion)) + { + this.logger.LogDebug( + "DotNetCore SDK version {globalJsonSdkVersion} is already installed. So skipping installing it again.", + sdkVersion); + return null; + } - if (this.platformInstaller.IsVersionAlreadyInstalled(dotNetCorePlatformDetectorResult.SdkVersion)) + // Priority: External SDK ACR → External-SDK → Direct-ACR → CDN + // 1. Try External SDK ACR (socket → ACR) + if (this.commonOptions.EnableExternalAcrSdkProvider) + { + var result = this.TryInstallFromExternalAcrSdkProvider(sdkVersion); + if (result != null) { - this.logger.LogDebug("DotNetCore SDK version {globalJsonSdkVersion} is already installed. So skipping installing it again.", dotNetCorePlatformDetectorResult.SdkVersion); + return result; } - else + } + + // 2. Try External-SDK (socket → blob storage) + if (this.commonOptions.EnableExternalSdkProvider) + { + var result = this.TryInstallFromExternalSdkProvider(sdkVersion); + if (result != null) { - if (this.commonOptions.EnableExternalSdkProvider) - { - this.logger.LogDebug("DotNetCore SDK version {version} is not installed. External SDK provider is enabled so trying to fetch SDK using it.", dotNetCorePlatformDetectorResult.SdkVersion); - - try - { - var blobName = BlobNameHelper.GetBlobNameForVersion(this.Name, dotNetCorePlatformDetectorResult.SdkVersion, this.commonOptions.DebianFlavor); - var isExternalFetchSuccess = this.externalSdkProvider.RequestBlobAsync(this.Name, blobName).Result; - if (isExternalFetchSuccess) - { - this.logger.LogDebug("DotNetCore SDK version {version} is fetched successfully using external SDK provider. So generating an installation script snippet which skips platform binary download.", dotNetCorePlatformDetectorResult.SdkVersion); - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet(dotNetCorePlatformDetectorResult.SdkVersion, skipSdkBinaryDownload: true); - } - else - { - this.logger.LogDebug("DotNetCore SDK version {version} is not fetched successfully using external SDK provider. So generating an installation script snippet for it.", dotNetCorePlatformDetectorResult.SdkVersion); - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet(dotNetCorePlatformDetectorResult.SdkVersion); - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error while fetching DotNetCore SDK version version {version} using external SDK provider.", dotNetCorePlatformDetectorResult.SdkVersion); - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet(dotNetCorePlatformDetectorResult.SdkVersion); - } - } - else - { - this.logger.LogDebug("DotNetCore SDK version {globalJsonSdkVersion} is not installed. So generating an installation script snippet for it.", dotNetCorePlatformDetectorResult.SdkVersion); - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet(dotNetCorePlatformDetectorResult.SdkVersion); - } + return result; } } - else + + // 3. Try Direct-ACR (direct OCI API calls) + if (this.commonOptions.EnableAcrSdkProvider) { - this.logger.LogDebug("Dynamic install is not enabled."); + var runtimeVersion = dotNetCorePlatformDetectorResult.PlatformVersion; + var result = this.TryInstallFromAcrSdkProvider(sdkVersion, runtimeVersion); + if (result != null) + { + return result; + } } - return installationScriptSnippet; + // 4. CDN fallback + this.outputWriter.WriteLine($"Falling back to CDN for '{this.Name}' version '{sdkVersion}'."); + this.logger.LogDebug( + "DotNetCore SDK version {globalJsonSdkVersion} is not installed. So generating an installation script snippet for it.", + sdkVersion); + return this.platformInstaller.GetInstallerScriptSnippet(sdkVersion); } /// @@ -295,12 +314,31 @@ public void ResolveVersions(RepositoryContext context, PlatformDetectorResult de $"'{typeof(DotNetCorePlatformDetectorResult)}' but got '{detectorResult.GetType()}'."); } - // Get runtime version + // Resolve runtime version (same for all flows — ExternalAcrSdkProvider does not affect this). var resolvedRuntimeVersion = this.GetRuntimeVersionUsingHierarchicalRules( dotNetCorePlatformDetectorResult.PlatformVersion); resolvedRuntimeVersion = this.GetMaxSatisfyingRuntimeVersionAndVerify(resolvedRuntimeVersion); dotNetCorePlatformDetectorResult.PlatformVersion = resolvedRuntimeVersion; + // Resolve SDK version. + // External ACR provider dictates the SDK version directly — no runtime→SDK lookup needed. + // This is .NET-specific: other platforms have a single version, + // but .NET has separate runtime and SDK versions. The external host already knows + // which SDK companion image to use, so we trust its SDK version. + if (this.commonOptions.EnableExternalAcrSdkProvider) + { + var dictatedSdk = this.externalAcrVersionProvider.GetSdkVersion(); + if (!string.IsNullOrEmpty(dictatedSdk)) + { + this.logger.LogInformation( + "External ACR provider returned .NET SDK version {Version}. Using it directly.", + dictatedSdk); + dotNetCorePlatformDetectorResult.SdkVersion = dictatedSdk; + return; + } + } + + // Normal SDK resolution: look up from runtime→SDK version map. var versionMap = this.versionProvider.GetSupportedVersions(); var sdkVersion = this.GetSdkVersion(context, dotNetCorePlatformDetectorResult.PlatformVersion, versionMap); dotNetCorePlatformDetectorResult.SdkVersion = sdkVersion; @@ -359,6 +397,113 @@ private static void SetStartupFileNameInfoInManifestFile( buildProperties[DotNetCoreManifestFilePropertyKeys.StartupDllFileName] = startupDllFileName; } + private string TryInstallFromAcrSdkProvider(string sdkVersion, string runtimeVersion) + { + this.logger.LogDebug( + "DotNetCore SDK version {version} is not installed. ACR SDK provider is enabled, so trying to fetch SDK using it.", + sdkVersion); + + try + { + var result = this.acrSdkProvider.RequestSdkFromAcrAsync( + this.Name, sdkVersion, this.commonOptions.DebianFlavor, runtimeVersion).Result; + + if (result) + { + this.logger.LogDebug( + "DotNetCore SDK version {version} is fetched successfully using ACR SDK provider.", + sdkVersion); + this.outputWriter.WriteLine($"SDK for '{this.Name}' version '{sdkVersion}' fetched via direct ACR provider."); + return this.platformInstaller.GetInstallerScriptSnippet(sdkVersion, skipSdkBinaryDownload: true); + } + + this.logger.LogDebug( + "DotNetCore SDK version {version} is not fetched via ACR SDK provider. Trying next provider.", + sdkVersion); + this.outputWriter.WriteLine($"Failed to fetch SDK via direct ACR provider for '{this.Name}' version '{sdkVersion}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Error while fetching DotNetCore SDK version {version} using ACR SDK provider. Trying next provider.", + sdkVersion); + this.outputWriter.WriteLine($"Error fetching SDK via direct ACR provider for '{this.Name}' version '{sdkVersion}'. Trying next provider."); + } + + return null; + } + + private string TryInstallFromExternalSdkProvider(string sdkVersion) + { + this.logger.LogDebug( + "DotNetCore SDK version {version} is not installed. External SDK provider is enabled so trying to fetch SDK using it.", + sdkVersion); + + try + { + var blobName = BlobNameHelper.GetBlobNameForVersion(this.Name, sdkVersion, this.commonOptions.DebianFlavor); + if (this.externalSdkProvider.RequestBlobAsync(this.Name, blobName).Result) + { + this.logger.LogDebug( + "DotNetCore SDK version {version} is fetched successfully using external SDK provider. Skipping platform binary download.", + sdkVersion); + this.outputWriter.WriteLine($"SDK for '{this.Name}' version '{sdkVersion}' fetched via external SDK provider."); + return this.platformInstaller.GetInstallerScriptSnippet(sdkVersion, skipSdkBinaryDownload: true); + } + + this.logger.LogDebug( + "DotNetCore SDK version {version} is not fetched successfully using external SDK provider. Generating installation script snippet.", + sdkVersion); + this.outputWriter.WriteLine($"Failed to fetch SDK via external SDK provider for '{this.Name}' version '{sdkVersion}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Error while fetching DotNetCore SDK version version {version} using external SDK provider.", + sdkVersion); + this.outputWriter.WriteLine($"Error fetching SDK via external SDK provider for '{this.Name}' version '{sdkVersion}'. Trying next provider."); + } + + return null; + } + + private string TryInstallFromExternalAcrSdkProvider(string sdkVersion) + { + this.logger.LogDebug( + "DotNetCore SDK version {version} is not installed. External ACR SDK provider is enabled, so trying to fetch SDK using it.", + sdkVersion); + + try + { + if (this.externalAcrSdkProvider.RequestSdkAsync( + this.Name, sdkVersion, this.commonOptions.DebianFlavor).Result) + { + this.logger.LogDebug( + "DotNetCore SDK version {version} is fetched successfully using external ACR SDK provider. Skipping platform binary download.", + sdkVersion); + this.outputWriter.WriteLine($"SDK for '{this.Name}' version '{sdkVersion}' fetched via external ACR provider."); + return this.platformInstaller.GetInstallerScriptSnippet(sdkVersion, skipSdkBinaryDownload: true); + } + + this.logger.LogDebug( + "DotNetCore SDK version {version} is not fetched via external ACR SDK provider. Trying next provider.", + sdkVersion); + this.outputWriter.WriteLine($"Failed to fetch SDK via external ACR provider for '{this.Name}' version '{sdkVersion}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Error while fetching DotNetCore SDK version {version} using external ACR SDK provider. Trying next provider.", + sdkVersion); + this.outputWriter.WriteLine($"Error fetching SDK via external ACR provider for '{this.Name}' version '{sdkVersion}'. Trying next provider."); + } + + return null; + } + private string GetSdkVersion( RepositoryContext context, string runtimeVersion, diff --git a/src/BuildScriptGenerator/DotNetCore/DotnetCoreScriptGeneratorServiceCollectionExtensions.cs b/src/BuildScriptGenerator/DotNetCore/DotnetCoreScriptGeneratorServiceCollectionExtensions.cs index ecd68fd61c..00ae8d20aa 100644 --- a/src/BuildScriptGenerator/DotNetCore/DotnetCoreScriptGeneratorServiceCollectionExtensions.cs +++ b/src/BuildScriptGenerator/DotNetCore/DotnetCoreScriptGeneratorServiceCollectionExtensions.cs @@ -19,8 +19,10 @@ public static IServiceCollection AddDotNetCoreScriptGeneratorServices(this IServ services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreAcrVersionProvider.cs b/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreAcrVersionProvider.cs new file mode 100644 index 0000000000..2f25037bff --- /dev/null +++ b/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreAcrVersionProvider.cs @@ -0,0 +1,125 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator.Common; + +namespace Microsoft.Oryx.BuildScriptGenerator.DotNetCore +{ + /// + /// ACR-based version provider for .NET SDKs. + /// Unlike other platforms, .NET requires a runtime→SDK version mapping. + /// This provider extracts the mapping directly from image tags, which encode + /// both versions in the format "{osFlavor}-{sdkVersion}_{runtimeVersion}" + /// (e.g. "noble-10.0.201_10.0.5"). + /// + public class DotNetCoreAcrVersionProvider : AcrVersionProviderBase, IDotNetCoreVersionProvider + { + private readonly BuildScriptGeneratorOptions commonOptions; + private readonly ILogger logger; + private Dictionary versionMap; + private string defaultRuntimeVersion; + + public DotNetCoreAcrVersionProvider( + IOptions commonOptions, + OciRegistryClient ociClient, + ILoggerFactory loggerFactory) + : base(commonOptions, ociClient, loggerFactory) + { + this.commonOptions = commonOptions.Value; + this.logger = loggerFactory.CreateLogger(); + } + + public string GetDefaultRuntimeVersion() + { + this.EnsureVersionInfo(); + + // Return null when no ACR tags matched the current OS flavor so the + // caller's fallback chain (External-blob → ACR → CDN) continues to the + // next provider. Without this, the hardcoded DefaultVersionPerFlavor + // value would be returned even though ACR has no matching SDKs. + if (this.versionMap == null || this.versionMap.Count == 0) + { + return null; + } + + return this.defaultRuntimeVersion; + } + + public Dictionary GetSupportedVersions() + { + this.EnsureVersionInfo(); + if (this.versionMap == null || this.versionMap.Count == 0) + { + return null; + } + + return this.versionMap; + } + + private void EnsureVersionInfo() + { + if (this.versionMap != null) + { + return; + } + + var repository = SdkImageRepositoryHelper.GetSdkImageRepository(DotNetCoreConstants.PlatformName, this.commonOptions.OryxAcrSdkRepositoryPrefix); + var debianFlavor = this.commonOptions.DebianFlavor; + + this.GetVersionInfoFromTags(repository, debianFlavor); + } + + /// + /// Parses the runtime→SDK version mapping from ACR tags. + /// Tags follow the format "{osFlavor}-{sdkVersion}_{runtimeVersion}". + /// + private void GetVersionInfoFromTags(string repository, string debianFlavor) + { + this.logger.LogDebug("Getting .NET version info from ACR tags for repository {repository}.", repository); + + var allTags = this.OciClient.GetAllTagsAsync(repository).GetAwaiter().GetResult(); + + var prefix = $"{debianFlavor}-"; + var supportedVersions = new Dictionary(StringComparer.OrdinalIgnoreCase); + + foreach (var tag in allTags) + { + if (!tag.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + // Strip the osFlavor prefix to get "{sdkVersion}_{runtimeVersion}" + var versionPart = tag.Substring(prefix.Length); + var parts = versionPart.Split('_', 2); + var sdkVersion = parts[0]; + var runtimeVersion = parts.Length > 1 ? parts[1] : null; + if (parts.Length != 2 || string.IsNullOrEmpty(sdkVersion) || string.IsNullOrEmpty(runtimeVersion)) + { + this.logger.LogDebug("Skipping tag '{tag}' — does not match expected format.", tag); + continue; + } + + supportedVersions[runtimeVersion] = sdkVersion; + } + + this.versionMap = supportedVersions; + + DotNetCoreConstants.DefaultVersionPerFlavor.TryGetValue( + debianFlavor ?? string.Empty, out var defaultVersion); + this.defaultRuntimeVersion = defaultVersion; + + this.logger.LogDebug( + "Found {count} .NET runtime→SDK mappings from tags (default runtime: {default}).", + supportedVersions.Count, + this.defaultRuntimeVersion ?? "none"); + } + } +} diff --git a/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreExternalAcrVersionProvider.cs b/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreExternalAcrVersionProvider.cs new file mode 100644 index 0000000000..e84443c686 --- /dev/null +++ b/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreExternalAcrVersionProvider.cs @@ -0,0 +1,50 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Oryx.BuildScriptGenerator.DotNetCore +{ + /// + /// ACR-based version provider for .NET SDKs via external socket provider. + /// Unlike other platforms that implement I{X}VersionProvider, this exposes + /// a raw SDK version string. handles + /// the adaptation to the runtime→SDK map. + /// + public class DotNetCoreExternalAcrVersionProvider : ExternalAcrVersionProviderBase + { + private string resolvedVersion; + private bool resolved; + + public DotNetCoreExternalAcrVersionProvider( + IOptions options, + ILoggerFactory loggerFactory, + IStandardOutputWriter outputWriter) + : base(options, loggerFactory, outputWriter) + { + } + + /// + /// Gets the single SDK version dictated by the external host, or null if unavailable. + /// + public virtual string GetSdkVersion() + { + if (!this.resolved) + { + var flavor = this.DebianFlavor; + if (string.IsNullOrEmpty(flavor)) + { + return null; + } + + this.resolvedVersion = this.GetCompanionSdkVersion(DotNetCoreConstants.PlatformName, debianFlavor: flavor); + this.resolved = true; + } + + return this.resolvedVersion; + } + } +} diff --git a/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreSdkStorageVersionProvider.cs b/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreSdkStorageVersionProvider.cs index 5724b0cc28..a8e7d6f044 100644 --- a/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreSdkStorageVersionProvider.cs +++ b/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreSdkStorageVersionProvider.cs @@ -70,7 +70,17 @@ public void GetVersionInfo() { this.logger.LogWarning(ex, "Failed to get list of blobs from primary storage. Trying backup storage."); var sdkStorageBackupBaseUrl = this.GetPlatformBinariesBackupStorageBaseUrl(); - xdoc = ListBlobsHelper.GetAllBlobs(sdkStorageBackupBaseUrl, DotNetCoreConstants.PlatformName, httpClient); + if (sdkStorageBackupBaseUrl != null) + { + xdoc = ListBlobsHelper.GetAllBlobs(sdkStorageBackupBaseUrl, DotNetCoreConstants.PlatformName, httpClient); + } + else + { + throw new InvalidOperationException( + $"Failed to get SDK versions for platform '{DotNetCoreConstants.PlatformName}' from primary storage URL '{sdkStorageBaseUrl}', " + + $"and backup storage URL is not configured. {Constants.NetworkConfigurationHelpText}", + ex); + } } // keys represent runtime version, values represent sdk version diff --git a/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreVersionProvider.cs b/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreVersionProvider.cs index 28c5236f5b..307c700d79 100644 --- a/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreVersionProvider.cs +++ b/src/BuildScriptGenerator/DotNetCore/VersionProviders/DotNetCoreVersionProvider.cs @@ -15,7 +15,9 @@ internal class DotNetCoreVersionProvider : IDotNetCoreVersionProvider private readonly DotNetCoreOnDiskVersionProvider onDiskVersionProvider; private readonly DotNetCoreSdkStorageVersionProvider sdkStorageVersionProvider; private readonly DotNetCoreExternalVersionProvider externalVersionProvider; + private readonly DotNetCoreAcrVersionProvider acrVersionProvider; private readonly ILogger logger; + private readonly IStandardOutputWriter outputWriter; private string defaultRuntimeVersion; private Dictionary supportedVersions; @@ -24,42 +26,26 @@ public DotNetCoreVersionProvider( DotNetCoreOnDiskVersionProvider onDiskVersionProvider, DotNetCoreSdkStorageVersionProvider sdkStorageVersionProvider, DotNetCoreExternalVersionProvider externalVersionProvider, - ILogger logger) + DotNetCoreAcrVersionProvider acrVersionProvider, + ILogger logger, + IStandardOutputWriter outputWriter) { this.cliOptions = cliOptions.Value; this.onDiskVersionProvider = onDiskVersionProvider; this.sdkStorageVersionProvider = sdkStorageVersionProvider; this.externalVersionProvider = externalVersionProvider; + this.acrVersionProvider = acrVersionProvider; this.logger = logger; + this.outputWriter = outputWriter; } public string GetDefaultRuntimeVersion() { if (string.IsNullOrEmpty(this.defaultRuntimeVersion)) { - if (this.cliOptions.EnableDynamicInstall) - { - if (this.cliOptions.EnableExternalSdkProvider) - { - try - { - this.defaultRuntimeVersion = this.externalVersionProvider.GetDefaultRuntimeVersion(); - } - catch (System.Exception ex) - { - this.logger.LogError($"Failed to get default runtime version from external SDK provider. Falling back to http based sdkStorageVersionProvider. Ex: {ex}"); - this.defaultRuntimeVersion = this.sdkStorageVersionProvider.GetDefaultRuntimeVersion(); - } - } - else - { - this.defaultRuntimeVersion = this.sdkStorageVersionProvider.GetDefaultRuntimeVersion(); - } - } - else - { - this.defaultRuntimeVersion = this.onDiskVersionProvider.GetDefaultRuntimeVersion(); - } + this.defaultRuntimeVersion = this.cliOptions.EnableDynamicInstall + ? this.ResolveDynamicDefaultRuntimeVersion() + : this.onDiskVersionProvider.GetDefaultRuntimeVersion(); } this.logger.LogDebug("Default runtime version is {defaultRuntimeVersion}", this.defaultRuntimeVersion); @@ -71,29 +57,9 @@ public Dictionary GetSupportedVersions() { if (this.supportedVersions == null) { - if (this.cliOptions.EnableDynamicInstall) - { - if (this.cliOptions.EnableExternalSdkProvider) - { - try - { - this.supportedVersions = this.externalVersionProvider.GetSupportedVersions(); - } - catch (System.Exception ex) - { - this.logger.LogError($"Failed to get supported versions from external SDK provider. Falling back to http based sdkStorageVersionProvider. Ex: {ex}"); - this.supportedVersions = this.sdkStorageVersionProvider.GetSupportedVersions(); - } - } - else - { - this.supportedVersions = this.sdkStorageVersionProvider.GetSupportedVersions(); - } - } - else - { - this.supportedVersions = this.onDiskVersionProvider.GetSupportedVersions(); - } + this.supportedVersions = this.cliOptions.EnableDynamicInstall + ? this.ResolveDynamicSupportedVersions() + : this.onDiskVersionProvider.GetSupportedVersions(); // A temporary fix to make building netcoreapp1.0 versions using the 1.1 SDK // This SDK has 2 runtimes: 1.1.13 and 1.0.16 @@ -105,5 +71,114 @@ public Dictionary GetSupportedVersions() return this.supportedVersions; } + + // Note: External ACR provider is handled by DotNetCorePlatform.ResolveVersions(), + // which overrides the SDK version(the runtime version is expected to be + // supplied by the caller (user-specified, detected from .csproj, or user-specified default) + // Priority here: External-blob → Direct-ACR → CDN + private string ResolveDynamicDefaultRuntimeVersion() + { + if (this.cliOptions.EnableExternalSdkProvider) + { + var version = this.TryGetDefaultRuntimeVersionFromExternalBlob(); + if (!string.IsNullOrEmpty(version)) + { + this.outputWriter.WriteLine("DotNet version resolved using external SDK provider(blob)."); + return version; + } + } + + if (this.cliOptions.EnableAcrSdkProvider) + { + var version = this.TryGetDefaultRuntimeVersionFromAcr(); + if (!string.IsNullOrEmpty(version)) + { + this.outputWriter.WriteLine("DotNet version resolved using direct ACR SDK provider."); + return version; + } + } + + this.outputWriter.WriteLine("DotNet version resolved using blob SDK storage provider(CDN)."); + return this.sdkStorageVersionProvider.GetDefaultRuntimeVersion(); + } + + private Dictionary ResolveDynamicSupportedVersions() + { + if (this.cliOptions.EnableExternalSdkProvider) + { + var versions = this.TryGetSupportedVersionsFromExternalBlob(); + if (versions != null) + { + return versions; + } + } + + if (this.cliOptions.EnableAcrSdkProvider) + { + var versions = this.TryGetSupportedVersionsFromAcr(); + if (versions != null) + { + return versions; + } + } + + return this.sdkStorageVersionProvider.GetSupportedVersions(); + } + + private string TryGetDefaultRuntimeVersionFromExternalBlob() + { + try + { + return this.externalVersionProvider.GetDefaultRuntimeVersion(); + } + catch (System.Exception ex) + { + this.logger.LogError( + $"Error while getting default runtime version from external blob provider. Ex: {ex}"); + return null; + } + } + + private string TryGetDefaultRuntimeVersionFromAcr() + { + try + { + return this.acrVersionProvider.GetDefaultRuntimeVersion(); + } + catch (System.Exception ex) + { + this.logger.LogError( + $"Error while getting default runtime version from direct ACR provider. Ex: {ex}"); + return null; + } + } + + private Dictionary TryGetSupportedVersionsFromExternalBlob() + { + try + { + return this.externalVersionProvider.GetSupportedVersions(); + } + catch (System.Exception ex) + { + this.logger.LogError( + $"Error while getting supported versions from external blob provider. Ex: {ex}"); + return null; + } + } + + private Dictionary TryGetSupportedVersionsFromAcr() + { + try + { + return this.acrVersionProvider.GetSupportedVersions(); + } + catch (System.Exception ex) + { + this.logger.LogError( + $"Error while getting supported versions from direct ACR provider. Ex: {ex}"); + return null; + } + } } } \ No newline at end of file diff --git a/src/BuildScriptGenerator/ExternalAcrSdkProvider.cs b/src/BuildScriptGenerator/ExternalAcrSdkProvider.cs new file mode 100644 index 0000000000..b10dec60f7 --- /dev/null +++ b/src/BuildScriptGenerator/ExternalAcrSdkProvider.cs @@ -0,0 +1,181 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.IO; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + /// + /// Pulls SDK tarballs from ACR via an external host over a Unix socket. + /// This is the ACR equivalent of (blob storage via socket). + /// + /// + /// Flow: Oryx → Unix socket → external host → ACR. + /// Connects to a dedicated ACR SDK socket and sends Action=pull-sdk so the + /// external host routes to the ACR image-pull logic. + /// Version discovery is handled by . + /// + public class ExternalAcrSdkProvider : IExternalAcrSdkProvider + { + /// + /// The directory where ACR-based SDKs are cached. + /// Must match the mount path used by the external host. + /// + public const string ExternalAcrSdksStorageDir = "/var/OryxAcrSdks"; + + private const string SocketPath = "/var/sdk-image-sockets/oryx-pull-sdk-image.socket"; + private const int MaxTimeoutForSocketOperationInSeconds = 100; + + private readonly ILogger logger; + private readonly IStandardOutputWriter outputWriter; + + public ExternalAcrSdkProvider( + IStandardOutputWriter outputWriter, + ILogger logger) + { + this.logger = logger; + this.outputWriter = outputWriter; + } + + /// + public async Task RequestSdkAsync(string platformName, string version, string debianFlavor) + { + if (string.IsNullOrEmpty(platformName)) + { + throw new ArgumentException("Platform name cannot be null or empty.", nameof(platformName)); + } + + if (string.IsNullOrEmpty(version)) + { + throw new ArgumentException("Version cannot be null or empty.", nameof(version)); + } + + if (string.IsNullOrEmpty(debianFlavor)) + { + throw new ArgumentException("Debian flavor cannot be null or empty.", nameof(debianFlavor)); + } + + // No local cache check here — external provider handles digest-based freshness internally. + // On each call, the external provider does a lightweight HEAD request to compare the remote manifest for sdk image + // digest against a local digest file, and only re-pulls the image if the digest changed. + this.logger.LogInformation( + "Requesting SDK from ACR via external provider: platform={PlatformName}, version={Version}, " + + "debianFlavor={DebianFlavor}", + platformName, + version, + debianFlavor); + this.outputWriter.WriteLine( + $"Requesting SDK from ACR via external provider: {platformName} {version} ({debianFlavor})"); + + try + { + var request = new ExternalAcrSdkProviderRequest + { + Action = "pull-sdk", + PlatformName = platformName, + Version = version, + DebianFlavor = debianFlavor, + }; + + var responseFilename = await this.SendRequestAsync(request); + + if (!string.IsNullOrEmpty(responseFilename)) + { + var filePath = Path.Combine(ExternalAcrSdksStorageDir, platformName, responseFilename); + this.logger.LogInformation( + "Successfully pulled SDK from ACR via external provider: {PlatformName} {Version}, " + + "available at {FilePath}", + platformName, + version, + filePath); + this.outputWriter.WriteLine( + $"Successfully pulled SDK from ACR via external provider: {platformName} {version}"); + return true; + } + else + { + this.logger.LogWarning( + "ACR SDK pull via external provider was unsuccessful: {PlatformName} {Version}", + platformName, + version); + this.outputWriter.WriteLine( + $"Failed to pull SDK from ACR via external provider: {platformName} {version}"); + return false; + } + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Error requesting SDK from ACR via external provider: {PlatformName} {Version}", + platformName, + version); + this.outputWriter.WriteLine( + $"Error pulling SDK from ACR via external provider: {platformName} {version}: {ex.Message}"); + return false; + } + } + + private async Task SendRequestAsync(ExternalAcrSdkProviderRequest request) + { + try + { + this.logger.LogInformation( + "Sending ACR request via socket: Action={Action}, PlatformName={PlatformName}, Version={Version}, DebianFlavor={DebianFlavor}", + request.Action, + request.PlatformName, + request.Version, + request.DebianFlavor); + + var responseString = await SocketRequestHelper.SendRequestAsync(SocketPath, request, MaxTimeoutForSocketOperationInSeconds); + responseString = responseString?.TrimEnd('$'); + + this.logger.LogInformation( + "Received response from external ACR provider: {Response}", responseString); + + if (!string.IsNullOrEmpty(responseString) && + !responseString.Equals("Error", StringComparison.OrdinalIgnoreCase)) + { + return responseString.Trim(); + } + else + { + this.logger.LogError( + "ACR request via socket was unsuccessful. Response: {Response}", + responseString); + } + } + catch (OperationCanceledException) + { + this.outputWriter.WriteLine("The external ACR provider operation was canceled due to timeout."); + this.logger.LogError("The external ACR provider operation was canceled due to timeout."); + } + catch (Exception ex) + { + this.outputWriter.WriteLine( + $"Error communicating with external ACR provider: {ex.Message}"); + this.logger.LogError(ex, "Error communicating with external ACR provider."); + } + + return null; + } + + private class ExternalAcrSdkProviderRequest + { + public string Action { get; set; } + + public string PlatformName { get; set; } + + public string Version { get; set; } + + public string DebianFlavor { get; set; } + } + } +} diff --git a/src/BuildScriptGenerator/ExternalAcrVersionProviderBase.cs b/src/BuildScriptGenerator/ExternalAcrVersionProviderBase.cs new file mode 100644 index 0000000000..5015271f8e --- /dev/null +++ b/src/BuildScriptGenerator/ExternalAcrVersionProviderBase.cs @@ -0,0 +1,129 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + /// + /// Base class for version providers that resolve the companion SDK version for a platform + /// via a Unix socket to the external host. + /// The external host dictates the SDK version to use for each platform. + /// + /// + /// Flow: Oryx → Unix socket → external host → single SDK version response. + /// Connects to the dedicated ACR SDK socket and sends Action=get-version. + /// SDK pulling is handled separately by . + /// + public class ExternalAcrVersionProviderBase + { + private const string SocketPath = "/var/sdk-image-sockets/oryx-pull-sdk-image.socket"; + private const int MaxTimeoutForSocketOperationInSeconds = 100; + + private readonly BuildScriptGeneratorOptions commonOptions; + private readonly ILogger logger; + private readonly IStandardOutputWriter outputWriter; + + public ExternalAcrVersionProviderBase( + IOptions options, + ILoggerFactory loggerFactory, + IStandardOutputWriter outputWriter) + { + this.commonOptions = options.Value; + this.logger = loggerFactory.CreateLogger(this.GetType()); + this.outputWriter = outputWriter; + } + + /// + /// Gets the Debian flavor from build options. + /// + protected string DebianFlavor => this.commonOptions.DebianFlavor; + + /// + /// Asks the external provider for the single SDK version to use for . + /// + /// The SDK version string, or null if the external provider could not resolve one. + protected string GetCompanionSdkVersion(string platformName, string debianFlavor) + { + this.logger.LogInformation( + "Requesting companion SDK version for {PlatformName} and Debian flavor {DebianFlavor} from external ACR provider.", + platformName, + debianFlavor); + + string version; + try + { + version = this.SendRequestAsync(platformName, debianFlavor).GetAwaiter().GetResult(); + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Failed to get companion SDK version for {PlatformName} and Debian flavor {DebianFlavor} from external ACR provider.", + platformName, + debianFlavor); + return null; + } + + if (!string.IsNullOrEmpty(version)) + { + this.logger.LogInformation( + "External ACR provider returned SDK version {Version} for {PlatformName} and Debian flavor {DebianFlavor}.", + version, + platformName, + debianFlavor); + this.outputWriter.WriteLine( + $"External ACR provider resolved version '{version}' for {platformName}."); + } + else + { + this.logger.LogWarning( + "External ACR provider returned no SDK version for {PlatformName} and Debian flavor {DebianFlavor}.", + platformName, + debianFlavor); + this.outputWriter.WriteLine( + $"External ACR version provider returned no version for {platformName}. Trying next provider."); + } + + return version; + } + + private async Task SendRequestAsync(string platformName, string debianFlavor, string action = "get-version") + { + try + { + var request = new { Action = action, PlatformName = platformName, DebianFlavor = debianFlavor }; + var responseString = await SocketRequestHelper.SendRequestAsync(SocketPath, request, MaxTimeoutForSocketOperationInSeconds); + responseString = responseString?.TrimEnd('$'); + + if (!string.IsNullOrWhiteSpace(responseString) && + !responseString.Equals("Error", StringComparison.OrdinalIgnoreCase)) + { + return responseString.Trim(); + } + + this.logger.LogError( + "External provider returned an unsuccessful response: {Response}", + responseString); + } + catch (OperationCanceledException) + { + this.logger.LogError("Request to external provider timed out."); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error communicating with external provider."); + } + + return null; + } + } +} \ No newline at end of file diff --git a/src/BuildScriptGenerator/ExternalSdkProvider.cs b/src/BuildScriptGenerator/ExternalSdkProvider.cs index cfc19e0cc2..69ccf5a4de 100644 --- a/src/BuildScriptGenerator/ExternalSdkProvider.cs +++ b/src/BuildScriptGenerator/ExternalSdkProvider.cs @@ -7,11 +7,8 @@ using System.Collections.Generic; using System.IO; using System.Linq; -using System.Net.Sockets; using System.Security.Cryptography; -using System.Text; using System.Text.Json; -using System.Threading; using System.Threading.Tasks; using System.Xml.Linq; using System.Xml.XPath; @@ -207,34 +204,20 @@ public async Task RequestBlobAsync(string platformName, string blobName) private async Task SendRequestAsync(SdkProviderRequest request) { - using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified); try { this.logger.LogInformation("Sending request to external SDK provider: {PlatformName} , {BlobName}, UrlParameters: {UrlParamsJson}", request.PlatformName, request.BlobName, JsonSerializer.Serialize(request.UrlParameters)); - using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(MaxTimeoutForSocketOperationInSeconds))) - { - await socket.ConnectAsync(new UnixDomainSocketEndPoint(SocketPath), cts.Token); - var requestJson = JsonSerializer.Serialize(request); - this.logger.LogInformation("Connected to socket {socketPath} and sending request: {requestJson}", SocketPath, requestJson); - - // append $ at the end of the string to indicate end of request - requestJson += "$"; - var requestBytes = Encoding.UTF8.GetBytes(requestJson); + var responseString = await SocketRequestHelper.SendRequestAsync(SocketPath, request, MaxTimeoutForSocketOperationInSeconds); - await socket.SendAsync(new ArraySegment(requestBytes), SocketFlags.None, cts.Token); - var buffer = new byte[4096]; - var received = await socket.ReceiveAsync(new ArraySegment(buffer), SocketFlags.None, cts.Token); - var responseString = Encoding.UTF8.GetString(buffer, 0, received); - this.logger.LogInformation("Received response from external SDK provider: {response}", responseString); - if (!string.IsNullOrEmpty(responseString) && responseString.EqualsIgnoreCase("Success$")) - { - return true; - } - else - { - this.logger.LogError("Request to external SDK provider was unsuccessful. Response: {response}", responseString); - } + this.logger.LogInformation("Received response from external SDK provider: {response}", responseString); + if (!string.IsNullOrEmpty(responseString) && responseString.EqualsIgnoreCase("Success$")) + { + return true; + } + else + { + this.logger.LogError("Request to external SDK provider was unsuccessful. Response: {response}", responseString); } } catch (OperationCanceledException) diff --git a/src/BuildScriptGenerator/ExternalSdkStorageVersionProviderBase.cs b/src/BuildScriptGenerator/ExternalSdkStorageVersionProviderBase.cs index 2fa5c2f6cf..a15b163dfe 100644 --- a/src/BuildScriptGenerator/ExternalSdkStorageVersionProviderBase.cs +++ b/src/BuildScriptGenerator/ExternalSdkStorageVersionProviderBase.cs @@ -107,7 +107,7 @@ protected string GetDefaultVersion(string platformName) while ((line = stringReader.ReadLine()) != null) { // Ignore any comments in the file - if (!line.StartsWith("#") || !line.StartsWith("//")) + if (!line.StartsWith("#") && !line.StartsWith("//")) { defaultVersion = line.Trim(); break; diff --git a/src/BuildScriptGenerator/Helpers/OciDescriptor.cs b/src/BuildScriptGenerator/Helpers/OciDescriptor.cs new file mode 100644 index 0000000000..0c291f7b2e --- /dev/null +++ b/src/BuildScriptGenerator/Helpers/OciDescriptor.cs @@ -0,0 +1,21 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Text.Json.Serialization; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + public class OciDescriptor + { + [JsonPropertyName("mediaType")] + public string MediaType { get; set; } + + [JsonPropertyName("digest")] + public string Digest { get; set; } + + [JsonPropertyName("size")] + public long Size { get; set; } + } +} diff --git a/src/BuildScriptGenerator/Helpers/OciManifest.cs b/src/BuildScriptGenerator/Helpers/OciManifest.cs new file mode 100644 index 0000000000..36312d64cf --- /dev/null +++ b/src/BuildScriptGenerator/Helpers/OciManifest.cs @@ -0,0 +1,25 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + public class OciManifest + { + [JsonPropertyName("schemaVersion")] + public int SchemaVersion { get; set; } + + [JsonPropertyName("mediaType")] + public string MediaType { get; set; } + + [JsonPropertyName("config")] + public OciDescriptor Config { get; set; } + + [JsonPropertyName("layers")] + public List Layers { get; set; } + } +} diff --git a/src/BuildScriptGenerator/Helpers/OciRegistryClient.cs b/src/BuildScriptGenerator/Helpers/OciRegistryClient.cs new file mode 100644 index 0000000000..b4295de0d2 --- /dev/null +++ b/src/BuildScriptGenerator/Helpers/OciRegistryClient.cs @@ -0,0 +1,340 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + /// + /// HTTP client for the OCI Distribution API. Enables Oryx to discover SDK versions + /// and download SDK tarballs from an OCI-compliant container registry (e.g. Azure Container Registry) + /// using only HttpClient — no external tools (docker, crane, oras) required. + /// Public registries still require an anonymous bearer token obtained from the registry's + /// OAuth2 token endpoint; this client acquires and caches tokens per repository scope. + /// + public class OciRegistryClient + { + private readonly HttpClient httpClient; + private readonly string registryUrl; + private readonly string registryHost; + private readonly ILogger logger; + private readonly ConcurrentDictionary tokenCache + = new ConcurrentDictionary(); + + public OciRegistryClient(string registryUrl, IHttpClientFactory httpClientFactory, ILoggerFactory loggerFactory) + { + if (string.IsNullOrWhiteSpace(registryUrl)) + { + throw new ArgumentException("Registry URL must not be empty.", nameof(registryUrl)); + } + + var trimmed = registryUrl.TrimEnd('/'); + if (!trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + throw new ArgumentException( + "Registry URL must use HTTPS.", + nameof(registryUrl)); + } + + this.registryUrl = trimmed; + this.registryHost = new Uri(trimmed).Host; + this.httpClient = httpClientFactory.CreateClient("acr"); + this.logger = loggerFactory.CreateLogger(); + } + + /// + /// Gets the first layer digest from a manifest (SDK images are single-layer FROM scratch images). + /// + public static string GetFirstLayerDigest(OciManifest manifest) + { + return manifest?.Layers?.FirstOrDefault()?.Digest; + } + + /// + /// Lists all tags for a repository, handling Link-header pagination. + /// + public async Task> GetAllTagsAsync(string repository) + { + var allTags = new List(); + var url = $"{this.registryUrl}/v2/{repository}/tags/list"; + + while (!string.IsNullOrEmpty(url)) + { + this.logger.LogDebug("Fetching tags from {url}", url); + using (var request = await this.CreateAuthenticatedRequestAsync(HttpMethod.Get, url, repository)) + using (var response = await this.httpClient.SendAsync(request)) + { + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Failed to list tags for repository '{repository}' (HTTP {(int)response.StatusCode})."); + } + + var json = await response.Content.ReadAsStringAsync(); + var tagList = JsonSerializer.Deserialize(json); + if (tagList?.Tags != null) + { + allTags.AddRange(tagList.Tags); + } + + // Handle OCI pagination via Link header (RFC 5988) + url = null; + if (response.Headers.TryGetValues("Link", out var linkValues)) + { + var linkHeader = linkValues.FirstOrDefault(); + if (linkHeader != null) + { + var match = Regex.Match(linkHeader, @"<([^>]+)>;\s*rel=""next"""); + if (match.Success) + { + url = match.Groups[1].Value; + if (!url.StartsWith("http")) + { + url = $"{this.registryUrl}{url}"; + } + else if (!new Uri(url).Host.Equals(this.registryHost, StringComparison.OrdinalIgnoreCase)) + { + this.logger.LogWarning( + "Ignoring Link header — URL host does not match registry host {RegistryHost}: {Url}", + this.registryHost, + url); + url = null; + } + } + } + } + } + } + + return allTags; + } + + /// + /// Fetches an OCI image manifest for the given repository and tag. + /// Sends both OCI and Docker v2 Accept types in a single request so the registry + /// can return whichever format it supports without needing a fallback round-trip. + /// + public async Task GetManifestAsync(string repository, string tag) + { + var url = $"{this.registryUrl}/v2/{repository}/manifests/{tag}"; + using (var request = await this.CreateAuthenticatedRequestAsync(HttpMethod.Get, url, repository)) + { + // Accept both formats in one request — avoids a second round-trip when + // the registry only supports Docker v2 (benchmarked ~50% faster on ACR). + request.Headers.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json", 1.0)); + request.Headers.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.v2+json", 0.9)); + + using (var response = await this.httpClient.SendAsync(request)) + { + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Failed to fetch manifest for '{repository}:{tag}' (HTTP {(int)response.StatusCode})."); + } + + // Deserialize directly from the response stream — avoids an + // intermediate string allocation for the manifest JSON. + using (var stream = await response.Content.ReadAsStreamAsync()) + { + return await JsonSerializer.DeserializeAsync(stream); + } + } + } + } + + /// + /// Gets the manifest digest for a tag via a HEAD request. + /// Returns the Docker-Content-Digest header value (e.g. sha256:abc...), + /// or null if unavailable. + /// + public async Task GetManifestDigestAsync(string repository, string tag) + { + var url = $"{this.registryUrl}/v2/{repository}/manifests/{tag}"; + using (var request = await this.CreateAuthenticatedRequestAsync(HttpMethod.Head, url, repository)) + { + request.Headers.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/vnd.oci.image.manifest.v1+json", 1.0)); + request.Headers.Accept.Add( + new MediaTypeWithQualityHeaderValue("application/vnd.docker.distribution.manifest.v2+json", 0.9)); + + using (var response = await this.httpClient.SendAsync(request)) + { + if (!response.IsSuccessStatusCode) + { + this.logger.LogWarning( + "HEAD manifest failed for '{repository}:{tag}' (HTTP {statusCode}).", + repository, + tag, + (int)response.StatusCode); + return null; + } + + if (response.Headers.TryGetValues("Docker-Content-Digest", out var values)) + { + return values.FirstOrDefault(); + } + + return null; + } + } + } + + /// + /// Downloads a layer blob (the SDK tarball) to disk and verifies its SHA256 digest. + /// The digest in the manifest IS the content hash — no separate checksum metadata needed. + /// Uses single-pass streaming: the SHA256 hash is computed incrementally as bytes are + /// written to disk, eliminating a second full read of the file for verification. + /// + public async Task DownloadLayerBlobAsync(string repository, string layerDigest, string outputPath) + { + var url = $"{this.registryUrl}/v2/{repository}/blobs/{layerDigest}"; + this.logger.LogDebug("Downloading layer blob {digest} from {repository}", layerDigest, repository); + + var expectedSha = layerDigest.StartsWith("sha256:") + ? layerDigest.Substring("sha256:".Length) + : layerDigest; + + using (var request = await this.CreateAuthenticatedRequestAsync(HttpMethod.Get, url, repository)) + { + using (var response = await this.httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead)) + { + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException( + $"Failed to download blob from '{repository}' (HTTP {(int)response.StatusCode})."); + } + + using (var sha256 = IncrementalHash.CreateHash(HashAlgorithmName.SHA256)) + using (var networkStream = await response.Content.ReadAsStreamAsync()) + using (var fileStream = File.Create(outputPath)) + { + var buffer = new byte[81920]; + int bytesRead; + while ((bytesRead = await networkStream.ReadAsync(buffer, 0, buffer.Length)) > 0) + { + sha256.AppendData(buffer, 0, bytesRead); + await fileStream.WriteAsync(buffer, 0, bytesRead); + } + + var actualSha = Convert.ToHexString(sha256.GetHashAndReset()).ToLowerInvariant(); + if (!string.Equals(actualSha, expectedSha, StringComparison.OrdinalIgnoreCase)) + { + this.logger.LogError( + "SHA256 digest mismatch for blob {digest}. Expected: {expected}, Actual: {actual}", + layerDigest, + expectedSha, + actualSha); + fileStream.Close(); + File.Delete(outputPath); + return false; + } + } + } + } + + this.logger.LogDebug("Successfully downloaded and verified layer blob {digest}", layerDigest); + return true; + } + + /// + /// Acquires an anonymous bearer token for pulling from a public repository. + /// Token endpoint follows the standard pattern: https://{host}/oauth2/token + /// + private async Task GetAnonymousTokenAsync(string repository) + { + // MCR registries (mcr.microsoft.com, mcr.microsoft.us, mcr.microsoft.cn, etc.) + // are fully public and do not expose an OAuth2 token endpoint. + // Skip the token flow to avoid unnecessary latency and failed requests. + if (this.registryHost.StartsWith("mcr.microsoft", StringComparison.OrdinalIgnoreCase)) + { + this.logger.LogDebug("Skipping auth token for MCR registry {host}.", this.registryHost); + return null; + } + + const int TokenRefreshBufferSeconds = 60; + const int DefaultTokenLifetimeSeconds = 300; + + var scope = $"repository:{repository}:pull"; + if (this.tokenCache.TryGetValue(scope, out var cached) + && cached.ExpiresAt > DateTimeOffset.UtcNow.AddSeconds(TokenRefreshBufferSeconds)) + { + return cached.Token; + } + + var tokenUrl = $"{this.registryUrl}/oauth2/token?service={this.registryHost}&scope={scope}"; + this.logger.LogDebug("Requesting anonymous token for scope '{scope}'", scope); + + using (var response = await this.httpClient.GetAsync(tokenUrl)) + { + if (!response.IsSuccessStatusCode) + { + this.logger.LogWarning( + "Failed to obtain anonymous token (HTTP {statusCode}). Proceeding without auth.", + (int)response.StatusCode); + return null; + } + + var json = await response.Content.ReadAsStringAsync(); + using (var doc = JsonDocument.Parse(json)) + { + // Token endpoints return either "access_token" or "token" + var root = doc.RootElement; + string token = null; + if (root.TryGetProperty("access_token", out var at)) + { + token = at.GetString(); + } + else if (root.TryGetProperty("token", out var t)) + { + token = t.GetString(); + } + + if (!string.IsNullOrEmpty(token)) + { + int expiresIn = DefaultTokenLifetimeSeconds; + if (root.TryGetProperty("expires_in", out var expiresInProp) + && expiresInProp.TryGetInt32(out var parsed)) + { + expiresIn = parsed; + } + + var expiresAt = DateTimeOffset.UtcNow.AddSeconds(expiresIn); + this.tokenCache[scope] = (token, expiresAt); + } + + return token; + } + } + } + + /// + /// Creates an HttpRequestMessage with the anonymous bearer token if available. + /// + private async Task CreateAuthenticatedRequestAsync(HttpMethod method, string url, string repository) + { + var request = new HttpRequestMessage(method, url); + var token = await this.GetAnonymousTokenAsync(repository); + if (!string.IsNullOrEmpty(token)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + + return request; + } + } +} diff --git a/src/BuildScriptGenerator/Helpers/OciTagList.cs b/src/BuildScriptGenerator/Helpers/OciTagList.cs new file mode 100644 index 0000000000..261b23565b --- /dev/null +++ b/src/BuildScriptGenerator/Helpers/OciTagList.cs @@ -0,0 +1,19 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + public class OciTagList + { + [JsonPropertyName("name")] + public string Name { get; set; } + + [JsonPropertyName("tags")] + public List Tags { get; set; } + } +} diff --git a/src/BuildScriptGenerator/Helpers/SocketRequestHelper.cs b/src/BuildScriptGenerator/Helpers/SocketRequestHelper.cs new file mode 100644 index 0000000000..b76d107285 --- /dev/null +++ b/src/BuildScriptGenerator/Helpers/SocketRequestHelper.cs @@ -0,0 +1,70 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Net.Sockets; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + /// + /// Handles Unix domain socket communication used by the external SDK providers. + /// Encapsulates the connect → send → receive loop with '$' terminator protocol. + /// + public static class SocketRequestHelper + { + private const int DefaultTimeoutSeconds = 100; + private const int BufferSize = 4096; + + /// + /// Sends a JSON-serialized request to a Unix domain socket and returns the raw response. + /// The protocol appends '$' as a message terminator for both request and response. + /// + /// Path to the Unix domain socket. + /// Object to JSON-serialize and send. + /// Timeout for the entire operation. + /// The raw response string (including '$' terminator if present), or empty on EOF. + public static async Task SendRequestAsync( + string socketPath, + object request, + int timeoutSeconds = DefaultTimeoutSeconds) + { + using (var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified)) + using (var cts = new CancellationTokenSource(TimeSpan.FromSeconds(timeoutSeconds))) + { + await socket.ConnectAsync(new UnixDomainSocketEndPoint(socketPath), cts.Token); + + var requestJson = JsonSerializer.Serialize(request) + "$"; + var requestBytes = Encoding.UTF8.GetBytes(requestJson); + + await socket.SendAsync(new ArraySegment(requestBytes), SocketFlags.None, cts.Token); + + // Read until '$' terminator — TCP may fragment the response across multiple reads. + var responseBuilder = new StringBuilder(); + var buffer = new byte[BufferSize]; + while (true) + { + var received = await socket.ReceiveAsync( + new ArraySegment(buffer), SocketFlags.None, cts.Token); + if (received == 0) + { + break; + } + + responseBuilder.Append(Encoding.UTF8.GetString(buffer, 0, received)); + if (responseBuilder.Length > 0 && responseBuilder[responseBuilder.Length - 1] == '$') + { + break; + } + } + + return responseBuilder.ToString(); + } + } + } +} diff --git a/src/BuildScriptGenerator/Node/NodeConstants.cs b/src/BuildScriptGenerator/Node/NodeConstants.cs index 98ca091b71..e77b4fa6da 100644 --- a/src/BuildScriptGenerator/Node/NodeConstants.cs +++ b/src/BuildScriptGenerator/Node/NodeConstants.cs @@ -3,6 +3,8 @@ // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- +using System.Collections.Generic; + namespace Microsoft.Oryx.BuildScriptGenerator.Node { public static class NodeConstants @@ -51,5 +53,18 @@ public static class NodeConstants public const string InstalledNodeVersionsDir = "/opt/nodejs/"; public const string NodeVersion = "NODE_VERSION"; public const string LegacyZipNodeModules = "ENABLE_NODE_MODULES_ZIP"; + + /// + /// Default Node major version per OS flavor, matching platforms/nodejs/versions/*/defaultVersion.txt. + /// + public static readonly Dictionary DefaultVersionPerFlavor = new Dictionary + { + { "bookworm", "20" }, + { "bullseye", "16" }, + { "buster", "16" }, + { "focal-scm", "16" }, + { "noble", "24" }, + { "stretch", "16" }, + }; } } \ No newline at end of file diff --git a/src/BuildScriptGenerator/Node/NodePlatform.cs b/src/BuildScriptGenerator/Node/NodePlatform.cs index 87cbed0817..e949d8df87 100644 --- a/src/BuildScriptGenerator/Node/NodePlatform.cs +++ b/src/BuildScriptGenerator/Node/NodePlatform.cs @@ -86,7 +86,10 @@ internal class NodePlatform : IProgrammingPlatform private readonly IEnvironment environment; private readonly NodePlatformInstaller platformInstaller; private readonly IExternalSdkProvider externalSdkProvider; + private readonly IExternalAcrSdkProvider externalAcrSdkProvider; + private readonly IAcrSdkProvider acrSdkProvider; private readonly TelemetryClient telemetryClient; + private readonly IStandardOutputWriter outputWriter; /// /// Initializes a new instance of the class. @@ -108,7 +111,10 @@ public NodePlatform( IEnvironment environment, NodePlatformInstaller nodePlatformInstaller, IExternalSdkProvider externalSdkProvider, - TelemetryClient telemetryClient) + IExternalAcrSdkProvider externalAcrSdkProvider, + IAcrSdkProvider acrSdkProvider, + TelemetryClient telemetryClient, + IStandardOutputWriter outputWriter) { this.commonOptions = commonOptions.Value; this.nodeScriptGeneratorOptions = nodeScriptGeneratorOptions.Value; @@ -118,7 +124,10 @@ public NodePlatform( this.environment = environment; this.platformInstaller = nodePlatformInstaller; this.externalSdkProvider = externalSdkProvider; + this.externalAcrSdkProvider = externalAcrSdkProvider; + this.acrSdkProvider = acrSdkProvider; this.telemetryClient = telemetryClient; + this.outputWriter = outputWriter; } /// @@ -338,7 +347,7 @@ public BuildScriptSnippet GenerateBashBuildScriptSnippet( string frameworks = string.Join(",", frameworksObj.Select(p => p.Framework).ToArray()); manifestFileProperties[ManifestFilePropertyKeys.Frameworks] = frameworks; this.logger.LogInformation($"Detected the following frameworks: {frameworks}"); - Console.WriteLine($"Detected the following frameworks: {frameworks}"); + this.outputWriter.WriteLine($"Detected the following frameworks: {frameworks}"); } string compressNodeModulesCommand = null; @@ -493,74 +502,62 @@ public string GetInstallerScriptSnippet( BuildScriptGeneratorContext context, PlatformDetectorResult detectorResult) { - string installationScriptSnippet = null; - if (this.commonOptions.EnableDynamicInstall) + if (!this.commonOptions.EnableDynamicInstall) { - this.logger.LogDebug("Dynamic install is enabled."); + this.logger.LogDebug("Dynamic install not enabled."); + return null; + } + + this.logger.LogDebug("Dynamic install is enabled."); + + var version = detectorResult.PlatformVersion; + + if (this.platformInstaller.IsVersionAlreadyInstalled(version)) + { + this.logger.LogDebug( + "Node version {version} is already installed. So skipping installing it again.", + version); + return null; + } + + // Priority: External-ACR → External-SDK → Direct-ACR → CDN - if (this.platformInstaller.IsVersionAlreadyInstalled(detectorResult.PlatformVersion)) + // 1. Try External-ACR (Oryx -> socket → ACR) + if (this.commonOptions.EnableExternalAcrSdkProvider) + { + var result = this.TryInstallFromExternalAcrSdkProvider(version); + if (result != null) { - this.logger.LogDebug( - "Node version {version} is already installed. So skipping installing it again.", - detectorResult.PlatformVersion); + return result; } - else - { - if (this.commonOptions.EnableExternalSdkProvider) - { - this.logger.LogDebug( - "Node version {version} is not installed. " + - "External SDK provider is enabled so trying to fetch SDK using it.", - detectorResult.PlatformVersion); - - try - { - var blobName = BlobNameHelper.GetBlobNameForVersion(this.Name, detectorResult.PlatformVersion, this.commonOptions.DebianFlavor); - var isExternalFetchSuccess = this.externalSdkProvider.RequestBlobAsync(this.Name, blobName).Result; - if (isExternalFetchSuccess) - { - this.logger.LogDebug( - "Node version {version} is fetched successfully using external SDK provider. " + - "So generating an installation script snippet which skips platform binary download.", - detectorResult.PlatformVersion); - - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet(detectorResult.PlatformVersion, skipSdkBinaryDownload: true); - } - else - { - this.logger.LogDebug( - "Node version {version} is not fetched successfully using external SDK provider. " + - "So generating an installation script snippet for it.", - detectorResult.PlatformVersion); - - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet( - detectorResult.PlatformVersion); - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error while fetching Node.js version {version} using external SDK provider.", detectorResult.PlatformVersion); - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet(detectorResult.PlatformVersion); - } - } - else - { - this.logger.LogDebug( - "Node version {version} is not installed. " + - "So generating an installation script snippet for it.", - detectorResult.PlatformVersion); + } - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet( - detectorResult.PlatformVersion); - } + // 2. Try External-SDK (Oryx -> socket → blob storage) + if (this.commonOptions.EnableExternalSdkProvider) + { + var result = this.TryInstallFromExternalSdkProvider(version); + if (result != null) + { + return result; } } - else + + // 3. Try Direct-ACR (Oryx -> direct OCI API calls) + if (this.commonOptions.EnableAcrSdkProvider) { - this.logger.LogDebug("Dynamic install not enabled."); + var result = this.TryInstallFromAcrSdkProvider(version); + if (result != null) + { + return result; + } } - return installationScriptSnippet; + // 4. CDN fallback + this.outputWriter.WriteLine($"Falling back to CDN for '{this.Name}' version '{version}'."); + this.logger.LogDebug( + "Node version {version} is not installed. So generating an installation script snippet for it.", + version); + return this.platformInstaller.GetInstallerScriptSnippet(version); } /// @@ -717,6 +714,113 @@ private static void GetAppOutputDirPath(dynamic packageJson, Dictionary(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/BuildScriptGenerator/Node/VersionProviders/NodeAcrVersionProvider.cs b/src/BuildScriptGenerator/Node/VersionProviders/NodeAcrVersionProvider.cs new file mode 100644 index 0000000000..ba2e62b31f --- /dev/null +++ b/src/BuildScriptGenerator/Node/VersionProviders/NodeAcrVersionProvider.cs @@ -0,0 +1,36 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Oryx.BuildScriptGenerator.Node +{ + /// + /// ACR-based version provider for Node.js SDKs. + /// Parallel to but uses OCI Distribution API. + /// + internal class NodeAcrVersionProvider : AcrVersionProviderBase, INodeVersionProvider + { + private PlatformVersionInfo platformVersionInfo; + + public NodeAcrVersionProvider( + IOptions commonOptions, + OciRegistryClient ociClient, + ILoggerFactory loggerFactory) + : base(commonOptions, ociClient, loggerFactory) + { + } + + public virtual PlatformVersionInfo GetVersionInfo() + { + return this.platformVersionInfo + ??= this.GetAvailableVersionsFromAcr( + platformName: "nodejs", + defaultVersionPerFlavor: NodeConstants.DefaultVersionPerFlavor); + } + } +} diff --git a/src/BuildScriptGenerator/Node/VersionProviders/NodeExternalAcrVersionProvider.cs b/src/BuildScriptGenerator/Node/VersionProviders/NodeExternalAcrVersionProvider.cs new file mode 100644 index 0000000000..f3197422f5 --- /dev/null +++ b/src/BuildScriptGenerator/Node/VersionProviders/NodeExternalAcrVersionProvider.cs @@ -0,0 +1,40 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Oryx.BuildScriptGenerator.Node +{ + /// + /// ACR-based version provider for Node SDKs via external socket provider. + /// Parallel to (blob) and + /// (direct OCI). + /// + internal class NodeExternalAcrVersionProvider : ExternalAcrVersionProviderBase, INodeVersionProvider + { + public NodeExternalAcrVersionProvider( + IOptions options, + ILoggerFactory loggerFactory, + IStandardOutputWriter outputWriter) + : base(options, loggerFactory, outputWriter) + { + } + + public virtual PlatformVersionInfo GetVersionInfo() + { + var version = this.GetCompanionSdkVersion(platformName: "nodejs", debianFlavor: this.DebianFlavor); + if (string.IsNullOrEmpty(version)) + { + return null; + } + + return PlatformVersionInfo.CreateAvailableOnAcr( + supportedVersions: new[] { version }, + defaultVersion: version); + } + } +} diff --git a/src/BuildScriptGenerator/Node/VersionProviders/NodeVersionProvider.cs b/src/BuildScriptGenerator/Node/VersionProviders/NodeVersionProvider.cs index 1005176d50..e781110312 100644 --- a/src/BuildScriptGenerator/Node/VersionProviders/NodeVersionProvider.cs +++ b/src/BuildScriptGenerator/Node/VersionProviders/NodeVersionProvider.cs @@ -3,60 +3,47 @@ // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- -using System; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.Oryx.BuildScriptGenerator.Node { - internal class NodeVersionProvider : INodeVersionProvider + internal class NodeVersionProvider : PlatformVersionProviderBase, INodeVersionProvider { - private readonly BuildScriptGeneratorOptions options; private readonly NodeOnDiskVersionProvider onDiskVersionProvider; private readonly NodeSdkStorageVersionProvider sdkStorageVersionProvider; private readonly NodeExternalVersionProvider externalVersionProvider; - private readonly ILogger logger; - private PlatformVersionInfo versionInfo; + private readonly NodeExternalAcrVersionProvider externalAcrVersionProvider; + private readonly NodeAcrVersionProvider acrVersionProvider; public NodeVersionProvider( IOptions options, NodeOnDiskVersionProvider onDiskVersionProvider, NodeSdkStorageVersionProvider sdkStorageVersionProvider, NodeExternalVersionProvider externalVersionProvider, - ILogger logger) + NodeExternalAcrVersionProvider externalAcrVersionProvider, + NodeAcrVersionProvider acrVersionProvider, + ILogger logger, + IStandardOutputWriter outputWriter) + : base(options.Value, logger, outputWriter) { - this.options = options.Value; this.onDiskVersionProvider = onDiskVersionProvider; this.sdkStorageVersionProvider = sdkStorageVersionProvider; this.externalVersionProvider = externalVersionProvider; - this.logger = logger; + this.externalAcrVersionProvider = externalAcrVersionProvider; + this.acrVersionProvider = acrVersionProvider; } - public PlatformVersionInfo GetVersionInfo() - { - if (this.versionInfo == null) - { - if (this.options.EnableDynamicInstall) - { - if (this.options.EnableExternalSdkProvider) - { - try - { - return this.externalVersionProvider.GetVersionInfo(); - } - catch (Exception ex) - { - this.logger.LogError($"Failed to get version info from external SDK provider. Falling back to http based sdkStorageVersionProvider. Ex: {ex}"); - } - } - - return this.sdkStorageVersionProvider.GetVersionInfo(); - } - - this.versionInfo = this.onDiskVersionProvider.GetVersionInfo(); - } - - return this.versionInfo; - } + protected override string PlatformName => "nodejs"; + + protected override PlatformVersionInfo GetOnDiskVersionInfo() => this.onDiskVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetSdkStorageVersionInfo() => this.sdkStorageVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetExternalVersionInfo() => this.externalVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetExternalAcrVersionInfo() => this.externalAcrVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetAcrVersionInfo() => this.acrVersionProvider.GetVersionInfo(); } } \ No newline at end of file diff --git a/src/BuildScriptGenerator/Options/BuildScriptGeneratorOptions.cs b/src/BuildScriptGenerator/Options/BuildScriptGeneratorOptions.cs index e0c51dd5fd..efbd7061a3 100644 --- a/src/BuildScriptGenerator/Options/BuildScriptGeneratorOptions.cs +++ b/src/BuildScriptGenerator/Options/BuildScriptGeneratorOptions.cs @@ -98,5 +98,29 @@ public class BuildScriptGeneratorOptions public string ImageType { get; set; } public bool OryxDisablePipUpgrade { get; set; } + + /// + /// Gets or sets a value indicating whether the external ACR SDK provider (socket → ACR) is enabled. + /// When true, Oryx will request SDKs from ACR via the external host over a Unix socket. + /// + public bool EnableExternalAcrSdkProvider { get; set; } + + /// + /// Gets or sets a value indicating whether the direct ACR SDK provider is enabled. + /// When true, Oryx will discover and download SDKs directly from an OCI-compliant container registry. + /// + public bool EnableAcrSdkProvider { get; set; } + + /// + /// Gets or sets the base URL of the OCI registry hosting SDK images. + /// e.g. "https://mcr.microsoft.com" + /// + public string OryxAcrSdkRegistryUrl { get; set; } + + /// + /// Gets or sets the repository prefix for SDK images in the OCI registry. + /// e.g. "oryx" produces images like {registry}/oryx/nodejs-sdk:{tag}. + /// + public string OryxAcrSdkRepositoryPrefix { get; set; } } } \ No newline at end of file diff --git a/src/BuildScriptGenerator/Php/PhpConstants.cs b/src/BuildScriptGenerator/Php/PhpConstants.cs index 6cfad328ca..03c1ac7c04 100644 --- a/src/BuildScriptGenerator/Php/PhpConstants.cs +++ b/src/BuildScriptGenerator/Php/PhpConstants.cs @@ -3,6 +3,8 @@ // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- +using System.Collections.Generic; + namespace Microsoft.Oryx.BuildScriptGenerator.Php { public static class PhpConstants @@ -15,5 +17,31 @@ public static class PhpConstants public const string DefaultPhpRuntimeVersion = Common.PhpVersions.Php73Version; public const string InstalledPhpVersionsDir = "/opt/php/"; // TODO: consolidate with Dockerfile to yaml? public const string InstalledPhpComposerVersionDir = "/opt/php-composer/"; + + /// + /// Default PHP major.minor version per OS flavor, matching platforms/php/versions/*/defaultVersion.txt. + /// + public static readonly Dictionary DefaultVersionPerFlavor = new Dictionary + { + { "bookworm", "8.3" }, + { "bullseye", "8.0" }, + { "buster", "8.0" }, + { "focal-scm", "8.0" }, + { "noble", "8.5" }, + { "stretch", "8.0" }, + }; + + /// + /// Default PHP Composer major version per OS flavor, matching platforms/php/composer/versions/*/defaultVersion.txt. + /// + public static readonly Dictionary ComposerDefaultVersionPerFlavor = new Dictionary + { + { "bookworm", "2" }, + { "bullseye", "2" }, + { "buster", "2" }, + { "focal-scm", "2" }, + { "noble", "2" }, + { "stretch", "2" }, + }; } } diff --git a/src/BuildScriptGenerator/Php/PhpPlatform.cs b/src/BuildScriptGenerator/Php/PhpPlatform.cs index 2a189a077c..401d1c3efa 100644 --- a/src/BuildScriptGenerator/Php/PhpPlatform.cs +++ b/src/BuildScriptGenerator/Php/PhpPlatform.cs @@ -34,7 +34,10 @@ internal class PhpPlatform : IProgrammingPlatform private readonly PhpPlatformInstaller phpInstaller; private readonly PhpComposerInstaller phpComposerInstaller; private readonly IExternalSdkProvider externalSdkProvider; + private readonly IExternalAcrSdkProvider externalAcrSdkProvider; + private readonly IAcrSdkProvider acrSdkProvider; private readonly TelemetryClient telemetryClient; + private readonly IStandardOutputWriter outputWriter; /// /// Initializes a new instance of the class. @@ -57,7 +60,10 @@ public PhpPlatform( PhpPlatformInstaller phpInstaller, PhpComposerInstaller phpComposerInstaller, IExternalSdkProvider externalSdkProvider, - TelemetryClient telemetryClient) + IExternalAcrSdkProvider externalAcrSdkProvider, + IAcrSdkProvider acrSdkProvider, + TelemetryClient telemetryClient, + IStandardOutputWriter outputWriter) { this.phpScriptGeneratorOptions = phpScriptGeneratorOptions.Value; this.commonOptions = commonOptions.Value; @@ -68,7 +74,10 @@ public PhpPlatform( this.phpInstaller = phpInstaller; this.phpComposerInstaller = phpComposerInstaller; this.externalSdkProvider = externalSdkProvider; + this.externalAcrSdkProvider = externalAcrSdkProvider; + this.acrSdkProvider = acrSdkProvider; this.telemetryClient = telemetryClient; + this.outputWriter = outputWriter; } /// @@ -226,28 +235,21 @@ public string GetInstallerScriptSnippet( $"'{typeof(PhpPlatformDetectorResult)}' but got '{detectorResult.GetType()}'."); } - if (this.commonOptions.EnableDynamicInstall) + if (!this.commonOptions.EnableDynamicInstall) { - this.logger.LogDebug("Dynamic install is enabled."); + this.logger.LogDebug("Dynamic install not enabled."); + return null; + } - var scriptBuilder = new StringBuilder(); + this.logger.LogDebug("Dynamic install is enabled."); - this.InstallPhp(phpPlatformDetectorResult.PlatformVersion, scriptBuilder); + var scriptBuilder = new StringBuilder(); - this.InstallPhpComposer(phpPlatformDetectorResult.PhpComposerVersion, scriptBuilder); + this.InstallPhp(phpPlatformDetectorResult.PlatformVersion, scriptBuilder); - if (scriptBuilder.Length == 0) - { - return null; - } + this.InstallPhpComposer(phpPlatformDetectorResult.PhpComposerVersion, scriptBuilder); - return scriptBuilder.ToString(); - } - else - { - this.logger.LogDebug("Dynamic install not enabled."); - return null; - } + return scriptBuilder.Length == 0 ? null : scriptBuilder.ToString(); } /// @@ -291,56 +293,54 @@ public string GetMaxSatisfyingPhpComposerVersionAndVerify(string version) return maxSatisfyingVersion; } + // Method to install PHP + // with priority: External-ACR → External-SDK → Direct-ACR → CDN private void InstallPhp(string phpVersion, StringBuilder scriptBuilder) { - string script = null; if (this.phpInstaller.IsVersionAlreadyInstalled(phpVersion)) { this.logger.LogDebug("PHP version {version} is already installed. So skipping installing it again.", phpVersion); return; } - else + + bool phpInstalled = false; + + // If external ACR provider is enabled. + if (this.commonOptions.EnableExternalAcrSdkProvider) { - if (this.commonOptions.EnableExternalSdkProvider) - { - this.logger.LogDebug("Php version {version} is not installed. External SDK provider is enabled so trying to fetch SDK using it.", phpVersion); + phpInstalled = this.TryInstallPhpUsingExternalAcrSdk(phpVersion, scriptBuilder); + } - try - { - var blobName = BlobNameHelper.GetBlobNameForVersion("php", phpVersion, this.commonOptions.DebianFlavor); - var isExternalFetchSuccess = this.externalSdkProvider.RequestBlobAsync(this.Name, blobName).Result; - if (isExternalFetchSuccess) - { - this.logger.LogDebug("Php version {version} is fetched successfully using external SDK provider. So generating an installation script snippet which skips platform binary download.", phpVersion); - - script = this.phpInstaller.GetInstallerScriptSnippet(phpVersion, skipSdkBinaryDownload: true); - } - else - { - this.logger.LogDebug("Php version {version} is not fetched successfully using external SDK provider. So generating an installation script snippet for it.", phpVersion); - script = this.phpInstaller.GetInstallerScriptSnippet(phpVersion); - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error while fetching php version {version} using external SDK provider.", phpVersion); - script = this.phpInstaller.GetInstallerScriptSnippet(phpVersion); - } - } - else - { - this.logger.LogDebug("Php version {version} is not installed. So generating an installation script snippet for it.", phpVersion); - script = this.phpInstaller.GetInstallerScriptSnippet(phpVersion); - } + // If PHP is not installed yet and external SDK provider is enabled, try it. + if (!phpInstalled && this.commonOptions.EnableExternalSdkProvider) + { + phpInstalled = this.TryInstallPhpExternalSdk(phpVersion, scriptBuilder); + } + + // If PHP is still not installed and ACR SDK provider is enabled, try it. + if (!phpInstalled && this.commonOptions.EnableAcrSdkProvider) + { + phpInstalled = this.TryInstallPhpUsingAcrSdk(phpVersion, scriptBuilder); + } + if (!phpInstalled) + { + this.outputWriter.WriteLine($"Falling back to CDN for 'php' version '{phpVersion}'."); + this.logger.LogDebug("PHP version {version} is not installed. Trying to install it from CDN.", phpVersion); + var script = this.phpInstaller.GetInstallerScriptSnippet(phpVersion); scriptBuilder.AppendLine(script); } + else + { + this.logger.LogDebug("PHP version {version} is installed successfully using one of the providers.", phpVersion); + } } + // Method to install PHP Composer + // with priority: External-ACR → External-SDK → Direct-ACR → CDN private void InstallPhpComposer(string phpComposerVersion, StringBuilder scriptBuilder) { // Install PHP Composer - string script = null; if (string.IsNullOrEmpty(phpComposerVersion)) { phpComposerVersion = PhpVersions.ComposerDefaultVersion; @@ -351,49 +351,228 @@ private void InstallPhpComposer(string phpComposerVersion, StringBuilder scriptB this.logger.LogDebug("PHP Composer version {version} is already installed. So skipping installing it again.", phpComposerVersion); return; } + + bool composerInstalled = false; + + // If external ACR provider is enabled. + if (this.commonOptions.EnableExternalAcrSdkProvider) + { + composerInstalled = this.TryInstallPhpComposerExternalAcr(phpComposerVersion, scriptBuilder); + } + + // If PHP Composer is not installed yet and external SDK provider is enabled, try it. + if (!composerInstalled && this.commonOptions.EnableExternalSdkProvider) + { + composerInstalled = this.TryInstallPhpComposerExternalSdk(phpComposerVersion, scriptBuilder); + } + + // If PHP Composer is still not installed and ACR SDK provider is enabled, try it. + if (!composerInstalled && this.commonOptions.EnableAcrSdkProvider) + { + composerInstalled = this.TryInstallPhpComposerUsingAcrSdk(phpComposerVersion, scriptBuilder); + } + + if (!composerInstalled) + { + this.outputWriter.WriteLine($"Falling back to CDN for 'php-composer' version '{phpComposerVersion}'."); + this.logger.LogDebug("PHP Composer version {version} is not installed. Trying to install it from CDN.", phpComposerVersion); + var script = this.phpComposerInstaller.GetInstallerScriptSnippet(phpComposerVersion); + scriptBuilder.AppendLine(script); + } else { - if (this.commonOptions.EnableExternalSdkProvider) + this.logger.LogDebug("PHP Composer version {version} is installed successfully using one of the providers. So skipping CDN installation.", phpComposerVersion); + } + } + + private bool TryInstallPhpUsingAcrSdk(string phpVersion, StringBuilder scriptBuilder) + { + this.logger.LogDebug("PHP version {version} is not installed. ACR SDK provider is enabled, so trying to fetch SDK using it.", phpVersion); + + try + { + var result = this.acrSdkProvider.RequestSdkFromAcrAsync( + "php", phpVersion, this.commonOptions.DebianFlavor).Result; + + if (result) { - this.logger.LogDebug("Php Composer version {version} is not installed. External SDK provider is enabled so trying to fetch SDK using it.", phpComposerVersion); + this.logger.LogDebug("PHP version {version} is fetched successfully using ACR SDK provider.", phpVersion); + this.outputWriter.WriteLine($"SDK for 'php' version '{phpVersion}' fetched via direct ACR provider."); + scriptBuilder.AppendLine(this.phpInstaller.GetInstallerScriptSnippet(phpVersion, skipSdkBinaryDownload: true)); + return true; + } - try - { - var blobName = BlobNameHelper.GetBlobNameForVersion("php-composer", phpComposerVersion, this.commonOptions.DebianFlavor); - var isExternalFetchSuccess = this.externalSdkProvider.RequestBlobAsync("php-composer", blobName).Result; - if (isExternalFetchSuccess) - { - this.logger.LogDebug("Php composer version {version} is fetched successfully using external SDK provider. So generating an installation script snippet which skips platform binary download.", phpComposerVersion); - - script = this.phpComposerInstaller.GetInstallerScriptSnippet(phpComposerVersion, skipSdkBinaryDownload: true); - } - else - { - this.logger.LogDebug("Php comose version {version} is not fetched successfully using external SDK provider. So generating an installation script snippet for it.", phpComposerVersion); - script = this.phpComposerInstaller.GetInstallerScriptSnippet(phpComposerVersion); - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error while fetching php composer version {version} using external SDK provider.", phpComposerVersion); - script = this.phpComposerInstaller.GetInstallerScriptSnippet(phpComposerVersion); - } + this.logger.LogDebug("PHP version {version} is not fetched via ACR SDK provider. Trying next provider.", phpVersion); + this.outputWriter.WriteLine($"Failed to fetch SDK via direct ACR provider for 'php' version '{phpVersion}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error while fetching PHP version {version} using ACR SDK provider. Trying next provider.", phpVersion); + this.outputWriter.WriteLine($"Error fetching SDK via direct ACR provider for 'php' version '{phpVersion}'. Trying next provider."); + } + + return false; + } + + private bool TryInstallPhpComposerUsingAcrSdk(string phpComposerVersion, StringBuilder scriptBuilder) + { + if (string.IsNullOrEmpty(phpComposerVersion)) + { + phpComposerVersion = PhpVersions.ComposerDefaultVersion; + } + + this.logger.LogDebug("PHP Composer version {version} is not installed. ACR SDK provider is enabled, so trying to fetch SDK using it.", phpComposerVersion); + + try + { + var result = this.acrSdkProvider.RequestSdkFromAcrAsync( + "php-composer", phpComposerVersion, this.commonOptions.DebianFlavor).Result; + + if (result) + { + this.logger.LogDebug("PHP Composer version {version} is fetched successfully using ACR SDK provider.", phpComposerVersion); + this.outputWriter.WriteLine($"SDK for 'php-composer' version '{phpComposerVersion}' fetched via direct ACR provider."); + scriptBuilder.AppendLine(this.phpComposerInstaller.GetInstallerScriptSnippet(phpComposerVersion, skipSdkBinaryDownload: true)); + return true; + } + + this.logger.LogDebug("PHP Composer version {version} is not fetched via ACR SDK provider. Trying next provider.", phpComposerVersion); + this.outputWriter.WriteLine($"Failed to fetch SDK via direct ACR provider for 'php-composer' version '{phpComposerVersion}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error while fetching PHP Composer version {version} using ACR SDK provider. Trying next provider.", phpComposerVersion); + this.outputWriter.WriteLine($"Error fetching SDK via direct ACR provider for 'php-composer' version '{phpComposerVersion}'. Trying next provider."); + } + + return false; + } + + private bool TryInstallPhpUsingExternalAcrSdk(string phpVersion, StringBuilder scriptBuilder) + { + this.logger.LogDebug("PHP version {version} is not installed. External ACR SDK provider is enabled, so trying to fetch SDK using it.", phpVersion); + + try + { + if (this.externalAcrSdkProvider.RequestSdkAsync( + "php", phpVersion, this.commonOptions.DebianFlavor).Result) + { + this.logger.LogDebug("PHP version {version} is fetched successfully using external ACR SDK provider. Skipping platform binary download.", phpVersion); + this.outputWriter.WriteLine($"SDK for 'php' version '{phpVersion}' fetched via external ACR provider."); + scriptBuilder.AppendLine(this.phpInstaller.GetInstallerScriptSnippet(phpVersion, skipSdkBinaryDownload: true)); + return true; } - else + + this.logger.LogDebug("PHP version {version} is not fetched via external ACR SDK provider. Trying next provider.", phpVersion); + this.outputWriter.WriteLine($"Failed to fetch SDK via external ACR provider for 'php' version '{phpVersion}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error while fetching PHP version {version} using external ACR SDK provider. Trying next provider.", phpVersion); + this.outputWriter.WriteLine($"Error fetching SDK via external ACR provider for 'php' version '{phpVersion}'. Trying next provider."); + } + + return false; + } + + private bool TryInstallPhpComposerExternalAcr(string phpComposerVersion, StringBuilder scriptBuilder) + { + if (string.IsNullOrEmpty(phpComposerVersion)) + { + phpComposerVersion = PhpVersions.ComposerDefaultVersion; + } + + this.logger.LogDebug("PHP Composer version {version} is not installed. External ACR SDK provider is enabled, so trying to fetch SDK using it.", phpComposerVersion); + + try + { + if (this.externalAcrSdkProvider.RequestSdkAsync( + "php-composer", phpComposerVersion, this.commonOptions.DebianFlavor).Result) { - this.logger.LogDebug("Php composer version {version} is not installed. So generating an installation script snippet for it.", phpComposerVersion); - script = this.phpComposerInstaller.GetInstallerScriptSnippet(phpComposerVersion); + this.logger.LogDebug("PHP Composer version {version} is fetched successfully using external ACR SDK provider. Skipping platform binary download.", phpComposerVersion); + this.outputWriter.WriteLine($"SDK for 'php-composer' version '{phpComposerVersion}' fetched via external ACR provider."); + scriptBuilder.AppendLine(this.phpComposerInstaller.GetInstallerScriptSnippet(phpComposerVersion, skipSdkBinaryDownload: true)); + return true; } + + this.logger.LogDebug("PHP Composer version {version} is not fetched via external ACR SDK provider. Trying next provider.", phpComposerVersion); + this.outputWriter.WriteLine($"Failed to fetch SDK via external ACR provider for 'php-composer' version '{phpComposerVersion}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error while fetching PHP Composer version {version} using external ACR SDK provider. Trying next provider.", phpComposerVersion); + this.outputWriter.WriteLine($"Error fetching SDK via external ACR provider for 'php-composer' version '{phpComposerVersion}'. Trying next provider."); } - scriptBuilder.AppendLine(script); + return false; + } + + private bool TryInstallPhpExternalSdk(string phpVersion, StringBuilder scriptBuilder) + { + this.logger.LogDebug("PHP version {version} is not installed. External SDK provider is enabled, so trying to fetch SDK using it.", phpVersion); + + try + { + var blobName = BlobNameHelper.GetBlobNameForVersion("php", phpVersion, this.commonOptions.DebianFlavor); + if (this.externalSdkProvider.RequestBlobAsync(this.Name, blobName).Result) + { + this.logger.LogDebug("PHP version {version} is fetched successfully using external SDK provider. Skipping platform binary download.", phpVersion); + this.outputWriter.WriteLine($"SDK for 'php' version '{phpVersion}' fetched via external SDK provider."); + scriptBuilder.AppendLine(this.phpInstaller.GetInstallerScriptSnippet(phpVersion, skipSdkBinaryDownload: true)); + return true; + } + + this.logger.LogDebug("PHP version {version} is not fetched successfully using external SDK provider. Trying next provider.", phpVersion); + this.outputWriter.WriteLine($"Failed to fetch SDK via external SDK provider for 'php' version '{phpVersion}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error while fetching PHP version {version} using external SDK provider. Trying next provider.", phpVersion); + this.outputWriter.WriteLine($"Error fetching SDK via external SDK provider for 'php' version '{phpVersion}'. Trying next provider."); + } + + return false; + } + + private bool TryInstallPhpComposerExternalSdk(string phpComposerVersion, StringBuilder scriptBuilder) + { + if (string.IsNullOrEmpty(phpComposerVersion)) + { + phpComposerVersion = PhpVersions.ComposerDefaultVersion; + } + + this.logger.LogDebug("PHP Composer version {version} is not installed. External SDK provider is enabled, so trying to fetch SDK using it.", phpComposerVersion); + + try + { + var blobName = BlobNameHelper.GetBlobNameForVersion("php-composer", phpComposerVersion, this.commonOptions.DebianFlavor); + if (this.externalSdkProvider.RequestBlobAsync("php-composer", blobName).Result) + { + this.logger.LogDebug("PHP Composer version {version} is fetched successfully using external SDK provider. Skipping platform binary download.", phpComposerVersion); + this.outputWriter.WriteLine($"SDK for 'php-composer' version '{phpComposerVersion}' fetched via external SDK provider."); + scriptBuilder.AppendLine(this.phpComposerInstaller.GetInstallerScriptSnippet(phpComposerVersion, skipSdkBinaryDownload: true)); + return true; + } + + this.logger.LogDebug("PHP Composer version {version} is not fetched successfully using external SDK provider. Trying next provider.", phpComposerVersion); + this.outputWriter.WriteLine($"Failed to fetch SDK via external SDK provider for 'php-composer' version '{phpComposerVersion}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError(ex, "Error while fetching PHP Composer version {version} using external SDK provider. Trying next provider.", phpComposerVersion); + this.outputWriter.WriteLine($"Error fetching SDK via external SDK provider for 'php-composer' version '{phpComposerVersion}'. Trying next provider."); + } + + return false; } private void ResolveVersionsUsingHierarchicalRules(PhpPlatformDetectorResult detectorResult) { + // Resolve PHP version using hierarchical rules var phpVersion = ResolvePhpVersion(detectorResult.PlatformVersion); phpVersion = this.GetMaxSatisfyingPhpVersionAndVerify(phpVersion); + // Resolve PHP Composer version using hierarchical rules var phpComposerVersion = ResolvePhpComposerVersion(detectorResult.PhpComposerVersion); phpComposerVersion = this.GetMaxSatisfyingPhpComposerVersionAndVerify(phpComposerVersion); @@ -402,6 +581,17 @@ private void ResolveVersionsUsingHierarchicalRules(PhpPlatformDetectorResult det string ResolvePhpVersion(string detectedVersion) { + // If external ACR provider is enabled, try to resolve version from it first. + if (this.commonOptions.EnableExternalAcrSdkProvider) + { + var acrVersionInfo = this.phpVersionProvider.GetVersionInfo(); + if (acrVersionInfo?.DefaultVersion != null) + { + this.logger.LogDebug("External ACR SDK provider is enabled and returned version {version} for PHP.", acrVersionInfo.DefaultVersion); + return acrVersionInfo.DefaultVersion; + } + } + // Explicitly specified version by user wins over detected version if (!string.IsNullOrEmpty(this.phpScriptGeneratorOptions.PhpVersion)) { @@ -427,6 +617,19 @@ string ResolvePhpVersion(string detectedVersion) string ResolvePhpComposerVersion(string detectedVersion) { + // If external ACR provider is enabled, resolve version from the composer version provider. + // Note: Unlike PHP, Composer versions are hardcoded locally in PhpVersions.ComposerVersionsPerDebianFlavor + // rather than fetched from the external host. The provider still uses the same pattern for consistency. + if (this.commonOptions.EnableExternalAcrSdkProvider) + { + var acrVersionInfo = this.phpComposerVersionProvider.GetVersionInfo(); + if (acrVersionInfo?.DefaultVersion != null) + { + this.logger.LogDebug("External ACR SDK provider is enabled and returned version {version} for PHP Composer.", acrVersionInfo.DefaultVersion); + return acrVersionInfo.DefaultVersion; + } + } + // Explicitly specified version by user wins over detected version if (!string.IsNullOrEmpty(this.phpScriptGeneratorOptions.PhpComposerVersion)) { diff --git a/src/BuildScriptGenerator/Php/PhpScriptGeneratorServiceCollectionExtensions.cs b/src/BuildScriptGenerator/Php/PhpScriptGeneratorServiceCollectionExtensions.cs index fe96ac23f8..9a40abf620 100644 --- a/src/BuildScriptGenerator/Php/PhpScriptGeneratorServiceCollectionExtensions.cs +++ b/src/BuildScriptGenerator/Php/PhpScriptGeneratorServiceCollectionExtensions.cs @@ -22,8 +22,12 @@ public static IServiceCollection AddPhpScriptGeneratorServices(this IServiceColl services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/BuildScriptGenerator/Php/VersionProviders/PhpAcrVersionProvider.cs b/src/BuildScriptGenerator/Php/VersionProviders/PhpAcrVersionProvider.cs new file mode 100644 index 0000000000..1444211114 --- /dev/null +++ b/src/BuildScriptGenerator/Php/VersionProviders/PhpAcrVersionProvider.cs @@ -0,0 +1,36 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Oryx.BuildScriptGenerator.Php +{ + /// + /// ACR-based version provider for PHP SDKs. + /// Parallel to but uses OCI Distribution API. + /// + internal class PhpAcrVersionProvider : AcrVersionProviderBase, IPhpVersionProvider + { + private PlatformVersionInfo platformVersionInfo; + + public PhpAcrVersionProvider( + IOptions commonOptions, + OciRegistryClient ociClient, + ILoggerFactory loggerFactory) + : base(commonOptions, ociClient, loggerFactory) + { + } + + public virtual PlatformVersionInfo GetVersionInfo() + { + return this.platformVersionInfo + ??= this.GetAvailableVersionsFromAcr( + platformName: ToolNameConstants.PhpName, + defaultVersionPerFlavor: PhpConstants.DefaultVersionPerFlavor); + } + } +} diff --git a/src/BuildScriptGenerator/Php/VersionProviders/PhpComposerAcrVersionProvider.cs b/src/BuildScriptGenerator/Php/VersionProviders/PhpComposerAcrVersionProvider.cs new file mode 100644 index 0000000000..c29a364a64 --- /dev/null +++ b/src/BuildScriptGenerator/Php/VersionProviders/PhpComposerAcrVersionProvider.cs @@ -0,0 +1,36 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Oryx.BuildScriptGenerator.Php +{ + /// + /// ACR-based version provider for PHP Composer SDKs. + /// Parallel to but uses OCI Distribution API. + /// + internal class PhpComposerAcrVersionProvider : AcrVersionProviderBase, IPhpComposerVersionProvider + { + private PlatformVersionInfo platformVersionInfo; + + public PhpComposerAcrVersionProvider( + IOptions commonOptions, + OciRegistryClient ociClient, + ILoggerFactory loggerFactory) + : base(commonOptions, ociClient, loggerFactory) + { + } + + public virtual PlatformVersionInfo GetVersionInfo() + { + return this.platformVersionInfo + ??= this.GetAvailableVersionsFromAcr( + platformName: "php-composer", + defaultVersionPerFlavor: PhpConstants.ComposerDefaultVersionPerFlavor); + } + } +} diff --git a/src/BuildScriptGenerator/Php/VersionProviders/PhpComposerExternalAcrVersionProvider.cs b/src/BuildScriptGenerator/Php/VersionProviders/PhpComposerExternalAcrVersionProvider.cs new file mode 100644 index 0000000000..fd7953c09c --- /dev/null +++ b/src/BuildScriptGenerator/Php/VersionProviders/PhpComposerExternalAcrVersionProvider.cs @@ -0,0 +1,45 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator.Common; + +namespace Microsoft.Oryx.BuildScriptGenerator.Php +{ + /// + /// ACR-based version provider for PHP Composer SDKs via external socket provider. + /// Parallel to (blob) and + /// (direct OCI). + /// + internal class PhpComposerExternalAcrVersionProvider : ExternalAcrVersionProviderBase, IPhpComposerVersionProvider + { + public PhpComposerExternalAcrVersionProvider( + IOptions options, + ILoggerFactory loggerFactory, + IStandardOutputWriter outputWriter) + : base(options, loggerFactory, outputWriter) + { + } + + public virtual PlatformVersionInfo GetVersionInfo() + { + var availableVersions = PhpVersions.ComposerVersionsPerDebianFlavor.TryGetValue(this.DebianFlavor, out var versions) + ? versions + : null; + + if (availableVersions == null || availableVersions.Length == 0) + { + return null; + } + + return PlatformVersionInfo.CreateAvailableOnAcr( + supportedVersions: availableVersions.ToArray(), + defaultVersion: PhpVersions.ComposerDefaultVersion); + } + } +} diff --git a/src/BuildScriptGenerator/Php/VersionProviders/PhpComposerVersionProvider.cs b/src/BuildScriptGenerator/Php/VersionProviders/PhpComposerVersionProvider.cs index dd2334be23..f341bf9280 100644 --- a/src/BuildScriptGenerator/Php/VersionProviders/PhpComposerVersionProvider.cs +++ b/src/BuildScriptGenerator/Php/VersionProviders/PhpComposerVersionProvider.cs @@ -3,60 +3,47 @@ // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- -using System; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.Oryx.BuildScriptGenerator.Php { - internal class PhpComposerVersionProvider : IPhpComposerVersionProvider + internal class PhpComposerVersionProvider : PlatformVersionProviderBase, IPhpComposerVersionProvider { - private readonly BuildScriptGeneratorOptions options; private readonly PhpComposerOnDiskVersionProvider onDiskVersionProvider; private readonly PhpComposerSdkStorageVersionProvider sdkStorageVersionProvider; private readonly PhpComposerExternalVersionProvider externalVersionProvider; - private readonly ILogger logger; - private PlatformVersionInfo versionInfo; + private readonly PhpComposerExternalAcrVersionProvider externalAcrVersionProvider; + private readonly PhpComposerAcrVersionProvider acrVersionProvider; public PhpComposerVersionProvider( IOptions options, PhpComposerOnDiskVersionProvider onDiskVersionProvider, PhpComposerSdkStorageVersionProvider sdkStorageVersionProvider, PhpComposerExternalVersionProvider externalVersionProvider, - ILogger logger) + PhpComposerExternalAcrVersionProvider externalAcrVersionProvider, + PhpComposerAcrVersionProvider acrVersionProvider, + ILogger logger, + IStandardOutputWriter outputWriter) + : base(options.Value, logger, outputWriter) { - this.options = options.Value; this.onDiskVersionProvider = onDiskVersionProvider; this.sdkStorageVersionProvider = sdkStorageVersionProvider; this.externalVersionProvider = externalVersionProvider; - this.logger = logger; + this.externalAcrVersionProvider = externalAcrVersionProvider; + this.acrVersionProvider = acrVersionProvider; } - public PlatformVersionInfo GetVersionInfo() - { - if (this.versionInfo == null) - { - if (this.options.EnableDynamicInstall) - { - if (this.options.EnableExternalSdkProvider) - { - try - { - return this.externalVersionProvider.GetVersionInfo(); - } - catch (Exception ex) - { - this.logger.LogError($"Failed to get version info from external SDK provider. Falling back to http based sdkStorageVersionProvider. Ex: {ex}"); - } - } - - return this.sdkStorageVersionProvider.GetVersionInfo(); - } - - this.versionInfo = this.onDiskVersionProvider.GetVersionInfo(); - } - - return this.versionInfo; - } + protected override string PlatformName => "php-composer"; + + protected override PlatformVersionInfo GetOnDiskVersionInfo() => this.onDiskVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetSdkStorageVersionInfo() => this.sdkStorageVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetExternalVersionInfo() => this.externalVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetExternalAcrVersionInfo() => this.externalAcrVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetAcrVersionInfo() => this.acrVersionProvider.GetVersionInfo(); } } \ No newline at end of file diff --git a/src/BuildScriptGenerator/Php/VersionProviders/PhpExternalAcrVersionProvider.cs b/src/BuildScriptGenerator/Php/VersionProviders/PhpExternalAcrVersionProvider.cs new file mode 100644 index 0000000000..006b59859d --- /dev/null +++ b/src/BuildScriptGenerator/Php/VersionProviders/PhpExternalAcrVersionProvider.cs @@ -0,0 +1,40 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Oryx.BuildScriptGenerator.Php +{ + /// + /// ACR-based version provider for PHP SDKs via external socket provider. + /// Parallel to (blob) and + /// (direct OCI). + /// + internal class PhpExternalAcrVersionProvider : ExternalAcrVersionProviderBase, IPhpVersionProvider + { + public PhpExternalAcrVersionProvider( + IOptions options, + ILoggerFactory loggerFactory, + IStandardOutputWriter outputWriter) + : base(options, loggerFactory, outputWriter) + { + } + + public virtual PlatformVersionInfo GetVersionInfo() + { + var version = this.GetCompanionSdkVersion(platformName: ToolNameConstants.PhpName, debianFlavor: this.DebianFlavor); + if (string.IsNullOrEmpty(version)) + { + return null; + } + + return PlatformVersionInfo.CreateAvailableOnAcr( + supportedVersions: new[] { version }, + defaultVersion: version); + } + } +} diff --git a/src/BuildScriptGenerator/Php/VersionProviders/PhpVersionProvider.cs b/src/BuildScriptGenerator/Php/VersionProviders/PhpVersionProvider.cs index 991371670d..a22cbea396 100644 --- a/src/BuildScriptGenerator/Php/VersionProviders/PhpVersionProvider.cs +++ b/src/BuildScriptGenerator/Php/VersionProviders/PhpVersionProvider.cs @@ -3,60 +3,47 @@ // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- -using System; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.Oryx.BuildScriptGenerator.Php { - internal class PhpVersionProvider : IPhpVersionProvider + internal class PhpVersionProvider : PlatformVersionProviderBase, IPhpVersionProvider { - private readonly BuildScriptGeneratorOptions options; private readonly PhpOnDiskVersionProvider onDiskVersionProvider; private readonly PhpSdkStorageVersionProvider sdkStorageVersionProvider; private readonly PhpExternalVersionProvider externalVersionProvider; - private readonly ILogger logger; - private PlatformVersionInfo versionInfo; + private readonly PhpExternalAcrVersionProvider externalAcrVersionProvider; + private readonly PhpAcrVersionProvider acrVersionProvider; public PhpVersionProvider( IOptions options, PhpOnDiskVersionProvider onDiskVersionProvider, PhpSdkStorageVersionProvider sdkStorageVersionProvider, PhpExternalVersionProvider externalVersionProvider, - ILogger logger) + PhpExternalAcrVersionProvider externalAcrVersionProvider, + PhpAcrVersionProvider acrVersionProvider, + ILogger logger, + IStandardOutputWriter outputWriter) + : base(options.Value, logger, outputWriter) { - this.options = options.Value; this.onDiskVersionProvider = onDiskVersionProvider; this.sdkStorageVersionProvider = sdkStorageVersionProvider; this.externalVersionProvider = externalVersionProvider; - this.logger = logger; + this.externalAcrVersionProvider = externalAcrVersionProvider; + this.acrVersionProvider = acrVersionProvider; } - public PlatformVersionInfo GetVersionInfo() - { - if (this.versionInfo == null) - { - if (this.options.EnableDynamicInstall) - { - if (this.options.EnableExternalSdkProvider) - { - try - { - return this.externalVersionProvider.GetVersionInfo(); - } - catch (Exception ex) - { - this.logger.LogError($"Failed to get version info from external SDK provider. Falling back to http based sdkStorageVersionProvider. Ex: {ex}"); - } - } - - return this.sdkStorageVersionProvider.GetVersionInfo(); - } - - this.versionInfo = this.onDiskVersionProvider.GetVersionInfo(); - } - - return this.versionInfo; - } + protected override string PlatformName => "php"; + + protected override PlatformVersionInfo GetOnDiskVersionInfo() => this.onDiskVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetSdkStorageVersionInfo() => this.sdkStorageVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetExternalVersionInfo() => this.externalVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetExternalAcrVersionInfo() => this.externalAcrVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetAcrVersionInfo() => this.acrVersionProvider.GetVersionInfo(); } } \ No newline at end of file diff --git a/src/BuildScriptGenerator/PhpVersions.cs b/src/BuildScriptGenerator/PhpVersions.cs index ceedfc1cda..99f3e11822 100644 --- a/src/BuildScriptGenerator/PhpVersions.cs +++ b/src/BuildScriptGenerator/PhpVersions.cs @@ -55,5 +55,13 @@ public static class PhpVersions public const string Php56TarSha256 = "1369a51eee3995d7fbd1c5342e5cc917760e276d561595b6052b21ace2656d1c"; public static readonly List RuntimeVersions = new List { "7.4-debian-bullseye", "7.4-debian-buster", "8.0-debian-bullseye", "8.0-debian-buster", "8.1-debian-bullseye", "8.1-debian-buster", "8.2-debian-bullseye", "8.2-debian-buster", "8.3-debian-bullseye", "8.3-debian-buster", "8.3-debian-bookworm" }; public static readonly List FpmRuntimeVersions = new List { "7.4-fpm-debian-bullseye", "8.0-fpm-debian-bullseye", "8.1-fpm-debian-bullseye", "8.2-fpm-debian-bullseye", "8.3-fpm-debian-bullseye", "8.3-fpm-debian-bookworm", "8.4-fpm-debian-bullseye", "8.4-fpm-debian-bookworm", "8.5-fpm-ubuntu-noble" }; + + public static readonly Dictionary ComposerVersionsPerDebianFlavor = new Dictionary + { + { "bullseye", new[] { "1.9.2", "1.9.3", "1.10.0", "1.10.1", "1.10.2", "1.10.4", "1.10.5", "1.10.6", "1.10.7", "1.10.8", "1.10.9", "1.10.10", "1.10.11", "1.10.12", "1.10.13", "1.10.14", "1.10.15", "1.10.16", "1.10.17", "1.10.18", "1.10.19", "2.0.0", "2.0.1", "2.0.2", "2.0.3", "2.0.4", "2.0.5", "2.0.6", "2.0.7", "2.0.8", "2.2.9", "2.2.21", "2.3.4", "2.3.10", "2.4.4", "2.5.8", "2.6.2", "2.7.7", "2.8.2", "2.8.4", "2.8.6", "2.8.8" } }, + { "buster", new[] { "1.9.2", "1.9.3", "1.10.0", "1.10.1", "1.10.2", "1.10.4", "1.10.5", "1.10.6", "1.10.7", "1.10.8", "1.10.9", "1.10.10", "1.10.11", "1.10.12", "1.10.13", "1.10.14", "1.10.15", "1.10.16", "1.10.17", "1.10.18", "1.10.19", "2.0.0", "2.0.1", "2.0.2", "2.0.3", "2.0.4", "2.0.5", "2.0.6", "2.0.7", "2.0.8", "2.2.9", "2.2.21", "2.3.4", "2.3.10", "2.4.4", "2.5.8", "2.6.2" } }, + { "bookworm", new[] { "2.0.8", "2.6.2", "2.7.7", "2.8.2", "2.8.4", "2.8.6", "2.8.8" } }, + { "noble", new[] { "2.6.2", "2.7.7", "2.8.2", "2.8.4", "2.8.6", "2.8.8" } }, + }; } } \ No newline at end of file diff --git a/src/BuildScriptGenerator/PlatformInstallerBase.cs b/src/BuildScriptGenerator/PlatformInstallerBase.cs index 21fa5aee04..1603475663 100644 --- a/src/BuildScriptGenerator/PlatformInstallerBase.cs +++ b/src/BuildScriptGenerator/PlatformInstallerBase.cs @@ -146,13 +146,30 @@ protected string GetInstallerScriptSnippet( if (skipSdkBinaryDownload) { + // The tarball was pre-downloaded by an external or ACR SDK provider. + // Check the external blob cache (/var/OryxSdks) first, then the external + // ACR cache (/var/OryxAcrSdks), then the writable dynamic install dir. var tarFileName = BlobNameHelper.GetBlobNameForVersion(platformName, version, this.CommonOptions.DebianFlavor); - var tarFilePath = Path.Combine(ExternalSdkProvider.ExternalSdksStorageDir, platformName, tarFileName); - snippet.AppendLine($"echo \"Skipping download of {platformName} version {version} as it is available in external sdk provider cache...\"") - .AppendLine($"echo \"Extracting contents...\"") - .AppendLine($"tar -xzf {tarFilePath} -C .") - .AppendLine($"rm -f {tarFileName}") - .AppendLine($"echo \"Successfully extracted {platformName} version {version} from external sdk provider cache...\""); + var externalPath = Path.Combine(ExternalSdkProvider.ExternalSdksStorageDir, platformName, tarFileName); + var externalAcrPath = Path.Combine(ExternalAcrSdkProvider.ExternalAcrSdksStorageDir, platformName, tarFileName); + var dynamicPath = Path.Combine(this.CommonOptions.DynamicInstallRootDir, platformName, tarFileName); + + snippet.AppendLine($"echo \"SDK binary download was skipped. Looking for cached tarball...\"") + .AppendLine($"if [ -f \"{externalPath}\" ]; then") + .AppendLine($" echo \"Found tarball at {externalPath}\"") + .AppendLine($" tar -xzf {externalPath} -C .") + .AppendLine($"elif [ -f \"{externalAcrPath}\" ]; then") + .AppendLine($" echo \"Found tarball at {externalAcrPath}\"") + .AppendLine($" tar -xzf {externalAcrPath} -C .") + .AppendLine($"elif [ -f \"{dynamicPath}\" ]; then") + .AppendLine($" echo \"Found tarball at {dynamicPath}\"") + .AppendLine($" tar -xzf {dynamicPath} -C .") + .AppendLine($" rm -f {dynamicPath}") + .AppendLine($"else") + .AppendLine($" echo \"ERROR: Could not find cached tarball for {platformName} {version}\"") + .AppendLine($" exit 1") + .AppendLine($"fi") + .AppendLine($"echo \"Successfully extracted {platformName} version {version} from cached tarball.\""); } else { diff --git a/src/BuildScriptGenerator/PlatformVersionInfo.cs b/src/BuildScriptGenerator/PlatformVersionInfo.cs index fb40ecc3b0..5a1b1700fb 100644 --- a/src/BuildScriptGenerator/PlatformVersionInfo.cs +++ b/src/BuildScriptGenerator/PlatformVersionInfo.cs @@ -49,5 +49,17 @@ public static PlatformVersionInfo CreateAvailableViaExternalProvider( PlatformVersionSourceType = PlatformVersionSourceType.AvailableViaExternalProvider, }; } + + public static PlatformVersionInfo CreateAvailableOnAcr( + IEnumerable supportedVersions, + string defaultVersion) + { + return new PlatformVersionInfo + { + SupportedVersions = supportedVersions, + DefaultVersion = defaultVersion, + PlatformVersionSourceType = PlatformVersionSourceType.AvailableOnAcr, + }; + } } } diff --git a/src/BuildScriptGenerator/PlatformVersionProviderBase.cs b/src/BuildScriptGenerator/PlatformVersionProviderBase.cs new file mode 100644 index 0000000000..b907270c1c --- /dev/null +++ b/src/BuildScriptGenerator/PlatformVersionProviderBase.cs @@ -0,0 +1,133 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Oryx.BuildScriptGenerator +{ + /// + /// Base class for platform version providers that implements the 4-tier fallback chain: + /// External ACR → External SDK → Direct ACR → CDN (blob storage). + /// Used by Node.js, Python, PHP, and PHP Composer version providers. + /// + internal abstract class PlatformVersionProviderBase + { + private readonly BuildScriptGeneratorOptions options; + private readonly ILogger logger; + private readonly IStandardOutputWriter outputWriter; + private PlatformVersionInfo versionInfo; + + protected PlatformVersionProviderBase( + BuildScriptGeneratorOptions options, + ILogger logger, + IStandardOutputWriter outputWriter) + { + this.options = options; + this.logger = logger; + this.outputWriter = outputWriter; + } + + protected abstract string PlatformName { get; } + + public PlatformVersionInfo GetVersionInfo() + { + if (this.versionInfo != null) + { + return this.versionInfo; + } + + this.versionInfo = this.options.EnableDynamicInstall + ? this.ResolveDynamicVersionInfo() + : this.GetOnDiskVersionInfo(); + + return this.versionInfo; + } + + protected abstract PlatformVersionInfo GetOnDiskVersionInfo(); + + protected abstract PlatformVersionInfo GetSdkStorageVersionInfo(); + + protected abstract PlatformVersionInfo GetExternalVersionInfo(); + + protected abstract PlatformVersionInfo GetExternalAcrVersionInfo(); + + protected abstract PlatformVersionInfo GetAcrVersionInfo(); + + private static bool HasSupportedVersions(PlatformVersionInfo versionInfo) + { + return versionInfo?.SupportedVersions != null && versionInfo.SupportedVersions.Any(); + } + + /// + /// Resolves version info using the 4-tier provider chain. + /// Priority: External ACR → External SDK → Direct ACR → CDN. + /// + private PlatformVersionInfo ResolveDynamicVersionInfo() + { + if (this.options.EnableExternalAcrSdkProvider) + { + var result = this.TryGetVersionInfo(this.GetExternalAcrVersionInfo, "external ACR"); + if (HasSupportedVersions(result)) + { + this.outputWriter.WriteLine("Version resolved using external ACR SDK provider."); + return result; + } + } + + if (this.options.EnableExternalSdkProvider) + { + var result = this.TryGetVersionInfo(this.GetExternalVersionInfo, "external SDK"); + if (HasSupportedVersions(result)) + { + this.outputWriter.WriteLine("Version resolved using external SDK provider."); + return result; + } + } + + if (this.options.EnableAcrSdkProvider) + { + var result = this.TryGetVersionInfo(this.GetAcrVersionInfo, "direct ACR"); + if (HasSupportedVersions(result)) + { + this.outputWriter.WriteLine("Version resolved using direct ACR SDK provider."); + return result; + } + } + + this.outputWriter.WriteLine("Version resolved using blob SDK storage provider(CDN)."); + return this.GetSdkStorageVersionInfo(); + } + + private PlatformVersionInfo TryGetVersionInfo( + Func getVersionInfo, + string providerName) + { + try + { + var result = getVersionInfo(); + if (result == null) + { + this.logger.LogWarning( + "{ProviderName} version provider returned no version info for {PlatformName}. Trying next provider.", + providerName, + this.PlatformName); + } + + return result; + } + catch (Exception ex) + { + this.logger.LogError( + "Error while getting version info from {ProviderName} provider for {PlatformName}. Ex: {Ex}", + providerName, + this.PlatformName, + ex); + return null; + } + } + } +} diff --git a/src/BuildScriptGenerator/PlatformVersionSourceType.cs b/src/BuildScriptGenerator/PlatformVersionSourceType.cs index 8137a275b9..65ac237b04 100644 --- a/src/BuildScriptGenerator/PlatformVersionSourceType.cs +++ b/src/BuildScriptGenerator/PlatformVersionSourceType.cs @@ -10,5 +10,6 @@ public enum PlatformVersionSourceType OnDisk, AvailableOnWeb, AvailableViaExternalProvider, + AvailableOnAcr, } } diff --git a/src/BuildScriptGenerator/Python/PythonConstants.cs b/src/BuildScriptGenerator/Python/PythonConstants.cs index 0a336ea011..6e319d5fe4 100644 --- a/src/BuildScriptGenerator/Python/PythonConstants.cs +++ b/src/BuildScriptGenerator/Python/PythonConstants.cs @@ -3,6 +3,8 @@ // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- +using System.Collections.Generic; + namespace Microsoft.Oryx.BuildScriptGenerator.Python { public static class PythonConstants @@ -20,5 +22,18 @@ public static class PythonConstants public const string DefaultTargetPackageDirectory = "__oryx_packages__"; public const string SetupDotPyFileName = "setup.py"; public const string CondaExecutablePath = "/opt/conda/condabin/conda"; + + /// + /// Default Python major.minor version per OS flavor, matching platforms/python/versions/*/defaultVersion.txt. + /// + public static readonly Dictionary DefaultVersionPerFlavor = new Dictionary + { + { "bookworm", "3.8" }, + { "bullseye", "3.8" }, + { "buster", "3.8" }, + { "focal-scm", "3.8" }, + { "noble", "3.14" }, + { "stretch", "3.8" }, + }; } } diff --git a/src/BuildScriptGenerator/Python/PythonPlatform.cs b/src/BuildScriptGenerator/Python/PythonPlatform.cs index 071b9fe8cd..918f997a0e 100644 --- a/src/BuildScriptGenerator/Python/PythonPlatform.cs +++ b/src/BuildScriptGenerator/Python/PythonPlatform.cs @@ -89,7 +89,10 @@ internal class PythonPlatform : IProgrammingPlatform private readonly IPythonPlatformDetector detector; private readonly PythonPlatformInstaller platformInstaller; private readonly IExternalSdkProvider externalSdkProvider; + private readonly IExternalAcrSdkProvider externalAcrSdkProvider; + private readonly IAcrSdkProvider acrSdkProvider; private readonly TelemetryClient telemetryClient; + private readonly IStandardOutputWriter outputWriter; /// /// Initializes a new instance of the class. @@ -108,7 +111,10 @@ public PythonPlatform( IPythonPlatformDetector detector, PythonPlatformInstaller platformInstaller, IExternalSdkProvider externalSdkProvider, - TelemetryClient telemetryClient) + IExternalAcrSdkProvider externalAcrSdkProvider, + IAcrSdkProvider acrSdkProvider, + TelemetryClient telemetryClient, + IStandardOutputWriter outputWriter) { this.commonOptions = commonOptions.Value; this.pythonScriptGeneratorOptions = pythonScriptGeneratorOptions.Value; @@ -117,7 +123,10 @@ public PythonPlatform( this.detector = detector; this.platformInstaller = platformInstaller; this.externalSdkProvider = externalSdkProvider; + this.externalAcrSdkProvider = externalAcrSdkProvider; + this.acrSdkProvider = acrSdkProvider; this.telemetryClient = telemetryClient; + this.outputWriter = outputWriter; } /// @@ -382,57 +391,63 @@ public string GetInstallerScriptSnippet( return null; } - string installationScriptSnippet = null; - if (this.commonOptions.EnableDynamicInstall) + if (!this.commonOptions.EnableDynamicInstall) { - this.logger.LogDebug("Dynamic install is enabled."); + this.logger.LogDebug("Dynamic install not enabled."); + return null; + } + + this.logger.LogDebug("Dynamic install is enabled."); + + if (this.platformInstaller.IsVersionAlreadyInstalled(detectorResult.PlatformVersion)) + { + this.logger.LogDebug( + "Python version {version} is already installed. So skipping installing it again.", + detectorResult.PlatformVersion); + return null; + } + + var version = detectorResult.PlatformVersion; - if (this.platformInstaller.IsVersionAlreadyInstalled(detectorResult.PlatformVersion)) + // Priority: External-ACR → External-SDK → Direct-ACR → CDN + + // 1. Try External-ACR (socket → ACR) + if (this.commonOptions.EnableExternalAcrSdkProvider) + { + var result = this.TryInstallFromExternalAcrSdkProvider(version); + + if (result != null) { - this.logger.LogDebug( - "Python version {version} is already installed. So skipping installing it again.", - detectorResult.PlatformVersion); + return result; } - else + } + + // 2. Try External-SDK (socket → SDK) + if (this.commonOptions.EnableExternalSdkProvider) + { + var result = this.TryInstallFromExternalSdkProvider(version); + if (result != null) { - if (this.commonOptions.EnableExternalSdkProvider) - { - this.logger.LogDebug("Python version {version} is not installed. External SDK provider is enabled so trying to fetch SDK using it.", detectorResult.PlatformVersion); - - try - { - var blobName = BlobNameHelper.GetBlobNameForVersion(this.Name, detectorResult.PlatformVersion, this.commonOptions.DebianFlavor); - var isExternalFetchSuccess = this.externalSdkProvider.RequestBlobAsync(this.Name, blobName).Result; - if (isExternalFetchSuccess) - { - this.logger.LogDebug("Python version {version} is fetched successfully using external SDK provider. So generating an installation script snippet which skips platform binary download.", detectorResult.PlatformVersion); - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet(detectorResult.PlatformVersion, skipSdkBinaryDownload: true); - } - else - { - this.logger.LogDebug("Python version {version} is not fetched successfully using external SDK provider. So generating an installation script snippet for it.", detectorResult.PlatformVersion); - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet(detectorResult.PlatformVersion); - } - } - catch (Exception ex) - { - this.logger.LogError(ex, "Error while fetching python version {version} using external SDK provider.", detectorResult.PlatformVersion); - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet(detectorResult.PlatformVersion); - } - } - else - { - this.logger.LogDebug("Python version {version} is not installed. So generating an installation script snippet for it.", detectorResult.PlatformVersion); - installationScriptSnippet = this.platformInstaller.GetInstallerScriptSnippet(detectorResult.PlatformVersion); - } + return result; } } - else + + // 3. Try Direct-ACR (direct OCI API calls) + if (this.commonOptions.EnableAcrSdkProvider) { - this.logger.LogDebug("Dynamic install not enabled."); + var result = this.TryInstallFromAcrSdkProvider(version); + if (result != null) + { + return result; + } } - return installationScriptSnippet; + // 4. CDN fallback + this.outputWriter.WriteLine($"Falling back to CDN for '{this.Name}' version '{version}'."); + this.logger.LogDebug( + "Python version {version} is not installed. So generating an installation script snippet for it.", + version); + return this.platformInstaller.GetInstallerScriptSnippet(version); } /// @@ -565,6 +580,112 @@ private static bool IsCondaInstalledInImage() return File.Exists(PythonConstants.CondaExecutablePath); } + private string TryInstallFromExternalSdkProvider(string version) + { + this.logger.LogDebug( + "Python version {version} is not installed. External SDK provider is enabled so trying to fetch SDK using it.", + version); + + try + { + var blobName = BlobNameHelper.GetBlobNameForVersion(this.Name, version, this.commonOptions.DebianFlavor); + if (this.externalSdkProvider.RequestBlobAsync(this.Name, blobName).Result) + { + this.logger.LogDebug( + "Python version {version} is fetched successfully using external SDK provider. Skipping platform binary download.", + version); + this.outputWriter.WriteLine($"SDK for '{this.Name}' version '{version}' fetched via external SDK provider."); + return this.platformInstaller.GetInstallerScriptSnippet(version, skipSdkBinaryDownload: true); + } + + this.logger.LogDebug( + "Python version {version} is not fetched successfully using external SDK provider. Generating installation script snippet.", + version); + this.outputWriter.WriteLine($"Failed to fetch SDK via external SDK provider for '{this.Name}' version '{version}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Error while fetching python version {version} using external SDK provider.", + version); + this.outputWriter.WriteLine($"Error fetching SDK via external SDK provider for '{this.Name}' version '{version}'. Trying next provider."); + } + + return null; + } + + private string TryInstallFromAcrSdkProvider(string version) + { + this.logger.LogDebug( + "Python version {version} is not installed. ACR SDK provider is enabled, so trying to fetch SDK using it.", + version); + + try + { + var result = this.acrSdkProvider.RequestSdkFromAcrAsync( + this.Name, version, this.commonOptions.DebianFlavor).Result; + + if (result) + { + this.logger.LogDebug( + "Python version {version} is fetched successfully using ACR SDK provider.", + version); + this.outputWriter.WriteLine($"SDK for '{this.Name}' version '{version}' fetched via direct ACR provider."); + return this.platformInstaller.GetInstallerScriptSnippet(version, skipSdkBinaryDownload: true); + } + + this.logger.LogDebug( + "Python version {version} is not fetched via ACR SDK provider. Trying next provider.", + version); + this.outputWriter.WriteLine($"Failed to fetch SDK via direct ACR provider for '{this.Name}' version '{version}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Error while fetching python version {version} using ACR SDK provider. Trying next provider.", + version); + this.outputWriter.WriteLine($"Error fetching SDK via direct ACR provider for '{this.Name}' version '{version}'. Trying next provider."); + } + + return null; + } + + private string TryInstallFromExternalAcrSdkProvider(string version) + { + this.logger.LogDebug( + "Python version {version} is not installed. External ACR SDK provider is enabled, so trying to fetch SDK using it.", + version); + + try + { + if (this.externalAcrSdkProvider.RequestSdkAsync(this.Name, version, this.commonOptions.DebianFlavor).Result) + { + this.logger.LogDebug( + "Python version {version} is fetched successfully using external ACR SDK provider. Skipping platform binary download.", + version); + this.outputWriter.WriteLine($"SDK for '{this.Name}' version '{version}' fetched via external ACR provider."); + return this.platformInstaller.GetInstallerScriptSnippet(version, skipSdkBinaryDownload: true); + } + + this.logger.LogDebug( + "Python version {version} is not fetched via external ACR SDK provider. Trying next provider.", + version); + this.outputWriter.WriteLine($"Failed to fetch SDK via external ACR provider for '{this.Name}' version '{version}'. Trying next provider."); + } + catch (Exception ex) + { + this.logger.LogError( + ex, + "Error while fetching python version {version} using external ACR SDK provider. Trying next provider.", + version); + this.outputWriter.WriteLine($"Error fetching SDK via external ACR provider for '{this.Name}' version '{version}'. Trying next provider."); + } + + return null; + } + private BuildScriptSnippet GetBuildScriptSnippetForConda( BuildScriptGeneratorContext context, PythonPlatformDetectorResult detectorResult) @@ -711,6 +832,17 @@ private string GetMaxSatisfyingVersionAndVerify(string version) private string GetVersionUsingHierarchicalRules(string detectedVersion) { + // If External ACR SDK provider is enabled, then we try to get the version from it first + // before applying the hierarchical rules, because it has the highest priority in terms of version selection. + if (this.commonOptions.EnableExternalAcrSdkProvider) + { + var acrVersionInfo = this.versionProvider.GetVersionInfo(); + if (acrVersionInfo?.DefaultVersion != null) + { + return acrVersionInfo.DefaultVersion; + } + } + // Explicitly specified version by user wins over detected version if (!string.IsNullOrEmpty(this.pythonScriptGeneratorOptions.PythonVersion)) { diff --git a/src/BuildScriptGenerator/Python/PythonScriptGeneratorServiceCollectionExtensions.cs b/src/BuildScriptGenerator/Python/PythonScriptGeneratorServiceCollectionExtensions.cs index b767f48663..59ed405da7 100644 --- a/src/BuildScriptGenerator/Python/PythonScriptGeneratorServiceCollectionExtensions.cs +++ b/src/BuildScriptGenerator/Python/PythonScriptGeneratorServiceCollectionExtensions.cs @@ -20,6 +20,8 @@ public static IServiceCollection AddPythonScriptGeneratorServices(this IServiceC services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); return services; } } diff --git a/src/BuildScriptGenerator/Python/VersionProviders/PythonAcrVersionProvider.cs b/src/BuildScriptGenerator/Python/VersionProviders/PythonAcrVersionProvider.cs new file mode 100644 index 0000000000..0c1ee9f174 --- /dev/null +++ b/src/BuildScriptGenerator/Python/VersionProviders/PythonAcrVersionProvider.cs @@ -0,0 +1,36 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Net.Http; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Oryx.BuildScriptGenerator.Python +{ + /// + /// ACR-based version provider for Python SDKs. + /// Parallel to but uses OCI Distribution API. + /// + internal class PythonAcrVersionProvider : AcrVersionProviderBase, IPythonVersionProvider + { + private PlatformVersionInfo platformVersionInfo; + + public PythonAcrVersionProvider( + IOptions commonOptions, + OciRegistryClient ociClient, + ILoggerFactory loggerFactory) + : base(commonOptions, ociClient, loggerFactory) + { + } + + public virtual PlatformVersionInfo GetVersionInfo() + { + return this.platformVersionInfo + ??= this.GetAvailableVersionsFromAcr( + platformName: ToolNameConstants.PythonName, + defaultVersionPerFlavor: Python.PythonConstants.DefaultVersionPerFlavor); + } + } +} diff --git a/src/BuildScriptGenerator/Python/VersionProviders/PythonExternalAcrVersionProvider.cs b/src/BuildScriptGenerator/Python/VersionProviders/PythonExternalAcrVersionProvider.cs new file mode 100644 index 0000000000..d3427ac584 --- /dev/null +++ b/src/BuildScriptGenerator/Python/VersionProviders/PythonExternalAcrVersionProvider.cs @@ -0,0 +1,40 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.Oryx.BuildScriptGenerator.Python +{ + /// + /// ACR-based version provider for Python SDKs via external socket provider. + /// Parallel to (blob) and + /// (direct OCI). + /// + internal class PythonExternalAcrVersionProvider : ExternalAcrVersionProviderBase, IPythonVersionProvider + { + public PythonExternalAcrVersionProvider( + IOptions options, + ILoggerFactory loggerFactory, + IStandardOutputWriter outputWriter) + : base(options, loggerFactory, outputWriter) + { + } + + public virtual PlatformVersionInfo GetVersionInfo() + { + var version = this.GetCompanionSdkVersion(platformName: ToolNameConstants.PythonName, debianFlavor: this.DebianFlavor); + if (string.IsNullOrEmpty(version)) + { + return null; + } + + return PlatformVersionInfo.CreateAvailableOnAcr( + supportedVersions: new[] { version }, + defaultVersion: version); + } + } +} diff --git a/src/BuildScriptGenerator/Python/VersionProviders/PythonVersionProvider.cs b/src/BuildScriptGenerator/Python/VersionProviders/PythonVersionProvider.cs index b6a268e86e..a2792b4341 100644 --- a/src/BuildScriptGenerator/Python/VersionProviders/PythonVersionProvider.cs +++ b/src/BuildScriptGenerator/Python/VersionProviders/PythonVersionProvider.cs @@ -3,60 +3,47 @@ // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- -using System; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; namespace Microsoft.Oryx.BuildScriptGenerator.Python { - internal class PythonVersionProvider : IPythonVersionProvider + internal class PythonVersionProvider : PlatformVersionProviderBase, IPythonVersionProvider { - private readonly BuildScriptGeneratorOptions options; private readonly PythonOnDiskVersionProvider onDiskVersionProvider; private readonly PythonSdkStorageVersionProvider sdkStorageVersionProvider; private readonly PythonExternalVersionProvider externalVersionProvider; - private readonly ILogger logger; - private PlatformVersionInfo versionInfo; + private readonly PythonExternalAcrVersionProvider externalAcrVersionProvider; + private readonly PythonAcrVersionProvider acrVersionProvider; public PythonVersionProvider( IOptions options, PythonOnDiskVersionProvider onDiskVersionProvider, PythonSdkStorageVersionProvider sdkStorageVersionProvider, PythonExternalVersionProvider externalVersionProvider, - ILogger logger) + PythonExternalAcrVersionProvider externalAcrVersionProvider, + PythonAcrVersionProvider acrVersionProvider, + ILogger logger, + IStandardOutputWriter outputWriter) + : base(options.Value, logger, outputWriter) { - this.options = options.Value; this.onDiskVersionProvider = onDiskVersionProvider; this.sdkStorageVersionProvider = sdkStorageVersionProvider; this.externalVersionProvider = externalVersionProvider; - this.logger = logger; + this.externalAcrVersionProvider = externalAcrVersionProvider; + this.acrVersionProvider = acrVersionProvider; } - public PlatformVersionInfo GetVersionInfo() - { - if (this.versionInfo == null) - { - if (this.options.EnableDynamicInstall) - { - if (this.options.EnableExternalSdkProvider) - { - try - { - return this.externalVersionProvider.GetVersionInfo(); - } - catch (Exception ex) - { - this.logger.LogError($"Failed to get version info from external SDK provider. Falling back to http based sdkStorageVersionProvider. Ex: {ex}"); - } - } - - return this.sdkStorageVersionProvider.GetVersionInfo(); - } - - this.versionInfo = this.onDiskVersionProvider.GetVersionInfo(); - } - - return this.versionInfo; - } + protected override string PlatformName => "python"; + + protected override PlatformVersionInfo GetOnDiskVersionInfo() => this.onDiskVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetSdkStorageVersionInfo() => this.sdkStorageVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetExternalVersionInfo() => this.externalVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetExternalAcrVersionInfo() => this.externalAcrVersionProvider.GetVersionInfo(); + + protected override PlatformVersionInfo GetAcrVersionInfo() => this.acrVersionProvider.GetVersionInfo(); } } \ No newline at end of file diff --git a/src/BuildScriptGenerator/SdkStorageVersionProviderBase.cs b/src/BuildScriptGenerator/SdkStorageVersionProviderBase.cs index 4c43704974..ef3ace3913 100644 --- a/src/BuildScriptGenerator/SdkStorageVersionProviderBase.cs +++ b/src/BuildScriptGenerator/SdkStorageVersionProviderBase.cs @@ -66,6 +66,13 @@ protected PlatformVersionInfo GetAvailableVersionsFromStorage(string platformNam { xdoc = ListBlobsHelper.GetAllBlobs(sdkStorageBackupBaseUrl, platformName, httpClient); } + else + { + throw new InvalidOperationException( + $"Failed to get SDK versions for platform '{platformName}' from primary storage URL '{sdkStorageBaseUrl}', " + + $"and backup storage URL is not configured. {Constants.NetworkConfigurationHelpText}", + ex); + } } var supportedVersions = new List(); @@ -154,7 +161,7 @@ protected string GetDefaultVersion(string platformName, string sdkStorageBaseUrl while ((line = stringReader.ReadLine()) != null) { // Ignore any comments in the file - if (!line.StartsWith("#") || !line.StartsWith("//")) + if (!line.StartsWith("#") && !line.StartsWith("//")) { defaultVersion = line.Trim(); break; diff --git a/src/BuildScriptGeneratorCli/BuildScriptGeneratorCli.csproj b/src/BuildScriptGeneratorCli/BuildScriptGeneratorCli.csproj index 7c98980c80..0a93a56661 100644 --- a/src/BuildScriptGeneratorCli/BuildScriptGeneratorCli.csproj +++ b/src/BuildScriptGeneratorCli/BuildScriptGeneratorCli.csproj @@ -24,15 +24,15 @@ - - - - - - + + + + + + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/BuildScriptGeneratorCli/Options/BuildScriptGeneratorOptionsSetup.cs b/src/BuildScriptGeneratorCli/Options/BuildScriptGeneratorOptionsSetup.cs index f3313c7c7e..f3c27235c6 100644 --- a/src/BuildScriptGeneratorCli/Options/BuildScriptGeneratorOptionsSetup.cs +++ b/src/BuildScriptGeneratorCli/Options/BuildScriptGeneratorOptionsSetup.cs @@ -62,6 +62,14 @@ public void Configure(BuildScriptGeneratorLib.BuildScriptGeneratorOptions option // Dynamic install options.EnableDynamicInstall = this.GetBooleanValue(SettingsKeys.EnableDynamicInstall); options.EnableExternalSdkProvider = this.GetBooleanValue(SettingsKeys.EnableExternalSdkProvider); + options.EnableExternalAcrSdkProvider = this.GetBooleanValue(SettingsKeys.EnableExternalAcrSdkProvider); + + // If multi-platform build is enabled, we disable the external ACR provider. + options.EnableExternalAcrSdkProvider = options.EnableExternalAcrSdkProvider && !options.EnableMultiPlatformBuild; + + options.EnableAcrSdkProvider = this.GetBooleanValue(SettingsKeys.EnableAcrSdkProvider); + options.OryxAcrSdkRegistryUrl = this.GetStringValue(SettingsKeys.OryxAcrSdkRegistryUrl); + options.OryxAcrSdkRepositoryPrefix = this.GetStringValue(SettingsKeys.OryxAcrSdkRepositoryPrefix); var dynamicInstallRootDir = this.GetStringValue(SettingsKeys.DynamicInstallRootDir); diff --git a/src/BuildScriptGeneratorCli/SettingsKeys.cs b/src/BuildScriptGeneratorCli/SettingsKeys.cs index b478745127..78aac84e08 100644 --- a/src/BuildScriptGeneratorCli/SettingsKeys.cs +++ b/src/BuildScriptGeneratorCli/SettingsKeys.cs @@ -17,7 +17,6 @@ public static class SettingsKeys public const string CreatePackage = "CREATE_PACKAGE"; public const string CompressDestinationDir = "COMPRESS_DESTINATION_DIR"; public const string EnableDynamicInstall = "ENABLE_DYNAMIC_INSTALL"; - public const string EnableExternalSdkProvider = "ORYX_ENABLE_EXTERNAL_SDK_PROVIDER"; public const string DisableCheckers = "DISABLE_CHECKERS"; public const string DisableDotNetCoreBuild = "DISABLE_DOTNETCORE_BUILD"; public const string DisableGolangBuild = "DISABLE_GOLANG_BUILD"; @@ -74,6 +73,13 @@ public static class SettingsKeys public const string OsFlavor = "OS_FLAVOR"; public const string DebianFlavor = "DEBIAN_FLAVOR"; public const string CallerId = "CALLER_ID"; + + // SDK provider settings public const string OryxDisablePipUpgrade = "ORYX_DISABLE_PIP_UPGRADE"; + public const string EnableExternalSdkProvider = "ORYX_ENABLE_EXTERNAL_SDK_PROVIDER"; + public const string EnableExternalAcrSdkProvider = "ORYX_ENABLE_EXTERNAL_ACR_SDK_PROVIDER"; + public const string EnableAcrSdkProvider = "ORYX_ENABLE_ACR_SDK_PROVIDER"; + public const string OryxAcrSdkRegistryUrl = "ORYX_ACR_SDK_REGISTRY_URL"; + public const string OryxAcrSdkRepositoryPrefix = "ORYX_ACR_SDK_REPOSITORY_PREFIX"; } } diff --git a/src/Oryx.Common/Common.csproj b/src/Oryx.Common/Common.csproj index 1d28def0ad..7b53106443 100644 --- a/src/Oryx.Common/Common.csproj +++ b/src/Oryx.Common/Common.csproj @@ -17,7 +17,7 @@ - + diff --git a/tests/BuildScriptGenerator.Tests/AcrSdkProviderTest.cs b/tests/BuildScriptGenerator.Tests/AcrSdkProviderTest.cs new file mode 100644 index 0000000000..7e9cf7c2a8 --- /dev/null +++ b/tests/BuildScriptGenerator.Tests/AcrSdkProviderTest.cs @@ -0,0 +1,342 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Oryx.Tests.Common; +using Xunit; + +namespace Microsoft.Oryx.BuildScriptGenerator.Tests +{ + public class AcrSdkProviderTest + { + // --- Constructor arg validation --- + + [Fact] + public async Task RequestSdkFromAcrAsync_ThrowsForNullPlatformName() + { + var provider = CreateProvider(); + await Assert.ThrowsAsync( + () => provider.RequestSdkFromAcrAsync(null, "1.0", "bookworm")); + } + + [Fact] + public async Task RequestSdkFromAcrAsync_ThrowsForEmptyPlatformName() + { + var provider = CreateProvider(); + await Assert.ThrowsAsync( + () => provider.RequestSdkFromAcrAsync(string.Empty, "1.0", "bookworm")); + } + + [Fact] + public async Task RequestSdkFromAcrAsync_ThrowsForNullVersion() + { + var provider = CreateProvider(); + await Assert.ThrowsAsync( + () => provider.RequestSdkFromAcrAsync("nodejs", null, "bookworm")); + } + + [Fact] + public async Task RequestSdkFromAcrAsync_ThrowsForEmptyVersion() + { + var provider = CreateProvider(); + await Assert.ThrowsAsync( + () => provider.RequestSdkFromAcrAsync("nodejs", string.Empty, "bookworm")); + } + + [Fact] + public async Task RequestSdkFromAcrAsync_DefaultsDebianFlavor_WhenEmpty() + { + // Should NOT throw — debianFlavor defaults to "bookworm" when empty + var handler = new FailingHandler(); + var provider = CreateProvider(handler); + + // Will fail at the HTTP level, but should not throw ArgumentException for empty debianFlavor + var result = await provider.RequestSdkFromAcrAsync("nodejs", "20.0.0", string.Empty); + Assert.False(result); + } + + // --- Tag construction --- + + [Fact] + public async Task RequestSdkFromAcrAsync_ConstructsSimpleTag_WhenNoRuntimeVersion() + { + // When runtimeVersion is null, tag should be "{flavor}-{version}" + var handler = new TagCapturingHandler(); + var provider = CreateProvider(handler); + + await provider.RequestSdkFromAcrAsync("nodejs", "20.19.3", "bookworm"); + + // The handler captures the manifest URL to verify tag construction + Assert.Contains("bookworm-20.19.3", handler.LastManifestUrl); + Assert.DoesNotContain("_", handler.LastManifestUrl); + } + + [Fact] + public async Task RequestSdkFromAcrAsync_ConstructsCompoundTag_WithRuntimeVersion() + { + // When runtimeVersion is provided, tag should be "{flavor}-{version}_{runtimeVersion}" + var handler = new TagCapturingHandler(); + var provider = CreateProvider(handler); + + await provider.RequestSdkFromAcrAsync("dotnet", "8.0.403", "bookworm", "8.0.18"); + + Assert.Contains("bookworm-8.0.403_8.0.18", handler.LastManifestUrl); + } + + // --- Error handling --- + + [Fact] + public async Task RequestSdkFromAcrAsync_ReturnsFalse_WhenManifestNotFound() + { + var handler = new FailingHandler(); + var provider = CreateProvider(handler); + + var result = await provider.RequestSdkFromAcrAsync("nodejs", "20.0.0", "bookworm"); + Assert.False(result); + } + + [Fact] + public async Task RequestSdkFromAcrAsync_ReturnsFalse_WhenManifestHasNoLayers() + { + // Arrange — HEAD returns digest OK, GET manifest returns manifest with no layers + var handler = new ManifestHandler(layerDigest: null); + var provider = CreateProvider(handler); + + // Act + var result = await provider.RequestSdkFromAcrAsync("nodejs", "20.0.0", "bookworm"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task RequestSdkFromAcrAsync_ReturnsFalse_WhenHttpThrows() + { + // Arrange — handler that throws on all requests + var handler = new ThrowingHandler(); + var provider = CreateProvider(handler); + + // Act + var result = await provider.RequestSdkFromAcrAsync("nodejs", "20.0.0", "bookworm"); + + // Assert — exceptions are caught and return false + Assert.False(result); + } + + [Fact] + public async Task RequestSdkFromAcrAsync_ReturnsFalse_WhenServerError() + { + // Arrange — 500 Internal Server Error + var handler = new StatusCodeHandler(HttpStatusCode.InternalServerError); + var provider = CreateProvider(handler); + + // Act + var result = await provider.RequestSdkFromAcrAsync("nodejs", "20.0.0", "bookworm"); + + // Assert + Assert.False(result); + } + + [Fact] + public async Task RequestSdkFromAcrAsync_UsesDefaultRepositoryPrefix() + { + // Arrange — verify the repository path includes the default "oryx" prefix + var handler = new TagCapturingHandler(); + var provider = CreateProvider(handler); + + // Act + await provider.RequestSdkFromAcrAsync("python", "3.11.0", "bookworm"); + + // Assert — URL should contain "oryx/python-sdk" + Assert.Contains("oryx/python-sdk", handler.LastManifestUrl); + } + + // --- ExternalAcrSdkProvider arg validation --- + + [Fact] + public async Task ExternalAcrSdkProvider_RequestSdkAsync_ThrowsForNullPlatformName() + { + var provider = new ExternalAcrSdkProvider( + new DefaultStandardOutputWriter(), + NullLogger.Instance); + + await Assert.ThrowsAsync( + () => provider.RequestSdkAsync(null, "1.0", "bookworm")); + } + + [Fact] + public async Task ExternalAcrSdkProvider_RequestSdkAsync_ThrowsForNullVersion() + { + var provider = new ExternalAcrSdkProvider( + new DefaultStandardOutputWriter(), + NullLogger.Instance); + + await Assert.ThrowsAsync( + () => provider.RequestSdkAsync("nodejs", null, "bookworm")); + } + + [Fact] + public async Task ExternalAcrSdkProvider_RequestSdkAsync_ThrowsForNullDebianFlavor() + { + var provider = new ExternalAcrSdkProvider( + new DefaultStandardOutputWriter(), + NullLogger.Instance); + + await Assert.ThrowsAsync( + () => provider.RequestSdkAsync("nodejs", "1.0", null)); + } + + // --- Helpers --- + + private static AcrSdkProvider CreateProvider(HttpMessageHandler handler = null) + { + var options = Options.Create(new BuildScriptGeneratorOptions + { + OryxAcrSdkRegistryUrl = "https://test.azurecr.io", + DynamicInstallRootDir = "/tmp/oryx-test", + DebianFlavor = "bookworm", + }); + + var httpHandler = handler ?? new FailingHandler(); + var factory = new StubHttpClientFactory(httpHandler); + + var ociClient = new OciRegistryClient("https://test.azurecr.io", factory, NullLoggerFactory.Instance); + return new AcrSdkProvider( + new DefaultStandardOutputWriter(), + NullLogger.Instance, + options, + ociClient); + } + + private class StubHttpClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + + public StubHttpClientFactory(HttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) + { + return new HttpClient(_handler); + } + } + + /// + /// Handler that returns 404 for all requests, simulating missing images. + /// + private class FailingHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } + + /// + /// Handler that captures the manifest URL and returns a 404 for the HEAD request. + /// Used to verify tag construction without needing full OCI flow. + /// + private class TagCapturingHandler : HttpMessageHandler + { + public string LastManifestUrl { get; private set; } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri.ToString(); + + if (url.Contains("/manifests/")) + { + LastManifestUrl = url; + } + + // Return 404 to short-circuit — we only need to verify the URL was constructed correctly + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } + + /// + /// Handler that throws on all requests, simulating network failures. + /// + private class ThrowingHandler : HttpMessageHandler + { + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + throw new HttpRequestException("Simulated network failure"); + } + } + + /// + /// Handler that returns a specific status code for all requests. + /// + private class StatusCodeHandler : HttpMessageHandler + { + private readonly HttpStatusCode _statusCode; + + public StatusCodeHandler(HttpStatusCode statusCode) + { + _statusCode = statusCode; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + return Task.FromResult(new HttpResponseMessage(_statusCode)); + } + } + + /// + /// Handler that simulates a manifest with optional layer digest. + /// Returns digest on HEAD, manifest on GET, 404 on blob download. + /// + private class ManifestHandler : HttpMessageHandler + { + private readonly string _layerDigest; + + public ManifestHandler(string layerDigest) + { + _layerDigest = layerDigest; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri.ToString(); + + if (url.Contains("/manifests/") && request.Method == HttpMethod.Head) + { + var resp = new HttpResponseMessage(HttpStatusCode.OK); + resp.Headers.Add("Docker-Content-Digest", "sha256:testdigest"); + return Task.FromResult(resp); + } + + if (url.Contains("/manifests/") && request.Method == HttpMethod.Get) + { + var manifest = _layerDigest != null + ? $"{{\"schemaVersion\":2,\"layers\":[{{\"digest\":\"{_layerDigest}\",\"size\":100}}]}}" + : "{\"schemaVersion\":2,\"layers\":[]}"; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(manifest), + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } + } +} diff --git a/tests/BuildScriptGenerator.Tests/BuildScriptGenerator.Tests.csproj b/tests/BuildScriptGenerator.Tests/BuildScriptGenerator.Tests.csproj index 8a5f4bd900..5df219d0e5 100644 --- a/tests/BuildScriptGenerator.Tests/BuildScriptGenerator.Tests.csproj +++ b/tests/BuildScriptGenerator.Tests/BuildScriptGenerator.Tests.csproj @@ -16,8 +16,8 @@ - - + + diff --git a/tests/BuildScriptGenerator.Tests/DefaultBuildScriptGeneratorTest.cs b/tests/BuildScriptGenerator.Tests/DefaultBuildScriptGeneratorTest.cs index 1482d4f734..57f7e7d956 100644 --- a/tests/BuildScriptGenerator.Tests/DefaultBuildScriptGeneratorTest.cs +++ b/tests/BuildScriptGenerator.Tests/DefaultBuildScriptGeneratorTest.cs @@ -620,6 +620,7 @@ private DefaultBuildScriptGenerator CreateDefaultScriptGenerator( var defaultPlatformDetector = new DefaultPlatformsInformationProvider( platforms, new DefaultStandardOutputWriter(), + NullLogger.Instance, Options.Create(commonOptions)); var envScriptProvider = new BuildScriptGenerator.PlatformsInstallationScriptProvider( platforms, diff --git a/tests/BuildScriptGenerator.Tests/DefaultPlatformDetectorTest.cs b/tests/BuildScriptGenerator.Tests/DefaultPlatformDetectorTest.cs index 48112676c2..e5b26ee0b0 100644 --- a/tests/BuildScriptGenerator.Tests/DefaultPlatformDetectorTest.cs +++ b/tests/BuildScriptGenerator.Tests/DefaultPlatformDetectorTest.cs @@ -5,6 +5,8 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Oryx.Detector; using Moq; @@ -116,6 +118,7 @@ private DefaultPlatformsInformationProvider CreatePlatformDetector( return new DefaultPlatformsInformationProvider( platforms, new DefaultStandardOutputWriter(), + NullLogger.Instance, Options.Create(new BuildScriptGeneratorOptions())); } diff --git a/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCoreAcrVersionProviderTest.cs b/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCoreAcrVersionProviderTest.cs new file mode 100644 index 0000000000..c0703815b3 --- /dev/null +++ b/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCoreAcrVersionProviderTest.cs @@ -0,0 +1,201 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator.DotNetCore; +using Microsoft.Oryx.Tests.Common; +using Xunit; + +namespace Microsoft.Oryx.BuildScriptGenerator.Tests.DotnetCore +{ + public class DotNetCoreAcrVersionProviderTest + { + // --- Tag parsing --- + + [Fact] + public void GetSupportedVersions_ParsesCompoundTags_IntoVersionMap() + { + // Arrange: tags in format {os}-{sdkVersion}_{runtimeVersion} + var tags = new List + { + "bookworm-8.0.403_8.0.18", + "bookworm-9.0.100_9.0.0", + }; + + var provider = CreateProvider(tags, debianFlavor: "bookworm"); + + // Act + var versions = provider.GetSupportedVersions(); + + // Assert + Assert.NotNull(versions); + Assert.Equal(2, versions.Count); + Assert.Equal("8.0.403", versions["8.0.18"]); + Assert.Equal("9.0.100", versions["9.0.0"]); + } + + [Fact] + public void GetSupportedVersions_SkipsTagsWithWrongFlavor() + { + var tags = new List + { + "bookworm-8.0.403_8.0.18", + "noble-9.0.100_9.0.0", + }; + + var provider = CreateProvider(tags, debianFlavor: "bookworm"); + var versions = provider.GetSupportedVersions(); + + Assert.NotNull(versions); + Assert.Single(versions); + Assert.Equal("8.0.403", versions["8.0.18"]); + } + + [Fact] + public void GetSupportedVersions_SkipsMalformedTags_MissingUnderscore() + { + var tags = new List + { + "bookworm-8.0.403_8.0.18", + "bookworm-malformed-no-underscore", + }; + + var provider = CreateProvider(tags, debianFlavor: "bookworm"); + var versions = provider.GetSupportedVersions(); + + Assert.NotNull(versions); + Assert.Single(versions); + } + + [Fact] + public void GetSupportedVersions_ReturnsNull_WhenNoMatchingTags() + { + var tags = new List + { + "noble-8.0.403_8.0.18", + }; + + var provider = CreateProvider(tags, debianFlavor: "bookworm"); + var versions = provider.GetSupportedVersions(); + + // Returns null because the version map is empty (no tags match "bookworm" flavor) + Assert.Null(versions); + } + + [Fact] + public void GetSupportedVersions_HandlesDuplicateRuntimeKeys() + { + // When multiple tags map to the same runtime, the last one wins (Dictionary behavior) + var tags = new List + { + "bookworm-8.0.301_8.0.18", + "bookworm-8.0.403_8.0.18", + }; + + var provider = CreateProvider(tags, debianFlavor: "bookworm"); + var versions = provider.GetSupportedVersions(); + + Assert.NotNull(versions); + Assert.Single(versions); + Assert.Equal("8.0.403", versions["8.0.18"]); + } + + [Fact] + public void GetDefaultRuntimeVersion_ReturnsNull_WhenNoVersionMap() + { + var provider = CreateProvider(new List(), debianFlavor: "bookworm"); + var defaultVersion = provider.GetDefaultRuntimeVersion(); + Assert.Null(defaultVersion); + } + + // --- Helpers --- + + private static TestDotNetCoreAcrVersionProvider CreateProvider( + List tags, + string debianFlavor) + { + var options = Options.Create(new BuildScriptGeneratorOptions + { + DebianFlavor = debianFlavor, + OryxAcrSdkRegistryUrl = "https://test.azurecr.io", + }); + + var handler = new StubHttpMessageHandler(tags); + var httpClientFactory = new StubHttpClientFactory(handler); + var ociClient = new OciRegistryClient("https://test.azurecr.io", httpClientFactory, NullLoggerFactory.Instance); + + return new TestDotNetCoreAcrVersionProvider(options, ociClient, NullLoggerFactory.Instance); + } + + /// + /// Test subclass that overrides HTTP behavior to return predefined tags. + /// + private class TestDotNetCoreAcrVersionProvider : DotNetCoreAcrVersionProvider + { + public TestDotNetCoreAcrVersionProvider( + IOptions options, + OciRegistryClient ociClient, + ILoggerFactory loggerFactory) + : base(options, ociClient, loggerFactory) + { + } + } + + private class StubHttpClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + + public StubHttpClientFactory(HttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) + { + return new HttpClient(_handler); + } + } + + /// + /// Returns a tag list JSON response for any tags/list request. + /// Returns 404 for token endpoints (simulating MCR-style public access). + /// + private class StubHttpMessageHandler : HttpMessageHandler + { + private readonly List _tags; + + public StubHttpMessageHandler(List tags) + { + _tags = tags; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri.ToString(); + + if (url.Contains("/tags/list")) + { + var tagList = new OciTagList { Name = "test", Tags = _tags }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(tagList)), + }); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } + } +} diff --git a/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCoreBuildScriptGenerationTest.cs b/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCoreBuildScriptGenerationTest.cs index 3621abb421..f235e75793 100644 --- a/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCoreBuildScriptGenerationTest.cs +++ b/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCoreBuildScriptGenerationTest.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator; using Microsoft.Oryx.BuildScriptGenerator.DotNetCore; using Microsoft.Oryx.Detector.DotNetCore; using Xunit; @@ -94,12 +95,16 @@ private DotNetCorePlatform CreateDotNetCorePlatform( Options.Create(DotNetCoreScriptGeneratorOptions), Options.Create(commonOptions), versionProvider, + new DotNetCoreExternalAcrVersionProvider(Options.Create(new BuildScriptGeneratorOptions()), NullLoggerFactory.Instance, new DefaultStandardOutputWriter()), NullLogger.Instance, detector, DotNetCoreInstaller, globalJsonSdkResolver, - externalSdkProvider, - TelemetryClientHelper.GetTelemetryClient()); + externalSdkProvider, + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); } private class TestDotNetCorePlatform : DotNetCorePlatform @@ -108,14 +113,19 @@ public TestDotNetCorePlatform( IOptions DotNetCoreScriptGeneratorOptions, IOptions commonOptions, IDotNetCoreVersionProvider DotNetCoreVersionProvider, + DotNetCoreExternalAcrVersionProvider externalAcrVersionProvider, ILogger logger, IDotNetCorePlatformDetector detector, DotNetCorePlatformInstaller DotNetCoreInstaller, GlobalJsonSdkResolver globalJsonSdkResolver, IExternalSdkProvider externalSdkProvider, - TelemetryClient telemetryClient) + IExternalAcrSdkProvider externalAcrSdkProvider, + IAcrSdkProvider acrSdkProvider, + TelemetryClient telemetryClient, + IStandardOutputWriter outputWriter) : base( DotNetCoreVersionProvider, + externalAcrVersionProvider, logger, detector, commonOptions, @@ -123,7 +133,10 @@ public TestDotNetCorePlatform( DotNetCoreInstaller, globalJsonSdkResolver, externalSdkProvider, - telemetryClient) + externalAcrSdkProvider, + acrSdkProvider, + telemetryClient, + outputWriter) { } } diff --git a/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCorePlatformTest.cs b/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCorePlatformTest.cs index 328819275a..83a67e281e 100644 --- a/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCorePlatformTest.cs +++ b/tests/BuildScriptGenerator.Tests/DotnetCore/DotNetCorePlatformTest.cs @@ -1,12 +1,15 @@ -// -------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------- // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- using System.Collections.Generic; using Microsoft.ApplicationInsights; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator; +using Microsoft.Oryx.BuildScriptGenerator.Common; using Microsoft.Oryx.BuildScriptGenerator.DotNetCore; using Microsoft.Oryx.BuildScriptGenerator.Exceptions; using Microsoft.Oryx.Detector.DotNetCore; @@ -119,6 +122,304 @@ public void Detect_ReturnsExpectedVersion_BasedOnHierarchy( Assert.Equal(expectedSdkVersion, result.PlatformVersion); } + [Fact] + public void GetInstallerScript_UsesExternalAcrProvider_IfEnabled_AndSdkIsNotAlreadyInstalled() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: true); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var detector = CreateDetector(detectedVersion: "3.1"); + var platform = CreatePlatformWithProviders( + detector, + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider); + var context = CreateContext(); + var detectorResult = new DotNetCorePlatformDetectorResult + { + Platform = DotNetCoreConstants.PlatformName, + PlatformVersion = "3.1.2", + SdkVersion = "3.1.2", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.Equal(TestDotNetCorePlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, snippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + } + + [Fact] + public void GetInstallerScript_UsesDirectAcrProvider_IfEnabled_AndSdkIsNotAlreadyInstalled() + { + // Arrange + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var detector = CreateDetector(detectedVersion: "3.1"); + var platform = CreatePlatformWithProviders( + detector, + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + acrSdkProvider: acrSdkProvider); + var context = CreateContext(); + var detectorResult = new DotNetCorePlatformDetectorResult + { + Platform = DotNetCoreConstants.PlatformName, + PlatformVersion = "3.1.2", + SdkVersion = "3.1.2", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.Equal(TestDotNetCorePlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, snippet); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void GetInstallerScript_FallsBackFromExternalAcrToExternalSdk_WhenExternalAcrFails() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var detector = CreateDetector(detectedVersion: "3.1"); + var platform = CreatePlatformWithProviders( + detector, + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider); + var context = CreateContext(); + var detectorResult = new DotNetCorePlatformDetectorResult + { + Platform = DotNetCoreConstants.PlatformName, + PlatformVersion = "3.1.2", + SdkVersion = "3.1.2", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.Equal(TestDotNetCorePlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, snippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + } + + [Fact] + public void GetInstallerScript_FallsBackToCdn_WhenAllProvidersFail() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: false); + var externalSdkProvider = new TestExternalSdkProvider(requestBlobResult: false); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var detector = CreateDetector(detectedVersion: "3.1"); + var platform = CreatePlatformWithProviders( + detector, + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider, + acrSdkProvider: acrSdkProvider, + externalSdkProvider: externalSdkProvider); + var context = CreateContext(); + var detectorResult = new DotNetCorePlatformDetectorResult + { + Platform = DotNetCoreConstants.PlatformName, + PlatformVersion = "3.1.2", + SdkVersion = "3.1.2", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.Equal(TestDotNetCorePlatformInstaller.InstallerScript, snippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void GetInstallerScript_DirectAcrProvider_PassesRuntimeVersion() + { + // Arrange + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var detector = CreateDetector(detectedVersion: "3.1"); + var platform = CreatePlatformWithProviders( + detector, + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + acrSdkProvider: acrSdkProvider, + supportedVersions: new Dictionary + { + { "3.1.2", "3.1.2" }, + }); + var context = CreateContext(); + var detectorResult = new DotNetCorePlatformDetectorResult + { + Platform = DotNetCoreConstants.PlatformName, + PlatformVersion = "3.1.2", + SdkVersion = "3.1.2", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + Assert.Equal(DotNetCoreConstants.PlatformName, acrSdkProvider.LastRequestedPlatformName); + Assert.Equal("3.1.2", acrSdkProvider.LastRequestedVersion); + Assert.Equal(OsTypes.DebianBookworm, acrSdkProvider.LastRequestedDebianFlavor); + Assert.Equal("3.1.2", acrSdkProvider.LastRequestedRuntimeVersion); + } + + [Fact] + public void GetInstallerScript_FallsBackFromExternalAcrToDirectAcr_WhenBothExternalProvidersFail() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var externalSdkProvider = new TestExternalSdkProvider(requestBlobResult: false); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var detector = CreateDetector(detectedVersion: "3.1"); + var platform = CreatePlatformWithProviders( + detector, + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider, + acrSdkProvider: acrSdkProvider, + externalSdkProvider: externalSdkProvider); + var context = CreateContext(); + var detectorResult = new DotNetCorePlatformDetectorResult + { + Platform = DotNetCoreConstants.PlatformName, + PlatformVersion = "3.1.2", + SdkVersion = "3.1.2", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.Equal(TestDotNetCorePlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, snippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void ResolveVersions_UsesExternalAcrSdkVersion_WhenEnabled() + { + // Arrange + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var detector = CreateDetector(detectedVersion: "3.1"); + var externalAcrVersionProvider = new TestDotNetCoreExternalAcrVersionProvider( + Options.Create(commonOptions), NullLoggerFactory.Instance, new DefaultStandardOutputWriter(), + sdkVersion: "3.1.415"); + var platform = CreatePlatformWithExternalAcrVersionProvider( + detector, + commonOptions: commonOptions, + externalAcrVersionProvider: externalAcrVersionProvider, + supportedVersions: new Dictionary + { + { "3.1.2", "3.1.302" }, + }); + var context = CreateContext(); + var detectorResult = new DotNetCorePlatformDetectorResult + { + Platform = DotNetCoreConstants.PlatformName, + PlatformVersion = "3.1", + }; + + // Act + platform.ResolveVersions(context, detectorResult); + + // Assert - ExternalACR dictates SDK version, overriding normal map lookup + Assert.Equal("3.1.415", detectorResult.SdkVersion); + } + + [Fact] + public void ResolveVersions_FallsBackToVersionMap_WhenExternalAcrReturnsNull() + { + // Arrange + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var detector = CreateDetector(detectedVersion: "3.1"); + var externalAcrVersionProvider = new TestDotNetCoreExternalAcrVersionProvider( + Options.Create(commonOptions), NullLoggerFactory.Instance, new DefaultStandardOutputWriter(), + sdkVersion: null); + var platform = CreatePlatformWithExternalAcrVersionProvider( + detector, + commonOptions: commonOptions, + externalAcrVersionProvider: externalAcrVersionProvider, + supportedVersions: new Dictionary + { + { "3.1.2", "3.1.302" }, + }); + var context = CreateContext(); + var detectorResult = new DotNetCorePlatformDetectorResult + { + Platform = DotNetCoreConstants.PlatformName, + PlatformVersion = "3.1", + }; + + // Act + platform.ResolveVersions(context, detectorResult); + + // Assert - Falls back to normal version map + Assert.Equal("3.1.302", detectorResult.SdkVersion); + } + private BuildScriptGeneratorContext CreateContext(ISourceRepo sourceRepo = null) { sourceRepo = sourceRepo ?? new MemorySourceRepo(); @@ -160,28 +461,79 @@ private DotNetCorePlatform CreatePlatform( var globalJsonSdkResolver = new GlobalJsonSdkResolver(NullLogger.Instance); return new TestDotNetCorePlatform( versionProvider, + new DotNetCoreExternalAcrVersionProvider(Options.Create(new BuildScriptGeneratorOptions()), NullLoggerFactory.Instance, new DefaultStandardOutputWriter()), + detector, + Options.Create(commonOptions), + Options.Create(dotNetCoreScriptGeneratorOptions), + installer, + globalJsonSdkResolver, + externalSdkProvider, + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); + } + + private DotNetCorePlatform CreatePlatformWithProviders( + IDotNetCorePlatformDetector detector, + BuildScriptGeneratorOptions commonOptions = null, + bool sdkAlreadyInstalled = true, + Dictionary supportedVersions = null, + IExternalAcrSdkProvider externalAcrSdkProvider = null, + IAcrSdkProvider acrSdkProvider = null, + IExternalSdkProvider externalSdkProvider = null) + { + commonOptions = commonOptions ?? new BuildScriptGeneratorOptions(); + var defaultVersion = DotNetCoreRunTimeVersions.NetCoreApp31; + supportedVersions = supportedVersions ?? new Dictionary + { + { defaultVersion, defaultVersion }, + }; + var versionProvider = new TestDotNetCoreVersionProvider( + supportedVersions, + defaultVersion); + externalSdkProvider = externalSdkProvider ?? new TestExternalSdkProvider(); + externalAcrSdkProvider = externalAcrSdkProvider ?? new TestExternalAcrSdkProvider(); + acrSdkProvider = acrSdkProvider ?? new TestAcrSdkProvider(); + var dotNetCoreScriptGeneratorOptions = new DotNetCoreScriptGeneratorOptions(); + var installer = new TestDotNetCorePlatformInstaller( + Options.Create(commonOptions), + sdkAlreadyInstalled, + NullLoggerFactory.Instance); + var globalJsonSdkResolver = new GlobalJsonSdkResolver(NullLogger.Instance); + return new TestDotNetCorePlatform( + versionProvider, + new DotNetCoreExternalAcrVersionProvider(Options.Create(new BuildScriptGeneratorOptions()), NullLoggerFactory.Instance, new DefaultStandardOutputWriter()), detector, Options.Create(commonOptions), Options.Create(dotNetCoreScriptGeneratorOptions), installer, globalJsonSdkResolver, externalSdkProvider, - TelemetryClientHelper.GetTelemetryClient()); + externalAcrSdkProvider, + acrSdkProvider, + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); } private class TestDotNetCorePlatform : DotNetCorePlatform { public TestDotNetCorePlatform( IDotNetCoreVersionProvider versionProvider, + DotNetCoreExternalAcrVersionProvider externalAcrVersionProvider, IDotNetCorePlatformDetector detector, IOptions cliOptions, IOptions dotNetCoreScriptGeneratorOptions, DotNetCorePlatformInstaller platformInstaller, GlobalJsonSdkResolver globalJsonSdkResolver, IExternalSdkProvider externalSdkProvider, - TelemetryClient telemetryClient) + IExternalAcrSdkProvider externalAcrSdkProvider, + IAcrSdkProvider acrSdkProvider, + TelemetryClient telemetryClient, + IStandardOutputWriter outputWriter) : base( versionProvider, + externalAcrVersionProvider, NullLogger.Instance, detector, cliOptions, @@ -189,9 +541,100 @@ public TestDotNetCorePlatform( platformInstaller, globalJsonSdkResolver, externalSdkProvider, - telemetryClient) + externalAcrSdkProvider, + acrSdkProvider, + telemetryClient, + outputWriter) + { + } + } + + private class TestDotNetCorePlatformInstaller : DotNetCorePlatformInstaller + { + public static string InstallerScript = "installer-script-snippet"; + public static string InstallerScriptWithSkipSdkBinaryDownload = "installer-script-snippet-with-skip-sdk-binary-download"; + private readonly bool _sdkIsAlreadyInstalled; + + public TestDotNetCorePlatformInstaller( + IOptions cliOptions, + bool sdkIsAlreadyInstalled, + ILoggerFactory loggerFactory) + : base(cliOptions, loggerFactory) + { + _sdkIsAlreadyInstalled = sdkIsAlreadyInstalled; + } + + public override bool IsVersionAlreadyInstalled(string version) + { + return _sdkIsAlreadyInstalled; + } + + public override string GetInstallerScriptSnippet(string version, bool skipSdkBinaryDownload = false) + { + if (skipSdkBinaryDownload) + { + return InstallerScriptWithSkipSdkBinaryDownload; + } + + return InstallerScript; + } + } + + private class TestDotNetCoreExternalAcrVersionProvider : DotNetCoreExternalAcrVersionProvider + { + private readonly string _sdkVersion; + + public TestDotNetCoreExternalAcrVersionProvider( + IOptions options, + ILoggerFactory loggerFactory, + IStandardOutputWriter outputWriter, + string sdkVersion) + : base(options, loggerFactory, outputWriter) + { + _sdkVersion = sdkVersion; + } + + public override string GetSdkVersion() { + return _sdkVersion; } } + + private DotNetCorePlatform CreatePlatformWithExternalAcrVersionProvider( + IDotNetCorePlatformDetector detector, + BuildScriptGeneratorOptions commonOptions = null, + TestDotNetCoreExternalAcrVersionProvider externalAcrVersionProvider = null, + Dictionary supportedVersions = null) + { + commonOptions = commonOptions ?? new BuildScriptGeneratorOptions(); + var defaultVersion = DotNetCoreRunTimeVersions.NetCoreApp31; + supportedVersions = supportedVersions ?? new Dictionary + { + { defaultVersion, defaultVersion }, + }; + var versionProvider = new TestDotNetCoreVersionProvider( + supportedVersions, + defaultVersion); + externalAcrVersionProvider = externalAcrVersionProvider ?? new TestDotNetCoreExternalAcrVersionProvider( + Options.Create(commonOptions), NullLoggerFactory.Instance, new DefaultStandardOutputWriter(), sdkVersion: null); + var dotNetCoreScriptGeneratorOptions = new DotNetCoreScriptGeneratorOptions(); + var installer = new DotNetCorePlatformInstaller( + Options.Create(commonOptions), + NullLoggerFactory.Instance); + var globalJsonSdkResolver = new GlobalJsonSdkResolver(NullLogger.Instance); + return new TestDotNetCorePlatform( + versionProvider, + externalAcrVersionProvider, + detector, + Options.Create(commonOptions), + Options.Create(dotNetCoreScriptGeneratorOptions), + installer, + globalJsonSdkResolver, + new TestExternalSdkProvider(), + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); + } } } diff --git a/tests/BuildScriptGenerator.Tests/Node/NodeBuildScriptGenerationTest.cs b/tests/BuildScriptGenerator.Tests/Node/NodeBuildScriptGenerationTest.cs index 347d020f85..5a412f92e0 100644 --- a/tests/BuildScriptGenerator.Tests/Node/NodeBuildScriptGenerationTest.cs +++ b/tests/BuildScriptGenerator.Tests/Node/NodeBuildScriptGenerationTest.cs @@ -984,7 +984,10 @@ private static IProgrammingPlatform GetNodePlatform( new TestEnvironment(), new NodePlatformInstaller(Options.Create(commonOptions), NullLoggerFactory.Instance), externalSdkProvider, - TelemetryClientHelper.GetTelemetryClient()); + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); } private static BuildScriptGeneratorContext CreateScriptGeneratorContext(ISourceRepo sourceRepo) diff --git a/tests/BuildScriptGenerator.Tests/Node/NodePlatformTest.cs b/tests/BuildScriptGenerator.Tests/Node/NodePlatformTest.cs index c28e3d0ce9..a1015eea8b 100644 --- a/tests/BuildScriptGenerator.Tests/Node/NodePlatformTest.cs +++ b/tests/BuildScriptGenerator.Tests/Node/NodePlatformTest.cs @@ -452,6 +452,186 @@ public void BuildScript_UsesExternalProvider_IfDynamicInstallAndExternalProvider Assert.Equal(TestNodePlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, buildScriptSnippet); } + [Fact] + public void BuildScript_UsesExternalAcrProvider_IfEnabled_AndSdkIsNotAlreadyInstalled() + { + // Arrange + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: true); + var nodePlatform = CreateNodePlatformWithProviders( + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile(string.Empty, NodeConstants.PackageJsonFileName); + var context = CreateContext(repo); + var detectorResult = new NodePlatformDetectorResult + { + Platform = NodeConstants.PlatformName, + PlatformVersion = "20.14.0", + }; + + // Act + var buildScriptSnippet = nodePlatform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(buildScriptSnippet); + Assert.Equal(TestNodePlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, buildScriptSnippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + } + + [Fact] + public void BuildScript_UsesDirectAcrProvider_IfEnabled_AndSdkIsNotAlreadyInstalled() + { + // Arrange + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var nodePlatform = CreateNodePlatformWithProviders( + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + acrSdkProvider: acrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile(string.Empty, NodeConstants.PackageJsonFileName); + var context = CreateContext(repo); + var detectorResult = new NodePlatformDetectorResult + { + Platform = NodeConstants.PlatformName, + PlatformVersion = "20.14.0", + }; + + // Act + var buildScriptSnippet = nodePlatform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(buildScriptSnippet); + Assert.Equal(TestNodePlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, buildScriptSnippet); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void BuildScript_FallsBackFromExternalAcrToExternalSdk_WhenExternalAcrFails() + { + // Arrange + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var nodePlatform = CreateNodePlatformWithProviders( + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile(string.Empty, NodeConstants.PackageJsonFileName); + var context = CreateContext(repo); + var detectorResult = new NodePlatformDetectorResult + { + Platform = NodeConstants.PlatformName, + PlatformVersion = "20.14.0", + }; + + // Act + var buildScriptSnippet = nodePlatform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(buildScriptSnippet); + Assert.Equal(TestNodePlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, buildScriptSnippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + } + + [Fact] + public void BuildScript_FallsBackFromExternalAcrToDirectAcr_WhenBothExternalProvidersFail() + { + // Arrange + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var externalSdkProvider = new TestExternalSdkProvider(requestBlobResult: false); + var nodePlatform = CreateNodePlatformWithProviders( + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider, + acrSdkProvider: acrSdkProvider, + externalSdkProvider: externalSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile(string.Empty, NodeConstants.PackageJsonFileName); + var context = CreateContext(repo); + var detectorResult = new NodePlatformDetectorResult + { + Platform = NodeConstants.PlatformName, + PlatformVersion = "20.14.0", + }; + + // Act + var buildScriptSnippet = nodePlatform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(buildScriptSnippet); + Assert.Equal(TestNodePlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, buildScriptSnippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void BuildScript_FallsBackToCdn_WhenAllProvidersFail() + { + // Arrange + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: false); + var externalSdkProvider = new TestExternalSdkProvider(requestBlobResult: false); + var nodePlatform = CreateNodePlatformWithProviders( + commonOptions: commonOptions, + sdkAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider, + acrSdkProvider: acrSdkProvider, + externalSdkProvider: externalSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile(string.Empty, NodeConstants.PackageJsonFileName); + var context = CreateContext(repo); + var detectorResult = new NodePlatformDetectorResult + { + Platform = NodeConstants.PlatformName, + PlatformVersion = "20.14.0", + }; + + // Act + var buildScriptSnippet = nodePlatform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(buildScriptSnippet); + Assert.Equal(TestNodePlatformInstaller.InstallerScript, buildScriptSnippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + [Fact] public void BuildScript_HasNoSdkInstallScript_IfDynamicInstallIsEnabled_AndSdkIsAlreadyInstalled() { @@ -1000,6 +1180,33 @@ public void Detect_ReturnsResult_WithDefaultVersion_ForSourceRepoOnlyWithAppJs_A Assert.Equal(expectedVersion, result.PlatformVersion); } + [Fact] + public void Detect_ReturnsVersionProviderDefault_WhenExternalAcrEnabled_OverridingDetectedVersion() + { + // Arrange + var detectedVersion = "14.0.0"; + var versionProviderDefault = "18.0.0"; + var commonOptions = new BuildScriptGeneratorOptions + { + EnableExternalAcrSdkProvider = true, + }; + var platform = CreateNodePlatform( + supportedNodeVersions: new[] { detectedVersion, versionProviderDefault }, + defaultVersion: versionProviderDefault, + detectedVersion: detectedVersion, + commonOptions: commonOptions); + var context = CreateContext(); + + // Act + var result = platform.Detect(context); + + // Assert - ExternalACR short-circuit uses version provider's DefaultVersion, + // overriding the detected version + Assert.NotNull(result); + Assert.Equal(NodeConstants.PlatformName, result.Platform); + Assert.Equal(versionProviderDefault, result.PlatformVersion); + } + private TestNodePlatform CreateNodePlatform( string[] supportedNodeVersions = null, string defaultVersion = null, @@ -1025,7 +1232,10 @@ private TestNodePlatform CreateNodePlatform( environment, platformInstaller, externalSdkProvider, - TelemetryClientHelper.GetTelemetryClient()); + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); } private TestNodePlatform CreateNodePlatform( @@ -1049,7 +1259,10 @@ private TestNodePlatform CreateNodePlatform( environment, platformInstaller, externalSdkProvider, - TelemetryClientHelper.GetTelemetryClient()); + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); } private TestNodePlatform CreateNodePlatform( @@ -1077,7 +1290,44 @@ private TestNodePlatform CreateNodePlatform( environment, installer, externalSdkProvider, - TelemetryClientHelper.GetTelemetryClient()); + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); + } + + private TestNodePlatform CreateNodePlatformWithProviders( + BuildScriptGeneratorOptions commonOptions, + bool sdkAlreadyInstalled, + IExternalAcrSdkProvider externalAcrSdkProvider = null, + IAcrSdkProvider acrSdkProvider = null, + IExternalSdkProvider externalSdkProvider = null) + { + commonOptions.EnableDynamicInstall = true; + var environment = new TestEnvironment(); + var installer = new TestNodePlatformInstaller( + Options.Create(commonOptions), + sdkAlreadyInstalled, + NullLoggerFactory.Instance); + var versionProvider = new TestNodeVersionProvider(); + externalSdkProvider = externalSdkProvider ?? new TestExternalSdkProvider(); + externalAcrSdkProvider = externalAcrSdkProvider ?? new TestExternalAcrSdkProvider(); + acrSdkProvider = acrSdkProvider ?? new TestAcrSdkProvider(); + var nodeScriptGeneratorOptions = new NodeScriptGeneratorOptions(); + var detector = new TestNodePlatformDetector(); + return new TestNodePlatform( + Options.Create(commonOptions), + Options.Create(nodeScriptGeneratorOptions), + versionProvider, + NullLogger.Instance, + detector, + environment, + installer, + externalSdkProvider, + externalAcrSdkProvider, + acrSdkProvider, + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); } private BuildScriptGeneratorContext CreateContext(ISourceRepo sourceRepo = null) @@ -1101,7 +1351,10 @@ public TestNodePlatform( IEnvironment environment, NodePlatformInstaller nodePlatformInstaller, IExternalSdkProvider externalSdkProvider, - TelemetryClient telemetryClient) + IExternalAcrSdkProvider externalAcrSdkProvider, + IAcrSdkProvider acrSdkProvider, + TelemetryClient telemetryClient, + IStandardOutputWriter outputWriter) : base( cliOptions, nodeScriptGeneratorOptions, @@ -1111,7 +1364,10 @@ public TestNodePlatform( environment, nodePlatformInstaller, externalSdkProvider, - telemetryClient) + externalAcrSdkProvider, + acrSdkProvider, + telemetryClient, + outputWriter) { } } diff --git a/tests/BuildScriptGenerator.Tests/Node/NodeVersionProviderTest.cs b/tests/BuildScriptGenerator.Tests/Node/NodeVersionProviderTest.cs index f3080bc8c8..e5313a9f90 100644 --- a/tests/BuildScriptGenerator.Tests/Node/NodeVersionProviderTest.cs +++ b/tests/BuildScriptGenerator.Tests/Node/NodeVersionProviderTest.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------- // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator; using Microsoft.Oryx.BuildScriptGenerator.Node; using Microsoft.Oryx.Tests.Common; using Xunit; @@ -79,6 +80,215 @@ public void GetsVersions_UsesExternalVersionProvider_WhenExternalProviderAndDyna Assert.False(onDiskVersionProvider.GetVersionInfoCalled); } + [Fact] + public void GetsVersions_UsesExternalAcrProvider_WhenExternalAcrProviderAndDynamicInstallEnabled() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + Assert.False(result.OnDiskVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_UsesAcrProvider_WhenAcrProviderAndDynamicInstallEnabled() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableAcrSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + Assert.False(result.OnDiskVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_ExternalAcrTakesPriority_OverExternalSdk() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_ExternalAcrTakesPriority_OverDirectAcr() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableAcrSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToStorage_WhenExternalAcrReturnsNull() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + externalAcrReturnsNull: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToStorage_WhenAcrProviderThrows() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableAcrSdkProvider: true, + acrThrowsException: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToDirectAcr_WhenExternalAcrAndExternalSdkReturnNull() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true, + enableAcrSdkProvider: true, + externalAcrReturnsNull: true, + externalSdkReturnsNull: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToCdn_WhenAllProvidersReturnNull() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true, + enableAcrSdkProvider: true, + externalAcrReturnsNull: true, + externalSdkReturnsNull: true, + acrThrowsException: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToExternalSdk_WhenExternalAcrThrows() + { + // Arrange — ExternalACR throws, should fall to ExternalSDK + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true, + externalAcrThrowsException: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.False(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToDirectAcr_WhenExternalAcrAndExternalSdkThrow() + { + // Arrange — both external providers throw, should fall to direct ACR + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true, + enableAcrSdkProvider: true, + externalAcrThrowsException: true, + externalSdkThrowsException: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_CachesResult_OnSecondCall() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableAcrSdkProvider: true); + + // Act — call twice + var versionInfo1 = result.VersionProvider.GetVersionInfo(); + var versionInfo2 = result.VersionProvider.GetVersionInfo(); + + // Assert — same instance returned (cached) + Assert.Same(versionInfo1, versionInfo2); + } + private class TestNodeSdkStorageVersionProvider : NodeSdkStorageVersionProvider { public TestNodeSdkStorageVersionProvider( @@ -99,10 +309,16 @@ public override PlatformVersionInfo GetVersionInfo() private class TestNodeExternalVersionProvider : NodeExternalVersionProvider { + private readonly bool _returnsNull; + private readonly bool _throwsException; + public TestNodeExternalVersionProvider( - IOptions commonOptions, IExternalSdkProvider externalProvider, ILoggerFactory loggerFactory) + IOptions commonOptions, IExternalSdkProvider externalProvider, ILoggerFactory loggerFactory, + bool returnsNull = false, bool throwsException = false) : base(commonOptions, externalProvider, loggerFactory) { + _returnsNull = returnsNull; + _throwsException = throwsException; } public bool GetVersionInfoCalled { get; private set; } @@ -110,7 +326,18 @@ public TestNodeExternalVersionProvider( public override PlatformVersionInfo GetVersionInfo() { GetVersionInfoCalled = true; - return null; + if (_throwsException) + { + throw new System.Exception("External SDK provider simulated failure"); + } + + if (_returnsNull) + { + return null; + } + + return PlatformVersionInfo.CreateAvailableViaExternalProvider( + new[] { "1.0.0" }, "1.0.0"); } } @@ -137,7 +364,10 @@ public override PlatformVersionInfo GetVersionInfo() onDiskProvider, storageProvider, externalProvider, - NullLogger.Instance); + new NodeExternalAcrVersionProvider(Options.Create(new BuildScriptGeneratorOptions()), NullLoggerFactory.Instance, new DefaultStandardOutputWriter()), + new NodeAcrVersionProvider(commonOptions, new OciRegistryClient("https://test.azurecr.io", new TestHttpClientFactory(), NullLoggerFactory.Instance), NullLoggerFactory.Instance), + NullLogger.Instance, + new DefaultStandardOutputWriter()); return (versionProvider, onDiskProvider, storageProvider, externalProvider); } @@ -157,5 +387,148 @@ public override PlatformVersionInfo GetVersionInfo() return null; } } + + private class TestNodeExternalAcrVersionProvider : NodeExternalAcrVersionProvider + { + private readonly bool _returnsNull; + private readonly bool _throwsException; + + public TestNodeExternalAcrVersionProvider( + IOptions options, + ILoggerFactory loggerFactory, + IStandardOutputWriter outputWriter, + bool returnsNull = false, + bool throwsException = false) + : base(options, loggerFactory, outputWriter) + { + _returnsNull = returnsNull; + _throwsException = throwsException; + } + + public bool GetVersionInfoCalled { get; private set; } + + public override PlatformVersionInfo GetVersionInfo() + { + GetVersionInfoCalled = true; + if (_throwsException) + { + throw new System.Exception("External ACR provider simulated failure"); + } + + if (_returnsNull) + { + return null; + } + + return PlatformVersionInfo.CreateAvailableOnAcr( + new[] { "18.0.0" }, "18.0.0"); + } + } + + private class TestNodeAcrVersionProvider : NodeAcrVersionProvider + { + private readonly bool _throwsException; + + public TestNodeAcrVersionProvider( + IOptions commonOptions, + OciRegistryClient ociClient, + ILoggerFactory loggerFactory, + bool throwsException = false) + : base(commonOptions, ociClient, loggerFactory) + { + _throwsException = throwsException; + } + + public bool GetVersionInfoCalled { get; private set; } + + public override PlatformVersionInfo GetVersionInfo() + { + GetVersionInfoCalled = true; + if (_throwsException) + { + throw new System.Exception("ACR provider simulated failure"); + } + + return PlatformVersionInfo.CreateAvailableOnAcr( + new[] { "18.0.0" }, "18.0.0"); + } + } + + private class VersionProviderResult + { + public INodeVersionProvider VersionProvider { get; set; } + + public TestNodeOnDiskVersionProvider OnDiskVersionProvider { get; set; } + + public TestNodeSdkStorageVersionProvider StorageVersionProvider { get; set; } + + public TestNodeExternalVersionProvider ExternalVersionProvider { get; set; } + + public TestNodeExternalAcrVersionProvider ExternalAcrVersionProvider { get; set; } + + public TestNodeAcrVersionProvider AcrVersionProvider { get; set; } + } + + private VersionProviderResult CreateVersionProviderWithAcr( + bool enableDynamicInstall, + bool enableExternalSdkProvider = false, + bool enableExternalAcrSdkProvider = false, + bool enableAcrSdkProvider = false, + bool externalAcrReturnsNull = false, + bool externalSdkReturnsNull = false, + bool acrThrowsException = false, + bool externalAcrThrowsException = false, + bool externalSdkThrowsException = false) + { + var commonOptions = Options.Create(new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = enableDynamicInstall, + EnableExternalSdkProvider = enableExternalSdkProvider, + EnableExternalAcrSdkProvider = enableExternalAcrSdkProvider, + EnableAcrSdkProvider = enableAcrSdkProvider, + }); + + var onDiskProvider = new TestNodeOnDiskVersionProvider(commonOptions); + var storageProvider = new TestNodeSdkStorageVersionProvider( + commonOptions, + new TestHttpClientFactory(), + NullLoggerFactory.Instance); + var externalProvider = new TestNodeExternalVersionProvider( + commonOptions, + new TestExternalSdkProvider(), + NullLoggerFactory.Instance, + returnsNull: externalSdkReturnsNull, + throwsException: externalSdkThrowsException); + var externalAcrProvider = new TestNodeExternalAcrVersionProvider( + commonOptions, + NullLoggerFactory.Instance, + new DefaultStandardOutputWriter(), + returnsNull: externalAcrReturnsNull, + throwsException: externalAcrThrowsException); + var acrProvider = new TestNodeAcrVersionProvider( + commonOptions, + new OciRegistryClient("https://test.azurecr.io", new TestHttpClientFactory(), NullLoggerFactory.Instance), + NullLoggerFactory.Instance, + throwsException: acrThrowsException); + var versionProvider = new NodeVersionProvider( + commonOptions, + onDiskProvider, + storageProvider, + externalProvider, + externalAcrProvider, + acrProvider, + NullLogger.Instance, + new DefaultStandardOutputWriter()); + + return new VersionProviderResult + { + VersionProvider = versionProvider, + OnDiskVersionProvider = onDiskProvider, + StorageVersionProvider = storageProvider, + ExternalVersionProvider = externalProvider, + ExternalAcrVersionProvider = externalAcrProvider, + AcrVersionProvider = acrProvider, + }; + } } } diff --git a/tests/BuildScriptGenerator.Tests/OciRegistryClientTest.cs b/tests/BuildScriptGenerator.Tests/OciRegistryClientTest.cs new file mode 100644 index 0000000000..b98ac67ee0 --- /dev/null +++ b/tests/BuildScriptGenerator.Tests/OciRegistryClientTest.cs @@ -0,0 +1,403 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.Collections.Generic; +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.Oryx.BuildScriptGenerator.Tests +{ + public class OciRegistryClientTest + { + // --- Constructor validation --- + + [Fact] + public void Constructor_ThrowsForEmptyRegistryUrl() + { + var ex = Assert.Throws( + () => new OciRegistryClient(string.Empty, new MockHttpClientFactory(), NullLoggerFactory.Instance)); + Assert.Contains("Registry URL", ex.Message); + } + + [Fact] + public void Constructor_ThrowsForNonHttpsUrl() + { + var ex = Assert.Throws( + () => new OciRegistryClient("http://insecure.io", new MockHttpClientFactory(), NullLoggerFactory.Instance)); + Assert.Contains("HTTPS", ex.Message); + } + + [Fact] + public void Constructor_AcceptsValidHttpsUrl() + { + var client = new OciRegistryClient( + "https://myregistry.azurecr.io", + new MockHttpClientFactory(), + NullLoggerFactory.Instance); + Assert.NotNull(client); + } + + // --- GetFirstLayerDigest --- + + [Fact] + public void GetFirstLayerDigest_ReturnsNull_WhenManifestIsNull() + { + Assert.Null(OciRegistryClient.GetFirstLayerDigest(null)); + } + + [Fact] + public void GetFirstLayerDigest_ReturnsNull_WhenLayersAreEmpty() + { + var manifest = new OciManifest { Layers = new List() }; + Assert.Null(OciRegistryClient.GetFirstLayerDigest(manifest)); + } + + [Fact] + public void GetFirstLayerDigest_ReturnsNull_WhenLayersAreNull() + { + var manifest = new OciManifest { Layers = null }; + Assert.Null(OciRegistryClient.GetFirstLayerDigest(manifest)); + } + + [Fact] + public void GetFirstLayerDigest_ReturnsFirstDigest() + { + var manifest = new OciManifest + { + Layers = new List + { + new OciDescriptor { Digest = "sha256:abc123" }, + new OciDescriptor { Digest = "sha256:def456" }, + }, + }; + Assert.Equal("sha256:abc123", OciRegistryClient.GetFirstLayerDigest(manifest)); + } + + // --- GetAllTagsAsync --- + + [Fact] + public async Task GetAllTagsAsync_ReturnsTags_FromSinglePage() + { + var tagList = new OciTagList { Name = "test/repo", Tags = new List { "v1", "v2" } }; + var handler = new MockHttpMessageHandler(); + handler.AddResponse( + "https://myregistry.azurecr.io/v2/test/repo/tags/list", + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(tagList)), + }); + + var client = CreateClient(handler); + var tags = await client.GetAllTagsAsync("test/repo"); + Assert.Equal(new List { "v1", "v2" }, tags); + } + + [Fact] + public async Task GetAllTagsAsync_HandlesPagination() + { + var page1 = new OciTagList { Name = "test/repo", Tags = new List { "v1" } }; + var page2 = new OciTagList { Name = "test/repo", Tags = new List { "v2" } }; + + var handler = new MockHttpMessageHandler(); + var resp1 = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(page1)), + }; + resp1.Headers.Add("Link", "; rel=\"next\""); + handler.AddResponse("https://myregistry.azurecr.io/v2/test/repo/tags/list", resp1); + handler.AddResponse( + "https://myregistry.azurecr.io/v2/test/repo/tags/list?last=v1", + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(page2)), + }); + + var client = CreateClient(handler); + var tags = await client.GetAllTagsAsync("test/repo"); + Assert.Equal(new List { "v1", "v2" }, tags); + } + + [Fact] + public async Task GetAllTagsAsync_ThrowsOnFailure() + { + var handler = new MockHttpMessageHandler(); + handler.AddResponse( + "https://myregistry.azurecr.io/v2/test/repo/tags/list", + new HttpResponseMessage(HttpStatusCode.NotFound)); + + var client = CreateClient(handler); + await Assert.ThrowsAsync(() => client.GetAllTagsAsync("test/repo")); + } + + // --- GetManifestAsync --- + + [Fact] + public async Task GetManifestAsync_ParsesManifest() + { + var manifest = new OciManifest + { + SchemaVersion = 2, + Layers = new List + { + new OciDescriptor { Digest = "sha256:layer1", Size = 1234 }, + }, + }; + + var handler = new MockHttpMessageHandler(); + handler.AddResponse( + "https://myregistry.azurecr.io/v2/repo/manifests/latest", + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(manifest)), + }); + + var client = CreateClient(handler); + var result = await client.GetManifestAsync("repo", "latest"); + Assert.NotNull(result); + Assert.Equal(2, result.SchemaVersion); + Assert.Single(result.Layers); + Assert.Equal("sha256:layer1", result.Layers[0].Digest); + } + + // --- Token TTL --- + + [Fact] + public async Task GetAllTagsAsync_UsesTokenFromCache_WhenNotExpired() + { + var tagList = new OciTagList { Tags = new List { "v1" } }; + var registryHost = "custom.azurecr.io"; + var baseUrl = $"https://{registryHost}"; + + var handler = new MockHttpMessageHandler(); + // Token endpoint returns token with 600s lifetime + handler.AddResponse( + $"{baseUrl}/oauth2/token", + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"access_token\":\"tok1\",\"expires_in\":600}"), + }); + // First tags request + handler.AddResponse( + $"{baseUrl}/v2/repo/tags/list", + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(tagList)), + }); + + var client = CreateClient(handler, registryHost: registryHost); + + // First call — acquires token + await client.GetAllTagsAsync("repo"); + int tokenCallsAfterFirst = handler.GetCallCount($"{baseUrl}/oauth2/token"); + + // Second tags response + handler.AddResponse( + $"{baseUrl}/v2/repo/tags/list", + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(tagList)), + }); + + // Second call — should use cached token + await client.GetAllTagsAsync("repo"); + int tokenCallsAfterSecond = handler.GetCallCount($"{baseUrl}/oauth2/token"); + + // Token endpoint should only have been called once + Assert.Equal(tokenCallsAfterFirst, tokenCallsAfterSecond); + } + + // --- GetManifestDigestAsync --- + + [Fact] + public async Task GetManifestDigestAsync_ReturnsDigest() + { + var handler = new MockHttpMessageHandler(); + var resp = new HttpResponseMessage(HttpStatusCode.OK); + resp.Headers.Add("Docker-Content-Digest", "sha256:abc123"); + handler.AddResponse("https://myregistry.azurecr.io/v2/repo/manifests/v1", resp); + + var client = CreateClient(handler); + var digest = await client.GetManifestDigestAsync("repo", "v1"); + Assert.Equal("sha256:abc123", digest); + } + + [Fact] + public async Task GetManifestDigestAsync_ReturnsNull_OnFailure() + { + var handler = new MockHttpMessageHandler(); + handler.AddResponse( + "https://myregistry.azurecr.io/v2/repo/manifests/v1", + new HttpResponseMessage(HttpStatusCode.NotFound)); + + var client = CreateClient(handler); + var digest = await client.GetManifestDigestAsync("repo", "v1"); + Assert.Null(digest); + } + + // --- Link header host validation --- + + [Fact] + public async Task GetAllTagsAsync_IgnoresLinkHeader_WhenHostDoesNotMatchRegistry() + { + var tagList = new OciTagList { Name = "test/repo", Tags = new List { "v1" } }; + var handler = new MockHttpMessageHandler(); + var resp = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(tagList)), + }; + // Link header points to a different host + resp.Headers.Add("Link", "; rel=\"next\""); + handler.AddResponse("https://myregistry.azurecr.io/v2/test/repo/tags/list", resp); + + var client = CreateClient(handler); + var tags = await client.GetAllTagsAsync("test/repo"); + + // Should only return page 1 tags — the malicious Link was ignored + Assert.Equal(new List { "v1" }, tags); + } + + [Fact] + public async Task GetAllTagsAsync_FollowsRelativeLink() + { + var page1 = new OciTagList { Name = "test/repo", Tags = new List { "v1" } }; + var page2 = new OciTagList { Name = "test/repo", Tags = new List { "v2" } }; + + var handler = new MockHttpMessageHandler(); + var resp1 = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(page1)), + }; + // Relative link (no host) — should be prefixed with registry URL + resp1.Headers.Add("Link", "; rel=\"next\""); + handler.AddResponse("https://myregistry.azurecr.io/v2/test/repo/tags/list", resp1); + handler.AddResponse( + "https://myregistry.azurecr.io/v2/test/repo/tags/list?last=v1", + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(page2)), + }); + + var client = CreateClient(handler); + var tags = await client.GetAllTagsAsync("test/repo"); + Assert.Equal(new List { "v1", "v2" }, tags); + } + + // --- MCR skip-auth --- + + [Fact] + public async Task GetAllTagsAsync_SkipsAuth_ForMcrRegistry() + { + var tagList = new OciTagList { Name = "oryx/nodejs-sdk", Tags = new List { "bookworm-20.19.3" } }; + var handler = new MockHttpMessageHandler(); + handler.AddResponse( + "https://mcr.microsoft.com/v2/oryx/nodejs-sdk/tags/list", + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(JsonSerializer.Serialize(tagList)), + }); + + var client = CreateClient(handler, registryHost: "mcr.microsoft.com"); + var tags = await client.GetAllTagsAsync("oryx/nodejs-sdk"); + + // Should succeed without calling token endpoint + Assert.Equal(new List { "bookworm-20.19.3" }, tags); + Assert.Equal(0, handler.GetCallCount("https://mcr.microsoft.com/oauth2/token")); + } + + // --- Helpers --- + + private static OciRegistryClient CreateClient( + MockHttpMessageHandler handler, + string registryHost = "myregistry.azurecr.io") + { + var url = $"https://{registryHost}"; + var factory = new MockHttpClientFactory(handler); + return new OciRegistryClient(url, factory, NullLoggerFactory.Instance); + } + + private class MockHttpClientFactory : IHttpClientFactory + { + private readonly HttpMessageHandler _handler; + + public MockHttpClientFactory() + { + _handler = new MockHttpMessageHandler(); + } + + public MockHttpClientFactory(HttpMessageHandler handler) + { + _handler = handler; + } + + public HttpClient CreateClient(string name) + { + return new HttpClient(_handler); + } + } + + private class MockHttpMessageHandler : HttpMessageHandler + { + private readonly Dictionary> _responses + = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + private readonly Dictionary _callCounts + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + public void AddResponse(string urlPrefix, HttpResponseMessage response) + { + if (!_responses.ContainsKey(urlPrefix)) + { + _responses[urlPrefix] = new Queue(); + } + + _responses[urlPrefix].Enqueue(response); + } + + public int GetCallCount(string urlPrefix) + { + foreach (var kvp in _callCounts) + { + if (kvp.Key.StartsWith(urlPrefix, StringComparison.OrdinalIgnoreCase)) + { + return kvp.Value; + } + } + + return 0; + } + + protected override Task SendAsync( + HttpRequestMessage request, CancellationToken cancellationToken) + { + var url = request.RequestUri.ToString(); + + // Track call counts + if (!_callCounts.ContainsKey(url)) + { + _callCounts[url] = 0; + } + + _callCounts[url]++; + + // Find matching response by URL prefix + foreach (var kvp in _responses) + { + if (url.StartsWith(kvp.Key, StringComparison.OrdinalIgnoreCase) && kvp.Value.Count > 0) + { + return Task.FromResult(kvp.Value.Dequeue()); + } + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NotFound)); + } + } + } +} diff --git a/tests/BuildScriptGenerator.Tests/Php/PhpPlatformTest.cs b/tests/BuildScriptGenerator.Tests/Php/PhpPlatformTest.cs index 57f06f0c92..ab3aefa6d0 100644 --- a/tests/BuildScriptGenerator.Tests/Php/PhpPlatformTest.cs +++ b/tests/BuildScriptGenerator.Tests/Php/PhpPlatformTest.cs @@ -520,6 +520,401 @@ public void HasPhpAndComposerInstallScript_IfDynamicInstallIsEnabled_AndPhpAndCo Assert.Contains(TestPhpComposerInstaller.InstallerScript, actualScriptSnippet); } + [Fact] + public void PhpInstallViaExternalAcrProvider_IfEnabled_AndPhpVersionIsNotAlreadyInstalled() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: true); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var phpPlatform = CreatePhpPlatformWithProviders( + commonOptions: commonOptions, + isPhpVersionAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("{}", PhpConstants.ComposerFileName); + var context = CreateContext(repo); + var detectedResult = new PhpPlatformDetectorResult + { + Platform = PhpConstants.PlatformName, + PlatformVersion = "7.3.5", + }; + + // Act + var actualScriptSnippet = phpPlatform.GetInstallerScriptSnippet(context, detectedResult); + + // Assert + Assert.NotNull(actualScriptSnippet); + Assert.Contains(TestPhpPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + } + + [Fact] + public void PhpInstallViaDirectAcrProvider_IfEnabled_AndPhpVersionIsNotAlreadyInstalled() + { + // Arrange + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var phpPlatform = CreatePhpPlatformWithProviders( + commonOptions: commonOptions, + isPhpVersionAlreadyInstalled: false, + acrSdkProvider: acrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("{}", PhpConstants.ComposerFileName); + var context = CreateContext(repo); + var detectedResult = new PhpPlatformDetectorResult + { + Platform = PhpConstants.PlatformName, + PlatformVersion = "7.3.5", + }; + + // Act + var actualScriptSnippet = phpPlatform.GetInstallerScriptSnippet(context, detectedResult); + + // Assert + Assert.NotNull(actualScriptSnippet); + Assert.Contains(TestPhpPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void PhpFallsBackFromExternalAcrToExternalSdk_WhenExternalAcrFails() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var phpPlatform = CreatePhpPlatformWithProviders( + commonOptions: commonOptions, + isPhpVersionAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("{}", PhpConstants.ComposerFileName); + var context = CreateContext(repo); + var detectedResult = new PhpPlatformDetectorResult + { + Platform = PhpConstants.PlatformName, + PlatformVersion = "7.3.5", + }; + + // Act + var actualScriptSnippet = phpPlatform.GetInstallerScriptSnippet(context, detectedResult); + + // Assert + Assert.NotNull(actualScriptSnippet); + Assert.Contains(TestPhpPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + } + + [Fact] + public void PhpFallsBackToCdn_WhenAllProvidersFail() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: false); + var externalSdkProvider = new TestExternalSdkProvider(requestBlobResult: false); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var phpPlatform = CreatePhpPlatformWithProviders( + commonOptions: commonOptions, + isPhpVersionAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider, + acrSdkProvider: acrSdkProvider, + externalSdkProvider: externalSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("{}", PhpConstants.ComposerFileName); + var context = CreateContext(repo); + var detectedResult = new PhpPlatformDetectorResult + { + Platform = PhpConstants.PlatformName, + PlatformVersion = "7.3.5", + }; + + // Act + var actualScriptSnippet = phpPlatform.GetInstallerScriptSnippet(context, detectedResult); + + // Assert + Assert.NotNull(actualScriptSnippet); + Assert.Contains(TestPhpPlatformInstaller.InstallerScript, actualScriptSnippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void PhpFallsBackFromExternalAcrToDirectAcr_WhenBothExternalProvidersFail() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var externalSdkProvider = new TestExternalSdkProvider(requestBlobResult: false); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var phpPlatform = CreatePhpPlatformWithProviders( + commonOptions: commonOptions, + isPhpVersionAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider, + acrSdkProvider: acrSdkProvider, + externalSdkProvider: externalSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("{}", PhpConstants.ComposerFileName); + var context = CreateContext(repo); + var detectedResult = new PhpPlatformDetectorResult + { + Platform = PhpConstants.PlatformName, + PlatformVersion = "7.3.5", + }; + + // Act + var actualScriptSnippet = phpPlatform.GetInstallerScriptSnippet(context, detectedResult); + + // Assert + Assert.NotNull(actualScriptSnippet); + Assert.Contains(TestPhpPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void ComposerInstallViaExternalAcrProvider_IfEnabled_AndComposerVersionIsNotAlreadyInstalled() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: true); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var phpPlatform = CreatePhpPlatformWithProviders( + commonOptions: commonOptions, + isPhpVersionAlreadyInstalled: false, + isPhpComposerAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("{}", PhpConstants.ComposerFileName); + var context = CreateContext(repo); + var detectedResult = new PhpPlatformDetectorResult + { + Platform = PhpConstants.PlatformName, + PlatformVersion = "7.3.5", + }; + + // Act + var actualScriptSnippet = phpPlatform.GetInstallerScriptSnippet(context, detectedResult); + + // Assert + Assert.NotNull(actualScriptSnippet); + Assert.Contains(TestPhpPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + Assert.Contains(TestPhpComposerInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + } + + [Fact] + public void ComposerInstallViaDirectAcrProvider_IfEnabled_AndComposerVersionIsNotAlreadyInstalled() + { + // Arrange + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var phpPlatform = CreatePhpPlatformWithProviders( + commonOptions: commonOptions, + isPhpVersionAlreadyInstalled: false, + isPhpComposerAlreadyInstalled: false, + acrSdkProvider: acrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("{}", PhpConstants.ComposerFileName); + var context = CreateContext(repo); + var detectedResult = new PhpPlatformDetectorResult + { + Platform = PhpConstants.PlatformName, + PlatformVersion = "7.3.5", + }; + + // Act + var actualScriptSnippet = phpPlatform.GetInstallerScriptSnippet(context, detectedResult); + + // Assert + Assert.NotNull(actualScriptSnippet); + Assert.Contains(TestPhpPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + Assert.Contains(TestPhpComposerInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + } + + [Fact] + public void ComposerFallsBackToCdn_WhenAllProvidersFail() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: false); + var externalSdkProvider = new TestExternalSdkProvider(requestBlobResult: false); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var phpPlatform = CreatePhpPlatformWithProviders( + commonOptions: commonOptions, + isPhpVersionAlreadyInstalled: false, + isPhpComposerAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider, + acrSdkProvider: acrSdkProvider, + externalSdkProvider: externalSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("{}", PhpConstants.ComposerFileName); + var context = CreateContext(repo); + var detectedResult = new PhpPlatformDetectorResult + { + Platform = PhpConstants.PlatformName, + PlatformVersion = "7.3.5", + }; + + // Act + var actualScriptSnippet = phpPlatform.GetInstallerScriptSnippet(context, detectedResult); + + // Assert + Assert.NotNull(actualScriptSnippet); + Assert.Contains(TestPhpPlatformInstaller.InstallerScript, actualScriptSnippet); + Assert.Contains(TestPhpComposerInstaller.InstallerScript, actualScriptSnippet); + } + + [Fact] + public void ComposerFallsBackFromExternalAcrToExternalSdk_WhenExternalAcrFails() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var phpPlatform = CreatePhpPlatformWithProviders( + commonOptions: commonOptions, + isPhpVersionAlreadyInstalled: false, + isPhpComposerAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("{}", PhpConstants.ComposerFileName); + var context = CreateContext(repo); + var detectedResult = new PhpPlatformDetectorResult + { + Platform = PhpConstants.PlatformName, + PlatformVersion = "7.3.5", + }; + + // Act + var actualScriptSnippet = phpPlatform.GetInstallerScriptSnippet(context, detectedResult); + + // Assert - Both PHP and Composer should fall from ExternalACR to ExternalSDK + Assert.NotNull(actualScriptSnippet); + Assert.Contains(TestPhpPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + Assert.Contains(TestPhpComposerInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + } + + [Fact] + public void ComposerFallsBackFromExternalAcrToDirectAcr_WhenBothExternalProvidersFail() + { + // Arrange + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var externalSdkProvider = new TestExternalSdkProvider(requestBlobResult: false); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var phpPlatform = CreatePhpPlatformWithProviders( + commonOptions: commonOptions, + isPhpVersionAlreadyInstalled: false, + isPhpComposerAlreadyInstalled: false, + externalAcrSdkProvider: externalAcrSdkProvider, + acrSdkProvider: acrSdkProvider, + externalSdkProvider: externalSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("{}", PhpConstants.ComposerFileName); + var context = CreateContext(repo); + var detectedResult = new PhpPlatformDetectorResult + { + Platform = PhpConstants.PlatformName, + PlatformVersion = "7.3.5", + }; + + // Act + var actualScriptSnippet = phpPlatform.GetInstallerScriptSnippet(context, detectedResult); + + // Assert - Both PHP and Composer should fall from ExternalACR+ExternalSDK to DirectACR + Assert.NotNull(actualScriptSnippet); + Assert.Contains(TestPhpPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + Assert.Contains(TestPhpComposerInstaller.InstallerScriptWithSkipSdkBinaryDownload, actualScriptSnippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void Detect_ReturnsVersionProviderDefault_WhenExternalAcrEnabled_OverridingDetectedVersion() + { + // Arrange + var detectedVersion = "7.3.5"; + var versionProviderDefault = "8.1.0"; + var repo = new MemorySourceRepo(); + repo.AddFile("", "foo.php"); + var context = CreateContext(repo); + var commonOptions = new BuildScriptGeneratorOptions + { + EnableExternalAcrSdkProvider = true, + }; + var platform = CreatePhpPlatform( + supportedPhpVersions: new[] { detectedVersion, versionProviderDefault }, + defaultVersion: versionProviderDefault, + detectedVersion: detectedVersion, + commonOptions: commonOptions); + + // Act + var result = platform.Detect(context); + + // Assert - ExternalACR short-circuit uses version provider's DefaultVersion, + // overriding the detected version + Assert.NotNull(result); + Assert.Equal(PhpConstants.PlatformName, result.Platform); + Assert.Equal(versionProviderDefault, result.PlatformVersion); + } + [Theory] [InlineData(null, "7.4.30", null, "7.4.30")] [InlineData(null, "7.4.30", "7.3.1", "7.4.30")] @@ -591,7 +986,52 @@ private PhpPlatform CreatePhpPlatform( phpInstaller, phpComposerInstaller, externalSdkProvider, - TelemetryClientHelper.GetTelemetryClient()); + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); + } + + private PhpPlatform CreatePhpPlatformWithProviders( + BuildScriptGeneratorOptions commonOptions = null, + bool? isPhpVersionAlreadyInstalled = null, + bool? isPhpComposerAlreadyInstalled = null, + IExternalAcrSdkProvider externalAcrSdkProvider = null, + IAcrSdkProvider acrSdkProvider = null, + IExternalSdkProvider externalSdkProvider = null) + { + commonOptions = commonOptions ?? new BuildScriptGeneratorOptions(); + var phpScriptGeneratorOptions = new PhpScriptGeneratorOptions(); + isPhpVersionAlreadyInstalled = isPhpVersionAlreadyInstalled ?? true; + isPhpComposerAlreadyInstalled = isPhpComposerAlreadyInstalled ?? true; + var versionProvider = new TestPhpVersionProvider(null, null); + externalSdkProvider = externalSdkProvider ?? new TestExternalSdkProvider(); + externalAcrSdkProvider = externalAcrSdkProvider ?? new TestExternalAcrSdkProvider(); + acrSdkProvider = acrSdkProvider ?? new TestAcrSdkProvider(); + var composerVersionProvider = new TestPhpComposerVersionProvider( + new[] { PhpVersions.ComposerDefaultVersion }, + PhpVersions.ComposerDefaultVersion); + var detector = new TestPhpPlatformDetector(detectedVersion: null); + var phpInstaller = new TestPhpPlatformInstaller( + Options.Create(commonOptions), + isPhpVersionAlreadyInstalled.Value); + var phpComposerInstaller = new TestPhpComposerInstaller( + Options.Create(commonOptions), + isPhpComposerAlreadyInstalled.Value); + return new TestPhpPlatform( + Options.Create(phpScriptGeneratorOptions), + Options.Create(commonOptions), + versionProvider, + composerVersionProvider, + NullLogger.Instance, + detector, + phpInstaller, + phpComposerInstaller, + externalSdkProvider, + externalAcrSdkProvider, + acrSdkProvider, + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); } private BuildScriptGeneratorContext CreateContext(ISourceRepo sourceRepo = null) @@ -616,7 +1056,10 @@ public TestPhpPlatform( PhpPlatformInstaller phpInstaller, PhpComposerInstaller phpComposerInstaller, IExternalSdkProvider externalSdkProvider, - TelemetryClient telemetryClient) + IExternalAcrSdkProvider externalAcrSdkProvider, + IAcrSdkProvider acrSdkProvider, + TelemetryClient telemetryClient, + IStandardOutputWriter outputWriter) : base( phpScriptGeneratorOptions, commonOptions, @@ -627,7 +1070,10 @@ public TestPhpPlatform( phpInstaller, phpComposerInstaller, externalSdkProvider, - telemetryClient) + externalAcrSdkProvider, + acrSdkProvider, + telemetryClient, + outputWriter) { } } diff --git a/tests/BuildScriptGenerator.Tests/Php/PhpScriptGeneratorTest.cs b/tests/BuildScriptGenerator.Tests/Php/PhpScriptGeneratorTest.cs index 958a40ea0a..96ba448803 100644 --- a/tests/BuildScriptGenerator.Tests/Php/PhpScriptGeneratorTest.cs +++ b/tests/BuildScriptGenerator.Tests/Php/PhpScriptGeneratorTest.cs @@ -146,7 +146,10 @@ private IProgrammingPlatform GetScriptGenerator( phpInstaller: null, phpComposerInstaller: null, externalSdkProvider, - TelemetryClientHelper.GetTelemetryClient()); + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); } private static BuildScriptGeneratorContext CreateBuildScriptGeneratorContext(ISourceRepo sourceRepo) diff --git a/tests/BuildScriptGenerator.Tests/Php/PhpVersionProviderTest.cs b/tests/BuildScriptGenerator.Tests/Php/PhpVersionProviderTest.cs index fe082faa6a..6f40781af4 100644 --- a/tests/BuildScriptGenerator.Tests/Php/PhpVersionProviderTest.cs +++ b/tests/BuildScriptGenerator.Tests/Php/PhpVersionProviderTest.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------- // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator; using Microsoft.Oryx.BuildScriptGenerator.Php; using Microsoft.Oryx.Tests.Common; using Xunit; @@ -79,6 +80,157 @@ public void GetsVersions_UsesExternalVersionProvider_WhenExternalProviderAndDyna Assert.False(onDiskVersionProvider.GetVersionInfoCalled); } + [Fact] + public void GetsVersions_UsesExternalAcrProvider_WhenExternalAcrProviderAndDynamicInstallEnabled() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + Assert.False(result.OnDiskVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_UsesAcrProvider_WhenAcrProviderAndDynamicInstallEnabled() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableAcrSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + Assert.False(result.OnDiskVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_ExternalAcrTakesPriority_OverExternalSdk() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_ExternalAcrTakesPriority_OverDirectAcr() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableAcrSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToStorage_WhenExternalAcrReturnsNull() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + externalAcrReturnsNull: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToStorage_WhenAcrProviderThrows() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableAcrSdkProvider: true, + acrThrowsException: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToDirectAcr_WhenExternalAcrAndExternalSdkReturnNull() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true, + enableAcrSdkProvider: true, + externalAcrReturnsNull: true, + externalSdkReturnsNull: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToCdn_WhenAllProvidersReturnNull() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true, + enableAcrSdkProvider: true, + externalAcrReturnsNull: true, + externalSdkReturnsNull: true, + acrThrowsException: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.StorageVersionProvider.GetVersionInfoCalled); + } + private class TestPhpSdkStorageVersionProvider : PhpSdkStorageVersionProvider { public TestPhpSdkStorageVersionProvider( @@ -101,10 +253,14 @@ public override PlatformVersionInfo GetVersionInfo() private class TestPhpExternalVersionProvider : PhpExternalVersionProvider { + private readonly bool _returnsNull; + public TestPhpExternalVersionProvider( - IOptions commonOptions, IExternalSdkProvider externalProvider, ILoggerFactory loggerFactory) + IOptions commonOptions, IExternalSdkProvider externalProvider, ILoggerFactory loggerFactory, + bool returnsNull = false) : base(commonOptions, externalProvider, loggerFactory) { + _returnsNull = returnsNull; } public bool GetVersionInfoCalled { get; private set; } @@ -112,8 +268,13 @@ public TestPhpExternalVersionProvider( public override PlatformVersionInfo GetVersionInfo() { GetVersionInfoCalled = true; + if (_returnsNull) + { + return null; + } - return null; + return PlatformVersionInfo.CreateAvailableViaExternalProvider( + new[] { "1.0.0" }, "1.0.0"); } } @@ -140,7 +301,10 @@ public override PlatformVersionInfo GetVersionInfo() onDiskProvider, storageProvider, externalProvider, - NullLogger.Instance); + new PhpExternalAcrVersionProvider(Options.Create(new BuildScriptGeneratorOptions()), NullLoggerFactory.Instance, new DefaultStandardOutputWriter()), + new PhpAcrVersionProvider(commonOptions, new OciRegistryClient("https://test.azurecr.io", new TestHttpClientFactory(), NullLoggerFactory.Instance), NullLoggerFactory.Instance), + NullLogger.Instance, + new DefaultStandardOutputWriter()); return (versionProvider, onDiskProvider, storageProvider, externalProvider); } @@ -160,5 +324,136 @@ public override PlatformVersionInfo GetVersionInfo() return null; } } + + private class TestPhpExternalAcrVersionProvider : PhpExternalAcrVersionProvider + { + private readonly bool _returnsNull; + + public TestPhpExternalAcrVersionProvider( + IOptions options, + ILoggerFactory loggerFactory, + IStandardOutputWriter outputWriter, + bool returnsNull = false) + : base(options, loggerFactory, outputWriter) + { + _returnsNull = returnsNull; + } + + public bool GetVersionInfoCalled { get; private set; } + + public override PlatformVersionInfo GetVersionInfo() + { + GetVersionInfoCalled = true; + if (_returnsNull) + { + return null; + } + + return PlatformVersionInfo.CreateAvailableOnAcr( + new[] { "8.1.0" }, "8.1.0"); + } + } + + private class TestPhpAcrVersionProvider : PhpAcrVersionProvider + { + private readonly bool _throwsException; + + public TestPhpAcrVersionProvider( + IOptions commonOptions, + OciRegistryClient ociClient, + ILoggerFactory loggerFactory, + bool throwsException = false) + : base(commonOptions, ociClient, loggerFactory) + { + _throwsException = throwsException; + } + + public bool GetVersionInfoCalled { get; private set; } + + public override PlatformVersionInfo GetVersionInfo() + { + GetVersionInfoCalled = true; + if (_throwsException) + { + throw new System.Exception("ACR provider simulated failure"); + } + + return PlatformVersionInfo.CreateAvailableOnAcr( + new[] { "8.1.0" }, "8.1.0"); + } + } + + private class VersionProviderResult + { + public IPhpVersionProvider VersionProvider { get; set; } + + public TestPhpOnDiskVersionProvider OnDiskVersionProvider { get; set; } + + public TestPhpSdkStorageVersionProvider StorageVersionProvider { get; set; } + + public TestPhpExternalVersionProvider ExternalVersionProvider { get; set; } + + public TestPhpExternalAcrVersionProvider ExternalAcrVersionProvider { get; set; } + + public TestPhpAcrVersionProvider AcrVersionProvider { get; set; } + } + + private VersionProviderResult CreateVersionProviderWithAcr( + bool enableDynamicInstall, + bool enableExternalSdkProvider = false, + bool enableExternalAcrSdkProvider = false, + bool enableAcrSdkProvider = false, + bool externalAcrReturnsNull = false, + bool externalSdkReturnsNull = false, + bool acrThrowsException = false) + { + var commonOptions = Options.Create(new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = enableDynamicInstall, + EnableExternalSdkProvider = enableExternalSdkProvider, + EnableExternalAcrSdkProvider = enableExternalAcrSdkProvider, + EnableAcrSdkProvider = enableAcrSdkProvider, + }); + + var onDiskProvider = new TestPhpOnDiskVersionProvider(); + var storageProvider = new TestPhpSdkStorageVersionProvider( + commonOptions, + new TestHttpClientFactory(), + NullLoggerFactory.Instance); + var externalProvider = new TestPhpExternalVersionProvider( + commonOptions, + new TestExternalSdkProvider(), + NullLoggerFactory.Instance, + returnsNull: externalSdkReturnsNull); + var externalAcrProvider = new TestPhpExternalAcrVersionProvider( + commonOptions, + NullLoggerFactory.Instance, + new DefaultStandardOutputWriter(), + returnsNull: externalAcrReturnsNull); + var acrProvider = new TestPhpAcrVersionProvider( + commonOptions, + new OciRegistryClient("https://test.azurecr.io", new TestHttpClientFactory(), NullLoggerFactory.Instance), + NullLoggerFactory.Instance, + throwsException: acrThrowsException); + var versionProvider = new PhpVersionProvider( + commonOptions, + onDiskProvider, + storageProvider, + externalProvider, + externalAcrProvider, + acrProvider, + NullLogger.Instance, + new DefaultStandardOutputWriter()); + + return new VersionProviderResult + { + VersionProvider = versionProvider, + OnDiskVersionProvider = onDiskProvider, + StorageVersionProvider = storageProvider, + ExternalVersionProvider = externalProvider, + ExternalAcrVersionProvider = externalAcrProvider, + AcrVersionProvider = acrProvider, + }; + } } } diff --git a/tests/BuildScriptGenerator.Tests/PlatformsInstallationScriptProviderTest.cs b/tests/BuildScriptGenerator.Tests/PlatformsInstallationScriptProviderTest.cs index 5b5e430293..22f90c27dc 100644 --- a/tests/BuildScriptGenerator.Tests/PlatformsInstallationScriptProviderTest.cs +++ b/tests/BuildScriptGenerator.Tests/PlatformsInstallationScriptProviderTest.cs @@ -4,6 +4,8 @@ // -------------------------------------------------------------------------------------------- using System.Collections.Generic; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Microsoft.Oryx.Detector; using Microsoft.Oryx.Tests.Common; @@ -146,6 +148,7 @@ private PlatformsInstallationScriptProvider CreateEnvironmentSetupScriptProvider var platformDetector = new DefaultPlatformsInformationProvider( platforms, new DefaultStandardOutputWriter(), + NullLogger.Instance, Options.Create(new BuildScriptGeneratorOptions())); return new PlatformsInstallationScriptProvider( platforms, diff --git a/tests/BuildScriptGenerator.Tests/Python/PythonPlatformTests.cs b/tests/BuildScriptGenerator.Tests/Python/PythonPlatformTests.cs index f068c1a5ba..b074dc2e7c 100644 --- a/tests/BuildScriptGenerator.Tests/Python/PythonPlatformTests.cs +++ b/tests/BuildScriptGenerator.Tests/Python/PythonPlatformTests.cs @@ -141,6 +141,231 @@ public void UsesExternalProvider_IfDynamicInstallAndExternalProviderEnabled_AndS Assert.Equal(TestPythonPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, snippet); } + [Fact] + public void UsesExternalAcrProvider_IfEnabled_AndSdkIsNotAlreadyInstalled() + { + // Arrange + var pythonScriptGeneratorOptions = new PythonScriptGeneratorOptions(); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var versionProvider = new TestPythonVersionProvider(new[] { "3.7.5", "3.8.0" }, defaultVersion: "3.7.5"); + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: true); + var platformInstaller = new TestPythonPlatformInstaller( + isVersionAlreadyInstalled: false, + Options.Create(commonOptions), + NullLoggerFactory.Instance); + var platform = CreatePlatformWithProviders( + versionProvider, + platformInstaller, + commonOptions, + pythonScriptGeneratorOptions, + externalAcrSdkProvider: externalAcrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("", PythonConstants.RequirementsFileName); + repo.AddFile("print(1)", "bla.py"); + var context = new BuildScriptGeneratorContext { SourceRepo = repo }; + var detectorResult = new PythonPlatformDetectorResult + { + Platform = PythonConstants.PlatformName, + PlatformVersion = "3.7.5", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.Equal(TestPythonPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, snippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + } + + [Fact] + public void UsesDirectAcrProvider_IfEnabled_AndSdkIsNotAlreadyInstalled() + { + // Arrange + var pythonScriptGeneratorOptions = new PythonScriptGeneratorOptions(); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var versionProvider = new TestPythonVersionProvider(new[] { "3.7.5", "3.8.0" }, defaultVersion: "3.7.5"); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var platformInstaller = new TestPythonPlatformInstaller( + isVersionAlreadyInstalled: false, + Options.Create(commonOptions), + NullLoggerFactory.Instance); + var platform = CreatePlatformWithProviders( + versionProvider, + platformInstaller, + commonOptions, + pythonScriptGeneratorOptions, + acrSdkProvider: acrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("", PythonConstants.RequirementsFileName); + repo.AddFile("print(1)", "bla.py"); + var context = new BuildScriptGeneratorContext { SourceRepo = repo }; + var detectorResult = new PythonPlatformDetectorResult + { + Platform = PythonConstants.PlatformName, + PlatformVersion = "3.7.5", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.Equal(TestPythonPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, snippet); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void FallsBackFromExternalAcrToExternalSdk_WhenExternalAcrFails() + { + // Arrange + var pythonScriptGeneratorOptions = new PythonScriptGeneratorOptions(); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var versionProvider = new TestPythonVersionProvider(new[] { "3.7.5", "3.8.0" }, defaultVersion: "3.7.5"); + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var platformInstaller = new TestPythonPlatformInstaller( + isVersionAlreadyInstalled: false, + Options.Create(commonOptions), + NullLoggerFactory.Instance); + var platform = CreatePlatformWithProviders( + versionProvider, + platformInstaller, + commonOptions, + pythonScriptGeneratorOptions, + externalAcrSdkProvider: externalAcrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("", PythonConstants.RequirementsFileName); + repo.AddFile("print(1)", "bla.py"); + var context = new BuildScriptGeneratorContext { SourceRepo = repo }; + var detectorResult = new PythonPlatformDetectorResult + { + Platform = PythonConstants.PlatformName, + PlatformVersion = "3.7.5", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.Equal(TestPythonPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, snippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + } + + [Fact] + public void FallsBackFromExternalAcrToDirectAcr_WhenBothExternalProvidersFail() + { + // Arrange + var pythonScriptGeneratorOptions = new PythonScriptGeneratorOptions(); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var versionProvider = new TestPythonVersionProvider(new[] { "3.7.5", "3.8.0" }, defaultVersion: "3.7.5"); + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: true); + var externalSdkProvider = new TestExternalSdkProvider(requestBlobResult: false); + var platformInstaller = new TestPythonPlatformInstaller( + isVersionAlreadyInstalled: false, + Options.Create(commonOptions), + NullLoggerFactory.Instance); + var platform = CreatePlatformWithProviders( + versionProvider, + platformInstaller, + commonOptions, + pythonScriptGeneratorOptions, + externalSdkProvider: externalSdkProvider, + externalAcrSdkProvider: externalAcrSdkProvider, + acrSdkProvider: acrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("", PythonConstants.RequirementsFileName); + repo.AddFile("print(1)", "bla.py"); + var context = new BuildScriptGeneratorContext { SourceRepo = repo }; + var detectorResult = new PythonPlatformDetectorResult + { + Platform = PythonConstants.PlatformName, + PlatformVersion = "3.7.5", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.Equal(TestPythonPlatformInstaller.InstallerScriptWithSkipSdkBinaryDownload, snippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + + [Fact] + public void FallsBackToCdn_WhenAllProvidersFail() + { + // Arrange + var pythonScriptGeneratorOptions = new PythonScriptGeneratorOptions(); + var commonOptions = new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = true, + EnableExternalAcrSdkProvider = true, + EnableExternalSdkProvider = true, + EnableAcrSdkProvider = true, + DebianFlavor = OsTypes.DebianBookworm, + }; + var versionProvider = new TestPythonVersionProvider(new[] { "3.7.5", "3.8.0" }, defaultVersion: "3.7.5"); + var externalAcrSdkProvider = new TestExternalAcrSdkProvider(returnValue: false); + var acrSdkProvider = new TestAcrSdkProvider(returnValue: false); + var externalSdkProvider = new TestExternalSdkProvider(requestBlobResult: false); + var platformInstaller = new TestPythonPlatformInstaller( + isVersionAlreadyInstalled: false, + Options.Create(commonOptions), + NullLoggerFactory.Instance); + var platform = CreatePlatformWithProviders( + versionProvider, + platformInstaller, + commonOptions, + pythonScriptGeneratorOptions, + externalSdkProvider: externalSdkProvider, + externalAcrSdkProvider: externalAcrSdkProvider, + acrSdkProvider: acrSdkProvider); + var repo = new MemorySourceRepo(); + repo.AddFile("", PythonConstants.RequirementsFileName); + repo.AddFile("print(1)", "bla.py"); + var context = new BuildScriptGeneratorContext { SourceRepo = repo }; + var detectorResult = new PythonPlatformDetectorResult + { + Platform = PythonConstants.PlatformName, + PlatformVersion = "3.7.5", + }; + + // Act + var snippet = platform.GetInstallerScriptSnippet(context, detectorResult); + + // Assert + Assert.NotNull(snippet); + Assert.Equal(TestPythonPlatformInstaller.InstallerScript, snippet); + Assert.True(externalAcrSdkProvider.RequestSdkAsyncCalled); + Assert.True(acrSdkProvider.RequestSdkFromAcrAsyncCalled); + } + [Fact] public void GeneratedSnippet_DoesNotHaveInstallScript_IfVersionIsAlreadyPresentOnDisk() { @@ -416,6 +641,33 @@ public void Detect_ReturnsExpectedVersion_BasedOnHierarchy( Assert.Equal(PythonConstants.PlatformName, result.Platform); Assert.Equal(expectedSdkVersion, result.PlatformVersion); } + + [Fact] + public void Detect_ReturnsVersionProviderDefault_WhenExternalAcrEnabled_OverridingDetectedVersion() + { + // Arrange + var detectedVersion = "3.8.0"; + var versionProviderDefault = "3.10.0"; + var commonOptions = new BuildScriptGeneratorOptions + { + EnableExternalAcrSdkProvider = true, + }; + var platform = CreatePlatform( + supportedVersions: new[] { detectedVersion, versionProviderDefault }, + defaultVersion: versionProviderDefault, + detectedVersion: detectedVersion, + commonOptions: commonOptions); + var context = CreateContext(); + + // Act + var result = platform.Detect(context); + + // Assert - ExternalACR short-circuit uses version provider's DefaultVersion, + // overriding the detected version + Assert.NotNull(result); + Assert.Equal(PythonConstants.PlatformName, result.Platform); + Assert.Equal(versionProviderDefault, result.PlatformVersion); + } private PythonPlatform CreatePlatform( IPythonVersionProvider pythonVersionProvider, IExternalSdkProvider externalSdkProvider, @@ -433,7 +685,38 @@ private PythonPlatform CreatePlatform( detector: null, platformInstaller, externalSdkProvider, - TelemetryClientHelper.GetTelemetryClient()); + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); + } + + private PythonPlatform CreatePlatformWithProviders( + IPythonVersionProvider pythonVersionProvider, + PythonPlatformInstaller platformInstaller, + BuildScriptGeneratorOptions commonOptions = null, + PythonScriptGeneratorOptions pythonScriptGeneratorOptions = null, + IExternalSdkProvider externalSdkProvider = null, + IExternalAcrSdkProvider externalAcrSdkProvider = null, + IAcrSdkProvider acrSdkProvider = null) + { + commonOptions = commonOptions ?? new BuildScriptGeneratorOptions(); + pythonScriptGeneratorOptions = pythonScriptGeneratorOptions ?? new PythonScriptGeneratorOptions(); + externalSdkProvider = externalSdkProvider ?? new TestExternalSdkProvider(); + externalAcrSdkProvider = externalAcrSdkProvider ?? new TestExternalAcrSdkProvider(); + acrSdkProvider = acrSdkProvider ?? new TestAcrSdkProvider(); + return new PythonPlatform( + Options.Create(commonOptions), + Options.Create(pythonScriptGeneratorOptions), + pythonVersionProvider, + NullLogger.Instance, + detector: null, + platformInstaller, + externalSdkProvider, + externalAcrSdkProvider, + acrSdkProvider, + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); } private PythonPlatform CreatePlatform( @@ -460,7 +743,10 @@ private PythonPlatform CreatePlatform( detector, new PythonPlatformInstaller(Options.Create(commonOptions), NullLoggerFactory.Instance), externalSdkProvider, - TelemetryClientHelper.GetTelemetryClient()); + new TestExternalAcrSdkProvider(), + new TestAcrSdkProvider(), + TelemetryClientHelper.GetTelemetryClient(), + new DefaultStandardOutputWriter()); } private BuildScriptGeneratorContext CreateContext(ISourceRepo sourceRepo = null) diff --git a/tests/BuildScriptGenerator.Tests/Python/PythonVersionProviderTest.cs b/tests/BuildScriptGenerator.Tests/Python/PythonVersionProviderTest.cs index 800a2b8b2c..210be2207f 100644 --- a/tests/BuildScriptGenerator.Tests/Python/PythonVersionProviderTest.cs +++ b/tests/BuildScriptGenerator.Tests/Python/PythonVersionProviderTest.cs @@ -1,4 +1,4 @@ -// -------------------------------------------------------------------------------------------- +// -------------------------------------------------------------------------------------------- // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. // -------------------------------------------------------------------------------------------- @@ -7,6 +7,7 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; +using Microsoft.Oryx.BuildScriptGenerator; using Microsoft.Oryx.BuildScriptGenerator.Python; using Microsoft.Oryx.Tests.Common; using Xunit; @@ -79,12 +80,167 @@ public void GetsVersions_UsesExternalVersionProvider_WhenExternalProviderAndDyna Assert.False(onDiskVersionProvider.GetVersionInfoCalled); } + [Fact] + public void GetsVersions_UsesExternalAcrProvider_WhenExternalAcrProviderAndDynamicInstallEnabled() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + Assert.False(result.OnDiskVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_UsesAcrProvider_WhenAcrProviderAndDynamicInstallEnabled() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableAcrSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + Assert.False(result.OnDiskVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_ExternalAcrTakesPriority_OverExternalSdk() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_ExternalAcrTakesPriority_OverDirectAcr() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableAcrSdkProvider: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToStorage_WhenExternalAcrReturnsNull() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + externalAcrReturnsNull: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToStorage_WhenAcrProviderThrows() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableAcrSdkProvider: true, + acrThrowsException: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToDirectAcr_WhenExternalAcrAndExternalSdkReturnNull() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true, + enableAcrSdkProvider: true, + externalAcrReturnsNull: true, + externalSdkReturnsNull: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.False(result.StorageVersionProvider.GetVersionInfoCalled); + } + + [Fact] + public void GetsVersions_FallsBackToCdn_WhenAllProvidersReturnNull() + { + // Arrange + var result = CreateVersionProviderWithAcr( + enableDynamicInstall: true, + enableExternalAcrSdkProvider: true, + enableExternalSdkProvider: true, + enableAcrSdkProvider: true, + externalAcrReturnsNull: true, + externalSdkReturnsNull: true, + acrThrowsException: true); + + // Act + var versionInfo = result.VersionProvider.GetVersionInfo(); + + // Assert + Assert.True(result.ExternalAcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.ExternalVersionProvider.GetVersionInfoCalled); + Assert.True(result.AcrVersionProvider.GetVersionInfoCalled); + Assert.True(result.StorageVersionProvider.GetVersionInfoCalled); + } + private class TestPythonExternalVersionProvider : PythonExternalVersionProvider { + private readonly bool _returnsNull; + public TestPythonExternalVersionProvider( - IOptions commonOptions, IExternalSdkProvider externalProvider, ILoggerFactory loggerFactory) + IOptions commonOptions, IExternalSdkProvider externalProvider, ILoggerFactory loggerFactory, + bool returnsNull = false) : base(commonOptions, externalProvider, loggerFactory) { + _returnsNull = returnsNull; } public bool GetVersionInfoCalled { get; private set; } @@ -92,8 +248,13 @@ public TestPythonExternalVersionProvider( public override PlatformVersionInfo GetVersionInfo() { GetVersionInfoCalled = true; + if (_returnsNull) + { + return null; + } - return null; + return PlatformVersionInfo.CreateAvailableViaExternalProvider( + new[] { "1.0.0" }, "1.0.0"); } } @@ -138,7 +299,10 @@ public override PlatformVersionInfo GetVersionInfo() onDiskProvider, storageProvider, externalProvider, - NullLogger.Instance); + new PythonExternalAcrVersionProvider(Options.Create(new BuildScriptGeneratorOptions()), NullLoggerFactory.Instance, new DefaultStandardOutputWriter()), + new PythonAcrVersionProvider(commonOptions, new OciRegistryClient("https://test.azurecr.io", new TestHttpClientFactory(), NullLoggerFactory.Instance), NullLoggerFactory.Instance), + NullLogger.Instance, + new DefaultStandardOutputWriter()); return (versionProvider, onDiskProvider, storageProvider, externalProvider); } @@ -158,5 +322,136 @@ public override PlatformVersionInfo GetVersionInfo() return null; } } + + private class TestPythonExternalAcrVersionProvider : PythonExternalAcrVersionProvider + { + private readonly bool _returnsNull; + + public TestPythonExternalAcrVersionProvider( + IOptions options, + ILoggerFactory loggerFactory, + IStandardOutputWriter outputWriter, + bool returnsNull = false) + : base(options, loggerFactory, outputWriter) + { + _returnsNull = returnsNull; + } + + public bool GetVersionInfoCalled { get; private set; } + + public override PlatformVersionInfo GetVersionInfo() + { + GetVersionInfoCalled = true; + if (_returnsNull) + { + return null; + } + + return PlatformVersionInfo.CreateAvailableOnAcr( + new[] { "3.10.0" }, "3.10.0"); + } + } + + private class TestPythonAcrVersionProvider : PythonAcrVersionProvider + { + private readonly bool _throwsException; + + public TestPythonAcrVersionProvider( + IOptions commonOptions, + OciRegistryClient ociClient, + ILoggerFactory loggerFactory, + bool throwsException = false) + : base(commonOptions, ociClient, loggerFactory) + { + _throwsException = throwsException; + } + + public bool GetVersionInfoCalled { get; private set; } + + public override PlatformVersionInfo GetVersionInfo() + { + GetVersionInfoCalled = true; + if (_throwsException) + { + throw new System.Exception("ACR provider simulated failure"); + } + + return PlatformVersionInfo.CreateAvailableOnAcr( + new[] { "3.10.0" }, "3.10.0"); + } + } + + private class VersionProviderResult + { + public IPythonVersionProvider VersionProvider { get; set; } + + public TestPythonOnDiskVersionProvider OnDiskVersionProvider { get; set; } + + public TestPythonSdkStorageVersionProvider StorageVersionProvider { get; set; } + + public TestPythonExternalVersionProvider ExternalVersionProvider { get; set; } + + public TestPythonExternalAcrVersionProvider ExternalAcrVersionProvider { get; set; } + + public TestPythonAcrVersionProvider AcrVersionProvider { get; set; } + } + + private VersionProviderResult CreateVersionProviderWithAcr( + bool enableDynamicInstall, + bool enableExternalSdkProvider = false, + bool enableExternalAcrSdkProvider = false, + bool enableAcrSdkProvider = false, + bool externalAcrReturnsNull = false, + bool externalSdkReturnsNull = false, + bool acrThrowsException = false) + { + var commonOptions = Options.Create(new BuildScriptGeneratorOptions() + { + EnableDynamicInstall = enableDynamicInstall, + EnableExternalSdkProvider = enableExternalSdkProvider, + EnableExternalAcrSdkProvider = enableExternalAcrSdkProvider, + EnableAcrSdkProvider = enableAcrSdkProvider, + }); + + var onDiskProvider = new TestPythonOnDiskVersionProvider(); + var storageProvider = new TestPythonSdkStorageVersionProvider( + commonOptions, + new TestHttpClientFactory(), + NullLoggerFactory.Instance); + var externalProvider = new TestPythonExternalVersionProvider( + commonOptions, + new TestExternalSdkProvider(), + NullLoggerFactory.Instance, + returnsNull: externalSdkReturnsNull); + var externalAcrProvider = new TestPythonExternalAcrVersionProvider( + commonOptions, + NullLoggerFactory.Instance, + new DefaultStandardOutputWriter(), + returnsNull: externalAcrReturnsNull); + var acrProvider = new TestPythonAcrVersionProvider( + commonOptions, + new OciRegistryClient("https://test.azurecr.io", new TestHttpClientFactory(), NullLoggerFactory.Instance), + NullLoggerFactory.Instance, + throwsException: acrThrowsException); + var versionProvider = new PythonVersionProvider( + commonOptions, + onDiskProvider, + storageProvider, + externalProvider, + externalAcrProvider, + acrProvider, + NullLogger.Instance, + new DefaultStandardOutputWriter()); + + return new VersionProviderResult + { + VersionProvider = versionProvider, + OnDiskVersionProvider = onDiskProvider, + StorageVersionProvider = storageProvider, + ExternalVersionProvider = externalProvider, + ExternalAcrVersionProvider = externalAcrProvider, + AcrVersionProvider = acrProvider, + }; + } } } diff --git a/tests/BuildScriptGenerator.Tests/SocketRequestHelperTest.cs b/tests/BuildScriptGenerator.Tests/SocketRequestHelperTest.cs new file mode 100644 index 0000000000..5c7c5f9ed0 --- /dev/null +++ b/tests/BuildScriptGenerator.Tests/SocketRequestHelperTest.cs @@ -0,0 +1,179 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace Microsoft.Oryx.BuildScriptGenerator.Tests +{ + public class SocketRequestHelperTest + { + [Fact] + public async Task SendRequestAsync_ThrowsSocketException_WhenSocketDoesNotExist() + { + // Arrange — use a path that doesn't exist + var nonExistentSocket = Path.Combine(Path.GetTempPath(), $"oryx-test-{Guid.NewGuid():N}.socket"); + + // Act & Assert — should throw SocketException (no such file) + await Assert.ThrowsAsync( + () => SocketRequestHelper.SendRequestAsync(nonExistentSocket, new { Action = "test" }, timeoutSeconds: 5)); + } + + [Fact] + public async Task SendRequestAsync_ReadsFullResponse_WithTerminator() + { + // This test verifies the '$' terminator loop works by using a real Unix socket pair. + // Only runs on Unix-like systems where Unix sockets are available. + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + return; // Skip on Windows — Unix domain sockets may not be available + } + + var socketPath = Path.Combine(Path.GetTempPath(), $"oryx-test-{Guid.NewGuid():N}.socket"); + try + { + // Start a simple echo server that responds with "version-1.0$" + using (var serverSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified)) + { + serverSocket.Bind(new UnixDomainSocketEndPoint(socketPath)); + serverSocket.Listen(1); + + var serverTask = Task.Run(async () => + { + using (var client = await serverSocket.AcceptAsync()) + { + var buffer = new byte[4096]; + var received = await client.ReceiveAsync(new ArraySegment(buffer), SocketFlags.None); + // Read request and respond + var response = Encoding.UTF8.GetBytes("version-1.0$"); + await client.SendAsync(new ArraySegment(response), SocketFlags.None); + } + }); + + // Act + var result = await SocketRequestHelper.SendRequestAsync( + socketPath, new { Action = "get-version" }, timeoutSeconds: 10); + + // Assert — should include the terminator + Assert.Equal("version-1.0$", result); + + await serverTask; + } + } + finally + { + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + } + } + } + + [Fact] + public async Task SendRequestAsync_ReturnsEmptyString_WhenServerClosesImmediately() + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + return; + } + + var socketPath = Path.Combine(Path.GetTempPath(), $"oryx-test-{Guid.NewGuid():N}.socket"); + try + { + using (var serverSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified)) + { + serverSocket.Bind(new UnixDomainSocketEndPoint(socketPath)); + serverSocket.Listen(1); + + var serverTask = Task.Run(async () => + { + using (var client = await serverSocket.AcceptAsync()) + { + // Read the request but close without responding + var buffer = new byte[4096]; + await client.ReceiveAsync(new ArraySegment(buffer), SocketFlags.None); + client.Shutdown(SocketShutdown.Both); + } + }); + + // Act + var result = await SocketRequestHelper.SendRequestAsync( + socketPath, new { Action = "test" }, timeoutSeconds: 10); + + // Assert — EOF should return empty string + Assert.Equal(string.Empty, result); + + await serverTask; + } + } + finally + { + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + } + } + } + + [Fact] + public async Task SendRequestAsync_ReadsFragmentedResponse() + { + if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS()) + { + return; + } + + var socketPath = Path.Combine(Path.GetTempPath(), $"oryx-test-{Guid.NewGuid():N}.socket"); + try + { + using (var serverSocket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified)) + { + serverSocket.Bind(new UnixDomainSocketEndPoint(socketPath)); + serverSocket.Listen(1); + + var serverTask = Task.Run(async () => + { + using (var client = await serverSocket.AcceptAsync()) + { + var buffer = new byte[4096]; + await client.ReceiveAsync(new ArraySegment(buffer), SocketFlags.None); + + // Send response in fragments + await client.SendAsync( + new ArraySegment(Encoding.UTF8.GetBytes("ver")), SocketFlags.None); + await Task.Delay(50); + await client.SendAsync( + new ArraySegment(Encoding.UTF8.GetBytes("sion-2.0")), SocketFlags.None); + await Task.Delay(50); + await client.SendAsync( + new ArraySegment(Encoding.UTF8.GetBytes("$")), SocketFlags.None); + } + }); + + // Act + var result = await SocketRequestHelper.SendRequestAsync( + socketPath, new { Action = "test" }, timeoutSeconds: 10); + + // Assert — fragmented response should be reassembled + Assert.Equal("version-2.0$", result); + + await serverTask; + } + } + finally + { + if (File.Exists(socketPath)) + { + File.Delete(socketPath); + } + } + } + } +} diff --git a/tests/Oryx.Integration.Tests/BuildConfigurationFile/BuildConfigurationReadYaml.cs b/tests/Oryx.Integration.Tests/BuildConfigurationFile/BuildConfigurationReadYaml.cs index 398688a355..f5edc4d441 100644 --- a/tests/Oryx.Integration.Tests/BuildConfigurationFile/BuildConfigurationReadYaml.cs +++ b/tests/Oryx.Integration.Tests/BuildConfigurationFile/BuildConfigurationReadYaml.cs @@ -131,6 +131,7 @@ private DefaultBuildScriptGenerator CreateDefaultScriptGenerator( var defaultPlatformDetector = new DefaultPlatformsInformationProvider( platforms, new DefaultStandardOutputWriter(), + NullLogger.Instance, Options.Create(commonOptions)); var envScriptProvider = new PlatformsInstallationScriptProvider( platforms, diff --git a/tests/Oryx.Integration.Tests/Oryx.Integration.Tests.csproj b/tests/Oryx.Integration.Tests/Oryx.Integration.Tests.csproj index 22bab65510..ee09125767 100644 --- a/tests/Oryx.Integration.Tests/Oryx.Integration.Tests.csproj +++ b/tests/Oryx.Integration.Tests/Oryx.Integration.Tests.csproj @@ -16,7 +16,7 @@ - + diff --git a/tests/Oryx.Tests.Common/TestAcrSdkProvider.cs b/tests/Oryx.Tests.Common/TestAcrSdkProvider.cs new file mode 100644 index 0000000000..1b6455a667 --- /dev/null +++ b/tests/Oryx.Tests.Common/TestAcrSdkProvider.cs @@ -0,0 +1,40 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Threading.Tasks; +using Microsoft.Oryx.BuildScriptGenerator; + +namespace Microsoft.Oryx.Tests.Common +{ + public class TestAcrSdkProvider : IAcrSdkProvider + { + private readonly bool _returnValue; + + public TestAcrSdkProvider(bool returnValue = false) + { + _returnValue = returnValue; + } + + public bool RequestSdkFromAcrAsyncCalled { get; private set; } + + public string LastRequestedPlatformName { get; private set; } + + public string LastRequestedVersion { get; private set; } + + public string LastRequestedDebianFlavor { get; private set; } + + public string LastRequestedRuntimeVersion { get; private set; } + + public Task RequestSdkFromAcrAsync(string platformName, string version, string debianFlavor, string runtimeVersion = null) + { + RequestSdkFromAcrAsyncCalled = true; + LastRequestedPlatformName = platformName; + LastRequestedVersion = version; + LastRequestedDebianFlavor = debianFlavor; + LastRequestedRuntimeVersion = runtimeVersion; + return Task.FromResult(_returnValue); + } + } +} diff --git a/tests/Oryx.Tests.Common/TestExternalAcrSdkProvider.cs b/tests/Oryx.Tests.Common/TestExternalAcrSdkProvider.cs new file mode 100644 index 0000000000..4170842115 --- /dev/null +++ b/tests/Oryx.Tests.Common/TestExternalAcrSdkProvider.cs @@ -0,0 +1,37 @@ +// -------------------------------------------------------------------------------------------- +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. +// -------------------------------------------------------------------------------------------- + +using System.Threading.Tasks; +using Microsoft.Oryx.BuildScriptGenerator; + +namespace Microsoft.Oryx.Tests.Common +{ + public class TestExternalAcrSdkProvider : IExternalAcrSdkProvider + { + private readonly bool _returnValue; + + public TestExternalAcrSdkProvider(bool returnValue = false) + { + _returnValue = returnValue; + } + + public bool RequestSdkAsyncCalled { get; private set; } + + public string LastRequestedPlatformName { get; private set; } + + public string LastRequestedVersion { get; private set; } + + public string LastRequestedDebianFlavor { get; private set; } + + public Task RequestSdkAsync(string platformName, string version, string debianFlavor) + { + RequestSdkAsyncCalled = true; + LastRequestedPlatformName = platformName; + LastRequestedVersion = version; + LastRequestedDebianFlavor = debianFlavor; + return Task.FromResult(_returnValue); + } + } +} diff --git a/tests/Oryx.Tests.Common/TestExternalSdkProvider.cs b/tests/Oryx.Tests.Common/TestExternalSdkProvider.cs index aed3ac38c0..ea66eb3abb 100644 --- a/tests/Oryx.Tests.Common/TestExternalSdkProvider.cs +++ b/tests/Oryx.Tests.Common/TestExternalSdkProvider.cs @@ -12,7 +12,14 @@ namespace Microsoft.Oryx.Tests.Common { public class TestExternalSdkProvider : IExternalSdkProvider { - public const string ExternalSdksStorageDir = "/var/OryxSdksCache"; + private readonly bool _requestBlobResult; + + public const string ExternalSdksStorageDir = "/var/OryxSdks"; + + public TestExternalSdkProvider(bool requestBlobResult = true) + { + _requestBlobResult = requestBlobResult; + } public Task GetPlatformMetaDataAsync(string platformName) { @@ -26,7 +33,7 @@ public Task GetChecksumForVersionAsync(string platformName, string versi public Task RequestBlobAsync(string platformName, string blobName) { - return Task.FromResult(true); + return Task.FromResult(_requestBlobResult); } }