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_]*$") APP_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 Requirement: app: str # name of the required app — must resolve in installed/catalog/bundled # Hook paths are relative to the PROVIDER's app folder (not the consumer's). # Resolved at hook-fire time, not manifest-load time — the provider may not # be installed yet when this manifest is parsed. # on_install: script run via `docker compose exec` on the provider during install. on_install: str | None # on_start: script run on every boot before the consumer starts (must be idempotent). on_start: 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 = "" requires: tuple[Requirement, ...] = 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 _validate_hook_path(value: object, manifest_path: Path, where: str) -> str | None: if value is None: return None if not isinstance(value, str) or not value: raise ManifestError(f"{manifest_path}: {where} must be a non-empty string if set") if value.startswith("/"): raise ManifestError(f"{manifest_path}: {where} must be relative (no leading /)") parts = value.replace("\\", "/").split("/") if any(p == ".." for p in parts): raise ManifestError(f"{manifest_path}: {where} must not contain '..'") return value def _parse_requires(raw: object, manifest_path: Path, self_name: str) -> tuple[Requirement, ...]: if raw is None: return () if not isinstance(raw, list): raise ManifestError(f"{manifest_path}: requires must be a list") out: list[Requirement] = [] seen: set[str] = set() for i, item in enumerate(raw): if not isinstance(item, dict): raise ManifestError(f"{manifest_path}: requires[{i}] must be an object") app = item.get("app") if not isinstance(app, str) or not app or not APP_NAME_RE.match(app): raise ManifestError( f"{manifest_path}: requires[{i}].app must be a non-empty lowercase app name" ) if app == self_name: raise ManifestError(f"{manifest_path}: requires[{i}].app {app!r} is a self-reference") if app in seen: raise ManifestError(f"{manifest_path}: requires has duplicate app {app!r}") seen.add(app) on_install = _validate_hook_path( item.get("on_install"), manifest_path, f"requires[{app}].on_install" ) on_start = _validate_hook_path( item.get("on_start"), manifest_path, f"requires[{app}].on_start" ) out.append(Requirement(app=app, on_install=on_install, on_start=on_start)) 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) requires = _parse_requires(raw.get("requires"), path, name) 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, requires=requires, )