# Building a Furtka app from a Docker image A Furtka app is a folder with four files. The reconciler walks `/var/lib/furtka/apps/*` at boot, validates each manifest, ensures the declared volumes exist, and runs `docker compose up -d` per app. Filesystem is the only source of truth — no database. Use `apps/fileshare/` as the reference implementation. ## Folder layout ``` apps// manifest.json # required — app metadata and user-facing settings docker-compose.yaml # required — filename is .yaml, not .yml .env.example # required — keys consumed by docker-compose, with safe defaults icon.svg # required — referenced by manifest.icon ``` The folder name must equal `manifest.name`. The scanner rejects mismatches. ## `manifest.json` All top-level fields except `description_long` and `settings` are required. ```json { "name": "myapp", "display_name": "My App", "version": "0.1.0", "description": "One-line summary shown in the app list.", "description_long": "Longer German prose shown on the app page. Optional.", "volumes": ["data"], "ports": [8080], "icon": "icon.svg", "settings": [ { "name": "ADMIN_PASSWORD", "label": "Passwort", "description": "Wird beim ersten Start gesetzt.", "type": "password", "required": true } ] } ``` Rules enforced by `furtka/manifest.py`: - `volumes` — short names, strings. Namespaced to `furtka__` at runtime. - `ports` — integers. Informational only; compose owns the actual port binding. - `settings[].name` — must match `^[A-Z_][A-Z0-9_]*$`. This name becomes both the env-var key and the form-field ID. - `settings[].type` — one of `text`, `password`, `number`, `path`. - `settings[].required` — if true, the install refuses when the value is empty. - `settings[].default` — optional string. Used to pre-fill the form and the bootstrapped `.env`. ### Path-type settings (host bind mounts) Use `"type": "path"` when the app should point at an existing folder on the host — media libraries, document archives, photo backups. The value is written to `.env` like any other setting, and compose consumes it via `${VAR}` substitution as a bind mount. ```json { "name": "MEDIA_PATH", "label": "Medienordner", "description": "Absoluter Pfad zu deinem Medien-Ordner, z.B. /mnt/media.", "type": "path", "required": true } ``` ```yaml services: app: volumes: - ${MEDIA_PATH}:/media:ro ``` The installer (`install_from` and `update_env`) refuses values that: - aren't absolute (must start with `/`), - don't exist on the host, - aren't directories, - resolve (after `Path.resolve()`) into a system-path deny-list: `/`, `/etc`, `/root`, `/boot`, `/proc`, `/sys`, `/dev`, `/bin`, `/sbin`, `/usr/bin`, `/usr/sbin`, `/var/lib/furtka`. Traversal like `/mnt/../etc` is caught too — the deny-list check runs on the resolved path. Path settings sit alongside manifest-declared volumes. Use `manifest.volumes` for internal state the app owns (databases, caches, config), and path settings for user data the container should mount and — usually — read without owning. Mounting read-only (`:ro`) is a good default for data the app only consumes. ## `docker-compose.yaml` - File extension is `.yaml`. The compose runner hardcodes this — `.yml` will not be found. - Reference manifest volumes as `furtka__` with `external: true`. The reconciler creates the volume *before* `compose up`, so compose must not try to manage its lifecycle. - Values from `.env` are substituted by compose in the usual `${VAR}` form. - If the upstream image ships a HEALTHCHECK that misbehaves on Furtka's setup, disable it — a permanently-unhealthy container scares users reading `docker ps`. - Pin images to a digest or stable tag when you can. `:latest` is acceptable for an MVP but noisy. Minimal example: ```yaml services: app: image: ghcr.io/example/myapp:1.2.3 restart: unless-stopped environment: - ADMIN_PASSWORD=${ADMIN_PASSWORD} ports: - "8080:8080" volumes: - furtka_myapp_data:/var/lib/myapp volumes: furtka_myapp_data: external: true ``` ## `.env.example` One `KEY=VALUE` per line. Every key declared in `manifest.settings` should have a line here so the compose file resolves cleanly on first install even before the user opens the form. Do not use `changeme` (or any value listed in `furtka.installer.PLACEHOLDER_SECRETS`) as the default for a required secret. The install step scans the final `.env` and refuses to finish if a placeholder survives — this is the guardrail that stops us shipping an app with a known password. For non-secret values (usernames, paths), sensible defaults are fine and go straight into `.env` on first install. ## App-to-app dependencies (`requires`) An app that needs another app at runtime (e.g. `zigbee2mqtt` needs an MQTT broker) declares it in `manifest.json`. Installing the consumer pulls in the provider automatically; the UI shows a confirm dialog first. ```json { "name": "zigbee2mqtt", "requires": [ { "app": "mosquitto", "on_install": "scripts/provision-client.sh", "on_start": "scripts/ensure-client.sh" } ] } ``` - `app` — the provider's name. Must resolve in installed/catalog/bundled apps. - `on_install` / `on_start` — optional hook scripts, **paths relative to the _provider's_ folder** (not the consumer's). See `apps/mosquitto/scripts/` for the reference pair. ### How hooks run Both hooks execute **inside the provider's container** via `docker compose exec -T sh -s` — so they use whatever tools the *provider* image ships (mosquitto's hooks call `mosquitto_passwd`). The reconciler exports `FURTKA_CONSUMER_APP` and `FURTKA_CONSUMER_VERSION` into the hook's environment so it knows who it's provisioning for. - **`on_install`** runs once, while the consumer is being installed, after the provider is up. Its **stdout is merged into the consumer's `.env`**: print `KEY=VALUE` lines (keys must match `^[A-Z_][A-Z0-9_]*$`) and they become env vars the consumer's compose file can `${SUBSTITUTE}`. The placeholder-secret check re-runs over the merged `.env`, so a hook returning `MQTT_PASS=changeme` is refused just like an unedited `.env.example`. - **`on_start`** runs on every boot, before the consumer starts. Must be **idempotent**. ### Gotchas (learned building mosquitto + zigbee2mqtt) - **`on_start` is read-only toward the consumer.** Its stdout is *discarded* (unlike `on_install`, which merges its stdout into the consumer's `.env`). It reads consumer state to reconcile provider state; it doesn't mutate the consumer. Core that post-dates 26.17 injects the consumer's stored `.env` into the hook as `FURTKA_CONSUMER_ENV_`, so `on_start` can re-establish provider-side state (e.g. re-create an account after a volume wipe) with the **same** credentials the consumer already holds. On 26.17 itself that injection isn't there, so a provider that must survive that gap stashes what it needs at `on_install` time on its own volume. mosquitto's `ensure-client.sh` uses `FURTKA_CONSUMER_ENV_MQTT_PASS` when present and falls back to a stash (`/mosquitto/data/furtka-clients/`) otherwise. - **No shared app network (yet).** Each app is its own compose project on its own network, so a consumer can't reach a provider by service name. Publish the provider's port on the host and hand the consumer `host.docker.internal:` (the consumer adds `extra_hosts: ["host.docker.internal:host-gateway"]`). - **No device/serial setting type.** A `path`-type setting can't point at a `/dev` node (the validator rejects non-directories and `/dev` is on the deny-list). Use a plain `text` setting for the device path plus a `devices:` mapping in compose, and document the hardware requirement. ## `icon.svg` - 64×64 viewBox, no width/height attributes so the UI can scale it. - Use `fill="currentColor"` (and `stroke="currentColor"`) so the icon picks up the current theme instead of baking in a color. - Keep it single-path-ish. These render small in the app grid. - The icon is inlined into the `/apps` page by the defensive SVG sanitiser, which strips `