ci: accept QEMU paused prelaunch state and retire one-time cleanup

This commit is contained in:
dh
2026-10-03 20:17:30 +02:00
parent abe907ce70
commit 4e270ae0ea
3 changed files with 36 additions and 83 deletions
@@ -26,10 +26,6 @@ jobs:
if: always() if: always()
run: dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --cleanup --output artifacts/existing-kvm run: dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --cleanup --output artifacts/existing-kvm
- name: Remove only run 4165's unused inherited anonymous volume
if: always()
run: dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --cleanup-run4165-volume --output artifacts/existing-kvm/run4165-volume-cleanup
- name: Preserve KVM diagnostic evidence - name: Preserve KVM diagnostic evidence
if: always() if: always()
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3
+6 -4
View File
@@ -8,10 +8,10 @@ The entry point is the .NET 10 file-based app `tools/ci/ExistingKvmDiagnostic.cs
dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --help 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 -- --run --output artifacts/existing-kvm
dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --cleanup --output artifacts/existing-kvm dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --cleanup --output artifacts/existing-kvm
dotnet run --file tools/ci/ExistingKvmDiagnostic.cs -- --cleanup-run4165-volume --output artifacts/existing-kvm/run4165-volume-cleanup 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. `--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 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.
@@ -19,8 +19,10 @@ The helper has a 95-second operation budget and a separate 20-second cleanup bud
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. 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.
The temporary `--cleanup-run4165-volume` mode has a separate 20-second budget and accepts no target parameters. It records the frozen run/owner/volume target, artifact hashes, each command and its outcome under the supplied evidence directory. It requires the original Docker daemon ID `528941c8-73ac-49ff-8eb7-69113eb4a2a1`; absence of the old full container ID and random container name; exactly the recorded volume with local driver/scope and no options; and no container reference found by `docker ps --all --filter volume=...`. It uses `docker volume rm` without force, so Docker also refuses a reference introduced after the check. Already absent is an idempotent success only on the original daemon after old-container absence checks. A different daemon, attachment or missing evidence fails closed. No generalized orphan cleanup is provided. This temporary workflow step can be removed after actual removal/absence is verified. The source evidence is artifact ZIP SHA-256 `6745d90e8b81c867740405c99b4364cc165c47ebb165455052314459d5cd547b` and created-container inspect SHA-256 `7afdfc6c30c933bee2ef1d6c18ed011c8b2f709d1a5e88531928f9f40471c055`. 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 both `kvm support: enabled` and `VM status: paused`, followed by clean monitor/container exit after `quit`. 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. `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. 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.
+30 -75
View File
@@ -19,15 +19,15 @@ static class ExistingKvmDiagnostic
{ {
if (args.SequenceEqual(new[] { "--help" })) if (args.SequenceEqual(new[] { "--help" }))
{ {
Console.WriteLine("ExistingKvmDiagnostic.cs --run|--cleanup|--cleanup-run4165-volume --output DIRECTORY\nRequires .NET 10 and 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--cleanup-run4165-volume removes only run 4165's frozen unused anonymous volume after daemon, old-container absence and reference checks."); 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; return 0;
} }
if (args.Length != 3 || args[0] is not ("--run" or "--cleanup" or "--cleanup-run4165-volume") || args[1] != "--output") if (args.Length == 2 && args[0] == "--validate-evidence") return ValidateEvidence(Path.GetFullPath(args[1]));
throw new ArgumentException("Use --help or --run|--cleanup|--cleanup-run4165-volume --output DIRECTORY."); 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]); var output = Path.GetFullPath(args[2]);
Directory.CreateDirectory(output); Directory.CreateDirectory(output);
if (args[0] == "--cleanup") return await Cleanup(output) ? 0 : 1; if (args[0] == "--cleanup") return await Cleanup(output) ? 0 : 1;
if (args[0] == "--cleanup-run4165-volume") return await CleanupRun4165Volume(output) ? 0 : 1;
if (Directory.EnumerateFileSystemEntries(output).Any()) throw new InvalidOperationException("Run output must be empty; existing receipts cannot be reused."); if (Directory.EnumerateFileSystemEntries(output).Any()) throw new InvalidOperationException("Run output must be empty; existing receipts cannot be reused.");
var started = DateTimeOffset.UtcNow; var started = DateTimeOffset.UtcNow;
@@ -69,11 +69,7 @@ static class ExistingKvmDiagnostic
var exited = await InspectOwned(output, owner, "container-exited", budget.Token); var exited = await InspectOwned(output, owner, "container-exited", budget.Token);
var state = exited.GetProperty("State"); var state = exited.GetProperty("State");
var text = Read(output, "qemu-monitor", "stdout"); var text = Read(output, "qemu-monitor", "stdout");
var cleanExit = monitor.ExitCode == 0 && !monitor.TimedOut && !state.GetProperty("Running").GetBoolean() status = KvmUsable(text, monitor, state) ? "kvm_usable" : ClassifyFailure(Read(output, "qemu-monitor", "stderr") + "\n" + state.GetProperty("Error").GetString());
&& 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\r?$", RegexOptions.CultureInvariant);
status = cleanExit && enabled && paused ? "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." 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."; : "KVM initialization or its enabled/paused/clean-exit proof did not pass; inspect raw monitor output and container state.";
} }
@@ -85,6 +81,31 @@ static class ExistingKvmDiagnostic
return status == "kvm_usable" && cleaned ? 0 : 1; 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) static string ClassifyFailure(string text)
{ {
if (text.Contains("/dev/kvm", StringComparison.Ordinal) && text.Contains("error gathering device information", StringComparison.OrdinalIgnoreCase) if (text.Contains("/dev/kvm", StringComparison.Ordinal) && text.Contains("error gathering device information", StringComparison.OrdinalIgnoreCase)
@@ -177,72 +198,6 @@ static class ExistingKvmDiagnostic
} }
} }
static async Task<bool> CleanupRun4165Volume(string output)
{
// Frozen from actual run 4165: the exact owner-labelled container never started (created/PID 0/zero StartedAt).
// Its image's VOLUME instruction created this anonymous /storage volume before the mount guard failed.
const string daemonId = "528941c8-73ac-49ff-8eb7-69113eb4a2a1";
const string containerId = "1753f95ef334244e7a1b393a839f218ea885363de7d5335eec53132d64627010";
const string token = "43b7f4676c514f2a95c63c02577ac36e";
const string volumeName = "ef7daa62ef89a2ffb8aae50a9b7803f1d9b3075ee509aa3183f3e170f69ce595";
const string containerName = "meeting-assistant-kvm-" + token;
var prefix = "run4165-volume-" + Guid.NewGuid().ToString("N");
Save(output, prefix + ".target.json", new { runId = 4165, daemonId, containerId, containerName, token, volumeName,
archiveSha256 = "6745d90e8b81c867740405c99b4364cc165c47ebb165455052314459d5cd547b",
createdInspectSha256 = "7afdfc6c30c933bee2ef1d6c18ed011c8b2f709d1a5e88531928f9f40471c055",
ownerReceiptSha256 = "d96f15588412a5928ebe8a64b115764f113adaaaf6d164209fd87de8a4505572",
priorState = "created", priorPid = 0, priorStartedAt = "0001-01-01T00:00:00Z" });
using var budget = new CancellationTokenSource(TimeSpan.FromSeconds(20));
var success = false;
var outcome = "refused";
string? detail = null;
try
{
await Require(Command("docker", ["info", "--format", "{{.ID}}"], output, prefix + "-daemon", budget.Token));
if (Read(output, prefix + "-daemon", "stdout").Trim() != daemonId) throw new InvalidOperationException("Frozen run 4165 daemon ID differs; refusing volume cleanup.");
foreach (var selector in new[] { containerId, containerName })
{
var step = prefix + (selector == containerId ? "-old-id" : "-old-name");
var inspection = await Command("docker", ["inspect", "--type", "container", selector], output, step, budget.Token);
if (inspection.TimedOut || inspection.Error != null || inspection.ExitCode == 0 || !Read(output, step, "stderr").Contains("No such container", StringComparison.OrdinalIgnoreCase)
|| !Read(output, step, "stderr").Contains(selector, StringComparison.Ordinal)) throw new InvalidOperationException("Old run 4165 container absence was not proved for " + selector + ".");
}
var inspectStep = prefix + "-inspect";
var inspect = await Command("docker", ["volume", "inspect", volumeName], output, inspectStep, budget.Token);
if (VolumeAbsent(inspect, output, inspectStep, volumeName)) { success = true; outcome = "already-absent"; }
else
{
if (inspect.ExitCode != 0 || inspect.TimedOut || inspect.Error != null) throw new InvalidOperationException("Cannot inspect the exact run 4165 volume.");
using var document = JsonDocument.Parse(Read(output, inspectStep, "stdout"));
var values = document.RootElement;
if (values.GetArrayLength() != 1 || values[0].GetProperty("Name").GetString() != volumeName
|| values[0].GetProperty("Driver").GetString() != "local" || values[0].GetProperty("Scope").GetString() != "local"
|| !Empty(values[0].GetProperty("Options"))) throw new InvalidOperationException("Frozen volume name/local driver/scope/options boundary failed.");
await Require(Command("docker", ["ps", "--all", "--no-trunc", "--filter", "volume=" + volumeName, "--format", "{{.ID}}"], output, prefix + "-references", budget.Token));
if (Read(output, prefix + "-references", "stdout").Trim().Length != 0) throw new InvalidOperationException("A container references the frozen run 4165 volume; refusing removal.");
// No --force: Docker rejects an attachment made after the reference check, too.
await Require(Command("docker", ["volume", "rm", volumeName], output, prefix + "-remove", budget.Token));
var afterStep = prefix + "-after";
var after = await Command("docker", ["volume", "inspect", volumeName], output, afterStep, budget.Token);
if (!VolumeAbsent(after, output, afterStep, volumeName)) throw new InvalidOperationException("Volume absence after removal was not proved.");
success = true;
outcome = "removed";
}
}
catch (Exception error) { detail = error.Message; }
var receipt = new { runId = 4165, daemonId, volumeName, success, outcome, detail, finished = DateTimeOffset.UtcNow };
Save(output, prefix + ".receipt.json", receipt);
Console.WriteLine(JsonSerializer.Serialize(receipt));
return success;
}
static bool VolumeAbsent(CommandResult result, string output, string step, string name)
{
var error = Read(output, step, "stderr");
return result.ExitCode is not (null or 0) && !result.TimedOut && result.Error == null
&& error.Contains("no such volume", StringComparison.OrdinalIgnoreCase) && error.Contains(name, StringComparison.Ordinal);
}
static async Task<CommandResult> Command(string program, string[] arguments, string output, string step, CancellationToken cancellation, string? input = null) static async Task<CommandResult> Command(string program, string[] arguments, string output, string step, CancellationToken cancellation, string? input = null)
{ {
var started = DateTimeOffset.UtcNow; var started = DateTimeOffset.UtcNow;