diff --git a/docs/build_cache_requirements.md b/docs/build_cache_requirements.md new file mode 100644 index 000000000..3efac3690 --- /dev/null +++ b/docs/build_cache_requirements.md @@ -0,0 +1,95 @@ +# Build Cache Service — Requirements for Crank Integration + +## Context + +Crank (the .NET benchmarking tool) has been updated to support a new `buildcache` channel that downloads pre-built runtime binaries from the Build Cache Service (BCS) instead of resolving versions from VMR/NuGet feeds. This gives per-commit granularity for performance testing and regression bisection. + +The crank-side changes are complete. This document describes what's needed on the BCS/dotnet-performance-infra side to make the integration work end-to-end. + +--- + +## Requirement 1: Public Blob Access + +**Status:** Already in progress (per prior discussion). + +Crank's BCS client uses unauthenticated HTTP GET requests to download artifacts. The blobs in the `pvscmdupload` storage account's `$web` container need to be publicly readable. + +**URLs crank will hit:** + +``` +GET https://pvscmdupload.z22.web.core.windows.net/builds/{repoName}/latest/{branch}/latestBuilds.json +GET https://pvscmdupload.z22.web.core.windows.net/builds/{repoName}/buildArtifacts/{commitSha}/{configKey}/{artifactFile} +``` + +Where: +- `repoName` = `runtime` (initially; `aspnetcore` in the future) +- `branch` = e.g., `main`, `release/10.0` +- `configKey` = e.g., `coreclr_x64_linux`, `coreclr_arm64_windows` +- `artifactFile` = e.g., `BuildArtifacts_linux_x64_Release_coreclr.tar.gz` + +--- + +## Requirement 2: Commit Index File (Not Required) + +~~Originally proposed as a per-branch `commitIndex.json` mapping commits to timestamps.~~ + +**Decision:** Not needed. For the default case, `latestBuilds.json` provides the latest commit. For specific-commit runs (e.g., bisection), users will already know the SHAs — either from git history, GitHub, or a local tool that queries the GitHub API for the commit list. A separate index in BCS would be redundant. + +If automated bisection tooling is built in the future, it can query GitHub directly for ordered commit SHAs and then check BCS blob existence per-commit. + +--- + +## Requirement 3: latestBuilds.json Compatibility + +**Resolved.** The actual `latestBuilds.json` uses PascalCase (`CommitSha`, `CommitTime`), not snake_case. Crank's parser has been updated to accept both casings for forward compatibility. + +--- + +## Requirement 4: Artifact Layout Stability + +Crank extracts runtime artifacts using this path convention inside the archive: + +``` +microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/lib/net{X}.0/ → managed DLLs +microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/native/ → native libs +{rid}.Release/corehost/ → host binaries (dotnet, libhostfxr, libhostpolicy) +``` + +Where `{rid}` = `linux-x64`, `linux-arm64`, `win-x64`, etc. + +This layout was confirmed by inspecting `BuildArtifacts_linux_arm64_Release_coreclr.tar.gz`. **If this layout changes in future builds, the crank extraction will break.** Consider treating it as a stable contract or documenting it. + +--- + +## Nice-to-Have: Artifact Manifest + +A `manifest.json` per commit+config that describes the archive contents would make extraction more robust: + +``` +builds/{repoName}/buildArtifacts/{commitSha}/{configKey}/manifest.json +``` + +```json +{ + "runtimeVersion": "10.0.0-preview.4.26120.3", + "commitSha": "abc123...", + "rid": "linux-arm64", + "managedPath": "microsoft.netcore.app.runtime.linux-arm64/Release/runtimes/linux-arm64/lib/net10.0", + "nativePath": "microsoft.netcore.app.runtime.linux-arm64/Release/runtimes/linux-arm64/native", + "corehostPath": "linux-arm64.Release/corehost" +} +``` + +This isn't blocking — crank currently discovers paths by convention — but it would decouple crank from the internal archive layout and make future changes safe. + +--- + +## Summary + +| # | Requirement | Priority | Blocking? | +|---|-------------|----------|-----------| +| 1 | Public blob access | High | Yes — crank can't download without it | +| 2 | ~~Commit index~~ | N/A | Dropped — users provide SHAs directly or use GitHub | +| 3 | `latestBuilds.json` field names | N/A | Resolved — crank parser updated to handle PascalCase | +| 4 | Artifact layout stability | Medium | Not now, but breaking changes would break crank | +| 5 | Artifact manifest.json | Low | Nice-to-have for robustness | diff --git a/docs/dotnet_versions.md b/docs/dotnet_versions.md index 9c48d5305..70e21181c 100644 --- a/docs/dotnet_versions.md +++ b/docs/dotnet_versions.md @@ -55,9 +55,12 @@ When a TFM is configured, the agent will download the corresponding .NET SDK ver - `current`: only latest public versions, this is the default - `latest`: latest versions used by ASP.NET - `edge`: latest nightly builds available +- `buildcache`: runtime from the Build Cache Service (per-commit builds) The difference between `latest` and `edge` is that `latest` will pick runtimes and SDKs that are deemed compatible together. For instance a very recent .NET core runtime might be compatible with a less recent ASP.NET runtime. The `edge` is used to pick the absolute latest build for the select TFM. +The `buildcache` channel uses the Build Cache Service (BCS) from `dotnet-performance-infra` to resolve runtime versions by individual commit SHA rather than from VMR feeds. This provides much finer-grained control — every cached runtime commit is available, whereas VMR feeds may have multi-day gaps between ingested commits. SDK and ASP.NET Core versions are resolved from `latest` when using `buildcache`. + In order to benchmark and ASP.NET application using very recent runtimes of .NET 5, the `latest` channel is recommended: ``` @@ -115,4 +118,52 @@ The following command uses the `edge` channel but ASP.NET is fixed so it doesn't ``` > crank --config /crank/samples/hello/hello.benchmarks.yml --scenario hello --profile local --application.framework netcoreapp5.0 --application.channel edge --application.aspnetCoreVersion 5.0.0-preview.6.20279.12 -``` \ No newline at end of file +``` + +## Using the Build Cache channel + +The `buildcache` channel resolves the .NET runtime from the Build Cache Service (BCS), which caches pre-built runtime binaries for individual commits. This is useful for performance regression bisection where VMR feed gaps make it hard to pinpoint which commit caused a regression. + +### Basic usage (latest cached build on main) + +``` +> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache +``` + +### Specific commit SHA + +``` +> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.buildCacheCommitSha a1b2c3d4e5f6... +``` + +If the commit is not found in the cache, crank will fail with an error rather than falling back. + +### Different branch + +``` +> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.buildCacheBranch release/10.0 +``` + +### Mixed channels (BCS runtime + pinned ASP.NET) + +``` +> crank --config benchmarks.yml --scenario json --profile aspnet-perf-lin --application.channel buildcache --application.aspNetCoreVersion 10.0.0-preview.3.26115.7 +``` + +### Build Cache properties + +| Property | Default | Description | +|----------|---------|-------------| +| `buildCacheCommitSha` | (empty) | Specific runtime commit SHA. If empty, uses the latest cached build for the branch. | +| `buildCacheBranch` | `main` | Branch to query for the latest build. | +| `buildCacheConfig` | (auto-detected) | BCS configuration key (e.g., `coreclr_x64_linux`). Auto-detected from agent platform. | + +### Agent configuration + +The agent supports these command-line options for BCS: + +| Option | Default | Description | +|--------|---------|-------------| +| `--build-cache-base-url` | `https://pvscmdupload.z22.web.core.windows.net` | Base URL for BCS blob storage. | +| `--build-cache-repo-name` | `runtime` | Repository name in BCS. | +| `--build-cache-disabled` | (not set) | Disables BCS integration on this agent. | diff --git a/src/Microsoft.Crank.Agent/BuildCacheClient.cs b/src/Microsoft.Crank.Agent/BuildCacheClient.cs new file mode 100644 index 000000000..08d35f0ed --- /dev/null +++ b/src/Microsoft.Crank.Agent/BuildCacheClient.cs @@ -0,0 +1,909 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Formats.Tar; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Net.Http; +using System.Runtime.InteropServices; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.Crank.Agent +{ + /// + /// Lightweight client for the Build Caching Service (BCS) in dotnet-performance-infra. + /// Downloads pre-built runtime artifacts from public Azure Blob Storage and assembles a + /// per-job dotnet home (or overlays a self-contained published app) so the benchmark runs + /// against the BCS runtime instead of the feed-installed one. + /// + internal static class BuildCacheClient + { + private const int DownloadRetryCount = 3; + private static readonly TimeSpan _httpTimeout = TimeSpan.FromMinutes(10); + private static readonly TimeSpan _latestBuildsCacheDuration = TimeSpan.FromHours(1); + + private static readonly HttpClient _httpClient = new HttpClient { Timeout = _httpTimeout }; + + // Cache latestBuilds.json responses to avoid repeated downloads (keyed by baseUrl|repo|branch). + private static readonly ConcurrentDictionary _latestBuildsCache = new(); + + // Per-(commit,config) async locks so concurrent jobs serialize their downloads/extracts. + private static readonly ConcurrentDictionary _extractLocks = new(); + + // Hex SHA-1 (full or short), 8-40 chars. BCS commits are runtime repo commits. + private static readonly Regex _shaRegex = new("^[0-9a-fA-F]{8,40}$", RegexOptions.Compiled); + + /// + /// Maps the agent's platform (RID) to the BCS configuration key and artifact filename. + /// The reverse direction (config → RID) is used to decide which RID-shaped subtree to + /// look for inside the archive when the user supplies an explicit config override. + /// + internal static readonly IReadOnlyDictionary PlatformToBcsConfig = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["linux-x64"] = ("coreclr_x64_linux", "BuildArtifacts_linux_x64_Release_coreclr.tar.gz", "linux-x64"), + ["linux-arm64"] = ("coreclr_arm64_linux", "BuildArtifacts_linux_arm64_Release_coreclr.tar.gz", "linux-arm64"), + ["linux-musl-x64"] = ("coreclr_muslx64_linux", "BuildArtifacts_linux_musl_x64_Release_coreclr.tar.gz", "linux-musl-x64"), + ["win-x64"] = ("coreclr_x64_windows", "BuildArtifacts_windows_x64_Release_coreclr.zip", "win-x64"), + ["win-arm64"] = ("coreclr_arm64_windows", "BuildArtifacts_windows_arm64_Release_coreclr.zip", "win-arm64"), + ["win-x86"] = ("coreclr_x86_windows", "BuildArtifacts_windows_x86_Release_coreclr.zip", "win-x86"), + }; + + /// + /// Sentinel thrown for HTTP responses that are definitively not retryable (e.g. 404). + /// Distinguishes "the build doesn't exist" from "transient network blip". + /// + public class BuildCacheNotFoundException : InvalidOperationException + { + public BuildCacheNotFoundException(string message) : base(message) { } + } + + /// + /// Validates a user-supplied commit SHA. Accepts 8-40 lowercase/uppercase hex chars. + /// + internal static void ValidateCommitSha(string commitSha) + { + if (string.IsNullOrEmpty(commitSha)) + { + return; + } + + if (!_shaRegex.IsMatch(commitSha)) + { + throw new ArgumentException( + $"'{commitSha}' is not a valid commit SHA. Expected 8-40 hex characters.", + nameof(commitSha)); + } + } + + /// + /// Resolves the commit SHA to use from BCS. If a specific commit is provided, returns it + /// (after platform-config inference). Otherwise queries latestBuilds.json for the latest + /// commit on the branch. + /// + public static async Task<(string commitSha, string buildCacheConfig)> ResolveCommitAsync( + string baseUrl, + string repoName, + string branch, + string commitSha, + string buildCacheConfig, + CancellationToken cancellationToken = default) + { + ValidateCommitSha(commitSha); + buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig); + + if (string.IsNullOrEmpty(commitSha)) + { + var latestBuilds = await GetLatestBuildsAsync(baseUrl, repoName, branch, cancellationToken); + + if (latestBuilds.Entries.TryGetValue(buildCacheConfig, out var configEntry) && !string.IsNullOrEmpty(configEntry.CommitSha)) + { + commitSha = configEntry.CommitSha; + Log.Info($"Build Cache: Using latest commit {ShortSha(commitSha)} for config '{buildCacheConfig}' on branch '{branch}' (committed {configEntry.CommitTime})"); + } + else if (latestBuilds.Entries.TryGetValue("all", out var allEntry) && !string.IsNullOrEmpty(allEntry.CommitSha)) + { + commitSha = allEntry.CommitSha; + Log.Info($"Build Cache: Using latest commit {ShortSha(commitSha)} for all configs on branch '{branch}' (committed {allEntry.CommitTime})"); + } + else + { + throw new InvalidOperationException( + $"Build Cache: No latest build found for branch '{branch}' (config '{buildCacheConfig}'). Check that BCS has builds for this branch."); + } + } + else + { + Log.Info($"Build Cache: Using specified commit {ShortSha(commitSha)}"); + } + + return (commitSha, buildCacheConfig); + } + + /// + /// Downloads and extracts BCS runtime artifacts to a per-call temp directory. The caller + /// owns the returned directory and is responsible for deleting it when done. + /// + public static async Task DownloadAndExtractAsync( + string baseUrl, + string repoName, + string commitSha, + string buildCacheConfig, + CancellationToken cancellationToken = default) + { + ValidateCommitSha(commitSha); + if (string.IsNullOrEmpty(commitSha)) + { + throw new ArgumentException("commitSha must be provided.", nameof(commitSha)); + } + + buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig); + var artifactFile = GetArtifactFile(buildCacheConfig); + var normalizedBaseUrl = (baseUrl ?? string.Empty).TrimEnd('/'); + + var artifactUrl = + $"{normalizedBaseUrl}/builds/{Uri.EscapeDataString(repoName)}/buildArtifacts/" + + $"{Uri.EscapeDataString(commitSha)}/{Uri.EscapeDataString(buildCacheConfig)}/{Uri.EscapeDataString(artifactFile)}"; + + var rootCacheDir = Path.Combine(Path.GetTempPath(), "crank-buildcache"); + Directory.CreateDirectory(rootCacheDir); + + var safeConfig = SanitizeForPath(buildCacheConfig); + + // Per-(commit,config) lock so two concurrent jobs don't race on the same archive download. + var lockKey = $"{commitSha}|{safeConfig}"; + var gate = _extractLocks.GetOrAdd(lockKey, _ => new SemaphoreSlim(1, 1)); + await gate.WaitAsync(cancellationToken); + try + { + var commitDir = Path.Combine(rootCacheDir, commitSha); + Directory.CreateDirectory(commitDir); + + var archivePath = Path.Combine(commitDir, $"{safeConfig}-{artifactFile}"); + + if (!File.Exists(archivePath)) + { + Log.Info($"Build Cache: Downloading {artifactFile} from {artifactUrl}"); + await DownloadWithRetryAsync(artifactUrl, archivePath, cancellationToken); + Log.Info($"Build Cache: Downloaded {new FileInfo(archivePath).Length / (1024 * 1024)} MB"); + } + else + { + Log.Info($"Build Cache: Using cached archive at {archivePath}"); + } + + // Per-call unique extract dir so two concurrent jobs for the same (commit,config) + // never delete each other's working tree. + var extractDir = Path.Combine(commitDir, $"extracted-{safeConfig}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(extractDir); + + Log.Info($"Build Cache: Extracting archive to {extractDir} ..."); + await ExtractArchiveAsync(archivePath, extractDir, cancellationToken); + + return extractDir; + } + finally + { + gate.Release(); + } + } + + /// + /// Deletes a previously-extracted directory. Safe to call multiple times. Archives in the + /// parent commit dir are intentionally NOT deleted so subsequent jobs for the same commit + /// can reuse the download. + /// + public static void CleanupExtractDir(string extractDir) + { + if (string.IsNullOrEmpty(extractDir)) + { + return; + } + + try + { + if (Directory.Exists(extractDir)) + { + Directory.Delete(extractDir, recursive: true); + } + } + catch (Exception ex) + { + Log.Info($"Build Cache: Failed to clean up extracted dir '{extractDir}': {ex.Message}"); + } + } + + /// + /// Builds a per-job dotnet home that mirrors the relevant subtrees of the global dotnet + /// home (runtime, asp.net, host) and overlays BCS bits on top. The global dotnet home is + /// NOT modified, so concurrent jobs and subsequent non-buildcache jobs are unaffected. + /// + /// Absolute path to the per-job dotnet home root. Caller owns it. + public static string CreateBuildCacheDotnetHome( + string globalDotnetHome, + string extractDir, + string runtimeVersion, + string aspNetCoreVersion, + string commitSha, + string buildCacheConfig) + { + if (string.IsNullOrEmpty(runtimeVersion)) + { + throw new ArgumentException("runtimeVersion must be provided.", nameof(runtimeVersion)); + } + + buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig); + var rid = GetRidForConfig(buildCacheConfig); + + // Per-job, never reused across jobs to avoid pollution. + var bcsHomeRoot = Path.Combine( + Path.GetTempPath(), + "crank-buildcache", + $"home-{ShortSha(commitSha)}-{SanitizeForPath(buildCacheConfig)}-{Guid.NewGuid():N}"); + Directory.CreateDirectory(bcsHomeRoot); + + // 1. Copy the dotnet host binary. + var dotnetExeName = GetDotnetExecutableName(); + var srcDotnet = Path.Combine(globalDotnetHome, dotnetExeName); + if (File.Exists(srcDotnet)) + { + var dstDotnet = Path.Combine(bcsHomeRoot, dotnetExeName); + File.Copy(srcDotnet, dstDotnet, overwrite: true); + EnsureExecutable(dstDotnet); + } + + // 2. Mirror host/fxr/{runtimeVersion} (small dir, ~1 file). + var srcHostFxr = Path.Combine(globalDotnetHome, "host", "fxr", runtimeVersion); + var dstHostFxr = Path.Combine(bcsHomeRoot, "host", "fxr", runtimeVersion); + if (Directory.Exists(srcHostFxr)) + { + CopyDirectory(srcHostFxr, dstHostFxr); + } + + // 3. Mirror shared/Microsoft.NETCore.App/{runtimeVersion}. + var srcNetCoreApp = Path.Combine(globalDotnetHome, "shared", "Microsoft.NETCore.App", runtimeVersion); + var dstNetCoreApp = Path.Combine(bcsHomeRoot, "shared", "Microsoft.NETCore.App", runtimeVersion); + if (Directory.Exists(srcNetCoreApp)) + { + CopyDirectory(srcNetCoreApp, dstNetCoreApp); + } + + // 4. Mirror shared/Microsoft.AspNetCore.App/{aspNetCoreVersion}. + if (!string.IsNullOrEmpty(aspNetCoreVersion)) + { + var srcAspNet = Path.Combine(globalDotnetHome, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion); + var dstAspNet = Path.Combine(bcsHomeRoot, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion); + if (Directory.Exists(srcAspNet)) + { + CopyDirectory(srcAspNet, dstAspNet); + } + } + + // 5. Overlay BCS managed + native into the per-job NETCore.App. + int filesOverlaid = 0; + var nugetPackageDir = FindDirectory(extractDir, $"microsoft.netcore.app.runtime.{rid}"); + if (nugetPackageDir != null) + { + var runtimesDir = Path.Combine(nugetPackageDir, "Release", "runtimes", rid); + if (Directory.Exists(runtimesDir)) + { + filesOverlaid += CopyManaged(runtimesDir, dstNetCoreApp); + filesOverlaid += CopyNative(runtimesDir, dstNetCoreApp); + } + } + + // 6. Overlay BCS host binaries. + var corehostDir = FindCorehostDirectory(extractDir, rid); + if (corehostDir != null) + { + filesOverlaid += CopyHostBinaryIfPresent(corehostDir, dstNetCoreApp, GetNativeLibName("hostpolicy")); + + if (Directory.Exists(dstHostFxr)) + { + filesOverlaid += CopyHostBinaryIfPresent(corehostDir, dstHostFxr, GetNativeLibName("hostfxr")); + } + + var dstDotnetHost = Path.Combine(bcsHomeRoot, dotnetExeName); + var copied = CopyHostBinaryIfPresent(corehostDir, bcsHomeRoot, dotnetExeName); + if (copied > 0) + { + EnsureExecutable(dstDotnetHost); + } + filesOverlaid += copied; + } + + if (filesOverlaid == 0) + { + // The per-job home would be just a copy of the feed runtime with no BCS bits. + // Tear it down and let the caller fail the job loudly. + try { Directory.Delete(bcsHomeRoot, recursive: true); } catch { } + throw new InvalidOperationException( + $"Build Cache: overlay copied 0 files for commit {ShortSha(commitSha)} (config '{buildCacheConfig}', rid '{rid}'). " + + "The archive layout may have changed or the platform is not supported."); + } + + // 7. Rewrite .version so any consumer (the agent's own BenchmarksNetCoreAppVersion + // measurement, GetDependencies, etc.) reports the BCS commit. + File.WriteAllText( + Path.Combine(dstNetCoreApp, ".version"), + $"{commitSha}\n{runtimeVersion}\n"); + + Log.Info($"Build Cache: Per-job dotnet home built at {bcsHomeRoot} ({filesOverlaid} BCS files overlaid)"); + return bcsHomeRoot; + } + + /// + /// Overlays BCS runtime binaries (managed + native + apphost) into a self-contained + /// published output directory. For SCD the runtime ships next to the app, so this is the + /// only way to make the benchmark actually run BCS bits. The BCS apphost is renamed to + /// match the published app's executable name (the SDK renames apphost → AssemblyName). + /// + /// Number of files overlaid. + public static int OverlayPublishedOutput( + string extractDir, + string outputFolder, + string buildCacheConfig, + string assemblyName) + { + buildCacheConfig = ResolveBuildCacheConfig(buildCacheConfig); + var rid = GetRidForConfig(buildCacheConfig); + int filesCopied = 0; + + var nugetPackageDir = FindDirectory(extractDir, $"microsoft.netcore.app.runtime.{rid}"); + if (nugetPackageDir != null) + { + var runtimesDir = Path.Combine(nugetPackageDir, "Release", "runtimes", rid); + if (Directory.Exists(runtimesDir)) + { + filesCopied += CopyManaged(runtimesDir, outputFolder); + filesCopied += CopyNative(runtimesDir, outputFolder); + } + } + + var corehostDir = FindCorehostDirectory(extractDir, rid); + if (corehostDir != null) + { + filesCopied += CopyHostBinaryIfPresent(corehostDir, outputFolder, GetNativeLibName("hostpolicy")); + + // Intentionally NOT replacing the SDK-bound apphost. The BCS archive ships the raw, + // unbound apphost (the binary has a placeholder SHA-256 hash where the managed DLL + // path is encoded). The SDK's publish step normally invokes HostWriter.CreateAppHost + // to bake the managed entry-point path into that binary. Overlaying the raw BCS + // apphost on top of the SDK-bound one leaves the executable unable to locate its + // managed DLL and the app fails to start with: + // + // "This executable is not bound to a managed DLL to execute. The binding value + // is: ''" + // + // The perf-relevant runtime code (CoreCLR JIT, GC, managed BCL, hostfxr, hostpolicy) + // is still overlaid above. To overlay apphost as well, BCS would need to ship a + // pre-bound apphost per project, or the agent would need to invoke the apphost + // binder against the BCS apphost using the published app's binding metadata. + } + + return filesCopied; + } + + // --- HTTP / latestBuilds.json ------------------------------------------------- + + private static async Task GetLatestBuildsAsync( + string baseUrl, string repoName, string branch, CancellationToken cancellationToken) + { + var normalizedBaseUrl = (baseUrl ?? string.Empty).TrimEnd('/'); + var cacheKey = $"{normalizedBaseUrl}|{repoName}|{branch}"; + + if (_latestBuildsCache.TryGetValue(cacheKey, out var cached) && + DateTimeOffset.UtcNow - cached.fetchedAt < _latestBuildsCacheDuration) + { + return cached.data; + } + + // Branch may contain slashes (e.g., "release/10.0"). Escape each segment but keep + // the slash semantics so the URL still resolves correctly on the server. + var escapedBranch = string.Join("/", branch.Split('/').Select(Uri.EscapeDataString)); + var url = $"{normalizedBaseUrl}/builds/{Uri.EscapeDataString(repoName)}/latest/{escapedBranch}/latestBuilds.json"; + + Log.Info($"Build Cache: Fetching latest builds from {url}"); + + string json = null; + + // 404s are not transient; pre-check before entering the retry loop. + await RetryTransientAsync(DownloadRetryCount, async () => + { + using var response = await _httpClient.GetAsync(url, cancellationToken); + + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + throw new BuildCacheNotFoundException( + $"Build Cache: No latest builds found for branch '{branch}' in repo '{repoName}'. URL: {url}"); + } + + response.EnsureSuccessStatusCode(); + json = await response.Content.ReadAsStringAsync(cancellationToken); + }); + + var latestBuilds = ParseLatestBuilds(json); + _latestBuildsCache[cacheKey] = (DateTimeOffset.UtcNow, latestBuilds); + return latestBuilds; + } + + private static async Task DownloadWithRetryAsync(string url, string destination, CancellationToken cancellationToken) + { + var partial = destination + ".partial"; + + await RetryTransientAsync(DownloadRetryCount, async () => + { + if (File.Exists(partial)) + { + File.Delete(partial); + } + + using (var response = await _httpClient.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)) + { + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + // Definitively not transient; do not retry. + throw new BuildCacheNotFoundException( + $"Build Cache: Artifact not found at {url}. The build may not exist in the cache."); + } + + response.EnsureSuccessStatusCode(); + + var expectedLength = response.Content.Headers.ContentLength; + + using (var fileStream = File.Create(partial)) + { + await response.Content.CopyToAsync(fileStream, cancellationToken); + } + + if (expectedLength.HasValue) + { + var actual = new FileInfo(partial).Length; + if (actual != expectedLength.Value) + { + throw new InvalidOperationException( + $"Build Cache: Download size mismatch (expected {expectedLength.Value}, got {actual}). URL: {url}"); + } + } + } + + if (File.Exists(destination)) + { + File.Delete(destination); + } + + File.Move(partial, destination); + }); + } + + /// + /// Like + /// but rethrows immediately without retrying. + /// + private static async Task RetryTransientAsync(int retries, Func operation) + { + var attempts = 0; + while (true) + { + try + { + attempts++; + await operation(); + return; + } + catch (BuildCacheNotFoundException) + { + // Non-retryable: fail fast. + throw; + } + catch (Exception ex) + { + if (attempts > retries) + { + throw; + } + + Log.Info($"Build Cache: Attempt {attempts} failed: {ex.Message}"); + } + } + } + + /// + /// Parses the latestBuilds.json format from BCS. The JSON has dynamic keys for each + /// build configuration plus a "branch_name" / "BranchName" string property. + /// + internal static LatestBuildsResponse ParseLatestBuilds(string json) + { + var result = new LatestBuildsResponse(); + + using var doc = JsonDocument.Parse(json); + + foreach (var property in doc.RootElement.EnumerateObject()) + { + if (property.Name.Equals("branch_name", StringComparison.OrdinalIgnoreCase) || + property.Name.Equals("BranchName", StringComparison.Ordinal)) + { + if (property.Value.ValueKind == JsonValueKind.String) + { + result.BranchName = property.Value.GetString(); + } + continue; + } + + if (property.Value.ValueKind == JsonValueKind.Object) + { + var entry = new LatestBuildEntry + { + CommitSha = TryGetStringPropertyAnyCase(property.Value, "CommitSha", "commit_sha"), + CommitTime = TryGetStringPropertyAnyCase(property.Value, "CommitTime", "commit_time"), + }; + + result.Entries[property.Name] = entry; + } + } + + return result; + } + + private static string TryGetStringPropertyAnyCase(JsonElement element, params string[] names) + { + foreach (var name in names) + { + if (element.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String) + { + return value.GetString(); + } + } + + return null; + } + + // --- Extraction --------------------------------------------------------------- + + private static Task ExtractArchiveAsync(string archivePath, string outputDir, CancellationToken cancellationToken) + { + Directory.CreateDirectory(outputDir); + + if (archivePath.EndsWith(".tar.gz", StringComparison.OrdinalIgnoreCase)) + { + return ExtractTarGzAsync(archivePath, outputDir, cancellationToken); + } + + if (archivePath.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + return Task.Run(() => ZipFile.ExtractToDirectory(archivePath, outputDir, overwriteFiles: true), cancellationToken); + } + + throw new InvalidOperationException($"Unsupported archive format: {archivePath}"); + } + + private static async Task ExtractTarGzAsync(string archivePath, string outputDir, CancellationToken cancellationToken) + { + await using var fs = File.OpenRead(archivePath); + await using var gz = new GZipStream(fs, CompressionMode.Decompress); + await TarFile.ExtractToDirectoryAsync(gz, outputDir, overwriteFiles: true, cancellationToken: cancellationToken); + } + + // --- Overlay helpers ---------------------------------------------------------- + + /// + /// Selects the highest net{X}.0 directory under lib/ using a numeric comparison. + /// Lexicographic ordering puts "net9.0" above "net10.0", which would silently overlay + /// the wrong managed assemblies if BCS ever ships multiple TFMs. + /// + internal static string SelectHighestManagedDir(string libDir) + { + if (!Directory.Exists(libDir)) + { + return null; + } + + (int major, int minor, string path) Parse(string dir) + { + var name = Path.GetFileName(dir); + if (name.StartsWith("net", StringComparison.OrdinalIgnoreCase)) + { + var rest = name.Substring(3); + var dot = rest.IndexOf('.'); + if (dot > 0 && + int.TryParse(rest.Substring(0, dot), out var maj) && + int.TryParse(rest.Substring(dot + 1), out var min)) + { + return (maj, min, dir); + } + } + + return (-1, -1, dir); + } + + return Directory.GetDirectories(libDir) + .Select(Parse) + .OrderByDescending(t => t.major) + .ThenByDescending(t => t.minor) + .Select(t => t.path) + .FirstOrDefault(); + } + + private static int CopyManaged(string runtimesDir, string destinationDir) + { + int copied = 0; + var libDir = Path.Combine(runtimesDir, "lib"); + var managedDir = SelectHighestManagedDir(libDir); + + if (managedDir == null) + { + return 0; + } + + Directory.CreateDirectory(destinationDir); + + foreach (var file in Directory.GetFiles(managedDir, "*.dll")) + { + var dest = Path.Combine(destinationDir, Path.GetFileName(file)); + File.Copy(file, dest, overwrite: true); + copied++; + } + + return copied; + } + + private static int CopyNative(string runtimesDir, string destinationDir) + { + int copied = 0; + var nativeDir = Path.Combine(runtimesDir, "native"); + if (!Directory.Exists(nativeDir)) + { + return 0; + } + + Directory.CreateDirectory(destinationDir); + + foreach (var file in Directory.GetFiles(nativeDir)) + { + var fileName = Path.GetFileName(file); + if (fileName.EndsWith(".pdb", StringComparison.OrdinalIgnoreCase) || + fileName.EndsWith(".dbg", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var dest = Path.Combine(destinationDir, fileName); + File.Copy(file, dest, overwrite: true); + EnsureExecutable(dest); + copied++; + } + + return copied; + } + + private static int CopyHostBinaryIfPresent(string sourceDir, string destDir, string fileName) + { + var sourcePath = Path.Combine(sourceDir, fileName); + if (!File.Exists(sourcePath)) + { + return 0; + } + + Directory.CreateDirectory(destDir); + var destPath = Path.Combine(destDir, fileName); + File.Copy(sourcePath, destPath, overwrite: true); + EnsureExecutable(destPath); + return 1; + } + + /// + /// Recursively copies a directory tree. Used to materialize the per-job dotnet home + /// from the global feed-installed one. Native files and the dotnet host are chmod'd + /// executable on Unix-like systems. + /// + internal static void CopyDirectory(string sourceDir, string destDir) + { + Directory.CreateDirectory(destDir); + + foreach (var file in Directory.EnumerateFiles(sourceDir)) + { + var destFile = Path.Combine(destDir, Path.GetFileName(file)); + File.Copy(file, destFile, overwrite: true); + EnsureExecutable(destFile); + } + + foreach (var subDir in Directory.EnumerateDirectories(sourceDir)) + { + CopyDirectory(subDir, Path.Combine(destDir, Path.GetFileName(subDir))); + } + } + + /// + /// On Unix-like systems, ensures the destination file has the user-execute bit set. + /// Native libs don't strictly require +x, but the dotnet host and apphost do, and the + /// File.Copy + overwrite path can drop the bit if the destination didn't have it. + /// + private static void EnsureExecutable(string path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + try + { + var current = File.GetUnixFileMode(path); + var withExec = current + | UnixFileMode.UserExecute + | UnixFileMode.GroupExecute + | UnixFileMode.OtherExecute; + if (current != withExec) + { + File.SetUnixFileMode(path, withExec); + } + } + catch + { + // Best-effort; some filesystems (FAT, network shares) don't support mode bits. + } + } + + private static string FindDirectory(string root, string directoryName) + { + if (!Directory.Exists(root)) + { + return null; + } + + foreach (var dir in Directory.GetDirectories(root)) + { + if (Path.GetFileName(dir).Equals(directoryName, StringComparison.OrdinalIgnoreCase)) + { + return dir; + } + } + + return null; + } + + private static string FindCorehostDirectory(string extractDir, string rid) + { + var primary = Path.Combine(extractDir, $"{rid}.Release", "corehost"); + if (Directory.Exists(primary)) + { + return primary; + } + + var alternate = Path.Combine(extractDir, "corehost"); + if (Directory.Exists(alternate)) + { + return alternate; + } + + return null; + } + + // --- Platform / RID mapping --------------------------------------------------- + + private static string ResolveBuildCacheConfig(string buildCacheConfig) + { + if (!string.IsNullOrEmpty(buildCacheConfig)) + { + return buildCacheConfig; + } + + var rid = GetPlatformMoniker(); + if (PlatformToBcsConfig.TryGetValue(rid, out var mapped)) + { + return mapped.configKey; + } + + throw new InvalidOperationException( + $"No Build Cache configuration mapping for platform '{rid}'. Specify buildCacheConfig explicitly."); + } + + private static string GetArtifactFile(string buildCacheConfig) + { + var match = PlatformToBcsConfig.Values.FirstOrDefault(v => + string.Equals(v.configKey, buildCacheConfig, StringComparison.OrdinalIgnoreCase)); + + if (match.artifactFile == null) + { + throw new InvalidOperationException( + $"Unknown Build Cache configuration key: '{buildCacheConfig}'."); + } + + return match.artifactFile; + } + + /// + /// Maps a BCS config key back to its RID. Use this for overlay path discovery so an + /// explicit musl/cross-arch override actually finds the right runtime pack inside the + /// archive instead of falling back to the host's detected RID. + /// + internal static string GetRidForConfig(string buildCacheConfig) + { + var match = PlatformToBcsConfig.Values.FirstOrDefault(v => + string.Equals(v.configKey, buildCacheConfig, StringComparison.OrdinalIgnoreCase)); + + if (match.rid == null) + { + throw new InvalidOperationException( + $"Unknown Build Cache configuration key: '{buildCacheConfig}'."); + } + + return match.rid; + } + + internal static string GetNativeLibName(string baseName) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return $"{baseName}.dll"; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return $"lib{baseName}.dylib"; + } + + return $"lib{baseName}.so"; + } + + private static string GetDotnetExecutableName() + => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; + + internal static string GetPlatformMoniker() + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return RuntimeInformation.ProcessArchitecture switch + { + Architecture.Arm64 => "win-arm64", + Architecture.X86 => "win-x86", + _ => "win-x64", + }; + } + + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "osx-arm64" : "osx-x64"; + } + + return RuntimeInformation.ProcessArchitecture == Architecture.Arm64 ? "linux-arm64" : "linux-x64"; + } + + private static string SanitizeForPath(string value) + { + if (string.IsNullOrEmpty(value)) + { + return "default"; + } + + var invalid = Path.GetInvalidFileNameChars(); + return string.Concat(value.Select(c => invalid.Contains(c) ? '_' : c)); + } + + internal static string ShortSha(string commitSha) + => string.IsNullOrEmpty(commitSha) + ? string.Empty + : commitSha.Substring(0, Math.Min(8, commitSha.Length)); + + // --- DTOs --------------------------------------------------------------------- + + internal class LatestBuildsResponse + { + public string BranchName { get; set; } + public Dictionary Entries { get; set; } = new(StringComparer.OrdinalIgnoreCase); + } + + internal class LatestBuildEntry + { + public string CommitSha { get; set; } + public string CommitTime { get; set; } + } + } +} diff --git a/src/Microsoft.Crank.Agent/JobContext.cs b/src/Microsoft.Crank.Agent/JobContext.cs index a358b0e42..6959636bb 100644 --- a/src/Microsoft.Crank.Agent/JobContext.cs +++ b/src/Microsoft.Crank.Agent/JobContext.cs @@ -32,6 +32,21 @@ public class JobContext public string DockerImage { get; set; } public string DockerContainerId { get; set; } + /// + /// Per-job isolated dotnet home used for buildcache runs. When non-null, the agent should + /// run the application using this root (so the BCS-overlaid runtime is loaded), and the + /// global dotnet home is left untouched for concurrent jobs. The directory is owned by + /// the job and deleted when the job completes. + /// + public string BuildCacheDotnetHome { get; set; } + + /// + /// Temporary directory holding the extracted BCS archive for this job. Deleted at the + /// end of the job; the underlying archive in the parent commit directory is kept so + /// subsequent jobs for the same commit can reuse it without re-downloading. + /// + public string BuildCacheExtractDir { get; set; } + public ulong EventPipeSessionId { get; set; } public Task EventPipeTask { get; set; } public bool EventPipeTerminated { get; set; } diff --git a/src/Microsoft.Crank.Agent/Startup.cs b/src/Microsoft.Crank.Agent/Startup.cs index 8168a2e88..be2dbf116 100644 --- a/src/Microsoft.Crank.Agent/Startup.cs +++ b/src/Microsoft.Crank.Agent/Startup.cs @@ -109,6 +109,11 @@ public class Startup "https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet-public/nuget/v3/flat2" ]; + // Build Cache Service configuration + private static string _buildCacheBaseUrl = "https://pvscmdupload.z22.web.core.windows.net"; + private static string _buildCacheRepoName = "runtime"; + private static bool _buildCacheEnabled = true; + // Cached lists of SDKs and runtimes already installed private static readonly HashSet _installedAspNetRuntimes = new(StringComparer.OrdinalIgnoreCase); private static readonly HashSet _installedDotnetRuntimes = new(StringComparer.OrdinalIgnoreCase); @@ -270,6 +275,10 @@ public static int Main(string[] args) _certSniAuth = app.Option("--cert-sni", "Enable subject name / issuer based authentication (SNI).", CommandOptionType.NoValue); _managedIdentityClientId = app.Option("--mi-client-id", "Client ID of the user-assigned managed identity to use for authentication.", CommandOptionType.SingleValue); + var buildCacheBaseUrlOption = app.Option("--build-cache-base-url", $"Base URL for Build Cache Service blob storage. Default is '{_buildCacheBaseUrl}'.", CommandOptionType.SingleValue); + var buildCacheRepoNameOption = app.Option("--build-cache-repo-name", $"Repository name for Build Cache Service. Default is '{_buildCacheRepoName}'.", CommandOptionType.SingleValue); + var buildCacheDisabledOption = app.Option("--build-cache-disabled", "Disable Build Cache Service integration.", CommandOptionType.NoValue); + app.OnExecute(() => { var logConf = new LoggerConfiguration() @@ -278,6 +287,21 @@ public static int Main(string[] args) .Enrich.FromLogContext() .WriteTo.Console(theme: AnsiConsoleTheme.Code); + if (buildCacheBaseUrlOption.HasValue()) + { + _buildCacheBaseUrl = buildCacheBaseUrlOption.Value(); + } + + if (buildCacheRepoNameOption.HasValue()) + { + _buildCacheRepoName = buildCacheRepoNameOption.Value(); + } + + if (buildCacheDisabledOption.HasValue()) + { + _buildCacheEnabled = false; + } + if (_runAsService.HasValue() && OperatingSystem != OperatingSystem.Windows) { throw new PlatformNotSupportedException($"--service is only available on Windows"); @@ -1015,7 +1039,7 @@ private static async Task ProcessJobs(string hostname, string dockerHostname, Ca { buildAndRunTask = Task.Run(async () => { - benchmarksDir = await CloneRestoreAndBuild(tempDir, job, _dotnethome, cts.Token); + benchmarksDir = await CloneRestoreAndBuild(tempDir, job, _dotnethome, context, cts.Token); if (benchmarksDir == null) { @@ -1027,7 +1051,10 @@ private static async Task ProcessJobs(string hostname, string dockerHostname, Ca { try { - process = await StartProcess(hostname, Path.Combine(tempDir, benchmarksDir), job, _dotnethome, context); + // For buildcache jobs the per-job isolated dotnet home holds the + // BCS-overlaid runtime; run against it instead of the global home. + var runtimeDotnetHome = context.BuildCacheDotnetHome ?? _dotnethome; + process = await StartProcess(hostname, Path.Combine(tempDir, benchmarksDir), job, runtimeDotnetHome, context); Log.Info($"Process started: {job.ProcessId}"); @@ -1910,6 +1937,22 @@ async Task DeleteJobAsync() await TryDeleteDirAsync(tempDir); } + // Build Cache: clean up per-job extracted artifacts and the isolated + // dotnet home so concurrent / future jobs do not see stale state and + // /tmp does not accumulate multi-GB extracts. + if (_cleanup && !job.NoClean) + { + BuildCacheClient.CleanupExtractDir(context.BuildCacheExtractDir); + + if (!string.IsNullOrEmpty(context.BuildCacheDotnetHome)) + { + await TryDeleteDirAsync(context.BuildCacheDotnetHome); + } + + context.BuildCacheExtractDir = null; + context.BuildCacheDotnetHome = null; + } + // Delete temporary attachment files // NB: Attachments are already deleted once they are copied, unless the job fails // to reach that point. @@ -2832,7 +2875,7 @@ private static async Task DockerCleanUpAsync(string containerId, string imageNam } } - private static async Task CloneRestoreAndBuild(string path, Job job, string dotnetHome, CancellationToken cancellationToken = default) + private static async Task CloneRestoreAndBuild(string path, Job job, string dotnetHome, JobContext jobContext = null, CancellationToken cancellationToken = default) { var reuseFolder = await RetrieveSourcesAsync(job, path); @@ -2933,23 +2976,83 @@ private static async Task CloneRestoreAndBuild(string path, Job job, str runtimeVersion = channel; } + // For buildcache channel, SDK/ASP.NET/Desktop use "latest" since BCS only has runtime + var nonRuntimeChannel = String.Equals(channel, "buildcache", StringComparison.OrdinalIgnoreCase) ? "latest" : channel; + if (String.IsNullOrEmpty(desktopVersion)) { - desktopVersion = channel; + desktopVersion = nonRuntimeChannel; } if (String.IsNullOrEmpty(aspNetCoreVersion)) { - aspNetCoreVersion = channel; + aspNetCoreVersion = nonRuntimeChannel; } if (String.IsNullOrEmpty(sdkVersion)) { - sdkVersion = channel; + sdkVersion = nonRuntimeChannel; } runtimeVersion = await ResolveRuntimeVersion(buildToolsPath, targetFramework, runtimeVersion); + // Build Cache Service: if the runtime version is "BuildCache", prepare BCS artifacts + // and resolve a real "Latest" runtime version for the NuGet build. + var useBuildCache = String.Equals(runtimeVersion, "BuildCache", StringComparison.OrdinalIgnoreCase); + string buildCacheCommitSha = null; + string buildCacheExtractDir = null; + + string buildCacheConfigResolved = null; + if (useBuildCache) + { + if (!_buildCacheEnabled) + { + job.Error = "Build Cache channel was requested but Build Cache Service is disabled on this agent (--build-cache-disabled)."; + return null; + } + + // Validate user-supplied commit SHA early so we can fail with a clear message instead + // of throwing later from a Substring call. + if (!string.IsNullOrEmpty(job.BuildCacheCommitSha) && job.BuildCacheCommitSha.Length < 8) + { + job.Error = $"Build Cache: 'buildCacheCommitSha' must be at least 8 characters long (got '{job.BuildCacheCommitSha}')."; + return null; + } + + try + { + var branch = !string.IsNullOrEmpty(job.BuildCacheBranch) ? job.BuildCacheBranch : "main"; + var commitSha = job.BuildCacheCommitSha; + var buildCacheConfig = job.BuildCacheConfig; + + // Resolve which commit and config to use + var resolved = await BuildCacheClient.ResolveCommitAsync( + _buildCacheBaseUrl, _buildCacheRepoName, branch, commitSha, buildCacheConfig, cancellationToken); + + buildCacheCommitSha = resolved.commitSha; + buildCacheConfigResolved = resolved.buildCacheConfig; + + // Download and extract the BCS artifacts to a per-job temp directory + buildCacheExtractDir = await BuildCacheClient.DownloadAndExtractAsync( + _buildCacheBaseUrl, _buildCacheRepoName, buildCacheCommitSha, buildCacheConfigResolved, + cancellationToken); + + var shortSha = BuildCacheClient.ShortSha(buildCacheCommitSha); + Log.Info($"Build Cache: Artifacts for commit {shortSha} ready for post-build overlay"); + + // Resolve a REAL runtime version from feeds for the NuGet build. We deliberately keep + // runtimeVersion pointing at this feed-resolved version so PatchRuntimeConfig and the + // dotnet-install steps agree; the BCS bits are overlaid on top of that exact version. + runtimeVersion = await ResolveRuntimeVersion(buildToolsPath, targetFramework, "Latest"); + Log.Info($"Runtime for build: {runtimeVersion} (Latest from feeds, will be overlaid with BCS commit {shortSha})"); + } + catch (Exception ex) + { + job.Error = $"Build Cache: {ex.Message}"; + return null; + } + } + sdkVersion = await ResolveSdkVersion(sdkVersion, targetFramework); aspNetCoreVersion = await ResolveAspNetCoreVersion(aspNetCoreVersion, targetFramework); @@ -3206,6 +3309,44 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () => var dotnetDir = dotnetHome; + // Build Cache: build a per-job dotnet home that contains BCS-overlaid runtime + asp.net + // + host. We DO NOT mutate the global dotnet home — concurrent jobs and subsequent + // non-buildcache jobs must remain unaffected. The publish step continues to use the + // global dotnetDir (it has the SDK); only the runtime-resolution paths (metadata + // reading, crossgen/symbols emit, StartProcess) point at the per-job home. + var runtimeHomeDir = dotnetDir; + + if (useBuildCache && buildCacheExtractDir != null) + { + try + { + var bcsHome = BuildCacheClient.CreateBuildCacheDotnetHome( + dotnetDir, + buildCacheExtractDir, + runtimeVersion, + aspNetCoreVersion, + buildCacheCommitSha, + job.BuildCacheConfig); + + runtimeHomeDir = bcsHome; + + // Stash on the JobContext so StartProcess uses this isolated home (FDD) and + // so the cleanup pass at job end can delete it. + if (jobContext != null) + { + jobContext.BuildCacheDotnetHome = bcsHome; + jobContext.BuildCacheExtractDir = buildCacheExtractDir; + } + + Log.Info($"Build Cache: Isolated dotnet home: {bcsHome}"); + } + catch (Exception ex) + { + job.Error = $"Build Cache: failed to build isolated dotnet home: {ex.Message}"; + return null; + } + } + // Updating Job to reflect actual versions used job.AspNetCoreVersion = aspNetCoreVersion; job.RuntimeVersion = runtimeVersion; @@ -3239,7 +3380,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () => { try { - var aspNetCoreVersionFileName = Path.Combine(dotnetDir, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion, ".version"); + var aspNetCoreVersionFileName = Path.Combine(runtimeHomeDir, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion, ".version"); (_, var aspnetCoreCommitHash) = await ParseLatestVersionFile(aspNetCoreVersionFileName); job.Metadata.Enqueue(new MeasurementMetadata @@ -3272,7 +3413,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () => { try { - var netCoreAppVersionFileName = Path.Combine(dotnetDir, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"); + var netCoreAppVersionFileName = Path.Combine(runtimeHomeDir, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version"); (_, var netCoreAppCommitHash) = await ParseLatestVersionFile(netCoreAppVersionFileName); job.Metadata.Enqueue(new MeasurementMetadata @@ -3434,6 +3575,50 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () => Log.Info($"Application published successfully in {job.BuildTime.TotalMilliseconds} ms"); + // Build Cache: overlay BCS runtime binaries onto the just-published app. The + // per-job dotnet home was built earlier (used for FDD execution + metadata); + // here we cover the SCD case where the runtime ships in the publish output. + // PatchRuntimeConfig still runs with the feed-resolved runtimeVersion so + // runtimeconfig.json points to a real installed shared-framework dir. + if (useBuildCache && buildCacheExtractDir != null) + { + var shortSha = BuildCacheClient.ShortSha(buildCacheCommitSha); + + int publishedOverlay; + try + { + var publishProjectFileName = Path.Combine(benchmarkedApp, FormatPathSeparators(job.Project)); + var assemblyName = GetAssemblyName(job, publishProjectFileName); + + publishedOverlay = BuildCacheClient.OverlayPublishedOutput( + buildCacheExtractDir, + outputFolder, + job.BuildCacheConfig, + assemblyName); + + Log.Info($"Build Cache: Overlaid {publishedOverlay} files into published output (commit {shortSha})"); + } + catch (Exception ex) + { + job.Error = $"Build Cache: published-output overlay failed: {ex.Message}"; + return null; + } + + // For self-contained publishes the published output must contain runtime binaries + // (managed + native + apphost). For framework-dependent publishes 0 is acceptable + // here because the per-job dotnet home already provides BCS bits at runtime. + if (job.SelfContained && publishedOverlay == 0) + { + job.Error = $"Build Cache: published-output overlay copied 0 files for self-contained " + + $"commit {shortSha}. The archive layout may have changed or the platform is not supported."; + return null; + } + + // Record the BCS commit alongside the runtime version for reporting. We append rather than + // replace so PatchRuntimeConfig still sees a valid feed-resolved version below. + job.RuntimeVersion = $"{runtimeVersion}+buildcache.{shortSha}"; + } + PatchRuntimeConfig(job, outputFolder, aspNetCoreVersion, runtimeVersion); } @@ -3532,7 +3717,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () => { var crossgenFolder = job.SelfContained ? outputFolder - : Path.Combine(dotnetDir, "shared", "Microsoft.NETCore.App", runtimeVersion) + : Path.Combine(runtimeHomeDir, "shared", "Microsoft.NETCore.App", runtimeVersion) ; var crossgenFilename = Path.Combine(crossgenFolder, "crossgen"); @@ -3560,7 +3745,7 @@ await ProcessUtil.RetryOnExceptionAsync(3, async () => var symbolsFolder = job.SelfContained ? outputFolder - : Path.Combine(dotnetDir, "shared", "Microsoft.NETCore.App", runtimeVersion) + : Path.Combine(runtimeHomeDir, "shared", "Microsoft.NETCore.App", runtimeVersion) ; // dotnet symbol --symbols --output mySymbols /usr/share/dotnet/shared/Microsoft.NETCore.App/2.1.0/lib*.so @@ -4582,6 +4767,13 @@ private static async Task ResolveRuntimeVersion(string buildToolsPath, s break; } } + else if (String.Equals(runtimeVersion, "BuildCache", StringComparison.OrdinalIgnoreCase)) + { + // BuildCache channel: version resolution is deferred to InstallRuntimeFromBuildCacheAsync + // because it needs to download artifacts. We return a placeholder here. + runtimeVersion = "BuildCache"; + Log.Info($"Runtime: will be resolved from Build Cache Service"); + } else { // Custom version diff --git a/src/Microsoft.Crank.Models/Job.cs b/src/Microsoft.Crank.Models/Job.cs index 1128cd0f0..ee1537cb4 100644 --- a/src/Microsoft.Crank.Models/Job.cs +++ b/src/Microsoft.Crank.Models/Job.cs @@ -71,6 +71,11 @@ public class Job public string UseMonoRuntime { get; set; } = ""; public bool NoGlobalJson { get; set; } + // Build Cache Service properties for per-commit runtime resolution + public string BuildCacheCommitSha { get; set; } = ""; + public string BuildCacheBranch { get; set; } = ""; + public string BuildCacheConfig { get; set; } = ""; + // Delay from the process started to the console receiving "Application started" public TimeSpan StartupMainMethod { get; set; } public TimeSpan BuildTime { get; set; } @@ -399,7 +404,10 @@ public BuildKeyData GetBuildKeyData() DockerPull = DockerPull, DockerFile = DockerFile, DockerImageName = DockerImageName, - DockerContextDirectory = DockerContextDirectory + DockerContextDirectory = DockerContextDirectory, + BuildCacheCommitSha = BuildCacheCommitSha, + BuildCacheBranch = BuildCacheBranch, + BuildCacheConfig = BuildCacheConfig }; } @@ -508,5 +516,8 @@ public class BuildKeyData public string DockerFile { get; set; } public string DockerImageName { get; set; } public string DockerContextDirectory { get; set; } + public string BuildCacheCommitSha { get; set; } + public string BuildCacheBranch { get; set; } + public string BuildCacheConfig { get; set; } } } diff --git a/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs b/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs new file mode 100644 index 000000000..21cfe42bd --- /dev/null +++ b/test/Microsoft.Crank.UnitTests/BuildCacheClientTests.cs @@ -0,0 +1,578 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Runtime.InteropServices; +using Microsoft.Crank.Agent; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.Crank.UnitTests +{ + public class BuildCacheClientTests : IDisposable + { + private readonly ITestOutputHelper _output; + private readonly string _testDir; + + public BuildCacheClientTests(ITestOutputHelper output) + { + _output = output; + _testDir = Path.Combine(Path.GetTempPath(), "crank_buildcache_tests_" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_testDir); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_testDir)) + { + Directory.Delete(_testDir, true); + } + } + catch + { + // Ignore cleanup errors + } + } + + // ------------------------------------------------------------------- + // ParseLatestBuilds + // ------------------------------------------------------------------- + + [Fact] + public void ParseLatestBuilds_PascalCase_ParsesCommitShaAndTime() + { + const string json = """ + { + "BranchName": "main", + "coreclr_x64_linux": { + "CommitSha": "abc123def456", + "CommitTime": "2025-01-01T00:00:00Z" + } + } + """; + + var result = BuildCacheClient.ParseLatestBuilds(json); + + Assert.Equal("main", result.BranchName); + Assert.Equal("abc123def456", result.Entries["coreclr_x64_linux"].CommitSha); + } + + [Fact] + public void ParseLatestBuilds_SnakeCase_ParsesCommitShaAndTime() + { + const string json = """ + { + "branch_name": "release/10.0", + "coreclr_arm64_linux": { + "commit_sha": "deadbeef", + "commit_time": "2025-02-02T00:00:00Z" + } + } + """; + + var result = BuildCacheClient.ParseLatestBuilds(json); + + Assert.Equal("release/10.0", result.BranchName); + Assert.Equal("deadbeef", result.Entries["coreclr_arm64_linux"].CommitSha); + } + + [Fact] + public void ParseLatestBuilds_MixedCasing_ParsesAllConfigs() + { + const string json = """ + { + "branch_name": "main", + "coreclr_x64_windows": { "CommitSha": "win123", "CommitTime": "2025-03-03" }, + "coreclr_x64_linux": { "commit_sha": "lnx456", "commit_time": "2025-04-04" } + } + """; + + var result = BuildCacheClient.ParseLatestBuilds(json); + + Assert.Equal(2, result.Entries.Count); + Assert.Equal("win123", result.Entries["coreclr_x64_windows"].CommitSha); + Assert.Equal("lnx456", result.Entries["coreclr_x64_linux"].CommitSha); + } + + [Fact] + public void ParseLatestBuilds_NonObjectValues_AreSkipped() + { + const string json = """ + { + "branch_name": "main", + "schemaVersion": 2, + "lastUpdated": "2025-01-01", + "coreclr_x64_linux": { "CommitSha": "abc" } + } + """; + + var result = BuildCacheClient.ParseLatestBuilds(json); + + Assert.Single(result.Entries); + Assert.True(result.Entries.ContainsKey("coreclr_x64_linux")); + } + + // ------------------------------------------------------------------- + // ValidateCommitSha + // ------------------------------------------------------------------- + + [Theory] + [InlineData("")] + [InlineData(null)] + [InlineData("abcdef12")] // min length + [InlineData("ABCDEF12")] // upper hex + [InlineData("603403d9cb49d3d1c35b56bcff024ce99a8c5c3a")] // full 40 + public void ValidateCommitSha_AcceptsValid(string sha) + { + BuildCacheClient.ValidateCommitSha(sha); + } + + [Theory] + [InlineData("abc")] // too short + [InlineData("ghijklmn")] // non-hex + [InlineData("abcd 1234")] // contains space + [InlineData("../../../etc/passwd")] // path traversal attempt + [InlineData("aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa")] // 41 chars, too long + public void ValidateCommitSha_RejectsInvalid(string sha) + { + Assert.Throws(() => BuildCacheClient.ValidateCommitSha(sha)); + } + + // ------------------------------------------------------------------- + // ShortSha + // ------------------------------------------------------------------- + + [Fact] + public void ShortSha_LongInput_ReturnsFirstEight() + { + Assert.Equal("abcdef12", BuildCacheClient.ShortSha("abcdef1234567890")); + } + + [Fact] + public void ShortSha_ShortInput_ReturnsAsIs() + { + Assert.Equal("abc", BuildCacheClient.ShortSha("abc")); + } + + [Fact] + public void ShortSha_NullOrEmpty_ReturnsEmpty() + { + Assert.Equal(string.Empty, BuildCacheClient.ShortSha(null)); + Assert.Equal(string.Empty, BuildCacheClient.ShortSha("")); + } + + // ------------------------------------------------------------------- + // Platform / RID mapping + // ------------------------------------------------------------------- + + [Fact] + public void GetPlatformMoniker_ReturnsKnownRid() + { + var rid = BuildCacheClient.GetPlatformMoniker(); + + var validRids = new[] + { + "linux-x64", "linux-arm64", + "win-x64", "win-arm64", "win-x86", + "osx-x64", "osx-arm64", + }; + + Assert.Contains(rid, validRids); + } + + [Theory] + [InlineData("coreclr_x64_linux", "linux-x64")] + [InlineData("coreclr_arm64_linux", "linux-arm64")] + [InlineData("coreclr_muslx64_linux", "linux-musl-x64")] + [InlineData("coreclr_x64_windows", "win-x64")] + [InlineData("coreclr_arm64_windows", "win-arm64")] + [InlineData("coreclr_x86_windows", "win-x86")] + public void GetRidForConfig_ReturnsMatchingRid(string configKey, string expectedRid) + { + Assert.Equal(expectedRid, BuildCacheClient.GetRidForConfig(configKey)); + } + + [Fact] + public void GetRidForConfig_UnknownConfig_Throws() + { + Assert.Throws(() => BuildCacheClient.GetRidForConfig("totally_unknown")); + } + + // ------------------------------------------------------------------- + // SelectHighestManagedDir (numeric-aware) + // ------------------------------------------------------------------- + + [Fact] + public void SelectHighestManagedDir_NumericOrderNotLexicographic() + { + var libDir = Path.Combine(_testDir, "lib"); + Directory.CreateDirectory(Path.Combine(libDir, "net8.0")); + Directory.CreateDirectory(Path.Combine(libDir, "net9.0")); + Directory.CreateDirectory(Path.Combine(libDir, "net10.0")); + Directory.CreateDirectory(Path.Combine(libDir, "net11.0")); + + // Lexicographic: net9.0 > net8.0 > net11.0 > net10.0 (wrong). + // Numeric: net11.0 > net10.0 > net9.0 > net8.0 (correct). + var selected = BuildCacheClient.SelectHighestManagedDir(libDir); + + Assert.Equal("net11.0", Path.GetFileName(selected)); + } + + [Fact] + public void SelectHighestManagedDir_NoDirs_ReturnsNull() + { + var libDir = Path.Combine(_testDir, "empty-lib"); + Directory.CreateDirectory(libDir); + + Assert.Null(BuildCacheClient.SelectHighestManagedDir(libDir)); + } + + [Fact] + public void SelectHighestManagedDir_MissingDir_ReturnsNull() + { + Assert.Null(BuildCacheClient.SelectHighestManagedDir(Path.Combine(_testDir, "does-not-exist"))); + } + + // ------------------------------------------------------------------- + // OverlayPublishedOutput + // ------------------------------------------------------------------- + + [Fact] + public void OverlayPublishedOutput_CopiesRuntimeFilesAndHostpolicyButNotApphost() + { + // The BCS archive ships an unbound apphost (the SDK normally binds the published + // managed DLL path into the executable during publish). Overlaying the raw BCS apphost + // on top of the SDK-bound one breaks the published app, so we deliberately skip it. + var rid = BuildCacheClient.GetPlatformMoniker(); + var configKey = ConfigKeyForRid(rid); + var (extractDir, _, managed, native) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: true); + + var outputFolder = Path.Combine(_testDir, "published"); + Directory.CreateDirectory(outputFolder); + + // Pre-existing SDK-bound apphost that must NOT be overwritten. + var apphostName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "MyApp.exe" : "MyApp"; + File.WriteAllText(Path.Combine(outputFolder, apphostName), "SDK_BOUND_APPHOST"); + + var copied = BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder, configKey, "MyApp"); + + // managed + native + hostpolicy (no apphost contribution) + Assert.True(copied >= managed.Count + native.Count + 1); + + foreach (var dll in managed) + { + Assert.True(File.Exists(Path.Combine(outputFolder, dll)), $"Missing managed file {dll}"); + } + foreach (var n in native) + { + Assert.True(File.Exists(Path.Combine(outputFolder, n)), $"Missing native file {n}"); + } + + Assert.True(File.Exists(Path.Combine(outputFolder, BuildCacheClient.GetNativeLibName("hostpolicy")))); + + // SDK-bound apphost preserved. + Assert.Equal("SDK_BOUND_APPHOST", File.ReadAllText(Path.Combine(outputFolder, apphostName))); + } + + [Fact] + public void OverlayPublishedOutput_EmptyExtract_ReturnsZero() + { + var rid = BuildCacheClient.GetPlatformMoniker(); + var configKey = ConfigKeyForRid(rid); + + var extractDir = Path.Combine(_testDir, "empty"); + Directory.CreateDirectory(extractDir); + + var outputFolder = Path.Combine(_testDir, "output"); + Directory.CreateDirectory(outputFolder); + + var copied = BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder, configKey, "MyApp"); + Assert.Equal(0, copied); + } + + [Fact] + public void OverlayPublishedOutput_SkipsPdbAndDbg() + { + var rid = BuildCacheClient.GetPlatformMoniker(); + var configKey = ConfigKeyForRid(rid); + var (extractDir, runtimesDir, _, _) = BuildFakeBcsArchive(rid, includeHost: false, includeApphost: false); + + var nativeDir = Path.Combine(runtimesDir, "native"); + File.WriteAllText(Path.Combine(nativeDir, "coreclr.pdb"), "pdb"); + File.WriteAllText(Path.Combine(nativeDir, "libcoreclr.dbg"), "dbg"); + + var outputFolder = Path.Combine(_testDir, "published-pdb"); + Directory.CreateDirectory(outputFolder); + + BuildCacheClient.OverlayPublishedOutput(extractDir, outputFolder, configKey, "MyApp"); + + Assert.False(File.Exists(Path.Combine(outputFolder, "coreclr.pdb"))); + Assert.False(File.Exists(Path.Combine(outputFolder, "libcoreclr.dbg"))); + } + + // ------------------------------------------------------------------- + // CreateBuildCacheDotnetHome — the heart of round 3 + // ------------------------------------------------------------------- + + [Fact] + public void CreateBuildCacheDotnetHome_MirrorsGlobalAndOverlaysBcs() + { + var rid = BuildCacheClient.GetPlatformMoniker(); + var configKey = ConfigKeyForRid(rid); + var (extractDir, _, managed, native) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: false); + + const string runtimeVersion = "11.0.0-preview.5.26256.117"; + const string aspNetCoreVersion = "11.0.0-preview.5.26256.117"; + var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, aspNetCoreVersion); + var commitSha = "603403d9cb49d3d1c35b56bcff024ce99a8c5c3a"; + + var bcsHome = BuildCacheClient.CreateBuildCacheDotnetHome( + globalHome, extractDir, runtimeVersion, aspNetCoreVersion, commitSha, configKey); + + try + { + // 1. Global dotnet home must NOT be touched (no cross-job pollution). + var globalVersion = File.ReadAllText(Path.Combine(globalHome, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version")); + Assert.Contains("FEED_COMMIT", globalVersion); + Assert.DoesNotContain(commitSha, globalVersion); + + // 2. Per-job home exists with BCS overlay applied. + Assert.True(Directory.Exists(bcsHome)); + var bcsNetCoreApp = Path.Combine(bcsHome, "shared", "Microsoft.NETCore.App", runtimeVersion); + + foreach (var dll in managed) + { + Assert.True(File.Exists(Path.Combine(bcsNetCoreApp, dll)), $"Missing BCS managed {dll}"); + } + foreach (var n in native) + { + Assert.True(File.Exists(Path.Combine(bcsNetCoreApp, n)), $"Missing BCS native {n}"); + } + + // 3. .version was rewritten with BCS commit. + var bcsVersion = File.ReadAllText(Path.Combine(bcsNetCoreApp, ".version")); + Assert.Contains(commitSha, bcsVersion); + + // 4. ASP.NET Core dir was mirrored (from global, not overlaid). + Assert.True(Directory.Exists(Path.Combine(bcsHome, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion))); + + // 5. dotnet host binary is present. + var dotnetExeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; + Assert.True(File.Exists(Path.Combine(bcsHome, dotnetExeName))); + + // 6. host/fxr was mirrored AND overlaid. + var hostFxrFile = Path.Combine(bcsHome, "host", "fxr", runtimeVersion, BuildCacheClient.GetNativeLibName("hostfxr")); + Assert.True(File.Exists(hostFxrFile)); + } + finally + { + try { Directory.Delete(bcsHome, recursive: true); } catch { } + } + } + + [Fact] + public void CreateBuildCacheDotnetHome_NoBcsBitsForPlatform_Throws() + { + // Build a BCS archive layout for an RID that doesn't match the host RID, so the + // overlay finds nothing. + var hostRid = BuildCacheClient.GetPlatformMoniker(); + var wrongRid = hostRid == "linux-x64" ? "win-x64" : "linux-x64"; + var (extractDir, _, _, _) = BuildFakeBcsArchive(wrongRid, includeHost: false, includeApphost: false); + + const string runtimeVersion = "11.0.0-preview.5"; + const string aspNetCoreVersion = "11.0.0-preview.5"; + var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, aspNetCoreVersion); + + // Will resolve config from host RID and search for hostRid-shaped subtree → 0 files. + var ex = Assert.Throws(() => + BuildCacheClient.CreateBuildCacheDotnetHome( + globalHome, extractDir, runtimeVersion, aspNetCoreVersion, + "abcdef0123456789", buildCacheConfig: null)); + + Assert.Contains("0 files", ex.Message); + } + + [Fact] + public void CreateBuildCacheDotnetHome_TwoConcurrentJobs_AreIsolated() + { + var rid = BuildCacheClient.GetPlatformMoniker(); + var configKey = ConfigKeyForRid(rid); + var (extractDir1, _, _, _) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: false); + var (extractDir2, _, _, _) = BuildFakeBcsArchive(rid, includeHost: true, includeApphost: false); + + const string runtimeVersion = "11.0.0-preview.5"; + var globalHome = BuildFakeGlobalDotnetHome(runtimeVersion, runtimeVersion); + var sha1 = "1111aaaa2222bbbb3333cccc4444dddd55556666"; + var sha2 = "6666eeee7777ffff8888aaaa9999bbbbccccdddd"; + + var home1 = BuildCacheClient.CreateBuildCacheDotnetHome( + globalHome, extractDir1, runtimeVersion, runtimeVersion, sha1, configKey); + var home2 = BuildCacheClient.CreateBuildCacheDotnetHome( + globalHome, extractDir2, runtimeVersion, runtimeVersion, sha2, configKey); + + try + { + Assert.NotEqual(home1, home2); + + var v1 = File.ReadAllText(Path.Combine(home1, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version")); + var v2 = File.ReadAllText(Path.Combine(home2, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version")); + + Assert.Contains(sha1, v1); + Assert.DoesNotContain(sha2, v1); + Assert.Contains(sha2, v2); + Assert.DoesNotContain(sha1, v2); + + // Global home untouched. + var globalV = File.ReadAllText(Path.Combine(globalHome, "shared", "Microsoft.NETCore.App", runtimeVersion, ".version")); + Assert.DoesNotContain(sha1, globalV); + Assert.DoesNotContain(sha2, globalV); + } + finally + { + try { Directory.Delete(home1, recursive: true); } catch { } + try { Directory.Delete(home2, recursive: true); } catch { } + } + } + + // ------------------------------------------------------------------- + // CleanupExtractDir + // ------------------------------------------------------------------- + + [Fact] + public void CleanupExtractDir_DeletesDirectory() + { + var dir = Path.Combine(_testDir, "cleanup-target"); + Directory.CreateDirectory(dir); + File.WriteAllText(Path.Combine(dir, "x.txt"), "hi"); + + BuildCacheClient.CleanupExtractDir(dir); + + Assert.False(Directory.Exists(dir)); + } + + [Fact] + public void CleanupExtractDir_MissingDir_DoesNotThrow() + { + BuildCacheClient.CleanupExtractDir(Path.Combine(_testDir, "never-existed")); + BuildCacheClient.CleanupExtractDir(null); + BuildCacheClient.CleanupExtractDir(""); + } + + // ------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------- + + private static string ConfigKeyForRid(string rid) + => BuildCacheClient.PlatformToBcsConfig.TryGetValue(rid, out var v) ? v.configKey : null; + + /// + /// Builds a fake "global" dotnet home with .version files containing a FEED commit so + /// tests can detect whether the .version was overwritten with the BCS commit. + /// + private string BuildFakeGlobalDotnetHome(string runtimeVersion, string aspNetCoreVersion) + { + var home = Path.Combine(_testDir, "global-home-" + Guid.NewGuid().ToString("N")); + var netCoreApp = Path.Combine(home, "shared", "Microsoft.NETCore.App", runtimeVersion); + var aspNetCoreApp = Path.Combine(home, "shared", "Microsoft.AspNetCore.App", aspNetCoreVersion); + var hostFxr = Path.Combine(home, "host", "fxr", runtimeVersion); + + Directory.CreateDirectory(netCoreApp); + Directory.CreateDirectory(aspNetCoreApp); + Directory.CreateDirectory(hostFxr); + + File.WriteAllText(Path.Combine(netCoreApp, ".version"), "FEED_COMMIT_DO_NOT_TOUCH\n" + runtimeVersion + "\n"); + File.WriteAllText(Path.Combine(netCoreApp, "System.Private.CoreLib.dll"), "feed managed"); + File.WriteAllText(Path.Combine(netCoreApp, BuildCacheClient.GetNativeLibName("hostpolicy")), "feed hostpolicy"); + + File.WriteAllText(Path.Combine(aspNetCoreApp, ".version"), "FEED_ASPNET\n" + aspNetCoreVersion + "\n"); + File.WriteAllText(Path.Combine(aspNetCoreApp, "Microsoft.AspNetCore.dll"), "feed aspnet"); + + File.WriteAllText(Path.Combine(hostFxr, BuildCacheClient.GetNativeLibName("hostfxr")), "feed hostfxr"); + + var dotnetExeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "dotnet.exe" : "dotnet"; + File.WriteAllText(Path.Combine(home, dotnetExeName), "feed dotnet host"); + + return home; + } + + /// + /// Builds a fake BCS extraction at microsoft.netcore.app.runtime.{rid}/Release/runtimes/{rid}/ + /// + corehost layout. adds the renamed-by-SDK apphost binary. + /// + private (string extractDir, string runtimesDir, List managed, List native) + BuildFakeBcsArchive(string rid, bool includeHost, bool includeApphost) + { + var extractDir = Path.Combine(_testDir, "extracted-" + Guid.NewGuid().ToString("N")); + var nugetPkg = Path.Combine(extractDir, $"microsoft.netcore.app.runtime.{rid}"); + var runtimesDir = Path.Combine(nugetPkg, "Release", "runtimes", rid); + var libDir = Path.Combine(runtimesDir, "lib", "net11.0"); + var nativeDir = Path.Combine(runtimesDir, "native"); + Directory.CreateDirectory(libDir); + Directory.CreateDirectory(nativeDir); + + var managed = new List + { + "System.Private.CoreLib.dll", + "System.Runtime.dll", + "System.Console.dll", + }; + foreach (var dll in managed) + { + File.WriteAllText(Path.Combine(libDir, dll), "BCS managed " + dll); + } + + List native; + if (rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase)) + { + native = new List { "coreclr.dll", "clrjit.dll" }; + } + else if (rid.StartsWith("osx-", StringComparison.OrdinalIgnoreCase)) + { + native = new List { "libcoreclr.dylib", "libclrjit.dylib" }; + } + else + { + native = new List { "libcoreclr.so", "libclrjit.so" }; + } + foreach (var n in native) + { + File.WriteAllText(Path.Combine(nativeDir, n), "BCS native " + n); + } + + if (includeHost) + { + var hostDir = Path.Combine(extractDir, $"{rid}.Release", "corehost"); + Directory.CreateDirectory(hostDir); + File.WriteAllText(Path.Combine(hostDir, NativeLibForRid(rid, "hostpolicy")), "BCS hostpolicy"); + File.WriteAllText(Path.Combine(hostDir, NativeLibForRid(rid, "hostfxr")), "BCS hostfxr"); + File.WriteAllText(Path.Combine(hostDir, rid.StartsWith("win-") ? "dotnet.exe" : "dotnet"), "BCS dotnet host"); + + if (includeApphost) + { + File.WriteAllText(Path.Combine(hostDir, rid.StartsWith("win-") ? "apphost.exe" : "apphost"), "BCS apphost"); + } + } + + return (extractDir, runtimesDir, managed, native); + } + + private static string NativeLibForRid(string rid, string baseName) + { + if (rid.StartsWith("win-", StringComparison.OrdinalIgnoreCase)) + { + return $"{baseName}.dll"; + } + if (rid.StartsWith("osx-", StringComparison.OrdinalIgnoreCase)) + { + return $"lib{baseName}.dylib"; + } + return $"lib{baseName}.so"; + } + } +}