diff --git a/navi/overlay/apply.py b/navi/overlay/apply.py index f4a93a3..3fc969e 100644 --- a/navi/overlay/apply.py +++ b/navi/overlay/apply.py @@ -25,7 +25,7 @@ import structlog -from navi.overlay.fetch import Fetched, fetch_layers, store_applied +from navi.overlay.fetch import Fetched, fetch_layers, send_ack, store_applied from navi.overlay.merge import merge_patch from navi.overlay.schema import HOT_SETTINGS from navi.overlay.state import get_state, lock, status_summary @@ -204,37 +204,6 @@ return staged -def _ack(base_settings, fetched: Fetched, status: str, restart_required: list[str], - errors: list[str]) -> None: - if not base_settings.hive_url: - return - from navi.overlay.fetch import _identity_params, _read_key - - key = _read_key(base_settings.swarm_key_file) - identity = _identity_params(base_settings.instance_file).get("instance_id", "") - if not key or not identity: - return - import httpx - - payload = { - "instance_id": identity, - "hash": fetched.hash, - "version": fetched.version, - "status": status, - "restart_required": restart_required, - "error": errors[0] if errors else None, - } - try: - with httpx.Client(timeout=base_settings.hive_config_timeout_sec) as client: - client.post( - base_settings.hive_url.rstrip("/") + "/config/ack", - json=payload, - headers={"X-Swarm-Key": key}, - ) - except Exception as exc: # the announce payload is the redundant path - log.info("hive.config.ack_failed", error=f"{type(exc).__name__}: {exc}") - - def refresh(trigger: str = "notify") -> dict: """Fetch, validate and apply the current hive config. Never raises.""" state = get_state() @@ -277,7 +246,7 @@ ) # Tell the hive, so a rejected document is visible in its status # view instead of looking like a machine that never answered. - _ack(base, fetched, "rejected", [], validation.errors) + send_ack(base, fetched, "rejected", [], validation.errors) return { "status": "rejected", "version": fetched.version, @@ -305,7 +274,7 @@ state.hot_applied = hot state.status = "staged" if restart else "applied" store_applied(base, fetched) - _ack(base, fetched, state.status, restart, []) + send_ack(base, fetched, state.status, restart, []) log.info( "hive.config.applied", diff --git a/navi/overlay/fetch.py b/navi/overlay/fetch.py index bedcfa3..cb61ced 100644 --- a/navi/overlay/fetch.py +++ b/navi/overlay/fetch.py @@ -331,15 +331,69 @@ def confirm_startup(base_settings) -> None: - """The process reached the end of startup: the pending config works.""" + """The process reached the end of startup: the pending config works. + + Also re-reports the config we started with. Without this a machine that + restarted to apply a document would keep its old "staged, restart + required" ack forever, and the hive's status view would say it never took + the config it is in fact running. + """ path = _cache_path(base_settings) cached = load_cached(path) - if cached.pending is None: + if cached.pending is not None: + cached.good = cached.pending + cached.pending = None + save_cached(path, cached) + log.info("hive.config.confirmed", version=cached.good.get("version")) + _ack_startup(base_settings) + + +def _ack_startup(base_settings) -> None: + state = get_state() + if state.version <= 0 or state.status not in {"applied", "staged"}: return - cached.good = cached.pending - cached.pending = None - save_cached(path, cached) - log.info("hive.config.confirmed", version=cached.good.get("version")) + send_ack( + base_settings, + Fetched(version=state.version, layers=[], hash=state.hash), + state.status, + list(state.restart_required), + list(state.errors), + ) + + +def send_ack( + base_settings, + fetched: Fetched, + status: str, + restart_required: list[str], + errors: list[str], +) -> None: + """Tell the hive what this instance did with a version. Best effort.""" + if not base_settings.hive_url: + return + key = _read_key(base_settings.swarm_key_file) + identity = _identity_params(base_settings.instance_file).get("instance_id", "") + if not key or not identity: + return + payload = { + "instance_id": identity, + "hash": fetched.hash, + "version": fetched.version, + "status": status, + "restart_required": restart_required, + "error": errors[0] if errors else None, + } + import httpx + + try: + with httpx.Client(timeout=base_settings.hive_config_timeout_sec) as client: + client.post( + base_settings.hive_url.rstrip("/") + "/config/ack", + json=payload, + headers={"X-Swarm-Key": key}, + ) + except Exception as exc: # the announce payload is the redundant path + log.info("hive.config.ack_failed", error=f"{type(exc).__name__}: {exc}") def store_applied(base_settings, fetched: Fetched) -> None: diff --git a/tests/unit/overlay/test_fetch.py b/tests/unit/overlay/test_fetch.py index cc615c9..c5f262e 100644 --- a/tests/unit/overlay/test_fetch.py +++ b/tests/unit/overlay/test_fetch.py @@ -126,6 +126,36 @@ assert cached.pending is None +def test_confirm_startup_re_reports_the_config_it_started_with(base, monkeypatch): + from navi.overlay.state import get_state, reset_state + + reset_state() + state = get_state() + state.version = 5 + state.hash = "sha256:h" + state.status = "applied" + sent = [] + monkeypatch.setattr(fetch_mod, "send_ack", lambda *a: sent.append(a[2:])) + try: + confirm_startup(base) + assert sent == [("applied", [], [])] + finally: + reset_state() + + +def test_confirm_startup_sends_no_ack_without_a_hive_config(base, monkeypatch): + from navi.overlay.state import reset_state + + reset_state() + sent = [] + monkeypatch.setattr(fetch_mod, "send_ack", lambda *a: sent.append(a)) + try: + confirm_startup(base) # state.version is 0 — nothing was applied + assert sent == [] + finally: + reset_state() + + def test_same_config_retried_once_then_blocked(base, monkeypatch): """A config that was applied but never confirmed is retried once.""" _patch_fetch(