From 44196f5620f7e6ed2a5cb4d09f8277fe7d6efeff Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 6 Aug 2026 22:42:08 +0200 Subject: [PATCH] Validate module catalog provenance and availability --- docs/MODULE_ARCHITECTURE.md | 6 +- docs/RELEASE_DEPENDENCIES.md | 12 +++ .../core/module_package_catalog.py | 84 ++++++++++++++++++- tests/test_module_system.py | 77 +++++++++++++++++ webui/src/styles/components.css | 41 +++++++++ 5 files changed, 216 insertions(+), 4 deletions(-) diff --git a/docs/MODULE_ARCHITECTURE.md b/docs/MODULE_ARCHITECTURE.md index e69a2f7..47521f5 100644 --- a/docs/MODULE_ARCHITECTURE.md +++ b/docs/MODULE_ARCHITECTURE.md @@ -1377,8 +1377,10 @@ The package install-plan API records operator intent only: - `GET /api/v1/admin/system/modules/package-catalog` reads approved package references from `GOVOPLAN_MODULE_PACKAGE_CATALOG` so operators can add known module refs to the install plan without typing them manually. The endpoint - also reports catalog validity, channel, signature, trust state, and the - configured path. + also reports catalog validity, channel, signature, trust state, source and + artifact provenance, release availability, configuration requirements, and + per-entry compatibility/blocker state. Withdrawn entries are visible for + diagnosis but cannot be planned. - `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves a planned install or update row from a validated catalog entry. Installed modules are planned as updates. Catalog signature and approved-channel policy diff --git a/docs/RELEASE_DEPENDENCIES.md b/docs/RELEASE_DEPENDENCIES.md index 27d2da4..ab62db7 100644 --- a/docs/RELEASE_DEPENDENCIES.md +++ b/docs/RELEASE_DEPENDENCIES.md @@ -221,6 +221,12 @@ Each module entry can declare: - WebUI package name and pinned install reference - `artifact_integrity` for each package, including the HTTPS registry URL, filename, byte size, SHA-256, package identity, source tag, and source commit +- `source`, binding the repository and immutable tag/commit identity, with + optional HTTPS repository and revision links +- `availability`, either `available` or `withdrawn`; a withdrawn entry must + carry an operator-readable `availability_reason` and cannot be planned +- `configuration_requirements` and an optional HTTPS `release_notes_url` for + prerequisites and release-specific operator guidance - display metadata and tags - `license_features`, the feature entitlements required to plan that install - `dependencies` and `optional_dependencies`, the module ids expected in the @@ -248,6 +254,12 @@ Each module entry can declare: - `requires_interfaces`, named interface contracts and version ranges required by this module +Core validates these fields before exposing the directory. Admin derives a +read-only catalog state from the installed package set, catalog dependency +closure, named-interface providers, current-version window, availability, and +generic license policy. This is an early operator diagnostic; trusted installer +preflight remains the authoritative mutation gate. + The signature is Ed25519 over canonical JSON with both `signature` and `signatures` removed. Core accepts the legacy single `signature` field and the new `signatures` array. diff --git a/src/govoplan_core/core/module_package_catalog.py b/src/govoplan_core/core/module_package_catalog.py index 85bce74..14f92aa 100644 --- a/src/govoplan_core/core/module_package_catalog.py +++ b/src/govoplan_core/core/module_package_catalog.py @@ -32,6 +32,9 @@ from govoplan_core.security.http_fetch import fetch_http_text, is_http_url _INTERFACE_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*(?:\.[a-z][a-z0-9_]*)+$") _SHA256_RE = re.compile(r"^[0-9a-f]{64}$") _ARTIFACT_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,255}$") +_SOURCE_REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,254}$") +_SOURCE_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/+!-]{0,127}$") +_SOURCE_COMMIT_RE = re.compile(r"^(?:[0-9a-f]{40}|[0-9a-f]{64})$") CATALOG_MIGRATION_SAFETY = ("automatic", "requires_review", "forward_only", "destructive") CATALOG_MIGRATION_TASK_PHASES = ( "pre_migration_check", @@ -652,7 +655,23 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]: "requires_interfaces": _normalize_catalog_interface_requirements(value.get("requires_interfaces"), module_id=module_id), "notes": _optional_str(value, "notes"), "tags": _string_list(value.get("tags")), + "availability": _catalog_availability(value, module_id=module_id), + "availability_reason": _optional_str(value, "availability_reason"), + "configuration_requirements": _string_list(value.get("configuration_requirements")), } + if item["availability"] == "withdrawn" and not item["availability_reason"]: + raise ValueError( + f"Withdrawn module package catalog entry {module_id!r} requires availability_reason." + ) + release_notes_url = _optional_str(value, "release_notes_url") + if release_notes_url is not None: + item["release_notes_url"] = _catalog_https_url( + release_notes_url, + label=f"Module package catalog release_notes_url for {module_id!r}", + ) + source = _normalize_catalog_source(value.get("source"), module_id=module_id) + if source: + item["source"] = source raw_architecture = value.get("architecture") architecture_maturity: str | None = None if raw_architecture is not None: @@ -756,6 +775,67 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]: return item +def _catalog_availability(value: Mapping[str, object], *, module_id: str) -> str: + availability = str(value.get("availability") or "available").strip().lower() + if availability not in {"available", "withdrawn"}: + raise ValueError( + f"Unsupported catalog availability for {module_id!r}: {availability!r}." + ) + return availability + + +def _normalize_catalog_source( + value: object, + *, + module_id: str, +) -> dict[str, object]: + if value is None: + return {} + if not isinstance(value, Mapping): + raise ValueError( + f"Module package catalog source for {module_id!r} must be an object." + ) + repository = _required_str(value, "repository") + tag = _required_str(value, "tag") + commit = _required_str(value, "commit").lower() + if ( + _SOURCE_REPOSITORY_RE.fullmatch(repository) is None + or repository.startswith("/") + or repository.endswith("/") + or ".." in repository.split("/") + ): + raise ValueError( + f"Module package catalog source repository for {module_id!r} is invalid." + ) + if _SOURCE_REF_RE.fullmatch(tag) is None or ".." in tag.split("/"): + raise ValueError( + f"Module package catalog source tag for {module_id!r} is invalid." + ) + if _SOURCE_COMMIT_RE.fullmatch(commit) is None: + raise ValueError( + f"Module package catalog source commit for {module_id!r} is invalid." + ) + source: dict[str, object] = { + "repository": repository, + "tag": tag, + "commit": commit, + } + for field in ("repository_url", "revision_url"): + url = _optional_str(value, field) + if url is not None: + source[field] = _catalog_https_url( + url, + label=f"Module package catalog source {field} for {module_id!r}", + ) + return source + + +def _catalog_https_url(value: str, *, label: str) -> str: + if not is_http_url(value) or not value.startswith("https://"): + raise ValueError(f"{label} must use HTTPS.") + return value + + def _catalog_migration_safety(value: Any, *, module_id: str) -> str: if value is None: return "automatic" @@ -826,14 +906,14 @@ def _catalog_optional_positive_int(value: dict[str, Any], key: str, *, module_id return integer -def _required_str(value: dict[str, Any], key: str) -> str: +def _required_str(value: Mapping[str, Any], key: str) -> str: item = _optional_str(value, key) if not item: raise ValueError(f"Module package catalog entry is missing {key!r}.") return item -def _optional_str(value: dict[str, Any], key: str) -> str | None: +def _optional_str(value: Mapping[str, Any], key: str) -> str | None: item = value.get(key) if item is None: return None diff --git a/tests/test_module_system.py b/tests/test_module_system.py index ae24b30..4cfa2e9 100644 --- a/tests/test_module_system.py +++ b/tests/test_module_system.py @@ -3502,6 +3502,16 @@ finally: "version": "0.1.4", "python_package": "govoplan-files", "python_ref": "govoplan-files==0.1.4", + "availability": "available", + "configuration_requirements": ["Object storage binding"], + "release_notes_url": "https://git.example.test/modules/files/releases/v0.1.4", + "source": { + "repository": "govoplan-files", + "tag": "v0.1.4", + "commit": "a" * 40, + "repository_url": "https://git.example.test/modules/govoplan-files", + "revision_url": "https://git.example.test/modules/govoplan-files/commit/" + "a" * 40, + }, "dependencies": ["access"], "optional_dependencies": ["mail"], "migration_safety": "forward_only", @@ -3584,11 +3594,78 @@ finally: ) self.assertEqual("0" * 64, catalog[0]["artifact_integrity"]["python"]["sha256"]) self.assertEqual(123, catalog[0]["artifact_integrity"]["python"]["size"]) + self.assertEqual("available", catalog[0]["availability"]) + self.assertEqual(["Object storage binding"], catalog[0]["configuration_requirements"]) + self.assertEqual("v0.1.4", catalog[0]["source"]["tag"]) + self.assertEqual("a" * 40, catalog[0]["source"]["commit"]) + self.assertEqual( + "https://git.example.test/modules/files/releases/v0.1.4", + catalog[0]["release_notes_url"], + ) validation = validate_module_package_catalog(catalog_path) self.assertTrue(validation["valid"]) self.assertEqual("files", validation["modules"][0]["module_id"]) + def test_module_package_catalog_requires_reason_for_withdrawn_release(self) -> None: + root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-withdrawn-", dir=_TEST_ROOT)) + catalog_path = root / "catalog.json" + catalog_path.write_text(json.dumps({ + "modules": [{ + "module_id": "files", + "version": "0.1.4", + "availability": "withdrawn", + "python_package": "govoplan-files", + "python_ref": "govoplan-files==0.1.4", + }], + }), encoding="utf-8") + + validation = validate_module_package_catalog(catalog_path) + + self.assertFalse(validation["valid"]) + self.assertIn("availability_reason", str(validation["error"])) + + def test_module_package_catalog_rejects_invalid_source_provenance(self) -> None: + invalid_sources = ( + { + "repository": "../govoplan-files", + "tag": "v0.1.4", + "commit": "a" * 40, + }, + { + "repository": "govoplan-files", + "tag": "v0.1.4", + "commit": "not-a-commit", + }, + { + "repository": "govoplan-files", + "tag": "v0.1.4", + "commit": "a" * 40, + "repository_url": "http://git.example.test/govoplan-files", + }, + ) + for index, source in enumerate(invalid_sources): + with self.subTest(source=source): + root = Path(tempfile.mkdtemp( + prefix=f"govoplan-module-package-catalog-source-{index}-", + dir=_TEST_ROOT, + )) + catalog_path = root / "catalog.json" + catalog_path.write_text(json.dumps({ + "modules": [{ + "module_id": "files", + "version": "0.1.4", + "python_package": "govoplan-files", + "python_ref": "govoplan-files==0.1.4", + "source": source, + }], + }), encoding="utf-8") + + validation = validate_module_package_catalog(catalog_path) + + self.assertFalse(validation["valid"]) + self.assertIn("source", str(validation["error"]).lower()) + def test_module_package_catalog_warns_about_interface_range_mismatch(self) -> None: root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-interfaces-", dir=_TEST_ROOT)) catalog_path = root / "catalog.json" diff --git a/webui/src/styles/components.css b/webui/src/styles/components.css index 37254f8..d279844 100644 --- a/webui/src/styles/components.css +++ b/webui/src/styles/components.css @@ -629,6 +629,42 @@ line-height: 1.35; } +.module-package-catalog-toolbar { + display: flex; + align-items: end; + justify-content: space-between; + gap: 12px; + min-width: 0; +} + +.module-package-catalog-search { + display: grid; + gap: 5px; + width: min(420px, 100%); + color: var(--muted); + font-size: 12px; + font-weight: 700; +} + +.module-package-catalog-search input { + min-height: 34px; + padding-block: 7px; +} + +.module-package-catalog-provenance { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 6px 12px; + min-width: 0; + color: var(--muted); + font-size: 12px; +} + +.module-package-catalog-provenance code { + overflow-wrap: anywhere; +} + .module-installer-request-grid { display: grid; grid-template-columns: repeat(2, minmax(240px, 1fr)); @@ -962,6 +998,11 @@ grid-template-columns: 1fr; } + .module-package-catalog-toolbar { + align-items: stretch; + flex-direction: column; + } + .module-installer-runs-heading, .module-installer-run-row { display: grid;