430 lines
15 KiB
Python
430 lines
15 KiB
Python
"""Software-only checks for transactional bootstrap target deployment."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import shutil
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
|
|
BOOTSTRAP_ID = "rf001-bootstrap-2026.09.29.4"
|
|
RELEASE_ID = "rf001-agent-2026.09.29.1"
|
|
AGENT_VERSION = "2026.09.29.1"
|
|
RELEASE_PREFIX = f"versions/{RELEASE_ID}/"
|
|
FUNCTIONS = (
|
|
"Get-LowerSha256",
|
|
"Assert-ExactReleasePath",
|
|
"Get-ReleaseDirectoryState",
|
|
"Assert-ReleaseMetadata",
|
|
"New-UniqueQuarantineDestination",
|
|
"Write-CreateNewUtf8Json",
|
|
"Move-ConflictingTargetToQuarantine",
|
|
"Move-FailedReleasePathToQuarantine",
|
|
"Install-CleanRelease",
|
|
"Prepare-TargetRelease",
|
|
)
|
|
|
|
|
|
def _powershell() -> str:
|
|
executable = shutil.which("powershell.exe")
|
|
if executable is None:
|
|
pytest.skip("Windows PowerShell is required for installer transaction tests")
|
|
return executable
|
|
|
|
|
|
def _installer() -> Path:
|
|
return Path(__file__).parents[1] / "tools" / "install_rf001_rx_autostart.ps1"
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as stream:
|
|
while block := stream.read(1024 * 1024):
|
|
digest.update(block)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _release_fixture(tmp_path: Path) -> tuple[Path, Path, Path, Path]:
|
|
install_root = tmp_path / "install"
|
|
versions_root = install_root / "versions"
|
|
target = versions_root / RELEASE_ID
|
|
source = tmp_path / "signed-bootstrap" / "versions" / RELEASE_ID
|
|
tools = source / "app" / "tools"
|
|
tools.mkdir(parents=True)
|
|
(tools / "rf001_rx_http_agent.py").write_text(
|
|
f'AGENT_VERSION = "{AGENT_VERSION}"\n', encoding="utf-8"
|
|
)
|
|
(tools / "rf001_agent_update.py").write_text(
|
|
"# fixed signed updater fixture\n", encoding="utf-8"
|
|
)
|
|
ready = {
|
|
"agent_version": AGENT_VERSION,
|
|
"bootstrap_id": BOOTSTRAP_ID,
|
|
"hardware_accessed": False,
|
|
"protocol_version": 1,
|
|
"release_id": RELEASE_ID,
|
|
"rf_tx": 0,
|
|
"schema_version": 1,
|
|
"status": "READY",
|
|
}
|
|
(source / "READY.json").write_text(
|
|
json.dumps(ready, sort_keys=True, separators=(",", ":")) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
rows = []
|
|
for path in sorted(item for item in source.rglob("*") if item.is_file()):
|
|
relative = path.relative_to(source).as_posix()
|
|
rows.append(
|
|
{
|
|
"path": RELEASE_PREFIX + relative,
|
|
"bytes": path.stat().st_size,
|
|
"sha256": _sha256(path),
|
|
}
|
|
)
|
|
manifest = tmp_path / "release_rows.json"
|
|
manifest.write_text(json.dumps(rows), encoding="utf-8")
|
|
versions_root.mkdir(parents=True)
|
|
return source, install_root, target, manifest
|
|
|
|
|
|
def _function_loader() -> str:
|
|
names = ",".join(f'"{name}"' for name in FUNCTIONS)
|
|
return rf"""
|
|
$ErrorActionPreference = "Stop"
|
|
$tokens = $null
|
|
$parseErrors = $null
|
|
$syntax = [Management.Automation.Language.Parser]::ParseFile(
|
|
$env:RF001_INSTALLER,
|
|
[ref]$tokens,
|
|
[ref]$parseErrors
|
|
)
|
|
if ($parseErrors.Count -ne 0) {{
|
|
throw "Installer contains PowerShell parse errors"
|
|
}}
|
|
foreach ($name in @({names})) {{
|
|
$matches = @($syntax.FindAll({{
|
|
param($node)
|
|
$node -is [Management.Automation.Language.FunctionDefinitionAst] -and
|
|
$node.Name -eq $name
|
|
}}, $true))
|
|
if ($matches.Count -ne 1) {{
|
|
throw "Installer function is absent or ambiguous: $name"
|
|
}}
|
|
. ([scriptblock]::Create($matches[0].Extent.Text))
|
|
}}
|
|
$releaseRows = @(Get-Content -LiteralPath $env:RF001_ROWS -Raw -Encoding UTF8 | ConvertFrom-Json)
|
|
$installRoot = $env:RF001_INSTALL_ROOT
|
|
$versionsRoot = Join-Path $installRoot "versions"
|
|
$sourceRelease = $env:RF001_SOURCE_RELEASE
|
|
$targetRelease = Join-Path $versionsRoot "{RELEASE_ID}"
|
|
"""
|
|
|
|
|
|
def _run_powershell(
|
|
body: str,
|
|
*,
|
|
source: Path,
|
|
install_root: Path,
|
|
manifest: Path,
|
|
check: bool = True,
|
|
) -> subprocess.CompletedProcess[str]:
|
|
environment = os.environ.copy()
|
|
environment.update(
|
|
{
|
|
"RF001_INSTALLER": str(_installer()),
|
|
"RF001_ROWS": str(manifest),
|
|
"RF001_INSTALL_ROOT": str(install_root),
|
|
"RF001_SOURCE_RELEASE": str(source),
|
|
}
|
|
)
|
|
completed = subprocess.run(
|
|
[_powershell(), "-NoProfile", "-NonInteractive", "-Command", _function_loader() + body],
|
|
check=False,
|
|
capture_output=True,
|
|
encoding="utf-8",
|
|
env=environment,
|
|
)
|
|
if check and completed.returncode != 0:
|
|
raise AssertionError(
|
|
f"PowerShell transaction harness failed ({completed.returncode}):\n"
|
|
f"STDOUT:\n{completed.stdout}\nSTDERR:\n{completed.stderr}"
|
|
)
|
|
return completed
|
|
|
|
|
|
def _prepare(
|
|
source: Path,
|
|
install_root: Path,
|
|
manifest: Path,
|
|
) -> dict[str, object]:
|
|
body = rf"""
|
|
$result = Prepare-TargetRelease `
|
|
-InstallRoot $installRoot `
|
|
-VersionsRoot $versionsRoot `
|
|
-SourceRelease $sourceRelease `
|
|
-TargetRelease $targetRelease `
|
|
-ReleaseId "{RELEASE_ID}" `
|
|
-BootstrapId "{BOOTSTRAP_ID}" `
|
|
-AgentVersion "{AGENT_VERSION}" `
|
|
-ProtocolVersion 1 `
|
|
-ReleaseRows $releaseRows `
|
|
-ReleasePrefix "{RELEASE_PREFIX}"
|
|
$final = Get-ReleaseDirectoryState `
|
|
-TargetRelease $targetRelease `
|
|
-ReleaseRows $releaseRows `
|
|
-ReleasePrefix "{RELEASE_PREFIX}"
|
|
[ordered]@{{
|
|
result = $result
|
|
final_state = $final.state
|
|
}} | ConvertTo-Json -Depth 10 -Compress
|
|
"""
|
|
completed = _run_powershell(
|
|
body,
|
|
source=source,
|
|
install_root=install_root,
|
|
manifest=manifest,
|
|
)
|
|
value = json.loads(completed.stdout.lstrip("\ufeff"))
|
|
assert isinstance(value, dict)
|
|
return value
|
|
|
|
|
|
def _snapshot(root: Path) -> dict[str, tuple[int, int, str]]:
|
|
return {
|
|
path.relative_to(root).as_posix(): (
|
|
path.stat().st_size,
|
|
path.stat().st_mtime_ns,
|
|
_sha256(path),
|
|
)
|
|
for path in root.rglob("*")
|
|
if path.is_file()
|
|
}
|
|
|
|
|
|
def _assert_matches_manifest(target: Path, manifest: Path) -> None:
|
|
rows = json.loads(manifest.read_text(encoding="utf-8"))
|
|
expected = {
|
|
str(row["path"])[len(RELEASE_PREFIX) :]: (
|
|
int(row["bytes"]),
|
|
str(row["sha256"]),
|
|
)
|
|
for row in rows
|
|
}
|
|
actual = {
|
|
path.relative_to(target).as_posix(): (path.stat().st_size, _sha256(path))
|
|
for path in target.rglob("*")
|
|
if path.is_file()
|
|
}
|
|
assert actual == expected
|
|
|
|
|
|
def test_absent_target_is_deployed_from_signed_manifest(tmp_path: Path) -> None:
|
|
source, install_root, target, manifest = _release_fixture(tmp_path)
|
|
report = _prepare(source, install_root, manifest)
|
|
assert report["result"]["initial_state"] == "TARGET_ABSENT"
|
|
assert report["result"]["reused"] is False
|
|
assert report["final_state"] == "TARGET_VERIFIED"
|
|
_assert_matches_manifest(target, manifest)
|
|
|
|
|
|
def test_exact_target_is_reused_without_writing_bytes(tmp_path: Path) -> None:
|
|
source, install_root, target, manifest = _release_fixture(tmp_path)
|
|
shutil.copytree(source, target)
|
|
before = _snapshot(target)
|
|
report = _prepare(source, install_root, manifest)
|
|
assert report["result"]["initial_state"] == "TARGET_VERIFIED"
|
|
assert report["result"]["reused"] is True
|
|
assert _snapshot(target) == before
|
|
|
|
|
|
def test_mixed_release_is_quarantined_before_clean_deployment(tmp_path: Path) -> None:
|
|
source, install_root, target, manifest = _release_fixture(tmp_path)
|
|
shutil.copytree(source, target)
|
|
agent = target / "app" / "tools" / "rf001_rx_http_agent.py"
|
|
agent.write_text('AGENT_VERSION = "2026.09.29.2"\n', encoding="utf-8")
|
|
ready = json.loads((target / "READY.json").read_text(encoding="utf-8"))
|
|
ready["bootstrap_id"] = "rf001-bootstrap-2026.09.29.2"
|
|
(target / "READY.json").write_text(json.dumps(ready), encoding="utf-8")
|
|
conflict_hash = _sha256(agent)
|
|
|
|
report = _prepare(source, install_root, manifest)
|
|
result = report["result"]
|
|
assert result["initial_state"] == "TARGET_CONFLICT"
|
|
quarantine = Path(str(result["quarantine_path"]))
|
|
diagnostic_path = Path(str(result["quarantine_diagnostic"]))
|
|
assert quarantine.is_dir()
|
|
assert _sha256(quarantine / "app" / "tools" / agent.name) == conflict_hash
|
|
diagnostic = json.loads(diagnostic_path.read_text(encoding="utf-8-sig"))
|
|
assert diagnostic["bootstrap_id"] == BOOTSTRAP_ID
|
|
assert diagnostic["expected_release_id"] == RELEASE_ID
|
|
assert {row["kind"] for row in diagnostic["mismatches"]} >= {
|
|
"SHA256_MISMATCH"
|
|
}
|
|
assert any(row["sha256"] == conflict_hash for row in diagnostic["conflicting_files"])
|
|
_assert_matches_manifest(target, manifest)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("case", "expected_kind"),
|
|
(
|
|
("ready_mismatch", "SHA256_MISMATCH"),
|
|
("python_mismatch", "SHA256_MISMATCH"),
|
|
("missing", "MISSING"),
|
|
("unexpected", "UNEXPECTED"),
|
|
),
|
|
)
|
|
def test_each_file_set_conflict_is_quarantined(
|
|
tmp_path: Path,
|
|
case: str,
|
|
expected_kind: str,
|
|
) -> None:
|
|
source, install_root, target, manifest = _release_fixture(tmp_path)
|
|
shutil.copytree(source, target)
|
|
if case == "ready_mismatch":
|
|
(target / "READY.json").write_bytes(b"{}\n")
|
|
elif case == "python_mismatch":
|
|
(target / "app" / "tools" / "rf001_agent_update.py").write_bytes(b"changed\n")
|
|
elif case == "missing":
|
|
(target / "app" / "tools" / "rf001_agent_update.py").unlink()
|
|
else:
|
|
(target / "app" / "tools" / "unexpected.py").write_bytes(b"unexpected\n")
|
|
|
|
result = _prepare(source, install_root, manifest)["result"]
|
|
diagnostic = json.loads(
|
|
Path(str(result["quarantine_diagnostic"])).read_text(encoding="utf-8-sig")
|
|
)
|
|
assert expected_kind in {row["kind"] for row in diagnostic["mismatches"]}
|
|
_assert_matches_manifest(target, manifest)
|
|
|
|
|
|
def test_failure_after_quarantine_leaves_canonical_target_absent(tmp_path: Path) -> None:
|
|
source, install_root, target, manifest = _release_fixture(tmp_path)
|
|
shutil.copytree(source, target)
|
|
(target / "READY.json").write_bytes(b"conflict\n")
|
|
body = rf"""
|
|
$state = Get-ReleaseDirectoryState `
|
|
-TargetRelease $targetRelease `
|
|
-ReleaseRows $releaseRows `
|
|
-ReleasePrefix "{RELEASE_PREFIX}"
|
|
$quarantine = Move-ConflictingTargetToQuarantine `
|
|
-InstallRoot $installRoot `
|
|
-VersionsRoot $versionsRoot `
|
|
-TargetRelease $targetRelease `
|
|
-ReleaseId "{RELEASE_ID}" `
|
|
-BootstrapId "{BOOTSTRAP_ID}" `
|
|
-State $state
|
|
try {{
|
|
throw "fixed failure after quarantine before target creation"
|
|
}}
|
|
catch {{
|
|
[ordered]@{{
|
|
error = $_.Exception.Message
|
|
canonical_exists = Test-Path -LiteralPath $targetRelease
|
|
quarantine_exists = Test-Path -LiteralPath $quarantine.path -PathType Container
|
|
diagnostic_exists = Test-Path -LiteralPath $quarantine.diagnostic_path -PathType Leaf
|
|
}} | ConvertTo-Json -Compress
|
|
}}
|
|
"""
|
|
completed = _run_powershell(
|
|
body, source=source, install_root=install_root, manifest=manifest
|
|
)
|
|
report = json.loads(completed.stdout.lstrip("\ufeff"))
|
|
assert report == {
|
|
"error": "fixed failure after quarantine before target creation",
|
|
"canonical_exists": False,
|
|
"quarantine_exists": True,
|
|
"diagnostic_exists": True,
|
|
}
|
|
|
|
|
|
def test_failure_during_deployment_quarantines_partial_staging(tmp_path: Path) -> None:
|
|
source, install_root, target, manifest = _release_fixture(tmp_path)
|
|
(source / "READY.json").unlink()
|
|
body = rf"""
|
|
try {{
|
|
Install-CleanRelease `
|
|
-InstallRoot $installRoot `
|
|
-VersionsRoot $versionsRoot `
|
|
-SourceRelease $sourceRelease `
|
|
-TargetRelease $targetRelease `
|
|
-ReleaseId "{RELEASE_ID}" `
|
|
-BootstrapId "{BOOTSTRAP_ID}" `
|
|
-AgentVersion "{AGENT_VERSION}" `
|
|
-ProtocolVersion 1 `
|
|
-ReleaseRows $releaseRows `
|
|
-ReleasePrefix "{RELEASE_PREFIX}" | Out-Null
|
|
throw "deployment unexpectedly succeeded"
|
|
}}
|
|
catch {{
|
|
$failed = @(Get-ChildItem -LiteralPath (Join-Path $installRoot "quarantine") -Directory |
|
|
Where-Object {{ $_.Name -like "{RELEASE_ID}_failed_staging_*" }})
|
|
[ordered]@{{
|
|
error = $_.Exception.Message
|
|
canonical_exists = Test-Path -LiteralPath $targetRelease
|
|
failed_staging_count = $failed.Count
|
|
}} | ConvertTo-Json -Compress
|
|
}}
|
|
"""
|
|
completed = _run_powershell(
|
|
body, source=source, install_root=install_root, manifest=manifest
|
|
)
|
|
report = json.loads(completed.stdout.lstrip("\ufeff"))
|
|
assert "Clean release deployment failed" in report["error"]
|
|
assert report["canonical_exists"] is False
|
|
assert report["failed_staging_count"] == 1
|
|
|
|
|
|
def test_installer_switch_failure_restores_saved_task_and_launcher_state() -> None:
|
|
text = _installer().read_text(encoding="utf-8-sig")
|
|
switch = text.index("Set-ScheduledTask -TaskName $taskName -Action $action")
|
|
catch = text.index("catch {", switch)
|
|
restore_task = text.index("Register-ScheduledTask -TaskName $taskName -Xml $xml -Force", catch)
|
|
restore_files = text.index('foreach ($name in @("launcher"', catch)
|
|
restart_old = text.index("if ($wasRunning)", catch)
|
|
assert switch < catch < restore_task < restore_files < restart_old
|
|
|
|
|
|
def test_successful_reinstall_is_idempotent(tmp_path: Path) -> None:
|
|
source, install_root, target, manifest = _release_fixture(tmp_path)
|
|
first = _prepare(source, install_root, manifest)
|
|
before = _snapshot(target)
|
|
second = _prepare(source, install_root, manifest)
|
|
assert first["result"]["initial_state"] == "TARGET_ABSENT"
|
|
assert second["result"]["initial_state"] == "TARGET_VERIFIED"
|
|
assert second["result"]["reused"] is True
|
|
assert _snapshot(target) == before
|
|
quarantine = install_root / "quarantine"
|
|
assert not quarantine.exists() or not any(quarantine.iterdir())
|
|
|
|
|
|
def test_existing_quarantine_is_never_overwritten(tmp_path: Path) -> None:
|
|
source, install_root, target, manifest = _release_fixture(tmp_path)
|
|
shutil.copytree(source, target)
|
|
(target / "READY.json").write_bytes(b"conflict\n")
|
|
sentinel = install_root / "quarantine" / "existing-audit" / "sentinel.bin"
|
|
sentinel.parent.mkdir(parents=True)
|
|
sentinel.write_bytes(b"must remain unchanged")
|
|
before = _snapshot(sentinel.parent)
|
|
result = _prepare(source, install_root, manifest)["result"]
|
|
assert _snapshot(sentinel.parent) == before
|
|
assert Path(str(result["quarantine_path"])) != sentinel.parent
|
|
|
|
|
|
def test_matching_version_strings_do_not_validate_changed_bytes(tmp_path: Path) -> None:
|
|
source, install_root, target, manifest = _release_fixture(tmp_path)
|
|
shutil.copytree(source, target)
|
|
agent = target / "app" / "tools" / "rf001_rx_http_agent.py"
|
|
agent.write_text(
|
|
f'# changed bytes, same identity strings\nAGENT_VERSION = "{AGENT_VERSION}"\n',
|
|
encoding="utf-8",
|
|
)
|
|
result = _prepare(source, install_root, manifest)["result"]
|
|
assert result["initial_state"] == "TARGET_CONFLICT"
|
|
assert result["reused"] is False
|
|
_assert_matches_manifest(target, manifest)
|