mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-24 12:42:49 +00:00
Limit guarded demo updates to owned signed runtime files
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
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user