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
|
- `GET /api/v1/admin/system/modules/package-catalog` reads approved package
|
||||||
references from `GOVOPLAN_MODULE_PACKAGE_CATALOG` so operators can add known
|
references from `GOVOPLAN_MODULE_PACKAGE_CATALOG` so operators can add known
|
||||||
module refs to the install plan without typing them manually. The endpoint
|
module refs to the install plan without typing them manually. The endpoint
|
||||||
also reports catalog validity, channel, signature, trust state, and the
|
also reports catalog validity, channel, signature, trust state, source and
|
||||||
configured path.
|
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
|
- `POST /api/v1/admin/system/modules/install-plan/catalog/{module_id}` saves
|
||||||
a planned install or update row from a validated catalog entry. Installed
|
a planned install or update row from a validated catalog entry. Installed
|
||||||
modules are planned as updates. Catalog signature and approved-channel policy
|
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
|
- WebUI package name and pinned install reference
|
||||||
- `artifact_integrity` for each package, including the HTTPS registry URL,
|
- `artifact_integrity` for each package, including the HTTPS registry URL,
|
||||||
filename, byte size, SHA-256, package identity, source tag, and source commit
|
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
|
- display metadata and tags
|
||||||
- `license_features`, the feature entitlements required to plan that install
|
- `license_features`, the feature entitlements required to plan that install
|
||||||
- `dependencies` and `optional_dependencies`, the module ids expected in the
|
- `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
|
- `requires_interfaces`, named interface contracts and version ranges required
|
||||||
by this module
|
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
|
The signature is Ed25519 over canonical JSON with both `signature` and
|
||||||
`signatures` removed. Core accepts the legacy single `signature` field and the
|
`signatures` removed. Core accepts the legacy single `signature` field and the
|
||||||
new `signatures` array.
|
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_]*)+$")
|
_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}$")
|
_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}$")
|
_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_SAFETY = ("automatic", "requires_review", "forward_only", "destructive")
|
||||||
CATALOG_MIGRATION_TASK_PHASES = (
|
CATALOG_MIGRATION_TASK_PHASES = (
|
||||||
"pre_migration_check",
|
"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),
|
"requires_interfaces": _normalize_catalog_interface_requirements(value.get("requires_interfaces"), module_id=module_id),
|
||||||
"notes": _optional_str(value, "notes"),
|
"notes": _optional_str(value, "notes"),
|
||||||
"tags": _string_list(value.get("tags")),
|
"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")
|
raw_architecture = value.get("architecture")
|
||||||
architecture_maturity: str | None = None
|
architecture_maturity: str | None = None
|
||||||
if raw_architecture is not None:
|
if raw_architecture is not None:
|
||||||
@@ -756,6 +775,67 @@ def _normalize_catalog_item(value: Any) -> dict[str, object]:
|
|||||||
return item
|
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:
|
def _catalog_migration_safety(value: Any, *, module_id: str) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
return "automatic"
|
return "automatic"
|
||||||
@@ -826,14 +906,14 @@ def _catalog_optional_positive_int(value: dict[str, Any], key: str, *, module_id
|
|||||||
return integer
|
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)
|
item = _optional_str(value, key)
|
||||||
if not item:
|
if not item:
|
||||||
raise ValueError(f"Module package catalog entry is missing {key!r}.")
|
raise ValueError(f"Module package catalog entry is missing {key!r}.")
|
||||||
return item
|
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)
|
item = value.get(key)
|
||||||
if item is None:
|
if item is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -3502,6 +3502,16 @@ finally:
|
|||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"python_package": "govoplan-files",
|
"python_package": "govoplan-files",
|
||||||
"python_ref": "govoplan-files==0.1.4",
|
"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"],
|
"dependencies": ["access"],
|
||||||
"optional_dependencies": ["mail"],
|
"optional_dependencies": ["mail"],
|
||||||
"migration_safety": "forward_only",
|
"migration_safety": "forward_only",
|
||||||
@@ -3584,11 +3594,78 @@ finally:
|
|||||||
)
|
)
|
||||||
self.assertEqual("0" * 64, catalog[0]["artifact_integrity"]["python"]["sha256"])
|
self.assertEqual("0" * 64, catalog[0]["artifact_integrity"]["python"]["sha256"])
|
||||||
self.assertEqual(123, catalog[0]["artifact_integrity"]["python"]["size"])
|
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)
|
validation = validate_module_package_catalog(catalog_path)
|
||||||
self.assertTrue(validation["valid"])
|
self.assertTrue(validation["valid"])
|
||||||
self.assertEqual("files", validation["modules"][0]["module_id"])
|
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:
|
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))
|
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-interfaces-", dir=_TEST_ROOT))
|
||||||
catalog_path = root / "catalog.json"
|
catalog_path = root / "catalog.json"
|
||||||
|
|||||||
@@ -629,6 +629,42 @@
|
|||||||
line-height: 1.35;
|
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 {
|
.module-installer-request-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(240px, 1fr));
|
grid-template-columns: repeat(2, minmax(240px, 1fr));
|
||||||
@@ -962,6 +998,11 @@
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.module-package-catalog-toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.module-installer-runs-heading,
|
.module-installer-runs-heading,
|
||||||
.module-installer-run-row {
|
.module-installer-run-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
Reference in New Issue
Block a user