From 46fdc2764c1fb66e8c91970c31b2c956526827a9 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 02:26:51 -0400 Subject: [PATCH] Recover aborted maintenance without fabricated drain or foreign fence changes --- docs/indeehub-legacy-maintenance-controller.md | 9 +++++++-- scripts/indeehub-maintenance-controller.py | 6 ++++++ .../test_indeehub_maintenance_controller.py | 13 +++++++++++++ 3 files changed, 26 insertions(+), 2 deletions(-) diff --git a/docs/indeehub-legacy-maintenance-controller.md b/docs/indeehub-legacy-maintenance-controller.md index 3351704a..a357ca8c 100644 --- a/docs/indeehub-legacy-maintenance-controller.md +++ b/docs/indeehub-legacy-maintenance-controller.md @@ -1,6 +1,6 @@ # Legacy IndeeHub maintenance controller -Status: isolated source implementation. Ten pure Python fake-runtime regressions +Status: isolated source implementation. Twelve pure Python fake-runtime regressions pass; no live invocation or production qualification. The controller is not part of the already signed private app candidate and needs no new app image/API. @@ -55,7 +55,7 @@ prove compatibility with newly changed data. No automatic DB/media restore exist ## Qualification and remaining integration -`python3 tests/regression/test_indeehub_maintenance_controller.py` passes ten +`python3 tests/regression/test_indeehub_maintenance_controller.py` passes twelve fake-runtime cases in temporary directories, without services/network/containers. Source nginx template guard coverage also passes its parser check. Production adapter compilation, actual Podman event format/systemd clean-exit behavior, @@ -71,3 +71,8 @@ The backend must refuse missing/mismatched prerequisites before snapshots/stops. Native AppGate + nginx guards are separate node source changes owned by the supervised updater agent. The signed app catalog/private image receipts remain unchanged. Existing live stop/uninstall intent must not be rewritten as maintenance. + +A pre-acquire snapshot/preflight failure may leave no controller journal. An +Aborted node journal with target_startup_began=false then permits idempotent +no-op acknowledgement, without touching any other operation’s admission fence. +A matching fence without its controller journal requires recovery investigation. diff --git a/scripts/indeehub-maintenance-controller.py b/scripts/indeehub-maintenance-controller.py index a152086e..6ce10ff9 100644 --- a/scripts/indeehub-maintenance-controller.py +++ b/scripts/indeehub-maintenance-controller.py @@ -204,6 +204,12 @@ class Controller: return {'operation_id':self.operation,'state':'held'} def release(self, outcome): require(outcome in ('committed','restored','aborted'),'Invalid release outcome') + if self.record is None and outcome=='aborted': + runtime=json.loads((self.data/'update-transactions'/'supervised'/(self.operation+'.json')).read_text()) + require(runtime.get('phase')=='Aborted' and runtime.get('target_startup_began') is False,'Untouched abort evidence required') + if self.fence.exists(): + require(not self.fence.is_symlink() and self.fence.read_text()!=self.operation,'Matching fence without journal requires recovery') + return {'operation_id':self.operation,'state':'released'} require(self.record is not None,'Unknown maintenance operation') if self.record['phase']=='Released': require(self.record.get('outcome')==outcome,'Maintenance outcome changed') diff --git a/tests/regression/test_indeehub_maintenance_controller.py b/tests/regression/test_indeehub_maintenance_controller.py index 1c22becd..439f0daa 100644 --- a/tests/regression/test_indeehub_maintenance_controller.py +++ b/tests/regression/test_indeehub_maintenance_controller.py @@ -82,4 +82,17 @@ class MaintenanceTests(unittest.TestCase): self.assertEqual(module.validate_nginx_guards('\n'.join(blocks)),3) with self.assertRaisesRegex(RuntimeError,'missing its maintenance guard'):module.validate_nginx_guards('\n'.join(blocks).replace(guard,'',1)) with self.assertRaisesRegex(RuntimeError,'Unrecognized direct'):module.validate_nginx_guards('\n'.join(blocks)+'\nlocation /other/ {\n proxy_pass http://127.0.0.1:7778/;\n}') + def test_pre_acquire_abort_acknowledges_without_mutating_foreign_fence(self): + c=self.controller;runtime=c.data/'update-transactions'/'supervised'/(self.operation+'.json') + module.atomic(runtime,{'phase':'Aborted','target_startup_began':False}) + self.assertEqual(c.release('aborted')['state'],'released') + c.fence.parent.mkdir(parents=True);foreign=str(uuid.uuid4());c.fence.write_text(foreign) + self.assertEqual(c.release('aborted')['state'],'released');self.assertEqual(c.fence.read_text(),foreign) + module.atomic(runtime,{'phase':'Aborted','target_startup_began':True}) + with self.assertRaisesRegex(RuntimeError,'Untouched abort'):c.release('aborted') + def test_matching_fence_without_journal_is_not_an_untouched_abort(self): + c=self.controller;module.atomic(c.data/'update-transactions'/'supervised'/(self.operation+'.json'),{'phase':'Aborted','target_startup_began':False}) + c.fence.parent.mkdir(parents=True);c.fence.write_text(self.operation) + with self.assertRaisesRegex(RuntimeError,'without journal'):c.release('aborted') + self.assertTrue(c.fence.exists()) if __name__=='__main__':unittest.main()