chore: bootstrap furtka-apps catalog repo

Initial layout: apps/fileshare/ (seeded from daniel/furtka apps/), CI
(JSON + manifest validator + shellcheck), release pipeline (tag-driven,
mirrors core repo), vendored manifest schema for offline validation.

The core repo (daniel/furtka) at 26.6-alpha keeps apps/fileshare as a
seed so offline first-boot still has an installable app; this catalog
becomes authoritative once a box has synced at least once.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-04-20 14:14:50 +02:00
commit 692a637a50
15 changed files with 758 additions and 0 deletions

34
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,34 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
validate:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Validate JSON files
run: |
set -e
for f in $(find . -name '*.json' -not -path './node_modules/*'); do
echo "Validating $f"
python3 -m json.tool "$f" > /dev/null
done
- name: Validate apps/ (manifest schema + compose shape)
run: python3 scripts/validate-catalog.py
shellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install shellcheck
run: |
sudo apt-get update
sudo apt-get install -y --no-install-recommends shellcheck
- name: Run shellcheck
run: shellcheck scripts/*.sh

View file

@ -0,0 +1,28 @@
name: Release
# Tag-triggered: when `git push origin <version>` lands, this builds the
# catalog tarball and publishes it + the sha256 + release.json to the
# Forgejo releases page for that tag. Running Furtka boxes pull from here
# on their daily furtka-catalog-sync.timer.
#
# Version tags only (CalVer like 26.0, 26.1-alpha, 27.0-beta). Random tags
# are ignored by the [0-9]* prefix.
on:
push:
tags: ['[0-9]*']
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # changelog section extraction needs history
- name: Build catalog tarball
run: ./scripts/build-catalog-tarball.sh "${GITHUB_REF_NAME}"
- name: Publish to Forgejo releases
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_RELEASE_TOKEN }}
run: ./scripts/publish-catalog-release.sh "${GITHUB_REF_NAME}"

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
dist/
__pycache__/
*.pyc
.DS_Store

24
CHANGELOG.md Normal file
View file

@ -0,0 +1,24 @@
# Changelog
All notable changes to the Furtka apps catalog will be documented here.
Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
Versioning: CalVer (`YY.N`) — same scheme as the core Furtka repo.
## [Unreleased]
## [26.6-alpha] - 2026-04-20
### Added
- Initial catalog release. Carries one app: **fileshare** (v0.1.1, SMB
share over `dperson/samba`). Copied from the `daniel/furtka` seed so
boxes on 26.6 see the same fileshare bits whether they pull the
catalog or fall back to the bundled seed.
- Release pipeline + CI parity with the core repo:
`scripts/build-catalog-tarball.sh`, `scripts/publish-catalog-release.sh`,
`scripts/validate-catalog.py` (CI guardrail — loads every manifest via
the vendored `furtka.manifest.load_manifest` + cross-checks compose
volume references).
[Unreleased]: https://forgejo.sourcegate.online/daniel/furtka-apps/compare/26.6-alpha...HEAD
[26.6-alpha]: https://forgejo.sourcegate.online/daniel/furtka-apps/releases/tag/26.6-alpha

70
README.md Normal file
View file

@ -0,0 +1,70 @@
# furtka-apps
Apps catalog for [Furtka](https://furtka.org). Each release here ships a
tarball that running Furtka boxes pull via the daily catalog-sync timer
(see `furtka.catalog` in the core repo). Apps update on their own cadence,
independent of Furtka's core OS release schedule.
## Repo layout
```
apps/ # one folder per app
<name>/
manifest.json # JSON schema — see ./apps/README.md
docker-compose.yaml
.env.example # if the app has user-facing settings
icon.svg
scripts/
build-catalog-tarball.sh # bundle apps/ + VERSION into furtka-apps-<ver>.tar.gz
publish-catalog-release.sh # upload tarball + sha256 + release.json to Forgejo
validate-catalog.py # CI guardrail — manifest + compose syntax
vendor/
furtka_manifest.py # copy of daniel/furtka furtka/manifest.py
.forgejo/workflows/
ci.yml # JSON + manifest + shellcheck on every push/PR
release.yml # tag push → build tarball → publish to Forgejo releases
```
## Adding an app
See [apps/README.md](./apps/README.md) for the manifest schema, volume
namespacing rules, `.env.example` guardrails, and the SVG sanitiser
limits the Furtka UI enforces on `icon.svg`.
Shape of a new app PR:
1. Add `apps/<name>/` with all four files.
2. `python3 scripts/validate-catalog.py` locally — must go green.
3. Open a PR. CI runs the same validator + shellcheck.
4. Merged to `main` — next catalog release will include it.
## Cutting a release
Follows the same CalVer + tag-driven pattern as daniel/furtka:
```bash
# Bump CHANGELOG.md [Unreleased] → [<version>] section, commit:
git commit -m "chore: release <version>"
git tag -a <version> -m "Release <version>"
git push origin main
git push origin <version>
```
Tag push fires `.forgejo/workflows/release.yml`. Needs
`FORGEJO_RELEASE_TOKEN` secret (PAT with `write:repository` on this repo).
## Trust model
Releases are unsigned (SHA256 sidecar only). Boxes verify the sidecar
against the downloaded tarball before extracting anything. Same posture
as daniel/furtka core releases — PGP is a deferred cross-repo migration.
## Relationship to daniel/furtka
- The core repo still ships `apps/fileshare/` as a seed inside the
Furtka release tarball. That's the offline/first-boot fallback.
- When a box has synced the catalog at least once, the resolver
(`furtka.sources.resolve_app_name`) prefers the catalog copy.
- Already-installed apps don't auto-reinstall on catalog updates — user
hits "Reinstall" in `/apps` when they want the new version. Settings
are merge-preserved.

113
apps/README.md Normal file
View file

@ -0,0 +1,113 @@
# 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`.
- `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`.
## `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.
## `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/`.

View file

@ -0,0 +1,2 @@
SMB_USER=furtka
SMB_PASSWORD=changeme

View file

@ -0,0 +1,39 @@
# Furtka fileshare — SMB share via dperson/samba.
#
# The volume `furtka_fileshare_files` is created by the Furtka reconciler
# from the manifest's "volumes" list before this compose file is brought up;
# it's referenced as `external: true` here so docker compose doesn't try
# to manage its lifecycle.
#
# TODO(image-pin): `:latest` is shaky for production — pin to a digest
# (`dperson/samba@sha256:...`) or a stable tag once we've verified one
# against the upstream registry. For the MVP run we accept the drift
# risk to keep the install reproducible against whatever the upstream
# image happens to be on test day; revisit before any non-developer
# touches this.
services:
smbd:
image: dperson/samba:latest
restart: unless-stopped
network_mode: host
# The upstream image's HEALTHCHECK times out under normal operation on
# our setup (2026-04-15 VM test — all 6 probes failed while the share
# was reachable from clients). Disable to avoid a permanently-"unhealthy"
# container that scares users reading `docker ps`.
healthcheck:
disable: true
environment:
- USERID=1000
- GROUPID=1000
- TZ=Europe/Berlin
command: >
-u "${SMB_USER};${SMB_PASSWORD}"
-s "files;/mount;yes;no;no;${SMB_USER}"
-p
volumes:
- furtka_fileshare_files:/mount
volumes:
furtka_fileshare_files:
external: true

9
apps/fileshare/icon.svg Normal file
View file

@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" aria-hidden="true">
<path fill="currentColor" opacity="0.28" d="M6 18 Q6 14 10 14 H22 L28 20 H54 Q58 20 58 24 V46 Q58 50 54 50 H10 Q6 50 6 46 Z"/>
<path fill="currentColor" d="M6 28 Q6 24 10 24 H54 Q58 24 58 28 V46 Q58 50 54 50 H10 Q6 50 6 46 Z"/>
<g fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M42 12 A10 10 0 0 1 58 22"/>
<path d="M46 14 A6 6 0 0 1 56 22" opacity="0.75"/>
</g>
<circle cx="51" cy="19" r="1.8" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 561 B

View file

@ -0,0 +1,27 @@
{
"name": "fileshare",
"display_name": "Network Files",
"version": "0.1.1",
"description": "SMB share for Mac, Windows, Linux and Android devices on the LAN.",
"description_long": "Alle Geräte im WLAN sehen einen gemeinsamen Ordner. Funktioniert mit Windows, Mac, Linux und Android. Verbinden zu smb://furtka.local — Anmeldung mit dem hier gesetzten Benutzernamen und Passwort.",
"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 — auch dir nicht.",
"type": "password",
"required": true
}
]
}

View file

@ -0,0 +1,47 @@
#!/usr/bin/env bash
# Build a Furtka apps-catalog release tarball + sha256 sidecar + release.json.
#
# Usage: ./scripts/build-catalog-tarball.sh <version>
#
# Produces (in ./dist/):
# furtka-apps-<version>.tar.gz contents extract to /var/lib/furtka/catalog/
# furtka-apps-<version>.tar.gz.sha256 single-line sha256 (<hash> <name>)
# release.json {"version","sha256","size","created_at"}
#
# Tarball shape: VERSION at root + apps/<name>/ trees underneath. The on-box
# `furtka catalog sync` extracts into a staging dir, validates every
# manifest, then atomically renames into /var/lib/furtka/catalog/.
set -euo pipefail
VERSION="${1:?usage: $0 <version>}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIST_DIR="$REPO_ROOT/dist"
STAGE="$(mktemp -d)"
trap 'rm -rf "$STAGE"' EXIT
cp -a "$REPO_ROOT/apps" "$STAGE/"
find "$STAGE" -type d -name __pycache__ -exec rm -rf {} +
echo "$VERSION" > "$STAGE/VERSION"
mkdir -p "$DIST_DIR"
TARBALL="$DIST_DIR/furtka-apps-$VERSION.tar.gz"
tar -czf "$TARBALL" -C "$STAGE" .
SHA=$(sha256sum "$TARBALL" | awk '{print $1}')
SIZE=$(stat -c%s "$TARBALL")
printf '%s %s\n' "$SHA" "$(basename "$TARBALL")" > "$TARBALL.sha256"
cat > "$DIST_DIR/release.json" <<EOF
{
"version": "$VERSION",
"sha256": "$SHA",
"size": $SIZE,
"created_at": "$(date -Iseconds)"
}
EOF
echo "Built $TARBALL"
echo " sha256: $SHA"
echo " size: $SIZE bytes"

View file

@ -0,0 +1,96 @@
#!/usr/bin/env bash
# Publish a Furtka apps-catalog release to the Forgejo releases page.
#
# Usage: ./scripts/publish-catalog-release.sh <version>
#
# Preconditions:
# - $FORGEJO_TOKEN set (PAT with write:repository on daniel/furtka-apps)
# - dist/furtka-apps-<version>.tar.gz + .sha256 + release.json already built
#
# Behaviour: mirrors daniel/furtka's scripts/publish-release.sh — create a
# release via the Forgejo API, then upload all three assets sequentially.
# Python for JSON assembly so we don't drag jq onto the runner.
set -euo pipefail
VERSION="${1:?usage: $0 <version>}"
: "${FORGEJO_TOKEN:?FORGEJO_TOKEN must be set}"
HOST="${FORGEJO_HOST:-forgejo.sourcegate.online}"
REPO="${FORGEJO_REPO:-daniel/furtka-apps}"
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DIST_DIR="$REPO_ROOT/dist"
TARBALL="$DIST_DIR/furtka-apps-$VERSION.tar.gz"
SHA_FILE="$TARBALL.sha256"
RELEASE_JSON="$DIST_DIR/release.json"
for f in "$TARBALL" "$SHA_FILE" "$RELEASE_JSON"; do
[ -f "$f" ] || { echo "missing: $f"; exit 1; }
done
# Extract the changelog section for this version from CHANGELOG.md — matches
# `## [<version>]` up to (but not including) the next `## [` line.
BODY="$(awk -v v="$VERSION" '
BEGIN { inside=0 }
/^## \[/ {
if (inside) exit
if (index($0, "[" v "]") > 0) { inside=1; next }
}
inside { print }
' "$REPO_ROOT/CHANGELOG.md")"
if [ -z "$BODY" ]; then
BODY="Catalog release $VERSION"
fi
PRERELEASE=false
if [[ "$VERSION" == *-alpha* || "$VERSION" == *-beta* || "$VERSION" == *-rc* ]]; then
PRERELEASE=true
fi
api() {
curl --silent --show-error --fail-with-body \
--header "Authorization: token $FORGEJO_TOKEN" \
"$@"
}
base="https://$HOST/api/v1/repos/$REPO"
release_body_json="$(
VERSION="$VERSION" BODY="$BODY" PRE="$PRERELEASE" python3 -c '
import json, os
print(json.dumps({
"tag_name": os.environ["VERSION"],
"name": os.environ["VERSION"],
"body": os.environ["BODY"],
"prerelease": os.environ["PRE"] == "true",
}))
'
)"
echo "==> Creating catalog release $VERSION"
release_response="$(api --request POST "$base/releases" \
--header "Content-Type: application/json" \
--data "$release_body_json")"
release_id="$(echo "$release_response" | python3 -c 'import json, sys; print(json.load(sys.stdin).get("id", ""))')"
if [ -z "$release_id" ] || [ "$release_id" = "null" ]; then
echo "error: couldn't parse release id from response:"
echo "$release_response"
exit 1
fi
echo " release id: $release_id"
upload_asset() {
local path="$1"
local name
name="$(basename "$path")"
echo "==> Uploading $name"
api --request POST "$base/releases/$release_id/assets?name=$name" \
--form "attachment=@$path" > /dev/null
}
upload_asset "$TARBALL"
upload_asset "$SHA_FILE"
upload_asset "$RELEASE_JSON"
echo "Catalog release $VERSION published: https://$HOST/$REPO/releases/tag/$VERSION"

124
scripts/validate-catalog.py Executable file
View file

@ -0,0 +1,124 @@
#!/usr/bin/env python3
"""CI guardrail: validate every app in ./apps/ before cutting a release.
For each `apps/<name>/`:
- manifest.json must parse via furtka.manifest.load_manifest (same schema
the on-box reconciler expects fields, settings rules, icon name).
- docker-compose.yaml must exist and reference every manifest-declared
volume as `external: true` (the reconciler creates the volumes; compose
must not try to manage their lifecycle).
- `docker compose config` must parse the compose file (catches YAML typos
+ missing env refs).
The `furtka` package is vendored in `scripts/vendor/` lifted verbatim from
daniel/furtka's `furtka/manifest.py`. Keeping the copy local avoids dragging
the core repo's whole pyproject into this repo's CI. If the core manifest
schema evolves we bump the vendored copy with a conventional commit.
"""
from __future__ import annotations
import subprocess
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE / "vendor"))
from furtka_manifest import ManifestError, load_manifest # noqa: E402
APPS_ROOT = HERE.parent / "apps"
def check_app(app_dir: Path) -> list[str]:
errors: list[str] = []
manifest_path = app_dir / "manifest.json"
if not manifest_path.is_file():
return [f"{app_dir.name}: manifest.json missing"]
try:
m = load_manifest(manifest_path, expected_name=app_dir.name)
except ManifestError as e:
return [f"{app_dir.name}: invalid manifest: {e}"]
compose_path = app_dir / "docker-compose.yaml"
if not compose_path.is_file():
errors.append(f"{app_dir.name}: docker-compose.yaml missing")
return errors
compose_text = compose_path.read_text()
# Cheap text search — pyyaml would be cleaner but we stay stdlib-only.
# The reconciler also works on a text basis: volume must be referenced
# at all, and must carry `external: true` in its definition block.
for volume in m.volumes:
namespaced = f"furtka_{m.name}_{volume}"
if namespaced not in compose_text:
errors.append(
f"{app_dir.name}: docker-compose.yaml doesn't reference namespaced "
f"volume {namespaced!r}"
)
# very forgiving pattern — accept `external: true` anywhere after the
# namespaced name appears. A more rigorous YAML walk can come later.
tail = compose_text.split(namespaced, 1)[-1]
if "external: true" not in tail and "external:\n true" not in tail:
errors.append(
f"{app_dir.name}: volume {namespaced!r} must be declared with "
f"external: true (reconciler creates the volume before compose up)"
)
# `docker compose config` parses the file and resolves env substitutions.
# Skip the check if docker isn't installed in the environment — locally
# that's fine, and CI images should have it.
docker = subprocess.run(
["which", "docker"], capture_output=True, text=True, check=False
)
if docker.returncode == 0:
result = subprocess.run(
["docker", "compose", "-f", str(compose_path), "config"],
capture_output=True,
text=True,
check=False,
cwd=app_dir,
# Mask any env-var substitution errors — we don't have the real
# .env on a catalog validation pass; `docker compose config`
# returns the stderr line "WARN[0000] The X variable is not set"
# which is fine for syntax check.
)
if result.returncode != 0:
errors.append(
f"{app_dir.name}: `docker compose config` failed:\n"
+ (result.stderr or result.stdout).strip()
)
return errors
def main() -> int:
if not APPS_ROOT.is_dir():
print(f"no apps/ directory at {APPS_ROOT}", file=sys.stderr)
return 2
all_errors: list[str] = []
apps = sorted(p for p in APPS_ROOT.iterdir() if p.is_dir())
if not apps:
print("no apps to validate", file=sys.stderr)
return 2
for app_dir in apps:
errors = check_app(app_dir)
if errors:
all_errors.extend(errors)
else:
print(f"OK {app_dir.name}")
if all_errors:
print("\nFAIL:")
for e in all_errors:
print(f" - {e}")
return 1
print(f"\nValidated {len(apps)} app(s).")
return 0
if __name__ == "__main__":
sys.exit(main())

1
scripts/vendor/README.md vendored Normal file
View file

@ -0,0 +1 @@
# vendored from daniel/furtka furtka/manifest.py — bump on schema changes

140
scripts/vendor/furtka_manifest.py vendored Normal file
View file

@ -0,0 +1,140 @@
import json
import re
from dataclasses import dataclass, field
from pathlib import Path
REQUIRED_FIELDS = (
"name",
"display_name",
"version",
"description",
"volumes",
"ports",
"icon",
)
VALID_SETTING_TYPES = frozenset({"text", "password", "number"})
SETTING_NAME_RE = re.compile(r"^[A-Z_][A-Z0-9_]*$")
class ManifestError(Exception):
pass
@dataclass(frozen=True)
class Setting:
name: str # env-var name, e.g. SMB_PASSWORD
label: str # human label shown in the UI
description: str # one-sentence help text under the input
type: str # "text" | "password" | "number"
required: bool
default: str | None
@dataclass(frozen=True)
class Manifest:
name: str
display_name: str
version: str
description: str
volumes: tuple[str, ...]
ports: tuple[int, ...]
icon: str
description_long: str = ""
settings: tuple[Setting, ...] = field(default_factory=tuple)
def volume_name(self, short: str) -> str:
# Namespace volume names so two apps can each declare e.g. "data"
# without colliding in `docker volume ls`.
if short not in self.volumes:
raise ManifestError(f"{self.name}: volume {short!r} not declared in manifest")
return f"furtka_{self.name}_{short}"
def _parse_settings(raw: object, manifest_path: Path) -> tuple[Setting, ...]:
if raw is None:
return ()
if not isinstance(raw, list):
raise ManifestError(f"{manifest_path}: settings must be a list")
out: list[Setting] = []
seen: set[str] = set()
for i, item in enumerate(raw):
if not isinstance(item, dict):
raise ManifestError(f"{manifest_path}: settings[{i}] must be an object")
name = item.get("name")
if not isinstance(name, str) or not SETTING_NAME_RE.match(name):
raise ManifestError(
f"{manifest_path}: settings[{i}].name must be an UPPER_SNAKE_CASE env-var name"
)
if name in seen:
raise ManifestError(f"{manifest_path}: settings has duplicate name {name!r}")
seen.add(name)
label = item.get("label", name)
description = item.get("description", "")
type_ = item.get("type", "text")
if type_ not in VALID_SETTING_TYPES:
valid = sorted(VALID_SETTING_TYPES)
raise ManifestError(f"{manifest_path}: settings[{name}].type must be one of {valid}")
required = bool(item.get("required", False))
default = item.get("default")
if default is not None and not isinstance(default, str):
default = str(default)
out.append(
Setting(
name=name,
label=str(label),
description=str(description),
type=type_,
required=required,
default=default,
)
)
return tuple(out)
def load_manifest(path: Path, expected_name: str | None = None) -> Manifest:
"""Parse and validate a manifest.json.
`expected_name` is used by the scanner (where the install location's folder
name IS the source of truth and must match the manifest). For loading from
arbitrary source folders during install, leave it None the manifest's own
`name` field decides the install target.
"""
try:
raw = json.loads(path.read_text())
except json.JSONDecodeError as e:
raise ManifestError(f"{path}: invalid JSON: {e}") from e
if not isinstance(raw, dict):
raise ManifestError(f"{path}: top-level must be an object")
missing = [f for f in REQUIRED_FIELDS if f not in raw]
if missing:
raise ManifestError(f"{path}: missing required fields: {', '.join(missing)}")
name = raw["name"]
if not isinstance(name, str) or not name:
raise ManifestError(f"{path}: name must be a non-empty string")
if expected_name is not None and name != expected_name:
raise ManifestError(f"{path}: name {name!r} must equal {expected_name!r}")
volumes = raw["volumes"]
if not isinstance(volumes, list) or not all(isinstance(v, str) and v for v in volumes):
raise ManifestError(f"{path}: volumes must be a list of non-empty strings")
ports = raw["ports"]
if not isinstance(ports, list) or not all(isinstance(p, int) for p in ports):
raise ManifestError(f"{path}: ports must be a list of integers")
settings = _parse_settings(raw.get("settings"), path)
return Manifest(
name=name,
display_name=str(raw["display_name"]),
version=str(raw["version"]),
description=str(raw["description"]),
volumes=tuple(volumes),
ports=tuple(ports),
icon=str(raw["icon"]),
description_long=str(raw.get("description_long", "")),
settings=settings,
)