From 8f2369c2edcc8848209f70a3609a15c24407a9b4 Mon Sep 17 00:00:00 2001 From: Terranom674 Date: Mon, 17 Aug 2026 11:24:58 +0200 Subject: [PATCH] Add connector manifest builder runtime --- runtime/lib/build_manifest.py | 120 ++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 runtime/lib/build_manifest.py diff --git a/runtime/lib/build_manifest.py b/runtime/lib/build_manifest.py new file mode 100644 index 0000000..bc499f0 --- /dev/null +++ b/runtime/lib/build_manifest.py @@ -0,0 +1,120 @@ +#!/usr/bin/env python3 +"""Resolve Nextcloud view rows through explicitly configured storage mounts.""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import subprocess +import sys +import tempfile +from pathlib import Path, PurePosixPath + + +def read_config(path: Path) -> dict[str, tuple[str, Path]]: + result: dict[str, tuple[str, Path]] = {} + with path.open(encoding="utf-8") as handle: + for number, line in enumerate(handle, 1): + line = line.rstrip("\n") + if not line or line.startswith("#"): + continue + fields = line.split("\t") + if len(fields) != 3: + raise ValueError(f"{path}:{number}: expected storage_id, source_prefix and local_mount") + storage_id, prefix, mount = fields + result[storage_id] = (prefix.strip("/"), Path(mount)) + return result + + +def query_rows(args: argparse.Namespace) -> list[list[str]]: + password = args.password_file.read_text(encoding="utf-8").strip() + env = os.environ.copy() + env["PGPASSWORD"] = password + sql = f"SELECT share_id, display_name, storage_id, source_path FROM {args.view} ORDER BY share_id" + command = [ + "psql", "-X", "-A", "-F", "\t", "-t", + "-h", args.host, "-p", str(args.port), "-U", args.user, "-d", args.database, + "-c", sql, + ] + completed = subprocess.run(command, env=env, check=True, text=True, capture_output=True) + return list(csv.reader(completed.stdout.splitlines(), delimiter="\t")) + + +def contained_join(root: Path, relative: str) -> Path: + parts = PurePosixPath(relative).parts + if relative.startswith("/") or ".." in parts: + raise ValueError(f"unsafe source path: {relative}") + return root.joinpath(*parts) + + +def build(args: argparse.Namespace) -> dict[str, object]: + adapters = read_config(args.storage_config) + rows = query_rows(args) + if not rows and not args.allow_empty: + raise RuntimeError("Nextcloud returned no Showcase shares; refusing an empty manifest") + + manifest: list[str] = [] + errors: list[str] = [] + for row in rows: + if len(row) != 4: + errors.append(f"invalid database row with {len(row)} columns") + continue + share_id, display_name, storage_id, source_path = row + adapter = adapters.get(storage_id) + if not adapter: + errors.append(f"share {share_id}: unknown storage {storage_id}") + continue + prefix, mount = adapter + relative = source_path.strip("/") + if prefix: + expected = prefix + "/" + if relative != prefix and not relative.startswith(expected): + errors.append(f"share {share_id}: path does not match configured prefix") + continue + relative = relative[len(prefix):].lstrip("/") + source = contained_join(mount, relative) + if not mount.is_mount(): + errors.append(f"share {share_id}: storage mount unavailable: {mount}") + continue + if not source.is_dir(): + errors.append(f"share {share_id}: source directory unavailable: {source}") + continue + manifest.append(f"{share_id}\t{display_name.lstrip('/')}\t{source}") + + if errors: + raise RuntimeError("; ".join(errors)) + if len(manifest) != len(rows): + raise RuntimeError("not all Showcase shares could be resolved") + + args.output.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=args.output.parent, delete=False) as handle: + handle.write("\n".join(manifest) + ("\n" if manifest else "")) + temporary = Path(handle.name) + temporary.replace(args.output) + return {"shares": len(manifest), "manifest": str(args.output)} + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--host", required=True) + parser.add_argument("--port", type=int, default=5432) + parser.add_argument("--database", required=True) + parser.add_argument("--user", required=True) + parser.add_argument("--password-file", required=True, type=Path) + parser.add_argument("--view", default="piwigo_showcase_sources") + parser.add_argument("--storage-config", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + parser.add_argument("--allow-empty", action="store_true") + args = parser.parse_args() + try: + print(json.dumps(build(args), ensure_ascii=False)) + except Exception as error: + print(f"manifest: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())