From b48e4618722c0d89399643cf60615c4bde8fb0dc Mon Sep 17 00:00:00 2001 From: Autonomous Work Date: Sun, 16 Aug 2026 12:50:20 -0400 Subject: [PATCH 1/2] fix: auto-establish verified Buzz baseline --- .gitignore | 1 + plugins/buzz-control/README.md | 21 ++-- plugins/buzz-control/dashboard/manifest.json | 2 +- plugins/buzz-control/plugin.yaml | 2 +- plugins/buzz-control/scripts/reconcile.py | 88 ++++++++----- .../tests/test_plugin_contract.py | 4 +- plugins/buzz-control/tests/test_reconcile.py | 116 ++++++++++++++++++ 7 files changed, 189 insertions(+), 45 deletions(-) diff --git a/.gitignore b/.gitignore index dff2f41..bce10f7 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .DS_Store +.worktrees/ __pycache__/ *.py[cod] diff --git a/plugins/buzz-control/README.md b/plugins/buzz-control/README.md index a6d9350..4675d11 100644 --- a/plugins/buzz-control/README.md +++ b/plugins/buzz-control/README.md @@ -56,10 +56,13 @@ the confirmation token or configuration values. None of these files has a dashboard download route. An upgraded installation with no `applied.env` starts in -**`baseline_missing`**. This is intentional: installation and GET requests do -not silently declare an unverified production file safe. Save, Apply, and relay -recreation remain blocked until the operator explicitly adopts the current -healthy runtime. Image checks may still pull and compare the public image. +**`baseline_missing`**. Installation and GET requests do not silently declare +an unverified production file safe. The next manual or scheduled image update +automatically establishes the baseline only when the running relay exactly +matches `prod.env` and passes Docker and HTTP health checks, so ordinary image +updates do not require the configuration UI. A mismatched or unhealthy runtime +remains blocked until the operator resolves it and explicitly adopts the current +healthy configuration. ## Managed settings @@ -114,10 +117,12 @@ reconciler and lock: 1. Select `applied.env` whenever an applied baseline exists; a scheduled update cannot consume a later external edit or bypass Save/Apply policy. -2. Pull `ghcr.io/block/buzz:main` using an authentication-free Docker config. -3. Compare immutable image IDs. -4. Recreate only `relay` when the ID changed and an applied baseline exists. -5. Verify Docker health and atomically save a non-secret dashboard receipt. +2. When the baseline is missing, adopt `prod.env` only after verifying that the + running Compose generation and health match it. +3. Pull `ghcr.io/block/buzz:main` using an authentication-free Docker config. +4. Compare immutable image IDs. +5. Recreate only `relay` when the ID changed and a verified baseline exists. +6. Verify Docker health and atomically save a non-secret dashboard receipt. Reinstalling refreshes the wrapper while preserving an operator-edited cadence, paused state, and delivery setting. diff --git a/plugins/buzz-control/dashboard/manifest.json b/plugins/buzz-control/dashboard/manifest.json index 680aabe..7e468d8 100644 --- a/plugins/buzz-control/dashboard/manifest.json +++ b/plugins/buzz-control/dashboard/manifest.json @@ -3,7 +3,7 @@ "label": "Buzz", "description": "Manage core Buzz endpoint and access settings, inspect relay health, and reconcile images safely.", "icon": "RadioTower", - "version": "1.2.0", + "version": "1.2.1", "tab": { "path": "/buzz", "position": "after:achievements" diff --git a/plugins/buzz-control/plugin.yaml b/plugins/buzz-control/plugin.yaml index ef66ef5..f7a47a2 100644 --- a/plugins/buzz-control/plugin.yaml +++ b/plugins/buzz-control/plugin.yaml @@ -1,4 +1,4 @@ name: buzz-control -version: 1.2.0 +version: 1.2.1 description: "Core endpoint and access settings, health, and image reconciliation for a local Buzz deployment." hooks: [] diff --git a/plugins/buzz-control/scripts/reconcile.py b/plugins/buzz-control/scripts/reconcile.py index d305a3a..4ca4cc3 100755 --- a/plugins/buzz-control/scripts/reconcile.py +++ b/plugins/buzz-control/scripts/reconcile.py @@ -11,7 +11,7 @@ import subprocess import sys import time -from contextlib import closing +from contextlib import closing, suppress from pathlib import Path from typing import NamedTuple @@ -360,6 +360,45 @@ def _http_healthy(self, env_file: Path) -> bool: def _journal_blocks(self, view: object) -> bool: return view.journal.get("phase") in CONFIG_MODULE.BLOCKING_JOURNAL_PHASES + def _promote_matching_runtime_locked( + self, + view: object, + *, + action: str, + ) -> object: + recovery = action == "recover_adopt" + phase = "recovering" if recovery else "adopting" + outcome = "recovered" if recovery else "applied" + snapshot = self.store.operation_snapshot_locked(view.revision) + operation = self.paths.operation + self._compose(operation, self.settings.image, "config", "--quiet") + self._http_port(operation) + desired_hash = self._service_config_hash(operation, self.settings.image) + container_id = self._container_id(operation, self.settings.image) + if not container_id: + raise ReconcileFailure("runtime_unverified", 78) + image = self._container_image(container_id) + if ( + self._container_config_hash(container_id) != desired_hash + or self._container_health(container_id) != "healthy" + or not self._http_healthy(operation) + ): + raise ReconcileFailure("runtime_unverified", 78) + self._write_phase( + phase, + action, + view, + started_at=CONFIG_MODULE._utc_now(), + runtime_generation=image, + ) + return self.store.promote_operation_locked( + snapshot, + phase="applied", + action=action, + runtime_generation=image, + outcome=outcome, + ) + def image(self, trigger: str) -> int: if trigger not in {"manual", "scheduled"}: raise ReconcileFailure("invalid_invocation", 2) @@ -369,13 +408,23 @@ def image(self, trigger: str) -> int: if self._journal_blocks(view): raise ReconcileFailure("recovery_required", 78) if view.baseline_state == "baseline_missing": - selected = self.paths.desired - block_recreate = "true" - else: + with suppress(ReconcileFailure): + view = self._promote_matching_runtime_locked( + view, + action="auto_adopt", + ) + if view.baseline_state == "established": selected = self.paths.applied block_recreate = "false" + else: + selected = self.paths.desired + block_recreate = "true" + child_timeout = max(1, int(self._remaining())) environment = os.environ.copy() environment["BUZZ_CONTROL_RECONCILER_CHILD"] = "1" + environment["BUZZ_CONTROL_EXECUTION_TIMEOUT_SECONDS"] = str( + child_timeout + ) try: result = subprocess.run( [ @@ -388,7 +437,7 @@ def image(self, trigger: str) -> int: check=False, # The inner updater owns the operation deadline and still # needs a bounded moment to persist its safe timeout receipt. - timeout=self._remaining() + 3.0, + timeout=child_timeout + 3.0, env=environment, ) except subprocess.TimeoutExpired as exc: @@ -579,36 +628,9 @@ def adopt(self, revision: str, token: str, *, recovery: bool = False) -> str: and phase not in CONFIG_MODULE.RECOVERY_ADOPTION_PHASES ) or (not recovery and self._journal_blocks(view)): raise ReconcileFailure("policy_changed", 78) - snapshot = self.store.operation_snapshot_locked(revision) - operation = self.paths.operation - self._compose(operation, self.settings.image, "config", "--quiet") - self._http_port(operation) - desired_hash = self._service_config_hash( - operation, self.settings.image - ) - container_id = self._container_id(operation, self.settings.image) - if not container_id: - raise ReconcileFailure("runtime_unverified", 78) - image = self._container_image(container_id) - if ( - self._container_config_hash(container_id) != desired_hash - or self._container_health(container_id) != "healthy" - or not self._http_healthy(operation) - ): - raise ReconcileFailure("runtime_unverified", 78) - self._write_phase( - "recovering" if recovery else "adopting", - action, + self._promote_matching_runtime_locked( view, - started_at=CONFIG_MODULE._utc_now(), - runtime_generation=image, - ) - self.store.promote_operation_locked( - snapshot, - phase="applied", action=action, - runtime_generation=image, - outcome="recovered" if recovery else "applied", ) return "recovered" if recovery else "adopted" diff --git a/plugins/buzz-control/tests/test_plugin_contract.py b/plugins/buzz-control/tests/test_plugin_contract.py index 71672bf..c991d02 100644 --- a/plugins/buzz-control/tests/test_plugin_contract.py +++ b/plugins/buzz-control/tests/test_plugin_contract.py @@ -35,14 +35,14 @@ def test_manifest_registers_the_buzz_tab_and_backend(self): self.assertEqual(manifest["entry"], "dist/index.js") self.assertEqual(manifest["css"], "dist/style.css") self.assertEqual(manifest["api"], "plugin_api.py") - self.assertEqual(manifest["version"], "1.2.0") + self.assertEqual(manifest["version"], "1.2.1") def test_release_metadata_and_operator_runbook_cover_config_trust_model(self): runtime_manifest = (PLUGIN_ROOT / "plugin.yaml").read_text() readme = (PLUGIN_ROOT / "README.md").read_text().lower() installer = INSTALLER.read_text() - self.assertIn("version: 1.2.0", runtime_manifest) + self.assertIn("version: 1.2.1", runtime_manifest) for required in ( "not projected", "baseline_missing", diff --git a/plugins/buzz-control/tests/test_reconcile.py b/plugins/buzz-control/tests/test_reconcile.py index 1b97fd2..9e0efca 100644 --- a/plugins/buzz-control/tests/test_reconcile.py +++ b/plugins/buzz-control/tests/test_reconcile.py @@ -134,6 +134,29 @@ def make_runner( ) return runner, store, paths, saved, token, call_log + def run_image_with_stubbed_updater(self, runner, trigger, returncode): + completed = subprocess.CompletedProcess(["update"], returncode) + real_run = subprocess.run + + def run_command(command, *args, **kwargs): + if command[0] == str(self.reconcile.UPDATER_PATH): + return completed + return real_run(command, *args, **kwargs) + + with patch.object( + self.reconcile.subprocess, + "run", + side_effect=run_command, + ) as run: + result = runner.image(trigger) + + updater_call = next( + item + for item in run.call_args_list + if item.args[0][0] == str(self.reconcile.UPDATER_PATH) + ) + return result, updater_call.args[0], updater_call.kwargs + def test_settings_use_xdg_config_home_for_the_desired_file(self): with patch.dict( os.environ, @@ -477,6 +500,99 @@ def test_image_update_always_uses_applied_snapshot_when_baseline_exists(self): self.assertEqual(command[3], str(paths.applied)) self.assertEqual(command[4], "false") + def test_image_update_auto_establishes_verified_baseline_when_missing(self): + with tempfile.TemporaryDirectory() as td: + runner, store, paths, _saved, _token, _call_log = self.make_runner( + Path(td), 3300 + ) + paths.applied.unlink() + result, command, _options = self.run_image_with_stubbed_updater( + runner, "scheduled", 0 + ) + + self.assertEqual(result, 0) + view = store.describe() + self.assertEqual(view.baseline_state, "established") + self.assertFalse(view.pending) + self.assertEqual(paths.applied.read_bytes(), paths.desired.read_bytes()) + self.assertEqual(view.journal["action"], "auto_adopt") + self.assertEqual(command[3], str(paths.applied)) + self.assertEqual(command[4], "false") + + def test_image_update_does_not_auto_adopt_a_mismatched_runtime(self): + with tempfile.TemporaryDirectory() as td: + runner, store, paths, _saved, _token, _call_log = self.make_runner( + Path(td), 3300, config_hash_match=False + ) + paths.applied.unlink() + result, command, _options = self.run_image_with_stubbed_updater( + runner, "scheduled", 1 + ) + + self.assertEqual(result, 1) + self.assertEqual(store.describe().baseline_state, "baseline_missing") + self.assertFalse(paths.applied.exists()) + self.assertEqual(command[3], str(paths.desired)) + self.assertEqual(command[4], "true") + + def test_image_update_passes_remaining_budget_after_auto_adoption(self): + with tempfile.TemporaryDirectory() as td: + runner, _store, paths, _saved, _token, _call_log = self.make_runner( + Path(td), 3300 + ) + paths.applied.unlink() + real_promote = runner._promote_matching_runtime_locked + + def promote_after_slow_preflight(*args, **kwargs): + view = real_promote(*args, **kwargs) + runner.deadline = self.reconcile.time.monotonic() + 4.9 + return view + + runner._promote_matching_runtime_locked = promote_after_slow_preflight + result, _command, options = self.run_image_with_stubbed_updater( + runner, "scheduled", 0 + ) + + child_budget = int( + options["env"]["BUZZ_CONTROL_EXECUTION_TIMEOUT_SECONDS"] + ) + self.assertEqual(result, 0) + self.assertGreaterEqual(child_budget, 1) + self.assertLess(child_budget, runner.settings.timeout) + self.assertEqual(options["timeout"], child_budget + 3.0) + + def test_image_update_does_not_auto_adopt_an_unhealthy_runtime(self): + scenarios = { + "missing container": lambda runner: setattr( + runner, "_container_id", lambda *_args, **_kwargs: "" + ), + "unhealthy Docker state": lambda runner: setattr( + runner, "_container_health", lambda _container_id: "unhealthy" + ), + "failed HTTP liveness": lambda runner: setattr( + runner, "_http_healthy", lambda _env_file: False + ), + } + for name, configure in scenarios.items(): + with self.subTest(name=name), tempfile.TemporaryDirectory() as td: + runner, store, paths, _saved, _token, _call_log = self.make_runner( + Path(td), 3300 + ) + paths.applied.unlink() + configure(runner) + + result, command, _options = self.run_image_with_stubbed_updater( + runner, "scheduled", 1 + ) + + self.assertEqual(result, 1) + self.assertEqual( + store.describe().baseline_state, "baseline_missing" + ) + self.assertFalse(paths.applied.exists()) + self.assertEqual(command[3], str(paths.desired)) + self.assertEqual(command[4], "true") + def test_image_child_timeout_is_translated_to_safe_failure(self): with tempfile.TemporaryDirectory() as td: runner, _store, _paths, _saved, _token, _call_log = self.make_runner( From b72f07bbc25fab9486fa56b4a66d7df6d10951d3 Mon Sep 17 00:00:00 2001 From: "coderabbitai[bot]" <136622811+coderabbitai[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 17:05:29 +0000 Subject: [PATCH 2/2] fix: apply CodeRabbit auto-fixes Fixed 1 file(s) based on 1 unresolved review comment. Co-authored-by: CodeRabbit --- plugins/buzz-control/README.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/plugins/buzz-control/README.md b/plugins/buzz-control/README.md index 4675d11..1c226b7 100644 --- a/plugins/buzz-control/README.md +++ b/plugins/buzz-control/README.md @@ -61,8 +61,10 @@ an unverified production file safe. The next manual or scheduled image update automatically establishes the baseline only when the running relay exactly matches `prod.env` and passes Docker and HTTP health checks, so ordinary image updates do not require the configuration UI. A mismatched or unhealthy runtime -remains blocked until the operator resolves it and explicitly adopts the current -healthy configuration. +remains blocked until automatic verification completes successfully OR the +operator explicitly adopts the current healthy configuration. Subsequent manual +or scheduled image updates retry automatic baseline establishment when the +baseline is still missing. ## Managed settings