diff --git a/docs/managed-update-recovery-implementation.md b/docs/managed-update-recovery-implementation.md index b3e61c40..6ba1e263 100644 --- a/docs/managed-update-recovery-implementation.md +++ b/docs/managed-update-recovery-implementation.md @@ -261,3 +261,35 @@ recaptured as a new baseline, never described as preserved across that shutdown. A direct-kernel initramfs-only recovery boot installed an absent-marker manager startup condition before normal boot, and `ConditionResult=no` verified the old manager never started. The subsequent diagnostic boot shut down gracefully. + +### Actual RPC preflight and nginx namespace evidence (2026-10-08 UTC) + +Corrected VM executable built successfully from frozen inputs (normal binary +SHA256 `3cbe5a5c3a74e6463ab0874c74e23dfca68f0bfc3fe54883f0edf69abb37ac0d`; +stripped transfer SHA256 `753f8ba6ac342c8f4b9a19c8079a51dfd1da4dcb517d4ea4d0e54035c22f5788`). +Receipt: `/tmp/archy-indeehub-corrected-executable-20261007.json`. It predates the +later rental guard and nginx helper correction; not a release artifact. + +Actual manager startup completed a62-app reconcile pass with all seven IndeeHub +members `NoOp`, every baseline container identity preserved, and API/manager +HTTP200. Subsequent real update attempts initially met the legitimate background +lifecycle lock. A temporary behavior-preserving fixture tracer confirmed later +admission succeeded. One early diagnostic teardown interrupted asynchronous +preflight and is invalid as source-defect evidence. The corrected diagnostic +waited for its terminal refusal before restoring the deliberately withheld plan +and removing the tracer; all seven original identities remained, no supervised +journal existed. No speculative lifecycle-lock patch was made. + +The admitted reviewed-plan RPC then reached `Prepared → Editing → Restoring`. +All seven recovery images exist; target startup never began. The controller +remained `Prepared` because `sudo nginx -T` attempted to open `/run/nginx.pid` +inside the manager's read-only mount namespace. Holds and the unresolved journal +were preserved; this is not a successful rollback or migration receipt. + +A manager-equivalent hardened VM probe passed with the fixed command +`sudo -n /usr/bin/systemd-run --quiet --wait --pipe --collect -- /usr/sbin/nginx -T`. +It validated the required ingress guards without printing effective configuration +or widening manager write access. The controller uses that fixed command now; +25 pure controller tests pass, including refusal before fence creation on dump +failure. A new helper hash requires a matching backend rebuild. Candidate-helper +qualification remains separate from actual backend transaction acceptance. diff --git a/scripts/indeehub-maintenance-controller.py b/scripts/indeehub-maintenance-controller.py index a87f4222..a2774abf 100644 --- a/scripts/indeehub-maintenance-controller.py +++ b/scripts/indeehub-maintenance-controller.py @@ -167,7 +167,13 @@ class Controller: def close_ingress(self): # The deployed native AppGate and legacy nginx guards consume this exact # sentinel. This code never edits arbitrary nginx configuration. - config=self.run(['sudo','-n','nginx','-T']).decode() + # nginx -T tests its pid file as well as reading configuration. The + # manager's strict mount namespace makes /run/nginx.pid read-only, even + # after sudo. Use one fixed read-only command in PID1's fresh service + # context; do not broaden the manager's writable paths or detach the + # controller that owns the inherited lifecycle lock. + config=self.run(['sudo','-n','/usr/bin/systemd-run','--quiet','--wait', + '--pipe','--collect','--','/usr/sbin/nginx','-T']).decode() validate_nginx_guards(config) self.fence.parent.mkdir(mode=0o755,exist_ok=True) self.fence.parent.chmod(0o755) diff --git a/tests/regression/test_indeehub_maintenance_controller.py b/tests/regression/test_indeehub_maintenance_controller.py index 6529ff36..d9790691 100644 --- a/tests/regression/test_indeehub_maintenance_controller.py +++ b/tests/regression/test_indeehub_maintenance_controller.py @@ -155,6 +155,15 @@ class MaintenanceTests(unittest.TestCase): module.atomic(c.data/'update-transactions'/'supervised'/(self.operation+'.json'),{'phase':'Restored','target_startup_began':True}) with self.assertRaisesRegex(RuntimeError,'Data compatibility'):c.release('restored') self.assertTrue(c.fence.exists()) + def test_nginx_namespace_failure_cannot_create_admission_fence(self): + def failed_dump(argv,timeout,output): + self.assertEqual(argv,['sudo','-n','/usr/bin/systemd-run','--quiet','--wait', + '--pipe','--collect','--','/usr/sbin/nginx','-T']) + raise module.subprocess.CalledProcessError(1,argv) + self.controller.runner=failed_dump + with self.assertRaises(module.subprocess.CalledProcessError):self.controller.close_ingress() + self.assertFalse(self.controller.fence.exists()) + self.assertIsNone(self.controller.record) def test_every_legacy_sublocation_must_be_fenced(self): guard='if (-f /var/lib/archipelago/app-maintenance/indeedhub) { return 503; }' blocks=[f'location /app/indeedhub/{suffix} {{\n {guard}\n proxy_pass http://127.0.0.1:7778/;\n}}' for suffix in ('','_next/','ws/')]