Preserve native updater failures across cleanup

Keep the original failed transaction command distinct from any later cleanup command. Retain fixed operation labels and require portable controls for command, timeout, assertion, cleanup-only and successful receipts, without changing update transactions or disclosing command output.

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-14 09:16:19 +01:00
parent a6ac018c45
commit e1cd6843e9
4 changed files with 144 additions and 2 deletions
@@ -369,6 +369,23 @@ cleanup removes only objects created by this attempt. Compiler and root-command
failures stay gating under the existing job's deadline; a seven-day closed receipt
contains source/binary/output hashes, all phases and cleanup, never signer keys,
persistent contents or raw application/installer logs.
Failed commands and assertions name their fixed preparation or transaction
operation. The original transaction's `command_failure` is preserved before
cleanup runs, and a cleanup command failure is retained separately under
`cleanup.command_failure`. A successful cleanup must not label the earlier
transaction failure as its own; an assertion or cleanup-only failure must not
invent an executed transaction command. Timeout facts remain distinct from a
later numeric cleanup exit, with no stale output hash attached. Neither these
labels nor completed owned cleanup diagnose why an update failed or accept a
native phase. `test_primary_command_survives_successful_or_failed_cleanup`,
`test_primary_timeout_is_not_replaced_by_cleanup_exit`,
`test_assertion_and_cleanup_only_errors_do_not_invent_primary_command` and
`test_successful_receipt_retains_no_failure_record` drive the actual receipt
producer with real confined failing/timeout children and no native manager;
the registered Go harness requires every portable verdict. Raw command output,
private fixture contents, argv and paths remain outside these observations.
The private fixture disables telemetry and binds the existing service to loopback;
it exposes no test server on the hosted VM's external interface.
Before compiling the native pair, CI requires the exact checkout head and no
+34 -2
View File
@@ -146,6 +146,7 @@ class NativeLab:
self.owned_unit = False
self.owned_root = False
self.owned_user = False
self.operation = "prepare-admission"
def run(self, *args, timeout=120, ok=(0,)):
process = subprocess.Popen(args, env=self.env, stdin=subprocess.DEVNULL,
@@ -162,7 +163,8 @@ class NativeLab:
process.communicate(timeout=2)
self.receipt["command_failure"] = {"command": Path(args[0]).name,
"deadline_exceeded": True,
"observed_exit": process.returncode}
"observed_exit": process.returncode,
"operation": self.operation}
raise RuntimeError("native command deadline exceeded") from None
# Private in-memory diagnostic for confined controls, never a receipt field.
self._last_output = output
@@ -170,6 +172,7 @@ class NativeLab:
if result.returncode not in ok:
self.receipt["command_failure"] = {"command": Path(args[0]).name,
"observed_exit": result.returncode,
"operation": self.operation,
"output_bytes": len(output),
"output_sha256": hashlib.sha256(output).hexdigest()}
raise RuntimeError(f"native command failed ({result.returncode}): {Path(args[0]).name}")
@@ -193,6 +196,7 @@ class NativeLab:
return pairs
def prepare(self):
self.operation = "prepare-admission"
for path in (self.unit, Path("/run/systemd/system") / self.unit.name):
if path.exists() or path.is_symlink():
raise ValueError("native fixture unit already exists")
@@ -205,6 +209,7 @@ class NativeLab:
raise ValueError("native fixture unit is already known to systemd")
for name in ("tools", "tmp", "archives", "signer"):
(self.root / name).mkdir(mode=0o700 if name == "signer" else 0o755)
self.operation = "prepare-source"
source = (self.repo / "install.sh").read_text()
(self.root / "installer-definitions").write_text(installer_definitions(source))
self.receipt["sources"] = {p: digest(self.repo / p) for p in
@@ -214,10 +219,12 @@ class NativeLab:
if len(set(self.receipt["binaries"].values())) != 2:
raise ValueError("same executable bytes prove no replacement")
for tag, binary in self.binaries.items():
self.operation = "verify-binary-" + tag
if self.run(str(binary), "--version").stdout.decode().splitlines()[0] != "Pulse " + tag:
raise ValueError("synthetic binary identity mismatch")
self.create_signer()
for tag, binary in self.binaries.items():
self.operation = "prepare-archive-" + tag
version = self.root / "VERSION"
version.write_text(tag + "\n")
archive = self.root / "archives" / f"pulse-{tag}-linux-amd64.tar.gz"
@@ -225,27 +232,35 @@ class NativeLab:
tar.add(binary, arcname="bin/pulse")
tar.add(version, arcname="VERSION")
self.sign(archive)
self.operation = "prepare-installer"
(self.root / "installer").write_text(INSTALLER)
self.sign(self.root / "installer")
self.operation = "prepare-transport"
for name in ("curl", "mktemp"):
path = self.root / "tools" / name
path.write_text(TRANSPORT)
path.chmod(0o755)
# Establish the baseline using real source staging and unit/helper writers.
self.operation = "baseline-account-admission"
self.owned_user = self.run("id", "-u", "pulse", ok=(0, 1)).returncode == 1
self.owned_unit = True # Any partial writer output is ours to clean up.
self.operation = "baseline-install"
self.shell('source "$PULSE_NATIVE_ROOT/installer-definitions"; '
'PINNED_RELEASE_SSH_PUBLIC_KEY=$(cat "$PULSE_NATIVE_ROOT/signer/key.pub"); '
'create_user; setup_directories; '
'install_pulse_archive "$PULSE_NATIVE_ROOT/archives/pulse-v0.0.1-linux-amd64.tar.gz" v0.0.1; '
'install_systemd_service; setup_update_command')
self.operation = "baseline-configure"
data = self.root / "data"
(data / ".env").write_text("PULSE_TELEMETRY=false\nBIND_ADDRESS=127.0.0.1\n")
(data / "system.json").write_text('{"autoUpdateEnabled":false,"updateChannel":"stable"}\n')
(data / "native-persistence-sentinel").write_text("original persistent data\n")
self.operation = "baseline-start"
self.run("systemctl", "start", self.unit.name)
self.operation = "baseline-readback"
self.assert_runtime(TAGS[0], "active")
time.sleep(2) # Let asynchronous initial persistence settle once.
self.operation = "baseline-persistence"
files = (".env", "system.json", ".encryption.key", "native-persistence-sentinel")
self.baseline_data = {p: digest(data / p) for p in files}
self.baseline_mode = (self.root / "install/bin/pulse").stat().st_mode & 0o777
@@ -274,6 +289,7 @@ class NativeLab:
why a hosted OpenSSH invocation failed before the first native phase.
Diagnostic labels below mean observed text, not a causal diagnosis.
"""
self.operation = operation
try:
return self.run("ssh-keygen", *arguments)
except RuntimeError:
@@ -373,6 +389,7 @@ class NativeLab:
return state
def phase(self, name, tag, expected_exit=0, active="active", fault="", retain=False, manual=False):
self.operation = name
before_state = self.state()
self.env.update(PULSE_NATIVE_TARGET=tag, PULSE_NATIVE_FAULT=fault)
result = self.run("/bin/bash", "-c", '"$PULSE_UPDATE_HELPER_PATH" --version "$PULSE_NATIVE_TARGET"'
@@ -404,18 +421,22 @@ class NativeLab:
self.phase("forward", TAGS[1])
self.phase("manual-reverse", TAGS[0], manual=True)
self.phase("failed-active", TAGS[1], 1, fault="installer")
self.operation = "prepare-inactive"
self.run("systemctl", "stop", self.unit.name)
self.phase("failed-inactive", TAGS[1], 1, active="inactive", fault="installer")
# A real /dev/null mask, not a manager double returning 'masked'.
self.operation = "prepare-masked"
saved_unit = self.unit.read_bytes()
self.unit.unlink()
self.run("systemctl", "mask", self.unit.name)
self.run("systemctl", "daemon-reload")
self.phase("failed-masked", TAGS[1], 1, active="inactive", fault="installer")
self.operation = "restore-unmasked"
self.run("systemctl", "unmask", self.unit.name)
self.unit.write_bytes(saved_unit)
self.run("systemctl", "daemon-reload")
self.run("systemctl", "start", self.unit.name)
self.operation = "prepare-bad-signature"
installer = self.root / "installer"
original = installer.read_bytes()
calls = (self.root / "installer-calls").read_bytes()
@@ -430,6 +451,7 @@ class NativeLab:
self.phase("incomplete-restore", TAGS[1], 1, active="inactive", fault="restore", retain=True)
def cleanup(self):
self.operation = "cleanup"
if self.owned_unit:
state = self.state()
if state["LoadState"] != "not-found":
@@ -479,13 +501,23 @@ def main():
lab.exercise()
except (OSError, ValueError, RuntimeError, subprocess.SubprocessError) as error:
failed = True
lab.receipt["failure"] = {"type": type(error).__name__, "message": str(error)}
lab.receipt["failure"] = {"type": type(error).__name__, "message": str(error),
"operation": lab.operation}
finally:
# Cleanup runs its own commands. Preserve the original transaction
# observation before any later failure can replace it or be mistaken
# for that transaction. A logical assertion has no command observation.
transaction_failure = lab.receipt.pop("command_failure", None)
try:
lab.cleanup()
except (OSError, RuntimeError, subprocess.SubprocessError) as error:
failed = True
lab.receipt["cleanup"] = {"complete": False, "error_type": type(error).__name__}
cleanup_failure = lab.receipt.pop("command_failure", None)
if cleanup_failure is not None:
lab.receipt["cleanup"]["command_failure"] = cleanup_failure
if transaction_failure is not None:
lab.receipt["command_failure"] = transaction_failure
receipt.write_text(json.dumps(lab.receipt, indent=2) + "\n")
if failed or len(lab.receipt["phases"]) != 7:
raise SystemExit("Native updater proof incomplete; inspect the bounded receipt.")
@@ -37,6 +37,10 @@ func TestAutoUpdateNativeSystemdHarnessContracts(t *testing.T) {
"test_phase_cannot_accept_wrong_bytes_state_mask_persistence_or_backup",
"test_managed_process_identity_follows_served_readiness_and_rejects_residuals",
"test_runtime_readback_rejects_post_comparison_drift",
"test_primary_command_survives_successful_or_failed_cleanup",
"test_primary_timeout_is_not_replaced_by_cleanup_exit",
"test_assertion_and_cleanup_only_errors_do_not_invent_primary_command",
"test_successful_receipt_retains_no_failure_record",
"test_connected_linux_control_is_required_without_macho_or_validator_bypass",
} {
if results[name] != "ok" {
+89
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env python3
"""Discriminating controls for the native CI recipe; no host manager/root use."""
import contextlib
import importlib.util
import io
import json
@@ -299,6 +300,7 @@ class NativeContracts(unittest.TestCase):
else:
lab.phase("forward", "v0.0.2", active="inactive")
self.assertTrue(lab.receipt["phases"][0]["passed"])
self.assertEqual(lab.operation, "forward")
def test_managed_process_identity_follows_served_readiness_and_rejects_residuals(self):
for mutation in ("", "process-bytes", "stopped-after-readiness", "inactive-pid"):
@@ -665,6 +667,93 @@ else: sys.exit(97)
source.replace("runs-on: ubuntu-24.04", "runs-on: self-hosted")):
with self.assertRaises(AssertionError): recipe(changed)
def run_receipt(self, primary, cleanup_fails):
# Execute the actual receipt producer, with real unprivileged failing
# children. Substitute only native admission and service/account work.
# These controls cannot provide a native transaction or cleanup proof.
with tempfile.TemporaryDirectory() as directory:
root = Path(directory)
receipt = root / "receipt.json"
lab = native.NativeLab(root, ROOT, root / "a", root / "b", "a" * 40)
owned_cleanup = lab.cleanup
def prepare():
lab.operation = "baseline-install"
if primary == "command":
lab.run("/bin/bash", "-c", "printf 'private-transaction-fixture'; exit 23")
if primary == "timeout":
lab.run(sys.executable, "-c", "import time; time.sleep(60)", timeout=0.05)
if primary == "assertion":
raise RuntimeError("confined persistence assertion")
def exercise():
lab.receipt["phases"] = [{"name": "confined-control"}] * 7
def cleanup():
lab.operation = "cleanup"
if cleanup_fails:
lab.run(sys.executable, "-c", "print('private-cleanup-fixture'); raise SystemExit(31)")
owned_cleanup() # Owns nothing; touches no manager.
argv = ["native-receipt-control", "--before", "unused-before", "--after", "unused-after",
"--receipt", str(receipt)]
with patch.dict(os.environ, {"EXPECTED_SOURCE_SHA": "a" * 40, "RUNNER_TEMP": str(root)}), \
patch.object(sys, "argv", argv), patch.object(native, "hosted_admission"), \
patch.object(native, "NativeLab", return_value=lab), \
patch.object(lab, "prepare", side_effect=prepare), \
patch.object(lab, "exercise", side_effect=exercise), \
patch.object(lab, "cleanup", side_effect=cleanup), \
contextlib.redirect_stdout(io.StringIO()):
if primary or cleanup_fails:
with self.assertRaises(SystemExit): native.main()
else:
native.main()
result = json.loads(receipt.read_text())
for private in ("private-transaction-fixture", "private-cleanup-fixture", str(root)):
self.assertNotIn(private, json.dumps(result))
return result
def test_primary_command_survives_successful_or_failed_cleanup(self):
for cleanup_fails in (False, True):
with self.subTest(cleanup_fails=cleanup_fails):
result = self.run_receipt("command", cleanup_fails)
self.assertEqual(result["command_failure"]["observed_exit"], 23)
self.assertEqual(result["command_failure"]["command"], "bash")
self.assertEqual(result["command_failure"]["operation"], "baseline-install")
self.assertEqual(result["failure"]["operation"], "baseline-install")
self.assertEqual(result["phases"], [])
self.assertEqual(result["cleanup"]["complete"], not cleanup_fails)
if cleanup_fails:
self.assertEqual(result["cleanup"]["command_failure"]["observed_exit"], 31)
self.assertEqual(result["cleanup"]["command_failure"]["operation"], "cleanup")
else:
self.assertNotIn("command_failure", result["cleanup"])
def test_primary_timeout_is_not_replaced_by_cleanup_exit(self):
result = self.run_receipt("timeout", True)
self.assertTrue(result["command_failure"]["deadline_exceeded"])
self.assertLess(result["command_failure"]["observed_exit"], 0)
self.assertEqual(result["command_failure"]["operation"], "baseline-install")
self.assertNotIn("output_sha256", result["command_failure"])
self.assertEqual(result["cleanup"]["command_failure"]["observed_exit"], 31)
self.assertEqual(result["phases"], [])
def test_assertion_and_cleanup_only_errors_do_not_invent_primary_command(self):
for primary in ("assertion", ""):
with self.subTest(primary=primary):
result = self.run_receipt(primary, True)
self.assertNotIn("command_failure", result)
self.assertEqual(result["cleanup"]["command_failure"]["observed_exit"], 31)
self.assertFalse(result["cleanup"]["complete"])
if primary:
self.assertEqual(result["failure"]["operation"], "baseline-install")
self.assertEqual(result["phases"], [])
else:
self.assertNotIn("failure", result)
def test_successful_receipt_retains_no_failure_record(self):
result = self.run_receipt("", False)
self.assertNotIn("failure", result)
self.assertNotIn("command_failure", result)
self.assertNotIn("command_failure", result["cleanup"])
self.assertTrue(result["cleanup"]["complete"])
if __name__ == "__main__":
unittest.main()