ci: report native progress and require complete unique test evidence

This commit is contained in:
dh committed 2026-10-03 15:54:58 +02:00
1 parent 01596e1f52
commit 90d86af887
3 files changed
+66 -7

No files matched your search

+2 -2
View File
@@ -43,7 +43,7 @@ dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifa
The host requires a clean exact Git HEAD, creates its Git/PAX source archive and SHA-256, and downloads macOS/x64 SDK `10.0.401` from Microsoft's release URL with the fixed official SHA-512 recorded in both helpers. Source archive, SDK and helper files are copied inside the image into the newly owned anonymous `/storage` volume; no workstation bind mount is introduced. The full flow uses persistent `/storage/14/ci-state` as its existing 9p share, with a run-owner marker and an erase guard that is never removed on installer failure. It never automatically restarts a container or retries erasure. The host requires a clean exact Git HEAD, creates its Git/PAX source archive and SHA-256, and downloads macOS/x64 SDK `10.0.401` from Microsoft's release URL with the fixed official SHA-512 recorded in both helpers. Source archive, SDK and helper files are copied inside the image into the newly owned anonymous `/storage` volume; no workstation bind mount is introduced. The full flow uses persistent `/storage/14/ci-state` as its existing 9p share, with a run-owner marker and an erase guard that is never removed on installer failure. It never automatically restarts a container or retries erasure.
Before the only guest `eraseDisk`, C# revalidates the owned Docker boundary, sole anonymous storage mount, exact 64-GiB raw image, live QEMU attachment and per-run emulated disk serial. Only after a valid native Recovery receipt does it atomically provide the run/commit/disk permit. The guarded Apple installer rechecks `diskutil` and the corresponding IORegistry serial; missing or ambiguous identity fails. Upstream `startosinstall`, USR1 bootstrap staging, Setup Assistant/admin packages and byte-for-byte staging checks remain in use. Installer reboots preserve the same QEMU process, disk, NVRAM and share. The readonly Recovery media stays attached. Before the only guest `eraseDisk`, C# revalidates the owned Docker boundary, sole anonymous storage mount, exact 64-GiB raw image, live QEMU attachment and per-run emulated disk serial. Only after a valid native Recovery receipt does it atomically provide the run/commit/disk permit. The guarded Apple installer rechecks `diskutil` and the corresponding IORegistry serial; missing or ambiguous identity fails. With mounted run-owned state, `fail()`, nonzero `startosinstall` and TERM/INT atomically publish a token-bound `installation-failed` phase for the next host poll, preserving the erase guard. Upstream `startosinstall`, USR1 bootstrap staging, Setup Assistant/admin packages and byte-for-byte staging checks remain in use. Installer reboots preserve the same QEMU process, disk, NVRAM and share. The readonly Recovery media stays attached.
The existing firstboot LaunchDaemon invokes `tools/ci/macos-native-firstboot.sh` before its staging cleanup. This Bash seam is required because the guest has Apple boot tools but no .NET SDK yet. It proves installed APFS `/` belongs to the same owned 64-GiB physical disk, mounts the state share, installs a compatible Apple CLT catalog label through headless `softwareupdate`, verifies the CLT package/compiler and builds a framework smoke program. There is no GUI fallback, Apple account or new secret. `macos-native-disk-guard.sh` holds the shared pre-.NET Apple disk/IORegistry check. The existing upstream Python UDIF patcher remains the image-format runtime binding; both its exact patch matches and compressed-slot checks remain enforced. The existing firstboot LaunchDaemon invokes `tools/ci/macos-native-firstboot.sh` before its staging cleanup. This Bash seam is required because the guest has Apple boot tools but no .NET SDK yet. It proves installed APFS `/` belongs to the same owned 64-GiB physical disk, mounts the state share, installs a compatible Apple CLT catalog label through headless `softwareupdate`, verifies the CLT package/compiler and builds a framework smoke program. There is no GUI fallback, Apple account or new secret. `macos-native-disk-guard.sh` holds the shared pre-.NET Apple disk/IORegistry check. The existing upstream Python UDIF patcher remains the image-format runtime binding; both its exact patch matches and compressed-slot checks remain enforced.
@@ -51,6 +51,6 @@ After verifying and extracting the SDK on the guest's own APFS work directory, `
The full helper's outer deadline is 172 minutes; the job declares 180 minutes within the existing three-hour server limit, leaving time for evidence and owned-resource cleanup. Independent budgets are Recovery 40 minutes, installer 80, firstboot/CLT 30 and guest checks/restore/build/tests 25; the outer deadline also bounds their combined runtime and preparation. The same 4-GiB/two-CPU guest and 6-GiB container remain, with explicit sparse allocation. Full execution checks 32 GiB of existing Docker free space before Recovery downloads/boot and 8 GiB of guest free space before toolchain work. Insufficient resources, networking, Apple catalog availability, disk ownership, installer progress or test proof fail clearly without changing infrastructure. The full helper's outer deadline is 172 minutes; the job declares 180 minutes within the existing three-hour server limit, leaving time for evidence and owned-resource cleanup. Independent budgets are Recovery 40 minutes, installer 80, firstboot/CLT 30 and guest checks/restore/build/tests 25; the outer deadline also bounds their combined runtime and preparation. The same 4-GiB/two-CPU guest and 6-GiB container remain, with explicit sparse allocation. Full execution checks 32 GiB of existing Docker free space before Recovery downloads/boot and 8 GiB of guest free space before toolchain work. Insufficient resources, networking, Apple catalog availability, disk ownership, installer progress or test proof fail clearly without changing infrastructure.
Artifacts add source/SDK hashes, generated pinned boot-source patches, installer/Apple/firstboot logs, installed-root/disk identity, native tool logs, guest phase and full-result receipts, native helper hashes and binary-preserved TRX. Bounded final build/test output and the full-result receipt are also printed in CI. Cleanup uses the same exact saved resource ID/ownership label through `finally` and workflow `always()`; it removes only this run's container/image/anonymous volume. Installed guest files disappear with that volume and retained CI evidence stays outside it. Shared Docker build cache is not pruned. Artifacts add source/SDK hashes, generated pinned boot-source patches, installer/Apple/firstboot logs, installed-root/disk identity, native tool logs, guest phase and full-result receipts, native helper hashes and binary-preserved TRX. The polling loop prints a bounded heartbeat after each elapsed minute with the current phase, its elapsed/budget time, container liveness and readiness state, without dumping environment variables or download URLs. Bounded final build/test output and the full-result receipt are also printed in CI. Cleanup uses the same exact saved resource ID/ownership label through `finally` and workflow `always()`; it removes only this run's container/image/anonymous volume. Installed guest files disappear with that volume and retained CI evidence stays outside it. Shared Docker build cache is not pruned.
Local `--validate` creates only patch/fixture evidence; it never starts Docker, installs an OS/toolchain, erases a disk, builds the application or runs native tests. With `--compression-chunk` it can read the retained qualified Recovery raw chunk and validate the unchanged LaunchDaemon/mount patch against Python zlib's real compressed slot, without changing the DMG. The IORegistry parser's root association and installed-APFS mapping still require the actual emulated guest's output; synthetic fixtures do not qualify that disk identity. This is a candidate until an actual remote installed guest produces every required native receipt. Local `--validate` creates only patch/fixture evidence; it never starts Docker, installs an OS/toolchain, erases a disk, builds the application or runs native tests. With `--compression-chunk` it can read the retained qualified Recovery raw chunk and validate the unchanged LaunchDaemon/mount patch against Python zlib's real compressed slot, without changing the DMG. The IORegistry parser's root association and installed-APFS mapping still require the actual emulated guest's output; synthetic fixtures do not qualify that disk identity. This is a candidate until an actual remote installed guest produces every required native receipt.
+45 -2
View File
@@ -134,6 +134,7 @@ static class NativeDiagnostic
var phaseStarted = Stopwatch.StartNew(); var phaseStarted = Stopwatch.StartNew();
var phase = "recovery"; var phase = "recovery";
var phaseBudget = TimeSpan.FromMinutes(40); var phaseBudget = TimeSpan.FromMinutes(40);
var heartbeat = Stopwatch.StartNew();
var permitted = false; var permitted = false;
while (true) while (true)
{ {
@@ -185,6 +186,11 @@ static class NativeDiagnostic
} }
var running = await Command("docker", ["inspect", "--format", "{{.State.Running}}", id], output, "container-running", deadline.Token); var running = await Command("docker", ["inspect", "--format", "{{.State.Running}}", id], output, "container-running", deadline.Token);
if (running.Output.Trim() != "true") throw new InvalidOperationException("Guest container exited before a native readiness result."); if (running.Output.Trim() != "true") throw new InvalidOperationException("Guest container exited before a native readiness result.");
if (heartbeat.Elapsed >= TimeSpan.FromSeconds(60))
{
Console.WriteLine($"[native-diagnostic] phase={phase}; elapsed={phaseStarted.Elapsed.TotalMinutes:F1}/{phaseBudget.TotalMinutes:F0} minutes; container=running; readiness={(permitted ? "passed" : "pending")}");
heartbeat.Restart();
}
await Task.Delay(TimeSpan.FromSeconds(20), deadline.Token); await Task.Delay(TimeSpan.FromSeconds(20), deadline.Token);
} }
} }
@@ -346,9 +352,18 @@ static class NativeDiagnostic
installer = ReplaceOnce(installer, installer[selectorStart..selectorEnd], selector); installer = ReplaceOnce(installer, installer[selectorStart..selectorEnd], selector);
installer = ReplaceOnce(installer, "MIN_TARGET_SIZE=$((16 * 1024 * 1024 * 1024))", "# Target policy is exclusively the own writable 64-GiB emulated disk."); installer = ReplaceOnce(installer, "MIN_TARGET_SIZE=$((16 * 1024 * 1024 * 1024))", "# Target policy is exclusively the own writable 64-GiB emulated disk.");
installer = ReplaceOnce(installer, "no writable installation disk of at least 16 GiB was found", "the run-owned writable 64-GiB installation disk was not proved"); installer = ReplaceOnce(installer, "no writable installation disk of at least 16 GiB was found", "the run-owned writable 64-GiB installation disk was not proved");
installer = ReplaceOnce(installer, " local message=\"$1\"\n\n echo \"[log] ERROR: $message\"", " local message=\"$1\"\n\n mark_installation_failed || :\n echo \"[log] ERROR: $message\"");
installer = ReplaceOnce(installer, "if (( rc != 0 )); then\n", "if (( rc != 0 )); then\n mark_installation_failed || :\n");
installer = ReplaceAllExact(installer, "rm -f \"$STARTED\"", ": # Keep the owned erase guard on failure; never erase again.", 2); installer = ReplaceAllExact(installer, "rm -f \"$STARTED\"", ": # Keep the owned erase guard on failure; never erase again.", 2);
installer = ReplaceOnce(installer, ": > \"$STARTED\" || fail \"failed to create installation guard\"", "( set -o noclobber; printf '%s:%s:%s\\n' \"$PROOF_TOKEN\" \"$(cat \"$STATE_DIR/source.commit\")\" \"$TARGET_DISK\" > \"$STARTED\" ) || fail \"failed to create the exclusive owned installation guard\""); installer = ReplaceOnce(installer, ": > \"$STARTED\" || fail \"failed to create installation guard\"", "( set -o noclobber; printf '%s:%s:%s\\n' \"$PROOF_TOKEN\" \"$(cat \"$STATE_DIR/source.commit\")\" \"$TARGET_DISK\" > \"$STARTED\" ) || fail \"failed to create the exclusive owned installation guard\"");
installer = ReplaceOnce(installer, "set -u\n", "set -u\nPROOF_TOKEN=\"" + token + "\"\n" + """ installer = ReplaceOnce(installer, "set -u\n", "set -u\nPROOF_TOKEN=\"" + token + "\"\n" + """
mark_installation_failed() {
local state="${STATE_DIR:-/Volumes/installstate}" temporary
[ "$(cat "$state/run.owner" 2>/dev/null)" = "$PROOF_TOKEN" ] || return 1
temporary="$state/guest-phase.install.$$.tmp"
printf '{"token":"%s","phase":"installation-failed"}\n' "$PROOF_TOKEN" > "$temporary" &&
/bin/mv -f "$temporary" "$state/guest-phase.json"
}
installer_parent=$$ installer_parent=$$
# Installer watchdog: 80 minutes, also bounded by the host's 172-minute total. # Installer watchdog: 80 minutes, also bounded by the host's 172-minute total.
( (
@@ -356,7 +371,7 @@ static class NativeDiagnostic
sleep 4800 & sleeper=$!; wait "$sleeper"; kill -TERM "$installer_parent" 2>/dev/null || : sleep 4800 & sleeper=$!; wait "$sleeper"; kill -TERM "$installer_parent" 2>/dev/null || :
) & install_watchdog=$! ) & install_watchdog=$!
trap 'kill -TERM "$install_watchdog" 2>/dev/null || :; wait "$install_watchdog" 2>/dev/null || :' EXIT trap 'kill -TERM "$install_watchdog" 2>/dev/null || :; wait "$install_watchdog" 2>/dev/null || :' EXIT
trap 'kill "${STARTOSINSTALL_PID:-}" "${BOOTSTRAPPER_PID:-}" 2>/dev/null || :; printf "{\"token\":\"%s\",\"phase\":\"installation-failed\"}\n" "$PROOF_TOKEN" > /Volumes/installstate/guest-phase.json; exit 1' TERM INT trap 'kill "${STARTOSINSTALL_PID:-}" "${BOOTSTRAPPER_PID:-}" 2>/dev/null || :; mark_installation_failed || :; exit 1' TERM INT
""" + "\n"); """ + "\n");
var firstboot = ReadPinned(source, "src/install/firstboot/launch.sh", "d6b29bb42ffe99edda6b3be3faf6009c4e0b34e5b8bba6eb0855cf24a0c24307"); var firstboot = ReadPinned(source, "src/install/firstboot/launch.sh", "d6b29bb42ffe99edda6b3be3faf6009c4e0b34e5b8bba6eb0855cf24a0c24307");
firstboot = ReplaceOnce(firstboot, "log \"prebuilt account package installed successfully\"\n", "log \"prebuilt account package installed successfully\"\n" + """ firstboot = ReplaceOnce(firstboot, "log \"prebuilt account package installed successfully\"\n", "log \"prebuilt account package installed successfully\"\n" + """
@@ -392,12 +407,40 @@ static class NativeDiagnostic
foreach (var name in new[] { "macos-native-firstboot.sh", "macos-native-disk-guard.sh" }) foreach (var name in new[] { "macos-native-firstboot.sh", "macos-native-disk-guard.sh" })
await Command("bash", ["-n", Path.Combine("tools", "ci", name)], output, name + "-syntax", cancellation); await Command("bash", ["-n", Path.Combine("tools", "ci", name)], output, name + "-syntax", cancellation);
Save(Path.Combine(output, "full-source-hashes.json"), new Dictionary<string, string> { ["full-install.sh"] = Hash(Encoding.UTF8.GetBytes(installer)), ["full-firstboot.sh"] = Hash(Encoding.UTF8.GetBytes(firstboot)), ["full-state-source.sh"] = Hash(Encoding.UTF8.GetBytes(initialize)), ["MacOsNativeGuest.cs"] = Hash(File.ReadAllBytes("tools/ci/MacOsNativeGuest.cs")), ["macos-native-firstboot.sh"] = Hash(File.ReadAllBytes("tools/ci/macos-native-firstboot.sh")), ["macos-native-disk-guard.sh"] = Hash(File.ReadAllBytes("tools/ci/macos-native-disk-guard.sh")) }); Save(Path.Combine(output, "full-source-hashes.json"), new Dictionary<string, string> { ["full-install.sh"] = Hash(Encoding.UTF8.GetBytes(installer)), ["full-firstboot.sh"] = Hash(Encoding.UTF8.GetBytes(firstboot)), ["full-state-source.sh"] = Hash(Encoding.UTF8.GetBytes(initialize)), ["MacOsNativeGuest.cs"] = Hash(File.ReadAllBytes("tools/ci/MacOsNativeGuest.cs")), ["macos-native-firstboot.sh"] = Hash(File.ReadAllBytes("tools/ci/macos-native-firstboot.sh")), ["macos-native-disk-guard.sh"] = Hash(File.ReadAllBytes("tools/ci/macos-native-disk-guard.sh")) });
if (!writeSource) return; if (!writeSource)
{
await ValidateInstallerFailureReceipt(installer, output, token, cancellation);
return;
}
File.WriteAllText(Path.Combine(source, "src/install/recovery/full-install.sh"), installer, new UTF8Encoding(false)); File.WriteAllText(Path.Combine(source, "src/install/recovery/full-install.sh"), installer, new UTF8Encoding(false));
File.WriteAllText(Path.Combine(source, "src/install/firstboot/launch.sh"), firstboot, new UTF8Encoding(false)); File.WriteAllText(Path.Combine(source, "src/install/firstboot/launch.sh"), firstboot, new UTF8Encoding(false));
File.WriteAllText(Path.Combine(source, "src/install.sh"), initialize, new UTF8Encoding(false)); File.WriteAllText(Path.Combine(source, "src/install.sh"), initialize, new UTF8Encoding(false));
} }
static async Task ValidateInstallerFailureReceipt(string installer, string output, string token, CancellationToken cancellation)
{
const string start = "mark_installation_failed() {";
const string end = "\n}\ninstaller_parent=$$";
var begin = installer.IndexOf(start, StringComparison.Ordinal);
var finish = begin < 0 ? -1 : installer.IndexOf(end, begin, StringComparison.Ordinal);
if (begin < 0 || finish < begin || installer.Split("mark_installation_failed || :", StringSplitOptions.None).Length != 4)
throw new InvalidOperationException("Pinned installer must publish its terminal failure phase from fail(), nonzero startosinstall and TERM/INT.");
var function = installer[begin..(finish + 2)];
var state = Path.Combine(output, "installer-failure-fixture");
Directory.CreateDirectory(state);
File.WriteAllText(Path.Combine(state, "run.owner"), token);
var command = "set -u\n" + function + "\nPROOF_TOKEN=\"$1\"; STATE_DIR=\"$2\"; mark_installation_failed";
await Command("bash", ["-c", command, "installer-failure-validation", token, state], output, "installer-terminal-failure", cancellation);
var receipt = File.ReadAllText(Path.Combine(state, "guest-phase.json"));
using var json = JsonDocument.Parse(receipt);
if (json.RootElement.GetProperty("token").GetString() != token || json.RootElement.GetProperty("phase").GetString() != "installation-failed" || Directory.GetFiles(state, "*.tmp").Length != 0)
throw new InvalidOperationException("Installer failed to publish a complete atomic terminal phase.");
File.WriteAllText(Path.Combine(state, "run.owner"), "foreign");
var foreign = await Command("bash", ["-c", command, "installer-failure-validation", token, state], output, "installer-foreign-failure", cancellation, requireSuccess: false);
if (foreign.ExitCode == 0 || File.ReadAllText(Path.Combine(state, "guest-phase.json")) != receipt)
throw new InvalidOperationException("Installer terminal phase overwrote foreign run-owned state.");
}
static async Task PermitInstallation(string id, string output, string token, string commit, string readiness, CancellationToken cancellation) static async Task PermitInstallation(string id, string output, string token, string commit, string readiness, CancellationToken cancellation)
{ {
await Command("docker", ["inspect", id], output, "full-container-boundary", cancellation); await Command("docker", ["inspect", id], output, "full-container-boundary", cancellation);
+19 -3
View File
@@ -277,17 +277,18 @@ static class NativeGuest
} }
var results = root.Element(ns + "Results")?.Elements(ns + "UnitTestResult").ToArray() ?? []; var results = root.Element(ns + "Results")?.Elements(ns + "UnitTestResult").ToArray() ?? [];
var executionIds = new HashSet<string>(StringComparer.Ordinal); var executionIds = new HashSet<string>(StringComparer.Ordinal);
var testIds = new HashSet<string>(StringComparer.Ordinal);
var native = new List<string>(); var native = new List<string>();
foreach (var result in results) foreach (var result in results)
{ {
var id = (string?)result.Attribute("testId") ?? ""; var id = (string?)result.Attribute("testId") ?? "";
var executionId = (string?)result.Attribute("executionId") ?? ""; var executionId = (string?)result.Attribute("executionId") ?? "";
if ((string?)result.Attribute("outcome") != "Passed" || executionId.Length == 0 || !executionIds.Add(executionId) || !definitions.TryGetValue(id, out var identity)) if ((string?)result.Attribute("outcome") != "Passed" || executionId.Length == 0 || !executionIds.Add(executionId) || !testIds.Add(id) || !definitions.TryGetValue(id, out var identity))
throw new InvalidOperationException("TRX has a missing, duplicate or non-passed execution."); throw new InvalidOperationException("TRX has a missing, duplicate or non-passed execution.");
if (RequiredNativeTests.Contains(identity, StringComparer.Ordinal)) native.Add(identity); if (RequiredNativeTests.Contains(identity, StringComparer.Ordinal)) native.Add(identity);
} }
if (results.Length != expected || native.Count != RequiredNativeTests.Length || !native.Order().SequenceEqual(RequiredNativeTests.Order())) if (definitions.Count != expected || results.Length != expected || native.Count != RequiredNativeTests.Length || !native.Order().SequenceEqual(RequiredNativeTests.Order()))
throw new InvalidOperationException("TRX does not prove every expected execution and the five explicit native macOS tests."); throw new InvalidOperationException("TRX does not prove every expected test definition exactly once and the five explicit native macOS tests.");
return new(total, executed, passed, failed, notExecuted, native.ToArray()); return new(total, executed, passed, failed, notExecuted, native.ToArray());
} }
@@ -461,6 +462,21 @@ static class NativeGuest
var duplicate = new XDocument(fixture); var duplicate = new XDocument(fixture);
duplicate.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("executionId", "execution-0"); duplicate.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("executionId", "execution-0");
Reject(() => ValidateTrx(duplicate.ToString(), ExpectedTests, started)); Reject(() => ValidateTrx(duplicate.ToString(), ExpectedTests, started));
var repeatedManaged = new XDocument(fixture);
repeatedManaged.Descendants(ns + "UnitTestResult").Last().SetAttributeValue("testId", "test-5");
Reject(() => ValidateTrx(repeatedManaged.ToString(), ExpectedTests, started));
var unexecutedDefinition = new XDocument(fixture);
var extraDefinition = new XElement(unexecutedDefinition.Descendants(ns + "UnitTest").Last());
extraDefinition.SetAttributeValue("id", "unexecuted-test");
unexecutedDefinition.Root!.Element(ns + "TestDefinitions")!.Add(extraDefinition);
Reject(() => ValidateTrx(unexecutedDefinition.ToString(), ExpectedTests, started));
var missingDefinition = new XDocument(fixture);
missingDefinition.Descendants(ns + "UnitTest").Last().Remove();
Reject(() => ValidateTrx(missingDefinition.ToString(), ExpectedTests, started));
var theoryRows = new XDocument(fixture);
foreach (var method in theoryRows.Descendants(ns + "TestMethod").Skip(RequiredNativeTests.Length).Take(2))
method.SetAttributeValue("name", "TheoryWithDistinctRowIds");
ValidateTrx(theoryRows.ToString(), ExpectedTests, started);
var counters = new XDocument(fixture); var counters = new XDocument(fixture);
counters.Descendants(ns + "Counters").Single().SetAttributeValue("passed", "572"); counters.Descendants(ns + "Counters").Single().SetAttributeValue("passed", "572");
Reject(() => ValidateTrx(counters.ToString(), ExpectedTests, started)); Reject(() => ValidateTrx(counters.ToString(), ExpectedTests, started));