328 lines
12 KiB
Python
328 lines
12 KiB
Python
#!/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())
|