forked from Manuel/meeting-assistant
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4606de0696 |
@@ -1,36 +0,0 @@
|
||||
name: Existing Docker-host KVM diagnostic
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
existing-kvm-diagnostic:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
env:
|
||||
DOTNET_SKIP_FIRST_TIME_EXPERIENCE: "1"
|
||||
DOTNET_NOLOGO: "1"
|
||||
steps:
|
||||
- name: Checkout diagnostic source
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Setup .NET for the diagnostic helper
|
||||
uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: "10.0.x"
|
||||
|
||||
- name: Test only the existing Docker-host KVM device
|
||||
run: dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --run --output artifacts/existing-kvm
|
||||
|
||||
- name: Always retry cleanup of this diagnostic's owned container
|
||||
if: always()
|
||||
run: dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --cleanup --output artifacts/existing-kvm
|
||||
|
||||
- name: Preserve KVM diagnostic evidence
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: existing-docker-host-kvm-diagnostic
|
||||
path: artifacts/existing-kvm/
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -1,28 +0,0 @@
|
||||
# Existing Docker-host KVM diagnostic
|
||||
|
||||
This manual-only diagnostic checks whether the existing Ubuntu runner's Docker daemon can expose its already-existing `/dev/kvm` and successfully initialize QEMU's KVM accelerator. It does not install or load host modules, change the host, or request infrastructure. A prior container configured with `KVM=N` and no device mappings cannot answer this question.
|
||||
|
||||
The entry point is the .NET 10 file-based app `tools/ci/ExistingKvmDiagnostic.cs`. From the repository root:
|
||||
|
||||
```sh
|
||||
dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --help
|
||||
dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --run --output artifacts/existing-kvm
|
||||
dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --cleanup --output artifacts/existing-kvm
|
||||
dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --validate-evidence /path/to/downloaded-kvm-artifact
|
||||
```
|
||||
|
||||
`--run` requires an empty output directory, the existing Docker CLI/daemon and Git. It records the source commit/helper SHA-256, Docker context/server identity, exact image metadata, commands, raw stdout/stderr, exit/state evidence, result and cleanup receipts. The workflow `.gitea/workflows/macos-kvm-diagnostic.yaml` is dispatched manually and always uploads these files. `--help` invokes no Docker command. `--validate-evidence` reads `qemu-monitor.stdout.log`, `qemu-monitor.result.json` and `container-exited.stdout.log` through the same success parser as `--run`; it invokes no Docker command and writes no files. This mode checks only saved HMP protocol and clean exit interpretation, without requalifying source, ownership or the container boundary.
|
||||
|
||||
The image is pinned to `qemux/qemu:7.50@sha256:e7f6fda52503a546fd649670ba46e4bc23dc6dcef275bc3fac48877fbbc430df`; if absent it may be pulled into the existing daemon's cache. One random-name/label container invokes `/usr/bin/qemu-system-x86_64` directly with KVM only, `-cpu host`, `-S`, no default devices, no display and HMP on stdin. It attaches no OS, disk or persistent volume and never continues the paused CPU. A read-only 4-KiB tmpfs at `/storage` (`ro,nosuid,nodev,noexec,size=4096,mode=0555`) replaces the image's inherited `VOLUME /storage`. Inspect must prove `Mounts=[]` and precisely that sole tmpfs entry; otherwise a specific mount/tmpfs error is retained before QEMU starts. The only host device mapping is `/dev/kvm:/dev/kvm:rw`. The filesystem is read-only, network is `none`, all Linux capabilities are dropped, and no-new-privileges is set. Limits are 0.5 CPU, 256 MiB container RAM/no additional swap, 32 PIDs, and 64 MiB paused guest RAM. There are no binds, ports, privileged mode, added capabilities or host networking.
|
||||
|
||||
The helper has a 95-second operation budget and a separate 20-second cleanup budget, plus at most two seconds to drain killed command output. The workflow permits five minutes including SDK setup, compilation and upload. Cleanup checks the saved random name/label and exact full container ID before stopping or removing that container. An interrupted create can recover its ID only from the saved random name with the exact ownership label. `docker rm --volumes` also removes any anonymous volume attached to that exact owned container if creation did not match the expected tmpfs boundary. Cleanup does not remove images, prune resources, or touch another container.
|
||||
|
||||
Actual run 4165 failed the original zero-mount guard because this pinned image declared `/storage` as a volume and Docker created an anonymous writable volume. QEMU never started: the saved container state was `created`, PID zero and `StartedAt` zero. Its exact container ID was `1753f95ef334244e7a1b393a839f218ea885363de7d5335eec53132d64627010`, owner token `43b7f4676c514f2a95c63c02577ac36e`, and anonymous volume `ef7daa62ef89a2ffb8aae50a9b7803f1d9b3075ee509aa3183f3e170f69ce595`. The original cleanup proved container removal; it did not prove volume removal.
|
||||
|
||||
Run 4167's temporary, frozen-target cleanup verified the original daemon ID `528941c8-73ac-49ff-8eb7-69113eb4a2a1`, absence of the old container ID/name, the exact local volume and absence of container references. Removal without force exited zero; a subsequent inspect returned `no such volume`, and the receipt recorded `outcome=removed` at `2026-10-03T18:03:06.5767095Z`. The one-time cleanup mode and workflow step have therefore been removed. No generalized orphan cleanup is provided. The original source evidence is run 4165 artifact ZIP SHA-256 `6745d90e8b81c867740405c99b4364cc165c47ebb165455052314459d5cd547b` and created-container inspect SHA-256 `7afdfc6c30c933bee2ef1d6c18ed011c8b2f709d1a5e88531928f9f40471c055`.
|
||||
|
||||
`kvm_usable` requires QEMU to report the complete line `kvm support: enabled` and exactly `VM status: paused` or `VM status: paused (prelaunch)`, followed by clean monitor/container exit after `quit`. The monitor command must exit zero without timeout or error, and the container must be stopped with exit zero and no OOM. A device path alone is insufficient. Other receipts distinguish a Docker-reported missing daemon-host device, observed access denial, an unavailable QEMU KVM backend, an initialization error, and inconclusive evidence. These categories describe the observed output; they do not diagnose BIOS, nested virtualization, policy or hardware causes. All failures remain failed workflow runs with retained raw evidence. Even a usable result proves only this blank paused KVM initialization, not macOS boot, installation, native build or tests.
|
||||
|
||||
Actual run 4167 successfully initialized KVM and reported `VM status: paused (prelaunch)` before clean exit. Its original workflow still failed because the parser accepted only `paused`. The read-only CLI evidence mode reproduced that behavioral failure against the actual artifact (exit one, `usable=false`); after the narrow state-parser correction, the identical artifact passed (exit zero, `usable=true`). This reinterprets retained evidence and does not claim that the original workflow result changed or that another VM was run.
|
||||
|
||||
The CLI and monitor behavior follow the primary [QEMU command-line reference](https://www.qemu.org/docs/master/system/qemu-manpage.html) and [QEMU monitor reference](https://www.qemu.org/docs/master/system/monitor.html). Device/container options follow the [Docker create reference](https://docs.docker.com/reference/cli/docker/container/create/) and [Docker tmpfs reference](https://docs.docker.com/engine/storage/tmpfs/). Moby 28.3.3's [volume creation](https://raw.githubusercontent.com/moby/moby/v28.3.3/daemon/create_unix.go) and [mount detection](https://raw.githubusercontent.com/moby/moby/v28.3.3/container/container_unix.go) explicitly skip an inherited anonymous volume when that destination already has the tmpfs entry.
|
||||
@@ -18,7 +18,9 @@ dotnet run --file tools/ci/MacOsNativeDiagnostic.cs -- --cleanup --output artifa
|
||||
|
||||
Dependencies are the existing Linux/x64 runner, .NET 10 SDK, Git, Bash and Docker CLI/socket. The actual execution downloads public Dockur source, upstream build assets, Docker images and Apple Recovery; it does not use workstation credentials. The existing upstream Python UDIF patcher and the Bash hook are retained because they run inside the pinned Linux/macOS boot integration. Independent orchestration and validation remain C#.
|
||||
|
||||
The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, verifies its exact Recovery patcher hash, and makes three narrowly verified source edits. The early `rc.cdrom.sh` hook only mounts the existing state share and returns. A same-length XML replacement makes the existing `com.apple.recoveryosd` LaunchDaemon execute `/bin/bash /Volumes/installstate/launch.sh` after boot tasks. The staged `launch.sh` is replaced entirely by the checked-in read-only readiness probe. All replacement counts are exact; an upstream mismatch fails. The two imported QEMU image digests are pinned and the final image/source/Recovery hashes are retained. Other upstream Dockerfile downloads are observed through the resulting image identity rather than asserted to be immutable.
|
||||
The helper clones Dockur commit `16a5b470cdd601bae8b05b02d748d7edfb36c12e`, verifies its exact Recovery patcher and staging-script hashes, and makes narrowly verified source edits. The early `rc.cdrom.sh` hook only mounts the existing state share and returns. A same-length XML replacement makes the existing `com.apple.recoveryosd` LaunchDaemon execute `/bin/bash /Volumes/installstate/launch.sh` after boot tasks. In this separate bootstrap A/B candidate, that file is the small `tools/ci/macos-native-bootstrap.sh` wrapper: it starts `/bin/bash /Volumes/installstate/readiness.sh` in the background, then `exec /usr/libexec/recoveryosd`. The original Apple executable therefore replaces the wrapper under the same launchd job/PID if exec succeeds. The staging copy and comparison transport the second script through the existing 9p share. All replacement counts are exact; an upstream mismatch fails. The two imported QEMU image digests are pinned and the final image/source/Recovery hashes are retained. Other upstream Dockerfile downloads are observed through the resulting image identity rather than asserted to be immutable.
|
||||
|
||||
The experiment starts from commit `40281b57a5e80bbe699ae50d8927e629d09d05ad`. The readiness script is byte-identical to that baseline; validation enforces SHA-256 `4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3`. This changes only whether Apple's original Recovery daemon runs alongside the same probe. The probe now has that daemon as its parent and competes with its work for guest CPU/I/O. If the job restarts, the wrapper could start another read-only probe. Those lifecycle and scheduling effects are part of the experiment, not proof of a CoreFoundation or DiskArbitration dependency. No original Apple daemon implementation or such dependency is established by the retained plist.
|
||||
|
||||
The VM uses TCG (`KVM=N`), slirp networking, a 4-GiB guest, two virtual CPUs and a sparse 64-GiB data disk. Its container has a 6-GiB memory/swap ceiling and a two-CPU limit. The existing Docker daemon must report at least two CPUs and 6 GiB total memory, the runner must have at least 5 GiB available memory, and the Docker filesystem must have at least 8 GiB free before Recovery downloads or boot. Native commands have 45-second watchdogs, except the single UID gate's targeted 180-second timing experiment. The ten-minute disk-readiness phase, 40-minute host deadline and 45-minute workflow limit remain unchanged.
|
||||
|
||||
@@ -28,11 +30,11 @@ The next probe runs mandatory architecture, root identity and platform gates bef
|
||||
|
||||
After an initial platform failure the hook collects native launchd context and repeats the identical `sw_vers` command once, with the same 45-second limit. Native product version and all original identity/service/disk gates remain required. Optional process and CPU diagnostics run only after a gate fails. The upstream AVX2 warning reads host flags; run 4159 observed AVX2 in the actual guest. No host or guest CPU settings change.
|
||||
|
||||
Actual run 4161 separated native wait from timer cleanup: architecture passed after 39 seconds, but the UID gate was terminated by its 45-second watchdog (51-second fork/exec/wait duration). Native ps commands passed after 34-42 seconds; output flushes took 289 and 76 seconds. The next diagnostic changes only the UID gate's watchdog to 180 seconds while retaining exit-zero/exact-root checks. This tests whether the measured short limit caused that failure; it does not establish a guest startup or service cause, and it does not qualify native CI. The earlier local 16-case harness qualified the previous 45-second timer/cancellation/output behavior, not this new timing experiment or the actual emulated guest.
|
||||
Actual run 4161 separated native wait from timer cleanup: architecture passed after 39 seconds, but the UID gate was terminated by its 45-second watchdog (51-second fork/exec/wait duration). Native ps commands passed after 34-42 seconds; output flushes took 289 and 76 seconds. Run 4163 used the targeted UID watchdog of 180 seconds but returned UID 0 with exit zero after 38 seconds, so it did not establish a need for that longer limit. Architecture passed after 46 seconds; the initial `sw_vers`, system-domain and DiskArbitration queries were terminated. The recovery-label query passed after 31 seconds but described the replacement Bash job, not Apple's executable. The identical warm `sw_vers` retry had no final exit before the 40-minute host deadline. This bootstrap A/B preserves all those limits and native pass conditions; no cause or native readiness is claimed. The earlier local 16-case harness qualified the previous 45-second timer/cancellation/output behavior, not this experiment or the actual emulated guest.
|
||||
|
||||
## Evidence and cleanup
|
||||
|
||||
Evidence is written under the requested output directory: run identity and candidate commit, Docker/runner resources, exact source patch artifacts and hashes, image/container inspection, Recovery hash, native platform/process/launchctl/diskutil logs, machine-readable guest result, outcome and cleanup receipt. The workflow retains these as a seven-day artifact. Phase names and up to 512 KiB of the final native proof also appear in CI stdout, on success or failure, with the run token replaced; no environment or credential dump is printed. A Docker start/build exit zero is not a successful native result. A missing, stale, unsupported-platform, read-only or wrong-size guest receipt fails.
|
||||
Evidence is written under the requested output directory: run identity and candidate commit, Docker/runner resources, exact source patch artifacts and hashes, image/container inspection, Recovery hash, native platform/process/launchctl/diskutil logs, machine-readable guest result, outcome and cleanup receipt. `guest-launch.sh` records the wrapper and its original `recoveryosd` exec path; `guest-readiness.sh` records the separate token-bound probe; `image.sh.patched` records the copy/comparison seam. `source-hashes.json` distinguishes all three. These source artifacts alone do not prove that Apple's executable actually ran. The workflow retains these as a seven-day artifact. Phase names and up to 512 KiB of the final native proof also appear in CI stdout, on success or failure, with the run token replaced; no environment or credential dump is printed. A Docker start/build exit zero is not a successful native result. A missing, stale, unsupported-platform, read-only or wrong-size guest receipt fails.
|
||||
|
||||
While Recovery readiness is pending, a minute heartbeat reports elapsed guest time and the container's running state. Before final cleanup, an optional ten-second capture rechecks the saved container ID/ownership label and uses the pinned image's existing Unix HMP socket, `nc.openbsd` and a five-second `timeout` to collect only [`info status` and `screendump`](https://www.qemu.org/docs/master/system/monitor.html), retaining the command transcript, exit codes and fresh bounded PPM screenshot. Capture failure is visible and never changes native readiness success.
|
||||
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
#:property PublishAot=false
|
||||
using System.Diagnostics;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
return await ExistingKvmDiagnostic.Run(args);
|
||||
|
||||
static class ExistingKvmDiagnostic
|
||||
{
|
||||
const string Image = "qemux/qemu:7.50@sha256:e7f6fda52503a546fd649670ba46e4bc23dc6dcef275bc3fac48877fbbc430df";
|
||||
const string Label = "cloud.schweigert.meeting-assistant.existing-kvm-probe";
|
||||
const string StorageTmpfsOptions = "ro,nosuid,nodev,noexec,size=4096,mode=0555";
|
||||
static readonly string[] QemuArguments = ["-machine", "pc", "-accel", "kvm", "-cpu", "host", "-m", "64", "-smp", "1", "-S", "-nodefaults",
|
||||
"-display", "none", "-monitor", "stdio", "-serial", "none", "-parallel", "none", "-nic", "none"];
|
||||
static readonly JsonSerializerOptions Json = new() { WriteIndented = true };
|
||||
|
||||
public static async Task<int> Run(string[] args)
|
||||
{
|
||||
if (args.SequenceEqual(new[] { "--help" }))
|
||||
{
|
||||
Console.WriteLine("ExistingKvmDiagnostic.cs --run|--cleanup --output DIRECTORY\nExistingKvmDiagnostic.cs --validate-evidence DIRECTORY\nRequires .NET 10; --run/--cleanup also require the existing Docker CLI/daemon. --help never calls Docker.\n--run tests only a paused, diskless QEMU with existing /dev/kvm; retains evidence and cleans up its own container. --cleanup retries that saved cleanup.\n--validate-evidence reads saved monitor/result/container evidence through the run's success parser; no Docker commands or file writes.");
|
||||
return 0;
|
||||
}
|
||||
if (args.Length == 2 && args[0] == "--validate-evidence") return ValidateEvidence(Path.GetFullPath(args[1]));
|
||||
if (args.Length != 3 || args[0] is not ("--run" or "--cleanup") || args[1] != "--output")
|
||||
throw new ArgumentException("Use --help, --validate-evidence DIRECTORY or --run|--cleanup --output DIRECTORY.");
|
||||
var output = Path.GetFullPath(args[2]);
|
||||
Directory.CreateDirectory(output);
|
||||
if (args[0] == "--cleanup") return await Cleanup(output) ? 0 : 1;
|
||||
if (Directory.EnumerateFileSystemEntries(output).Any()) throw new InvalidOperationException("Run output must be empty; existing receipts cannot be reused.");
|
||||
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
var token = Guid.NewGuid().ToString("N");
|
||||
var owner = new Owner(token, "meeting-assistant-kvm-" + token);
|
||||
Save(output, "owner.json", owner);
|
||||
Save(output, "source.json", new { image = Image, helperSha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes("tools/ci/ExistingKvmDiagnostic.cs"))).ToLowerInvariant() });
|
||||
using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(95));
|
||||
var status = "inconclusive";
|
||||
string? detail = null;
|
||||
try
|
||||
{
|
||||
await Require(Command("git", ["rev-parse", "HEAD"], output, "source-commit", budget.Token));
|
||||
await Require(Command("docker", ["context", "show"], output, "docker-context", budget.Token));
|
||||
await Require(Command("docker", ["version", "--format", "{{json .}}"], output, "docker-version", budget.Token));
|
||||
await Require(Command("docker", ["info", "--format", "{\"ID\":{{json .ID}},\"Name\":{{json .Name}},\"ServerVersion\":{{json .ServerVersion}},\"KernelVersion\":{{json .KernelVersion}},\"OperatingSystem\":{{json .OperatingSystem}},\"OSType\":{{json .OSType}},\"Architecture\":{{json .Architecture}}}"], output, "docker-daemon", budget.Token));
|
||||
var image = await Command("docker", ["image", "inspect", Image], output, "image-before", budget.Token);
|
||||
if (image.ExitCode != 0) await Require(Command("docker", ["pull", "--platform", "linux/amd64", Image], output, "image-pull", budget.Token));
|
||||
await Require(Command("docker", ["image", "inspect", Image], output, "image-exact", budget.Token));
|
||||
using var imageDocument = JsonDocument.Parse(Read(output, "image-exact", "stdout"));
|
||||
var imageId = imageDocument.RootElement[0].GetProperty("Id").GetString();
|
||||
var create = await Command("docker", ["create", "--platform", "linux/amd64", "--pull", "never", "--name", owner.Name,
|
||||
"--label", Label + "=" + token, "--cidfile", Path.Combine(output, "container.id"), "--interactive", "--read-only",
|
||||
"--network", "none", "--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--cpus", "0.5",
|
||||
"--memory", "256m", "--memory-swap", "256m", "--pids-limit", "32", "--no-healthcheck",
|
||||
"--tmpfs", "/storage:" + StorageTmpfsOptions,
|
||||
"--device", "/dev/kvm:/dev/kvm:rw", "--entrypoint", "/usr/bin/qemu-system-x86_64", Image, .. QemuArguments], output, "container-create", budget.Token);
|
||||
if (create.ExitCode != 0)
|
||||
{
|
||||
status = ClassifyFailure(Read(output, "container-create", "stderr"));
|
||||
detail = "Docker did not successfully create the device-mapped container; see container-create logs.";
|
||||
}
|
||||
else
|
||||
{
|
||||
var created = await InspectOwned(output, owner, "container-created", budget.Token);
|
||||
ValidateBoundary(created, imageId);
|
||||
var id = created.GetProperty("Id").GetString()!;
|
||||
var monitor = await Command("docker", ["start", "--attach", "--interactive", id], output, "qemu-monitor", budget.Token, "info version\ninfo kvm\ninfo status\nquit\n");
|
||||
var exited = await InspectOwned(output, owner, "container-exited", budget.Token);
|
||||
var state = exited.GetProperty("State");
|
||||
var text = Read(output, "qemu-monitor", "stdout");
|
||||
status = KvmUsable(text, monitor, state) ? "kvm_usable" : ClassifyFailure(Read(output, "qemu-monitor", "stderr") + "\n" + state.GetProperty("Error").GetString());
|
||||
detail = status == "kvm_usable" ? "QEMU initialized KVM, reported enabled and paused, and exited successfully after quit. No guest CPU or OS was run."
|
||||
: "KVM initialization or its enabled/paused/clean-exit proof did not pass; inspect raw monitor output and container state.";
|
||||
}
|
||||
}
|
||||
catch (Exception error) { detail = error.Message; }
|
||||
var cleaned = await Cleanup(output);
|
||||
Save(output, "result.json", new { started, finished = DateTimeOffset.UtcNow, status, usable = status == "kvm_usable", cleaned, detail, image = Image, token });
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { status, cleaned, detail }));
|
||||
return status == "kvm_usable" && cleaned ? 0 : 1;
|
||||
}
|
||||
|
||||
static bool KvmUsable(string text, CommandResult monitor, JsonElement state)
|
||||
{
|
||||
var cleanExit = monitor.ExitCode == 0 && !monitor.TimedOut && monitor.Error is null && !state.GetProperty("Running").GetBoolean()
|
||||
&& state.GetProperty("ExitCode").GetInt32() == 0 && !state.GetProperty("OOMKilled").GetBoolean();
|
||||
var enabled = Regex.IsMatch(text, @"(?m)^kvm support: enabled\r?$", RegexOptions.CultureInvariant);
|
||||
var paused = Regex.IsMatch(text, @"(?m)^VM status: paused(?: \(prelaunch\))?\r?$", RegexOptions.CultureInvariant);
|
||||
return cleanExit && enabled && paused;
|
||||
}
|
||||
|
||||
static int ValidateEvidence(string evidence)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var monitorDocument = JsonDocument.Parse(File.ReadAllText(Path.Combine(evidence, "qemu-monitor.result.json")));
|
||||
var result = monitorDocument.RootElement;
|
||||
var monitor = new CommandResult(result.GetProperty("ExitCode").GetInt32(), result.GetProperty("TimedOut").GetBoolean(), result.GetProperty("Error").GetString());
|
||||
using var exited = JsonDocument.Parse(Read(evidence, "container-exited", "stdout"));
|
||||
if (exited.RootElement.GetArrayLength() != 1) throw new InvalidOperationException("Expected exactly one saved exited container.");
|
||||
var usable = KvmUsable(Read(evidence, "qemu-monitor", "stdout"), monitor, exited.RootElement[0].GetProperty("State"));
|
||||
Console.WriteLine(JsonSerializer.Serialize(new { usable, scope = "Saved HMP protocol and clean exit interpretation only; source, ownership and container boundary are not requalified." }));
|
||||
return usable ? 0 : 1;
|
||||
}
|
||||
catch (Exception error) { Console.Error.WriteLine("Evidence interpretation failed: " + error.Message); return 1; }
|
||||
}
|
||||
|
||||
static string ClassifyFailure(string text)
|
||||
{
|
||||
if (text.Contains("/dev/kvm", StringComparison.Ordinal) && text.Contains("error gathering device information", StringComparison.OrdinalIgnoreCase)
|
||||
&& text.Contains("no such file or directory", StringComparison.OrdinalIgnoreCase)) return "daemon_device_missing";
|
||||
if (text.Contains("Permission denied", StringComparison.OrdinalIgnoreCase) || text.Contains("Operation not permitted", StringComparison.OrdinalIgnoreCase)) return "access_denied_observed";
|
||||
if (text.Contains("invalid accelerator kvm", StringComparison.OrdinalIgnoreCase)) return "qemu_kvm_backend_unavailable";
|
||||
if (text.Contains("failed to initialize kvm", StringComparison.OrdinalIgnoreCase)) return "kvm_initialization_failed";
|
||||
return "inconclusive";
|
||||
}
|
||||
|
||||
static void ValidateBoundary(JsonElement container, string? imageId)
|
||||
{
|
||||
var host = container.GetProperty("HostConfig");
|
||||
var devices = host.GetProperty("Devices");
|
||||
if (container.GetProperty("Mounts").GetArrayLength() != 0)
|
||||
throw new InvalidOperationException("Container mount boundary failed: persistent volumes or binds were created; expected Mounts=[] with only the read-only /storage tmpfs.");
|
||||
if (!host.TryGetProperty("Tmpfs", out var tmpfs) || tmpfs.ValueKind != JsonValueKind.Object || tmpfs.EnumerateObject().Count() != 1
|
||||
|| !tmpfs.TryGetProperty("/storage", out var options) || options.GetString() != StorageTmpfsOptions)
|
||||
throw new InvalidOperationException("Container tmpfs boundary failed: expected only /storage:" + StorageTmpfsOptions + ".");
|
||||
if (imageId == null || container.GetProperty("Image").GetString() != imageId || container.GetProperty("Config").GetProperty("Image").GetString() != Image
|
||||
|| container.GetProperty("Path").GetString() != "/usr/bin/qemu-system-x86_64" || !container.GetProperty("Args").EnumerateArray().Select(x => x.GetString()).SequenceEqual(QemuArguments)
|
||||
|| host.GetProperty("Privileged").GetBoolean() || !host.GetProperty("ReadonlyRootfs").GetBoolean()
|
||||
|| host.GetProperty("NetworkMode").GetString() != "none"
|
||||
|| devices.GetArrayLength() != 1 || devices[0].GetProperty("PathOnHost").GetString() != "/dev/kvm"
|
||||
|| devices[0].GetProperty("PathInContainer").GetString() != "/dev/kvm" || devices[0].GetProperty("CgroupPermissions").GetString() != "rw"
|
||||
|| !host.GetProperty("CapDrop").EnumerateArray().Any(x => x.GetString() == "ALL")
|
||||
|| !Empty(host.GetProperty("CapAdd")) || !Empty(host.GetProperty("Binds")) || !Empty(host.GetProperty("PortBindings"))
|
||||
|| !Empty(host.GetProperty("DeviceCgroupRules"))
|
||||
|| !host.GetProperty("SecurityOpt").EnumerateArray().Any(x => x.GetString() == "no-new-privileges")
|
||||
|| host.GetProperty("Memory").GetInt64() != 268435456 || host.GetProperty("MemorySwap").GetInt64() != 268435456
|
||||
|| host.GetProperty("NanoCpus").GetInt64() != 500000000 || host.GetProperty("PidsLimit").GetInt64() != 32)
|
||||
throw new InvalidOperationException("Created container does not match the diagnostic's restricted resource boundary.");
|
||||
}
|
||||
|
||||
static bool Empty(JsonElement value) => value.ValueKind == JsonValueKind.Null
|
||||
|| value.ValueKind == JsonValueKind.Array && value.GetArrayLength() == 0
|
||||
|| value.ValueKind == JsonValueKind.Object && !value.EnumerateObject().Any();
|
||||
|
||||
static async Task<JsonElement> InspectOwned(string output, Owner owner, string step, CancellationToken cancellation)
|
||||
{
|
||||
var idPath = Path.Combine(output, "container.id");
|
||||
var savedId = File.Exists(idPath) ? File.ReadAllText(idPath).Trim() : null;
|
||||
if (savedId != null && !Regex.IsMatch(savedId, "^[a-f0-9]{64}$")) throw new InvalidOperationException("Saved container ID is invalid.");
|
||||
await Require(Command("docker", ["inspect", "--type", "container", savedId ?? owner.Name], output, step, cancellation));
|
||||
using var document = JsonDocument.Parse(Read(output, step, "stdout"));
|
||||
var values = document.RootElement;
|
||||
if (values.GetArrayLength() != 1) throw new InvalidOperationException("Container inspection did not return exactly one object.");
|
||||
var value = values[0];
|
||||
var id = value.GetProperty("Id").GetString()!;
|
||||
if (!Regex.IsMatch(id, "^[a-f0-9]{64}$") || (savedId != null && id != savedId) || value.GetProperty("Name").GetString() != "/" + owner.Name
|
||||
|| !value.GetProperty("Config").GetProperty("Labels").TryGetProperty(Label, out var label) || label.GetString() != owner.Token)
|
||||
throw new InvalidOperationException("Container ownership ID/name/label mismatch; refusing resource operations.");
|
||||
// Recover an interrupted create receipt by the saved random name and exact label, then use only its full ID.
|
||||
if (savedId == null) File.WriteAllText(idPath, id + "\n");
|
||||
return value.Clone();
|
||||
}
|
||||
|
||||
static async Task<bool> Cleanup(string output)
|
||||
{
|
||||
using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(20));
|
||||
var prefix = "cleanup-" + Guid.NewGuid().ToString("N");
|
||||
try
|
||||
{
|
||||
if (!File.Exists(Path.Combine(output, "owner.json"))) { Save(output, "cleanup.json", new { cleaned = true, reason = "No owned resource receipt exists." }); return true; }
|
||||
var owner = JsonSerializer.Deserialize<Owner>(File.ReadAllText(Path.Combine(output, "owner.json")))!;
|
||||
if (!Regex.IsMatch(owner.Token, "^[a-f0-9]{32}$") || owner.Name != "meeting-assistant-kvm-" + owner.Token)
|
||||
throw new InvalidOperationException("Invalid saved ownership receipt.");
|
||||
var container = await InspectOwned(output, owner, prefix + "-inspect", budget.Token);
|
||||
var id = container.GetProperty("Id").GetString()!;
|
||||
if (container.GetProperty("State").GetProperty("Running").GetBoolean())
|
||||
{
|
||||
await Command("docker", ["stop", "--time", "1", id], output, prefix + "-stop", budget.Token);
|
||||
await InspectOwned(output, owner, prefix + "-reinspect", budget.Token);
|
||||
}
|
||||
await Require(Command("docker", ["rm", "--force", "--volumes", id], output, prefix + "-remove", budget.Token));
|
||||
var receipt = new { cleaned = true, id, finished = DateTimeOffset.UtcNow };
|
||||
Save(output, prefix + ".receipt.json", receipt);
|
||||
Save(output, "cleanup.json", receipt);
|
||||
return true;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
var path = Path.Combine(output, prefix + "-inspect.stderr.log");
|
||||
var absent = File.Exists(path) && new FileInfo(path).Length <= 1024 * 1024
|
||||
&& File.ReadAllText(path).Contains("No such container", StringComparison.OrdinalIgnoreCase);
|
||||
var receipt = new { cleaned = absent, reason = error.Message, finished = DateTimeOffset.UtcNow };
|
||||
Save(output, prefix + ".receipt.json", receipt);
|
||||
Save(output, "cleanup.json", receipt);
|
||||
return absent;
|
||||
}
|
||||
}
|
||||
|
||||
static async Task<CommandResult> Command(string program, string[] arguments, string output, string step, CancellationToken cancellation, string? input = null)
|
||||
{
|
||||
var started = DateTimeOffset.UtcNow;
|
||||
Save(output, step + ".command.json", new { program, arguments, input, started });
|
||||
var start = new ProcessStartInfo(program) { UseShellExecute = false, RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = input != null };
|
||||
foreach (var argument in arguments) start.ArgumentList.Add(argument);
|
||||
using var process = new Process { StartInfo = start };
|
||||
await using var stdout = File.Create(Path.Combine(output, step + ".stdout.log"));
|
||||
await using var stderr = File.Create(Path.Combine(output, step + ".stderr.log"));
|
||||
int? exit = null;
|
||||
var timedOut = false;
|
||||
string? error = null;
|
||||
try
|
||||
{
|
||||
cancellation.ThrowIfCancellationRequested();
|
||||
process.Start();
|
||||
var copies = Task.WhenAll(process.StandardOutput.BaseStream.CopyToAsync(stdout), process.StandardError.BaseStream.CopyToAsync(stderr));
|
||||
try
|
||||
{
|
||||
if (input != null)
|
||||
{
|
||||
try { await process.StandardInput.WriteAsync(input.AsMemory(), cancellation); await process.StandardInput.FlushAsync(cancellation); }
|
||||
catch (IOException) { /* QEMU can reject KVM before accepting stdin; preserve its stderr and state. */ }
|
||||
process.StandardInput.Close();
|
||||
}
|
||||
await process.WaitForExitAsync(cancellation);
|
||||
}
|
||||
catch (OperationCanceledException) { timedOut = true; if (!process.HasExited) process.Kill(entireProcessTree: true); }
|
||||
await copies.WaitAsync(TimeSpan.FromSeconds(2));
|
||||
if (process.HasExited) exit = process.ExitCode;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
error = exception.Message;
|
||||
try { if (!process.HasExited) process.Kill(entireProcessTree: true); } catch (InvalidOperationException) { /* Process never started or already exited. */ }
|
||||
}
|
||||
var result = new CommandResult(exit, timedOut, error);
|
||||
Save(output, step + ".result.json", new { result.ExitCode, result.TimedOut, result.Error, started, finished = DateTimeOffset.UtcNow });
|
||||
return result;
|
||||
}
|
||||
|
||||
static async Task Require(Task<CommandResult> command)
|
||||
{
|
||||
var result = await command;
|
||||
if (result.ExitCode != 0 || result.TimedOut || result.Error != null) throw new InvalidOperationException($"Command did not succeed: exit={result.ExitCode}, timedOut={result.TimedOut}, error={result.Error}");
|
||||
}
|
||||
static string Read(string output, string step, string stream)
|
||||
{
|
||||
var path = Path.Combine(output, step + "." + stream + ".log");
|
||||
if (new FileInfo(path).Length > 1024 * 1024) throw new InvalidOperationException("Diagnostic output exceeds the one-MiB interpretation limit; inspect the retained raw log.");
|
||||
return File.ReadAllText(path);
|
||||
}
|
||||
static void Save(string output, string name, object value) => File.WriteAllText(Path.Combine(output, name), JsonSerializer.Serialize(value, Json) + "\n");
|
||||
sealed record Owner(string Token, string Name);
|
||||
sealed record CommandResult(int? ExitCode, bool TimedOut, string? Error);
|
||||
}
|
||||
@@ -168,6 +168,8 @@ static class NativeDiagnostic
|
||||
|
||||
static void ValidateContracts()
|
||||
{
|
||||
if (Hash(File.ReadAllBytes(Path.Combine("tools", "ci", "macos-native-readiness.sh"))) != "4d428f594dac14eff64ed87b172c81ecf85ac91da8c5460cd6ec4b1d310800c3")
|
||||
throw new InvalidOperationException("Bootstrap A/B requires the unchanged 40281b readiness probe and limits.");
|
||||
XDocument.Parse(DiagnosticDaemon);
|
||||
if (Encoding.UTF8.GetByteCount(DiagnosticDaemon) > Encoding.UTF8.GetByteCount(OriginalDaemon + "\n")) throw new InvalidOperationException("Daemon replacement exceeds original file.");
|
||||
var good = JsonSerializer.Serialize(new { token = "validation", success = true, osVersion = "14.6.1", architecture = "x86_64", uid = 0, disk = "/dev/disk1", diskBytes = GuestDiskBytes, readOnly = false, systemExit = 0, diskArbitrationExit = 0, recoveryExit = 0, diskListExit = 0 });
|
||||
@@ -199,16 +201,26 @@ static class NativeDiagnostic
|
||||
var entry = ReplaceOnce(File.ReadAllText(entryPath), "set -Eeuo pipefail\n", "set -Eeuo pipefail\n\n# Diagnostic budget: inspect existing Docker storage before Recovery download/boot.\ndf -Pk /storage\nfree_kib=$(df -Pk /storage | awk 'NR==2 {print $4}')\n[[ \"$free_kib\" =~ ^[0-9]+$ ]] && (( free_kib >= 8 * 1024 * 1024 )) || { echo 'Existing Docker storage has less than the 8-GiB diagnostic budget.' >&2; exit 1; }\n");
|
||||
var hookPath = Path.Combine("tools", "ci", "macos-native-readiness.sh");
|
||||
var hook = ReplaceOnce(File.ReadAllText(hookPath), "@@PROOF_TOKEN@@", token);
|
||||
foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", hook), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("early-bootstrap.sh", MountOnlyBootstrap) })
|
||||
var wrapper = File.ReadAllText(Path.Combine("tools", "ci", "macos-native-bootstrap.sh"));
|
||||
var imagePath = Path.Combine(source, "src", "image.sh");
|
||||
var originalImage = File.ReadAllText(imagePath);
|
||||
if (Hash(Encoding.UTF8.GetBytes(originalImage)) != "c08bf9436fb8b72ea82fdf0e677641ab2fc42a0a59e2cf0309c00df519884c5c") throw new InvalidOperationException("Pinned Recovery staging script hash mismatch.");
|
||||
var image = ReplaceOnce(originalImage, " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\"; then\n", " if ! cp -f \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cp -f \"$IMAGE_TOOLS/recovery/readiness.sh\" \"${script%/*}/readiness.sh\"; then\n");
|
||||
image = ReplaceOnce(image, " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n", " if ! cmp -s \"$IMAGE_TOOLS/recovery/launch.sh\" \"$script\" ||\n ! cmp -s \"$IMAGE_TOOLS/recovery/readiness.sh\" \"$state/readiness.sh\" ||\n");
|
||||
foreach (var pair in new[] { ("recovery-patch.py", patch), ("Dockerfile.patched", dockerfile), ("container-entry.sh", entry), ("guest-launch.sh", wrapper), ("guest-readiness.sh", hook), ("image.sh.patched", image), ("recoveryosd-original.plist", daemon), ("recoveryosd-diagnostic.plist", DiagnosticDaemon), ("early-bootstrap.sh", MountOnlyBootstrap) })
|
||||
File.WriteAllText(Path.Combine(output, pair.Item1), pair.Item2, new UTF8Encoding(false));
|
||||
Save(Path.Combine(output, "source-hashes.json"), Directory.GetFiles(output).Where(path => Path.GetFileName(path) is "recovery-patch.py" or "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "early-bootstrap.sh").ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path))));
|
||||
Save(Path.Combine(output, "source-hashes.json"), Directory.GetFiles(output).Where(path => Path.GetFileName(path) is "recovery-patch.py" or "Dockerfile.patched" or "container-entry.sh" or "guest-launch.sh" or "guest-readiness.sh" or "image.sh.patched" or "recoveryosd-original.plist" or "recoveryosd-diagnostic.plist" or "early-bootstrap.sh").ToDictionary(path => Path.GetFileName(path)!, path => Hash(File.ReadAllBytes(path))));
|
||||
await Command("bash", ["-n", Path.Combine(output, "guest-launch.sh")], output, "guest-hook-syntax", cancellation);
|
||||
await Command("bash", ["-n", Path.Combine(output, "guest-readiness.sh")], output, "guest-readiness-syntax", cancellation);
|
||||
await Command("bash", ["-n", Path.Combine(output, "image.sh.patched")], output, "guest-staging-syntax", cancellation);
|
||||
await Command("bash", ["-n", Path.Combine(output, "container-entry.sh")], output, "entry-syntax", cancellation);
|
||||
if (!writeSource) return;
|
||||
File.WriteAllText(patchPath, patch, new UTF8Encoding(false));
|
||||
File.WriteAllText(dockerPath, dockerfile, new UTF8Encoding(false));
|
||||
File.WriteAllText(entryPath, entry, new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(source, "src/install/recovery/launch.sh"), hook, new UTF8Encoding(false));
|
||||
File.WriteAllText(imagePath, image, new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(source, "src/install/recovery/launch.sh"), wrapper, new UTF8Encoding(false));
|
||||
File.WriteAllText(Path.Combine(source, "src/install/recovery/readiness.sh"), hook, new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
static string ReplaceOnce(string text, string oldValue, string newValue) => ReplaceAllExact(text, oldValue, newValue, 1);
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
#!/bin/bash
|
||||
# Apple Recovery has Bash before any SDK is installed. Preserve the original
|
||||
# daemon under its launchd label/PID while the unchanged read-only probe runs.
|
||||
/bin/bash /Volumes/installstate/readiness.sh &
|
||||
exec /usr/libexec/recoveryosd
|
||||
Reference in New Issue
Block a user