#!/usr/bin/env python3 """Generate and sign a GovOPlaN module package release catalog.""" from __future__ import annotations import argparse import base64 from dataclasses import dataclass from datetime import UTC, datetime, timedelta import json from pathlib import Path from typing import Any from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey GITEA_BASE = "git+ssh://git@git.add-ideas.de/add-ideas" @dataclass(frozen=True, slots=True) class CatalogModule: module_id: str repo: str python_package: str name: str description: str tags: tuple[str, ...] webui_package: str | None = None CATALOG_MODULES = ( CatalogModule( module_id="tenancy", repo="govoplan-tenancy", python_package="govoplan-tenancy", name="Tenancy", description="Tenant registry, tenant settings, and tenant resolution platform module.", tags=("official", "platform-module"), ), CatalogModule( module_id="organizations", repo="govoplan-organizations", python_package="govoplan-organizations", name="Organizations", description="Organization units, functions, and account-held function assignments.", tags=("official", "platform-module"), ), CatalogModule( module_id="identity", repo="govoplan-identity", python_package="govoplan-identity", name="Identity", description="Canonical identities and links between identities and platform accounts.", tags=("official", "platform-module"), ), CatalogModule( module_id="access", repo="govoplan-access", python_package="govoplan-access", name="Access", description="Authentication, accounts, users, groups, roles, API keys, and access capabilities.", tags=("official", "platform-module"), webui_package="@govoplan/access-webui", ), CatalogModule( module_id="admin", repo="govoplan-admin", python_package="govoplan-admin", name="Admin", description="System settings, governance templates, module management, and admin shell contributions.", tags=("official", "platform-module"), webui_package="@govoplan/admin-webui", ), CatalogModule( module_id="policy", repo="govoplan-policy", python_package="govoplan-policy", name="Policy", description="Policy and governance capability module.", tags=("official", "platform-module"), ), CatalogModule( module_id="audit", repo="govoplan-audit", python_package="govoplan-audit", name="Audit", description="Audit-log storage and audit administration routes.", tags=("official", "platform-module"), ), CatalogModule( module_id="dashboard", repo="govoplan-dashboard", python_package="govoplan-dashboard", name="Dashboard", description="Configurable user home assembled from module-provided dashboard widgets.", tags=("official", "platform-module"), webui_package="@govoplan/dashboard-webui", ), CatalogModule( module_id="files", repo="govoplan-files", python_package="govoplan-files", name="Files", description="Managed file spaces and campaign attachment integration.", tags=("official", "service-module"), webui_package="@govoplan/files-webui", ), CatalogModule( module_id="mail", repo="govoplan-mail", python_package="govoplan-mail", name="Mail", description="SMTP/IMAP profile management, credential policy, and read-only mailbox access.", tags=("official", "service-module"), webui_package="@govoplan/mail-webui", ), CatalogModule( module_id="campaigns", repo="govoplan-campaign", python_package="govoplan-campaign", name="Campaigns", description="Campaign authoring, validation, queueing, delivery control, and reports.", tags=("official", "business-module"), webui_package="@govoplan/campaign-webui", ), CatalogModule( module_id="calendar", repo="govoplan-calendar", python_package="govoplan-calendar", name="Calendar", description="Calendar collections, events, CalDAV sources, and calendar WebUI routes.", tags=("official", "service-module"), webui_package="@govoplan/calendar-webui", ), CatalogModule( module_id="docs", repo="govoplan-docs", python_package="govoplan-docs", name="Docs", description="Configured-system documentation and evidence-aware help surfaces.", tags=("official", "platform-module"), webui_package="@govoplan/docs-webui", ), CatalogModule( module_id="ops", repo="govoplan-ops", python_package="govoplan-ops", name="Ops", description="Runtime health, deployment profile, worker split, and sizing visibility.", tags=("official", "platform-module"), webui_package="@govoplan/ops-webui", ), ) def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--version", required=True, help="GovOPlaN release version, without leading v.") parser.add_argument("--channel", default="stable") parser.add_argument("--sequence", type=int, help="Monotonic channel sequence. Defaults to UTC timestamp.") parser.add_argument("--expires-days", type=int, default=90) parser.add_argument("--catalog-output", type=Path, required=True) parser.add_argument("--keyring-output", type=Path) parser.add_argument( "--catalog-signing-key", action="append", default=[], metavar="KEY_ID=PRIVATE_KEY", help="Ed25519 private key used to sign the catalog; may be repeated for rotation.", ) parser.add_argument("--public-base-url", default="https://govoplan.add-ideas.de") parser.add_argument("--repository-base", default=GITEA_BASE) args = parser.parse_args() version = args.version.removeprefix("v") tag = f"v{version}" generated_at = datetime.now(tz=UTC) sequence = args.sequence if args.sequence is not None else int(generated_at.strftime("%Y%m%d%H%M")) expires_at = generated_at + timedelta(days=args.expires_days) signing_keys = [_parse_signing_key(value) for value in args.catalog_signing_key] catalog = _catalog_payload( version=version, tag=tag, channel=args.channel, sequence=sequence, generated_at=generated_at, expires_at=expires_at, repository_base=args.repository_base.rstrip("/"), public_base_url=args.public_base_url.rstrip("/"), ) if signing_keys: catalog["signatures"] = [_signature(catalog, key_id=key_id, private_key=private_key) for key_id, private_key in signing_keys] output = args.catalog_output.expanduser() output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(catalog, indent=2, sort_keys=True) + "\n", encoding="utf-8") if args.keyring_output is not None: keyring_output = args.keyring_output.expanduser() keyring_output.parent.mkdir(parents=True, exist_ok=True) keyring_output.write_text( json.dumps(_keyring(signing_keys=signing_keys, generated_at=generated_at), indent=2, sort_keys=True) + "\n", encoding="utf-8", ) print(f"catalog={output}") if args.keyring_output is not None: print(f"keyring={args.keyring_output.expanduser()}") print(f"channel={args.channel}") print(f"sequence={sequence}") print(f"version={version}") return 0 def _catalog_payload( *, version: str, tag: str, channel: str, sequence: int, generated_at: datetime, expires_at: datetime, repository_base: str, public_base_url: str, ) -> dict[str, Any]: modules: list[dict[str, Any]] = [] for module in CATALOG_MODULES: entry: dict[str, Any] = { "module_id": module.module_id, "name": module.name, "description": module.description, "version": version, "action": "install", "python_package": module.python_package, "python_ref": f"{module.python_package} @ {repository_base}/{module.repo}.git@{tag}", "license_features": [f"module.{module.module_id}"], "tags": list(module.tags), } if module.webui_package: entry["webui_package"] = module.webui_package entry["webui_ref"] = f"{repository_base}/{module.repo}.git#{tag}" modules.append(entry) return { "catalog_version": "1", "channel": channel, "sequence": sequence, "generated_at": _json_datetime(generated_at), "expires_at": _json_datetime(expires_at), "release": { "version": version, "tag": tag, "catalog_url": f"{public_base_url}/catalogs/v1/channels/{channel}.json", "keyring_url": f"{public_base_url}/catalogs/v1/keyring.json", }, "core_release": { "name": "GovOPlaN Core", "version": version, "python_package": "govoplan-core", "python_ref": f"govoplan-core[server] @ {repository_base}/govoplan-core.git@{tag}", "webui_package": "@govoplan/core-webui", "webui_ref": f"{repository_base}/govoplan-core.git#{tag}", }, "modules": modules, } def _parse_signing_key(value: str) -> tuple[str, Ed25519PrivateKey]: key_id, separator, path_text = value.partition("=") if not separator or not key_id.strip() or not path_text.strip(): raise SystemExit("--catalog-signing-key must use KEY_ID=/path/to/private.pem") path = Path(path_text).expanduser() private_key = serialization.load_pem_private_key(path.read_bytes(), password=None) if not isinstance(private_key, Ed25519PrivateKey): raise SystemExit(f"Catalog signing key must be an Ed25519 private key: {path}") return key_id.strip(), private_key def _signature(payload: dict[str, Any], *, key_id: str, private_key: Ed25519PrivateKey) -> dict[str, str]: signature_payload = dict(payload) signature_payload.pop("signature", None) signature_payload.pop("signatures", None) signature = private_key.sign(_canonical_bytes(signature_payload)) return { "algorithm": "ed25519", "key_id": key_id, "value": base64.b64encode(signature).decode("ascii"), } def _keyring(*, signing_keys: list[tuple[str, Ed25519PrivateKey]], generated_at: datetime) -> dict[str, Any]: return { "keyring_version": "1", "purpose": "govoplan module package catalog signatures", "generated_at": _json_datetime(generated_at), "keys": [ { "key_id": key_id, "status": "active", "public_key": _public_key_base64(private_key), "not_before": generated_at.date().isoformat() + "T00:00:00Z", } for key_id, private_key in signing_keys ], } def _public_key_base64(private_key: Ed25519PrivateKey) -> str: public_bytes = private_key.public_key().public_bytes( encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw, ) return base64.b64encode(public_bytes).decode("ascii") def _canonical_bytes(payload: object) -> bytes: return json.dumps(payload, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8") def _json_datetime(value: datetime) -> str: return value.astimezone(UTC).isoformat().replace("+00:00", "Z") if __name__ == "__main__": raise SystemExit(main())