diff --git a/docs/architecture.md b/docs/architecture.md index fd31bfb..ade7d3d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -67,7 +67,9 @@ before doing expensive work or acquiring a guest. `release-clean` resolves VM lanes to `clean` and the physical Mac to `ready`; the Mac consumer then requests the stronger `runtime-ready` cleanup contract on its lease. `dev-fast` never silently falls back from its declared mapping. -Every accepted clean or named baseline has a SHA-256 provenance manifest under -`golden/manifests/`. Provenance binds the selected VM's canonical image and -provisioning contract. Profiles, retention policy, physical hosts, and other VM -definitions do not invalidate an unchanged baseline. +Every accepted clean or named baseline has a SHA-256 fingerprint manifest under +`golden/manifests/`. The record describes the baseline disk, UEFI state, TPM +state, and QEMU image metadata; it is not coupled to the mutable controller +manifest. `doctor` validates the current controller and provisioning contract +separately, while `doctor --deep` re-hashes accepted clean images when byte-level +integrity must be re-established. diff --git a/docs/checkpoints.md b/docs/checkpoints.md index c703053..fd1cf8a 100644 --- a/docs/checkpoints.md +++ b/docs/checkpoints.md @@ -38,7 +38,8 @@ public snapshot interface. Test the checkpoint first with an explicit `preflight --baseline NAME`. If it is then selected by a shared profile, update `lab-manifest.json` and run `install.sh` -against the deployed lab. Recapture provenance only when that VM's image or -provisioning contract changes. Profile-only changes do not invalidate baseline -provenance. Then run `lab.sh doctor --strict` and the exact consumer/profile -`lab.sh preflight`. +against the deployed lab. Recapture its fingerprint only when the checkpoint's +disk, UEFI, or TPM state changes. Controller, profile, and provisioning-source +changes do not rewrite or invalidate an unchanged checkpoint record. Validate +those current inputs with `lab.sh doctor --strict`, then run the exact +consumer/profile `lab.sh preflight`. diff --git a/docs/rebuild.md b/docs/rebuild.md index 1e73a8f..4a83d5f 100644 --- a/docs/rebuild.md +++ b/docs/rebuild.md @@ -25,10 +25,11 @@ Capture the controller-owned manifest immediately after accepting a baseline: ./lab.sh doctor --strict ``` -The capture records the whole-manifest hash for diagnostics and a canonical -hash of the selected VM's image and provisioning contract for validation. -Changing profiles, retention, hosts, or another VM does not make this baseline -stale. Changing this VM's contract does. +The capture records baseline disk, UEFI, TPM, and QEMU image metadata. A +controller-manifest hash may be retained as capture-time diagnostics, but it is +not a validity gate: changing controller configuration cannot change the bytes +of an already captured baseline. `doctor` validates current controller and +provisioning inputs independently. `doctor --deep` additionally re-hashes the clean golden disks. Run it after a rebuild or suspected storage corruption; routine consumers use `--strict` and diff --git a/lab.sh b/lab.sh index c57ff49..cbaea85 100755 --- a/lab.sh +++ b/lab.sh @@ -327,7 +327,7 @@ lease_command() { if [[ -n "$lease_signal" && "$result" == 0 ]]; then case "$lease_signal" in INT) result=130 ;; TERM) result=143 ;; HUP) result=129 ;; esac fi - if [[ "$kind" == vm && -n "$cleanup_baseline" && "$result" -ge 128 ]]; then + if [[ "$kind" == vm && -n "$cleanup_baseline" ]]; then if "$ENGINE" status "$vm" | grep -Eq "^${vm} running "; then "$ENGINE" stop "$vm" || result=1 fi @@ -717,25 +717,8 @@ tree_digest() { done | sha256sum | awk '{print $1}' } -provenance_contract_sha() { - local vm="$1" - python3 - "$MANIFEST" "$vm" <<'PY' -import hashlib, json, sys -path, vm = sys.argv[1:] -with open(path) as handle: - manifest = json.load(handle) -contract = { - "schemaVersion": 1, - "vm": vm, - "vmSpec": manifest["vms"][vm], -} -encoded = json.dumps(contract, sort_keys=True, separators=(",", ":")).encode() -print(hashlib.sha256(encoded).hexdigest()) -PY -} - provenance_capture_one() { - local vm="$1" name="$2" destination disk_sha vars_sha tpm_sha contract_sha + local vm="$1" name="$2" destination disk_sha vars_sha tpm_sha golden_paths "$vm" "$name" [[ -f "$GOLDEN_CAPTURE_DISK" ]] || { printf 'Missing golden disk: %s\n' "$GOLDEN_CAPTURE_DISK" >&2; return 1; } [[ -f "$GOLDEN_CAPTURE_VARS" ]] || { printf 'Missing golden UEFI state: %s\n' "$GOLDEN_CAPTURE_VARS" >&2; return 1; } @@ -747,12 +730,11 @@ provenance_capture_one() { disk_sha="$(sha256sum "$GOLDEN_CAPTURE_DISK" | awk '{print $1}')" vars_sha="$(sha256sum "$GOLDEN_CAPTURE_VARS" | awk '{print $1}')" tpm_sha="$(tree_digest "$GOLDEN_CAPTURE_TPM")" - contract_sha="$(provenance_contract_sha "$vm")" mkdir -p "$LAB_ROOT/golden/manifests" destination="$LAB_ROOT/golden/manifests/${vm}-${name}.json" - python3 - "$destination" "$vm" "$(distro_name "$vm")" "$name" "$disk_sha" "$vars_sha" "$tpm_sha" "$LAB_VERSION" "$MANIFEST" "$GOLDEN_CAPTURE_DISK" "$contract_sha" <<'PY' + python3 - "$destination" "$vm" "$(distro_name "$vm")" "$name" "$disk_sha" "$vars_sha" "$tpm_sha" "$LAB_VERSION" "$MANIFEST" "$GOLDEN_CAPTURE_DISK" <<'PY' import datetime, hashlib, json, os, subprocess, sys, tempfile -path, vm, distro, name, disk_sha, vars_sha, tpm_sha, version, config_path, disk_path, contract_sha = sys.argv[1:] +path, vm, distro, name, disk_sha, vars_sha, tpm_sha, version, config_path, disk_path = sys.argv[1:] config_sha = hashlib.sha256(open(config_path, "rb").read()).hexdigest() if os.path.exists(config_path) else None qemu_info = json.loads(subprocess.check_output(["qemu-img", "info", "--output=json", disk_path])) record = { @@ -764,7 +746,6 @@ record = { "provenance": "captured-current-state", "controllerVersion": version, "labManifestSha256": config_sha, - "provenanceContractSha256": contract_sha, "diskSha256": disk_sha, "uefiVarsSha256": vars_sha, "tpmTreeSha256": tpm_sha, @@ -804,24 +785,19 @@ provenance_command() { provenance_metadata_valid() { local path="$1" vm="$2" baseline="$3" - local contract_sha - contract_sha="$(provenance_contract_sha "$vm")" - python3 - "$path" "$MANIFEST" "$vm" "$baseline" "$contract_sha" <<'PY' -import hashlib, json, re, sys -path, config_path, vm, baseline, contract_sha = sys.argv[1:] + python3 - "$path" "$vm" "$baseline" <<'PY' +import json, re, sys +path, vm, baseline = sys.argv[1:] try: with open(path) as handle: record = json.load(handle) - with open(config_path, "rb") as handle: - config_sha = hashlib.sha256(handle.read()).hexdigest() assert record["schemaVersion"] == 1 assert record["vm"] == vm assert record["baseline"] == baseline + if "labManifestSha256" in record: + assert re.fullmatch(r"[0-9a-f]{64}", record["labManifestSha256"]) if "provenanceContractSha256" in record: - assert record["provenanceContractSha256"] == contract_sha - else: - # Compatibility for provenance captured before contract-scoped hashes. - assert record["labManifestSha256"] == config_sha + assert re.fullmatch(r"[0-9a-f]{64}", record["provenanceContractSha256"]) assert re.fullmatch(r"[0-9a-f]{64}", record["diskSha256"]) assert re.fullmatch(r"[0-9a-f]{64}", record["uefiVarsSha256"]) assert record["tpmTreeSha256"] == "none" or re.fullmatch(r"[0-9a-f]{64}", record["tpmTreeSha256"]) diff --git a/release-notes.d/baseline-fingerprints-and-lease-cleanup.md b/release-notes.d/baseline-fingerprints-and-lease-cleanup.md new file mode 100644 index 0000000..d63a8c2 --- /dev/null +++ b/release-notes.d/baseline-fingerprints-and-lease-cleanup.md @@ -0,0 +1,7 @@ +--- +type: fixed +area: controller +--- + +Keep unchanged VM baselines usable when controller configuration changes, and +honor `--cleanup-baseline` after successful, failed, and interrupted VM leases. diff --git a/tests/lab_test.sh b/tests/lab_test.sh index a13db41..510b995 100755 --- a/tests/lab_test.sh +++ b/tests/lab_test.sh @@ -36,15 +36,10 @@ python3 - "$test_root/lab-manifest.json" "$test_root/golden/manifests/appimage-d import hashlib, json, sys with open(sys.argv[1], "rb") as handle: config_sha = hashlib.sha256(handle.read()).hexdigest() -with open(sys.argv[1]) as handle: - manifest = json.load(handle) -contract = {"schemaVersion": 1, "vm": "appimage", "vmSpec": manifest["vms"]["appimage"]} -contract_sha = hashlib.sha256(json.dumps(contract, sort_keys=True, separators=(",", ":")).encode()).hexdigest() record = { "schemaVersion": 1, "vm": "appimage", "labManifestSha256": config_sha, - "provenanceContractSha256": contract_sha, "diskSha256": "a" * 64, "uefiVarsSha256": "b" * 64, "tpmTreeSha256": "none", @@ -59,55 +54,44 @@ PY python3 -c 'import json,sys; assert json.load(sys.stdin)["ready"] is True' "$test_root/lab.sh" preflight cli release-clean --lanes appimage --json | python3 -c 'import json,sys; assert json.load(sys.stdin)["ready"] is True' -python3 - "$test_root/golden/manifests/appimage-clean.json" <<'PY' +python3 - "$test_root/lab-manifest.json" <<'PY' import json, sys path = sys.argv[1] with open(path) as handle: - record = json.load(handle) -record.pop("provenanceContractSha256") + manifest = json.load(handle) +manifest["profiles"]["dev-fast"]["deb"] = "desktop-e2e-v4" with open(path, "w") as handle: - json.dump(record, handle) + json.dump(manifest, handle, indent=2, sort_keys=True) + handle.write("\n") PY -"$test_root/lab.sh" preflight cli release-clean --lanes appimage --json | +"$test_root/lab.sh" preflight desktop dev-fast --lanes appimage --json | python3 -c 'import json,sys; assert json.load(sys.stdin)["ready"] is True' -python3 - "$test_root/golden/manifests/appimage-clean.json" "$test_root/golden/manifests/appimage-desktop-e2e-v2.json" <<'PY' -import json, sys -path, source = sys.argv[1:] -with open(path) as handle: - record = json.load(handle) -with open(source) as handle: - current = json.load(handle) -record["provenanceContractSha256"] = current["provenanceContractSha256"] -with open(path, "w") as handle: - json.dump(record, handle) -PY python3 - "$test_root/lab-manifest.json" <<'PY' import json, sys path = sys.argv[1] with open(path) as handle: manifest = json.load(handle) -manifest["profiles"]["dev-fast"]["deb"] = "desktop-e2e-v4" +manifest["vms"]["appimage"]["memoryMiB"] += 1 with open(path, "w") as handle: json.dump(manifest, handle, indent=2, sort_keys=True) handle.write("\n") PY "$test_root/lab.sh" preflight cli release-clean --lanes appimage --json | python3 -c 'import json,sys; assert json.load(sys.stdin)["ready"] is True' -python3 - "$test_root/lab-manifest.json" <<'PY' +python3 - "$test_root/golden/manifests/appimage-clean.json" <<'PY' import json, sys path = sys.argv[1] with open(path) as handle: - manifest = json.load(handle) -manifest["vms"]["appimage"]["memoryMiB"] += 1 + record = json.load(handle) +record["diskSha256"] = "not-a-digest" with open(path, "w") as handle: - json.dump(manifest, handle, indent=2, sort_keys=True) - handle.write("\n") + json.dump(record, handle) PY -if changed_contract_result="$("$test_root/lab.sh" preflight cli release-clean --lanes appimage --json)"; then - printf 'VM contract change unexpectedly preserved provenance\n' >&2 +if malformed_fingerprint_result="$("$test_root/lab.sh" preflight cli release-clean --lanes appimage --json)"; then + printf 'Malformed baseline fingerprint unexpectedly passed preflight\n' >&2 exit 1 fi -python3 -c 'import json,sys; assert json.load(sys.stdin)["ready"] is False' <<<"$changed_contract_result" +python3 -c 'import json,sys; assert json.load(sys.stdin)["ready"] is False' <<<"$malformed_fingerprint_result" "$test_root/lab.sh" preflight cli release-clean --lanes macos-arm64 --json | python3 -c 'import json,sys; assert json.load(sys.stdin)["ready"] is True' if "$test_root/lab.sh" start ubuntu >/dev/null 2>&1; then @@ -116,6 +100,9 @@ if "$test_root/lab.sh" start ubuntu >/dev/null 2>&1; then fi "$test_root/lab.sh" lease ubuntu test successful -- bash -c 'test "$OMNIDECK_VM_LAB_VM" = appimage' [[ ! -e "$test_root/discarded/runs/successful-appimage" ]] +"$test_root/lab.sh" lease ubuntu test successful-cleanup --cleanup-baseline clean -- true +grep -Fxq 'reset appimage clean' "$test_root/runtime/fake-actions.log" +[[ ! -e "$test_root/discarded/runs/successful-cleanup-appimage" ]] "$test_root/lab.sh" lease macos test host-successful -- "$test_root/lab.sh" run macos true [[ ! -e "$test_root/discarded/runs/host-successful-macos-arm64" ]] if "$test_root/lab.sh" lease macos test host-cleanup-failed --cleanup-baseline runtime-ready -- bash -c 'exit 7'; then @@ -123,10 +110,11 @@ if "$test_root/lab.sh" lease macos test host-cleanup-failed --cleanup-baseline r exit 1 fi grep -Fxq 'reset macos-arm64 runtime-ready' "$test_root/runtime/fake-actions.log" -if "$test_root/lab.sh" lease debian test failed -- bash -c 'exit 9'; then +if "$test_root/lab.sh" lease debian test failed --cleanup-baseline clean -- bash -c 'exit 9'; then printf 'failing leased command unexpectedly succeeded\n' >&2 exit 1 fi +grep -Fxq 'reset deb clean' "$test_root/runtime/fake-actions.log" [[ -f "$test_root/discarded/runs/failed-deb/metadata.json" ]] "$test_root/lab.sh" lease fedora test held -- bash -c 'sleep 2' &