All checks were successful
CI / lint (pull_request) Successful in 31s
CI / test (pull_request) Successful in 1m27s
CI / validate-json (pull_request) Successful in 25s
CI / markdown-links (pull_request) Successful in 54s
Build ISO / build-iso (push) Successful in 18m35s
CI / lint (push) Successful in 29s
CI / test (push) Successful in 1m20s
CI / validate-json (push) Successful in 25s
CI / markdown-links (push) Successful in 54s
The reconciler handed an on_start dependency hook only FURTKA_CONSUMER_APP/_VERSION, so it had no way to learn the consumer's existing secrets. That made the feature's own headline use case — re-create a provider account (e.g. an MQTT user) after a wipe with the same password the consumer already holds — impossible unless the provider stashed a copy itself. on_start now also receives the consumer's .env values, namespaced under FURTKA_CONSUMER_ENV_<KEY> (UPPER_SNAKE_CASE keys only, so a hand-edited .env can't produce a malformed --env arg). on_start stays read-only toward the consumer: unlike on_install, its stdout is intentionally not merged back into the consumer's .env. Surfaced building the first real provider/consumer catalog pair (mosquitto + zigbee2mqtt) in daniel/furtka-apps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
143 lines
6.1 KiB
Python
143 lines
6.1 KiB
Python
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from furtka import deps, dockerops, installer
|
|
from furtka.manifest import SETTING_NAME_RE, ManifestError, load_manifest
|
|
from furtka.scanner import scan
|
|
|
|
_ON_START_TIMEOUT_SECONDS = 30.0
|
|
|
|
# Consumer .env values are exported into an on_start hook's environment under
|
|
# this prefix. Namespaced so a consumer setting named PATH/HOME/etc. can't
|
|
# clobber the provider container's own environment when the hook runs.
|
|
_CONSUMER_ENV_PREFIX = "FURTKA_CONSUMER_ENV_"
|
|
|
|
|
|
@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=_on_start_hook_env(consumer, apps_root),
|
|
timeout=_ON_START_TIMEOUT_SECONDS,
|
|
)
|
|
|
|
|
|
def _on_start_hook_env(consumer, apps_root: Path) -> dict[str, str]:
|
|
"""Build the environment handed to an on_start hook.
|
|
|
|
Beyond the consumer's name/version, this injects the consumer's stored
|
|
`.env` values (namespaced under `FURTKA_CONSUMER_ENV_`) so the hook can
|
|
re-establish provider-side state *idempotently* — e.g. re-create an MQTT
|
|
account with the SAME password the consumer already holds. The reconciler
|
|
does not merge an on_start hook's stdout back into the consumer's `.env`
|
|
(unlike on_install): on_start reads consumer state, it doesn't mutate it.
|
|
Only UPPER_SNAKE_CASE keys are injected so a hand-edited `.env` can't
|
|
produce a malformed `docker compose exec --env` argument.
|
|
"""
|
|
env = {
|
|
"FURTKA_CONSUMER_APP": consumer.name,
|
|
"FURTKA_CONSUMER_VERSION": consumer.version,
|
|
}
|
|
consumer_env = installer.read_env_values(apps_root / consumer.name / ".env")
|
|
for key, value in consumer_env.items():
|
|
if SETTING_NAME_RE.match(key):
|
|
env[f"{_CONSUMER_ENV_PREFIX}{key}"] = value
|
|
return env
|
|
|
|
|
|
def has_errors(actions: list[Action]) -> bool:
|
|
return any(a.kind == "error" for a in actions)
|