diff --git a/agent99-contract-check.ps1 b/agent99-contract-check.ps1 index 7748c73f4..baba7ae80 100644 --- a/agent99-contract-check.ps1 +++ b/agent99-contract-check.ps1 @@ -214,6 +214,8 @@ $host110BackupRuntimeContract = [bool]( $host110BackupRuntimeBroker.Contains('"merge-base", "--is-ancestor", $SourceRevision, $sourceHead') -and $host110BackupRuntimeBroker.Contains('executorSha256 = $executorDigest') -and $host110BackupRuntimeBroker.Contains('verifierSha256 = $verifierDigest') -and + $host110BackupRuntimeBroker.Contains('"scripts/ops/backup-health-textfile-exporter.py"') -and + $host110BackupRuntimeBroker.Contains('$ExpectedFileCount = 18') -and $host110BackupRuntimeBroker.Contains('StrictHostKeyChecking=yes') -and $host110BackupRuntimeBroker.Contains('$process.WaitForExit($TimeoutSeconds * 1000)') -and $host110BackupRuntimeBroker.Contains('taskkill.exe /PID $process.Id /T /F') -and diff --git a/agent99-host110-backup-runtime-broker.ps1 b/agent99-host110-backup-runtime-broker.ps1 index 8bccacbde..af94e0f79 100644 --- a/agent99-host110-backup-runtime-broker.ps1 +++ b/agent99-host110-backup-runtime-broker.ps1 @@ -20,8 +20,8 @@ $IdentityFile = Join-Path $ExpectedAgentRoot "keys\agent99_ed25519" $EvidenceRoot = Join-Path $ExpectedAgentRoot "evidence\host110-backup-runtime" $SourceRootBase = Join-Path $ExpectedAgentRoot "deploy" $TransportMutexName = "Global\WoooAgent99Host110BackupRuntimeV2" -$ExpectedFileCount = 17 -$ExpectedRemoteStageFileCount = 20 +$ExpectedFileCount = 18 +$ExpectedRemoteStageFileCount = 21 $RuntimeRelativePaths = @( "scripts/backup/common.sh", "scripts/backup/backup-all.sh", @@ -39,7 +39,8 @@ $RuntimeRelativePaths = @( "scripts/backup/sync-offsite-backups.sh", "scripts/backup/backup-offsite-readiness-gate.sh", "scripts/backup/verify-offsite-full-sync.sh", - "scripts/backup/enforce-latest-only-retention.sh" + "scripts/backup/enforce-latest-only-retention.sh", + "scripts/ops/backup-health-textfile-exporter.py" ) $ExecutorRelativePath = "scripts/backup/host110-backup-runtime-executor.sh" $VerifierRelativePath = "scripts/backup/verify-host110-backup-runtime.py" @@ -281,7 +282,9 @@ function Get-Agent99ExecutorResult { [string]$parsed.mode -ne $ExpectedMode.ToLowerInvariant() -or -not [bool]$parsed.ok -or [string]$parsed.sourceRevision -ne $SourceRevision -or - [int]$parsed.fileCount -ne $ExpectedFileCount + [int]$parsed.fileCount -ne $ExpectedFileCount -or + [int]$parsed.backupScriptFileCount -ne 17 -or + -not [bool]$parsed.backupHealthExporterIncluded ) { throw "host110_${ExpectedMode}_receipt_contract_failed" } @@ -313,6 +316,8 @@ function Get-Agent99VerifierResult { [string]$parsed.sourceRevision -ne $SourceRevision -or [string]$parsed.runId -ne $RunId -or [int]$parsed.fileCount -ne $ExpectedFileCount -or + [int]$parsed.backupScriptFileCount -ne 17 -or + -not [bool]$parsed.backupHealthExporterIncluded -or [bool]$parsed.remoteWritePerformed -or [string]$parsed.verifierSha256 -ne [string]$SourcePackage.verifierSha256 ) { @@ -324,7 +329,7 @@ function Get-Agent99VerifierResult { function Invoke-Agent99ReadOnlyPreflight { param([object]$SourcePackage) - $command = 'test "$(id -un)" = wooo && hostname -I | tr " " "\n" | grep -qx 192.168.0.110 && test -d /backup/scripts && test ! -L /backup/scripts && command -v timeout >/dev/null && command -v flock >/dev/null && command -v python3 >/dev/null && printf HOST110_BACKUP_PREFLIGHT_OK=1' + $command = 'test "$(id -un)" = wooo && hostname -I | tr " " "\n" | grep -qx 192.168.0.110 && test -d /backup/scripts && test ! -L /backup/scripts && test -d /home/wooo/scripts && test ! -L /home/wooo/scripts && command -v timeout >/dev/null && command -v flock >/dev/null && command -v python3 >/dev/null && printf HOST110_BACKUP_PREFLIGHT_OK=1' $transport = Invoke-Agent99BoundedSsh $command 45 if (-not $transport.ok -or ([string]$transport.stdout).Trim() -ne "HOST110_BACKUP_PREFLIGHT_OK=1") { throw "host110_read_only_preflight_failed" @@ -335,6 +340,8 @@ function Invoke-Agent99ReadOnlyPreflight { sourceRevision = $SourceRevision sourceHead = [string]$SourcePackage.sourceHead fileCount = $ExpectedFileCount + backupScriptFileCount = 17 + backupHealthExporterIncluded = $true remoteWritePerformed = $false } } @@ -342,7 +349,7 @@ function Invoke-Agent99ReadOnlyPreflight { function Invoke-Agent99NoWriteVerifier { param([object]$SourcePackage) - $command = "timeout --signal=TERM --kill-after=5s 60s python3 - --manifest-base64 $($SourcePackage.manifestBase64) --destination /backup/scripts --source-revision $SourceRevision --run-id $RunId --expected-verifier-sha256 $($SourcePackage.verifierSha256)" + $command = "timeout --signal=TERM --kill-after=5s 60s python3 - --manifest-base64 $($SourcePackage.manifestBase64) --destination /backup/scripts --exporter-destination /home/wooo/scripts/backup-health-textfile-exporter.py --source-revision $SourceRevision --run-id $RunId --expected-verifier-sha256 $($SourcePackage.verifierSha256)" $transport = Invoke-Agent99BoundedSsh $command 90 ([string]$SourcePackage.verifierPath) return Get-Agent99VerifierResult $transport $SourcePackage } diff --git a/scripts/backup/host110-backup-runtime-executor.sh b/scripts/backup/host110-backup-runtime-executor.sh index e1ef53614..7111f3405 100755 --- a/scripts/backup/host110-backup-runtime-executor.sh +++ b/scripts/backup/host110-backup-runtime-executor.sh @@ -7,6 +7,8 @@ umask 077 readonly EXPECTED_HOST_IP="192.168.0.110" readonly EXPECTED_USER="wooo" readonly DEST_ROOT="/backup/scripts" +readonly EXPORTER_ROOT="/home/wooo/scripts" +readonly EXPORTER_FILE="backup-health-textfile-exporter.py" readonly STATUS_ROOT="/backup/status" readonly STAGE_ROOT="/backup/.agent99-backup-runtime-stage" readonly ROLLBACK_ROOT="/backup/.agent99-backup-runtime-rollback" @@ -31,6 +33,7 @@ readonly -a RUNTIME_FILES=( verify-offsite-full-sync.sh enforce-latest-only-retention.sh ) +readonly -a PAYLOAD_FILES=("${RUNTIME_FILES[@]}" "$EXPORTER_FILE") MODE="" SOURCE_REVISION="" @@ -46,13 +49,14 @@ APPLY_COMPLETE=0 ROLLBACK_VERIFIED=0 declare -A FILE_EXISTED=() declare -A PREVIOUS_DIGEST=() +declare -A PREVIOUS_METADATA=() declare -A EXPECTED_DIGEST=() usage() { printf '%s\n' \ "Usage: $0 --mode check|apply|verify --source-revision SHA --run-id ID --source-stage PATH" \ "The executor is fixed to host 192.168.0.110, an Agent99 run-bound source stage," \ - "and the 17-file /backup/scripts transaction." + "and the 18-file transaction: 17 /backup/scripts files plus the backup health exporter." } fail() { @@ -93,7 +97,7 @@ case "$MODE" in check|apply|verify) ;; *) fail "invalid_mode" ;; esac expected_source_stage="/tmp/agent99-host110-backup-runtime-${RUN_ID}" [ "$SOURCE_STAGE" = "$expected_source_stage" ] || fail "invalid_source_stage" -for command_name in awk bash cp flock grep hostname id install mv pgrep python3 readlink rm sha256sum stat timeout tr; do +for command_name in awk bash cp dirname flock grep hostname id install mv pgrep python3 readlink rm sha256sum stat timeout tr; do command -v "$command_name" >/dev/null 2>&1 || fail "required_command_missing_${command_name}" done @@ -102,8 +106,9 @@ current_user="$(id -un)" hostname -I 2>/dev/null | tr ' ' '\n' | grep -qx "$EXPECTED_HOST_IP" || fail "unexpected_executor_host" [ -d "$SOURCE_STAGE" ] && [ ! -L "$SOURCE_STAGE" ] || fail "canonical_source_stage_unavailable" [ -d "$DEST_ROOT" ] && [ ! -L "$DEST_ROOT" ] || fail "backup_runtime_destination_unavailable" +[ -d "$EXPORTER_ROOT" ] && [ ! -L "$EXPORTER_ROOT" ] || fail "backup_exporter_destination_unavailable" -manifest_rows="$(python3 - "$SOURCE_STAGE/manifest.json" "$SOURCE_REVISION" "$RUN_ID" "${RUNTIME_FILES[@]}" <<'PY' +manifest_rows="$(python3 - "$SOURCE_STAGE/manifest.json" "$SOURCE_REVISION" "$RUN_ID" "${PAYLOAD_FILES[@]}" <<'PY' import json import re import sys @@ -184,7 +189,7 @@ source_digest() { validate_source() { local name source_path expected_digest actual_digest resolved - for name in "${RUNTIME_FILES[@]}"; do + for name in "${PAYLOAD_FILES[@]}"; do source_path="$SOURCE_STAGE/$name" [ -f "$source_path" ] && [ ! -L "$source_path" ] || return 65 resolved="$(readlink -f "$source_path")" @@ -196,10 +201,19 @@ validate_source() { done } +destination_path() { + local name="$1" + if [ "$name" = "$EXPORTER_FILE" ]; then + printf '%s/%s\n' "$EXPORTER_ROOT" "$name" + else + printf '%s/%s\n' "$DEST_ROOT" "$name" + fi +} + verify_destination() { local name dest_path expected_digest actual_digest - for name in "${RUNTIME_FILES[@]}"; do - dest_path="$DEST_ROOT/$name" + for name in "${PAYLOAD_FILES[@]}"; do + dest_path="$(destination_path "$name")" [ -f "$dest_path" ] && [ ! -L "$dest_path" ] || return 68 expected_digest="$(source_digest "$name")" actual_digest="$(working_digest "$dest_path")" @@ -227,7 +241,9 @@ document = { "sourceRevision": sys.argv[3], "sourceHead": sys.argv[4], "runId": sys.argv[5], - "fileCount": 17, + "fileCount": 18, + "backupScriptFileCount": 17, + "backupHealthExporterIncluded": True, "rollbackPerformed": sys.argv[6] == "1", "rollbackVerified": sys.argv[6] == "1", "verifierSha256": sys.argv[7], @@ -248,23 +264,26 @@ PY } record_prestate() { - local name dest_path digest + local name dest_path digest metadata : > "$ROLLBACK_DIR/prestate.tsv" chmod 600 "$ROLLBACK_DIR/prestate.tsv" - for name in "${RUNTIME_FILES[@]}"; do - dest_path="$DEST_ROOT/$name" + for name in "${PAYLOAD_FILES[@]}"; do + dest_path="$(destination_path "$name")" if [ -f "$dest_path" ] && [ ! -L "$dest_path" ]; then [ "$(stat -c '%U:%G' "$dest_path")" = "wooo:wooo" ] || return 71 FILE_EXISTED[$name]=1 digest="$(working_digest "$dest_path")" + metadata="$(stat -c '%u:%g:%a' "$dest_path")" PREVIOUS_DIGEST[$name]="$digest" - printf '%s\t1\t%s\n' "$name" "$digest" >> "$ROLLBACK_DIR/prestate.tsv" + PREVIOUS_METADATA[$name]="$metadata" + printf '%s\t1\t%s\t%s\n' "$name" "$digest" "$metadata" >> "$ROLLBACK_DIR/prestate.tsv" cp -p -- "$dest_path" "$ROLLBACK_DIR/$name" [ "$(working_digest "$ROLLBACK_DIR/$name")" = "$digest" ] || return 72 elif [ ! -e "$dest_path" ]; then FILE_EXISTED[$name]=0 PREVIOUS_DIGEST[$name]="-" - printf '%s\t0\t-\n' "$name" >> "$ROLLBACK_DIR/prestate.tsv" + PREVIOUS_METADATA[$name]="-" + printf '%s\t0\t-\t-\n' "$name" >> "$ROLLBACK_DIR/prestate.tsv" else return 73 fi @@ -272,14 +291,15 @@ record_prestate() { } rollback_transaction() { - local name dest_path backup_path temporary failed=0 + local name dest_path destination_parent backup_path temporary failed=0 set +e - for name in "${RUNTIME_FILES[@]}"; do + for name in "${PAYLOAD_FILES[@]}"; do [ -n "${FILE_EXISTED[$name]+x}" ] || { failed=1; continue; } - dest_path="$DEST_ROOT/$name" + dest_path="$(destination_path "$name")" if [ "${FILE_EXISTED[$name]}" = "1" ]; then backup_path="$ROLLBACK_DIR/$name" - temporary="$DEST_ROOT/.${name}.agent99-rollback-${RUN_ID}" + destination_parent="$(dirname "$dest_path")" + temporary="$destination_parent/.${name}.agent99-rollback-${RUN_ID}" cp -p -- "$backup_path" "$temporary" \ && mv -f -- "$temporary" "$dest_path" \ || failed=1 @@ -287,11 +307,12 @@ rollback_transaction() { rm -f -- "$dest_path" || failed=1 fi done - for name in "${RUNTIME_FILES[@]}"; do - dest_path="$DEST_ROOT/$name" + for name in "${PAYLOAD_FILES[@]}"; do + dest_path="$(destination_path "$name")" if [ "${FILE_EXISTED[$name]:-}" = "1" ]; then [ -f "$dest_path" ] && [ ! -L "$dest_path" ] \ && [ "$(working_digest "$dest_path" 2>/dev/null)" = "${PREVIOUS_DIGEST[$name]:-invalid}" ] \ + && [ "$(stat -c '%u:%g:%a' "$dest_path" 2>/dev/null)" = "${PREVIOUS_METADATA[$name]:-invalid}" ] \ || failed=1 elif [ "${FILE_EXISTED[$name]:-}" = "0" ]; then [ ! -e "$dest_path" ] || failed=1 @@ -328,13 +349,13 @@ cleanup() { validate_source || fail "source_validation_failed" if [ "$MODE" = "check" ]; then - printf '{"schemaVersion":"agent99_host110_backup_runtime_executor_v1","mode":"check","ok":true,"sourceRevision":"%s","sourceHead":"%s","fileCount":17,"remoteWritePerformed":false}\n' "$SOURCE_REVISION" "$SOURCE_HEAD" + printf '{"schemaVersion":"agent99_host110_backup_runtime_executor_v1","mode":"check","ok":true,"sourceRevision":"%s","sourceHead":"%s","fileCount":18,"backupScriptFileCount":17,"backupHealthExporterIncluded":true,"remoteWritePerformed":false}\n' "$SOURCE_REVISION" "$SOURCE_HEAD" exit 0 fi if [ "$MODE" = "verify" ]; then verify_destination || fail "destination_verification_failed" - printf '{"schemaVersion":"agent99_host110_backup_runtime_executor_v1","mode":"verify","ok":true,"sourceRevision":"%s","sourceHead":"%s","fileCount":17,"remoteWritePerformed":false}\n' "$SOURCE_REVISION" "$SOURCE_HEAD" + printf '{"schemaVersion":"agent99_host110_backup_runtime_executor_v1","mode":"verify","ok":true,"sourceRevision":"%s","sourceHead":"%s","fileCount":18,"backupScriptFileCount":17,"backupHealthExporterIncluded":true,"remoteWritePerformed":false}\n' "$SOURCE_REVISION" "$SOURCE_HEAD" exit 0 fi @@ -362,7 +383,7 @@ trap cleanup EXIT trap 'exit 130' INT trap 'exit 143' TERM HUP -for name in "${RUNTIME_FILES[@]}"; do +for name in "${PAYLOAD_FILES[@]}"; do install -m 700 "$SOURCE_STAGE/$name" "$STAGE_DIR/$name" [ "$(working_digest "$STAGE_DIR/$name")" = "$(source_digest "$name")" ] || fail "stage_identity_failed" validate_file "$STAGE_DIR/$name" || fail "stage_validation_failed" @@ -374,16 +395,19 @@ APPLY_STARTED=1 # common.sh is promoted first. Every managed mutating backup entrypoint then # takes the shared side of LOCK_PATH before doing work, while this process holds # the exclusive side until the independent verifier has completed. -for name in "${RUNTIME_FILES[@]}"; do - temporary="$DEST_ROOT/.${name}.agent99-${RUN_ID}" +for name in "${PAYLOAD_FILES[@]}"; do + dest_path="$(destination_path "$name")" + destination_parent="$(dirname "$dest_path")" + temporary="$destination_parent/.${name}.agent99-${RUN_ID}" install -m 755 "$STAGE_DIR/$name" "$temporary" - mv -f -- "$temporary" "$DEST_ROOT/$name" + mv -f -- "$temporary" "$dest_path" done verify_destination || fail "post_apply_verification_failed" verifier_result="$(BACKUP_RUNTIME_DEPLOY_CONTEXT=1 python3 "$SOURCE_STAGE/verify-host110-backup-runtime.py" \ --manifest "$SOURCE_STAGE/manifest.json" \ --destination "$DEST_ROOT" \ + --exporter-destination "$EXPORTER_ROOT/$EXPORTER_FILE" \ --source-revision "$SOURCE_REVISION" \ --run-id "$RUN_ID" \ --expected-verifier-sha256 "$VERIFIER_DIGEST")" || fail "independent_verifier_failed" @@ -400,7 +424,9 @@ if not ( and row.get("ok") is True and row.get("sourceRevision") == sys.argv[2] and row.get("runId") == sys.argv[3] - and row.get("fileCount") == 17 + and row.get("fileCount") == 18 + and row.get("backupScriptFileCount") == 17 + and row.get("backupHealthExporterIncluded") is True and row.get("remoteWritePerformed") is False and row.get("selfIdentityVerified") is True ): @@ -423,7 +449,9 @@ print(json.dumps({ "ok": True, "sourceRevision": sys.argv[1], "sourceHead": sys.argv[2], - "fileCount": 17, + "fileCount": 18, + "backupScriptFileCount": 17, + "backupHealthExporterIncluded": True, "rollbackPerformed": False, "receipt": sys.argv[3], "verifier": json.loads(sys.argv[4]), diff --git a/scripts/backup/verify-host110-backup-runtime.py b/scripts/backup/verify-host110-backup-runtime.py index 826750e77..1c9afb0e3 100755 --- a/scripts/backup/verify-host110-backup-runtime.py +++ b/scripts/backup/verify-host110-backup-runtime.py @@ -16,8 +16,9 @@ from pathlib import Path EXPECTED_DESTINATION = Path("/backup/scripts") +EXPECTED_EXPORTER_DESTINATION = Path("/home/wooo/scripts/backup-health-textfile-exporter.py") RUNTIME_LOCK = Path("/tmp/agent99-host110-backup-runtime.lock") -EXPECTED_FILES = ( +EXPECTED_BACKUP_FILES = ( "common.sh", "backup-all.sh", "backup-host188-products.sh", @@ -36,6 +37,8 @@ EXPECTED_FILES = ( "verify-offsite-full-sync.sh", "enforce-latest-only-retention.sh", ) +EXPORTER_FILE = "backup-health-textfile-exporter.py" +EXPECTED_FILES = EXPECTED_BACKUP_FILES + (EXPORTER_FILE,) def fail(reason: str) -> None: @@ -78,6 +81,7 @@ def main() -> None: manifest_source.add_argument("--manifest") manifest_source.add_argument("--manifest-base64") parser.add_argument("--destination", required=True) + parser.add_argument("--exporter-destination", required=True) parser.add_argument("--source-revision", required=True) parser.add_argument("--run-id", required=True) parser.add_argument("--expected-verifier-sha256", required=True) @@ -90,6 +94,9 @@ def main() -> None: destination = Path(args.destination) if destination != EXPECTED_DESTINATION or destination.is_symlink() or not destination.is_dir(): fail("invalid_destination") + exporter_destination = Path(args.exporter_destination) + if exporter_destination != EXPECTED_EXPORTER_DESTINATION: + fail("invalid_exporter_destination") runtime_lock = acquire_runtime_read_lock(destination) if not re.fullmatch(r"[0-9a-f]{64}", args.expected_verifier_sha256): @@ -131,11 +138,14 @@ def main() -> None: root = destination.resolve(strict=True) for name in EXPECTED_FILES: - path = destination / name + path = exporter_destination if name == EXPORTER_FILE else destination / name if path.is_symlink() or not path.is_file(): fail("runtime_file_type_failed") resolved = path.resolve(strict=True) - if resolved.parent != root: + if name == EXPORTER_FILE: + if resolved != exporter_destination: + fail("runtime_file_boundary_failed") + elif resolved.parent != root: fail("runtime_file_boundary_failed") stat = resolved.stat() if stat.st_uid != os.getuid() or stat.st_gid != os.getgid() or stat.st_mode & 0o777 != 0o755: @@ -149,7 +159,10 @@ def main() -> None: "sourceRevision": args.source_revision, "runId": args.run_id, "fileCount": len(EXPECTED_FILES), + "backupScriptFileCount": len(EXPECTED_BACKUP_FILES), + "backupHealthExporterIncluded": True, "destination": str(EXPECTED_DESTINATION), + "exporterDestination": str(EXPECTED_EXPORTER_DESTINATION), "remoteWritePerformed": False, "secretValuesRead": False, "selfIdentityVerified": self_identity_verified, diff --git a/scripts/reboot-recovery/tests/test_agent99_host110_backup_runtime_broker.py b/scripts/reboot-recovery/tests/test_agent99_host110_backup_runtime_broker.py index 00ac91317..3bea09c33 100644 --- a/scripts/reboot-recovery/tests/test_agent99_host110_backup_runtime_broker.py +++ b/scripts/reboot-recovery/tests/test_agent99_host110_backup_runtime_broker.py @@ -64,6 +64,7 @@ def test_windows99_broker_fetches_exact_gitea_revision_and_is_bounded() -> None: assert '"checkout", "--quiet", $SourceRevision, "--"' in source assert 'executorSha256 = $executorDigest' in source assert 'verifierSha256 = $verifierDigest' in source + assert '"scripts/ops/backup-health-textfile-exporter.py"' in source assert "/home/wooo/awoooi" not in source assert '"StrictHostKeyChecking=yes"' in source assert '"NumberOfPasswordPrompts=0"' in source @@ -98,6 +99,9 @@ def test_host110_executor_is_manifest_bound_atomic_and_fail_closed() -> None: assert 'readonly EXPECTED_HOST_IP="192.168.0.110"' in source assert 'readonly EXPECTED_USER="wooo"' in source assert 'readonly DEST_ROOT="/backup/scripts"' in source + assert 'readonly EXPORTER_ROOT="/home/wooo/scripts"' in source + assert 'readonly EXPORTER_FILE="backup-health-textfile-exporter.py"' in source + assert 'readonly -a PAYLOAD_FILES=("${RUNTIME_FILES[@]}" "$EXPORTER_FILE")' in source assert '--source-stage' in source assert 'document.get("executorSha256"' in source assert 'document.get("verifierSha256"' in source @@ -105,16 +109,20 @@ def test_host110_executor_is_manifest_bound_atomic_and_fail_closed() -> None: assert 'verifier_identity_failed' in source assert 'flock -x -w 30 9' in source assert 'legacy_backup_runtime_active' in source - assert source.index("for name in \"${RUNTIME_FILES[@]}\"") < source.index("mv -f -- \"$temporary\"") + assert source.index("for name in \"${PAYLOAD_FILES[@]}\"") < source.index("mv -f -- \"$temporary\"") assert 'rollback_unverified' in source assert 'failed_rolled_back' in source assert 'working_digest "$dest_path"' in source + assert 'PREVIOUS_METADATA' in source + assert "stat -c '%u:%g:%a'" in source assert 'os.link(temporary, path)' in source assert 'run_identity_receipt_exists' in source assert 'run_identity_stage_exists' in source assert 'run_identity_rollback_exists' in source assert 'verify-host110-backup-runtime.py' in source assert 'selfIdentityVerified' in source + assert '"backupScriptFileCount": 17' in source + assert '"backupHealthExporterIncluded": True' in source assert '"productionServiceRestarted": False' in source assert '"secretValuesRead": False' in source assert "sudo " not in source @@ -139,12 +147,16 @@ def test_independent_verifier_accepts_exact_bundle_without_writing(tmp_path, mon verifier = _load_verifier() destination = tmp_path / "scripts" destination.mkdir() + exporter_destination = tmp_path / verifier.EXPORTER_FILE rows = [] - for name in verifier.EXPECTED_FILES: + for name in verifier.EXPECTED_BACKUP_FILES: path = destination / name path.write_text(f"runtime:{name}\n", encoding="utf-8") path.chmod(0o755) rows.append({"name": name, "sha256": _sha256(path)}) + exporter_destination.write_text("exporter\n", encoding="utf-8") + exporter_destination.chmod(0o755) + rows.append({"name": verifier.EXPORTER_FILE, "sha256": _sha256(exporter_destination)}) verifier_digest = _sha256(VERIFIER) source_revision = "a" * 40 run_id = "unit-exact" @@ -159,8 +171,12 @@ def test_independent_verifier_accepts_exact_bundle_without_writing(tmp_path, mon } manifest_path = tmp_path / "manifest.json" manifest_path.write_text(json.dumps(manifest), encoding="utf-8") - before = {path.name: (path.stat().st_mtime_ns, _sha256(path)) for path in destination.iterdir()} + before = { + path.name: (path.stat().st_mtime_ns, _sha256(path)) + for path in [*destination.iterdir(), exporter_destination] + } monkeypatch.setattr(verifier, "EXPECTED_DESTINATION", destination) + monkeypatch.setattr(verifier, "EXPECTED_EXPORTER_DESTINATION", exporter_destination) monkeypatch.setattr( sys, "argv", @@ -170,6 +186,8 @@ def test_independent_verifier_accepts_exact_bundle_without_writing(tmp_path, mon str(manifest_path), "--destination", str(destination), + "--exporter-destination", + str(exporter_destination), "--source-revision", source_revision, "--run-id", @@ -182,8 +200,14 @@ def test_independent_verifier_accepts_exact_bundle_without_writing(tmp_path, mon verifier.main() result = json.loads(capsys.readouterr().out) - after = {path.name: (path.stat().st_mtime_ns, _sha256(path)) for path in destination.iterdir()} + after = { + path.name: (path.stat().st_mtime_ns, _sha256(path)) + for path in [*destination.iterdir(), exporter_destination] + } assert result["ok"] is True + assert result["fileCount"] == 18 + assert result["backupScriptFileCount"] == 17 + assert result["backupHealthExporterIncluded"] is True assert result["remoteWritePerformed"] is False assert result["selfIdentityVerified"] is True assert before == after @@ -193,13 +217,17 @@ def test_independent_verifier_rejects_drifted_bundle(tmp_path, monkeypatch, caps verifier = _load_verifier() destination = tmp_path / "scripts" destination.mkdir() + exporter_destination = tmp_path / verifier.EXPORTER_FILE rows = [] - for name in verifier.EXPECTED_FILES: + for name in verifier.EXPECTED_BACKUP_FILES: path = destination / name path.write_text(f"runtime:{name}\n", encoding="utf-8") path.chmod(0o755) rows.append({"name": name, "sha256": _sha256(path)}) - (destination / verifier.EXPECTED_FILES[0]).write_text("drifted\n", encoding="utf-8") + exporter_destination.write_text("exporter\n", encoding="utf-8") + exporter_destination.chmod(0o755) + rows.append({"name": verifier.EXPORTER_FILE, "sha256": _sha256(exporter_destination)}) + (destination / verifier.EXPECTED_BACKUP_FILES[0]).write_text("drifted\n", encoding="utf-8") verifier_digest = _sha256(VERIFIER) source_revision = "d" * 40 run_id = "unit-drift" @@ -214,6 +242,7 @@ def test_independent_verifier_rejects_drifted_bundle(tmp_path, monkeypatch, caps } encoded = base64.b64encode(json.dumps(manifest).encode()).decode() monkeypatch.setattr(verifier, "EXPECTED_DESTINATION", destination) + monkeypatch.setattr(verifier, "EXPECTED_EXPORTER_DESTINATION", exporter_destination) monkeypatch.setattr( sys, "argv", @@ -223,6 +252,8 @@ def test_independent_verifier_rejects_drifted_bundle(tmp_path, monkeypatch, caps encoded, "--destination", str(destination), + "--exporter-destination", + str(exporter_destination), "--source-revision", source_revision, "--run-id",