Addresses the four issues raised in the slice-3 audit before pushing. #1 (critical) — refuse to finish install when .env still contains placeholder secrets like "changeme". Without this, `furtka app install fileshare` would happily start an SMB server with a publicly-known password — the kind of default that ends up screenshotted on Hacker News. PLACEHOLDER_SECRETS lives in installer.py; new tests cover placeholder rejection, post-edit retry, and quoted values. #3 — reconciler now catches DockerError / FileNotFoundError / OSError per-app instead of letting a single broken app abort the whole boot-scan. Errors get surfaced as Action(kind="error", …) and has_errors() drives the CLI exit code so systemd still shows red, but the other apps actually got reconciled. #4 — chmod 0600 on .env after install so app secrets aren't world- readable on multi-user boxes. Done before the placeholder check so even the half-installed state is safe. #5 — load_manifest() got an optional expected_name. The scanner passes the folder name (filesystem source-of-truth contract); installer leaves it None so `furtka app install /tmp/some-fork/` works regardless of what the source folder is named. #2 — TODO comment on dperson/samba:latest. Switching to a digest needs a verified upstream release; left for the test-day pin. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
52 lines
1.9 KiB
Python
52 lines
1.9 KiB
Python
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
from furtka import dockerops
|
|
from furtka.scanner import scan
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Action:
|
|
kind: str # "ensure_volume" | "compose_up" | "skip"
|
|
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.
|
|
|
|
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] = []
|
|
for result in scan(apps_root):
|
|
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)
|
|
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 has_errors(actions: list[Action]) -> bool:
|
|
return any(a.kind == "error" for a in actions)
|