Validate module catalog provenance and availability
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user