Validate module catalog provenance and availability

This commit is contained in:
2026-08-06 22:42:08 +02:00
parent 9ceb1b8c22
commit 44196f5620
5 changed files with 216 additions and 4 deletions
+4 -2
View File
@@ -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
+12
View File
@@ -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.
@@ -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
+77
View File
@@ -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"
+41
View File
@@ -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;