mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-19 02:28:59 +00:00
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.
702 lines
25 KiB
Python
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()
|