mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
a2fc29b70b
Retain the rejected VERSION rollback result. Replace general installer execution with pinned-signature archive selection and atomic binary/VERSION swaps, preserve unrelated distribution assets, and fail closed on missing matching native acceptance before production mutation. Change-source: pulse-maintainer
597 lines
28 KiB
Python
597 lines
28 KiB
Python
#!/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 tarfile
|
|
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"),
|
|
"version": Path("/opt/pulse/VERSION"),
|
|
"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/healthz"
|
|
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"}
|
|
MAX_BINARY = 128 * 1024 * 1024
|
|
|
|
|
|
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", "binary_sha256", "version_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")
|
|
for name in ("binary_sha256", "version_sha256"):
|
|
digest = request[name]
|
|
if (not isinstance(digest, str)
|
|
or (request["mode"] == "update" and not re.fullmatch(r"[0-9a-f]{64}", digest))
|
|
or (request["mode"] == "recover" and digest != "")):
|
|
raise Failure("request-runtime")
|
|
|
|
|
|
def runtime_members(archive, version):
|
|
"""Select two regular files from an already signature-verified archive.
|
|
|
|
Never extract archive paths or execute its installer. This demo-only route
|
|
does not update bundled agents, scripts, helpers, units or timer assets.
|
|
"""
|
|
files = {}
|
|
count = 0
|
|
with tarfile.open(archive, "r:gz") as stream:
|
|
for member in stream:
|
|
count += 1
|
|
if count > 4096:
|
|
raise Failure("archive-member-bound")
|
|
name = member.name.removeprefix("./")
|
|
if name not in {"pulse", "bin/pulse", "VERSION"}:
|
|
continue
|
|
label = "version" if name == "VERSION" else "binary"
|
|
if (label in files or not member.isfile() or member.issparse()
|
|
or not 0 < member.size <= (128 if label == "version" else MAX_BINARY)):
|
|
raise Failure("archive-runtime-shape")
|
|
with stream.extractfile(member) as source:
|
|
files[label] = source.read(member.size + 1)
|
|
if len(files[label]) != member.size:
|
|
raise Failure("archive-runtime-size")
|
|
if set(files) != {"binary", "version"}:
|
|
raise Failure("archive-runtime-missing")
|
|
if files["version"].strip() not in {version.encode(), version.removeprefix("v").encode()}:
|
|
raise Failure("archive-runtime-version")
|
|
# The demo estate is the existing Linux/amd64 server, not a new platform.
|
|
data = files["binary"]
|
|
if len(data) < 64 or data[:6] != b"\x7fELF\x02\x01" or data[18:20] != b"\x3e\x00":
|
|
raise Failure("archive-runtime-architecture")
|
|
return files
|
|
|
|
|
|
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")
|
|
if PATHS["version"].is_symlink() or any(p.is_symlink() for p in PATHS["binary"].parents):
|
|
raise Failure("distribution-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", "--quiet", "--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):
|
|
# Trust is checked against the pinned release key in CI, before these
|
|
# exact bytes enter the authenticated SSH envelope. Check the retained
|
|
# byte identities again before any swap; no arbitrary shell is run.
|
|
staged = {label: attempt / label for label in ("binary", "version")}
|
|
for label, path in staged.items():
|
|
if path.is_symlink() or not path.is_file():
|
|
raise Failure("runtime-payload-shape")
|
|
if not 0 < path.stat().st_size <= (128 if label == "version" else MAX_BINARY):
|
|
raise Failure("runtime-payload-bound")
|
|
if hashlib.sha256(path.read_bytes()).hexdigest() != request[label + "_sha256"]:
|
|
raise Failure("runtime-payload-identity")
|
|
if staged["version"].read_bytes().strip() not in {
|
|
request["version"].encode(), request["version"].removeprefix("v").encode()}:
|
|
raise Failure("runtime-payload-version")
|
|
for label, source in staged.items():
|
|
destination = PATHS[label]
|
|
# Sibling staging keeps replacement atomic; owned recovery removes
|
|
# the sibling if a failure interrupts either of the two swaps.
|
|
temporary = destination.with_name(destination.name + ".demo-new")
|
|
if temporary.exists() or temporary.is_symlink():
|
|
raise Failure("runtime-staging-exists")
|
|
try:
|
|
with open(source, "rb") as src, open(temporary, "xb") as dst:
|
|
shutil.copyfileobj(src, dst)
|
|
owner = PATHS["binary"].stat()
|
|
os.fchown(dst.fileno(), owner.st_uid, owner.st_gid)
|
|
os.fchmod(dst.fileno(), 0o755 if label == "binary" else 0o644)
|
|
dst.flush()
|
|
os.fsync(dst.fileno())
|
|
os.replace(temporary, destination)
|
|
fd = os.open(destination.parent, os.O_DIRECTORY)
|
|
try:
|
|
os.fsync(fd)
|
|
finally:
|
|
os.close(fd)
|
|
finally:
|
|
if temporary.is_file() and not temporary.is_symlink():
|
|
temporary.unlink()
|
|
|
|
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)
|
|
data_owner = PATHS["data"].stat()
|
|
os.chown(env_file, data_owner.st_uid, data_owner.st_gid)
|
|
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.chown(billing_file, data_owner.st_uid, data_owner.st_gid)
|
|
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"],
|
|
"footprint": "server-binary-version-and-demo-data",
|
|
"binary_sha256": request["binary_sha256"], "version_sha256": request["version_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,
|
|
"initial_services": baseline})
|
|
while True:
|
|
self.host.check_journal(cursors)
|
|
state = self.host.state()
|
|
evidence["last_services"] = state
|
|
if state != baseline or not self.healthy(state):
|
|
raise Failure("sustained-health")
|
|
evidence["observed_version"] = self.host.version(self.request["local_url"])
|
|
if evidence["observed_version"] != expected_version:
|
|
raise Failure("runtime-version")
|
|
runtime = estate_hash({name: PATHS[name] for name in ("binary", "version")})
|
|
expected = self.receipt.get("runtime_snapshot_sha256" if phase == "recovery" else "installed_runtime_sha256")
|
|
if expected and runtime != expected:
|
|
raise Failure("distribution-state-changed")
|
|
evidence["runtime_sha256"] = runtime
|
|
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())
|
|
# Keep room for the independent estate and the two atomic runtime swaps.
|
|
# Never run the installer's backup rotation or delete data 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["runtime_snapshot_sha256"] = estate_hash({name: PATHS[name] for name in ("binary", "version")})
|
|
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
|
|
self.receipt["baseline_services"] = states
|
|
self.receipt["baseline_version"] = original_version
|
|
old_handlers = {sig: signal.getsignal(sig) for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP)}
|
|
for sig in old_handlers:
|
|
signal.signal(sig, cancel)
|
|
# 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
|
|
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)
|
|
self.receipt["installed_runtime_sha256"] = estate_hash({
|
|
name: PATHS[name] for name in ("binary", "version")})
|
|
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())
|