diff --git a/eng/pipelines/libraries/helix-cache-shadow.yml b/eng/pipelines/libraries/helix-cache-shadow.yml new file mode 100644 index 00000000000000..0909d5a9f66ec3 --- /dev/null +++ b/eng/pipelines/libraries/helix-cache-shadow.yml @@ -0,0 +1,59 @@ +parameters: + enabled: false + archType: '' + osGroup: '' + osSubgroup: '' + buildConfig: '' + runtimeFlavor: '' + runtimeVariant: '' + helixQueues: '' + targetRid: '' + nameSuffix: '' + platform: '' + shouldContinueOnError: false + +steps: +- ${{ if and(eq(parameters.enabled, 'true'), eq(parameters.platform, 'linux_x64'), eq(variables['System.TeamProject'], 'public')) }}: + # Persist completed entries only. Input snapshots are recreated in each job. + - task: Cache@2 + displayName: Helix shadow result cache + continueOnError: true + inputs: + key: '"helix-results" | "v1" | "linux-x64" | "System.Memory.Data.Tests" | "${{ parameters.buildConfig }}" | "$(Build.BuildId)"' + path: $(Pipeline.Workspace)/.helix-cache/entries + restoreKeys: | + "helix-results" | "v1" | "linux-x64" | "System.Memory.Data.Tests" | "${{ parameters.buildConfig }}" + + - script: | + set -euo pipefail + dotnet="$(Build.SourcesDirectory)/.dotnet/dotnet" + project="$(Build.SourcesDirectory)/src/tools/helix-cache" + output="$(Build.SourcesDirectory)/artifacts/log/$(_BuildConfig)/helix-cache-shadow" + cache="$(Pipeline.Workspace)/.helix-cache" + mkdir -p "$cache/entries" + + "$dotnet" publish "$project/src/HelixCache.csproj" -c Release -r linux-x64 \ + -o "$(Build.SourcesDirectory)/artifacts/tools/helix-cache" + executable="$(Build.SourcesDirectory)/artifacts/tools/helix-cache/helix-cache" + "$dotnet" test "$project/tests/HelixCache.Tests.csproj" -c Release + + "$dotnet" msbuild "$(Build.SourcesDirectory)/src/libraries/sendtohelix.proj" \ + -t:PrepareCorrelationPayloadDirectory -p:Configuration=$(_BuildConfig) -p:RuntimeFlavor=CoreCLR + settings="$("$dotnet" msbuild "$(Build.SourcesDirectory)/src/libraries/sendtohelix.proj" \ + -nologo -p:Configuration=$(_BuildConfig) -getProperty:NetCoreAppCurrentBuildSettings)" + archives="$("$dotnet" msbuild "$(Build.SourcesDirectory)/src/libraries/System.Memory.Data/tests/System.Memory.Data.Tests.csproj" \ + -nologo -p:Configuration=$(_BuildConfig) -getProperty:TestArchiveTestsDir)" + sdk="$("$dotnet" msbuild "$(Build.SourcesDirectory)/eng/testing/helix-cache-shadow.proj" \ + -nologo -getProperty:HelixCacheSdkPackage)" + "$executable" prepare --cache "$cache" --output "$output" \ + --payload "$archives/System.Memory.Data.Tests.zip" \ + --runtime "$(Build.SourcesDirectory)/artifacts/helix/runtime/test-runtime-$settings.zip" \ + --queue AzureLinux.3.Amd64.Open --helix-sdk "$sdk" + + # A cache hit never bypasses this submission. + "$dotnet" msbuild "$(Build.SourcesDirectory)/eng/testing/helix-cache-shadow.proj" -t:Submit \ + -p:HelixCacheShadowDirectory="$output" -p:ShadowCreator=dotnet-bot + "$executable" collect --cache "$cache" --output "$output" + displayName: Validate Helix cache in shadow mode (always execute) + continueOnError: true + timeoutInMinutes: 75 diff --git a/eng/pipelines/runtime.yml b/eng/pipelines/runtime.yml index 36dbc5958e2034..00d4a2f5e5f376 100644 --- a/eng/pipelines/runtime.yml +++ b/eng/pipelines/runtime.yml @@ -1,3 +1,9 @@ +parameters: +- name: helixCacheShadow + displayName: Validate Linux Helix result caching (shadow mode) + type: boolean + default: true + # Setting batch to true, triggers one build at a time. # if there is a push while a build in progress, it will wait, # until the running build finishes, and produce a build with all the changes @@ -385,6 +391,9 @@ extends: eq(variables['archType'], 'arm64'), eq(variables['coreclrContainsChange'], true)), eq(variables['isRollingBuild'], true)) + - template: /eng/pipelines/libraries/helix-cache-shadow.yml + parameters: + enabled: ${{ parameters.helixCacheShadow }} condition: >- or( eq(stageDependencies.EvaluatePaths.evaluate_paths.outputs['SetPathVars_non_mono_and_wasm.containsChange'], true), diff --git a/eng/testing/helix-cache-shadow.proj b/eng/testing/helix-cache-shadow.proj new file mode 100644 index 00000000000000..73b4a2bc98cea5 --- /dev/null +++ b/eng/testing/helix-cache-shadow.proj @@ -0,0 +1,53 @@ + + + + + $(ShadowQueue) + $([System.IO.Path]::GetFullPath('$(MicrosoftDotNetHelixSdkTasksAssembly)/../../../microsoft.dotnet.helix.sdk.$(MicrosoftDotNetHelixSdkPackageVersion).nupkg')) + + + + + + $(ShadowPayload) + $(ShadowCommand) + $(ShadowPostCommand) + $(ShadowTimeout) + + + + + + + + + + + + + + + + + + + + + + 1 + $(ShadowCancellationToken) + + + + + diff --git a/src/tools/helix-cache/README.md b/src/tools/helix-cache/README.md new file mode 100644 index 00000000000000..6f223feb5be3d3 --- /dev/null +++ b/src/tools/helix-cache/README.md @@ -0,0 +1,238 @@ +# Helix result cache prototype + +A C# NativeAOT CLI for a local, trusted filesystem cache of Linux x64 Helix +work-item results. An opt-in Azure Pipelines shadow lane exercises actual +submission and collection without replacing any test executions or results. + +## Build and exercise + +From the repository root, using its configured SDK and NuGet feeds: + +```sh +dotnet publish src/tools/helix-cache/src/HelixCache.csproj -c Release -r linux-x64 \ + -o artifacts/tools/helix-cache +dotnet test src/tools/helix-cache/tests/HelixCache.Tests.csproj -c Release +``` + +NativeAOT publishing requires a supported native compiler/linker and development +libraries. For managed development, build with `-p:PublishAot=false` and run the +generated apphost with the SDK runtime available. + +## Structure + +`src/` contains the executable project; `tests/` is a sibling xUnit project. +Tests exercise the published NativeAOT executable and inject HTTP responses for +Helix collection. Neither project needs source-glob exclusions. + +The command pipeline is: + +1. `CommandLine` parses and validates arguments into command-specific records before any I/O. +2. `Submission` validates work items and payload declarations; `LinuxEnvironment` resolves the declared queue environment. +3. `Cache.Lookup` freezes shared inputs once per lookup, constructs execution identities, and checks completed entries. +4. `HelixClient` handles typed HTTP responses, completion polling, and result downloads. +5. `Results.Read` validates receipts and reports and produces a `TestRun` containing counts and comparable outcomes. +6. `ShadowValidation` compares a fresh run with any cached run before `Cache.Store` publishes a passing entry atomically. + +JSON contracts use source-generated System.Text.Json serialization. +`HelixJsonContext` uses the service's PascalCase names for transport models. +`CacheJsonContext` uses camelCase for local models, including environment +fingerprints. Image/provisioning descriptions remain opaque JSON because their +open-ended fields must all participate in the key. Canonical key encoding is +separate from deserialization and domain validation. Prototype formats and keys +may change during development; existing entries are disposable. + +## Lookup + +```sh +helix-cache lookup submission.json --cache /path/to/cache --output lookup.json +``` + +Example `submission.json`: + +```json +{ + "schemaVersion": 1, + "workItems": [ + { + "name": "Example.Tests", + "queues": ["AzureLinux.3.Amd64.Open"], + "payload": { "path": "Example.Tests.zip" }, + "correlationPayloads": [ + { "path": "test-runtime.zip", "destination": "" } + ], + "command": "./RunTests.sh", + "preCommands": [], + "postCommands": [], + "environment": { "DOTNET_TieredCompilation": "1" }, + "timeoutSeconds": 900, + "expectedExitCode": 0 + } + ] +} +``` + +Input paths are relative to the submission manifest. A payload is either a ZIP +archive or a directory. ZIP snapshots are rewritten in ordinal entry order with +fixed timestamps and compression, preserving file contents, external attributes +(including Unix modes and symlinks), and empty directories. Duplicate paths, +traversal, and file/directory conflicts are rejected. Directory snapshots preserve +Unix modes and empty directories and normalize filesystem timestamps. Symlinks in +directory inputs/results are rejected; put linked payloads in an archive. Shared +inputs are captured once per invocation. Only frozen archives are rewritten; +original archives are never changed. + +The tool resolves the actual host queue, requires an available Linux AMD64 queue, +and fingerprints its declared image, ordered provisioning recipe and workspace +path. Docker targets use Arcade's `(alias)host@image` syntax and must include a +lowercase `@sha256:` digest. Operational queue state is excluded. The full target +string remains part of the execution identity, including its alias. + +For offline use or deterministic tests, supply `--queue-info queues.json`, where +the file is a trusted snapshot of the array returned by +`https://helix.dot.net/api/2019-06-17/info/queues`. Without that option the tool +fetches queue information once from that endpoint. + +Each `lookup.json` entry contains `status` (`hit` or `miss`), an `execution` +record, and a `resultsPath` for hits. Hits and misses both exit zero; malformed +inputs, HTTP failures, unreadable files and corrupt entries exit nonzero. +Diagnostics go to stderr. The caller must retain every requested item when +combining cached and fresh results, including an all-hit submission. + +**Submit the snapshot paths in the execution record, not the original input +paths.** Treat snapshots as immutable. If the sender needs writable directories +(the Helix SDK can write `.payload` markers), make separate submission copies +from these snapshots. Do not resolve inputs again after computing the key. + +## Store completed results + +Save one entry's `execution` object from `lookup.json` as `execution.json`, and +collect the original work item's results into a directory: + +```sh +helix-cache store execution.json --results downloaded-results --cache /path/to/cache +``` + +The directory must include a `result.json` receipt constructed by the trusted +result collector from Helix's completed-work-item status: + +```json +{ + "schemaVersion": 1, + "completed": true, + "infrastructureSucceeded": true, + "exitCode": 0, + "jobId": "00000000-0000-0000-0000-000000000001", + "workItemName": "Example.Tests", + "targetQueue": "AzureLinux.3.Amd64.Open", + "resultFiles": ["testResults.xml"] +} +``` + +`infrastructureSucceeded` must be false for Helix infrastructure errors, +cancellations or timeouts. Submission success is not completion. This tool does +not independently authenticate the receipt with Helix. + +The prototype accepts xUnit XML (`assemblies` / `assembly` / `test`) only. Each assembly must +contain tests whose results agree with its total/passed/failed/skipped counts; +failures, errors, empty or incomplete reports are rejected. Skips are retained. +All files in the results directory are copied and checksummed. The execution key +and input snapshots are checked before publishing an entry. Atomic directory +renames make concurrent stores safe; an existing valid entry wins. + +Store uses the environment captured at lookup, never today's queue metadata. +Lookup checks artifact integrity and reports corruption as an error rather than +returning a cached pass. Original job provenance and reports are retained. +Cached results must be labeled as reused when published; no new Helix job is +invented. TRX/JUnit, cache eviction and replacing normal MSBuild test submissions +with cached results are outside this prototype. + +## Azure Pipelines shadow validation + +Queue `eng/pipelines/runtime.yml` with its `helixCacheShadow` boolean parameter +enabled. This draft temporarily defaults to **true** for validation; disable it +before merging. The additional steps run only in the public +project's Linux x64 `CoreCLR_AllSubsets` job, after its normal submissions. +They use the existing build's `System.Memory.Data.Tests.zip` and runtime archive +and submit one additional work item to `AzureLinux.3.Amd64.Open`. + +`Cache@2` restores `$(Pipeline.Workspace)/.helix-cache/entries`, using a rolling +per-build snapshot key and a stable restore prefix, as the compiler caches do. +The cache task's build ID is **not** part of an execution digest. Only completed +entries are persisted; frozen input snapshots are recreated in each job. +Existing Azure Pipelines branch/PR cache scoping applies. + +All orchestration runs in the C# NativeAOT executable. Its `prepare` command +records the full command, upload post-command, timeout, queue and SHA-256 digest +of the pinned Helix SDK package, runs native lookup, and exports frozen paths to +`submission.props`. `--helix-sdk` must identify that installed SDK's `.nupkg`. +`eng/testing/helix-cache-shadow.proj` submits those exact archives through the +pinned Helix SDK without injecting extra reporters. The SDK generates its own +command wrapper, including chmod and exit-code handling; its package digest +participates in the key as `commandGeneratorDigest`, and submission checks the +installed package against that digest. The generated UUID script directory is +not stable and is not fingerprinted literally. This assumes those random wrapper +paths are irrelevant to the tests; it is not a byte-for-byte fingerprint of every +SDK-generated archive. Its default target only +validates inputs; submission requires **`-t:Submit`**, which waits for completion +and cancels the submitted job on task cancellation. + +`collect` checks the public Helix API's job, submitted queue alias (or queue ID +when no alias is reported), work-item identity, terminal state, infrastructure +errors, failure reason and exit code, then downloads the uploaded +xUnit XML and console log. It constructs the receipt from that completion, +passes the report through native admission, and compares any hit with the fresh +test identities/outcomes/skip reasons. Ordering, worker directory prefixes and +execution timings are ignored. The resolved physical queue ID and submitted +alias are preserved in provenance alongside the original cached job. +On a hit, mismatched outcomes fail validation before store. Every hit still +executes on Helix; no cached result is published as a new run. + +Evidence is included in the existing build-log artifact under +`artifacts/log//helix-cache-shadow/`, including `lookup.json`, +`execution.json`, `work-item.json`, `summary.json`, and fresh results. Shadow +errors are visible but use `continueOnError`, so they do not replace or weaken +the normal test decision. Internal queues and authentication are not supported +by this first shadow lane. + +For local preparation without submitting, after building the archives: + +```sh +sdk="$(dotnet msbuild eng/testing/helix-cache-shadow.proj -nologo -getProperty:HelixCacheSdkPackage)" +artifacts/tools/helix-cache/helix-cache prepare \ + --cache artifacts/tmp/helix-cache \ + --output artifacts/log/Release/helix-cache-shadow \ + --payload artifacts/helix/tests/linux.AnyCPU.Release/System.Memory.Data.Tests.zip \ + --runtime artifacts/helix/runtime/test-runtime-net11.0-linux-Release-x64.zip \ + --queue AzureLinux.3.Amd64.Open --helix-sdk "$sdk" +dotnet msbuild eng/testing/helix-cache-shadow.proj -t:Validate \ + -p:HelixCacheShadowDirectory="$PWD/artifacts/log/Release/helix-cache-shadow" +``` + +The integration suite mocks public completion/file endpoints; live submission, +cross-build restoration and unchanged-rebuild hit rates require shadow validation. +Repacking unchanged ZIP members no longer changes the frozen bytes or key. +Changed binary contents, permissions, paths, and other execution inputs still +invalidate results, even when source changes appear unrelated to a test. + +## Cache contract + +Keys include the schema namespace, work-item name, target queue, declared Linux +environment, payload identities and destinations, ordered correlation overlays, +commands, an optional command-generator digest, environment overrides, timeout +and expected exit code. Callers that inject generated commands must fingerprint +their generator or provide its actual frozen output. Object +properties are sorted ordinally; command and overlay order is preserved. Local +absolute paths and result timestamps are not key material. + +This is a fingerprint of the **declared Linux environment**, not an attestation of +the worker's installed packages, kernel or CPU features. Native queues can drift +without changing their published recipes; a pinned container identifies its +userspace but not its host. External services and randomized/stress trials may +also make result reuse inappropriate. Enable caching only where that equivalence +contract is acceptable. Build-specific command arguments remain in the key; +there is no heuristic removal of build IDs or reporting arguments. + +The cache and manifests are trusted local inputs, not a shared-cache security +boundary. Input snapshots live under `inputs/v1`; completed results are under +`entries/v1//`. Do not mutate entries or delete snapshots while +their executions are outstanding. diff --git a/src/tools/helix-cache/src/Cache.cs b/src/tools/helix-cache/src/Cache.cs new file mode 100644 index 00000000000000..ae99b9646c780a --- /dev/null +++ b/src/tools/helix-cache/src/Cache.cs @@ -0,0 +1,178 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace HelixCache; + +internal sealed class Cache +{ + internal const int ProtocolVersion = 1; + private readonly string _root; + + internal Cache(string directory) => _root = Path.GetFullPath(directory); + + internal LookupResult Lookup(Submission submission, string baseDirectory, LinuxEnvironment environment) + { + submission.Validate(); + Dictionary fingerprints = submission.WorkItems.SelectMany(item => item.Queues) + .Distinct(StringComparer.Ordinal).ToDictionary(target => target, environment.Resolve, StringComparer.Ordinal); + Dictionary snapshots = new(StringComparer.Ordinal); + LookupResult result = new(); + foreach (WorkItem item in submission.WorkItems) + { + Snapshot payload = Capture(item.Payload, baseDirectory, snapshots); + List correlation = item.CorrelationPayloads.Select(p => Capture(p, baseDirectory, snapshots)).ToList(); + + foreach (string target in item.Queues) + { + ExecutionInputs inputs = new() + { + Name = item.Name, + TargetQueue = target, + EnvironmentFingerprint = fingerprints[target], + Payload = Identity(item.Payload, payload), + CorrelationPayloads = item.CorrelationPayloads.Select((p, i) => Identity(p, correlation[i])).ToList(), + Command = item.Command, + PreCommands = [.. item.PreCommands], + PostCommands = [.. item.PostCommands], + CommandGeneratorDigest = item.CommandGeneratorDigest, + Environment = new(item.Environment, StringComparer.Ordinal), + TimeoutSeconds = item.TimeoutSeconds, + ExpectedExitCode = item.ExpectedExitCode, + }; + ExecutionRecord execution = new() + { + SchemaVersion = ProtocolVersion, + Key = ExecutionKey.Create(inputs), + Inputs = inputs, + PayloadPath = payload.Path, + CorrelationPayloadPaths = correlation.Select(p => p.Path).ToList(), + }; + string entry = EntryPath(execution.Key); + bool hit = Directory.Exists(entry); + if (hit) + { + ValidateEntry(entry, execution.Key); + } + result.Entries.Add(new LookupEntry + { + Status = hit ? LookupStatus.Hit : LookupStatus.Miss, + Execution = execution, + ResultsPath = hit ? Path.Combine(entry, "results") : null, + }); + } + } + return result; + } + + internal void Store(ExecutionRecord execution, string results) + { + execution.Validate(); + VerifySnapshot(execution.PayloadPath, execution.Inputs.Payload.Digest); + for (int i = 0; i < execution.CorrelationPayloadPaths.Count; i++) + { + VerifySnapshot(execution.CorrelationPayloadPaths[i], execution.Inputs.CorrelationPayloads[i].Digest); + } + results = Path.GetFullPath(results); + Snapshot.EnsureOutside(results, _root); + using TemporaryDirectory temporary = new(Path.Combine(_root, "tmp")); + Snapshot.EnsureOutside(results, temporary.Path); + string stagedResults = Path.Combine(temporary.Path, "results"); + Snapshot.Copy(results, stagedResults); + TestSummary summary = Results.Read(stagedResults, execution).Summary; + StoredEntry entry = new() + { + SchemaVersion = ProtocolVersion, + Execution = execution, + CreatedUtc = DateTimeOffset.UtcNow, + ResultsDigest = Snapshot.Hash(stagedResults), + Summary = summary, + }; + using (FileStream file = File.Create(Path.Combine(temporary.Path, "entry.json"))) + { + JsonSerializer.Serialize(file, entry, CacheJsonContext.Default.StoredEntry); + file.Flush(flushToDisk: true); + } + string final = EntryPath(execution.Key); + Directory.CreateDirectory(Path.GetDirectoryName(final)!); + try + { + Directory.Move(temporary.Path, final); + } + catch (IOException) when (Directory.Exists(final)) + { + ValidateEntry(final, execution.Key); + } + } + + internal TestRun ReadResults(ExecutionRecord execution) + { + execution.Validate(); + return ValidateEntry(EntryPath(execution.Key), execution.Key); + } + + private Snapshot Capture(Payload payload, string baseDirectory, Dictionary snapshots) + { + string path = Path.GetFullPath(payload.Path, baseDirectory); + if (!snapshots.TryGetValue(path, out Snapshot? snapshot)) + { + Snapshot.EnsureOutside(path, _root); + snapshot = Snapshot.Capture(path, _root); + snapshots.Add(path, snapshot); + } + return snapshot; + } + + private static PayloadIdentity Identity(Payload payload, Snapshot snapshot) => new() + { + Digest = snapshot.Digest, + Destination = payload.Destination, + Kind = snapshot.Kind, + }; + + private static TestRun ValidateEntry(string directory, string key) + { + using FileStream file = File.OpenRead(Path.Combine(directory, "entry.json")); + StoredEntry entry = JsonSerializer.Deserialize(file, CacheJsonContext.Default.StoredEntry) + ?? throw new InvalidDataException($"Cache entry failed validation: {directory}."); + ValidateVersion(entry.SchemaVersion); + entry.Execution.Validate(); + string results = Path.Combine(directory, "results"); + if (entry.Execution.Key != key || Snapshot.Hash(results) != entry.ResultsDigest) + { + throw new InvalidDataException($"Cache entry failed validation: {directory}."); + } + TestRun run = Results.Read(results, entry.Execution); + if (run.Summary.Total != entry.Summary.Total || run.Summary.Passed != entry.Summary.Passed || run.Summary.Skipped != entry.Summary.Skipped) + { + throw new InvalidDataException($"Cache entry failed validation: {directory}."); + } + return run; + } + + private static void VerifySnapshot(string path, string digest) + { + if (Snapshot.Hash(path) != digest) + { + throw new InvalidDataException($"Snapshot failed validation: {path}."); + } + } + + private string EntryPath(string key) => Path.Combine(_root, "entries", "v1", key[..2], key); + + internal static void ValidateVersion(int version) + { + if (version != ProtocolVersion) + { + throw new InvalidDataException($"Unsupported schema version {version}; expected {ProtocolVersion}."); + } + } + + internal static bool IsDigest(string value) => + value.Length == 64 && value.All(c => c is >= '0' and <= '9' or >= 'a' and <= 'f'); +} diff --git a/src/tools/helix-cache/src/CommandLine.cs b/src/tools/helix-cache/src/CommandLine.cs new file mode 100644 index 00000000000000..2e5109ce19aa2a --- /dev/null +++ b/src/tools/helix-cache/src/CommandLine.cs @@ -0,0 +1,89 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.IO; + +namespace HelixCache; + +internal abstract record Command(string CacheDirectory); +internal sealed record LookupCommand(string Manifest, string CacheDirectory, string Output, string? QueueInfo) : Command(CacheDirectory); +internal sealed record StoreCommand(string Manifest, string CacheDirectory, string Results) : Command(CacheDirectory); +internal sealed record PrepareCommand(string CacheDirectory, string Output, string Payload, string Runtime, string Queue, string HelixSdk, string? QueueInfo) : Command(CacheDirectory); +internal sealed record CollectCommand(string CacheDirectory, string Output, TimeSpan Wait) : Command(CacheDirectory); + +internal static class CommandLine +{ + internal const string Usage = """ + Usage: helix-cache lookup submission.json --cache DIRECTORY --output lookup.json [--queue-info queues.json] + helix-cache store execution.json --results DIRECTORY --cache DIRECTORY + helix-cache prepare --cache DIRECTORY --output DIRECTORY --payload tests.zip --runtime runtime.zip --queue QUEUE --helix-sdk SDK.nupkg [--queue-info queues.json] + helix-cache collect --cache DIRECTORY --output DIRECTORY [--wait-seconds SECONDS] + Only Linux x64 queues and digest-pinned container references are supported. + """; + + internal static Command Parse(string[] args) + { + if (args.Length == 0 || args[0] is not ("lookup" or "store" or "prepare" or "collect")) + { + throw new InvalidDataException(Usage); + } + bool hasManifest = args[0] is "lookup" or "store"; + int firstOption = hasManifest ? 2 : 1; + if (args.Length < firstOption || + (hasManifest && (string.IsNullOrWhiteSpace(args[1]) || args[1].StartsWith("--", StringComparison.Ordinal)))) + { + throw new InvalidDataException($"Missing manifest for '{args[0]}'.\n{Usage}"); + } + Dictionary options = new(StringComparer.Ordinal); + for (int i = firstOption; i < args.Length; i += 2) + { + string name = args[i]; + if (!name.StartsWith("--", StringComparison.Ordinal) || i + 1 == args.Length || + string.IsNullOrWhiteSpace(args[i + 1]) || args[i + 1].StartsWith("--", StringComparison.Ordinal)) + { + throw new InvalidDataException($"Expected an option and value at '{name}'.\n{Usage}"); + } + if (!options.TryAdd(name, args[i + 1])) + { + throw new InvalidDataException($"Duplicate option '{name}'."); + } + } + + string cache = Required("--cache"); + Command command = args[0] switch + { + "lookup" => new LookupCommand(args[1], cache, Required("--output"), Optional("--queue-info")), + "store" => new StoreCommand(args[1], cache, Required("--results")), + "prepare" => new PrepareCommand(cache, Required("--output"), Required("--payload"), + Required("--runtime"), Required("--queue"), Required("--helix-sdk"), Optional("--queue-info")), + "collect" => new CollectCommand(cache, Required("--output"), ParseWait(Optional("--wait-seconds"))), + _ => throw new UnreachableException(), + }; + if (options.Count > 0) + { + throw new InvalidDataException($"Unknown option '{string.Join("', '", options.Keys)}'."); + } + return command; + + string Required(string name) => Optional(name) ?? throw new InvalidDataException($"Missing required option '{name}'.\n{Usage}"); + string? Optional(string name) => options.Remove(name, out string? value) ? value : null; + } + + private static TimeSpan ParseWait(string? value) + { + if (value is null) + { + return TimeSpan.FromHours(1); + } + if (!int.TryParse(value, NumberStyles.None, CultureInfo.InvariantCulture, out int seconds) || + seconds <= 0 || seconds > (uint.MaxValue - 1) / 1000) + { + throw new InvalidDataException("--wait-seconds must be a positive number of seconds supported by a cancellation timer."); + } + return TimeSpan.FromSeconds(seconds); + } +} diff --git a/src/tools/helix-cache/src/ExecutionKey.cs b/src/tools/helix-cache/src/ExecutionKey.cs new file mode 100644 index 00000000000000..4613d7dc040e44 --- /dev/null +++ b/src/tools/helix-cache/src/ExecutionKey.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text.Json; + +namespace HelixCache; + +internal static class ExecutionKey +{ + internal static string Create(ExecutionInputs inputs) + { + using MemoryStream stream = new(); + using (Utf8JsonWriter writer = new(stream)) + { + WriteCanonical(writer, JsonSerializer.SerializeToElement(inputs, CacheJsonContext.Default.ExecutionInputs)); + } + return Convert.ToHexStringLower(SHA256.HashData(stream.GetBuffer().AsSpan(0, checked((int)stream.Length)))); + } + + // Property order is not execution input, including within opaque Helix provisioning metadata. + private static void WriteCanonical(Utf8JsonWriter writer, JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.Object: + writer.WriteStartObject(); + foreach (JsonProperty property in value.EnumerateObject().OrderBy(p => p.Name, StringComparer.Ordinal)) + { + writer.WritePropertyName(property.Name); + WriteCanonical(writer, property.Value); + } + writer.WriteEndObject(); + break; + case JsonValueKind.Array: + writer.WriteStartArray(); + foreach (JsonElement item in value.EnumerateArray()) + { + WriteCanonical(writer, item); + } + writer.WriteEndArray(); + break; + default: + value.WriteTo(writer); + break; + } + } +} diff --git a/src/tools/helix-cache/src/HelixCache.csproj b/src/tools/helix-cache/src/HelixCache.csproj new file mode 100644 index 00000000000000..ad2071f61a0bb1 --- /dev/null +++ b/src/tools/helix-cache/src/HelixCache.csproj @@ -0,0 +1,15 @@ + + + helix-cache + $(NetCoreAppToolCurrent) + Exe + false + true + enable + false + true + + + + + diff --git a/src/tools/helix-cache/src/HelixClient.cs b/src/tools/helix-cache/src/HelixClient.cs new file mode 100644 index 00000000000000..2e85d64827bfae --- /dev/null +++ b/src/tools/helix-cache/src/HelixClient.cs @@ -0,0 +1,94 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace HelixCache; + +internal sealed class HelixClient(HttpClient client) +{ + private const string Api = "https://helix.dot.net/api/2019-06-17"; + + internal async Task> GetQueuesAsync(CancellationToken cancellationToken) => + await client.GetFromJsonAsync($"{Api}/info/queues", HelixJsonContext.Default.ListHelixQueue, cancellationToken) + ?? throw new InvalidDataException("Helix returned no queue metadata."); + + internal async Task GetJobAsync(string jobId, CancellationToken cancellationToken) => + await client.GetFromJsonAsync($"{Api}/jobs/{jobId}/details", HelixJsonContext.Default.HelixJob, cancellationToken) + ?? throw new InvalidDataException("Helix returned no job details."); + + internal async Task WaitForCompletionAsync(string jobId, string name, TimeSpan wait, + CancellationToken cancellationToken = default) + { + using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(wait); + try + { + while (true) + { + HelixWorkItem item = await client.GetFromJsonAsync( + $"{Api}/jobs/{jobId}/workitems/{Uri.EscapeDataString(name)}", + HelixJsonContext.Default.HelixWorkItem, timeout.Token) + ?? throw new InvalidDataException("Helix returned no work item."); + if (item.Job != jobId || item.Name != name) + { + throw new InvalidDataException("Helix returned a different job or work item."); + } + if (item.State is "Finished" or "Passed" or "Failed") + { + return item; + } + if (item.State is not ("Unscheduled" or "Waiting" or "Running")) + { + throw new InvalidDataException($"Unexpected Helix work item state: {item.State}."); + } + await Task.Delay(TimeSpan.FromSeconds(15), timeout.Token); + } + } + catch (OperationCanceledException ex) when (timeout.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + throw new TimeoutException($"Helix job {jobId} did not complete within {wait.TotalSeconds} seconds.", ex); + } + } + + internal async Task DownloadResultsAsync(HelixWorkItem item, string directory, CancellationToken cancellationToken) + { + List files = await client.GetFromJsonAsync( + $"{Api}/jobs/{item.Job}/workitems/{Uri.EscapeDataString(item.Name)}/files?latestOnly=true", + HelixJsonContext.Default.ListHelixFile, cancellationToken) + ?? throw new InvalidDataException("Helix returned no work item file list."); + if (files.Any(file => file is null)) + { + throw new InvalidDataException("Helix work item files must not contain null."); + } + List reports = files.Where(file => file.Name == "testResults.xml").ToList(); + if (reports.Count != 1) + { + throw new InvalidDataException($"Expected exactly one uploaded testResults.xml; found {reports.Count}."); + } + if (Directory.Exists(directory) || File.Exists(directory)) + { + throw new IOException($"The result download directory already exists: {directory}."); + } + Directory.CreateDirectory(directory); + await DownloadAsync(reports[0].Link, Path.Combine(directory, "testResults.xml"), cancellationToken); + if (!string.IsNullOrEmpty(item.ConsoleOutputUri)) + { + await DownloadAsync(item.ConsoleOutputUri, Path.Combine(directory, "console.log"), cancellationToken); + } + } + + private async Task DownloadAsync(string uri, string path, CancellationToken cancellationToken) + { + using Stream source = await client.GetStreamAsync(uri, cancellationToken); + using FileStream destination = new(path, FileMode.CreateNew, FileAccess.Write); + await source.CopyToAsync(destination, cancellationToken); + } +} diff --git a/src/tools/helix-cache/src/HelixModels.cs b/src/tools/helix-cache/src/HelixModels.cs new file mode 100644 index 00000000000000..5c34836c6199fa --- /dev/null +++ b/src/tools/helix-cache/src/HelixModels.cs @@ -0,0 +1,57 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Collections.Generic; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HelixCache; + +internal sealed record HelixQueue +{ + public required string QueueId { get; init; } + public string? Architecture { get; init; } + public string? OperatingSystemGroup { get; init; } + public bool IsAvailable { get; init; } + + [JsonExtensionData] + public Dictionary Metadata { get; init; } = []; +} + +internal sealed record HelixJob +{ + public required string Name { get; init; } + public required string QueueId { get; init; } + public string? QueueAlias { get; init; } +} + +internal sealed record HelixWorkItem +{ + public required string Job { get; init; } + public required string Name { get; init; } + public required string State { get; init; } + public int? ExitCode { get; init; } + public string? MachineName { get; init; } + public string? ConsoleOutputUri { get; init; } + public HelixFailure? FailureReason { get; init; } + public List? Errors { get; init; } +} + +internal sealed class HelixFailure +{ + [JsonExtensionData] + public Dictionary Details { get; init; } = []; +} + +internal sealed class HelixFile +{ + public required string Name { get; init; } + public required string Link { get; init; } +} + +[JsonSourceGenerationOptions(AllowDuplicateProperties = false, RespectNullableAnnotations = true)] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(HelixJob))] +[JsonSerializable(typeof(HelixWorkItem))] +[JsonSerializable(typeof(List))] +internal sealed partial class HelixJsonContext : JsonSerializerContext; diff --git a/src/tools/helix-cache/src/LinuxEnvironment.cs b/src/tools/helix-cache/src/LinuxEnvironment.cs new file mode 100644 index 00000000000000..d37a1928689dfb --- /dev/null +++ b/src/tools/helix-cache/src/LinuxEnvironment.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace HelixCache; + +internal sealed class LinuxEnvironment +{ + private readonly List _queues; + private readonly Dictionary _fingerprints = new(StringComparer.Ordinal); + + internal LinuxEnvironment(List queues) => _queues = queues; + + internal static async Task LoadAsync(string? queueInfo, HelixClient client, CancellationToken cancellationToken) + { + if (queueInfo is not null) + { + using FileStream file = File.OpenRead(queueInfo); + return new LinuxEnvironment(await JsonSerializer.DeserializeAsync(file, HelixJsonContext.Default.ListHelixQueue, cancellationToken) + ?? throw new InvalidDataException($"Invalid or missing queue metadata: {queueInfo}.")); + } + + return new LinuxEnvironment(await client.GetQueuesAsync(cancellationToken)); + } + + internal EnvironmentFingerprint Resolve(string target) + { + if (_fingerprints.TryGetValue(target, out EnvironmentFingerprint? fingerprint)) + { + return fingerprint; + } + + // Matches Arcade JobSender's (alias)host@container syntax; retain a digest's second '@'. + // https://github.com/dotnet/arcade/blob/7103cc604358625941ee1d105e662f0d697085c6/src/Microsoft.DotNet.Helix/JobSender/JobDefinition.cs#L374-L399 + int separator = target.IndexOf('@'); + string host = separator < 0 ? target : target[..separator]; + string? container = separator < 0 ? null : target[(separator + 1)..]; + if (host.StartsWith('(')) + { + int close = host.IndexOf(')'); + if (separator < 0 || close <= 1 || close >= host.Length - 1) + { + throw new InvalidDataException($"Invalid target queue: {target}."); + } + host = host[(close + 1)..]; + } + + if (container is not null) + { + int digestStart = container.LastIndexOf("@sha256:", StringComparison.Ordinal); + if (digestStart <= 0 || !Cache.IsDigest(container[(digestStart + "@sha256:".Length)..])) + { + throw new InvalidDataException($"Container reference '{container}' must end in an explicit @sha256: digest."); + } + } + + HelixQueue? queue = null; + foreach (HelixQueue candidate in _queues) + { + if (candidate is null) + { + throw new InvalidDataException("Helix queue metadata must not contain null."); + } + if (string.Equals(candidate.QueueId, host, StringComparison.OrdinalIgnoreCase)) + { + if (queue is not null) + { + throw new InvalidDataException($"Duplicate target queue: {target}."); + } + queue = candidate; + } + } + + if (queue is not { Architecture: string architecture, OperatingSystemGroup: string operatingSystem } || + !string.Equals(queue.OperatingSystemGroup, "linux", StringComparison.OrdinalIgnoreCase) || + !string.Equals(queue.Architecture, "AMD64", StringComparison.OrdinalIgnoreCase) || + !queue.IsAvailable) + { + throw new InvalidDataException($"Queue '{host}' is missing, unavailable, or not a Linux AMD64 queue."); + } + + fingerprint = new EnvironmentFingerprint + { + QueueId = queue.QueueId, + Architecture = architecture, + OperatingSystemGroup = operatingSystem, + Container = container, + Provisioning = queue.Metadata.Where(p => p.Key is + "GalleryImage" or "PreInstalledImage" or "PreparedImage" or "Artifacts" or "WorkspacePath") + .ToDictionary(p => p.Key, p => p.Value, StringComparer.Ordinal), + }; + _fingerprints.Add(target, fingerprint); + return fingerprint; + } +} diff --git a/src/tools/helix-cache/src/Models.cs b/src/tools/helix-cache/src/Models.cs new file mode 100644 index 00000000000000..f46d2a2055f85d --- /dev/null +++ b/src/tools/helix-cache/src/Models.cs @@ -0,0 +1,280 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace HelixCache; + +internal enum PayloadKind +{ + [JsonStringEnumMemberName("directory")] + Directory, + [JsonStringEnumMemberName("archive")] + Archive, +} + +internal enum LookupStatus +{ + [JsonStringEnumMemberName("miss")] + Miss, + [JsonStringEnumMemberName("hit")] + Hit, +} + +internal enum OutcomeComparison +{ + [JsonStringEnumMemberName("not-applicable")] + NotApplicable, + [JsonStringEnumMemberName("match")] + Match, + [JsonStringEnumMemberName("mismatch")] + Mismatch, +} + +internal sealed record Submission +{ + public required int SchemaVersion { get; init; } + public required List WorkItems { get; init; } + + internal void Validate() + { + Cache.ValidateVersion(SchemaVersion); + HashSet names = new(StringComparer.Ordinal); + if (WorkItems.Count == 0) + { + throw new InvalidDataException("A submission must contain at least one work item."); + } + foreach (WorkItem item in WorkItems) + { + if (item is null || string.IsNullOrWhiteSpace(item.Name) || !names.Add(item.Name)) + { + throw new InvalidDataException("Work items must be nonnull and have unique, nonempty names."); + } + item.Validate(); + } + } +} + +internal sealed record WorkItem +{ + public required string Name { get; init; } + public required List Queues { get; init; } + public required Payload Payload { get; init; } + public List CorrelationPayloads { get; init; } = []; + public required string Command { get; init; } + public List PreCommands { get; init; } = []; + public List PostCommands { get; init; } = []; + public string? CommandGeneratorDigest { get; init; } + public Dictionary Environment { get; init; } = []; + public required int TimeoutSeconds { get; init; } + public required int ExpectedExitCode { get; init; } + + internal void Validate() + { + ValidateExecution(Command, TimeoutSeconds, PreCommands, PostCommands, Environment, CommandGeneratorDigest); + if (Queues.Count == 0 || Queues.Any(string.IsNullOrWhiteSpace) || + Queues.Distinct(StringComparer.Ordinal).Count() != Queues.Count) + { + throw new InvalidDataException($"Work item '{Name}' requires unique, nonempty target queues."); + } + Payload.Validate(); + foreach (Payload payload in CorrelationPayloads) + { + if (payload is null) + { + throw new InvalidDataException($"Work item '{Name}' has a null correlation payload."); + } + payload.Validate(); + } + } + + internal static void ValidateExecution(string command, int timeoutSeconds, List preCommands, + List postCommands, Dictionary environment, string? commandGeneratorDigest) + { + if (string.IsNullOrWhiteSpace(command) || timeoutSeconds <= 0 || + preCommands.Any(c => c is null) || postCommands.Any(c => c is null) || + environment.Any(p => string.IsNullOrEmpty(p.Key) || p.Value is null)) + { + throw new InvalidDataException("Execution requires a nonempty command, positive timeout, nonnull commands and environment values."); + } + if (commandGeneratorDigest is not null && !Cache.IsDigest(commandGeneratorDigest)) + { + throw new InvalidDataException("A command generator digest must be a lowercase SHA-256 digest."); + } + } +} + +internal sealed record Payload +{ + public required string Path { get; init; } + public string Destination { get; init; } = ""; + + internal void Validate() + { + if (string.IsNullOrWhiteSpace(Path)) + { + throw new InvalidDataException("A payload requires a nonempty path."); + } + Snapshot.ValidateRelativePath(Destination, allowEmpty: true); + } +} + +internal sealed record PayloadIdentity +{ + public required string Digest { get; init; } + public required string Destination { get; init; } + public required PayloadKind Kind { get; init; } + + internal void Validate() + { + if (!Cache.IsDigest(Digest) || Kind is not (PayloadKind.Directory or PayloadKind.Archive)) + { + throw new InvalidDataException("A payload identity requires a SHA-256 digest and a directory or archive kind."); + } + Snapshot.ValidateRelativePath(Destination, allowEmpty: true); + } +} + +internal sealed class EnvironmentFingerprint +{ + public required string QueueId { get; init; } + public required string Architecture { get; init; } + public required string OperatingSystemGroup { get; init; } + public string? Container { get; init; } + + [JsonExtensionData] + public Dictionary Provisioning { get; init; } = []; +} + +internal sealed record ExecutionInputs +{ + public required string Name { get; init; } + public required string TargetQueue { get; init; } + public required EnvironmentFingerprint EnvironmentFingerprint { get; init; } + public required PayloadIdentity Payload { get; init; } + public required List CorrelationPayloads { get; init; } + public required string Command { get; init; } + public required List PreCommands { get; init; } + public required List PostCommands { get; init; } + public string? CommandGeneratorDigest { get; init; } + public required Dictionary Environment { get; init; } + public required int TimeoutSeconds { get; init; } + public required int ExpectedExitCode { get; init; } +} + +internal sealed record ExecutionRecord +{ + public required int SchemaVersion { get; init; } + public required string Key { get; init; } + public required ExecutionInputs Inputs { get; init; } + public required string PayloadPath { get; init; } + public required List CorrelationPayloadPaths { get; init; } + + internal void Validate() + { + Cache.ValidateVersion(SchemaVersion); + WorkItem.ValidateExecution(Inputs.Command, Inputs.TimeoutSeconds, Inputs.PreCommands, Inputs.PostCommands, Inputs.Environment, + Inputs.CommandGeneratorDigest); + Inputs.Payload.Validate(); + foreach (PayloadIdentity payload in Inputs.CorrelationPayloads) + { + if (payload is null) + { + throw new InvalidDataException("Execution correlation payload identities must not contain null."); + } + payload.Validate(); + } + if (!Cache.IsDigest(Key) || ExecutionKey.Create(Inputs) != Key || + string.IsNullOrWhiteSpace(Inputs.Name) || string.IsNullOrWhiteSpace(Inputs.TargetQueue) || + string.IsNullOrWhiteSpace(PayloadPath) || CorrelationPayloadPaths.Any(string.IsNullOrWhiteSpace) || + CorrelationPayloadPaths.Count != Inputs.CorrelationPayloads.Count) + { + throw new InvalidDataException($"Execution record failed validation: {Key}."); + } + } +} + +internal sealed class LookupResult +{ + public int SchemaVersion { get; init; } = Cache.ProtocolVersion; + public List Entries { get; init; } = []; +} + +internal sealed class LookupEntry +{ + public required LookupStatus Status { get; init; } + public required ExecutionRecord Execution { get; init; } + public string? ResultsPath { get; init; } +} + +internal sealed record ResultReceipt +{ + public required int SchemaVersion { get; init; } + public required bool Completed { get; init; } + public required bool InfrastructureSucceeded { get; init; } + public required int ExitCode { get; init; } + public required string JobId { get; init; } + public required string WorkItemName { get; init; } + public required string TargetQueue { get; init; } + public required List ResultFiles { get; init; } +} + +internal sealed class TestSummary +{ + public int Total { get; set; } + public int Passed { get; set; } + public int Skipped { get; set; } +} + +internal sealed class StoredEntry +{ + public required int SchemaVersion { get; init; } + public required ExecutionRecord Execution { get; init; } + public required DateTimeOffset CreatedUtc { get; init; } + public required string ResultsDigest { get; init; } + public required TestSummary Summary { get; init; } +} + +internal sealed record ShadowSummary +{ + public required string Key { get; init; } + public required LookupStatus Lookup { get; init; } + public required string JobId { get; init; } + public string Mode { get; init; } = "shadow"; + public int? Tests { get; init; } + public OutcomeComparison? Comparison { get; init; } + public string? CachedJobId { get; init; } + public bool Stored { get; init; } +} + +internal sealed class ExecutionProvenance +{ + public string Kind { get; init; } = "helix-execution"; + public required string JobId { get; init; } + public string? QueueAlias { get; init; } + public required string ResolvedQueueId { get; init; } + public string? MachineName { get; init; } + public required string State { get; init; } +} + +[JsonSourceGenerationOptions( + AllowDuplicateProperties = false, + PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, + WriteIndented = true, + UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow, + UseStringEnumConverter = true, + RespectNullableAnnotations = true)] +[JsonSerializable(typeof(Submission))] +[JsonSerializable(typeof(ExecutionRecord))] +[JsonSerializable(typeof(ExecutionInputs))] +[JsonSerializable(typeof(LookupResult))] +[JsonSerializable(typeof(ResultReceipt))] +[JsonSerializable(typeof(StoredEntry))] +[JsonSerializable(typeof(ShadowSummary))] +[JsonSerializable(typeof(ExecutionProvenance))] +internal sealed partial class CacheJsonContext : JsonSerializerContext; diff --git a/src/tools/helix-cache/src/Program.cs b/src/tools/helix-cache/src/Program.cs new file mode 100644 index 00000000000000..067c190bd0ff6e --- /dev/null +++ b/src/tools/helix-cache/src/Program.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.IO; +using System.Net.Http; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Xml; + +namespace HelixCache; + +internal static class Program +{ + private static async Task Main(string[] args) + { + using CancellationTokenSource cancellation = new(); + ConsoleCancelEventHandler cancel = (_, e) => + { + e.Cancel = true; + cancellation.Cancel(); + }; + Console.CancelKeyPress += cancel; + try + { + if (args is ["--help" or "-h"] or ["lookup" or "store" or "prepare" or "collect", "--help" or "-h"]) + { + Console.WriteLine(CommandLine.Usage); + return 0; + } + Command command = CommandLine.Parse(args); + if (!OperatingSystem.IsLinux()) + { + throw new PlatformNotSupportedException("This prototype requires a Linux host."); + } + await ExecuteAsync(command, cancellation.Token); + return 0; + } + catch (OperationCanceledException) when (cancellation.IsCancellationRequested) + { + Console.Error.WriteLine("helix-cache: Cancelled."); + return 130; + } + catch (Exception ex) when (ex is InvalidDataException or IOException or UnauthorizedAccessException or JsonException or + HttpRequestException or TaskCanceledException or XmlException or ArgumentException or + OverflowException or PlatformNotSupportedException or TimeoutException) + { + Console.Error.WriteLine($"helix-cache: {ex.Message}"); + return 1; + } + finally + { + Console.CancelKeyPress -= cancel; + } + } + + private static async Task ExecuteAsync(Command command, CancellationToken cancellationToken) + { + Cache cache = new(command.CacheDirectory); + using HttpClient http = new() { Timeout = TimeSpan.FromSeconds(60) }; + HelixClient helix = new(http); + switch (command) + { + case LookupCommand lookup: + { + string manifest = Path.GetFullPath(lookup.Manifest); + using FileStream input = File.OpenRead(manifest); + Submission submission = await JsonSerializer.DeserializeAsync(input, CacheJsonContext.Default.Submission, cancellationToken) + ?? throw new InvalidDataException($"Missing submission: {manifest}."); + submission.Validate(); + LinuxEnvironment environment = await LinuxEnvironment.LoadAsync(lookup.QueueInfo, helix, cancellationToken); + LookupResult result = cache.Lookup(submission, Path.GetDirectoryName(manifest)!, environment); + WriteLookup(lookup.Output, result); + break; + } + case StoreCommand store: + { + using FileStream input = File.OpenRead(store.Manifest); + ExecutionRecord execution = await JsonSerializer.DeserializeAsync(input, CacheJsonContext.Default.ExecutionRecord, cancellationToken) + ?? throw new InvalidDataException($"Missing execution record: {store.Manifest}."); + cache.Store(execution, store.Results); + Console.Error.WriteLine($"Stored {execution.Key}."); + break; + } + case PrepareCommand prepare: + { + LinuxEnvironment environment = await LinuxEnvironment.LoadAsync(prepare.QueueInfo, helix, cancellationToken); + LookupEntry entry = ShadowValidation.Prepare(cache, prepare.Output, prepare.Payload, prepare.Runtime, prepare.Queue, + prepare.HelixSdk, environment); + Console.WriteLine($"Helix cache shadow: {entry.Status.ToString().ToLowerInvariant()} {entry.Execution.Key}; execution will NOT be skipped."); + break; + } + case CollectCommand collect: + { + ShadowSummary summary = await ShadowValidation.CollectAsync(cache, collect.Output, collect.Wait, helix, cancellationToken); + Console.WriteLine(JsonSerializer.Serialize(summary, CacheJsonContext.Default.ShadowSummary)); + break; + } + } + } + + private static void WriteLookup(string path, LookupResult result) + { + string output = Path.GetFullPath(path); + Directory.CreateDirectory(Path.GetDirectoryName(output)!); + string temporary = output + "." + Guid.NewGuid().ToString("N") + ".tmp"; + try + { + using (FileStream file = File.Create(temporary)) + { + JsonSerializer.Serialize(file, result, CacheJsonContext.Default.LookupResult); + file.Flush(flushToDisk: true); + } + File.Move(temporary, output, overwrite: true); + } + finally + { + File.Delete(temporary); + } + } +} diff --git a/src/tools/helix-cache/src/Results.cs b/src/tools/helix-cache/src/Results.cs new file mode 100644 index 00000000000000..ae4437d1c98b4e --- /dev/null +++ b/src/tools/helix-cache/src/Results.cs @@ -0,0 +1,119 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Xml; +using System.Xml.Linq; + +namespace HelixCache; + +internal readonly record struct TestOutcome(string Assembly, string Name, string Result, string SkipReason); + +internal sealed record TestRun(ResultReceipt Receipt, TestSummary Summary, Dictionary Outcomes) +{ + internal bool HasSameOutcomes(TestRun other) => + Outcomes.Count == other.Outcomes.Count && + Outcomes.All(p => other.Outcomes.TryGetValue(p.Key, out int count) && count == p.Value); +} + +internal static class Results +{ + internal static TestRun Read(string directory, ExecutionRecord execution) + { + using FileStream file = File.OpenRead(Path.Combine(directory, "result.json")); + ResultReceipt receipt = JsonSerializer.Deserialize(file, CacheJsonContext.Default.ResultReceipt) + ?? throw new InvalidDataException($"Missing result receipt: {directory}."); + Cache.ValidateVersion(receipt.SchemaVersion); + if (!receipt.Completed || !receipt.InfrastructureSucceeded || + receipt.ExitCode != execution.Inputs.ExpectedExitCode || + receipt.WorkItemName != execution.Inputs.Name || receipt.TargetQueue != execution.Inputs.TargetQueue || + !Guid.TryParse(receipt.JobId, out _) || receipt.ResultFiles.Count == 0 || + receipt.ResultFiles.Any(string.IsNullOrWhiteSpace)) + { + throw new InvalidDataException($"Results are incomplete, unsuccessful, or do not match the execution record: {directory}."); + } + + TestSummary summary = new(); + Dictionary outcomes = []; + HashSet reports = new(StringComparer.Ordinal); + foreach (string report in receipt.ResultFiles) + { + Snapshot.ValidateRelativePath(report); + if (!reports.Add(report)) + { + throw new InvalidDataException($"Duplicate report in {nameof(receipt.ResultFiles)}: {report}."); + } + ReadReport(Path.Combine(directory, report), summary, outcomes); + } + return new TestRun(receipt, summary, outcomes); + } + + private static void ReadReport(string path, TestSummary summary, Dictionary outcomes) + { + using XmlReader reader = XmlReader.Create(path, new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null }); + XDocument document = XDocument.Load(reader); + if (document.Root is not XElement root || root.Name != "assemblies" || !root.Elements("assembly").Any() || + document.Descendants("error").Any() || document.Descendants("failure").Any()) + { + throw InvalidReport(path); + } + foreach (XElement assembly in root.Elements("assembly")) + { + string? assemblyName = Path.GetFileName((string?)assembly.Attribute("name")); + if (string.IsNullOrWhiteSpace(assemblyName)) + { + throw InvalidReport(path); + } + TestSummary actual = new(); + foreach (XElement test in assembly.Elements("collection").Elements("test")) + { + string? name = (string?)test.Attribute("name"); + string? result = (string?)test.Attribute("result"); + if (string.IsNullOrWhiteSpace(name) || result is not ("Pass" or "Skip")) + { + throw InvalidReport(path); + } + TestOutcome outcome = new(assemblyName, name, result, + result == "Skip" ? (string?)test.Element("reason")?.Element("message") ?? "" : ""); + outcomes.TryGetValue(outcome, out int count); + checked + { + outcomes[outcome] = count + 1; + actual.Total++; + actual.Passed += result == "Pass" ? 1 : 0; + actual.Skipped += result == "Skip" ? 1 : 0; + } + } + if (actual.Total == 0 || assembly.Descendants("test").Count() != actual.Total || + Count(assembly, "total") != actual.Total || Count(assembly, "passed") != actual.Passed || + Count(assembly, "skipped") != actual.Skipped || Count(assembly, "failed") != 0 || + (assembly.Attribute("errors") is not null && Count(assembly, "errors") != 0)) + { + throw InvalidReport(path); + } + checked + { + summary.Total += actual.Total; + summary.Passed += actual.Passed; + summary.Skipped += actual.Skipped; + } + } + } + + private static InvalidDataException InvalidReport(string path) => + new($"Expected a completed, nonempty, passing xUnit report with named tests and assemblies: {path}."); + + private static int Count(XElement assembly, string name) + { + if (!int.TryParse((string?)assembly.Attribute(name), NumberStyles.None, CultureInfo.InvariantCulture, out int count)) + { + throw new InvalidDataException($"Invalid xUnit report count: {name}."); + } + return count; + } +} diff --git a/src/tools/helix-cache/src/ShadowValidation.cs b/src/tools/helix-cache/src/ShadowValidation.cs new file mode 100644 index 00000000000000..cc0908df29e410 --- /dev/null +++ b/src/tools/helix-cache/src/ShadowValidation.cs @@ -0,0 +1,172 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using System.Xml.Linq; + +namespace HelixCache; + +internal static class ShadowValidation +{ + internal const string WorkItem = "System.Memory.Data.Tests"; + internal const string Command = "bash ./RunTests.sh --runtime-path \"$HELIX_CORRELATION_PAYLOAD\""; + internal const string PostCommand = "cp testResults.xml \"$HELIX_WORKITEM_UPLOAD_ROOT/testResults.xml\""; + + internal static LookupEntry Prepare(Cache cache, string output, string payload, string runtime, string queue, string helixSdk, + LinuxEnvironment environment) + { + payload = Path.GetFullPath(payload); + runtime = Path.GetFullPath(runtime); + foreach (string archive in new[] { payload, runtime }) + { + if (!File.Exists(archive) || !archive.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException($"The shadow submitter requires an existing ZIP archive: {archive}."); + } + } + output = Path.GetFullPath(output); + if (Directory.Exists(output) || File.Exists(output)) + { + throw new IOException($"The shadow output directory already exists: {output}."); + } + Submission submission = new() + { + SchemaVersion = Cache.ProtocolVersion, + WorkItems = [new WorkItem + { + Name = WorkItem, + Queues = [queue], + Payload = new Payload { Path = payload }, + CorrelationPayloads = [new Payload { Path = runtime }], + Command = Command, + PostCommands = [PostCommand], + CommandGeneratorDigest = Snapshot.HashFile(helixSdk), + TimeoutSeconds = 900, + ExpectedExitCode = 0, + }], + }; + LookupResult lookup = cache.Lookup(submission, output, environment); + LookupEntry entry = lookup.Entries.Single(); + + Directory.CreateDirectory(output); + File.WriteAllText(Path.Combine(output, "submission.json"), JsonSerializer.Serialize(submission, CacheJsonContext.Default.Submission)); + File.WriteAllText(Path.Combine(output, "lookup.json"), JsonSerializer.Serialize(lookup, CacheJsonContext.Default.LookupResult)); + File.WriteAllText(Path.Combine(output, "execution.json"), JsonSerializer.Serialize(entry.Execution, CacheJsonContext.Default.ExecutionRecord)); + WriteSubmissionProperties(output, entry.Execution); + return entry; + } + + internal static async Task CollectAsync(Cache cache, string output, TimeSpan wait, HelixClient helix, + CancellationToken cancellationToken = default) + { + output = Path.GetFullPath(output); + LookupResult lookup = JsonSerializer.Deserialize(File.ReadAllText(Path.Combine(output, "lookup.json")), + CacheJsonContext.Default.LookupResult) ?? throw new InvalidDataException("Missing shadow lookup result."); + Cache.ValidateVersion(lookup.SchemaVersion); + if (lookup.Entries.Count != 1 || lookup.Entries[0] is not LookupEntry entry || + entry.Status is not (LookupStatus.Hit or LookupStatus.Miss)) + { + throw new InvalidDataException("Expected exactly one shadow lookup entry with hit or miss status."); + } + ExecutionRecord execution = entry.Execution; + execution.Validate(); + TestRun? cached = entry.Status == LookupStatus.Hit ? cache.ReadResults(execution) : null; + if (!Guid.TryParse(File.ReadAllText(Path.Combine(output, "job-id.txt")).Trim(), out Guid job)) + { + throw new InvalidDataException("Expected a Helix job GUID in job-id.txt."); + } + string jobId = job.ToString(); + ShadowSummary summary = new() { Key = execution.Key, Lookup = entry.Status, JobId = jobId }; + string summaryPath = Path.Combine(output, "summary.json"); + File.WriteAllText(summaryPath, JsonSerializer.Serialize(summary, CacheJsonContext.Default.ShadowSummary)); + + HelixJob jobDetails = await helix.GetJobAsync(jobId, cancellationToken); + string submittedQueue = string.IsNullOrEmpty(jobDetails.QueueAlias) ? jobDetails.QueueId : jobDetails.QueueAlias; + if (jobDetails.Name != jobId || + !string.Equals(submittedQueue, execution.Inputs.EnvironmentFingerprint.QueueId, StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException("Helix returned a different job or target queue."); + } + HelixWorkItem item = await helix.WaitForCompletionAsync(jobId, execution.Inputs.Name, wait, cancellationToken); + File.WriteAllText(Path.Combine(output, "work-item.json"), JsonSerializer.Serialize(item, HelixJsonContext.Default.HelixWorkItem)); + if (item.State is not ("Finished" or "Passed") || item.FailureReason is not null || + item.Errors is { Count: > 0 } || item.ExitCode is null || item.ExitCode != execution.Inputs.ExpectedExitCode) + { + throw new InvalidDataException($"Helix work item did not complete successfully; see {Path.Combine(output, "work-item.json")}."); + } + + string results = Path.Combine(output, "results"); + await helix.DownloadResultsAsync(item, results, cancellationToken); + ResultReceipt receipt = new() + { + SchemaVersion = Cache.ProtocolVersion, + Completed = true, + InfrastructureSucceeded = true, + ExitCode = item.ExitCode.Value, + JobId = jobId, + WorkItemName = item.Name, + TargetQueue = execution.Inputs.TargetQueue, + ResultFiles = ["testResults.xml"], + }; + ExecutionProvenance provenance = new() + { + JobId = jobId, + QueueAlias = jobDetails.QueueAlias, + ResolvedQueueId = jobDetails.QueueId, + MachineName = item.MachineName, + State = item.State, + }; + File.WriteAllText(Path.Combine(results, "result.json"), JsonSerializer.Serialize(receipt, CacheJsonContext.Default.ResultReceipt)); + File.WriteAllText(Path.Combine(results, "provenance.json"), JsonSerializer.Serialize(provenance, CacheJsonContext.Default.ExecutionProvenance)); + + TestRun fresh = Results.Read(results, execution); + summary = summary with + { + Tests = fresh.Summary.Total, + CachedJobId = cached?.Receipt.JobId, + Comparison = cached is null ? OutcomeComparison.NotApplicable : + cached.HasSameOutcomes(fresh) ? OutcomeComparison.Match : OutcomeComparison.Mismatch, + }; + if (summary.Comparison == OutcomeComparison.Mismatch) + { + File.WriteAllText(summaryPath, JsonSerializer.Serialize(summary, CacheJsonContext.Default.ShadowSummary)); + throw new InvalidDataException($"Cached and fresh test outcomes differ; see {summaryPath}."); + } + cache.Store(execution, results); + summary = summary with { Stored = true }; + File.WriteAllText(summaryPath, JsonSerializer.Serialize(summary, CacheJsonContext.Default.ShadowSummary)); + return summary; + } + + private static void WriteSubmissionProperties(string output, ExecutionRecord execution) + { + XElement properties = new("PropertyGroup"); + foreach ((string name, string value) in new Dictionary + { + ["ShadowName"] = execution.Inputs.Name, + ["ShadowQueue"] = execution.Inputs.TargetQueue, + ["ShadowPayload"] = execution.PayloadPath, + ["ShadowRuntime"] = execution.CorrelationPayloadPaths[0], + ["ShadowCommand"] = execution.Inputs.Command, + ["ShadowPostCommand"] = execution.Inputs.PostCommands[0], + ["ShadowCommandGeneratorDigest"] = execution.Inputs.CommandGeneratorDigest!, + ["ShadowTimeout"] = TimeSpan.FromSeconds(execution.Inputs.TimeoutSeconds).ToString("c", CultureInfo.InvariantCulture), + }) + { + string escaped = value; + foreach (char character in "%$@();'*?") + { + escaped = escaped.Replace(character.ToString(), $"%{(int)character:X2}", StringComparison.Ordinal); + } + properties.Add(new XElement(name, escaped)); + } + new XDocument(new XElement("Project", properties)).Save(Path.Combine(output, "submission.props")); + } +} diff --git a/src/tools/helix-cache/src/Snapshot.cs b/src/tools/helix-cache/src/Snapshot.cs new file mode 100644 index 00000000000000..cdd58cce08aa5d --- /dev/null +++ b/src/tools/helix-cache/src/Snapshot.cs @@ -0,0 +1,223 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Security.Cryptography; +using System.Text.Json; + +namespace HelixCache; + +internal sealed class Snapshot +{ + private static readonly DateTime s_timestamp = new(2000, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + internal required string Path { get; init; } + internal required string Digest { get; init; } + internal required PayloadKind Kind { get; init; } + + internal static Snapshot Capture(string source, string cache) + { + source = System.IO.Path.GetFullPath(source); + FileAttributes attributes = File.GetAttributes(source); + RejectLink(source, attributes); + bool directory = (attributes & FileAttributes.Directory) != 0; + if (!directory && !source.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) + { + throw new InvalidDataException($"Payload '{source}' must be a directory or a ZIP archive."); + } + + using TemporaryDirectory temporary = new(System.IO.Path.Combine(cache, "tmp")); + EnsureOutside(source, temporary.Path); + string name = directory ? "payload" : System.IO.Path.GetFileName(source); + string staged = System.IO.Path.Combine(temporary.Path, name); + if (directory) + { + Copy(source, staged); + } + else + { + NormalizeArchive(source, staged); + File.SetLastWriteTimeUtc(staged, s_timestamp); + File.SetUnixFileMode(staged, File.GetUnixFileMode(source)); + } + string digest = Hash(staged); + string parent = System.IO.Path.Combine(cache, "inputs", "v1"); + Directory.CreateDirectory(parent); + string final = System.IO.Path.Combine(parent, digest); + try + { + Directory.Move(temporary.Path, final); + } + catch (IOException) when (Directory.Exists(final)) + { + string existing = System.IO.Path.Combine(final, name); + if (Hash(existing) != digest) + { + throw new InvalidDataException($"Snapshot failed validation: {existing}."); + } + } + + return new Snapshot { Path = System.IO.Path.Combine(final, name), Digest = digest, Kind = directory ? PayloadKind.Directory : PayloadKind.Archive }; + } + + internal static void EnsureOutside(string source, string destination) + { + if (Directory.Exists(source) && + (destination == source || destination.StartsWith(System.IO.Path.TrimEndingDirectorySeparator(source) + "/", StringComparison.Ordinal))) + { + throw new InvalidDataException($"Cache or staging directory '{destination}' must not be inside input directory '{source}'."); + } + } + + internal static void ValidateRelativePath(string path, bool allowEmpty = false) + { + if ((!allowEmpty && string.IsNullOrWhiteSpace(path)) || System.IO.Path.IsPathRooted(path) || + path.Split('/').Any(p => p is "." or "..") || path.Contains('\\')) + { + throw new InvalidDataException($"Expected a relative path without traversal: {path}."); + } + } + + internal static void Copy(string source, string destination) + { + FileAttributes attributes = File.GetAttributes(source); + RejectLink(source, attributes); + UnixFileMode mode = File.GetUnixFileMode(source); + if ((attributes & FileAttributes.Directory) != 0) + { + Directory.CreateDirectory(destination); + foreach (string child in Directory.EnumerateFileSystemEntries(source).Order(StringComparer.Ordinal)) + { + Copy(child, System.IO.Path.Combine(destination, System.IO.Path.GetFileName(child))); + } + Directory.SetLastWriteTimeUtc(destination, s_timestamp); + } + else + { + using (FileStream input = File.OpenRead(source)) + using (FileStream output = new(destination, FileMode.CreateNew, FileAccess.Write)) + { + input.CopyTo(output); + } + File.SetLastWriteTimeUtc(destination, s_timestamp); + } + File.SetUnixFileMode(destination, mode); + } + + internal static string Hash(string path) + { + using MemoryStream manifest = new(); + using (Utf8JsonWriter writer = new(manifest)) + { + writer.WriteStartArray(); + WriteTree(writer, path, Directory.Exists(path) ? "" : System.IO.Path.GetFileName(path)); + writer.WriteEndArray(); + } + return Convert.ToHexStringLower(SHA256.HashData(manifest.GetBuffer().AsSpan(0, checked((int)manifest.Length)))); + } + + internal static string HashFile(string path) + { + using FileStream file = File.OpenRead(path); + return Convert.ToHexStringLower(SHA256.HashData(file)); + } + + private static void NormalizeArchive(string source, string destination) + { + using ZipArchive input = ZipFile.OpenRead(source); + Dictionary paths = new(StringComparer.Ordinal); + foreach (ZipArchiveEntry entry in input.Entries) + { + string path = entry.FullName.TrimEnd('/'); + ValidateRelativePath(path); + if (path.Split('/').Any(string.IsNullOrEmpty) || entry.FullName.EndsWith("//", StringComparison.Ordinal) || + path.Contains('\0') || !paths.TryAdd(path, entry) || + (entry.FullName.EndsWith('/') && entry.Length != 0)) + { + throw new InvalidDataException($"Invalid or duplicate ZIP entry '{entry.FullName}' in '{source}'."); + } + } + foreach (string path in paths.Keys) + { + for (int separator = path.IndexOf('/'); separator >= 0; separator = path.IndexOf('/', separator + 1)) + { + if (paths.TryGetValue(path[..separator], out ZipArchiveEntry? parent) && !parent.FullName.EndsWith('/')) + { + throw new InvalidDataException($"ZIP entry '{path}' has a non-directory parent in '{source}'."); + } + } + } + + using FileStream file = new(destination, FileMode.CreateNew, FileAccess.Write); + using ZipArchive output = new(file, ZipArchiveMode.Create); + foreach (ZipArchiveEntry entry in input.Entries.OrderBy(e => e.FullName, StringComparer.Ordinal)) + { + ZipArchiveEntry normalized = output.CreateEntry(entry.FullName, CompressionLevel.Fastest); + normalized.LastWriteTime = new DateTimeOffset(s_timestamp); + normalized.ExternalAttributes = entry.ExternalAttributes; + using Stream contents = entry.Open(); + using Stream target = normalized.Open(); + contents.CopyTo(target); + } + } + + private static void WriteTree(Utf8JsonWriter writer, string path, string relative) + { + FileAttributes attributes = File.GetAttributes(path); + RejectLink(path, attributes); + bool directory = (attributes & FileAttributes.Directory) != 0; + writer.WriteStartArray(); + writer.WriteStringValue(relative); + writer.WriteStringValue(directory ? "directory" : "file"); + writer.WriteNumberValue((int)File.GetUnixFileMode(path)); + if (!directory) + { + writer.WriteStringValue(HashFile(path)); + } + writer.WriteEndArray(); + if (directory) + { + foreach (string child in Directory.EnumerateFileSystemEntries(path).Order(StringComparer.Ordinal)) + { + string name = System.IO.Path.GetFileName(child); + WriteTree(writer, child, relative.Length == 0 ? name : relative + "/" + name); + } + } + } + + private static void RejectLink(string path, FileAttributes attributes) + { + if ((attributes & FileAttributes.ReparsePoint) != 0) + { + throw new InvalidDataException($"Symbolic links are not supported in directory payloads or results: {path}. Use an archive for payloads containing links."); + } + } +} + +internal sealed class TemporaryDirectory : IDisposable +{ + internal string Path { get; } + + internal TemporaryDirectory(string parent) + { + Path = System.IO.Path.Combine(parent, Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(Path); + } + + public void Dispose() + { + if (Directory.Exists(Path)) + { + // Copied inputs can contain read-only directories. + foreach (string directory in Directory.EnumerateDirectories(Path, "*", SearchOption.AllDirectories).Prepend(Path)) + { + File.SetUnixFileMode(directory, File.GetUnixFileMode(directory) | UnixFileMode.UserWrite | UnixFileMode.UserExecute); + } + Directory.Delete(Path, recursive: true); + } + } +} diff --git a/src/tools/helix-cache/tests/CacheFixture.cs b/src/tools/helix-cache/tests/CacheFixture.cs new file mode 100644 index 00000000000000..5a32c837b1173b --- /dev/null +++ b/src/tools/helix-cache/tests/CacheFixture.cs @@ -0,0 +1,177 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Serialization.Metadata; +using System.Threading.Tasks; +using Xunit; + +namespace HelixCache.Tests; + +internal sealed class CacheFixture : IDisposable +{ + internal const int ArchiveFileMode = 0x81A4; + internal const int ArchiveExecutableMode = 0x81ED; + internal const int ArchiveDirectoryMode = 0x41ED; + internal const int ArchiveLinkMode = 0xA1FF; + private const UnixFileMode ExecutableMode = UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute | + UnixFileMode.GroupRead | UnixFileMode.GroupExecute | UnixFileMode.OtherRead | UnixFileMode.OtherExecute; + internal const string FirstJob = "00000000-0000-0000-0000-000000000001"; + internal const string SecondJob = "00000000-0000-0000-0000-000000000002"; + internal const string Report = """ + + + + """; + + internal string Root { get; } = Directory.CreateTempSubdirectory("helix-cache-test-").FullName; + internal string CacheDirectory { get; set; } + internal string PayloadDirectory { get; } + internal string Archive { get; } + internal List Queues { get; } + internal Submission Submission { get; set; } + internal WorkItem Item + { + get => Submission.WorkItems[0]; + set => Submission.WorkItems[0] = value; + } + + internal CacheFixture() + { + CacheDirectory = Path.Combine(Root, "cache"); + PayloadDirectory = Directory.CreateDirectory(Path.Combine(Root, "payload")).FullName; + File.SetUnixFileMode(PayloadDirectory, ExecutableMode); + File.WriteAllText(Path.Combine(PayloadDirectory, "run.sh"), "#!/bin/sh\nexit 0\n"); + File.SetUnixFileMode(Path.Combine(PayloadDirectory, "run.sh"), ExecutableMode); + Archive = Path.Combine(Root, "shared.zip"); + WriteArchive(Archive, [("shared.txt", "shared payload", ArchiveFileMode)]); + Queues = [new HelixQueue + { + QueueId = "linux.test.open", Architecture = "AMD64", OperatingSystemGroup = "linux", IsAvailable = true, + Metadata = new Dictionary + { + ["GalleryImage"] = JsonSerializer.SerializeToElement(new { Version = "1" }), + ["Artifacts"] = JsonSerializer.SerializeToElement(new[] { new { Id = "prepare", Parameters = new { version = "1" } } }), + }, + }]; + Submission = new Submission + { + SchemaVersion = 1, + WorkItems = [new WorkItem + { + Name = "suite", Queues = [Queues[0].QueueId], Payload = new Payload { Path = "payload" }, + CorrelationPayloads = [new Payload { Path = "shared.zip", Destination = "runtime" }], + Command = "./run.sh", Environment = new() { ["SECOND"] = "2", ["FIRST"] = "1" }, + TimeoutSeconds = 60, ExpectedExitCode = 0, + }], + }; + } + + internal static void WriteArchive(string path, (string Name, string Content, int Mode)[] entries, bool repack = false) + { + using FileStream file = File.Create(path); + using ZipArchive archive = new(file, ZipArchiveMode.Create); + foreach ((string name, string content, int mode) in repack ? entries.Reverse() : entries) + { + ZipArchiveEntry entry = archive.CreateEntry(name, repack ? CompressionLevel.NoCompression : CompressionLevel.Optimal); + entry.LastWriteTime = new DateTimeOffset(repack ? 2025 : 2024, 1, 1, 0, 0, 0, TimeSpan.Zero); + entry.ExternalAttributes = mode << 16; + using StreamWriter writer = new(entry.Open()); + writer.Write(content); + } + } + + internal string Write(string name, T value, JsonTypeInfo type) + { + string path = Path.Combine(Root, name); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(value, type)); + return path; + } + + internal static T Read(string path, JsonTypeInfo type) => + JsonSerializer.Deserialize(File.ReadAllText(path), type)!; + + internal LookupEntry Lookup(bool success = true) + { + Invoke(success, "lookup", Write("submission.json", Submission, CacheJsonContext.Default.Submission), + "--cache", CacheDirectory, "--output", Path.Combine(Root, "lookup.json"), + "--queue-info", Write("queues.json", Queues, HelixJsonContext.Default.ListHelixQueue)); + return success ? Read(Path.Combine(Root, "lookup.json"), CacheJsonContext.Default.LookupResult).Entries[0] : null!; + } + + internal string CreateResults(ExecutionRecord execution, string jobId = FirstJob) + { + string results = Directory.CreateDirectory(Path.Combine(Root, "results")).FullName; + Write("results/result.json", new ResultReceipt + { + SchemaVersion = 1, + Completed = true, + InfrastructureSucceeded = true, + ExitCode = 0, + JobId = jobId, + WorkItemName = execution.Inputs.Name, + TargetQueue = execution.Inputs.TargetQueue, + ResultFiles = ["tests.xml"], + }, CacheJsonContext.Default.ResultReceipt); + File.WriteAllText(Path.Combine(results, "tests.xml"), Report); + return results; + } + + internal void Store(ExecutionRecord execution, bool success = true) => + Invoke(success, "store", Write("execution.json", execution, CacheJsonContext.Default.ExecutionRecord), + "--results", Path.Combine(Root, "results"), "--cache", CacheDirectory); + + internal string Prepare(string name = "shadow") + { + string output = Path.Combine(Root, name); + ShadowValidation.Prepare(new Cache(CacheDirectory), output, Archive, Archive, Queues[0].QueueId, Archive, new LinuxEnvironment(Queues)); + return output; + } + + internal static LookupEntry ShadowLookup(string output) => + Assert.Single(Read(Path.Combine(output, "lookup.json"), CacheJsonContext.Default.LookupResult).Entries); + + internal string Invoke(bool success, params string[] arguments) + { + string? executable = Environment.GetEnvironmentVariable("HELIX_CACHE_EXECUTABLE"); + if (executable is null) + { + DirectoryInfo? directory = new(Environment.CurrentDirectory); + while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "global.json"))) + { + directory = directory.Parent; + } + Assert.NotNull(directory); + executable = Path.Combine(directory.FullName, "artifacts/tools/helix-cache/helix-cache"); + } + ProcessStartInfo start = new(executable) { RedirectStandardOutput = true, RedirectStandardError = true }; + foreach (string argument in arguments) + { + start.ArgumentList.Add(argument); + } + using Process process = Process.Start(start)!; + Task stdout = process.StandardOutput.ReadToEndAsync(); + Task stderr = process.StandardError.ReadToEndAsync(); + if (!process.WaitForExit(30_000)) + { + process.Kill(entireProcessTree: true); + Assert.Fail("The native cache process timed out."); + } + string error = stderr.GetAwaiter().GetResult(); + Assert.True(process.ExitCode == (success ? 0 : 1), error); + if (!success) + { + Assert.Contains("helix-cache:", error); + } + return stdout.GetAwaiter().GetResult(); + } + + public void Dispose() => Directory.Delete(Root, recursive: true); +} diff --git a/src/tools/helix-cache/tests/CacheTests.cs b/src/tools/helix-cache/tests/CacheTests.cs new file mode 100644 index 00000000000000..63c38292d6d4d6 --- /dev/null +++ b/src/tools/helix-cache/tests/CacheTests.cs @@ -0,0 +1,548 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading.Tasks; +using Xunit; + +namespace HelixCache.Tests; + +public sealed class CacheTests +{ + [Fact] + public void RoundTripPreservesFrozenInputsAndResults() + { + using CacheFixture f = new(); + LookupEntry miss = f.Lookup(); + Assert.Equal(LookupStatus.Miss, miss.Status); + Assert.NotEqual(f.PayloadDirectory, miss.Execution.PayloadPath); + JsonObject fingerprint = JsonNode.Parse(File.ReadAllText(Path.Combine(f.Root, "lookup.json")))! + ["entries"]![0]!["execution"]!["inputs"]!["environmentFingerprint"]!.AsObject(); + Assert.Equal(f.Queues[0].QueueId, (string?)fingerprint["queueId"]); + Assert.Equal("AMD64", (string?)fingerprint["architecture"]); + Assert.Equal("linux", (string?)fingerprint["operatingSystemGroup"]); + Assert.Contains("container", fingerprint.Select(p => p.Key)); + f.CreateResults(miss.Execution); + f.Store(miss.Execution); + LookupEntry hit = f.Lookup(); + Assert.Equal(LookupStatus.Hit, hit.Status); + Assert.Equal(miss.Execution.Key, hit.Execution.Key); + Assert.Equal(File.ReadAllBytes(Path.Combine(f.Root, "results/tests.xml")), + File.ReadAllBytes(Path.Combine(hit.ResultsPath!, "tests.xml"))); + File.WriteAllText(Path.Combine(f.PayloadDirectory, "run.sh"), "changed"); + Assert.Equal("#!/bin/sh\nexit 0\n", File.ReadAllText(Path.Combine(miss.Execution.PayloadPath, "run.sh"))); + Assert.Equal(LookupStatus.Miss, f.Lookup().Status); + } + + [Fact] + public void RepeatedLookupsOnOneCacheCaptureChangedInputs() + { + using CacheFixture f = new(); + Cache cache = new(f.CacheDirectory); + LinuxEnvironment environment = new(f.Queues); + string before = Assert.Single(cache.Lookup(f.Submission, f.Root, environment).Entries).Execution.Key; + File.WriteAllText(Path.Combine(f.PayloadDirectory, "run.sh"), "changed"); + string after = Assert.Single(cache.Lookup(f.Submission, f.Root, environment).Entries).Execution.Key; + Assert.NotEqual(before, after); + } + + [Fact] + public void WholeSubmissionIsValidatedBeforeCapturingInputs() + { + using CacheFixture f = new(); + f.Submission.WorkItems.Add(f.Item with { Name = "invalid", TimeoutSeconds = 0 }); + Assert.Throws(() => new Cache(f.CacheDirectory).Lookup(f.Submission, f.Root, new LinuxEnvironment(f.Queues))); + Assert.False(Directory.Exists(f.CacheDirectory)); + } + + [Fact] + public void OpaqueProvisioningPreservesNullArrayValues() + { + using CacheFixture f = new(); + f.Queues[0].Metadata["PreparedImage"] = JsonSerializer.Deserialize("""{"FutureField":[null,1]}"""); + LookupEntry miss = f.Lookup(); + f.CreateResults(miss.Execution); + f.Store(miss.Execution); + Assert.Equal(LookupStatus.Hit, f.Lookup().Status); + f.Queues[0].Metadata["PreparedImage"] = JsonSerializer.Deserialize("""{"FutureField":[null,2]}"""); + Assert.NotEqual(miss.Execution.Key, f.Lookup().Execution.Key); + } + + [Theory] + [InlineData("command")] + [InlineData("preCommands")] + [InlineData("postCommands")] + [InlineData("timeout")] + [InlineData("exitCode")] + [InlineData("environment")] + [InlineData("commandGenerator")] + public void ExecutionChangesInvalidateKey(string field) + { + using CacheFixture f = new(); + string baseline = f.Lookup().Execution.Key; + f.Item = field switch + { + "command" => f.Item with { Command = "./run.sh other" }, + "preCommands" => f.Item with { PreCommands = ["export STRESS=1"] }, + "postCommands" => f.Item with { PostCommands = ["echo done"] }, + "timeout" => f.Item with { TimeoutSeconds = 61 }, + "exitCode" => f.Item with { ExpectedExitCode = 100 }, + "environment" => f.Item with { Environment = new() { ["FIRST"] = "changed" } }, + "commandGenerator" => f.Item with { CommandGeneratorDigest = new string('a', 64) }, + _ => throw new InvalidOperationException(), + }; + Assert.NotEqual(baseline, f.Lookup().Execution.Key); + } + + [Theory] + [InlineData("GalleryImage", """{"Version":"2"}""")] + [InlineData("Artifacts", """[{"Id":"prepare","Parameters":{"version":"2"}}]""")] + [InlineData("PreparedImage", """{"Version":"new","FutureField":{"b":2,"a":1}}""")] + public void ImageAndProvisioningChangesInvalidateKey(string field, string value) + { + using CacheFixture f = new(); + string baseline = f.Lookup().Execution.Key; + f.Queues[0].Metadata[field] = JsonSerializer.Deserialize(value); + Assert.NotEqual(baseline, f.Lookup().Execution.Key); + } + + [Fact] + public void ObjectOrderingLiveQueueStateAndTimestampsDoNotChangeKey() + { + using CacheFixture f = new(); + f.Queues[0].Metadata["PreparedImage"] = JsonSerializer.Deserialize("""{"b":{"z":2,"a":1},"a":null}"""); + string baseline = f.Lookup().Execution.Key; + f.Queues[0].Metadata["PreparedImage"] = JsonSerializer.Deserialize("""{"a":null,"b":{"a":1,"z":2}}"""); + f.Queues[0].Metadata["QueueDepth"] = JsonSerializer.SerializeToElement(300); + f.Queues[0].Metadata["IsInProtectedMode"] = JsonSerializer.SerializeToElement(true); + f.Item = f.Item with { Environment = new() { ["FIRST"] = "1", ["SECOND"] = "2" } }; + File.SetLastWriteTimeUtc(Path.Combine(f.PayloadDirectory, "run.sh"), DateTime.UnixEpoch); + Assert.Equal(baseline, f.Lookup().Execution.Key); + } + + [Fact] + public void RelocationDoesNotChangeKeyButModesAndEmptyDirectoriesDo() + { + using CacheFixture f = new(); + string baseline = f.Lookup().Execution.Key; + string relocated = Path.Combine(f.Root, "relocated"); + Directory.Move(f.PayloadDirectory, relocated); + f.Item = f.Item with { Payload = f.Item.Payload with { Path = relocated } }; + Assert.Equal(baseline, f.Lookup().Execution.Key); + string script = Path.Combine(relocated, "run.sh"); + UnixFileMode mode = File.GetUnixFileMode(script); + File.SetUnixFileMode(script, mode & ~UnixFileMode.UserExecute); + Assert.NotEqual(baseline, f.Lookup().Execution.Key); + File.SetUnixFileMode(script, mode); + Directory.CreateDirectory(Path.Combine(relocated, "empty")); + Assert.NotEqual(baseline, f.Lookup().Execution.Key); + } + + [Fact] + public void QueuesAndPinnedContainersArePartitioned() + { + using CacheFixture f = new(); + string target = "(Debian.Amd64)linux.test.open@registry/image:tag@sha256:" + new string('a', 64); + f.Item.Queues.Add(target); + LookupEntry first = f.Lookup(); + LookupResult lookup = CacheFixture.Read(Path.Combine(f.Root, "lookup.json"), CacheJsonContext.Default.LookupResult); + Assert.Equal(2, lookup.Entries.Count); + Assert.NotEqual(first.Execution.Key, lookup.Entries[1].Execution.Key); + Assert.Equal(target[(target.IndexOf('@') + 1)..], lookup.Entries[1].Execution.Inputs.EnvironmentFingerprint.Container); + f.CreateResults(first.Execution); + f.Store(first.Execution); + f.Lookup(); + lookup = CacheFixture.Read(Path.Combine(f.Root, "lookup.json"), CacheJsonContext.Default.LookupResult); + Assert.Equal([LookupStatus.Hit, LookupStatus.Miss], lookup.Entries.Select(e => e.Status)); + } + + [Theory] + [InlineData("linux.test.open@registry/image:latest")] + [InlineData("(alias)linux.test.open")] + public void UnpinnedOrMalformedContainerTargetsAreRejected(string target) + { + using CacheFixture f = new(); + f.Item = f.Item with { Queues = [target] }; + f.Lookup(success: false); + } + + [Fact] + public void SharedPayloadBytesDestinationAndOverlayOrderAffectKey() + { + using CacheFixture f = new(); + string baseline = f.Lookup().Execution.Key; + CacheFixture.WriteArchive(f.Archive, [("shared.txt", "changed payload", CacheFixture.ArchiveFileMode)]); + Assert.NotEqual(baseline, f.Lookup().Execution.Key); + CacheFixture.WriteArchive(f.Archive, [("shared.txt", "shared payload", CacheFixture.ArchiveFileMode)]); + f.Item.CorrelationPayloads[0] = f.Item.CorrelationPayloads[0] with { Destination = "other" }; + Assert.NotEqual(baseline, f.Lookup().Execution.Key); + f.Item.CorrelationPayloads.Add(new Payload { Path = f.Archive, Destination = "second" }); + string before = f.Lookup().Execution.Key; + f.Item.CorrelationPayloads.Reverse(); + Assert.NotEqual(before, f.Lookup().Execution.Key); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void RepackedArchivesReuseFrozenBytesAndResults(bool workItemPayload) + { + using CacheFixture f = new(); + if (workItemPayload) + { + f.Item = f.Item with { Payload = new Payload { Path = f.Archive } }; + } + (string, string, int)[] entries = + [ + ("run.sh", "#!/bin/sh\nexit 0\n", CacheFixture.ArchiveExecutableMode), + ("data.txt", "payload", CacheFixture.ArchiveFileMode), + ("empty/", "", CacheFixture.ArchiveDirectoryMode), + ("link", "data.txt", CacheFixture.ArchiveLinkMode), + ]; + CacheFixture.WriteArchive(f.Archive, entries); + byte[] original = File.ReadAllBytes(f.Archive); + LookupEntry first = f.Lookup(); + string frozen = first.Execution.CorrelationPayloadPaths[0]; + Assert.Equal(original, File.ReadAllBytes(f.Archive)); + f.CreateResults(first.Execution); + f.Store(first.Execution); + + CacheFixture.WriteArchive(f.Archive, entries, repack: true); + Assert.False(original.SequenceEqual(File.ReadAllBytes(f.Archive))); + LookupEntry second = f.Lookup(); + Assert.Equal(first.Execution.Key, second.Execution.Key); + Assert.Equal(LookupStatus.Hit, second.Status); + Assert.Equal(frozen, second.Execution.CorrelationPayloadPaths[0]); + using ZipArchive archive = ZipFile.OpenRead(frozen); + Assert.Equal(["data.txt", "empty/", "link", "run.sh"], archive.Entries.Select(e => e.FullName)); + Assert.All(archive.Entries, e => Assert.Equal(new DateTime(2000, 1, 1), e.LastWriteTime.DateTime)); + foreach ((string name, string content, int mode) in entries) + { + ZipArchiveEntry entry = archive.GetEntry(name)!; + Assert.Equal(mode << 16, entry.ExternalAttributes); + using StreamReader reader = new(entry.Open()); + Assert.Equal(content, reader.ReadToEnd()); + } + } + + [Theory] + [InlineData("content")] + [InlineData("mode")] + [InlineData("emptyDirectory")] + [InlineData("linkTarget")] + public void ArchiveExecutionChangesInvalidateKey(string change) + { + using CacheFixture f = new(); + (string, string, int)[] entries = + [ + ("run.sh", "original", CacheFixture.ArchiveExecutableMode), + ("link", "run.sh", CacheFixture.ArchiveLinkMode), + ]; + CacheFixture.WriteArchive(f.Archive, entries); + string before = f.Lookup().Execution.Key; + entries = change switch + { + "content" => [("run.sh", "changed", CacheFixture.ArchiveExecutableMode), entries[1]], + "mode" => [("run.sh", "original", CacheFixture.ArchiveFileMode), entries[1]], + "emptyDirectory" => [.. entries, ("empty/", "", CacheFixture.ArchiveDirectoryMode)], + "linkTarget" => [entries[0], ("link", "other", CacheFixture.ArchiveLinkMode)], + _ => throw new InvalidOperationException(), + }; + CacheFixture.WriteArchive(f.Archive, entries); + Assert.NotEqual(before, f.Lookup().Execution.Key); + } + + [Theory] + [InlineData("../outside", "other")] + [InlineData("/absolute", "other")] + [InlineData("a//b", "other")] + [InlineData("a\\b", "other")] + [InlineData("same", "same")] + [InlineData("same", "same/")] + [InlineData("parent", "parent/child")] + public void AmbiguousArchivePathsAreRejected(string first, string second) + { + using CacheFixture f = new(); + CacheFixture.WriteArchive(f.Archive, [(first, "", CacheFixture.ArchiveFileMode), (second, "", CacheFixture.ArchiveFileMode)]); + f.Lookup(success: false); + } + + [Fact] + public void InvalidArchivesAreRejected() + { + using CacheFixture f = new(); + File.WriteAllText(f.Archive, "not a ZIP archive"); + f.Lookup(success: false); + } + + [Fact] + public void NativePrepareFingerprintsTheCommandGeneratorPackage() + { + using CacheFixture f = new(); + string package = Path.Combine(f.Root, "sdk.nupkg"); + File.WriteAllText(package, "command generator"); + string first = Prepare("first"); + string relocated = Path.Combine(f.Root, "relocated.nupkg"); + File.Copy(package, relocated); + package = relocated; + Assert.Equal(first, Prepare("relocated")); + File.AppendAllText(package, " changed"); + Assert.NotEqual(first, Prepare("changed")); + + string Prepare(string name) + { + string output = Path.Combine(f.Root, name); + f.Invoke(true, "prepare", "--cache", f.CacheDirectory, "--output", output, "--payload", f.Archive, + "--runtime", f.Archive, "--queue", f.Queues[0].QueueId, "--helix-sdk", package, + "--queue-info", f.Write("queues.json", f.Queues, HelixJsonContext.Default.ListHelixQueue)); + return CacheFixture.ShadowLookup(output).Execution.Key; + } + } + + [Theory] + [InlineData("completed")] + [InlineData("infrastructure")] + [InlineData("exitCode")] + [InlineData("workItem")] + [InlineData("queue")] + [InlineData("job")] + [InlineData("empty")] + [InlineData("traversal")] + [InlineData("duplicate")] + public void UnsuccessfulReceiptsAreNotStored(string field) + { + using CacheFixture f = new(); + ExecutionRecord execution = f.Lookup().Execution; + f.CreateResults(execution); + ResultReceipt receipt = CacheFixture.Read(Path.Combine(f.Root, "results/result.json"), CacheJsonContext.Default.ResultReceipt); + receipt = field switch + { + "completed" => receipt with { Completed = false }, + "infrastructure" => receipt with { InfrastructureSucceeded = false }, + "exitCode" => receipt with { ExitCode = 1 }, + "workItem" => receipt with { WorkItemName = "other" }, + "queue" => receipt with { TargetQueue = "other" }, + "job" => receipt with { JobId = "not-a-guid" }, + "empty" => receipt with { ResultFiles = [] }, + "traversal" => receipt with { ResultFiles = ["../outside.xml"] }, + "duplicate" => receipt with { ResultFiles = ["tests.xml", "tests.xml"] }, + _ => throw new InvalidOperationException(), + }; + f.Write("results/result.json", receipt, CacheJsonContext.Default.ResultReceipt); + f.Store(execution, success: false); + Assert.Equal(LookupStatus.Miss, f.Lookup().Status); + } + + [Theory] + [InlineData("")] + [InlineData("""""")] + [InlineData("""""")] + [InlineData("""""")] + [InlineData("""""")] + [InlineData("""]>&x;""")] + [InlineData("""""")] + [InlineData("""""")] + [InlineData("""""")] + public void ReportsMustBeCompleteNonemptyAndPassing(string report) + { + using CacheFixture f = new(); + ExecutionRecord execution = f.Lookup().Execution; + f.CreateResults(execution); + File.WriteAllText(Path.Combine(f.Root, "results/tests.xml"), report); + f.Store(execution, success: false); + Assert.Equal(LookupStatus.Miss, f.Lookup().Status); + } + + [Fact] + public void CorruptResultsAreAnErrorRatherThanAHitOrMiss() + { + using CacheFixture f = new(); + ExecutionRecord execution = f.Lookup().Execution; + f.CreateResults(execution); + f.Store(execution); + string results = f.Lookup().ResultsPath!; + File.WriteAllText(Path.Combine(results, "tests.xml"), "corrupted"); + f.Lookup(success: false); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ForgedKeysAndModifiedSnapshotsCannotBeStored(bool forgeKey) + { + using CacheFixture f = new(); + ExecutionRecord execution = f.Lookup().Execution; + f.CreateResults(execution); + if (forgeKey) + { + execution = execution with { Key = new string('0', 64) }; + } + else + { + File.WriteAllText(Path.Combine(execution.PayloadPath, "run.sh"), "changed"); + } + f.Store(execution, success: false); + } + + [Fact] + public void ResultsCannotContainTheirStagingDirectory() + { + using CacheFixture f = new(); + ExecutionRecord execution = f.Lookup().Execution; + f.Invoke(false, "store", f.Write("execution.json", execution, CacheJsonContext.Default.ExecutionRecord), + "--results", Path.Combine(f.CacheDirectory, "tmp"), "--cache", f.CacheDirectory); + Assert.Empty(Directory.EnumerateFileSystemEntries(Path.Combine(f.CacheDirectory, "tmp"))); + Assert.Equal(LookupStatus.Miss, f.Lookup().Status); + } + + [Fact] + public async Task DuplicateStoresAreAtomicAndIdempotent() + { + using CacheFixture f = new(); + ExecutionRecord execution = f.Lookup().Execution; + f.CreateResults(execution); + string record = f.Write("execution.json", execution, CacheJsonContext.Default.ExecutionRecord); + Task[] operations = Enumerable.Range(0, 2).Select(_ => Task.Run(() => f.Invoke(true, + "store", record, "--results", Path.Combine(f.Root, "results"), "--cache", f.CacheDirectory))).ToArray(); + await Task.WhenAll(operations); + Assert.Equal(LookupStatus.Hit, f.Lookup().Status); + Assert.Empty(Directory.EnumerateFileSystemEntries(Path.Combine(f.CacheDirectory, "tmp"))); + } + + [Theory] + [InlineData("architecture")] + [InlineData("os")] + [InlineData("availability")] + public void UnsupportedQueuesAreRejected(string field) + { + using CacheFixture f = new(); + f.Queues[0] = field switch + { + "architecture" => f.Queues[0] with { Architecture = "ARM64" }, + "os" => f.Queues[0] with { OperatingSystemGroup = "windows" }, + "availability" => f.Queues[0] with { IsAvailable = false }, + _ => throw new InvalidOperationException(), + }; + f.Lookup(success: false); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public void LinksAndNestedCachesAreRejected(bool link) + { + using CacheFixture f = new(); + if (link) + { + File.CreateSymbolicLink(Path.Combine(f.PayloadDirectory, "link"), f.Archive); + } + else + { + f.CacheDirectory = Path.Combine(f.PayloadDirectory, "cache"); + } + f.Lookup(success: false); + } + + [Theory] + [InlineData("version")] + [InlineData("empty")] + [InlineData("nullItem")] + [InlineData("unknown")] + [InlineData("missing")] + [InlineData("nullCommand")] + [InlineData("timeout")] + [InlineData("nullCorrelation")] + [InlineData("commandGenerator")] + public void InvalidManifestsAreRejected(string mutation) + { + using CacheFixture f = new(); + JsonNode manifest = JsonNode.Parse(JsonSerializer.Serialize(f.Submission, CacheJsonContext.Default.Submission))!; + JsonNode item = manifest["workItems"]![0]!; + switch (mutation) + { + case "version": manifest["schemaVersion"] = 2; break; + case "empty": manifest["workItems"] = new JsonArray(); break; + case "nullItem": manifest["workItems"]![0] = null; break; + case "unknown": manifest["unrecognized"] = true; break; + case "missing": item.AsObject().Remove("expectedExitCode"); break; + case "nullCommand": item["command"] = null; break; + case "timeout": item["timeoutSeconds"] = 0; break; + case "nullCorrelation": item["correlationPayloads"]![0] = null; break; + case "commandGenerator": item["commandGeneratorDigest"] = "invalid"; break; + default: throw new InvalidOperationException(); + } + string path = Path.Combine(f.Root, "invalid.json"); + File.WriteAllText(path, manifest.ToJsonString()); + f.Invoke(false, "lookup", path, "--cache", f.CacheDirectory, "--output", Path.Combine(f.Root, "lookup.json"), + "--queue-info", f.Write("queues.json", f.Queues, HelixJsonContext.Default.ListHelixQueue)); + } + + [Theory] + [InlineData("""{"schemaVersion":1,"schemaVersion":1,"workItems":[]}""")] + [InlineData("""{"schemaVersion":1,"workItems":[],"workItems":[]}""")] + public void DuplicateJsonPropertiesAreRejected(string manifest) + { + using CacheFixture f = new(); + string path = Path.Combine(f.Root, "duplicate.json"); + File.WriteAllText(path, manifest); + f.Invoke(false, "lookup", path, "--cache", f.CacheDirectory, "--output", Path.Combine(f.Root, "lookup.json"), + "--queue-info", f.Write("queues.json", f.Queues, HelixJsonContext.Default.ListHelixQueue)); + } + + [Fact] + public void CliRejectsMissingAndDuplicateOptions() + { + using CacheFixture f = new(); + f.Invoke(false, "store", "missing", "--cache", f.CacheDirectory); + f.Invoke(false, "lookup", "missing", "--cache", f.CacheDirectory, "--cache", f.CacheDirectory); + f.Invoke(false, "collect", "--cache", f.CacheDirectory, "--output", f.Root, "--wait-seconds", "0"); + } + + [Theory] + [InlineData("--help")] + [InlineData("lookup --help")] + [InlineData("store -h")] + [InlineData("prepare --help")] + [InlineData("collect --help")] + public void HelpDoesNotRequireInputsOrCreateCache(string arguments) + { + using CacheFixture f = new(); + Assert.Contains("Usage: helix-cache", f.Invoke(true, arguments.Split(' '))); + Assert.False(Directory.Exists(f.CacheDirectory)); + } + + [Theory] + [InlineData("lookup", "Missing manifest")] + [InlineData("lookup --cache cache --output output", "Missing manifest")] + [InlineData("lookup missing.json --cache cache --output output --unknown value", "Unknown option '--unknown'")] + [InlineData("lookup missing.json --cache cache --cache cache", "Duplicate option '--cache'")] + [InlineData("store missing.json --cache cache", "Missing required option '--results'")] + [InlineData("collect --cache cache --output output --wait-seconds -1", "--wait-seconds")] + [InlineData("collect --cache --output output", "Expected an option and value")] + public void CommandLineErrorsIdentifyTheInvalidArgument(string arguments, string diagnostic) + { + InvalidDataException error = Assert.Throws(() => CommandLine.Parse(arguments.Split(' '))); + Assert.Contains(diagnostic, error.Message); + } + + [Fact] + public void NativePrepareWritesFrozenSubmissionProperties() + { + using CacheFixture f = new(); + string output = Path.Combine(f.Root, "native-shadow"); + f.Invoke(true, "prepare", "--cache", f.CacheDirectory, "--output", output, "--payload", f.Archive, + "--runtime", f.Archive, "--queue", f.Queues[0].QueueId, "--helix-sdk", f.Archive, + "--queue-info", f.Write("queues.json", f.Queues, HelixJsonContext.Default.ListHelixQueue)); + LookupEntry entry = CacheFixture.ShadowLookup(output); + Assert.Equal(LookupStatus.Miss, entry.Status); + Assert.NotEqual(f.Archive, entry.Execution.PayloadPath); + Assert.Equal(ShadowValidation.Command, entry.Execution.Inputs.Command); + Assert.Contains("%24", File.ReadAllText(Path.Combine(output, "submission.props"))); + } +} diff --git a/src/tools/helix-cache/tests/HelixCache.Tests.csproj b/src/tools/helix-cache/tests/HelixCache.Tests.csproj new file mode 100644 index 00000000000000..a521f2c3ab8d57 --- /dev/null +++ b/src/tools/helix-cache/tests/HelixCache.Tests.csproj @@ -0,0 +1,10 @@ + + + $(NetCoreAppToolCurrent) + enable + false + + + + + diff --git a/src/tools/helix-cache/tests/ShadowTests.cs b/src/tools/helix-cache/tests/ShadowTests.cs new file mode 100644 index 00000000000000..2cfe4734d90198 --- /dev/null +++ b/src/tools/helix-cache/tests/ShadowTests.cs @@ -0,0 +1,303 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace HelixCache.Tests; + +public sealed class ShadowTests +{ + [Fact] + public async Task ColdStoreAndRestoredEntriesCompareActualOutcomes() + { + using CacheFixture f = new(); + string first = f.Prepare(); + await Collect(f, first, CacheFixture.FirstJob); + ShadowSummary cold = CacheFixture.Read(Path.Combine(first, "summary.json"), CacheJsonContext.Default.ShadowSummary); + Assert.Equal(LookupStatus.Miss, cold.Lookup); + Assert.Equal(2, cold.Tests); + string restored = Path.Combine(f.Root, "restored-cache"); + Snapshot.Copy(Path.Combine(f.CacheDirectory, "entries"), Path.Combine(restored, "entries")); + Directory.Move(f.CacheDirectory, Path.Combine(f.Root, "previous-cache")); + f.CacheDirectory = restored; + string second = f.Prepare("second"); + Assert.Equal(LookupStatus.Hit, CacheFixture.ShadowLookup(second).Status); + string reordered = """ + + + + """; + await Collect(f, second, CacheFixture.SecondJob, report: reordered); + ShadowSummary summary = CacheFixture.Read(Path.Combine(second, "summary.json"), CacheJsonContext.Default.ShadowSummary); + Assert.Equal(OutcomeComparison.Match, summary.Comparison); + Assert.Equal(CacheFixture.FirstJob, summary.CachedJobId); + Assert.Equal(CacheFixture.SecondJob, summary.JobId); + Assert.Equal(cold.Key, summary.Key); + } + + [Theory] + [InlineData("name")] + [InlineData("skipReason")] + [InlineData("duplicate")] + public async Task MismatchesDoNotOverwriteCachedProvenance(string change) + { + using CacheFixture f = new(); + string first = f.Prepare(); + await Collect(f, first, CacheFixture.FirstJob); + string second = f.Prepare("second"); + string changed = change switch + { + "name" => CacheFixture.Report.Replace("name=\"one\"", "name=\"different-test\"", StringComparison.Ordinal), + "skipReason" => CacheFixture.Report.Replace("", + "different", StringComparison.Ordinal), + "duplicate" => CacheFixture.Report.Replace("total=\"2\" passed=\"1\"", "total=\"3\" passed=\"2\"", StringComparison.Ordinal) + .Replace("", "", StringComparison.Ordinal), + _ => throw new InvalidOperationException(), + }; + InvalidDataException error = await Assert.ThrowsAsync( + () => Collect(f, second, CacheFixture.SecondJob, report: changed)); + Assert.Contains("outcomes differ", error.Message); + ShadowSummary summary = CacheFixture.Read(Path.Combine(second, "summary.json"), CacheJsonContext.Default.ShadowSummary); + Assert.Equal(OutcomeComparison.Mismatch, summary.Comparison); + Assert.False(summary.Stored); + Assert.Equal(CacheFixture.FirstJob, summary.CachedJobId); + string cached = CacheFixture.ShadowLookup(second).ResultsPath!; + Assert.Equal(CacheFixture.FirstJob, + CacheFixture.Read(Path.Combine(cached, "result.json"), CacheJsonContext.Default.ResultReceipt).JobId); + } + + [Theory] + [InlineData("failed")] + [InlineData("failureReason")] + [InlineData("errors")] + [InlineData("exitCode")] + [InlineData("missingExitCode")] + public async Task UnsuccessfulRemoteCompletionsAreNotAdmitted(string failure) + { + using CacheFixture f = new(); + string output = f.Prepare(); + HelixWorkItem item = PassedItem(CacheFixture.FirstJob); + item = failure switch + { + "failed" => item with { State = "Failed" }, + "failureReason" => item with { FailureReason = new HelixFailure() }, + "errors" => item with { Errors = [new HelixFailure()] }, + "exitCode" => item with { ExitCode = 1 }, + "missingExitCode" => item with { ExitCode = null }, + _ => throw new InvalidOperationException(), + }; + InvalidDataException error = await Assert.ThrowsAsync( + () => Collect(f, output, CacheFixture.FirstJob, item: item)); + Assert.Contains("did not complete", error.Message); + Assert.False(Directory.Exists(Path.Combine(output, "results"))); + Assert.Empty(Directory.EnumerateFiles(f.CacheDirectory, "entry.json", SearchOption.AllDirectories)); + } + + [Theory] + [InlineData("linux.test.open.rt", "LINUX.TEST.OPEN", false, true)] + [InlineData("linux.test.open", null, false, true)] + [InlineData("linux.test.open", "", false, true)] + [InlineData("linux.test.open", "other", false, false)] + [InlineData("other", "other", false, false)] + [InlineData("other", null, false, false)] + [InlineData("linux.test.open.rt", "linux.test.open", true, false)] + public async Task QueueAliasesAndJobIdentityAreValidatedAndRetained( + string queueId, string? alias, bool wrongJob, bool accepted) + { + using CacheFixture f = new(); + string output = f.Prepare(); + HelixJob job = new() + { + Name = wrongJob ? CacheFixture.SecondJob : CacheFixture.FirstJob, + QueueId = queueId, + QueueAlias = alias, + }; + if (!accepted) + { + InvalidDataException error = await Assert.ThrowsAsync( + () => Collect(f, output, CacheFixture.FirstJob, job: job)); + Assert.Contains("job or target queue", error.Message); + Assert.False(Directory.Exists(Path.Combine(output, "results"))); + Assert.Empty(Directory.EnumerateFiles(f.CacheDirectory, "entry.json", SearchOption.AllDirectories)); + return; + } + await Collect(f, output, CacheFixture.FirstJob, job: job); + string entry = Directory.GetFiles(f.CacheDirectory, "entry.json", SearchOption.AllDirectories)[0]; + string results = Path.Combine(Path.GetDirectoryName(entry)!, "results"); + ExecutionProvenance provenance = CacheFixture.Read(Path.Combine(results, "provenance.json"), CacheJsonContext.Default.ExecutionProvenance); + Assert.Equal(queueId, provenance.ResolvedQueueId); + Assert.Equal(alias, provenance.QueueAlias); + Assert.Equal("linux.test.open", + CacheFixture.Read(Path.Combine(results, "result.json"), CacheJsonContext.Default.ResultReceipt).TargetQueue); + } + + [Fact] + public void MissingQueueAliasIsSupportedByTypedDeserialization() + { + HelixJob job = JsonSerializer.Deserialize("""{"Name":"job","QueueId":"queue"}""", HelixJsonContext.Default.HelixJob)!; + Assert.Null(job.QueueAlias); + } + + [Theory] + [InlineData(0)] + [InlineData(2)] + public async Task ExactlyOneUploadedReportIsRequired(int count) + { + using CacheFixture f = new(); + string output = f.Prepare(); + List files = []; + for (int i = 0; i < count; i++) + { + files.Add(new HelixFile { Name = "testResults.xml", Link = "https://example.invalid/report" }); + } + InvalidDataException error = await Assert.ThrowsAsync( + () => Collect(f, output, CacheFixture.FirstJob, files: files)); + Assert.Contains("exactly one", error.Message); + Assert.False(Directory.Exists(Path.Combine(output, "results"))); + } + + [Theory] + [InlineData("")] + [InlineData("""""")] + public async Task UploadedXmlMustPassNativeAdmission(string report) + { + using CacheFixture f = new(); + string output = f.Prepare(); + await Assert.ThrowsAsync(() => Collect(f, output, CacheFixture.FirstJob, report: report)); + Assert.Empty(Directory.EnumerateFiles(f.CacheDirectory, "entry.json", SearchOption.AllDirectories)); + } + + [Fact] + public async Task CorruptionBetweenPrepareAndCollectIsNotOverwritten() + { + using CacheFixture f = new(); + string first = f.Prepare(); + await Collect(f, first, CacheFixture.FirstJob); + string second = f.Prepare("second"); + string cachedReport = Path.Combine(CacheFixture.ShadowLookup(second).ResultsPath!, "testResults.xml"); + File.WriteAllText(cachedReport, "corrupt"); + await Assert.ThrowsAsync(() => Collect(f, second, CacheFixture.SecondJob)); + Assert.False(Directory.Exists(Path.Combine(second, "results"))); + Assert.Equal("corrupt", File.ReadAllText(cachedReport)); + } + + [Theory] + [InlineData("Canceled", false, false)] + [InlineData("Passed", true, false)] + [InlineData("Passed", false, true)] + public async Task PollingRejectsUnknownStatesAndWrongIdentity(string state, bool wrongJob, bool wrongName) + { + HelixWorkItem item = PassedItem(CacheFixture.FirstJob) with + { + State = state, + Job = wrongJob ? CacheFixture.SecondJob : CacheFixture.FirstJob, + Name = wrongName ? "other" : ShadowValidation.WorkItem, + }; + using HttpClient client = Client(new HelixJob { Name = CacheFixture.FirstJob, QueueId = "queue" }, item); + await Assert.ThrowsAsync(() => + new HelixClient(client).WaitForCompletionAsync(CacheFixture.FirstJob, ShadowValidation.WorkItem, TimeSpan.FromMinutes(1))); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task PollingHonorsTimeoutAndCallerCancellation(bool cancelCaller) + { + using HttpClient client = new(new Stub(async (_, cancellation) => + { + await Task.Delay(Timeout.InfiniteTimeSpan, cancellation); + throw new InvalidOperationException(); + })); + using CancellationTokenSource cancellation = new(); + if (cancelCaller) + { + cancellation.Cancel(); + await Assert.ThrowsAnyAsync(() => new HelixClient(client).WaitForCompletionAsync( + CacheFixture.FirstJob, ShadowValidation.WorkItem, TimeSpan.FromMinutes(1), cancellation.Token)); + } + else + { + await Assert.ThrowsAsync(() => new HelixClient(client).WaitForCompletionAsync( + CacheFixture.FirstJob, ShadowValidation.WorkItem, TimeSpan.FromMilliseconds(20))); + } + } + + [Fact] + public async Task PollingWaitsForTerminalCompletion() + { + int requests = 0; + using HttpClient client = new(new Stub((_, _) => + { + HelixWorkItem item = PassedItem(CacheFixture.FirstJob) with { State = ++requests == 1 ? "Running" : "Finished" }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = JsonContent.Create(item, HelixJsonContext.Default.HelixWorkItem), + }); + })); + HelixWorkItem completed = await new HelixClient(client).WaitForCompletionAsync( + CacheFixture.FirstJob, ShadowValidation.WorkItem, TimeSpan.FromMinutes(1)); + Assert.Equal("Finished", completed.State); + Assert.Equal(2, requests); + } + + [Fact] + public async Task TransportTimeoutIsNotReportedAsTheJobDeadline() + { + using HttpClient client = new(new Stub((_, _) => throw new TaskCanceledException("HTTP request timed out"))); + TaskCanceledException error = await Assert.ThrowsAsync(() => + new HelixClient(client).WaitForCompletionAsync(CacheFixture.FirstJob, ShadowValidation.WorkItem, TimeSpan.FromMinutes(1))); + Assert.Equal("HTTP request timed out", error.Message); + } + + private static async Task Collect(CacheFixture f, string output, string jobId, + HelixJob? job = null, HelixWorkItem? item = null, List? files = null, string? report = null) + { + File.WriteAllText(Path.Combine(output, "job-id.txt"), jobId + "\n"); + using HttpClient client = Client(job ?? new HelixJob { Name = jobId, QueueId = f.Queues[0].QueueId }, + item ?? PassedItem(jobId), files, report); + await ShadowValidation.CollectAsync(new Cache(f.CacheDirectory), output, TimeSpan.FromMinutes(1), new HelixClient(client)); + } + + private static HelixWorkItem PassedItem(string jobId) => new() + { + Job = jobId, + Name = ShadowValidation.WorkItem, + State = "Passed", + ExitCode = 0, + MachineName = "test-worker", + }; + + private static HttpClient Client(HelixJob job, HelixWorkItem item, List? files = null, string? report = null) => + new(new Stub((request, _) => + { + HttpContent content = request.RequestUri!.AbsolutePath switch + { + string path when path.EndsWith("/details", StringComparison.Ordinal) => + JsonContent.Create(job, HelixJsonContext.Default.HelixJob), + string path when path.EndsWith("/files", StringComparison.Ordinal) => + JsonContent.Create(files ?? [new HelixFile { Name = "testResults.xml", Link = "https://example.invalid/report" }], + HelixJsonContext.Default.ListHelixFile), + "/report" => new StringContent(report ?? CacheFixture.Report, Encoding.UTF8, "application/xml"), + string path when path.Contains("/workitems/", StringComparison.Ordinal) => + JsonContent.Create(item, HelixJsonContext.Default.HelixWorkItem), + _ => throw new InvalidOperationException($"Unexpected HTTP request: {request.RequestUri}"), + }; + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = content }); + })); + + private sealed class Stub(Func> send) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + send(request, cancellationToken); + } +}