feat(furtka): resource-manager skeleton — manifest, scanner, CLI
Slice 1 of the Resource Manager (see docs/resource-manager.md +
plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the
read-only half: a JSON manifest schema with namespacing, a scanner
that walks /var/lib/furtka/apps/, and a `furtka` CLI with
`app list` and `reconcile --dry-run`. Reconciler / volume creation
/ docker compose calls land in the next slice.
- furtka.manifest: dataclass + load_manifest with required-field +
type validation. volume_name() injects the furtka_<app>_<vol>
namespace so apps can each declare a "data" volume without colliding.
- furtka.scanner: tolerant — broken manifest = ScanResult with error,
not an exception. Lets reconcile log + skip rather than abort.
- furtka.cli: text + --json output. argparse with `app list` and
`reconcile --dry-run`. main() returns int for clean exit codes.
- furtka.paths: FURTKA_APPS_DIR env override so tests don't need root.
- 19 new tests covering valid manifests, every validation branch,
scanner edge cases (missing root, broken manifest, sort order), and
the CLI subcommands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:59:41 +02:00
|
|
|
import argparse
|
|
|
|
|
import json
|
|
|
|
|
import sys
|
|
|
|
|
|
2026-04-15 10:02:00 +02:00
|
|
|
from furtka import dockerops, installer, reconciler
|
feat(furtka): resource-manager skeleton — manifest, scanner, CLI
Slice 1 of the Resource Manager (see docs/resource-manager.md +
plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the
read-only half: a JSON manifest schema with namespacing, a scanner
that walks /var/lib/furtka/apps/, and a `furtka` CLI with
`app list` and `reconcile --dry-run`. Reconciler / volume creation
/ docker compose calls land in the next slice.
- furtka.manifest: dataclass + load_manifest with required-field +
type validation. volume_name() injects the furtka_<app>_<vol>
namespace so apps can each declare a "data" volume without colliding.
- furtka.scanner: tolerant — broken manifest = ScanResult with error,
not an exception. Lets reconcile log + skip rather than abort.
- furtka.cli: text + --json output. argparse with `app list` and
`reconcile --dry-run`. main() returns int for clean exit codes.
- furtka.paths: FURTKA_APPS_DIR env override so tests don't need root.
- 19 new tests covering valid manifests, every validation branch,
scanner edge cases (missing root, broken manifest, sort order), and
the CLI subcommands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:59:41 +02:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
2026-04-15 10:02:00 +02:00
|
|
|
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()}")
|
2026-04-15 10:17:00 +02:00
|
|
|
return 1 if reconciler.has_errors(actions) else 0
|
2026-04-15 10:02:00 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
feat(furtka): web UI + HTTP API for app install/remove
Adds the management UI Daniel asked for end-of-session. Goes beyond
the original MVP scope (plan punted UI to v2) but the architecture
already supports it cleanly: stdlib http.server only, no new deps.
- furtka.api: minimal HTTP server. GET / serves a self-contained
HTML page (dark-mode card list, vanilla JS, no build step). GET
/api/apps + /api/bundled return JSON. POST /api/apps/{install,
remove} accept {"name": "..."} and call the same installer +
reconciler the CLI uses, so the placeholder-secret refusal and
per-app reconcile isolation flow through unchanged.
- furtka.cli: new `furtka serve` subcommand. Imports api lazily so
`furtka app list` / `reconcile` startup stays zero-cost.
- webinstaller: new furtka-api.service (Type=simple, restart on
failure, after reconcile). Caddyfile gets two new handle blocks
to reverse-proxy /api and /apps to localhost:7000. Landing page's
"App store coming soon" tile becomes a real "Manage installed apps
→" link to /apps.
- Bound to 127.0.0.1 by default; Caddy makes it LAN-reachable. The
UI shouts a "no auth, anyone on your LAN can install/remove" warning
at the top — Authentik integration is the proper fix later.
UX wrinkle worth noting: a placeholder-rejected install leaves the
app in /var/lib/furtka/apps/<name>/ (so the user can edit .env in
place). To re-trigger after editing, the Installed list now shows
both Reinstall and Remove buttons.
10 new tests: helper functions (list_installed, list_bundled with
hide-already-installed), install/remove endpoints with the no_docker
fixture, and two real-socket urllib smoke tests that boot the actual
HTTPServer on an ephemeral port and round-trip GET / + POST.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:23:46 +02:00
|
|
|
def _cmd_serve(args: argparse.Namespace) -> int:
|
|
|
|
|
# Imported lazily so `furtka` startup stays cheap when the user only runs
|
|
|
|
|
# `app list` or `reconcile` (the common case during tests + scripts).
|
|
|
|
|
from furtka import api
|
|
|
|
|
|
|
|
|
|
api.serve(args.host, args.port)
|
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
feat(furtka): resource-manager skeleton — manifest, scanner, CLI
Slice 1 of the Resource Manager (see docs/resource-manager.md +
plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the
read-only half: a JSON manifest schema with namespacing, a scanner
that walks /var/lib/furtka/apps/, and a `furtka` CLI with
`app list` and `reconcile --dry-run`. Reconciler / volume creation
/ docker compose calls land in the next slice.
- furtka.manifest: dataclass + load_manifest with required-field +
type validation. volume_name() injects the furtka_<app>_<vol>
namespace so apps can each declare a "data" volume without colliding.
- furtka.scanner: tolerant — broken manifest = ScanResult with error,
not an exception. Lets reconcile log + skip rather than abort.
- furtka.cli: text + --json output. argparse with `app list` and
`reconcile --dry-run`. main() returns int for clean exit codes.
- furtka.paths: FURTKA_APPS_DIR env override so tests don't need root.
- 19 new tests covering valid manifests, every validation branch,
scanner edge cases (missing root, broken manifest, sort order), and
the CLI subcommands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:59:41 +02:00
|
|
|
def _cmd_reconcile(args: argparse.Namespace) -> int:
|
2026-04-15 10:02:00 +02:00
|
|
|
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)")
|
2026-04-15 10:17:00 +02:00
|
|
|
# Exit non-zero on any per-app failure so systemd marks furtka-reconcile
|
|
|
|
|
# red — but only AFTER all apps were attempted, so a broken app doesn't
|
|
|
|
|
# hide healthy ones.
|
|
|
|
|
return 1 if reconciler.has_errors(actions) else 0
|
feat(furtka): resource-manager skeleton — manifest, scanner, CLI
Slice 1 of the Resource Manager (see docs/resource-manager.md +
plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the
read-only half: a JSON manifest schema with namespacing, a scanner
that walks /var/lib/furtka/apps/, and a `furtka` CLI with
`app list` and `reconcile --dry-run`. Reconciler / volume creation
/ docker compose calls land in the next slice.
- furtka.manifest: dataclass + load_manifest with required-field +
type validation. volume_name() injects the furtka_<app>_<vol>
namespace so apps can each declare a "data" volume without colliding.
- furtka.scanner: tolerant — broken manifest = ScanResult with error,
not an exception. Lets reconcile log + skip rather than abort.
- furtka.cli: text + --json output. argparse with `app list` and
`reconcile --dry-run`. main() returns int for clean exit codes.
- furtka.paths: FURTKA_APPS_DIR env override so tests don't need root.
- 19 new tests covering valid manifests, every validation branch,
scanner edge cases (missing root, broken manifest, sort order), and
the CLI subcommands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:59:41 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
2026-04-15 10:02:00 +02:00
|
|
|
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)
|
|
|
|
|
|
feat(furtka): resource-manager skeleton — manifest, scanner, CLI
Slice 1 of the Resource Manager (see docs/resource-manager.md +
plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the
read-only half: a JSON manifest schema with namespacing, a scanner
that walks /var/lib/furtka/apps/, and a `furtka` CLI with
`app list` and `reconcile --dry-run`. Reconciler / volume creation
/ docker compose calls land in the next slice.
- furtka.manifest: dataclass + load_manifest with required-field +
type validation. volume_name() injects the furtka_<app>_<vol>
namespace so apps can each declare a "data" volume without colliding.
- furtka.scanner: tolerant — broken manifest = ScanResult with error,
not an exception. Lets reconcile log + skip rather than abort.
- furtka.cli: text + --json output. argparse with `app list` and
`reconcile --dry-run`. main() returns int for clean exit codes.
- furtka.paths: FURTKA_APPS_DIR env override so tests don't need root.
- 19 new tests covering valid manifests, every validation branch,
scanner edge cases (missing root, broken manifest, sort order), and
the CLI subcommands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:59:41 +02:00
|
|
|
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)
|
|
|
|
|
|
feat(furtka): web UI + HTTP API for app install/remove
Adds the management UI Daniel asked for end-of-session. Goes beyond
the original MVP scope (plan punted UI to v2) but the architecture
already supports it cleanly: stdlib http.server only, no new deps.
- furtka.api: minimal HTTP server. GET / serves a self-contained
HTML page (dark-mode card list, vanilla JS, no build step). GET
/api/apps + /api/bundled return JSON. POST /api/apps/{install,
remove} accept {"name": "..."} and call the same installer +
reconciler the CLI uses, so the placeholder-secret refusal and
per-app reconcile isolation flow through unchanged.
- furtka.cli: new `furtka serve` subcommand. Imports api lazily so
`furtka app list` / `reconcile` startup stays zero-cost.
- webinstaller: new furtka-api.service (Type=simple, restart on
failure, after reconcile). Caddyfile gets two new handle blocks
to reverse-proxy /api and /apps to localhost:7000. Landing page's
"App store coming soon" tile becomes a real "Manage installed apps
→" link to /apps.
- Bound to 127.0.0.1 by default; Caddy makes it LAN-reachable. The
UI shouts a "no auth, anyone on your LAN can install/remove" warning
at the top — Authentik integration is the proper fix later.
UX wrinkle worth noting: a placeholder-rejected install leaves the
app in /var/lib/furtka/apps/<name>/ (so the user can edit .env in
place). To re-trigger after editing, the Installed list now shows
both Reinstall and Remove buttons.
10 new tests: helper functions (list_installed, list_bundled with
hide-already-installed), install/remove endpoints with the no_docker
fixture, and two real-socket urllib smoke tests that boot the actual
HTTPServer on an ephemeral port and round-trip GET / + POST.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 10:23:46 +02:00
|
|
|
serve = sub.add_parser("serve", help="Run the resource-manager HTTP API + UI")
|
|
|
|
|
serve.add_argument("--host", default="127.0.0.1", help="Bind address (default 127.0.0.1)")
|
|
|
|
|
serve.add_argument("--port", type=int, default=7000, help="Bind port (default 7000)")
|
|
|
|
|
serve.set_defaults(func=_cmd_serve)
|
|
|
|
|
|
feat(furtka): resource-manager skeleton — manifest, scanner, CLI
Slice 1 of the Resource Manager (see docs/resource-manager.md +
plan in ~/.claude/plans/stateful-juggling-pike.md). Lays down the
read-only half: a JSON manifest schema with namespacing, a scanner
that walks /var/lib/furtka/apps/, and a `furtka` CLI with
`app list` and `reconcile --dry-run`. Reconciler / volume creation
/ docker compose calls land in the next slice.
- furtka.manifest: dataclass + load_manifest with required-field +
type validation. volume_name() injects the furtka_<app>_<vol>
namespace so apps can each declare a "data" volume without colliding.
- furtka.scanner: tolerant — broken manifest = ScanResult with error,
not an exception. Lets reconcile log + skip rather than abort.
- furtka.cli: text + --json output. argparse with `app list` and
`reconcile --dry-run`. main() returns int for clean exit codes.
- furtka.paths: FURTKA_APPS_DIR env override so tests don't need root.
- 19 new tests covering valid manifests, every validation branch,
scanner edge cases (missing root, broken manifest, sort order), and
the CLI subcommands.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 09:59:41 +02:00
|
|
|
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())
|