from dataclasses import dataclass from pathlib import Path from furtka import deps, dockerops from furtka.manifest import ManifestError, load_manifest from furtka.scanner import scan _ON_START_TIMEOUT_SECONDS = 30.0 @dataclass(frozen=True) class Action: kind: str # "ensure_volume" | "compose_up" | "hook" | "skip" | "error" target: str detail: str = "" def describe(self) -> str: if self.detail: return f"{self.kind:14s} {self.target} ({self.detail})" return f"{self.kind:14s} {self.target}" def reconcile(apps_root: Path, dry_run: bool = False) -> list[Action]: """Walk the apps tree and bring docker into the desired state. Apps are visited in dependency order — providers before consumers — so a consumer's `on_start` hook runs against an already-up provider. Within a tier, order stays alphabetical for deterministic boot logs. Apps with unresolvable `requires` (missing provider, broken manifest cycle) are visited last; reconcile's per-app isolation still kicks in if they fail. Failures during one app's reconcile (Docker errors, missing binary, …) are captured as Action(kind='error', …) and do NOT abort the whole sweep — the other apps still get reconciled. Callers inspect the returned actions to decide overall success. """ actions: list[Action] = [] results = scan(apps_root) for result in deps.installed_topo_order(results): if not result.ok: actions.append(Action("skip", result.path.name, result.error or "")) continue m = result.manifest try: for vol_short in m.volumes: full = m.volume_name(vol_short) actions.append(Action("ensure_volume", full)) if not dry_run: dockerops.ensure_volume(full) hook_failed = False for req in m.requires: if not req.on_start: continue hook_label = f"{m.name}:{req.app}:on_start" actions.append(Action("hook", hook_label, req.on_start)) if dry_run: continue try: _fire_on_start_hook(m, req, apps_root) except ( dockerops.DockerError, FileNotFoundError, OSError, ManifestError, ) as e: actions.append( Action("error", m.name, f"on_start({req.app}): {e}") ) hook_failed = True break if hook_failed: # Skip compose_up: a consumer whose provider's contract didn't # get re-established (e.g. missing MQTT user) starting up # blindly is worse than not starting it. The provider stays up # and other apps in the sweep keep going. continue actions.append(Action("compose_up", m.name)) if not dry_run: dockerops.compose_up(result.path, m.name) except (dockerops.DockerError, FileNotFoundError, OSError) as e: # Catch broad enough to cover: docker daemon down, docker binary # missing on the box, compose file unreadable. Narrow enough that # programmer errors (KeyError etc.) still surface. actions.append(Action("error", m.name, str(e))) return actions def _fire_on_start_hook(consumer, req, apps_root: Path) -> None: """Run a single `on_start` hook against the provider's running container. Reconciler-local helper — kept narrow on purpose so reconcile's main loop stays scannable. Errors propagate; the caller decorates with the per-app Action("error", ...) and skips compose_up for this consumer. """ provider_dir = apps_root / req.app provider_manifest_path = provider_dir / "manifest.json" if not provider_manifest_path.is_file(): raise FileNotFoundError( f"required app {req.app!r} is not installed" ) # Validate provider manifest loads (otherwise scanner would have skipped # it and we'd still try to exec — fail loud here instead). load_manifest(provider_manifest_path, expected_name=req.app) hook_abs = provider_dir / req.on_start if not hook_abs.is_file(): raise FileNotFoundError( f"on_start hook {req.on_start!r} missing in provider {req.app}" ) service = deps.provider_exec_service(provider_dir, req.app) dockerops.compose_exec_script( provider_dir, req.app, service, hook_abs, env={ "FURTKA_CONSUMER_APP": consumer.name, "FURTKA_CONSUMER_VERSION": consumer.version, }, timeout=_ON_START_TIMEOUT_SECONDS, ) def has_errors(actions: list[Action]) -> bool: return any(a.kind == "error" for a in actions)