furtka-apps/apps/README.md
Daniel Maksymilian Syrnicki 3408e1aad8
All checks were successful
CI / validate (pull_request) Successful in 6s
CI / shellcheck (pull_request) Successful in 13s
feat: add mosquitto + zigbee2mqtt dependency pair
First catalog apps to exercise core 26.17's app-to-app dependency
feature — until now every app was standalone.

- mosquitto: MQTT broker, first dependency *provider*. Mandatory auth;
  per-consumer accounts created by on_install/on_start hooks
  (scripts/provision-client.sh, scripts/ensure-client.sh) that run inside
  the broker container via `docker compose exec`. Provider-side password
  stash so on_start can restore an account after a volume wipe.
- zigbee2mqtt: first dependency *consumer*. `requires` mosquitto; MQTT
  creds wired in from the provisioning hook via ZIGBEE2MQTT_CONFIG_* env.
  Serial coordinator path as a text setting + devices mapping.

Supporting changes:
- Bump vendored furtka_manifest.py (26.10-era -> 26.17) so the validator
  actually validates the `requires` schema instead of ignoring it.
- Document `requires`/hooks in apps/README.md (was undocumented), including
  the three framework gaps building this pair surfaced.
- CI now shellchecks app hook scripts (apps/*/scripts/*.sh), not just
  repo-root scripts/.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-28 23:45:27 +02:00

9.1 KiB
Raw Blame History

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/<name>/
  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.

{
  "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_<app>_<short> 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.

{
  "name": "MEDIA_PATH",
  "label": "Medienordner",
  "description": "Absoluter Pfad zu deinem Medien-Ordner, z.B. /mnt/media.",
  "type": "path",
  "required": true
}
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_<app>_<short> 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:

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.

{
  "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 <provider> 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_<KEY>, 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:<port> (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 <script>, on* attributes, and javascript: refs and enforces a 16 KB cap. Anything fancier than static paths and shapes will be rejected.

Install and test

From the repo root on a dev box with Furtka installed:

sudo furtka app install ./apps/myapp

furtka app install runs a reconcile as its last step, so the container is up once the command returns. Open the Web UI (http://furtka.local/), fill in the settings form, and confirm the app starts. docker ps should show one container per compose service; docker volume ls should show furtka_myapp_*.

To bundle the app into the ISO, drop the folder into apps/ before iso/build.sh runs — the build tarballs the whole apps/ tree into the image.

Out of scope (for now)

  • Sharing volumes between apps. v1 keeps them isolated.
  • Auth on the Web UI. The UI itself has a banner about this.
  • Automatic updates. User-triggered per-app update is POST /api/apps/<name>/update.
  • A network catalog. furtka app install <name> only resolves bundled apps in /opt/furtka/apps/.