furtka-apps/scripts/vendor/furtka_manifest.py
Daniel Maksymilian Syrnicki fcf2f22a47
Some checks are pending
CI / validate (push) Waiting to run
CI / shellcheck (push) Waiting to run
feat(jellyfin): add media-server app using new path-type setting
First app to use the core-26.10-alpha `path` setting type. User picks
their existing media folder (MEDIA_PATH=/mnt/media) in the install
form; core installer validates the path server-side; compose mounts
it read-only at /media. Docker-managed volumes hold /config and
/cache; admin account is created from the first browser visit to
:8096. No HW transcoding yet — that's a later schema extension.

Also bumps the vendored manifest schema in scripts/vendor/ to match
core 26.10-alpha — catches up both the missing `open_url` field (gap
since 26.6-alpha) and the new `path` setting type. No changes needed
to validate-catalog.py itself.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-21 11:40:29 +02:00

151 lines
5.3 KiB
Python

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", "path"})
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)
# Optional "Open" link for the landing page + installed-app row.
# `{host}` is substituted with the current browser hostname at render
# time so the URL follows whatever the user typed to reach Furtka —
# furtka.local, a raw IP, a future reverse-proxy hostname. Apps with
# no frontend (CLI-only, background workers) leave this empty.
open_url: str = ""
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)
open_url_raw = raw.get("open_url", "")
if not isinstance(open_url_raw, str):
raise ManifestError(f"{path}: open_url must be a string if set")
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,
open_url=open_url_raw,
)