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>
209 lines
9.1 KiB
Markdown
209 lines
9.1 KiB
Markdown
# 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/`.
|