141 lines
4.7 KiB
Python
141 lines
4.7 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"})
|
||
|
|
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,
|
||
|
|
)
|