diff --git a/.github/scripts/demo-runtime-transaction.py b/.github/scripts/demo-runtime-transaction.py index 6f12dfebf2..ca3984c65b 100644 --- a/.github/scripts/demo-runtime-transaction.py +++ b/.github/scripts/demo-runtime-transaction.py @@ -14,6 +14,7 @@ import socket import stat import subprocess import sys +import tarfile import time from urllib.parse import urlsplit @@ -24,6 +25,7 @@ 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")} @@ -40,6 +42,7 @@ DEMO_HISTORY = ("ai_incidents.json", "ai_incidents.json.tmp", "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): @@ -62,7 +65,7 @@ def atomic_json(path, value): def validate(request): - if set(request) != {"mode", "hostname", "local_url", "public_url", "version", "profile", "control_sha", "run_id", "run_attempt", "installer_sha256"}: + 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") @@ -92,9 +95,47 @@ def validate(request): 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") + 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): @@ -175,6 +216,8 @@ class Host: 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") @@ -244,11 +287,44 @@ class Host: 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"}) + # 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", @@ -308,7 +384,9 @@ class Transaction: 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"], + "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): @@ -346,6 +424,11 @@ class Transaction: 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 @@ -358,8 +441,8 @@ class Transaction: 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. + # 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") @@ -372,6 +455,7 @@ class Transaction: 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 @@ -433,8 +517,8 @@ class Transaction: 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.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) diff --git a/.github/scripts/dispatch-demo-runtime.py b/.github/scripts/dispatch-demo-runtime.py index a56e018ff0..815735075e 100644 --- a/.github/scripts/dispatch-demo-runtime.py +++ b/.github/scripts/dispatch-demo-runtime.py @@ -4,7 +4,10 @@ from __future__ import annotations import base64 import hashlib +import importlib.util import json +import datetime +import re import os from pathlib import Path import shlex @@ -14,12 +17,14 @@ 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'} +payload = json.loads(sys.stdin.read(192 * 1024 * 1024 + 1)) +assert os.geteuid() == 0 and set(payload) == {'request', 'source', 'binary', 'version_file'} 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'] == '') +binary = base64.b64decode(payload['binary'], validate=True) +version_file = base64.b64decode(payload['version_file'], validate=True) +assert len(source) < 128 * 1024 and len(binary) <= 128 * 1024 * 1024 and len(version_file) <= 128 +for name, data in [('binary', binary), ('version', version_file)]: + assert (hashlib.sha256(data).hexdigest() == payload['request'][name + '_sha256']) if data else (payload['request'][name + '_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 @@ -31,7 +36,7 @@ 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), + for name, data in [('transaction.py', source), ('binary', binary), ('version', version_file), ('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: @@ -83,20 +88,103 @@ def payload(mode, environ=os.environ): ('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'' + binary = Path('/tmp/pulse-demo-binary').read_bytes() if mode == 'update' else b'' + version_file = Path('/tmp/pulse-demo-VERSION').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 ''} + 'binary_sha256': hashlib.sha256(binary).hexdigest() if binary else '', + 'version_sha256': hashlib.sha256(version_file).hexdigest() if version_file else ''} return {'request': request, 'source': base64.b64encode(Path(__file__).with_name('demo-runtime-transaction.py').read_bytes()).decode(), - 'installer': base64.b64encode(installer).decode()} + 'binary': base64.b64encode(binary).decode(), 'version_file': base64.b64encode(version_file).decode()} + + +NATIVE_PATHS = ('.github/scripts/demo-runtime-transaction.py', + '.github/scripts/dispatch-demo-runtime.py', + '.github/scripts/tests/demo_runtime_native.py', + '.github/workflows/demo-runtime-native.yml') + + +def require_native(environ=os.environ): + """Fail closed before SSH; a metadata/component pass is not native proof.""" + sha, repository = environ['GITHUB_SHA'], environ['GITHUB_REPOSITORY'] + if not re.fullmatch(r'[0-9a-f]{40}', sha) or repository != 'rcourtman/Pulse': + raise ValueError('native-source-identity') + since = (datetime.datetime.now(datetime.timezone.utc).date() - datetime.timedelta(days=6)).isoformat() + base = f'repos/{repository}/actions/workflows/demo-runtime-native.yml/runs?branch=main&event=push&per_page=5' + + def read(query): + result = subprocess.run(['gh', 'api', query], capture_output=True, timeout=30, check=False) + if result.returncode: + raise ValueError('native-evidence-unavailable') + rows = json.loads(result.stdout)['workflow_runs'] + if not isinstance(rows, list) or any(not isinstance(row, dict) for row in rows): + raise ValueError('native-evidence-malformed') + for row in rows: + if (type(row.get('id')) is not int or not re.fullmatch(r'[0-9a-f]{40}', str(row.get('head_sha'))) + or type(row.get('run_attempt')) is not int or not isinstance(row.get('updated_at'), str) + or not isinstance(row.get('created_at'), str)): + raise ValueError('native-evidence-malformed') + return rows + + rows = read(base + '&created=' + since + '..*') + if len(rows) < 5: + rows += read(base) + merged = {} + for row in rows: + old = merged.get(row['id']) + if old is None or (row['run_attempt'], row['updated_at']) > (old['run_attempt'], old['updated_at']): + merged[row['id']] = row + for row in sorted(merged.values(), key=lambda r: (r['created_at'], r['id']), reverse=True): + head = row['head_sha'] + if (row.get('event') != 'push' or row.get('head_branch') != 'main' + or row.get('path') != '.github/workflows/demo-runtime-native.yml'): + raise ValueError('native-run-identity') + ancestor = subprocess.run(['git', 'merge-base', '--is-ancestor', head, sha], capture_output=True) + if ancestor.returncode != 0: + continue + matching = subprocess.run(['git', 'diff', '--quiet', head, sha, '--', *NATIVE_PATHS], capture_output=True) + if matching.returncode == 1: + continue + if matching.returncode != 0: + raise ValueError('native-source-unavailable') + # Never select an older green sample over a newer matching failure or + # pending run. The native driver exits success only with full cleanup. + if row.get('status') != 'completed' or row.get('conclusion') != 'success': + raise ValueError('native-acceptance-not-passed') + return {'run_id': row['id'], 'run_attempt': row['run_attempt'], 'source_sha': head} + raise ValueError('native-acceptance-missing') + + +def prepare_runtime(): + version = os.environ['EXPECTED_VERSION'] + if not re.fullmatch(r'v[0-9]+\.[0-9]+\.[0-9]+', version): + raise ValueError('release-version') + spec = importlib.util.spec_from_file_location('demo_runtime', Path(__file__).with_name('demo-runtime-transaction.py')) + engine = importlib.util.module_from_spec(spec); spec.loader.exec_module(engine) + # Called only after mandatory pinned-key SSH signature verification in CI. + files = engine.runtime_members('/tmp/pulse-demo-release.tgz', version) + for label, name in (('binary', '/tmp/pulse-demo-binary'), ('version', '/tmp/pulse-demo-VERSION')): + with open(name, 'xb') as stream: + os.chmod(name, 0o600) + stream.write(files[label]) def main(): - if len(sys.argv) != 2 or sys.argv[1] not in {'update', 'recover'}: + if len(sys.argv) != 2 or sys.argv[1] not in {'prepare', 'update', 'recover'}: return 2 + if sys.argv[1] == 'prepare': + prepare_runtime() + return 0 + # Check exact reviewed native source before opening the production SSH path. + try: + require_native() + except (ValueError, KeyError, OSError, subprocess.TimeoutExpired): + print(json.dumps({'schema_version': 2, 'status': 'refused', 'mutated': False, + 'failure': 'native-acceptance-unavailable'})) + return 1 # 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', diff --git a/.github/scripts/tests/demo_runtime_native.py b/.github/scripts/tests/demo_runtime_native.py index 0b7fc6a73b..e381e2e435 100644 --- a/.github/scripts/tests/demo_runtime_native.py +++ b/.github/scripts/tests/demo_runtime_native.py @@ -31,6 +31,9 @@ def binary(version, fail_after=0, port=17655): return ('''#!/usr/bin/python3 import http.server,json,os,threading,time VERSION = %r +if %r == 17655: + from pathlib import Path + Path('/etc/pulse/persistent-marker').write_text('candidate data ' + VERSION) def fail(): time.sleep(%r) print('panic: synthetic disposable service fault',flush=True) @@ -45,29 +48,13 @@ class Handler(http.server.BaseHTTPRequestHandler): self.send_response(200); self.end_headers(); self.wfile.write(body) def log_message(self,*args): pass http.server.HTTPServer(('127.0.0.1',%r),Handler).serve_forever() -''' % (version, fail_after, fail_after, port, port)).encode() +''' % (version, port, fail_after, fail_after, port, port)).encode() def unit(executable, user="pulse"): return f"[Unit]\nDescription=Disposable demo native fixture\n[Service]\nUser={user}\nGroup={user}\nExecStart={executable}\nRestart=no\n[Install]\nWantedBy=multi-user.target\n" -def installer(version, fail_after=0): - source = binary(version, fail_after) - return ("#!/usr/bin/python3\nfrom pathlib import Path\n" - "import base64\n" - f"p=Path('/opt/pulse/bin/pulse');p.write_bytes(base64.b64decode({base64.b64encode(source).decode()!r}));p.chmod(0o755)\n" - "p=Path('/etc/systemd/system/pulse.service');p.write_text(p.read_text()+'# candidate fixture\\n')\n" - f"Path('/etc/pulse/persistent-marker').write_text({'candidate data ' + version!r})\n").encode() - - -def fixture_installer(version, fail_after=0): - # The real engine invokes bash like the published installer. This fixture - # deliberately changes executable, unit and persistent data without download. - script = installer(version, fail_after) - return ("#!/bin/bash\nset -eu\npython3 - <<'PY'\n" + script.decode().split("\n", 1)[1] + "\nPY\n").encode() - - def wait(predicate, limit): deadline = time.monotonic() + limit while time.monotonic() < deadline: @@ -110,6 +97,13 @@ def main(): for path in Path("/etc/pulse").iterdir(): os.chown(path, owner.pw_uid, owner.pw_gid) Path("/opt/pulse/bin/pulse").write_bytes(binary("1.0.0")); Path("/opt/pulse/bin/pulse").chmod(0o755) + Path("/opt/pulse/VERSION").write_text("1.0.0\n") + Path("/opt/pulse/scripts").mkdir() + untouched = {"agent": Path("/opt/pulse/bin/pulse-agent-fixture"), + "script": Path("/opt/pulse/scripts/install-fixture.sh")} + for path in untouched.values(): + path.write_bytes(b"unchanged bundled distribution asset") + untouched_before = {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in untouched.items()} Path("/opt/pulse/bin/relay").write_bytes(binary("1.0.0", port=17656)); Path("/opt/pulse/bin/relay").chmod(0o755) for name, executable in (("pulse", "/opt/pulse/bin/pulse"), ("pulse-relay", "/opt/pulse/bin/relay")): Path(f"/etc/systemd/system/{name}.service").write_text(unit(executable)) @@ -144,13 +138,16 @@ def main(): profile.update(seed_duration="2h", sample_interval="5m", update_interval="15s") def submit(version, fault=0): - install = fixture_installer(version, fault) + candidate = binary(version, fault) + metadata = (version + "\n").encode() request = {"mode": "update", "hostname": socket.gethostname(), "local_url": "http://127.0.0.1:17655", "public_url": "https://127.0.0.1:18443/api/health", "version": "v" + version, "profile": profile, "control_sha": os.environ["GITHUB_SHA"], "run_id": os.environ["GITHUB_RUN_ID"], "run_attempt": os.environ["GITHUB_RUN_ATTEMPT"], - "installer_sha256": hashlib.sha256(install).hexdigest()} - payload = {"request": request, "source": base64.b64encode(source).decode(), "installer": base64.b64encode(install).decode()} + "binary_sha256": hashlib.sha256(candidate).hexdigest(), + "version_sha256": hashlib.sha256(metadata).hexdigest()} + payload = {"request": request, "source": base64.b64encode(source).decode(), + "binary": base64.b64encode(candidate).decode(), "version_file": base64.b64encode(metadata).decode()} identity = hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode()).hexdigest() proc = subprocess.Popen(["python3", "-c", dispatcher.BOOTSTRAP], stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE) processes.append(proc); proc.stdin.write(json.dumps(payload).encode()); proc.stdin.close() @@ -165,6 +162,8 @@ def main(): receipt = json.loads((attempt / "receipt.json").read_text()); result["cases"].append(receipt) assert proc.returncode == 0 and receipt["status"] == "committed" and receipt["forward"]["elapsed_seconds"] >= 300 assert Path("/etc/pulse/persistent-marker").read_text() == "candidate data 1.0.1" + assert Path("/opt/pulse/VERSION").read_text() == "1.0.1\n" + assert {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in untouched.items()} == untouched_before # A later candidate fails at 55s; lose the observer while the owned # systemd child continues, then TERM during actual full recovery. proc, attempt, payload = submit("1.0.2", 55) @@ -180,6 +179,8 @@ def main(): assert replay.returncode == 1 and receipt["status"] == "rolled_back" and receipt["recovery"]["elapsed_seconds"] >= 300 assert receipt["failure"] in {"new-service-crash", "sustained-health"} assert Path("/etc/pulse/persistent-marker").read_text() == "candidate data 1.0.1" + assert Path("/opt/pulse/VERSION").read_text() == "1.0.1\n" + assert {name: hashlib.sha256(path.read_bytes()).hexdigest() for name, path in untouched.items()} == untouched_before version = json.loads(command(["curl", "--disable", "--silent", "--fail", "http://127.0.0.1:17655/api/version"]))["version"] assert version == "1.0.1" result["stage"] = "complete" diff --git a/.github/scripts/tests/test_demo_runtime_transaction.py b/.github/scripts/tests/test_demo_runtime_transaction.py index 86bc90e60c..7215be25a6 100644 --- a/.github/scripts/tests/test_demo_runtime_transaction.py +++ b/.github/scripts/tests/test_demo_runtime_transaction.py @@ -1,5 +1,7 @@ import hashlib import importlib.util +import io +import tarfile import json import os from pathlib import Path @@ -29,7 +31,8 @@ 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 "", + "binary_sha256": hashlib.sha256(b"changed binary").hexdigest() if mode == "update" else "", + "version_sha256": hashlib.sha256(b"6.4.6\n").hexdigest() if mode == "update" else "", "profile": {**{name: "2" for name in engine.COUNT_KEYS}, "seed_duration": "2h", "sample_interval": "5m", "update_interval": "15s"}} @@ -123,13 +126,10 @@ class FixtureHost(engine.Host): 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") + # Keep the clock/services virtual, but use the real narrow file swaps. + super().install(attempt, req) 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) @@ -159,7 +159,15 @@ class TransactionTest(unittest.TestCase): 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.paths["version"].write_text("6.4.5\n") + (self.attempt / "binary").write_bytes(b"changed binary") + (self.attempt / "version").write_bytes(b"6.4.6\n") + # Untouched distribution/service assets are outside the chosen operation. + self.untouched = {name: root / "unowned" / name for name in + ("agent", "scripts", "helper", "auto-update", "timer", "symlink", "marker", "backup")} + for name, path in self.untouched.items(): + path.parent.mkdir(parents=True, exist_ok=True); path.write_text("original " + name) + self.untouched_before = engine.estate_hash(self.untouched) self.addCleanup(patch.stopall) patch.object(engine, "ROOT", self.root).start() patch.object(engine, "PATHS", self.paths).start() @@ -222,6 +230,34 @@ class TransactionTest(unittest.TestCase): self.assertEqual(engine.estate_hash(self.paths), self.before) self.assertEqual(self.host.runtime_version, "6.4.5") + def test_success_changes_only_runtime_and_profile_leaving_distribution_assets(self): + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual((code, receipt["status"]), (0, "committed")) + self.assertEqual(self.paths["binary"].read_bytes(), b"changed binary") + self.assertEqual(self.paths["version"].read_text(), "6.4.6\n") + for name in ("unit", "dropins"): + self.assertEqual(engine.estate_hash({name: self.paths[name]}), + engine.estate_hash({name: self.attempt / "snapshot" / name})) + self.assertEqual(engine.estate_hash(self.untouched), self.untouched_before) + self.assertEqual(receipt["forward"]["elapsed_seconds"], 300) + + def test_late_failure_restores_VERSION_and_runtime_with_untouched_helpers(self): + self.host.fail_at = 55 + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual((code, receipt["status"]), (1, "rolled_back")) + self.assertEqual(self.paths["version"].read_text(), "6.4.5\n") + self.assertEqual(engine.estate_hash(self.paths), self.before) + self.assertEqual(engine.estate_hash(self.untouched), self.untouched_before) + self.assertEqual(receipt["recovery"]["runtime_sha256"], receipt["runtime_snapshot_sha256"]) + self.assertEqual(receipt["recovery"]["elapsed_seconds"], 300) + + def test_payload_version_mismatch_restores_without_installing(self): + (self.attempt / "version").write_text("6.4.7\n") + code, receipt = self.run_transaction("update", "v6.4.6") + self.assertEqual((code, receipt["status"]), (1, "rolled_back")) + self.assertEqual(receipt["failure"], "runtime-payload-identity") + self.assertEqual(engine.estate_hash(self.paths), self.before) + def test_failure_at_299_seconds_still_collects_entire_recovery_and_cancellation(self): self.host.fail_at = 299 self.host.cancel_in_recovery = True @@ -359,7 +395,7 @@ class InputAndCommandTest(unittest.TestCase): ("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")] + ("binary_sha256", "arbitrary"), ("version_sha256", "arbitrary")] for name, value in cases: with self.subTest(name=name, value=value): req = request(); req[name] = value @@ -435,7 +471,7 @@ class DispatchLifecycleTest(unittest.TestCase): source = source.replace("root.stat().st_uid == 0", "root.stat().st_uid == os.getuid()") clock = [0] calls = [] - payload = {"request": request(), "source": "c291cmNl", "installer": ""} + payload = {"request": request(), "source": "c291cmNl", "binary": "", "version_file": ""} def launch(argv, **kwargs): calls.append(argv) @@ -490,13 +526,103 @@ class NativeAdmissionTest(unittest.TestCase): self.assertEqual(result["failure_code"], "not-an-empty-disposable-public-ci-runner") self.assertTrue(existing.is_dir()) - def test_synthetic_changed_executable_and_data_are_distinct_between_steps(self): + def test_synthetic_changed_executables_have_actual_persistent_effects(self): compile(native.binary("1.0.1"), "fixture-runtime", "exec") - compile(native.installer("1.0.1"), "fixture-installer", "exec") self.assertNotEqual(native.binary("1.0.1"), native.binary("1.0.2", 55)) - self.assertIn(b"candidate data 1.0.1", native.installer("1.0.1")) - self.assertIn(b"candidate data 1.0.2", native.installer("1.0.2", 55)) - self.assertNotIn(b"curl", native.fixture_installer("1.0.1")) + self.assertIn(b"persistent-marker", native.binary("1.0.1")) + self.assertNotIn(b"curl", native.binary("1.0.1")) + + + +class RuntimeSelectionTest(unittest.TestCase): + def archive(self, directory, entries): + path = Path(directory) / 'release.tgz' + with tarfile.open(path, 'w:gz') as stream: + for name, content, kind in entries: + member = tarfile.TarInfo(name); member.size = len(content); member.type = kind + if kind == tarfile.SYMTYPE: + member.linkname = '/outside'; member.size = 0 + stream.addfile(member) + else: + stream.addfile(member, io.BytesIO(content)) + return path + + def elf(self): + body = bytearray(64); body[:6] = b'\x7fELF\x02\x01'; body[18:20] = b'\x3e\x00' + return bytes(body) + + def test_selects_only_signed_archive_runtime_without_extracting_paths(self): + with tempfile.TemporaryDirectory() as directory: + entries = [('bin/pulse', self.elf(), tarfile.REGTYPE), ('./VERSION', b'6.4.6\n', tarfile.REGTYPE), + ('../outside', b'untrusted path', tarfile.REGTYPE), + ('scripts/install.sh', b'never executed', tarfile.REGTYPE), + ('bin/pulse-agent', b'not installed', tarfile.REGTYPE)] + files = engine.runtime_members(self.archive(directory, entries), 'v6.4.6') + self.assertEqual(set(files), {'binary', 'version'}) + self.assertEqual(files['binary'], self.elf()) + self.assertEqual([p.name for p in Path(directory).iterdir()], ['release.tgz']) + + def test_duplicate_link_wrong_version_or_architecture_is_rejected(self): + valid = [('bin/pulse', self.elf(), tarfile.REGTYPE), ('VERSION', b'6.4.6\n', tarfile.REGTYPE)] + cases = [valid + [('pulse', self.elf(), tarfile.REGTYPE)], + [valid[0], ('VERSION', b'', tarfile.SYMTYPE)], + [valid[0], ('VERSION', b'6.4.7\n', tarfile.REGTYPE)], + [('bin/pulse', b'#!/bin/sh\n', tarfile.REGTYPE), valid[1]], [valid[0]]] + for entries in cases: + with self.subTest(entries=[x[0] for x in entries]), tempfile.TemporaryDirectory() as directory: + with self.assertRaises(engine.Failure): + engine.runtime_members(self.archive(directory, entries), 'v6.4.6') + + +class NativeGateTest(unittest.TestCase): + def row(self, run_id=10, **fields): + return {'id': run_id, 'head_sha': 'a' * 40, 'run_attempt': 1, + 'created_at': '2026-10-03T01:00:00Z', 'updated_at': '2026-10-03T01:10:00Z', + 'path': '.github/workflows/demo-runtime-native.yml', 'event': 'push', 'head_branch': 'main', + 'status': 'completed', 'conclusion': 'success', **fields} + + def check(self, rows, history=None, read_exit=0, same=True): + calls = [] + def command(argv, **kwargs): + calls.append(argv) + if argv[0] == 'gh': + payload = rows if 'created=' in argv[-1] else (history if history is not None else rows) + return subprocess.CompletedProcess(argv, read_exit, json.dumps({'workflow_runs': payload}).encode()) + return subprocess.CompletedProcess(argv, 0 if argv[1] == 'merge-base' or same else 1) + with patch.object(dispatcher.subprocess, 'run', side_effect=command): + result = dispatcher.require_native({'GITHUB_SHA': 'b' * 40, 'GITHUB_REPOSITORY': 'rcourtman/Pulse'}) + return result, calls + + def test_matching_reviewed_native_source_passes_with_current_first_inventory(self): + result, calls = self.check([self.row()]) + self.assertEqual(result['run_id'], 10) + reads = [c for c in calls if c[0] == 'gh'] + self.assertEqual(len(reads), 2) + self.assertIn('created=', reads[0][-1]); self.assertNotIn('created=', reads[1][-1]) + self.assertEqual(set(calls[-1][6:]), set(dispatcher.NATIVE_PATHS)) + + def test_newer_matching_failure_or_pending_run_cannot_use_older_green(self): + for fields in ({'conclusion': 'failure'}, {'status': 'in_progress', 'conclusion': None}): + with self.subTest(fields=fields), self.assertRaisesRegex(ValueError, 'not-passed'): + self.check([self.row(11, **fields), self.row(10)]) + + def test_latest_duplicate_attempt_retains_failure(self): + with self.assertRaisesRegex(ValueError, 'not-passed'): + self.check([self.row()], [self.row(run_attempt=2, conclusion='failure')]) + + def test_missing_changed_or_misidentified_proof_cannot_enable_mutation(self): + for rows, same in (([], True), ([self.row()], False), ([self.row(event='workflow_dispatch')], True)): + with self.subTest(rows=rows, same=same), self.assertRaises(ValueError): + self.check(rows, same=same) + + def test_refused_read_has_no_history_fallback_or_ssh(self): + calls = [] + def denied(argv, **kwargs): + calls.append(argv); return subprocess.CompletedProcess(argv, 1, b'') + with patch.object(dispatcher.subprocess, 'run', side_effect=denied), self.assertRaises(ValueError): + dispatcher.require_native({'GITHUB_SHA': 'b' * 40, 'GITHUB_REPOSITORY': 'rcourtman/Pulse'}) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0:2], ['gh', 'api']) if __name__ == "__main__": diff --git a/.github/workflows/deploy-demo-server.yml b/.github/workflows/deploy-demo-server.yml index bd6a2e4b63..3ca779668f 100644 --- a/.github/workflows/deploy-demo-server.yml +++ b/.github/workflows/deploy-demo-server.yml @@ -14,6 +14,7 @@ jobs: name: Verify Current Committed Stable Demo (No Mutation) permissions: contents: read + actions: read uses: ./.github/workflows/update-demo-server.yml secrets: inherit with: diff --git a/.github/workflows/recover-demo-server.yml b/.github/workflows/recover-demo-server.yml index b0dc92f2c0..5e6b0f37b8 100644 --- a/.github/workflows/recover-demo-server.yml +++ b/.github/workflows/recover-demo-server.yml @@ -5,6 +5,7 @@ on: permissions: contents: read + actions: read concurrency: # This is the same physical target lock used by update-demo-server.yml. @@ -99,6 +100,7 @@ jobs: - name: Recover current installed runtime id: recovery env: + GH_TOKEN: ${{ github.token }} DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} EXPECTED_VERSION: ${{ steps.stable.outputs.tag }} diff --git a/.github/workflows/release-convergence.yml b/.github/workflows/release-convergence.yml index 19aa282df1..736580dfee 100644 --- a/.github/workflows/release-convergence.yml +++ b/.github/workflows/release-convergence.yml @@ -340,6 +340,7 @@ jobs: if: ${{ needs.acquire_customer_promotion_lease.result == 'success' && needs.acquire_customer_promotion_lease.outputs.superseded != 'true' && inputs.prerelease != true && startsWith(inputs.version, '6.') }} permissions: contents: read + actions: read uses: ./.github/workflows/update-demo-server.yml secrets: inherit with: diff --git a/.github/workflows/release-dry-run.yml b/.github/workflows/release-dry-run.yml index 3ea8d6fd08..0789d377a3 100644 --- a/.github/workflows/release-dry-run.yml +++ b/.github/workflows/release-dry-run.yml @@ -639,6 +639,7 @@ jobs: needs: dry-run permissions: contents: read + actions: read uses: ./.github/workflows/update-demo-server.yml secrets: inherit with: diff --git a/.github/workflows/update-demo-server.yml b/.github/workflows/update-demo-server.yml index 214115ebbe..f39ac182d0 100644 --- a/.github/workflows/update-demo-server.yml +++ b/.github/workflows/update-demo-server.yml @@ -40,6 +40,7 @@ on: permissions: contents: read + actions: read # GitHub environments gate secret access and deployment admission, but do not # serialize jobs. Share one resource lock with the recovery workflow so two @@ -343,23 +344,27 @@ jobs: echo "::error::Timeout waiting for release assets" exit 1 - - name: Materialize tagged installer + - name: Verify signed release and select only demo server runtime if: inputs.verify_only != true env: PULSE_UPDATE_SIGNING_PUBLIC_KEY: ${{ vars.PULSE_UPDATE_SIGNING_PUBLIC_KEY }} - WORKFLOW_OUTPUT_1: ${{ needs.resolve.outputs.tag }} + EXPECTED_VERSION: ${{ needs.resolve.outputs.tag }} run: | set -euo pipefail - TAG="${WORKFLOW_OUTPUT_1}" TRUSTED_SSH_PUBLIC_KEY="$( go run ./scripts/release_update_key.go public-key-ssh \ --public-key "${PULSE_UPDATE_SIGNING_PUBLIC_KEY}" \ --comment pulse-installer )" - git show "refs/tags/${TAG}:install.sh" > /tmp/pulse-install.sh - sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh - grep -F "PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"" /tmp/pulse-install.sh - chmod +x /tmp/pulse-install.sh + asset="pulse-${EXPECTED_VERSION}-linux-amd64.tar.gz" + curl --disable --fail --location --silent --show-error --max-time 300 \ + -o /tmp/pulse-demo-release.tgz "https://github.com/rcourtman/Pulse/releases/download/${EXPECTED_VERSION}/${asset}" + curl --disable --fail --location --silent --show-error --max-time 30 \ + -o /tmp/pulse-demo-release.sshsig "https://github.com/rcourtman/Pulse/releases/download/${EXPECTED_VERSION}/${asset}.sshsig" + printf 'pulse-installer %s\n' "$TRUSTED_SSH_PUBLIC_KEY" > /tmp/pulse-demo-signers + ssh-keygen -Y verify -f /tmp/pulse-demo-signers -I pulse-installer \ + -n pulse-install -s /tmp/pulse-demo-release.sshsig < /tmp/pulse-demo-release.tgz + python3 .github/scripts/dispatch-demo-runtime.py prepare - name: Tailscale id: tailscale @@ -453,6 +458,7 @@ jobs: - name: Apply guarded demo transaction if: inputs.verify_only != true env: + GH_TOKEN: ${{ github.token }} DEMO_SERVER_HOST: ${{ secrets.DEMO_SERVER_HOST }} DEMO_SERVER_USER: ${{ secrets.DEMO_SERVER_USER }} EXPECTED_VERSION: ${{ needs.resolve.outputs.tag }} diff --git a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md index 106e939ffc..0202d8e0c0 100644 --- a/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md +++ b/docs/release-control/v6/internal/DEMO_RUNTIME_TRANSACTION.md @@ -9,13 +9,17 @@ workflow queue does not exclude another repository's host deployment. - Both existing mutating workflows submit the same reviewed Python engine. Update still requires the immutable stable activation and customer-promotion - lease, exact tagged installer and trusted release signing key. Verification + lease and archive verification against the trusted release signing key. Only + the signed Linux/amd64 server binary and VERSION are selected; no installer is + executed. The demo does not serve enrolled agents, so updating its bundled + agents/scripts or updater/service assets is not part of this runtime operation. Verification only never submits it. Recovery never installs a different executable. - The independently owned systemd child holds the existing Relay deployment - file lock through capture, installation/profile work, forward observation, + file lock through capture, atomic runtime/profile work, forward observation, complete restoration and recovery observation. It does not restart Relay or Caddy, upgrade a package, lift the Caddy hold or activate frozen commercial copy. -- The complete quiescent Pulse executable/unit/drop-in/data estate is retained +- The complete quiescent **chosen footprint**: Pulse executable, VERSION, + unit/drop-in identity and demo data estate is retained privately before mutation. No backup or database is deleted for space. The unhealthy demo-only route retains the original generated operational history before its existing bounded-profile reset. No snapshot is automatically erased. @@ -39,7 +43,7 @@ workflow queue does not exclude another repository's host deployment. `TestDemoTransactionConnectedRecoveryControls` executes the real engine and bootstrap using a real private filesystem estate and virtual-clock command, service, journal and HTTP adapters. Tests retain delayed 55/299-second failure, -changed executable/unit/data rollback, cancellation, failed and unhealthy +changed executable/VERSION/data rollback, cancellation, failed and unhealthy recovery, headroom refusal, shared-lock contention, source mismatch, journal failure, cohost restart, source-bound repeated/lost submission and no-op proof. The production window is not shortened. This is **not native acceptance**. @@ -54,13 +58,13 @@ sent during restoration, and the old executable/data must return through a full real-clock recovery window. Sanitised artifacts bind control/driver/ engine/bootstrap identity, service PID/restart observations and cleanup. The fixture rebinds only Relay's fixed health URL to its local Caddy endpoint; the -original and fixture hashes remain distinct. This is a synthetic installer, -not signed published-installer or customer acceptance. Its source exists; +original and fixture hashes remain distinct. This exercises the actual narrow atomic runtime swaps with synthetic executables, +not a signed published archive or customer acceptance. Its source exists; **no native pass is asserted by this document**. Read the exact-head terminal job and artifact before judging that result. Before treating this route as restored, the exact reviewed source must return -native disposable systemd/SSH-loss receipts and signed published-installer +native disposable systemd/SSH-loss receipts and signed published-runtime forward/reverse data/identity results. Then reconcile actual demo workflow and public browser readback through the release owner; source tests do not establish an installed recovery. Delivery owns that continuation and the remaining ingress @@ -73,3 +77,36 @@ do not remove a lock, intent or snapshot merely to permit a fresh sample. A later frontend/network failure is not authority for a second SSH session to stop a committed or unrelated service. Keep the signed packet, failure and native/browser acceptance distinct in the release judgment. + +## Returned footprint defect and chosen correction (3 October 2026) + +Review of ee451f8c reproduced late failure followed by `rolled_back/verified` +with the candidate `/opt/pulse/VERSION` left installed. The old tests restricted +the synthetic installer to PATHS and missed that distribution mutation. + +The actual v6.4.5 installer at 99c8740785dcb6d66f7078112ae673ab45bf1b81 +changes the server, VERSION, bundled agents and scripts, binary symlink, +`/usr/local/bin/update-pulse`, `pulse-auto-update.sh`, service/auto-update units, +existing timer assets/activation, config/marker state and sibling configuration +backups (including rotation). Missing-install paths can create a service account +and acquire OS dependencies. Snapshotting only a binary and /etc/pulse cannot +roll all that back; adding more partial snapshots would repeat the defect. + +The existing installer and in-process updater were compared. Both include agent +distribution/helper provisioning unnecessary for this synthetic demo runtime; +the in-process updater also lacks this route's cohost exclusion and full-window +recovery. We keep existing signature verification and atomic sibling-swap +semantics but apply **only the two runtime files** and demo profile. Agents, +scripts, helpers, symlinks, backup rotation, service account, packages, unit and +timer provisioning/activation remain untouched. VERSION joins the private +snapshot and both runtime file identities are checked through forward/recovery +watches. Data rollback remains quiescent and retained; startup may naturally +write new data. This is not a claim to restore every general-installer effect. + +The CI dispatcher refuses production SSH mutation until the newest matching +main native run passes. It checks ancestry and final content of the engine, +dispatcher, native driver and workflow; an unrelated later main commit does not +invalidate unchanged proof. A matching failure/pending run cannot select an +older favourable sample. Refused/malformed reads have no fallback. Only the +normal recent-first/history-fill inventory rule is used for successful reads. +Native and signed installed acceptance are still separate; absence fails closed. diff --git a/docs/release-control/v6/internal/subsystems/deployment-installability.md b/docs/release-control/v6/internal/subsystems/deployment-installability.md index c24547706f..06b0edcb4f 100644 --- a/docs/release-control/v6/internal/subsystems/deployment-installability.md +++ b/docs/release-control/v6/internal/subsystems/deployment-installability.md @@ -29,7 +29,7 @@ new route; disposable acceptance also rejects an unknown route rather than responding healthy to every URL. Before installation or runtime-profile mutation, stop only Pulse and durably -capture its executable, unit/drop-ins and entire persistent data estate. Refuse +capture its executable, VERSION, unit/drop-ins and entire persistent data estate. Refuse insufficient space rather than prune backups/databases. A failed healthy change restores that complete estate and observes another 300 seconds; the original operation still fails. An unhealthy recovery baseline is never claimed as a @@ -50,7 +50,7 @@ recovery. See [demo transaction acceptance](../DEMO_RUNTIME_TRANSACTION.md). The secret-free public `demo-runtime-native.yml` checks the exact PR/push source on a fresh hosted runner, using actual systemd and Caddy with disposable TLS and -synthetic executable/data fixtures. Its two real-window cases exercise a +synthetic executable/data fixtures through the real narrow atomic swaps. Its two real-window cases exercise a changed executable, a 55-second delayed synthetic failure, observer loss and TERM during full restoration. It records original/fixture source identities, actual bounded service observations and cleanup; a missing or failed record is @@ -2634,6 +2634,21 @@ artifact-selection behaviour. ## Current State +### Demo runtime footprint qualification + +The demo transaction executes no general installer. It verifies the activated +stable archive with the pinned SSH key/namespace, selects only regular bounded +Linux/amd64 server and VERSION members, and rechecks their exact hashes before +atomic sibling swaps. VERSION is in the owned rollback snapshot; runtime hashes +remain fixed through forward and restored 300-second observations. No agent, +script, helper, unit/timer, service-account, package or backup-rotation effect is +claimed or applied. CI production mutation requires a successful main native +run whose engine/dispatcher/driver/workflow final content matches current main, +and refuses newer matching failures, pending runs and unavailable evidence. +The v6.4.5 full-installer VERSION rollback defect and this route's signed/native +limits are retained in DEMO_RUNTIME_TRANSACTION.md. + + ### Existing-install auto-update consent (1 October 2026) A manual update, version-pinned rollback or reinstall is not consent to turn diff --git a/scripts/installtests/build_release_assets_test.go b/scripts/installtests/build_release_assets_test.go index fff9fd54fd..efc7f3a7f6 100644 --- a/scripts/installtests/build_release_assets_test.go +++ b/scripts/installtests/build_release_assets_test.go @@ -2326,7 +2326,9 @@ func TestUpdateDemoWorkflowUsesGovernedNetworkPath(t *testing.T) { `Refuse mutation during verification-only checks`, `uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0`, `go run ./scripts/release_update_key.go public-key-ssh`, - `sed -i "s|^PINNED_RELEASE_SSH_PUBLIC_KEY=.*|PINNED_RELEASE_SSH_PUBLIC_KEY=\"${TRUSTED_SSH_PUBLIC_KEY}\"|" /tmp/pulse-install.sh`, + `ssh-keygen -Y verify -f /tmp/pulse-demo-signers -I pulse-installer`, + `-n pulse-install -s /tmp/pulse-demo-release.sshsig`, + `python3 .github/scripts/dispatch-demo-runtime.py prepare`, `Verify target host identity`, `bash .github/scripts/setup-demo-ssh.sh`, `Demo environment points at host $REMOTE_HOSTNAME but expected $DEMO_EXPECTED_HOSTNAME.`, diff --git a/scripts/installtests/demo_transaction_test.go b/scripts/installtests/demo_transaction_test.go index d2d2be0683..dbc8bafe0d 100644 --- a/scripts/installtests/demo_transaction_test.go +++ b/scripts/installtests/demo_transaction_test.go @@ -41,7 +41,7 @@ func TestDemoTransactionKeepsSignedAdmissionAndVerificationOnlyReadOnly(t *testi t.Fatalf("unguarded demo mutation remains: %s", forbidden) } } - for _, required := range []string{"Require exact committed activation marker for mutation", "release-activation.json", "Stable demo mutation refuses mutable, inactive, or prerelease tag", "PINNED_RELEASE_SSH_PUBLIC_KEY", "git show \"refs/tags/${TAG}:install.sh\"", "Verify public browser smoke"} { + for _, required := range []string{"Require exact committed activation marker for mutation", "release-activation.json", "Stable demo mutation refuses mutable, inactive, or prerelease tag", "ssh-keygen -Y verify", "-n pulse-install", "${asset}.sshsig", "Verify public browser smoke"} { if !strings.Contains(text, required) { t.Fatalf("lost signed/admission/customer verification boundary: %s", required) }