Own demo update and recovery through sustained host observation

Compose the existing signed stable demo paths with the Relay host lock, durable systemd ownership, full persistent estate capture and five-minute forward and recovery watches. Preserve uncertain attempts and failed rollback, refuse destructive headroom pruning, and keep native installed acceptance separate from connected source controls.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-03 01:05:16 +01:00
parent e929442038
commit f56e28e6af
11 changed files with 1278 additions and 756 deletions
+504
View File
@@ -0,0 +1,504 @@
#!/usr/bin/env python3
"""Demo-only host transaction. Called by the reviewed CI dispatcher, not a worker tool."""
from __future__ import annotations
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import shutil
import signal
import socket
import stat
import subprocess
import sys
import time
from urllib.parse import urlsplit
WINDOW = 300
INTERVAL = 5
READY_TIMEOUT = 60
SERVICES = ("pulse", "pulse-relay", "caddy")
ROOT = Path("/var/lib/pulse-deploy/demo")
LOCK = Path("/var/lib/pulse-deploy/relay/deploy.lock")
PATHS = {"binary": Path("/opt/pulse/bin/pulse"),
"unit": Path("/etc/systemd/system/pulse.service"),
"dropins": Path("/etc/systemd/system/pulse.service.d"),
"data": Path("/etc/pulse")}
RELAY_HEALTH = "https://relay.pulserelay.pro/health"
COUNT_KEYS = ("nodes", "vms_per_node", "lxcs_per_node", "docker_hosts",
"docker_containers", "generic_hosts", "k8s_clusters", "k8s_nodes",
"k8s_pods", "k8s_deployments")
DURATION_KEYS = ("seed_duration", "sample_interval", "update_interval")
# Only generated demo operational memory is reset on the already-unhealthy
# recovery route. Its complete original estate remains in the private snapshot.
DEMO_HISTORY = ("ai_incidents.json", "ai_incidents.json.tmp",
"alerts/alert-history.json", "alerts/alert-history.backup.json",
"alerts/alert-history.json.imported", "alerts/alert-history.backup.json.imported",
"alerts/events.db", "alerts/events.db-shm", "alerts/events.db-wal")
CRASH = re.compile(r"\bpanic\b|\bfatal error\b|segmentation fault|core dumped|watchdog timeout", re.I)
TERMINAL = {"committed", "healthy_noop", "rolled_back", "refused"}
class Failure(Exception):
"""Only fixed stage names, never command output or private response bodies."""
def atomic_json(path, value):
tmp = path.with_suffix(".tmp")
with open(tmp, "w", encoding="utf-8") as stream:
os.chmod(tmp, 0o600)
json.dump(value, stream, sort_keys=True)
stream.flush()
os.fsync(stream.fileno())
os.replace(tmp, path)
fd = os.open(path.parent, os.O_DIRECTORY)
try:
os.fsync(fd)
finally:
os.close(fd)
def validate(request):
if set(request) != {"mode", "hostname", "local_url", "public_url", "version", "profile", "control_sha", "run_id", "run_attempt", "installer_sha256"}:
raise Failure("request-shape")
if request["mode"] not in {"update", "recover"}:
raise Failure("request-mode")
for field, pattern in (("version", r"v[0-9]+\.[0-9]+\.[0-9]+"),
("hostname", r"[a-zA-Z0-9][a-zA-Z0-9.-]{0,252}"),
("control_sha", r"[0-9a-f]{40}"),
("run_id", r"[1-9][0-9]*"), ("run_attempt", r"[1-9][0-9]*")):
if not isinstance(request[field], str) or not re.fullmatch(pattern, request[field]):
raise Failure("request-identity")
for field, public in (("local_url", False), ("public_url", True)):
try:
url = urlsplit(request[field])
valid = (url.scheme == ("https" if public else "http")
and url.hostname and not url.username and not url.password
and not url.query and not url.fragment
and (url.path == "/api/health" if public else url.path in {"", "/"})
and (public or url.hostname in {"localhost", "127.0.0.1", "::1"})
and 0 < (url.port or (443 if public else 80)) < 65536)
except (TypeError, ValueError):
valid = False
if not valid:
raise Failure("request-url")
profile = request["profile"]
if not isinstance(profile, dict) or set(profile) != set(COUNT_KEYS + DURATION_KEYS):
raise Failure("request-profile")
for name, value in profile.items():
pattern = r"[1-9][0-9]{0,3}" if name in COUNT_KEYS else r"[1-9][0-9]{0,3}[smhd]"
if not isinstance(value, str) or not re.fullmatch(pattern, value):
raise Failure("request-profile")
digest = request["installer_sha256"]
if (request["mode"] == "update" and not re.fullmatch(r"[0-9a-f]{64}", digest)) or (request["mode"] == "recover" and digest != ""):
raise Failure("request-installer")
def copy_path(src, dst):
"""Copy without following symlinks, retaining ownership as well as modes."""
info = src.lstat()
if stat.S_ISLNK(info.st_mode):
dst.symlink_to(os.readlink(src))
elif stat.S_ISDIR(info.st_mode):
dst.mkdir(mode=0o700)
for child in src.iterdir():
copy_path(child, dst / child.name)
shutil.copystat(src, dst, follow_symlinks=False)
elif stat.S_ISREG(info.st_mode):
shutil.copy2(src, dst, follow_symlinks=False)
else:
raise Failure("snapshot-special-file")
os.chown(dst, info.st_uid, info.st_gid, follow_symlinks=False)
if not stat.S_ISLNK(info.st_mode):
fd = os.open(dst, os.O_RDONLY | (os.O_DIRECTORY if stat.S_ISDIR(info.st_mode) else 0))
try:
os.fsync(fd)
finally:
os.close(fd)
def remove_path(path):
if path.is_symlink() or path.is_file():
path.unlink()
elif path.is_dir():
shutil.rmtree(path)
def estate_hash(paths):
rows = []
for label, root in sorted(paths.items()):
exists = root.exists() or root.is_symlink()
if not exists:
rows.append((label, "absent"))
continue
for path in [root] + (sorted(root.rglob("*")) if root.is_dir() and not root.is_symlink() else []):
info = path.lstat()
kind = stat.S_IFMT(info.st_mode)
content = os.readlink(path) if stat.S_ISLNK(info.st_mode) else ""
if stat.S_ISREG(info.st_mode):
digest = hashlib.sha256()
with open(path, "rb") as stream:
for block in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(block)
content = digest.hexdigest()
rows.append((label, str(path.relative_to(root)), kind, stat.S_IMODE(info.st_mode), info.st_uid, info.st_gid, content))
return hashlib.sha256(json.dumps(rows, sort_keys=True).encode()).hexdigest()
class Host:
now = staticmethod(time.monotonic)
sleep = staticmethod(time.sleep)
def command(self, argv, *, timeout=20, env=None):
try:
result = subprocess.run(argv, capture_output=True, timeout=timeout, env=env, check=False)
except (OSError, subprocess.TimeoutExpired) as exc:
raise Failure("command-unavailable") from exc
if result.returncode or len(result.stdout) > 1024 * 1024:
raise Failure("command-failed")
return result.stdout.decode("utf-8", errors="strict")
def identity(self, hostname):
if socket.gethostname() != hostname or os.geteuid() != 0:
raise Failure("host-identity")
unit = self.command(["systemctl", "show", "pulse", "--property=FragmentPath,User,Group,ExecStart,DropInPaths", "--no-pager"])
fields = dict(line.split("=", 1) for line in unit.splitlines() if "=" in line)
if (fields.get("FragmentPath") != str(PATHS["unit"])
or fields.get("User") != "pulse" or fields.get("Group") != "pulse"
or str(PATHS["binary"]) not in fields.get("ExecStart", "")):
raise Failure("service-identity")
for name in ("binary", "unit", "data"):
if PATHS[name].is_symlink() or not PATHS[name].exists():
raise Failure("estate-identity")
if PATHS["dropins"].is_symlink():
raise Failure("dropin-identity")
for path in fields.get("DropInPaths", "").split():
if Path(path).parent != PATHS["dropins"] or Path(path).is_symlink():
raise Failure("unsupported-dropin")
if self.command(["systemctl", "is-enabled", "pulse"]).strip() != "enabled":
raise Failure("service-disabled")
def state(self):
states = {}
for service in SERVICES:
text = self.command(["systemctl", "show", service, "--property=ActiveState,SubState,MainPID,NRestarts", "--no-pager"])
fields = dict(line.split("=", 1) for line in text.splitlines() if "=" in line)
if (set(fields) != {"ActiveState", "SubState", "MainPID", "NRestarts"}
or not fields["MainPID"].isdigit() or not fields["NRestarts"].isdigit()):
raise Failure("service-observation")
states[service] = fields
return states
def status(self, url):
# No redirects, response bodies, credential arguments or ambient curl config.
try:
result = self.command(["curl", "--disable", "--silent", "--connect-timeout", "3", "--max-time", "8", "--output", "/dev/null", "--write-out", "%{http_code}", "--", url], timeout=10)
except Failure:
return "000"
return result if re.fullmatch(r"[1-5][0-9]{2}", result) else "000"
def version(self, url):
try:
body = self.command(["curl", "--disable", "--silent", "--fail", "--connect-timeout", "3", "--max-time", "8", "--", url.rstrip("/") + "/api/version"], timeout=10)
if len(body) > 8192:
raise Failure("version-observation")
value = json.loads(body)["version"]
if not isinstance(value, str) or not re.fullmatch(r"v?[0-9]+\.[0-9]+\.[0-9]+", value):
raise Failure("version-observation")
return value.lstrip("v")
except (ValueError, KeyError, TypeError) as exc:
raise Failure("version-observation") from exc
def cursors(self):
cursors = {}
for service in SERVICES:
text = self.command(["journalctl", "-u", service, "-n", "1", "--output=json", "--show-cursor", "--no-pager"])
matches = re.findall(r"^-- cursor: ([^\s]+)$", text, re.M)
if len(matches) != 1:
raise Failure("journal-cursor")
cursors[service] = matches[0]
return cursors
def check_journal(self, cursors):
for service, cursor in cursors.items():
text = self.command(["journalctl", "-u", service, "--after-cursor", cursor, "--output=json", "--no-pager"])
for line in text.splitlines():
try:
entry = json.loads(line)
if not isinstance(entry.get("MESSAGE"), str) or not isinstance(entry.get("__CURSOR"), str):
raise Failure("journal-observation")
except (ValueError, TypeError, AttributeError) as exc:
raise Failure("journal-observation") from exc
if CRASH.search(entry["MESSAGE"]):
raise Failure("new-service-crash")
cursors[service] = entry["__CURSOR"]
def stop(self):
self.command(["systemctl", "stop", "pulse"], timeout=90)
def start(self):
self.command(["systemctl", "daemon-reload"])
self.command(["systemctl", "start", "pulse"], timeout=90)
def install(self, attempt, request):
installer = attempt / "installer.sh"
if hashlib.sha256(installer.read_bytes()).hexdigest() != request["installer_sha256"]:
raise Failure("installer-identity")
self.command(["bash", str(installer), "--version", request["version"]], timeout=900,
env={"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/root", "PULSE_SERVICE_NAME": "pulse"})
def profile_values(self, request):
values = {"DEMO_MODE": "true", "PULSE_MOCK_MODE": "true", "PULSE_MOCK_RANDOM_METRICS": "true",
"PULSE_MOCK_STOPPED_PERCENT": "6", "PULSE_MOCK_SEED_METRICS_STORE": "false"}
for key, value in request["profile"].items():
key = {"seed_duration": "trends_seed_duration", "sample_interval": "trends_sample_interval"}.get(key, key)
values["PULSE_MOCK_" + key.upper()] = value
return values
def profile_matches(self, request):
text = (PATHS["data"] / ".env").read_text().splitlines()
values = self.profile_values(request)
return (all(text.count(key + "=" + value) == 1 for key, value in values.items())
and "demo_fixtures" in json.loads((PATHS["data"] / "billing.json").read_text()).get("capabilities", []))
def profile(self, request, unhealthy):
env_file = PATHS["data"] / ".env"
billing_file = PATHS["data"] / "billing.json"
if env_file.is_symlink() or billing_file.is_symlink():
raise Failure("configuration-symlink")
values = self.profile_values(request)
text = env_file.read_text()
for key, value in values.items():
pattern = re.compile(r"^[ \t]*" + re.escape(key) + r"=.*$", re.M)
text = pattern.sub(key + "=" + value, text) if pattern.search(text) else text.rstrip("\n") + "\n" + key + "=" + value + "\n"
env_file.write_text(text)
os.chmod(env_file, 0o600)
billing = json.loads(billing_file.read_text())
if not isinstance(billing, dict) or not isinstance(billing.get("capabilities", []), list):
raise Failure("billing-shape")
billing["capabilities"] = sorted(set(billing.get("capabilities", []) + ["demo_fixtures"]))
billing.pop("integrity", None)
billing_file.write_text(json.dumps(billing))
os.chmod(billing_file, 0o600)
if unhealthy:
for relative in DEMO_HISTORY:
path = PATHS["data"] / relative
# Never follow a writable parent symlink to a path outside the estate.
if any(p.is_symlink() for p in path.parents if p != PATHS["data"].parent):
raise Failure("history-symlink")
if path.is_symlink():
raise Failure("history-symlink")
if path.is_file():
path.unlink()
def active(state):
return state["ActiveState"] == "active" and state["SubState"] == "running" and int(state["MainPID"]) > 0
class Transaction:
def __init__(self, host, request, attempt):
self.host, self.request, self.attempt = host, request, attempt
self.receipt = {"schema_version": 2, "status": "intent", "mutated": False,
"control_sha": request["control_sha"], "run_id": request["run_id"],
"run_attempt": request["run_attempt"], "mode": request["mode"],
"expected_version": request["version"], "installer_sha256": request["installer_sha256"],
"forward": {}, "recovery": {}, "recovery_required": False}
def save(self, status):
self.receipt["status"] = status
atomic_json(self.attempt / "receipt.json", self.receipt)
def urls(self):
return (self.request["local_url"].rstrip("/") + "/api/health", self.request["public_url"], RELAY_HEALTH)
def healthy(self, states=None):
return all(active(s) for s in (states or self.host.state()).values()) and all(self.host.status(url) == "200" for url in self.urls())
def ready(self, cursors):
deadline = self.host.now() + READY_TIMEOUT
while True:
self.host.check_journal(cursors)
if self.healthy():
return
if self.host.now() >= deadline:
raise Failure("listener-readiness")
self.host.sleep(INTERVAL)
def watch(self, phase, expected_version, cursors):
baseline = self.host.state()
started = self.host.now()
evidence = self.receipt[phase]
evidence.update({"required_seconds": WINDOW, "samples": 0, "elapsed_seconds": 0})
while True:
self.host.check_journal(cursors)
state = self.host.state()
if state != baseline or not self.healthy(state):
raise Failure("sustained-health")
if self.host.version(self.request["local_url"]) != expected_version:
raise Failure("runtime-version")
evidence["samples"] += 1
elapsed = self.host.now() - started
evidence["elapsed_seconds"] = elapsed
if elapsed >= WINDOW:
return
self.host.sleep(min(INTERVAL, WINDOW - elapsed))
def capture(self):
snapshot = self.attempt / "snapshot"
size = sum(path.lstat().st_size for root in PATHS.values() if root.exists()
for path in ([root] + (list(root.rglob("*")) if root.is_dir() else []))
if path.is_file() and not path.is_symlink())
# Both our independent estate and the signed installer's own backup need
# space. Refuse shortage; never delete backups or databases for headroom.
if any(shutil.disk_usage(parent).free < 2 * size + 64 * 1024 * 1024
for parent in (self.attempt, PATHS["data"].parent)):
raise Failure("snapshot-headroom")
snapshot.mkdir(mode=0o700)
manifest = {}
for label, path in PATHS.items():
manifest[label] = path.exists() or path.is_symlink()
if manifest[label]:
copy_path(path, snapshot / label)
atomic_json(snapshot / "paths.json", manifest)
digest = estate_hash(PATHS)
self.receipt["snapshot_sha256"] = digest
self.receipt["snapshot_retained"] = True
return digest
def restore(self):
snapshot = self.attempt / "snapshot"
for label, exists in json.loads((snapshot / "paths.json").read_text()).items():
remove_path(PATHS[label])
if exists:
copy_path(snapshot / label, PATHS[label])
if estate_hash(PATHS) != self.receipt["snapshot_sha256"]:
raise Failure("restored-estate-identity")
def run(self):
old_handlers = {}
healthy_baseline = False
stopped = False
original_version = ""
states = {}
def cancel(_signum, _frame):
raise Failure("cancelled-forward")
try:
validate(self.request)
self.host.identity(self.request["hostname"])
for entry in ROOT.glob("attempts/*"):
if entry == self.attempt:
continue
receipt = entry / "receipt.json"
if not receipt.exists() or json.loads(receipt.read_text()).get("status") not in TERMINAL:
raise Failure("prior-recovery-required")
states = self.host.state()
if not all(active(states[name]) for name in ("pulse-relay", "caddy")) or self.host.status(RELAY_HEALTH) != "200":
raise Failure("cohost-unhealthy")
healthy_baseline = self.healthy(states)
if healthy_baseline:
original_version = self.host.version(self.request["local_url"])
if self.request["mode"] == "recover" and original_version != self.request["version"].lstrip("v"):
raise Failure("recovery-source-mismatch")
elif self.request["mode"] == "update":
raise Failure("update-baseline-unhealthy")
self.receipt["healthy_baseline"] = healthy_baseline
# The no-op is observed too; an immediate healthy sample is not success.
if self.request["mode"] == "recover" and healthy_baseline and self.host.profile_matches(self.request):
self.watch("forward", original_version, self.host.cursors())
self.save("healthy_noop")
return 0
old_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)}
for sig in old_handlers:
signal.signal(sig, cancel)
self.save("capturing")
stopped = True
self.host.stop()
self.capture()
self.save("applying")
self.receipt["mutated"] = True
cursors = self.host.cursors()
if self.request["mode"] == "update":
self.host.install(self.attempt, self.request)
# The installer may start the unit; finish profile work quiescent.
self.host.stop()
self.host.profile(self.request, not healthy_baseline)
self.host.start()
self.ready(cursors)
self.watch("forward", self.request["version"].lstrip("v"), cursors)
# No unrelated Relay/Caddy restart is owned by this transaction.
final = self.host.state()
for service in ("pulse-relay", "caddy"):
if final[service] != states[service]:
raise Failure("cohost-identity-changed")
self.save("committed")
return 0
except Exception as exc:
self.receipt["failure"] = str(exc) if isinstance(exc, Failure) else "transaction-observation"
# Owned restoration, observation and the terminal receipt survive cancellation.
for sig in old_handlers:
signal.signal(sig, signal.SIG_IGN)
if stopped:
try:
self.save("recovering")
self.host.stop()
if "snapshot_sha256" in self.receipt:
self.restore()
if not healthy_baseline:
self.receipt["recovery_required"] = True
self.receipt["rollback"] = "unavailable-unhealthy-baseline"
self.save("recovery_required")
else:
cursors = self.host.cursors()
self.host.start()
self.ready(cursors)
self.watch("recovery", original_version, cursors)
final = self.host.state()
if any(final[name] != states[name] for name in ("pulse-relay", "caddy")):
raise Failure("cohost-identity-changed")
self.receipt["rollback"] = "verified"
self.save("rolled_back")
except Exception as recovery:
self.receipt["rollback"] = "failed"
self.receipt["recovery_failure"] = str(recovery) if isinstance(recovery, Failure) else "recovery-observation"
self.receipt["recovery_required"] = True
self.save("rollback_failed")
else:
self.save("refused")
return 1
finally:
for sig, handler in old_handlers.items():
signal.signal(sig, handler)
def main():
# Attempt paths are generated by the fixed CI bootstrap, not CLI overrides.
if len(sys.argv) != 2:
return 2
attempt = Path(sys.argv[1])
if attempt.parent != ROOT / "attempts" or not re.fullmatch(r"[0-9a-f]{64}", attempt.name) or attempt.is_symlink():
return 2
request = json.loads((attempt / "request.json").read_text())
transaction = Transaction(Host(), request, attempt)
fd = None
try:
LOCK.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
fd = os.open(LOCK, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
return transaction.run()
except (OSError, ValueError):
transaction.receipt["failure"] = "host-lock-unavailable"
transaction.save("refused")
return 1
finally:
if fd is not None:
os.close(fd)
if __name__ == "__main__":
sys.exit(main())
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python3
"""The two existing demo CI paths share one retained, independently owned child."""
from __future__ import annotations
import base64
import hashlib
import json
import os
from pathlib import Path
import shlex
import subprocess
import sys
BOOTSTRAP = r'''
import base64, fcntl, hashlib, json, os, pathlib, subprocess, sys, time
root = pathlib.Path('/var/lib/pulse-deploy/demo')
payload = json.loads(sys.stdin.read(512 * 1024))
assert os.geteuid() == 0 and set(payload) == {'request', 'source', 'installer'}
source = base64.b64decode(payload['source'], validate=True)
installer = base64.b64decode(payload['installer'], validate=True)
assert len(source) < 128 * 1024 and len(installer) < 256 * 1024
assert (hashlib.sha256(installer).hexdigest() == payload['request']['installer_sha256']) if installer else (payload['request']['installer_sha256'] == '')
identity = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(',', ':')).encode()).hexdigest()
root.mkdir(mode=0o700, parents=True, exist_ok=True)
assert not root.is_symlink() and root.stat().st_uid == 0
os.chmod(root, 0o700)
(root / 'attempts').mkdir(mode=0o700, exist_ok=True)
assert not (root / 'attempts').is_symlink()
fd = os.open(root / 'submit.lock', os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
fcntl.flock(fd, fcntl.LOCK_EX)
attempt = root / 'attempts' / identity
if not attempt.exists():
attempt.mkdir(mode=0o700)
for name, data in [('transaction.py', source), ('installer.sh', installer),
('request.json', json.dumps(payload['request']).encode()),
('intent.json', json.dumps({'schema_version': 1, 'identity': identity, 'state': 'intent'}).encode())]:
with open(attempt / name, 'xb') as stream:
os.chmod(attempt / name, 0o600)
stream.write(data); stream.flush(); os.fsync(stream.fileno())
# Intent is durable before starting the unit. An uncertain launch is never replayed.
dfd = os.open(attempt, os.O_DIRECTORY); os.fsync(dfd); os.close(dfd)
try:
child = subprocess.run(['systemd-run', '--quiet', '--collect',
'--unit=pulse-demo-' + identity[:32],
'--property=RuntimeMaxSec=45min',
'--property=TimeoutStopSec=20min', '--property=KillMode=mixed',
'/usr/bin/python3', str(attempt / 'transaction.py'), str(attempt)],
capture_output=True, timeout=30)
launch = {'schema_version': 1, 'state': 'submitted' if child.returncode == 0 else 'uncertain',
'unit': 'pulse-demo-' + identity[:32]}
except (OSError, subprocess.TimeoutExpired):
launch = {'schema_version': 1, 'state': 'uncertain'}
with open(attempt / 'launch.json', 'x') as stream:
os.chmod(attempt / 'launch.json', 0o600)
json.dump(launch, stream); stream.flush(); os.fsync(stream.fileno())
os.close(fd)
# Closing or losing this observer never stops the independently owned unit.
deadline = time.monotonic() + 4000
terminal = {'committed', 'healthy_noop', 'rolled_back', 'rollback_failed', 'recovery_required', 'refused'}
while time.monotonic() < deadline:
try:
receipt = json.loads((attempt / 'receipt.json').read_text())
except (FileNotFoundError, ValueError):
receipt = None
if receipt and receipt.get('status') in terminal:
receipt['request_identity'] = identity
print(json.dumps(receipt))
sys.exit(0 if receipt['status'] in {'committed', 'healthy_noop'} else 1)
launch = attempt / 'launch.json'
if launch.exists() and json.loads(launch.read_text()).get('state') == 'uncertain':
break
time.sleep(5)
print(json.dumps({'schema_version': 2, 'status': 'uncertain', 'request_identity': identity,
'failure': 'terminal-receipt-unavailable', 'recovery_required': True}))
sys.exit(1)
'''
def payload(mode, environ=os.environ):
profile = {key: environ['MOCK_' + key.upper()] for key in
('nodes', 'vms_per_node', 'lxcs_per_node', 'docker_hosts', 'docker_containers',
'generic_hosts', 'k8s_clusters', 'k8s_nodes', 'k8s_pods', 'k8s_deployments',
'seed_duration', 'sample_interval', 'update_interval')}
installer = Path('/tmp/pulse-install.sh').read_bytes() if mode == 'update' else b''
request = {'mode': mode, 'hostname': environ['DEMO_EXPECTED_HOSTNAME'],
'local_url': environ['DEMO_LOCAL_BASE_URL'], 'public_url': environ['DEMO_PUBLIC_HEALTH_URL'],
'version': environ['EXPECTED_VERSION'], 'profile': profile,
'control_sha': environ['GITHUB_SHA'], 'run_id': environ['GITHUB_RUN_ID'],
'run_attempt': environ['GITHUB_RUN_ATTEMPT'],
'installer_sha256': hashlib.sha256(installer).hexdigest() if installer else ''}
return {'request': request, 'source': base64.b64encode(Path(__file__).with_name('demo-runtime-transaction.py').read_bytes()).decode(),
'installer': base64.b64encode(installer).decode()}
def main():
if len(sys.argv) != 2 or sys.argv[1] not in {'update', 'recover'}:
return 2
# Same project CI SSH identity and pinned host verification as before. This
# is not an installed worker dispatcher or a new project credential.
command = ['ssh', '-i', str(Path.home() / '.ssh/id_ed25519'), '-o', 'IdentitiesOnly=yes',
'-o', 'StrictHostKeyChecking=yes', '-o', 'UserKnownHostsFile=' + str(Path.home() / '.ssh/known_hosts'),
os.environ['DEMO_SERVER_USER'] + '@' + os.environ['DEMO_SERVER_HOST'],
'sudo python3 -c ' + shlex.quote(BOOTSTRAP)]
try:
result = subprocess.run(command, input=json.dumps(payload(sys.argv[1])).encode(), timeout=4100, check=False)
except (OSError, subprocess.TimeoutExpired):
print(json.dumps({'schema_version': 2, 'status': 'uncertain', 'failure': 'observer-unavailable', 'recovery_required': True}))
return 1
return result.returncode
if __name__ == '__main__':
sys.exit(main())
+2 -393
View File
@@ -1,395 +1,4 @@
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 16 ]; then
echo "usage: recover-demo-runtime.sh EXPECTED_HOSTNAME LOCAL_BASE_URL EXPECTED_VERSION MOCK_NODES MOCK_VMS_PER_NODE MOCK_LXCS_PER_NODE MOCK_DOCKER_HOSTS MOCK_DOCKER_CONTAINERS MOCK_GENERIC_HOSTS MOCK_K8S_CLUSTERS MOCK_K8S_NODES MOCK_K8S_PODS MOCK_K8S_DEPLOYMENTS MOCK_SEED_DURATION MOCK_SAMPLE_INTERVAL MOCK_UPDATE_INTERVAL" >&2
exit 2
fi
EXPECTED_HOSTNAME="$1"
LOCAL_BASE_URL="${2%/}"
EXPECTED_VERSION="${3#v}"
MOCK_NODES="$4"
MOCK_VMS_PER_NODE="$5"
MOCK_LXCS_PER_NODE="$6"
MOCK_DOCKER_HOSTS="$7"
MOCK_DOCKER_CONTAINERS="$8"
MOCK_GENERIC_HOSTS="$9"
MOCK_K8S_CLUSTERS="${10}"
MOCK_K8S_NODES="${11}"
MOCK_K8S_PODS="${12}"
MOCK_K8S_DEPLOYMENTS="${13}"
MOCK_SEED_DURATION="${14}"
MOCK_SAMPLE_INTERVAL="${15}"
MOCK_UPDATE_INTERVAL="${16}"
SERVICE_NAME="pulse"
RELAY_SERVICE_NAME="pulse-relay"
EXPECTED_BINARY="/opt/pulse/bin/pulse"
EXPECTED_UNIT="/etc/systemd/system/pulse.service"
MUTATED=false
BACKUP_DIR=""
HISTORY_BACKUP_ARCHIVE=""
DEMO_HISTORY_PATHS=(
ai_incidents.json
ai_incidents.json.tmp
alerts/alert-history.json
alerts/alert-history.backup.json
alerts/alert-history.json.imported
alerts/alert-history.backup.json.imported
alerts/events.db
alerts/events.db-shm
alerts/events.db-wal
)
for fixture_count in \
"$MOCK_NODES" \
"$MOCK_VMS_PER_NODE" \
"$MOCK_LXCS_PER_NODE" \
"$MOCK_DOCKER_HOSTS" \
"$MOCK_DOCKER_CONTAINERS" \
"$MOCK_GENERIC_HOSTS" \
"$MOCK_K8S_CLUSTERS" \
"$MOCK_K8S_NODES" \
"$MOCK_K8S_PODS" \
"$MOCK_K8S_DEPLOYMENTS"; do
[[ "$fixture_count" =~ ^[1-9][0-9]*$ ]]
done
[[ "$MOCK_SEED_DURATION" =~ ^[1-9][0-9]*[smhd]$ ]]
[[ "$MOCK_SAMPLE_INTERVAL" =~ ^[1-9][0-9]*[smh]$ ]]
[[ "$MOCK_UPDATE_INTERVAL" =~ ^[1-9][0-9]*[smh]$ ]]
log() {
printf '[demo-recovery] %s\n' "$*" >&2
}
unit_hash() {
sudo sha256sum "$EXPECTED_UNIT" | awk '{print $1}'
}
binary_hash() {
sudo sha256sum "$EXPECTED_BINARY" | awk '{print $1}'
}
dropin_manifest_hash() {
local paths path
paths="$(systemctl show "$SERVICE_NAME" --property=DropInPaths --value)"
if [ -z "$paths" ]; then
printf '%s' 'none' | sha256sum | awk '{print $1}'
return
fi
for path in $paths; do
sudo test -f "$path"
printf '%s\t' "$path"
sudo sha256sum "$path" | awk '{print $1}'
done | sort | sha256sum | awk '{print $1}'
}
runtime_config_hash() {
local path
for path in /etc/pulse/.env /etc/pulse/billing.json; do
sudo test -f "$path"
printf '%s\t' "$path"
sudo sha256sum "$path" | awk '{print $1}'
done | sha256sum | awk '{print $1}'
}
set_env_value() {
local key="$1"
local value="$2"
local env_file="/etc/pulse/.env"
if sudo grep -Eq "^[[:space:]]*${key}=" "$env_file"; then
sudo sed -i "s|^[[:space:]]*${key}=.*|${key}=${value}|" "$env_file"
else
printf '\n%s=%s\n' "$key" "$value" | sudo tee -a "$env_file" >/dev/null
fi
}
runtime_profile_matches() {
sudo grep -Fxq "PULSE_MOCK_NODES=${MOCK_NODES}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_VMS_PER_NODE=${MOCK_VMS_PER_NODE}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_LXCS_PER_NODE=${MOCK_LXCS_PER_NODE}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_DOCKER_HOSTS=${MOCK_DOCKER_HOSTS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_DOCKER_CONTAINERS=${MOCK_DOCKER_CONTAINERS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_GENERIC_HOSTS=${MOCK_GENERIC_HOSTS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_K8S_CLUSTERS=${MOCK_K8S_CLUSTERS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_K8S_NODES=${MOCK_K8S_NODES}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_K8S_PODS=${MOCK_K8S_PODS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_K8S_DEPLOYMENTS=${MOCK_K8S_DEPLOYMENTS}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_SEED_METRICS_STORE=false" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_TRENDS_SEED_DURATION=${MOCK_SEED_DURATION}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_TRENDS_SAMPLE_INTERVAL=${MOCK_SAMPLE_INTERVAL}" /etc/pulse/.env &&
sudo grep -Fxq "PULSE_MOCK_UPDATE_INTERVAL=${MOCK_UPDATE_INTERVAL}" /etc/pulse/.env
}
cleanup_config_backup() {
[ -n "$BACKUP_DIR" ] || return 0
sudo unlink "$BACKUP_DIR/runtime.env" 2>/dev/null || true
sudo unlink "$BACKUP_DIR/billing.json" 2>/dev/null || true
if [ -n "$HISTORY_BACKUP_ARCHIVE" ]; then
sudo unlink "$HISTORY_BACKUP_ARCHIVE" 2>/dev/null || true
HISTORY_BACKUP_ARCHIVE=""
fi
rmdir "$BACKUP_DIR" 2>/dev/null || true
BACKUP_DIR=""
}
clear_demo_operational_history() {
local rel_path
local -a existing=()
for rel_path in "${DEMO_HISTORY_PATHS[@]}"; do
if sudo test -f "/etc/pulse/${rel_path}"; then
existing+=("$rel_path")
fi
done
((${#existing[@]} > 0)) || return 0
HISTORY_BACKUP_ARCHIVE="$BACKUP_DIR/demo-operational-history.tar"
sudo tar --create --preserve-permissions \
--file "$HISTORY_BACKUP_ARCHIVE" --directory /etc/pulse -- "${existing[@]}"
for rel_path in "${existing[@]}"; do
sudo unlink "/etc/pulse/${rel_path}"
done
}
restore_demo_operational_history() {
local rel_path
[ -n "$HISTORY_BACKUP_ARCHIVE" ] || return 0
sudo test -f "$HISTORY_BACKUP_ARCHIVE" || return 0
for rel_path in "${DEMO_HISTORY_PATHS[@]}"; do
sudo unlink "/etc/pulse/${rel_path}" 2>/dev/null || true
done
sudo tar --extract --preserve-permissions \
--file "$HISTORY_BACKUP_ARCHIVE" --directory /etc/pulse
}
restore_runtime_config() {
[ -n "$BACKUP_DIR" ] || return 0
sudo cp --archive "$BACKUP_DIR/runtime.env" /etc/pulse/.env
sudo cp --archive "$BACKUP_DIR/billing.json" /etc/pulse/billing.json
}
local_version() {
curl --connect-timeout 3 --max-time 8 -fsS \
"${LOCAL_BASE_URL}/api/version" | jq -er '.version'
}
local_healthy() {
curl --connect-timeout 3 --max-time 8 -fsS \
"${LOCAL_BASE_URL}/api/health" >/dev/null && local_version >/dev/null
}
emit_evidence() {
local status="$1"
local before_pid="$2"
local after_pid="$3"
local before_relay_pid="$4"
local after_relay_pid="$5"
local before_binary_sha="$6"
local after_binary_sha="$7"
local before_unit_sha="$8"
local after_unit_sha="$9"
local before_dropins_sha="${10}"
local after_dropins_sha="${11}"
local version="${12}"
local before_config_sha="${13}"
local after_config_sha="${14}"
jq -n \
--arg status "$status" \
--argjson mutated "$MUTATED" \
--arg hostname "$(hostname)" \
--arg service "$SERVICE_NAME" \
--arg before_pid "$before_pid" \
--arg after_pid "$after_pid" \
--arg relay_service "$RELAY_SERVICE_NAME" \
--arg before_relay_pid "$before_relay_pid" \
--arg after_relay_pid "$after_relay_pid" \
--arg binary "$EXPECTED_BINARY" \
--arg before_binary_sha256 "$before_binary_sha" \
--arg after_binary_sha256 "$after_binary_sha" \
--arg unit "$EXPECTED_UNIT" \
--arg before_unit_sha256 "$before_unit_sha" \
--arg after_unit_sha256 "$after_unit_sha" \
--arg before_dropins_sha256 "$before_dropins_sha" \
--arg after_dropins_sha256 "$after_dropins_sha" \
--arg before_runtime_config_sha256 "$before_config_sha" \
--arg after_runtime_config_sha256 "$after_config_sha" \
--arg version "$version" \
--arg expected_version "$EXPECTED_VERSION" \
--arg mock_nodes "$MOCK_NODES" \
--arg mock_vms_per_node "$MOCK_VMS_PER_NODE" \
--arg mock_lxcs_per_node "$MOCK_LXCS_PER_NODE" \
--arg mock_docker_hosts "$MOCK_DOCKER_HOSTS" \
--arg mock_docker_containers "$MOCK_DOCKER_CONTAINERS" \
--arg mock_generic_hosts "$MOCK_GENERIC_HOSTS" \
--arg mock_k8s_clusters "$MOCK_K8S_CLUSTERS" \
--arg mock_k8s_nodes "$MOCK_K8S_NODES" \
--arg mock_k8s_pods "$MOCK_K8S_PODS" \
--arg mock_k8s_deployments "$MOCK_K8S_DEPLOYMENTS" \
--arg mock_seed_duration "$MOCK_SEED_DURATION" \
--arg mock_sample_interval "$MOCK_SAMPLE_INTERVAL" \
--arg mock_update_interval "$MOCK_UPDATE_INTERVAL" \
'{schema_version: 1, status: $status, mutated: $mutated,
hostname: $hostname, service: $service,
before_pid: $before_pid, after_pid: $after_pid,
relay_service: $relay_service,
before_relay_pid: $before_relay_pid,
after_relay_pid: $after_relay_pid,
binary: $binary,
before_binary_sha256: $before_binary_sha256,
after_binary_sha256: $after_binary_sha256,
unit: $unit,
before_unit_sha256: $before_unit_sha256,
after_unit_sha256: $after_unit_sha256,
before_dropins_sha256: $before_dropins_sha256,
after_dropins_sha256: $after_dropins_sha256,
before_runtime_config_sha256: $before_runtime_config_sha256,
after_runtime_config_sha256: $after_runtime_config_sha256,
version: $version, expected_version: $expected_version,
runtime_profile: {mock_nodes: $mock_nodes,
mock_vms_per_node: $mock_vms_per_node,
mock_lxcs_per_node: $mock_lxcs_per_node,
mock_docker_hosts: $mock_docker_hosts,
mock_docker_containers: $mock_docker_containers,
mock_generic_hosts: $mock_generic_hosts,
mock_k8s_clusters: $mock_k8s_clusters,
mock_k8s_nodes: $mock_k8s_nodes,
mock_k8s_pods: $mock_k8s_pods,
mock_k8s_deployments: $mock_k8s_deployments,
mock_seed_duration: $mock_seed_duration,
mock_sample_interval: $mock_sample_interval,
mock_update_interval: $mock_update_interval}}'
}
[ "$(hostname)" = "$EXPECTED_HOSTNAME" ] || {
log "target hostname does not match the governed demo environment"
exit 1
}
[ "$(systemctl show "$SERVICE_NAME" --property=FragmentPath --value)" = "$EXPECTED_UNIT" ]
[ "$(systemctl show "$SERVICE_NAME" --property=User --value)" = "pulse" ]
[ "$(systemctl show "$SERVICE_NAME" --property=Group --value)" = "pulse" ]
[ "$(systemctl is-enabled "$SERVICE_NAME")" = "enabled" ]
[ "$(systemctl is-active "$RELAY_SERVICE_NAME")" = "active" ]
systemctl show "$SERVICE_NAME" --property=ExecStart --value | grep -Fq "$EXPECTED_BINARY"
sudo test -x "$EXPECTED_BINARY"
sudo test -f "$EXPECTED_UNIT"
BEFORE_PID="$(systemctl show "$SERVICE_NAME" --property=MainPID --value)"
BEFORE_RELAY_PID="$(systemctl show "$RELAY_SERVICE_NAME" --property=MainPID --value)"
BEFORE_BINARY_SHA="$(binary_hash)"
BEFORE_UNIT_SHA="$(unit_hash)"
BEFORE_DROPINS_SHA="$(dropin_manifest_hash)"
BEFORE_CONFIG_SHA="$(runtime_config_hash)"
if local_healthy && runtime_profile_matches; then
VERSION="$(local_version)"
[ "${VERSION#v}" = "$EXPECTED_VERSION" ]
emit_evidence healthy_noop "$BEFORE_PID" "$BEFORE_PID" \
"$BEFORE_RELAY_PID" "$BEFORE_RELAY_PID" \
"$BEFORE_BINARY_SHA" "$BEFORE_BINARY_SHA" \
"$BEFORE_UNIT_SHA" "$BEFORE_UNIT_SHA" \
"$BEFORE_DROPINS_SHA" "$BEFORE_DROPINS_SHA" "$VERSION" \
"$BEFORE_CONFIG_SHA" "$BEFORE_CONFIG_SHA"
exit 0
fi
rollback_on_error() {
local rc=$?
local failed_pid
trap - ERR
if [ "$MUTATED" = true ]; then
failed_pid="$(systemctl show "$SERVICE_NAME" --property=MainPID --value || true)"
if [[ "$failed_pid" =~ ^[1-9][0-9]*$ ]] && sudo kill -0 "$failed_pid" 2>/dev/null; then
# The HTTP listener never opened, so pprof is unavailable. SIGQUIT gives
# one bounded Go goroutine dump in the service journal before the already
# failed process is stopped; it does not widen recovery to any other unit.
log "capturing failed Pulse startup goroutines before compensation"
sudo kill -QUIT "$failed_pid" || true
sleep 2
fi
log "recovery validation failed; compensating by stopping only pulse.service"
sudo systemctl stop "$SERVICE_NAME" || true
restore_runtime_config || true
restore_demo_operational_history || true
fi
emit_evidence compensated_unavailable "$BEFORE_PID" \
"$(systemctl show "$SERVICE_NAME" --property=MainPID --value || true)" \
"$BEFORE_RELAY_PID" \
"$(systemctl show "$RELAY_SERVICE_NAME" --property=MainPID --value || true)" \
"$BEFORE_BINARY_SHA" "$(binary_hash || true)" \
"$BEFORE_UNIT_SHA" "$(unit_hash || true)" \
"$BEFORE_DROPINS_SHA" "$(dropin_manifest_hash || true)" "" \
"$BEFORE_CONFIG_SHA" "$(runtime_config_hash || true)"
cleanup_config_backup
exit "$rc"
}
BACKUP_DIR="$(mktemp -d)"
sudo cp --archive /etc/pulse/.env "$BACKUP_DIR/runtime.env"
sudo cp --archive /etc/pulse/billing.json "$BACKUP_DIR/billing.json"
trap rollback_on_error ERR
log "applying the target-compatible demo runtime profile and restarting only pulse.service"
MUTATED=true
set_env_value PULSE_MOCK_NODES "$MOCK_NODES"
set_env_value PULSE_MOCK_VMS_PER_NODE "$MOCK_VMS_PER_NODE"
set_env_value PULSE_MOCK_LXCS_PER_NODE "$MOCK_LXCS_PER_NODE"
set_env_value PULSE_MOCK_DOCKER_HOSTS "$MOCK_DOCKER_HOSTS"
set_env_value PULSE_MOCK_DOCKER_CONTAINERS "$MOCK_DOCKER_CONTAINERS"
set_env_value PULSE_MOCK_GENERIC_HOSTS "$MOCK_GENERIC_HOSTS"
set_env_value PULSE_MOCK_K8S_CLUSTERS "$MOCK_K8S_CLUSTERS"
set_env_value PULSE_MOCK_K8S_NODES "$MOCK_K8S_NODES"
set_env_value PULSE_MOCK_K8S_PODS "$MOCK_K8S_PODS"
set_env_value PULSE_MOCK_K8S_DEPLOYMENTS "$MOCK_K8S_DEPLOYMENTS"
# Persistent mock-store backfill is reserved for the isolated local-dev data
# directory. The governed demo already has persistent runtime data, so a stale
# opt-in here can turn startup into an unbounded SQLite backfill.
set_env_value PULSE_MOCK_SEED_METRICS_STORE false
set_env_value PULSE_MOCK_TRENDS_SEED_DURATION "$MOCK_SEED_DURATION"
set_env_value PULSE_MOCK_TRENDS_SAMPLE_INTERVAL "$MOCK_SAMPLE_INTERVAL"
set_env_value PULSE_MOCK_UPDATE_INTERVAL "$MOCK_UPDATE_INTERVAL"
sudo chown pulse:pulse /etc/pulse/.env
sudo chmod 600 /etc/pulse/.env
# Alert lifecycle and AI incident history on this host are generated demo
# operational memory. A failed 50-node bootstrap can leave thousands of
# lifecycle events whose v6.4 replay queues one serialized incident save per
# event before HTTP opens. Back up and clear only those fixed demo-history
# files, including the retired JSON sources that repopulate a cleared event
# log; compensation restores them if the bounded profile still fails.
clear_demo_operational_history
sudo systemctl restart "$SERVICE_NAME"
for _ in $(seq 1 12); do
if [ "$(systemctl is-active "$SERVICE_NAME" || true)" = "active" ] && local_healthy; then
break
fi
sleep 5
done
[ "$(systemctl is-active "$SERVICE_NAME")" = "active" ]
[ "$(systemctl show "$SERVICE_NAME" --property=SubState --value)" = "running" ]
local_healthy
runtime_profile_matches
AFTER_PID="$(systemctl show "$SERVICE_NAME" --property=MainPID --value)"
AFTER_RELAY_PID="$(systemctl show "$RELAY_SERVICE_NAME" --property=MainPID --value)"
AFTER_BINARY_SHA="$(binary_hash)"
AFTER_UNIT_SHA="$(unit_hash)"
AFTER_DROPINS_SHA="$(dropin_manifest_hash)"
AFTER_CONFIG_SHA="$(runtime_config_hash)"
VERSION="$(local_version)"
[ "$AFTER_PID" -gt 0 ]
[ "$AFTER_PID" != "$BEFORE_PID" ]
[ "$AFTER_RELAY_PID" = "$BEFORE_RELAY_PID" ]
[ "$AFTER_BINARY_SHA" = "$BEFORE_BINARY_SHA" ]
[ "$AFTER_UNIT_SHA" = "$BEFORE_UNIT_SHA" ]
[ "$AFTER_DROPINS_SHA" = "$BEFORE_DROPINS_SHA" ]
[ "${VERSION#v}" = "$EXPECTED_VERSION" ]
trap - ERR
cleanup_config_backup
emit_evidence recovered "$BEFORE_PID" "$AFTER_PID" \
"$BEFORE_RELAY_PID" "$AFTER_RELAY_PID" \
"$BEFORE_BINARY_SHA" "$AFTER_BINARY_SHA" \
"$BEFORE_UNIT_SHA" "$AFTER_UNIT_SHA" \
"$BEFORE_DROPINS_SHA" "$AFTER_DROPINS_SHA" "$VERSION" \
"$BEFORE_CONFIG_SHA" "$AFTER_CONFIG_SHA"
# CI observer only; remote systemd owns mutation, restoration and both watches.
exec python3 .github/scripts/dispatch-demo-runtime.py recover
@@ -0,0 +1,443 @@
import copy
import hashlib
import importlib.util
import json
import os
from pathlib import Path
import signal
import subprocess
import tempfile
import unittest
from unittest.mock import patch
SCRIPTS = Path(__file__).resolve().parents[1]
def load(name, filename):
spec = importlib.util.spec_from_file_location(name, SCRIPTS / filename)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
engine = load("demo_transaction", "demo-runtime-transaction.py")
dispatcher = load("demo_dispatcher", "dispatch-demo-runtime.py")
def request(mode="recover"):
return {"mode": mode, "hostname": "fixture", "local_url": "http://127.0.0.1:7655",
"public_url": "https://demo.example/api/health", "version": "v6.4.5",
"control_sha": "a" * 40, "run_id": "123", "run_attempt": "1",
"installer_sha256": hashlib.sha256(b"signed-installer-fixture").hexdigest() if mode == "update" else "",
"profile": {**{name: "2" for name in engine.COUNT_KEYS},
"seed_duration": "2h", "sample_interval": "5m", "update_interval": "15s"}}
class FixtureHost(engine.Host):
"""Real filesystem estate with a virtual *unchanged* 300-second clock.
Commands/services/journal/HTTP are adapters, not native or installed proof.
"""
def __init__(self):
self.clock = 0
self.pid = 100
self.running = True
self.runtime_version = "6.4.5"
self.starts = 0
self.stops = 0
self.install_count = 0
self.fail_at = None
self.failed = False
self.rollback_failure = False
self.unhealthy = False
self.journal_failure = False
self.ready_until = 0
self.cancel_at = None
self.cancel_in_recovery = False
self.co_host_change = False
self.co_pid = 200
self.transaction = None
def now(self):
return self.clock
def sleep(self, seconds):
self.clock += seconds
def identity(self, hostname):
if hostname != "fixture":
raise engine.Failure("host-identity")
def state(self):
state = {"ActiveState": "active", "SubState": "running", "MainPID": str(self.pid), "NRestarts": "0"}
result = {"pulse": state, "pulse-relay": {**state, "MainPID": "200"}, "caddy": {**state, "MainPID": str(self.co_pid)}}
if not self.running:
result["pulse"] = {**state, "ActiveState": "inactive", "SubState": "dead", "MainPID": "0"}
return result
def status(self, url):
if url == engine.RELAY_HEALTH:
return "200"
if self.clock < self.ready_until or self.unhealthy:
return "503"
if self.failed and self.rollback_failure:
return "503"
return "200" if self.running else "000"
def version(self, url):
return self.runtime_version
def cursors(self):
return {name: str(self.clock) for name in engine.SERVICES}
def check_journal(self, cursors):
phase = self.transaction.receipt["status"] if self.transaction else ""
if self.journal_failure:
raise engine.Failure("journal-observation")
if self.co_host_change and self.clock >= 55:
self.co_pid = 201
if self.cancel_at is not None and self.clock >= self.cancel_at and not self.failed:
self.failed = True
handler = signal.getsignal(signal.SIGTERM)
handler(signal.SIGTERM, None)
if self.cancel_in_recovery and phase == "recovering" and self.clock >= 350:
# Exercise the actual installed handler decision; do not signal the
# source-proof executor or kill another process to prove it.
assert signal.getsignal(signal.SIGTERM) == signal.SIG_IGN
self.cancel_in_recovery = False
if self.fail_at is not None and self.clock >= self.fail_at and not self.failed:
self.failed = True
raise engine.Failure("new-service-crash")
def stop(self):
self.stops += 1
self.running = False
def start(self):
self.starts += 1
self.pid += 1
self.running = True
if self.transaction.receipt["status"] == "recovering":
self.runtime_version = "6.4.5"
self.unhealthy = False
def install(self, attempt, req):
if hashlib.sha256((attempt / "installer.sh").read_bytes()).hexdigest() != req["installer_sha256"]:
raise engine.Failure("installer-identity")
self.install_count += 1
self.runtime_version = req["version"].lstrip("v")
engine.PATHS["binary"].write_bytes(b"new executable")
engine.PATHS["unit"].write_bytes(b"new unit")
(engine.PATHS["data"] / "persisted-history").write_bytes(b"forward history")
def profile(self, req, unhealthy):
super().profile(req, unhealthy)
if self.unhealthy:
self.unhealthy = False
class TransactionTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
root = Path(self.tmp.name)
self.paths = {name: root / "estate" / name for name in engine.PATHS}
for name, path in self.paths.items():
path.parent.mkdir(parents=True, exist_ok=True)
if name in {"dropins", "data"}:
path.mkdir()
else:
path.write_bytes(b"old " + name.encode())
path.chmod(0o755 if name == "binary" else 0o644)
(self.paths["dropins"] / "limits.conf").write_bytes(b"old limits")
(self.paths["data"] / ".env").write_text("PRIVATE_TOKEN=not-logged\n")
(self.paths["data"] / "billing.json").write_text('{"capabilities":[],"integrity":"private"}')
(self.paths["data"] / "persisted-history").write_bytes(b"original history")
(self.paths["data"] / "alerts").mkdir()
(self.paths["data"] / "alerts/events.db").write_bytes(b"synthetic opaque persistent file")
self.root = root / "control"
self.attempt = self.root / "attempts" / ("a" * 64)
self.attempt.mkdir(parents=True)
(self.attempt / "installer.sh").write_bytes(b"signed-installer-fixture")
self.addCleanup(patch.stopall)
patch.object(engine, "ROOT", self.root).start()
patch.object(engine, "PATHS", self.paths).start()
self.before = engine.estate_hash(self.paths)
self.host = FixtureHost()
def run_transaction(self, mode="recover", version=None):
req = request(mode)
if version:
req["version"] = version
tx = engine.Transaction(self.host, req, self.attempt)
self.host.transaction = tx
result = tx.run()
self.assertEqual(tx.receipt, json.loads((self.attempt / "receipt.json").read_text()))
return result, tx.receipt
def test_healthy_profile_change_keeps_data_and_watches_complete_window(self):
code, receipt = self.run_transaction()
self.assertEqual(code, 0)
self.assertEqual(receipt["status"], "committed")
self.assertEqual(receipt["forward"]["elapsed_seconds"], 300)
self.assertEqual(receipt["forward"]["samples"], 61)
self.assertEqual((self.paths["data"] / "alerts/events.db").read_bytes(), b"synthetic opaque persistent file")
self.assertTrue((self.attempt / "snapshot/data/persisted-history").exists())
self.assertNotIn("not-logged", json.dumps(receipt))
def test_healthy_noop_requires_profile_and_full_watch_without_restart(self):
self.host.profile(request(), False)
code, receipt = self.run_transaction()
self.assertEqual(code, 0)
self.assertEqual(receipt["status"], "healthy_noop")
self.assertEqual(receipt["forward"]["elapsed_seconds"], 300)
self.assertFalse(receipt["mutated"])
self.assertEqual(self.host.stops, 0)
def test_update_restores_complete_changed_executable_unit_and_data_after_late_failure(self):
self.host.fail_at = 55
code, receipt = self.run_transaction("update", "v6.4.6")
self.assertEqual(code, 1)
self.assertEqual(receipt["status"], "rolled_back")
self.assertEqual(receipt["failure"], "new-service-crash")
self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300)
self.assertEqual(engine.estate_hash(self.paths), self.before)
self.assertEqual(self.host.runtime_version, "6.4.5")
def test_failure_at_299_seconds_still_collects_entire_recovery_and_cancellation(self):
self.host.fail_at = 299
self.host.cancel_in_recovery = True
previous = signal.getsignal(signal.SIGTERM)
code, receipt = self.run_transaction("update", "v6.4.6")
self.assertEqual(code, 1)
self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300)
self.assertGreaterEqual(self.host.clock, 600)
self.assertFalse(self.host.cancel_in_recovery)
self.assertEqual(signal.getsignal(signal.SIGTERM), previous)
self.assertEqual(engine.estate_hash(self.paths), self.before)
def test_forward_cancel_owns_full_recovery(self):
self.host.cancel_at = 55
code, receipt = self.run_transaction("update", "v6.4.6")
self.assertEqual(code, 1)
self.assertEqual(receipt["failure"], "cancelled-forward")
self.assertEqual(receipt["status"], "rolled_back")
self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300)
def test_failed_rollback_retains_estate_and_blocks_next_transaction(self):
self.host.fail_at = 55
self.host.rollback_failure = True
code, receipt = self.run_transaction("update", "v6.4.6")
self.assertEqual(code, 1)
self.assertEqual(receipt["status"], "rollback_failed")
self.assertTrue(receipt["snapshot_retained"])
next_attempt = self.root / "attempts" / ("b" * 64)
next_attempt.mkdir()
self.attempt = next_attempt
self.host.rollback_failure = False
code, receipt = self.run_transaction()
self.assertEqual(receipt["failure"], "prior-recovery-required")
self.assertFalse(receipt["mutated"])
def test_unhealthy_recovery_retains_original_generated_history(self):
self.host.unhealthy = True
code, receipt = self.run_transaction()
self.assertEqual(code, 0)
self.assertFalse(receipt["healthy_baseline"])
self.assertFalse((self.paths["data"] / "alerts/events.db").exists())
self.assertEqual((self.attempt / "snapshot/data/alerts/events.db").read_bytes(), b"synthetic opaque persistent file")
self.assertEqual(receipt["forward"]["elapsed_seconds"], 300)
def test_failed_unhealthy_recovery_is_not_a_healthy_rollback(self):
self.host.unhealthy = True
self.host.fail_at = 55
code, receipt = self.run_transaction()
self.assertEqual(code, 1)
self.assertEqual(receipt["status"], "recovery_required")
self.assertEqual(receipt["rollback"], "unavailable-unhealthy-baseline")
self.assertEqual(engine.estate_hash(self.paths), self.before)
self.assertFalse(self.host.running)
def test_update_refuses_unhealthy_baseline_without_stopping_or_pruning(self):
self.host.unhealthy = True
code, receipt = self.run_transaction("update", "v6.4.6")
self.assertEqual(code, 1)
self.assertEqual(receipt["failure"], "update-baseline-unhealthy")
self.assertEqual(self.host.stops, 0)
self.assertEqual(engine.estate_hash(self.paths), self.before)
def test_recovery_refuses_different_healthy_source(self):
code, receipt = self.run_transaction(version="v6.4.6")
self.assertEqual(code, 1)
self.assertEqual(receipt["failure"], "recovery-source-mismatch")
self.assertEqual(self.host.stops, 0)
def test_listener_readiness_does_not_spend_the_watch_window(self):
self.host.ready_until = 30
code, receipt = self.run_transaction()
self.assertEqual(code, 0)
self.assertEqual(receipt["forward"]["elapsed_seconds"], 300)
self.assertGreaterEqual(self.host.clock, 330)
self.assertEqual(self.host.starts, 1)
def test_missing_journal_fails_closed_and_retains_failure(self):
self.host.journal_failure = True
code, receipt = self.run_transaction()
self.assertEqual(code, 1)
self.assertEqual(receipt["status"], "rollback_failed")
self.assertEqual(receipt["recovery_failure"], "journal-observation")
def test_cohost_restart_is_not_silently_accepted_as_recovery(self):
self.host.co_host_change = True
code, receipt = self.run_transaction("update", "v6.4.6")
self.assertEqual(code, 1)
self.assertEqual(receipt["status"], "rollback_failed")
self.assertEqual(receipt["recovery_failure"], "cohost-identity-changed")
def test_snapshot_headroom_failure_never_deletes_persistent_files(self):
with patch.object(engine.shutil, "disk_usage", return_value=type("Usage", (), {"free": 0})()):
code, receipt = self.run_transaction()
self.assertEqual(code, 1)
self.assertEqual(receipt["status"], "rolled_back")
self.assertFalse(receipt["mutated"])
self.assertEqual(receipt["failure"], "snapshot-headroom")
self.assertEqual(engine.estate_hash(self.paths), self.before)
def test_linked_history_cannot_remove_files_outside_estate(self):
(self.paths["data"] / "alerts/events.db").unlink()
outside = self.root / "private-file"
outside.write_text("keep")
(self.paths["data"] / "alerts/events.db").symlink_to(outside)
self.host.unhealthy = True
code, receipt = self.run_transaction()
self.assertEqual(code, 1)
self.assertEqual(receipt["failure"], "history-symlink")
self.assertEqual(outside.read_text(), "keep")
def test_shared_host_lock_refusal_is_terminal_and_has_no_mutation(self):
lock = self.root / "host.lock"
fd = os.open(lock, os.O_CREAT | os.O_RDWR, 0o600)
self.addCleanup(os.close, fd)
engine.fcntl.flock(fd, engine.fcntl.LOCK_EX | engine.fcntl.LOCK_NB)
(self.attempt / "request.json").write_text(json.dumps(request()))
with patch.object(engine, "LOCK", lock), patch.object(engine.sys, "argv", ["transaction", str(self.attempt)]):
self.assertEqual(engine.main(), 1)
receipt = json.loads((self.attempt / "receipt.json").read_text())
self.assertEqual(receipt["status"], "refused")
self.assertFalse(receipt["mutated"])
self.assertEqual(engine.estate_hash(self.paths), self.before)
class InputAndCommandTest(unittest.TestCase):
def test_valid_current_envelope(self):
engine.validate(request())
engine.validate(request("update"))
def test_no_candidate_prerelease_arbitrary_service_url_or_profile(self):
cases = [("mode", "shell"), ("version", "v6.4.6-rc.1"), ("control_sha", "main"),
("local_url", "http://other-host:7655"), ("public_url", "http://demo.example/api/health"),
("public_url", "https://user:secret@demo.example/api/health"),
("public_url", "https://demo.example/api/health?token=secret"),
("installer_sha256", "arbitrary")]
for name, value in cases:
with self.subTest(name=name, value=value):
req = request(); req[name] = value
with self.assertRaises(engine.Failure):
engine.validate(req)
req = request(); req["service"] = "license"
with self.assertRaises(engine.Failure):
engine.validate(req)
req = request(); req["profile"]["nodes"] = "2; restart caddy"
with self.assertRaises(engine.Failure):
engine.validate(req)
def test_http_observation_rejects_redirect_and_transport_failure(self):
host = engine.Host()
with patch.object(host, "command", return_value="302") as command:
self.assertEqual(host.status("https://demo.example/api/health"), "302")
self.assertNotIn("--location", command.call_args.args[0])
self.assertIn("--disable", command.call_args.args[0])
self.assertIn("/dev/null", command.call_args.args[0])
with patch.object(host, "command", side_effect=engine.Failure("command-failed")):
self.assertEqual(host.status("https://demo.example/api/health"), "000")
def test_malformed_or_unavailable_journal_is_not_no_crash(self):
host = engine.Host()
for text in ["not-json", '{"MESSAGE":"fine"}', '{"MESSAGE":[],"__CURSOR":"c"}']:
with self.subTest(text=text), patch.object(host, "command", return_value=text):
with self.assertRaises(engine.Failure):
host.check_journal({"pulse": "c"})
with patch.object(host, "command", return_value='{"MESSAGE":"panic: fixture","__CURSOR":"d"}'):
with self.assertRaisesRegex(engine.Failure, "new-service-crash"):
host.check_journal({"pulse": "c"})
def test_journal_cursor_advances_without_returning_private_message(self):
host = engine.Host(); cursors = {"pulse": "c"}
with patch.object(host, "command", return_value='{"MESSAGE":"private normal message","__CURSOR":"d"}'):
host.check_journal(cursors)
self.assertEqual(cursors, {"pulse": "d"})
def test_no_journal_cursor_and_malformed_service_state_fail_closed(self):
host = engine.Host()
with patch.object(host, "command", return_value=""):
with self.assertRaises(engine.Failure):
host.cursors()
with self.assertRaises(engine.Failure):
host.state()
class DispatchLifecycleTest(unittest.TestCase):
def test_observer_budget_covers_child_and_recovery_and_no_wait_owned_by_ssh(self):
source = dispatcher.BOOTSTRAP
self.assertIn("RuntimeMaxSec=45min", source)
self.assertIn("TimeoutStopSec=20min", source)
self.assertIn("KillMode=mixed", source)
self.assertIn("+ 4000", source)
self.assertGreater(4000, 45 * 60 + 20 * 60)
self.assertNotIn("'--wait'", source)
self.assertNotIn("systemctl', 'stop'", source)
def test_bootstrap_retains_intent_and_repeated_or_uncertain_launch_does_not_replay(self):
# Execute the real bootstrap with only its filesystem/root/systemd/clock
# adapters rebound, including a lost submission response. No host tools.
for uncertain in (False, True):
with self.subTest(uncertain=uncertain), tempfile.TemporaryDirectory() as directory:
root = Path(directory) / "state"
source = dispatcher.BOOTSTRAP.replace("pathlib.Path('/var/lib/pulse-deploy/demo')", repr(root))
# repr(Path) is not a Python literal: bind just the fixed path.
source = dispatcher.BOOTSTRAP.replace("'/var/lib/pulse-deploy/demo'", repr(str(root)))
source = source.replace("root.stat().st_uid == 0", "root.stat().st_uid == os.getuid()")
clock = [0]
calls = []
payload = {"request": request(), "source": "c291cmNl", "installer": ""}
def launch(argv, **kwargs):
calls.append(argv)
attempt = next((root / "attempts").iterdir())
if uncertain:
raise subprocess.TimeoutExpired("systemd-run", 30)
(attempt / "receipt.json").write_text(json.dumps({"status": "committed"}))
return subprocess.CompletedProcess(argv, 0)
class Input:
def read(self, _limit):
return json.dumps(payload)
with patch.object(os, "geteuid", return_value=0), patch.object(subprocess, "run", side_effect=launch), \
patch.object(dispatcher.sys, "stdin", Input()), patch("time.monotonic", side_effect=lambda: clock[0]), \
patch("time.sleep", side_effect=lambda seconds: clock.__setitem__(0, clock[0] + seconds)), patch("builtins.print"):
for _ in range(2):
with self.assertRaises(SystemExit):
exec(compile(source, "fixed-bootstrap", "exec"), {})
self.assertEqual(len(calls), 1)
attempt = next((root / "attempts").iterdir())
self.assertTrue((attempt / "intent.json").exists())
self.assertEqual((attempt / "request.json").stat().st_mode & 0o777, 0o600)
self.assertEqual(attempt.stat().st_mode & 0o777, 0o700)
if uncertain:
self.assertEqual(json.loads((attempt / "launch.json").read_text())["state"], "uncertain")
if __name__ == "__main__":
unittest.main()