Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
198 changes: 142 additions & 56 deletions src/BuildScriptGenerator/ExternalAcrSdkProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
Expand All @@ -19,14 +20,24 @@
namespace Microsoft.Oryx.BuildScriptGenerator
{
/// <summary>
/// External ACR-based SDK provider that communicates with LWASv2 over a Unix socket
/// to pull SDK images from the WAWS Images ACR. This is the ACR equivalent of
/// <see cref="ExternalSdkProvider"/> which uses blob storage.
/// External ACR-based SDK provider that fetches SDK tarballs from OCI images.
/// SDK images are built as <c>FROM scratch; COPY sdk.tar.gz /</c>, so each image
/// contains exactly one layer — the SDK tarball itself.
/// </summary>
/// <remarks>
/// Uses the same Unix socket path as <see cref="ExternalSdkProvider"/> but differentiates
/// requests by setting <c>source=acr</c> in the UrlParameters. LWASv2's OryxProxy checks
/// this parameter to route the request to ACR pull logic instead of blob download.
/// Two pull strategies, tried in order:
/// <list type="number">
/// <item>
/// <b>Unix socket (LWASv2)</b> — available inside App Service. Sends a request to
/// LWASv2's OryxProxy with <c>source=acr</c>; LWASv2 pulls the image and extracts
/// the tarball to <c>/var/OryxSdks</c>.
/// </item>
/// <item>
/// <b>Direct OCI pull</b> — fallback when the socket is unavailable (CLI builds,
/// local dev). Uses <see cref="OciRegistryClient"/> to fetch the manifest, extract
/// the single layer digest, download the blob, and verify SHA256.
/// </item>
/// </list>
/// </remarks>
public class ExternalAcrSdkProvider : IExternalAcrSdkProvider
{
Expand All @@ -41,15 +52,24 @@ public class ExternalAcrSdkProvider : IExternalAcrSdkProvider
private readonly ILogger<ExternalAcrSdkProvider> logger;
private readonly IStandardOutputWriter outputWriter;
private readonly BuildScriptGeneratorOptions options;
private readonly OciRegistryClient ociClient;

public ExternalAcrSdkProvider(
IStandardOutputWriter outputWriter,
ILogger<ExternalAcrSdkProvider> logger,
IOptions<BuildScriptGeneratorOptions> options)
IOptions<BuildScriptGeneratorOptions> options,
IHttpClientFactory httpClientFactory,
ILoggerFactory loggerFactory)
{
this.logger = logger;
this.outputWriter = outputWriter;
this.options = options.Value;

var registryUrl = string.IsNullOrEmpty(this.options.OryxAcrSdkRegistryUrl)
? SdkStorageConstants.DefaultAcrSdkRegistryUrl
: this.options.OryxAcrSdkRegistryUrl;

this.ociClient = new OciRegistryClient(registryUrl, httpClientFactory, loggerFactory);
}

/// <inheritdoc/>
Expand All @@ -70,35 +90,64 @@ public async Task<bool> RequestSdkFromAcrAsync(string platformName, string versi
debianFlavor = this.options.DebianFlavor ?? "bookworm";
}

// Construct a blob name for the output file path (same naming as blob-based downloads)
var blobName = $"{platformName}-{debianFlavor}-{version}.tar.gz";
var expectedFilePath = Path.Combine(ExternalSdksStorageDir, platformName, blobName);

this.logger.LogInformation(
"Requesting SDK from ACR via LWASv2: platform={PlatformName}, version={Version}, " +
"debianFlavor={DebianFlavor}",
"Requesting SDK from ACR: platform={PlatformName}, version={Version}, debianFlavor={DebianFlavor}",
platformName,
version,
debianFlavor);
this.outputWriter.WriteLine(
$"Requesting SDK from ACR via external provider: {platformName} {version} ({debianFlavor})");
$"Requesting SDK from ACR: {platformName} {version} ({debianFlavor})");

// Check if the file is already cached locally
var expectedFilePath = Path.Combine(ExternalSdksStorageDir, platformName, blobName);
if (File.Exists(expectedFilePath))
{
this.logger.LogInformation(
"SDK already cached locally at {FilePath}, skipping ACR pull.",
expectedFilePath);
this.outputWriter.WriteLine(
$"SDK already cached locally at {expectedFilePath}");
this.outputWriter.WriteLine($"SDK already cached locally at {expectedFilePath}");
return true;
}

// Strategy 1: Try Unix socket (LWASv2) if the socket exists on disk
if (File.Exists(SocketPath))
{
var socketResult = await this.TryPullViaSocketAsync(platformName, version, debianFlavor, blobName, expectedFilePath);
if (socketResult)
{
return true;
}

this.logger.LogWarning(
"LWASv2 socket pull failed for {PlatformName} {Version}. Falling back to direct OCI pull.",
platformName,
version);
}
else
{
this.logger.LogDebug(
"LWASv2 socket not found at {SocketPath}. Using direct OCI pull.",
SocketPath);
}

// Strategy 2: Direct OCI pull — fetch manifest, get layer digest, download blob
return await this.TryPullDirectFromAcrAsync(platformName, version, debianFlavor, expectedFilePath);
}

/// <summary>
/// Pulls the SDK via LWASv2 Unix socket.
/// </summary>
private async Task<bool> TryPullViaSocketAsync(
string platformName,
string version,
string debianFlavor,
string blobName,
string expectedFilePath)
{
try
{
// Send a simple request to LWASv2 with source=acr.
// LWASv2 will resolve the corresponding SDK companion image
// from the site's runtime image (respecting pinning) and pull it.
var request = new AcrSdkProviderRequest
{
PlatformName = platformName,
Expand All @@ -111,102 +160,139 @@ public async Task<bool> RequestSdkFromAcrAsync(string platformName, string versi
},
};

var response = await this.SendRequestAsync(request);
var response = await this.SendSocketRequestAsync(request);

if (response && File.Exists(expectedFilePath))
{
this.logger.LogInformation(
"Successfully pulled SDK from ACR via LWASv2: {PlatformName} {Version}, " +
"available at {FilePath}",
"Successfully pulled SDK from ACR via LWASv2: {PlatformName} {Version}",
platformName,
version,
expectedFilePath);
version);
this.outputWriter.WriteLine(
$"Successfully pulled SDK from ACR: {platformName} {version}");
$"Successfully pulled SDK from ACR via LWASv2: {platformName} {version}");
return true;
}
else
}
catch (Exception ex)
{
this.logger.LogError(
ex,
"Error requesting SDK from ACR via LWASv2: {PlatformName} {Version}",
platformName,
version);
}

return false;
}

/// <summary>
/// Pulls the SDK directly from the ACR registry using the OCI Distribution API.
/// SDK images are FROM scratch with a single layer that IS the tarball:
/// <code>
/// FROM scratch
/// COPY sdk.tar.gz /
/// </code>
/// Flow: GET manifest → extract single layer digest → GET blob → verify SHA256 → save to cache.
/// </summary>
private async Task<bool> TryPullDirectFromAcrAsync(
string platformName,
string version,
string debianFlavor,
string expectedFilePath)
{
try
{
var repository = $"{SdkStorageConstants.AcrSdkRepositoryPrefix}/{platformName}";
var tag = $"{debianFlavor}-{version}";

this.logger.LogInformation(
"Pulling SDK directly from ACR via OCI API: {Repository}:{Tag}",
repository,
tag);
this.outputWriter.WriteLine(
$"Pulling SDK directly from ACR: {repository}:{tag}");

var success = await this.ociClient.PullSdkAsync(repository, tag, expectedFilePath);

if (success)
{
this.logger.LogWarning(
"ACR SDK pull via LWASv2 did not produce expected file: {PlatformName} {Version} " +
"at {FilePath}. Response: {Response}",
this.logger.LogInformation(
"Successfully pulled SDK directly from ACR: {PlatformName} {Version}, saved to {FilePath}",
platformName,
version,
expectedFilePath,
response);
expectedFilePath);
this.outputWriter.WriteLine(
$"Failed to pull SDK from ACR via external provider: {platformName} {version}");
return false;
$"Successfully pulled SDK from ACR: {platformName} {version}");
return true;
}

this.logger.LogWarning(
"Direct OCI pull did not succeed for {PlatformName} {Version}.",
platformName,
version);
this.outputWriter.WriteLine(
$"Failed to pull SDK from ACR: {platformName} {version}");
return false;
}
catch (Exception ex)
{
this.logger.LogError(
ex,
"Error requesting SDK from ACR via LWASv2: {PlatformName} {Version}",
"Error pulling SDK directly from ACR: {PlatformName} {Version}",
platformName,
version);
this.outputWriter.WriteLine(
$"Error pulling SDK from ACR via external provider: {platformName} {version}: {ex.Message}");
$"Error pulling SDK from ACR: {platformName} {version}: {ex.Message}");
return false;
}
}

private async Task<bool> SendRequestAsync(AcrSdkProviderRequest request)
private async Task<bool> SendSocketRequestAsync(AcrSdkProviderRequest request)
{
using var socket = new Socket(AddressFamily.Unix, SocketType.Stream, ProtocolType.Unspecified);
try
{
this.logger.LogInformation(
"Sending ACR SDK request to external provider: {PlatformName}, {BlobName}, " +
"UrlParameters: {UrlParamsJson}",
"Sending ACR SDK request to LWASv2: {PlatformName}, {BlobName}",
request.PlatformName,
request.BlobName,
JsonSerializer.Serialize(request.UrlParameters));
request.BlobName);

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 ACR request: {RequestJson}",
SocketPath,
requestJson);

requestJson += "$";
var requestJson = JsonSerializer.Serialize(request) + "$";
var requestBytes = Encoding.UTF8.GetBytes(requestJson);

await socket.SendAsync(new ArraySegment<byte>(requestBytes), SocketFlags.None, cts.Token);
var buffer = new byte[4096];
var received = await socket.ReceiveAsync(
new ArraySegment<byte>(buffer), SocketFlags.None, cts.Token);
var responseString = Encoding.UTF8.GetString(buffer, 0, received);

this.logger.LogInformation(
"Received response from external ACR SDK provider: {Response}", responseString);
"Received response from LWASv2: {Response}", responseString);

if (!string.IsNullOrEmpty(responseString) && responseString.EqualsIgnoreCase("Success$"))
{
return true;
}
else
{
this.logger.LogError(
"ACR SDK request to external provider was unsuccessful. Response: {Response}",
responseString);
}

this.logger.LogError(
"LWASv2 ACR SDK request unsuccessful. Response: {Response}",
responseString);
}
}
catch (OperationCanceledException)
{
this.outputWriter.WriteLine("The external ACR SDK provider operation was canceled due to timeout.");
this.logger.LogError("The external ACR SDK provider operation was canceled due to timeout.");
this.outputWriter.WriteLine("The LWASv2 ACR SDK request timed out.");
this.logger.LogError("The LWASv2 ACR SDK request timed out.");
}
catch (Exception ex)
{
this.outputWriter.WriteLine(
$"Error communicating with external ACR SDK provider: {ex.Message}");
this.logger.LogError(ex, "Error communicating with external ACR SDK provider.");
$"Error communicating with LWASv2: {ex.Message}");
this.logger.LogError(ex, "Error communicating with LWASv2.");
}

return false;
Expand Down
53 changes: 53 additions & 0 deletions src/BuildScriptGenerator/Helpers/OciRegistryClient.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,5 +233,58 @@ public async Task<bool> DownloadLayerBlobAsync(string repository, string layerDi
this.logger.LogDebug("Successfully downloaded and verified layer blob {digest}", layerDigest);
return true;
}

/// <summary>
/// Pulls an SDK tarball from an OCI image built with <c>FROM scratch; COPY sdk.tar.gz /</c>.
/// Because the image contains a single layer, that layer IS the SDK tarball.
/// Flow: fetch manifest → extract single layer digest → download blob → verify SHA256.
/// </summary>
/// <param name="repository">The repository name, e.g. "sdks/python".</param>
/// <param name="tag">The image tag, e.g. "bookworm-3.11.0".</param>
/// <param name="outputFilePath">The full path where the downloaded tarball should be saved.</param>
/// <returns>True if the SDK was pulled and verified successfully.</returns>
public async Task<bool> PullSdkAsync(string repository, string tag, string outputFilePath)
{
this.logger.LogInformation(
"Pulling SDK directly from ACR: {repository}:{tag} -> {outputPath}",
repository,
tag,
outputFilePath);

// Step 1: Fetch the OCI manifest
var manifest = await this.GetManifestAsync(repository, tag);
if (manifest == null)
{
this.logger.LogError("Failed to get manifest for {repository}:{tag}", repository, tag);
return false;
}

// Step 2: Get the single layer digest (FROM scratch images have exactly 1 layer)
var layerDigest = GetFirstLayerDigest(manifest);
if (string.IsNullOrEmpty(layerDigest))
{
this.logger.LogError(
"No layer found in manifest for {repository}:{tag}. Expected a single-layer FROM scratch image.",
repository,
tag);
return false;
}

this.logger.LogDebug(
"Manifest for {repository}:{tag} has layer digest: {digest}",
repository,
tag,
layerDigest);

// Ensure the output directory exists
var outputDir = Path.GetDirectoryName(outputFilePath);
if (!string.IsNullOrEmpty(outputDir))
{
Directory.CreateDirectory(outputDir);
}

// Step 3: Download the layer blob (this IS the SDK tarball) and verify SHA256
return await this.DownloadLayerBlobAsync(repository, layerDigest, outputFilePath);
}
}
}