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

209 lines
9.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.
```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_<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.
```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_<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:
```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 <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/`.