Harden RF node control and agent updates
This commit is contained in:
429
tests/test_rf001_installer_transaction.py
Normal file
429
tests/test_rf001_installer_transaction.py
Normal file
@@ -0,0 +1,429 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user