Reflects commit 61c7ee2 — manifest gains `settings` + `description_long`,
API gains `GET/POST /api/apps/<name>/settings`, install/reinstall accepts
a `settings` object. Drops the stale "in-UI .env editor" from the
out-of-scope list since that's what just shipped.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
8.9 KiB
Resource Manager
The layer between Furtka apps and the underlying system (disk, Docker, network). Apps don't touch Docker or the filesystem directly — they declare what they need in a manifest and the Resource Manager provisions, runs, and tracks them.
Status: v1 shipped 2026-04-15 in commits cfc4c0b..c6ed7a8, validated end-to-end on Proxmox VM same day (install → Web UI → fileshare install → SMB client → reboot-persistence all green). Commit 61c7ee2 adds the in-browser settings form and description_long so users no longer need SSH to configure an app. First consumer is the LAN fileshare app at apps/fileshare/. Web UI at http://<host>.local/apps.
For the conversation that produced these decisions and the Q&A live in the chat, see ~/.claude/plans/stateful-juggling-pike.md.
Anatomy of an app
Every Furtka app is a directory containing exactly:
manifest.json # required — the contract
docker-compose.yaml # required — container lifecycle
.env.example # optional — bootstraps .env on first install
.env # optional — user-edited secrets (preserved across upgrades)
icon.svg # optional but referenced in the manifest
The directory name is the app name. The manifest's name field must match it once installed (the scanner enforces this). When you install from an arbitrary source path the manifest's name decides where it lands — so furtka app install /tmp/some-fork/ works regardless of what the source folder is called.
Manifest schema
JSON. Fields marked required are mandatory; optional ones default as noted.
{
"name": "fileshare",
"display_name": "Network Files",
"version": "0.1.0",
"description": "SMB share for LAN devices",
"description_long": "Longer user-facing help shown above the settings form.",
"volumes": ["files"],
"ports": [445, 139],
"icon": "icon.svg",
"settings": [
{
"name": "SMB_USER",
"label": "Benutzername",
"description": "Der Name, mit dem sich Geräte am Share anmelden.",
"type": "text",
"default": "furtka",
"required": true
},
{
"name": "SMB_PASSWORD",
"label": "Passwort",
"description": "Mindestens 8 Zeichen. Wird nie angezeigt.",
"type": "password",
"required": true
}
]
}
Top-level fields:
name(required) — machine id, must equal the install folder name.display_name(required) — shown in the UI.version(required) — free-form string (semver expected, not enforced).description(required) — one-line summary rendered on the card.description_long(optional, default"") — multi-sentence help text rendered above the settings form. Plain text, newlines preserved.volumes(required) — list of short names. Furtka creates each asfurtka_<app>_<vol>(collision-free across apps). Compose files MUST reference the namespaced name asexternal: true.ports(required) — informational for the UI. Compose owns the actual port binding.icon(required) — relative path inside the app folder.settings(optional, default[]) — see next section.
Settings schema
Each entry in settings declares one environment variable the user can fill in via the Web UI (or via POST /api/apps/install with a settings object). On install/edit the values are written to .env in the app folder.
name(required) — env-var name. Must match^[A-Z_][A-Z0-9_]*$(UPPER_SNAKE_CASE). Duplicates rejected.label(optional, defaults toname) — human-readable label rendered as the form label.description(optional, default"") — one-sentence help text rendered under the label.type(optional, default"text") — one of"text","password","number". Controls the HTML input type AND whether the current value is masked on the settings GET endpoint (password values are never returned, only written).required(optional, defaultfalse) — checked against the merged.envafter submit, so edit-mode can omit unchanged fields and still pass.default(optional, defaultnull) — applied on first install if the user didn't submit the field. String-coerced if non-string.
Merge semantics on edit: the installed app's existing .env is the base, submitted settings overlay it. Omit a field and its current value is preserved; submit "" and it's cleared (which triggers required rejection).
Placeholder rejection still applies: if a final .env value matches furtka.installer.PLACEHOLDER_SECRETS (currently {"changeme"}), install fails with InstallError — so apps shipping an .env.example with changeme stay safe even if the user skips the form.
Lifecycle
Discovery: boot-scan
furtka-reconcile.service (oneshot, after docker.service) runs furtka reconcile at every boot. The reconciler walks /var/lib/furtka/apps/*, validates each manifest, ensures every declared volume exists, then docker compose up -d per app. Filesystem is the only source of truth — no separate index, no DB.
A failed reconcile of one app does not abort the others. The CLI exits non-zero if any app errored, so systemd marks the unit red, but the healthy apps still come up.
Install
furtka app install <path>— install from a local folder.furtka app install <name>— falls back to/opt/furtka/apps/<name>/(apps bundled with the ISO).- Web UI: click Install on a card under "Available to install".
The installer copies files into /var/lib/furtka/apps/<name>/, preserves any existing user .env, bootstraps .env from .env.example on first install, and chmod 0600 on .env.
Placeholder secrets are refused. If .env ends up containing values listed in furtka.installer.PLACEHOLDER_SECRETS (currently {"changeme"}), install raises InstallError and the reconciler is not run. Files are left in place so the user can vim the .env and re-run install.
Remove
furtka app remove <name>—docker compose down, then delete the app folder.- Web UI: Remove button.
Volumes are NEVER deleted. Reinstall recovers the data. Manual docker volume rm furtka_<app>_<vol> if you really want to wipe.
Backend
furtka/api.py runs as furtka-api.service — Python stdlib http.server (no Flask), bound to 127.0.0.1:7000. Caddy reverse-proxies /api/* and /apps* from :80.
Endpoints:
GET /and/apps— self-contained HTML UI.GET /api/apps— installed apps as JSON (each includeshas_settingsso the UI can show the "Einstellungen" button only when relevant).GET /api/bundled— apps available in/opt/furtka/apps/that aren't installed.GET /api/apps/<name>/settings— returns the manifest's settings alongside current.envvalues. Works for both installed and bundled apps. Password values are returned as empty strings.POST /api/apps/<name>/settings{"settings": {...}}— merges values into the installed app's.envand reconciles. Only for already-installed apps.POST /api/apps/install{"name": "...", "settings": {...}}— install/reinstall.settingsis optional; if present the.envis written from it before the placeholder check.POST /api/apps/remove{"name": "..."}— remove (folder, not volume).
The UI has no authentication. It shouts the warning at the top. Authentik integration is the proper fix later.
Out of scope for v1
These are deliberate omissions, not forgotten work. Adding any of them is a discussed design change.
- SQL database — filesystem is authoritative, full stop.
- Volume sharing between apps (would be the first DB use case).
- Auth on the web UI.
- TLS on
.local(separate problem — see commit history around mDNS for the reasoning). - Catalog repo —
install <name>only resolves bundled apps, no network catalog. - Auto-updates of installed apps.
- Free-form
.enveditor — the settings form only exposes fields declared in the manifest. Non-manifest keys in.envare preserved on edit but not editable through the UI.
Code map
| File | Purpose |
|---|---|
furtka/manifest.py |
JSON schema validation, Manifest dataclass, namespacing helper |
furtka/scanner.py |
Walks /var/lib/furtka/apps/, returns ScanResults (broken manifests = error, not exception) |
furtka/reconciler.py |
Drives the per-app loop; isolates errors so one broken app doesn't block others |
furtka/installer.py |
Copy-from-source, .env bootstrap + 0600, placeholder rejection |
furtka/dockerops.py |
docker volume + docker compose subprocess wrappers |
furtka/api.py |
HTTP server + HTML UI |
furtka/cli.py |
furtka app list/install/remove, furtka reconcile, furtka serve |
apps/fileshare/ |
First consumer — SMB share via dperson/samba |
iso/build.sh |
Tarballs furtka/ + apps/ into the live ISO at build time |
webinstaller/app.py |
_resource_manager_commands() + new systemd units (reconcile + api) for archinstall custom_commands |