furtka/furtka/cli.py
Daniel Maksymilian Syrnicki 7b96a25f5b feat(furtka): reconciler + install/remove — slice 2
Fills in the act-on-it half of the resource manager. Reconciler walks
the scanner output and brings docker into the desired state: ensures
each manifest-declared volume exists (idempotent), then runs
docker compose up -d for the project. install/remove on the CLI work
end-to-end against a real /var/lib/furtka/apps/ tree.

- furtka.dockerops: thin subprocess wrapper. Volume + compose
  primitives that other modules call. `_run` raises DockerError with
  the actual stderr so failures are diagnosable.
- furtka.reconciler: builds an ordered Action list (volumes then
  compose_up per app), executes unless dry-run. Broken manifests
  produce a "skip" action, the rest of the apps still get reconciled.
- furtka.installer: copy-from-source with two non-obvious rules —
  user .env is preserved across upgrade installs, and a missing .env
  is bootstrapped from .env.example so compose has values to
  substitute on first install. Bundled-app lookup falls back to
  /opt/furtka/apps/<name>/ when the source arg isn't a path.
- furtka.cli: app install/remove wired up. remove() ignores compose
  down failures so a botched compose doesn't trap users with an
  un-removable folder.
- 15 new tests using monkeypatch'd dockerops so the suite still runs
  without docker installed. Covers reconcile dry-run, multi-volume
  apps, broken-manifest skip behavior, .env preservation, bundled-name
  resolution, and remove edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:02:00 +02:00

135 lines
4.3 KiB
Python

import argparse
import json
import sys
from furtka import dockerops, installer, reconciler
from furtka.paths import apps_dir
from furtka.scanner import scan
def _cmd_app_list(args: argparse.Namespace) -> int:
results = scan(apps_dir())
if args.json:
out = [
{
"path": str(r.path),
"name": r.manifest.name if r.manifest else None,
"ok": r.ok,
"error": r.error,
"manifest": {
"name": r.manifest.name,
"display_name": r.manifest.display_name,
"version": r.manifest.version,
"description": r.manifest.description,
"volumes": list(r.manifest.volumes),
"ports": list(r.manifest.ports),
"icon": r.manifest.icon,
}
if r.manifest
else None,
}
for r in results
]
print(json.dumps(out, indent=2))
return 0
if not results:
print("(no apps installed)")
return 0
for r in results:
if r.ok:
m = r.manifest
print(f"{m.name:20s} {m.version:10s} {m.display_name}")
else:
print(f"{r.path.name:20s} ERROR {r.error}")
return 0
def _cmd_app_install(args: argparse.Namespace) -> int:
try:
src = installer.resolve_source(args.source)
target = installer.install_from(src)
except installer.InstallError as e:
print(f"error: {e}", file=sys.stderr)
return 2
print(f"installed {target.name} to {target}")
actions = reconciler.reconcile(apps_dir())
for a in actions:
print(f" {a.describe()}")
return 0
def _cmd_app_remove(args: argparse.Namespace) -> int:
target = apps_dir() / args.name
if not target.exists():
print(f"error: {args.name!r} is not installed", file=sys.stderr)
return 1
try:
dockerops.compose_down(target, args.name)
except dockerops.DockerError as e:
# Container may already be down (or never came up). Don't block removal.
print(f"warning: compose down failed, removing folder anyway: {e}", file=sys.stderr)
try:
installer.remove(args.name)
except installer.InstallError as e:
print(f"error: {e}", file=sys.stderr)
return 1
print(f"removed {args.name} (volumes preserved)")
return 0
def _cmd_reconcile(args: argparse.Namespace) -> int:
actions = reconciler.reconcile(apps_dir(), dry_run=args.dry_run)
print(f"Scanned {apps_dir()}: {len(actions)} actions")
for a in actions:
print(f" {a.describe()}")
if args.dry_run:
print("(dry-run — nothing changed)")
return 0
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(prog="furtka", description="Furtka resource manager")
sub = p.add_subparsers(dest="command", required=True)
app = sub.add_parser("app", help="Manage installed apps")
app_sub = app.add_subparsers(dest="subcommand", required=True)
app_list = app_sub.add_parser("list", help="List installed apps")
app_list.add_argument("--json", action="store_true", help="Emit JSON instead of a table")
app_list.set_defaults(func=_cmd_app_list)
app_install = app_sub.add_parser(
"install",
help="Install an app from a local folder or a bundled-app name",
)
app_install.add_argument(
"source",
help="Path to an app folder, or the name of a bundled app under /opt/furtka/apps/",
)
app_install.set_defaults(func=_cmd_app_install)
app_remove = app_sub.add_parser("remove", help="Stop and uninstall an app (keeps volumes)")
app_remove.add_argument("name", help="App name (folder name under /var/lib/furtka/apps/)")
app_remove.set_defaults(func=_cmd_app_remove)
reconcile = sub.add_parser(
"reconcile",
help="Bring docker state in line with /var/lib/furtka/apps",
)
reconcile.add_argument(
"--dry-run",
action="store_true",
help="Show what would be done without changing anything",
)
reconcile.set_defaults(func=_cmd_reconcile)
return p
def main(argv: list[str] | None = None) -> int:
args = build_parser().parse_args(argv)
return args.func(args)
if __name__ == "__main__":
sys.exit(main())