Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
.DS_Store
.worktrees/
__pycache__/
*.py[cod]
23 changes: 15 additions & 8 deletions plugins/buzz-control/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,15 @@ 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 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

Expand Down Expand Up @@ -114,10 +119,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.
Expand Down
2 changes: 1 addition & 1 deletion plugins/buzz-control/dashboard/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion plugins/buzz-control/plugin.yaml
Original file line number Diff line number Diff line change
@@ -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: []
88 changes: 55 additions & 33 deletions plugins/buzz-control/scripts/reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand All @@ -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(
[
Expand All @@ -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:
Expand Down Expand Up @@ -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"

Expand Down
4 changes: 2 additions & 2 deletions plugins/buzz-control/tests/test_plugin_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
116 changes: 116 additions & 0 deletions plugins/buzz-control/tests/test_reconcile.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down