Pulse/scripts/release_control/internal/unified_agent_rc_rehearsal_test.py
rcourtman 65cdcdd457 Add v5 agent self-update runtime proof
Extend the unified-agent RC rehearsal with an optional local v5-to-v6 process-swap check.

Record the v5.1.34 to v6.0.0-rc.6 proof result for the upgrade-continuity gate.
2026-06-03 09:39:25 +01:00

702 lines
25 KiB
Python

from __future__ import annotations
import contextlib
import io
import json
import shutil
import tempfile
import threading
import unittest
from dataclasses import asdict
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from unified_agent_rc_rehearsal import (
agent_binary_asset_name,
check_local_runtime_self_update,
check_update_info,
main,
render_markdown_report,
render_text,
release_asset_url,
run_rehearsal,
summarize_http_error_body,
)
def run_main(argv: list[str]) -> int:
with contextlib.redirect_stdout(io.StringIO()):
return main(argv)
class _FixtureHandler(BaseHTTPRequestHandler):
routes: dict[str, tuple[int, bytes, dict[str, str]]] = {}
request_headers: dict[str, dict[str, str]] = {}
def do_GET(self) -> None: # noqa: N802
self.request_headers[self.path] = {
key.lower(): value for key, value in self.headers.items()
}
status, body, headers = self.routes.get(self.path, (404, b"missing", {}))
self.send_response(status)
for key, value in headers.items():
self.send_header(key, value)
self.end_headers()
self.wfile.write(body)
def log_message(self, format: str, *args: object) -> None: # noqa: A003
return
class UnifiedAgentRCRehearsalTest(unittest.TestCase):
def setUp(self) -> None:
self.server = ThreadingHTTPServer(("127.0.0.1", 0), _FixtureHandler)
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
self.thread.start()
self.base_url = f"http://127.0.0.1:{self.server.server_port}"
def tearDown(self) -> None:
self.server.shutdown()
self.server.server_close()
self.thread.join(timeout=2)
def set_routes(self, routes: dict[str, tuple[int, bytes, dict[str, str]]]) -> None:
_FixtureHandler.routes = routes
_FixtureHandler.request_headers = {}
def write_fake_runtime_agents(self, root: Path) -> tuple[Path, Path]:
v5 = root / "fake-v5-agent"
v6 = root / "fake-v6-agent"
v5.write_text(
"""#!/usr/bin/env python3
import json
import os
import sys
import urllib.request
def arg(name):
if name in sys.argv:
index = sys.argv.index(name)
if index + 1 < len(sys.argv):
return sys.argv[index + 1]
return ""
if "--version" in sys.argv:
print("v5.1.34")
sys.exit(0)
base_url = arg("--url").rstrip("/")
agent_id = arg("--agent-id") or "agent-v5"
def post(path, payload):
request = urllib.request.Request(
base_url + path,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(request, timeout=5).read()
post("/api/agents/host/report", {"agent": {"id": agent_id, "type": "unified", "version": "v5.1.34"}})
urllib.request.urlopen(base_url + "/api/agent/version", timeout=5).read()
download = urllib.request.urlopen(base_url + "/download/pulse-agent?arch=darwin-arm64", timeout=5).read()
with open(sys.argv[0], "wb") as handle:
handle.write(download)
os.chmod(sys.argv[0], 0o755)
with open(os.path.join(os.path.dirname(sys.argv[0]), ".pulse-update-info"), "w", encoding="utf-8") as handle:
handle.write("v5.1.34")
os.execv(sys.argv[0], sys.argv)
""",
encoding="utf-8",
)
v6.write_text(
"""#!/usr/bin/env python3
import json
import os
import sys
import time
import urllib.request
def arg(name):
if name in sys.argv:
index = sys.argv.index(name)
if index + 1 < len(sys.argv):
return sys.argv[index + 1]
return ""
if "--version" in sys.argv:
print("v6.0.0-rc.6")
sys.exit(0)
base_url = arg("--url").rstrip("/")
agent_id = arg("--agent-id") or "agent-v6"
info_path = os.path.join(os.path.dirname(sys.argv[0]), ".pulse-update-info")
updated_from = ""
if os.path.exists(info_path):
with open(info_path, encoding="utf-8") as handle:
updated_from = handle.read().strip()
os.remove(info_path)
payload = {
"agent": {
"id": agent_id,
"type": "unified",
"version": "v6.0.0-rc.6",
"updatedFrom": updated_from,
}
}
request = urllib.request.Request(
base_url + "/api/agents/agent/report",
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
urllib.request.urlopen(request, timeout=5).read()
time.sleep(60)
""",
encoding="utf-8",
)
v5.chmod(0o755)
v6.chmod(0o755)
return v5, v6
def test_release_asset_url(self) -> None:
got = release_asset_url("https://example.invalid/releases/download/", "6.0.0-rc.1", "install.sh")
self.assertEqual(got, "https://example.invalid/releases/download/v6.0.0-rc.1/install.sh")
def test_agent_binary_asset_name_windows(self) -> None:
self.assertEqual(agent_binary_asset_name("windows-amd64"), "pulse-agent-windows-amd64.exe")
self.assertEqual(agent_binary_asset_name("linux-amd64"), "pulse-agent-linux-amd64")
def test_run_rehearsal_passes_with_matching_assets(self) -> None:
install = b"#!/bin/sh\necho install\n"
ps1 = b"Write-Output 'install'\n"
binary = b"pulse-agent-binary"
checksum = __import__("hashlib").sha256(binary).hexdigest()
self.set_routes(
{
"/pulse/api/agent/version": (
200,
json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/install.sh": (200, install, {}),
"/pulse/install.ps1": (200, ps1, {}),
"/pulse/download/pulse-agent?arch=linux-amd64": (
200,
binary,
{"X-Checksum-Sha256": checksum, "X-Served-From": "github-fallback"},
),
"/releases/v6.0.0-rc.1/install.sh": (200, install, {}),
"/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}),
"/releases/v6.0.0-rc.1/pulse-agent-linux-amd64": (200, binary, {}),
}
)
args = type(
"Args",
(),
{
"base_url": f"{self.base_url}/pulse",
"expected_version": "6.0.0-rc.1",
"release_base_url": f"{self.base_url}/releases",
"arch": ["linux-amd64"],
"update_info_dir": None,
"expected_updated_from": None,
"timeout": 5.0,
"api_token": None,
"bearer_token": None,
"cookie": None,
"expected_active_agents": None,
"expected_agent_name": [],
"expected_online_agents": None,
"json": False,
"skip_asset_checks": False,
"runtime_v5_agent": None,
"runtime_v6_agent": None,
"runtime_expected_from": "v5.1.34",
"runtime_expected_to": None,
"runtime_timeout": 60.0,
"runtime_work_dir": None,
"runtime_keep_work_dir": False,
},
)()
results = run_rehearsal(args)
self.assertTrue(all(result.ok for result in results), [asdict(result) for result in results])
def test_run_rehearsal_fails_on_binary_checksum_header_mismatch(self) -> None:
install = b"#!/bin/sh\n"
self.set_routes(
{
"/pulse/api/agent/version": (
200,
json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/install.sh": (200, install, {}),
"/pulse/install.ps1": (200, b"ps1", {}),
"/pulse/download/pulse-agent?arch=linux-amd64": (
200,
b"binary",
{"X-Checksum-Sha256": "bad"},
),
"/releases/v6.0.0-rc.1/install.sh": (200, install, {}),
"/releases/v6.0.0-rc.1/install.ps1": (200, b"ps1", {}),
"/releases/v6.0.0-rc.1/pulse-agent-linux-amd64": (200, b"binary", {}),
}
)
exit_code = run_main(
[
"--base-url",
f"{self.base_url}/pulse",
"--expected-version",
"6.0.0-rc.1",
"--release-base-url",
f"{self.base_url}/releases",
"--arch",
"linux-amd64",
"--json",
]
)
self.assertEqual(exit_code, 1)
def test_run_rehearsal_reports_missing_release_asset_without_crashing(self) -> None:
install = b"#!/bin/sh\n"
self.set_routes(
{
"/pulse/api/agent/version": (
200,
json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/install.sh": (200, install, {}),
"/pulse/install.ps1": (200, b"ps1", {}),
"/releases/v6.0.0-rc.1/install.ps1": (200, b"ps1", {}),
}
)
exit_code = run_main(
[
"--base-url",
f"{self.base_url}/pulse",
"--expected-version",
"6.0.0-rc.1",
"--release-base-url",
f"{self.base_url}/releases",
"--json",
]
)
self.assertEqual(exit_code, 1)
def test_summarize_http_error_body_omits_html_page(self) -> None:
body = b"<!DOCTYPE html>\n<html><body>missing</body></html>"
self.assertEqual(summarize_http_error_body(body), "<html error page omitted>")
def test_check_update_info(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
info_path = Path(tmp) / ".pulse-update-info"
info_path.write_text("5.1.14\n", encoding="utf-8")
result = check_update_info(tmp, "5.1.14")
self.assertTrue(result.ok)
def test_check_update_info_requires_matching_content(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
info_path = Path(tmp) / ".pulse-update-info"
info_path.write_text("5.1.13\n", encoding="utf-8")
result = check_update_info(tmp, "5.1.14")
self.assertFalse(result.ok)
def test_render_markdown_report(self) -> None:
report = render_markdown_report(
title="Unified Agent RC Rehearsal",
base_url="https://pulse.example.com",
expected_version="6.0.0-rc.1",
release_base_url="https://github.com/example/releases/download",
results=[
type("R", (), {"name": "agent-version-endpoint", "ok": True, "detail": "matched"})(),
type("R", (), {"name": "agent-binary-linux-amd64", "ok": False, "detail": "checksum mismatch"})(),
],
)
self.assertIn("# Unified Agent RC Rehearsal", report)
self.assertIn("`PASS` `agent-version-endpoint`", report)
self.assertIn("`FAIL` `agent-binary-linux-amd64`", report)
self.assertIn("## Manual Follow-up", report)
def test_render_text_accepts_one_pass_iterable(self) -> None:
result = type(
"R",
(),
{
"name": "local-runtime-self-update",
"ok": True,
"detail": "runtime proof passed",
},
)()
rendered = render_text(item for item in [result])
self.assertIn("[PASS] local-runtime-self-update", rendered)
self.assertIn("Runtime follow-up covered", rendered)
def test_main_refuses_empty_runtime_only_selection(self) -> None:
exit_code = run_main(
[
"--base-url",
f"{self.base_url}/pulse",
"--expected-version",
"6.0.0-rc.1",
"--release-base-url",
f"{self.base_url}/releases",
"--skip-asset-checks",
"--json",
]
)
self.assertEqual(exit_code, 1)
def test_main_refuses_arch_when_asset_checks_are_skipped(self) -> None:
exit_code = run_main(
[
"--base-url",
f"{self.base_url}/pulse",
"--expected-version",
"6.0.0-rc.1",
"--release-base-url",
f"{self.base_url}/releases",
"--skip-asset-checks",
"--arch",
"linux-amd64",
"--json",
]
)
self.assertEqual(exit_code, 1)
def test_local_runtime_self_update_exercises_process_swap(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
v5, v6 = self.write_fake_runtime_agents(Path(tmp))
result = check_local_runtime_self_update(
v5_agent=str(v5),
v6_agent=str(v6),
expected_from="v5.1.34",
expected_to="v6.0.0-rc.6",
timeout=10,
work_dir=None,
keep_work_dir=False,
)
self.assertTrue(result.ok, result.detail)
def test_local_runtime_self_update_keeps_temp_work_dir_when_requested(self) -> None:
with tempfile.TemporaryDirectory() as tmp:
v5, v6 = self.write_fake_runtime_agents(Path(tmp))
result = check_local_runtime_self_update(
v5_agent=str(v5),
v6_agent=str(v6),
expected_from="v5.1.34",
expected_to="v6.0.0-rc.6",
timeout=10,
work_dir=None,
keep_work_dir=True,
)
self.assertTrue(result.ok, result.detail)
work_dir = Path(result.detail.rsplit("work_dir=", maxsplit=1)[1])
try:
self.assertTrue(work_dir.exists())
self.assertTrue((work_dir / "pulse-agent").exists())
finally:
shutil.rmtree(work_dir, ignore_errors=True)
def test_main_writes_report(self) -> None:
install = b"#!/bin/sh\necho install\n"
ps1 = b"Write-Output 'install'\n"
self.set_routes(
{
"/pulse/api/agent/version": (
200,
json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/install.sh": (200, install, {}),
"/pulse/install.ps1": (200, ps1, {}),
"/releases/v6.0.0-rc.1/install.sh": (200, install, {}),
"/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}),
}
)
with tempfile.TemporaryDirectory() as tmp:
report_path = Path(tmp) / "report.md"
exit_code = main(
[
"--base-url",
f"{self.base_url}/pulse",
"--expected-version",
"6.0.0-rc.1",
"--release-base-url",
f"{self.base_url}/releases",
"--report-out",
str(report_path),
]
)
self.assertEqual(exit_code, 0)
content = report_path.read_text(encoding="utf-8")
self.assertIn("# Unified Agent RC Rehearsal", content)
self.assertIn("`PASS` `install-sh-asset`", content)
def test_run_rehearsal_verifies_active_agent_accounting_via_api_token(self) -> None:
install = b"#!/bin/sh\necho install\n"
ps1 = b"Write-Output 'install'\n"
self.set_routes(
{
"/pulse/api/agent/version": (
200,
json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/install.sh": (200, install, {}),
"/pulse/install.ps1": (200, ps1, {}),
"/pulse/api/license/entitlements": (
200,
json.dumps(
{
"limits": [
{"key": "max_agents", "limit": 10, "current": 3, "state": "ok"}
]
}
).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/api/license/agent-ledger": (
200,
json.dumps(
{
"agents": [{"name": "a"}, {"name": "b"}, {"name": "c"}],
"total": 3,
"limit": 10,
}
).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/releases/v6.0.0-rc.1/install.sh": (200, install, {}),
"/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}),
}
)
exit_code = run_main(
[
"--base-url",
f"{self.base_url}/pulse",
"--expected-version",
"6.0.0-rc.1",
"--release-base-url",
f"{self.base_url}/releases",
"--api-token",
"token-123",
"--expected-active-agents",
"3",
"--json",
]
)
self.assertEqual(exit_code, 0)
self.assertEqual(
_FixtureHandler.request_headers["/pulse/api/license/entitlements"].get("x-api-token"),
"token-123",
)
self.assertEqual(
_FixtureHandler.request_headers["/pulse/api/license/agent-ledger"].get("x-api-token"),
"token-123",
)
def test_run_rehearsal_fails_when_active_agent_accounting_mismatches(self) -> None:
install = b"#!/bin/sh\necho install\n"
ps1 = b"Write-Output 'install'\n"
self.set_routes(
{
"/pulse/api/agent/version": (
200,
json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/install.sh": (200, install, {}),
"/pulse/install.ps1": (200, ps1, {}),
"/pulse/api/license/entitlements": (
200,
json.dumps(
{
"limits": [
{"key": "max_agents", "limit": 10, "current": 2, "state": "ok"}
]
}
).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/api/license/agent-ledger": (
200,
json.dumps(
{
"agents": [{"name": "a"}, {"name": "b"}, {"name": "c"}],
"total": 3,
"limit": 10,
}
).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/releases/v6.0.0-rc.1/install.sh": (200, install, {}),
"/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}),
}
)
exit_code = run_main(
[
"--base-url",
f"{self.base_url}/pulse",
"--expected-version",
"6.0.0-rc.1",
"--release-base-url",
f"{self.base_url}/releases",
"--api-token",
"token-123",
"--expected-active-agents",
"3",
"--json",
]
)
self.assertEqual(exit_code, 1)
def test_run_rehearsal_requires_auth_for_active_agent_accounting(self) -> None:
install = b"#!/bin/sh\necho install\n"
ps1 = b"Write-Output 'install'\n"
self.set_routes(
{
"/pulse/api/agent/version": (
200,
json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/install.sh": (200, install, {}),
"/pulse/install.ps1": (200, ps1, {}),
"/releases/v6.0.0-rc.1/install.sh": (200, install, {}),
"/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}),
}
)
exit_code = run_main(
[
"--base-url",
f"{self.base_url}/pulse",
"--expected-version",
"6.0.0-rc.1",
"--release-base-url",
f"{self.base_url}/releases",
"--expected-active-agents",
"3",
"--json",
]
)
self.assertEqual(exit_code, 1)
def test_run_rehearsal_verifies_expected_agent_name_and_online_status(self) -> None:
install = b"#!/bin/sh\necho install\n"
ps1 = b"Write-Output 'install'\n"
self.set_routes(
{
"/pulse/api/agent/version": (
200,
json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/install.sh": (200, install, {}),
"/pulse/install.ps1": (200, ps1, {}),
"/pulse/api/license/agent-ledger": (
200,
json.dumps(
{
"agents": [
{
"name": "workstation-01",
"type": "agent",
"status": "online",
"last_seen": "2026-03-12T12:00:00Z",
"source": "agent",
}
],
"total": 1,
"limit": 10,
}
).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/releases/v6.0.0-rc.1/install.sh": (200, install, {}),
"/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}),
}
)
exit_code = run_main(
[
"--base-url",
f"{self.base_url}/pulse",
"--expected-version",
"6.0.0-rc.1",
"--release-base-url",
f"{self.base_url}/releases",
"--api-token",
"token-123",
"--expected-agent-name",
"workstation-01",
"--expected-online-agents",
"1",
"--json",
]
)
self.assertEqual(exit_code, 0)
def test_run_rehearsal_fails_when_expected_agent_name_is_duplicated(self) -> None:
install = b"#!/bin/sh\necho install\n"
ps1 = b"Write-Output 'install'\n"
self.set_routes(
{
"/pulse/api/agent/version": (
200,
json.dumps({"version": "6.0.0-rc.1"}).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/pulse/install.sh": (200, install, {}),
"/pulse/install.ps1": (200, ps1, {}),
"/pulse/api/license/agent-ledger": (
200,
json.dumps(
{
"agents": [
{"name": "workstation-01", "status": "online"},
{"name": "workstation-01", "status": "online"},
],
"total": 2,
"limit": 10,
}
).encode("utf-8"),
{"Content-Type": "application/json"},
),
"/releases/v6.0.0-rc.1/install.sh": (200, install, {}),
"/releases/v6.0.0-rc.1/install.ps1": (200, ps1, {}),
}
)
exit_code = run_main(
[
"--base-url",
f"{self.base_url}/pulse",
"--expected-version",
"6.0.0-rc.1",
"--release-base-url",
f"{self.base_url}/releases",
"--api-token",
"token-123",
"--expected-agent-name",
"workstation-01",
"--json",
]
)
self.assertEqual(exit_code, 1)
if __name__ == "__main__":
unittest.main()