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>
79 lines
2.5 KiB
Python
79 lines
2.5 KiB
Python
import json
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
|
|
REQUIRED_FIELDS = (
|
|
"name",
|
|
"display_name",
|
|
"version",
|
|
"description",
|
|
"volumes",
|
|
"ports",
|
|
"icon",
|
|
)
|
|
|
|
|
|
class ManifestError(Exception):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Manifest:
|
|
name: str
|
|
display_name: str
|
|
version: str
|
|
description: str
|
|
volumes: tuple[str, ...]
|
|
ports: tuple[int, ...]
|
|
icon: str
|
|
|
|
def volume_name(self, short: str) -> str:
|
|
# Namespace volume names so two apps can each declare e.g. "data"
|
|
# without colliding in `docker volume ls`.
|
|
if short not in self.volumes:
|
|
raise ManifestError(f"{self.name}: volume {short!r} not declared in manifest")
|
|
return f"furtka_{self.name}_{short}"
|
|
|
|
|
|
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
|
|
"""Parse and validate a manifest.json.
|
|
|
|
`expected_name` is used by the scanner (where the install location's folder
|
|
name IS the source of truth and must match the manifest). For loading from
|
|
arbitrary source folders during install, leave it None — the manifest's own
|
|
`name` field decides the install target.
|
|
"""
|
|
try:
|
|
raw = json.loads(path.read_text())
|
|
except json.JSONDecodeError as e:
|
|
raise ManifestError(f"{path}: invalid JSON: {e}") from e
|
|
if not isinstance(raw, dict):
|
|
raise ManifestError(f"{path}: top-level must be an object")
|
|
|
|
missing = [f for f in REQUIRED_FIELDS if f not in raw]
|
|
if missing:
|
|
raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}")
|
|
|
|
name = raw["name"]
|
|
if not isinstance(name, str) or not name:
|
|
raise ManifestError(f"{path}: name must be a non-empty string")
|
|
if expected_name is not None and name != expected_name:
|
|
raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}")
|
|
|
|
volumes = raw["volumes"]
|
|
if not isinstance(volumes, list) or not all(isinstance(v, str) and v for v in volumes):
|
|
raise ManifestError(f"{path}: volumes must be a list of non-empty strings")
|
|
|
|
ports = raw["ports"]
|
|
if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports):
|
|
raise ManifestError(f"{path}: ports must be a list of integers")
|
|
|
|
return Manifest(
|
|
name=name,
|
|
display_name=str(raw["display_name"]),
|
|
version=str(raw["version"]),
|
|
description=str(raw["description"]),
|
|
volumes=tuple(volumes),
|
|
ports=tuple(ports),
|
|
icon=str(raw["icon"]),
|
|
)
|