furtka/furtka/reconciler.py
Daniel Maksymilian Syrnicki 8e1f817d85
Some checks failed
Build ISO / build-iso (push) Successful in 21m39s
CI / lint (push) Failing after 28s
CI / test (push) Successful in 1m29s
CI / validate-json (push) Successful in 24s
CI / markdown-links (push) Successful in 14s
Release / release (push) Successful in 12m2s
feat(apps): app-to-app dependencies with install + start hooks
Manifests gain an optional `requires` array. Each entry points at
another app and may declare `on_install` + `on_start` hook scripts
that live in the *provider's* folder and run inside its container
via `docker compose exec`. Hook stdout (KEY=VALUE + optional
FURTKA_JSON: sentinel) gets merged into the consumer's .env; the
placeholder-secret check re-runs over the merged file. Provider apps
that aren't installed get auto-installed first (topo order, cycle
detection, explicit UI confirm). Removing an app is blocked while
other installed apps require it. Reconcile now visits apps in
dependency order so consumers' on_start hooks fire against already-up
providers; per-app error isolation skips just the offending consumer's
compose_up.

Release 26.17-alpha.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 19:39:10 +02:00

124 lines
4.9 KiB
Python

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)