Add trusted public module catalog installs

This commit is contained in:
2026-08-06 21:12:54 +02:00
parent 32c234fbdb
commit 9ceb1b8c22
9 changed files with 754 additions and 99 deletions
+5 -2
View File
@@ -278,12 +278,15 @@ through the same trusted address range.
| --- | --- | | --- | --- |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_URL` or `GOVOPLAN_MODULE_PACKAGE_CATALOG` | Module package catalog source. | | `GOVOPLAN_MODULE_PACKAGE_CATALOG_URL` or `GOVOPLAN_MODULE_PACKAGE_CATALOG` | Module package catalog source. |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE` | Preferred production keyring path. | | `GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE` | Preferred production keyring path. |
| `GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL` | Approved catalog channel, for example `stable`. | | `GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS` | Comma-separated approved catalog channels, for example `stable`. The legacy singular name remains readable during migration. |
| `GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE` | Trusted license issuer keyring path. | | `GOVOPLAN_LICENSE_TRUSTED_KEYS_FILE` | Trusted license issuer keyring path. |
| `GOVOPLAN_LICENSE_ENFORCEMENT` | Enables license enforcement when set to `true`. | | `GOVOPLAN_LICENSE_ENFORCEMENT` | Enables license enforcement when set to `true`. |
Trust roots are deployment-managed and should not be editable through the Trust roots are deployment-managed and should not be editable through the
running WebUI. running WebUI. When no catalog override is configured, the Admin package
directory uses GovOPlaN's public stable catalog and the trust anchor bundled
with the installed Core release. Production operators may still pin a newer or
institution-specific catalog/keyring explicitly with the settings above.
### Mail Test Credentials ### Mail Test Credentials
+4
View File
@@ -1476,6 +1476,10 @@ The installer preflight is intentionally conservative:
- the `shared` state profile blocks in-place package mutation; clustered - the `shared` state profile blocks in-place package mutation; clustered
installations must roll one verified immutable module composition across all installations must roll one verified immutable module composition across all
replicas; replicas;
- official runtime images carry the full verified package profile, while the
desired module graph controls activation and tenant/View/Policy contracts
control availability and presentation; package lifecycle must not be reused
as a tenant or user visibility switch;
- installed module manifests must be compatible with the supported manifest - installed module manifests must be compatible with the supported manifest
contract and current core version; contract and current core version;
- uninstalling `tenancy`, `access`, or `admin` is blocked; - uninstalling `tenancy`, `access`, or `admin` is blocked;
+20
View File
@@ -197,6 +197,13 @@ If both file and URL are set, the URL wins. The cache is used when a remote
fetch fails, so an operator can still inspect the last known catalog. A cached fetch fails, so an operator can still inspect the last known catalog. A cached
catalog must still pass signature, freshness, channel, and replay validation. catalog must still pass signature, freshness, channel, and replay validation.
If neither source is configured, the Admin package directory discovers the
official public stable catalog at
`https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json`. Core verifies
that fallback against the public key pinned in the installed Core package. An
explicit deployment catalog always takes precedence; a configured source that
is unavailable or invalid fails closed instead of silently falling back.
An official catalog is a JSON object with: An official catalog is a JSON object with:
- `catalog_version` - `catalog_version`
@@ -212,6 +219,8 @@ Each module entry can declare:
- backend package name and pinned install reference - backend package name and pinned install reference
- WebUI package name and pinned install reference - 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
- 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
@@ -302,6 +311,12 @@ Catalog provenance changes preflight severity:
plans, so operators can still use offline or emergency package refs plans, so operators can still use offline or emergency package refs
- valid-catalog warnings, such as intentionally unsigned local catalogs when - valid-catalog warnings, such as intentionally unsigned local catalogs when
signature enforcement is disabled, remain warnings signature enforcement is disabled, remain warnings
- a saved catalog plan must match the currently validated entry exactly;
altered package refs, artifact identities, channel, sequence, trust state, or
signing-key identity block the run and require replanning
- a trusted remote artifact is downloaded before mutation into a private
SHA-256-addressed installer cache, checked for exact size and digest, and
passed to `pip` or npm only as that verified local file
- selected catalog entries with unsatisfied non-optional named interface ranges - selected catalog entries with unsatisfied non-optional named interface ranges
block activation before the installer runs block activation before the installer runs
- selected catalog entries whose target dependencies are neither installed nor - selected catalog entries whose target dependencies are neither installed nor
@@ -519,6 +534,11 @@ Catalog entries can require license features:
Core checks those requirements against an offline license file before allowing Core checks those requirements against an offline license file before allowing
the entry into the install plan. the entry into the install plan.
Official open-source GovOPlaN entries do not declare license features. The
license contract remains generic for external catalogs, deployment presets,
configuration/package directories, and support offerings; it gates only an
entry that explicitly asks for a feature.
```bash ```bash
GOVOPLAN_LICENSE_FILE=/srv/govoplan/license.json GOVOPLAN_LICENSE_FILE=/srv/govoplan/license.json
GOVOPLAN_LICENSE_ENFORCEMENT=true GOVOPLAN_LICENSE_ENFORCEMENT=true
+1 -1
View File
@@ -26,7 +26,7 @@ dependencies = [
where = ["src"] where = ["src"]
[tool.setuptools.package-data] [tool.setuptools.package-data]
govoplan_core = ["py.typed"] govoplan_core = ["py.typed", "resources/*.json"]
[tool.setuptools.data-files] [tool.setuptools.data-files]
"govoplan_core_runtime" = ["alembic.ini"] "govoplan_core_runtime" = ["alembic.ini"]
+7 -4
View File
@@ -698,12 +698,15 @@ def _validate_module_catalog_trust(
"A module catalog source is configured without a trusted keyring file.", "A module catalog source is configured without a trusted keyring file.",
"Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.", "Pin the published GovOPlaN catalog keyring locally and set GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE.",
) )
if not _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL")): if not (
_clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS"))
or _clean(env.get("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL"))
):
collector.add( collector.add(
"error", "error",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS",
"A module catalog source is configured without an approved release channel.", "A module catalog source is configured without an approved release channel.",
"Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable or another approved deployment channel.", "Set GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS=stable or another approved deployment channel.",
) )
@@ -777,7 +780,7 @@ DEV_MAILBOX_API_ENABLED=false
GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json GOVOPLAN_MODULE_PACKAGE_CATALOG_URL=https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json
GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/etc/govoplan/catalog-keyring.json GOVOPLAN_MODULE_PACKAGE_CATALOG_TRUSTED_KEYS_FILE=/etc/govoplan/catalog-keyring.json
GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL=stable GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS=stable
""" """
+358 -17
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from collections import defaultdict from collections import defaultdict
from collections.abc import Iterable, Mapping from collections.abc import Iterable, Mapping
from contextlib import AbstractContextManager, closing from contextlib import AbstractContextManager, closing
from dataclasses import dataclass, field from dataclasses import dataclass, field, replace
from datetime import UTC, datetime from datetime import UTC, datetime
from importlib import metadata from importlib import metadata
import hashlib import hashlib
@@ -18,6 +18,7 @@ import sqlite3
import stat import stat
import subprocess # nosec B404 - installer commands are structured and policy-validated before execution. import subprocess # nosec B404 - installer commands are structured and policy-validated before execution.
import sys import sys
import tempfile
import tomllib import tomllib
from typing import Any, Literal from typing import Any, Literal
import time import time
@@ -62,6 +63,7 @@ MIGRATION_TASK_PHASES = (
MIGRATION_TASK_MUTATING_PHASES = {"pre_migration_prepare", "post_migration_backfill"} MIGRATION_TASK_MUTATING_PHASES = {"pre_migration_prepare", "post_migration_backfill"}
MIGRATION_TASK_REVIEW_SAFETY = {"requires_review", "forward_only", "destructive"} MIGRATION_TASK_REVIEW_SAFETY = {"requires_review", "forward_only", "destructive"}
MIGRATION_TASK_BLOCKING_SAFETY = {"forward_only", "destructive"} MIGRATION_TASK_BLOCKING_SAFETY = {"forward_only", "destructive"}
MAX_PACKAGE_ARTIFACT_BYTES = 512 * 1024 * 1024
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -464,14 +466,20 @@ def _package_target_action_preflight_issues(
"Python installs must include the distribution package name so rollback can uninstall newly added packages.", "Python installs must include the distribution package name so rollback can uninstall newly added packages.",
item.module_id, item.module_id,
)) ))
if item.python_ref and not _looks_pinned_dependency_ref(item.python_ref): if item.python_ref and not (
_looks_pinned_dependency_ref(item.python_ref)
or _artifact_ref_is_digest_pinned(item, "python", item.python_ref)
):
issues.append(ModuleInstallerIssue( issues.append(ModuleInstallerIssue(
"blocker", "blocker",
"unpinned_python_ref", "unpinned_python_ref",
"Python install refs must be pinned to an exact version or tagged git ref.", "Python install refs must be pinned to an exact version or tagged git ref.",
item.module_id, item.module_id,
)) ))
if item.webui_ref and not _looks_pinned_dependency_ref(item.webui_ref): if item.webui_ref and not (
_looks_pinned_dependency_ref(item.webui_ref)
or _artifact_ref_is_digest_pinned(item, "webui", item.webui_ref)
):
issues.append(ModuleInstallerIssue( issues.append(ModuleInstallerIssue(
"blocker", "blocker",
"unpinned_webui_ref", "unpinned_webui_ref",
@@ -481,6 +489,23 @@ def _package_target_action_preflight_issues(
return tuple(issues) return tuple(issues)
def _artifact_ref_is_digest_pinned(
item: ModuleInstallPlanItem,
kind: str,
package_ref: str,
) -> bool:
metadata = _artifact_metadata(item.artifact_integrity, kind)
if metadata is None:
return False
expected_ref = _artifact_text(metadata, "ref") or _artifact_text(metadata, "expected_ref")
sha256 = _artifact_text(metadata, "sha256")
return bool(
expected_ref == package_ref
and sha256
and re.fullmatch(r"[0-9a-f]{64}", sha256.lower())
)
def _frontend_rebuild_preflight_issues( def _frontend_rebuild_preflight_issues(
*, *,
frontend_rebuild_required: bool, frontend_rebuild_required: bool,
@@ -519,6 +544,7 @@ def run_module_install_plan(
) -> ModuleInstallerRunResult: ) -> ModuleInstallerRunResult:
maintenance_mode = saved_maintenance_mode(session) maintenance_mode = saved_maintenance_mode(session)
effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url) effective_runtime_dir = runtime_dir or default_installer_runtime_dir(database_url)
effective_plan = plan
preflight = module_install_preflight( preflight = module_install_preflight(
plan=plan, plan=plan,
available=available, available=available,
@@ -532,9 +558,30 @@ def run_module_install_plan(
if not preflight.allowed: if not preflight.allowed:
raise ModuleInstallerError("Install preflight is blocked: " + "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker")) raise ModuleInstallerError("Install preflight is blocked: " + "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker"))
if not dry_run:
effective_plan = acquire_catalog_package_artifacts(
plan,
runtime_dir=effective_runtime_dir,
)
preflight = module_install_preflight(
plan=effective_plan,
available=available,
current_enabled=current_enabled,
desired_enabled=desired_enabled,
maintenance_mode=maintenance_mode.enabled,
session=session,
webui_root=webui_root,
runtime_dir=effective_runtime_dir,
)
if not preflight.allowed:
raise ModuleInstallerError(
"Install preflight is blocked after artifact acquisition: "
+ "; ".join(issue.message for issue in preflight.issues if issue.severity == "blocker")
)
state = _prepare_module_install_run( state = _prepare_module_install_run(
session=session, session=session,
plan=plan, plan=effective_plan,
preflight=preflight, preflight=preflight,
database_url=database_url, database_url=database_url,
effective_runtime_dir=effective_runtime_dir, effective_runtime_dir=effective_runtime_dir,
@@ -556,7 +603,7 @@ def run_module_install_plan(
executed, failed_error = _execute_module_install_run( executed, failed_error = _execute_module_install_run(
session=session, session=session,
plan=plan, plan=effective_plan,
available=available, available=available,
effective_runtime_dir=effective_runtime_dir, effective_runtime_dir=effective_runtime_dir,
state=state, state=state,
@@ -566,7 +613,7 @@ def run_module_install_plan(
return _failed_module_install_run_result( return _failed_module_install_run_result(
session=session, session=session,
state=state, state=state,
plan=plan, plan=effective_plan,
executed=executed, executed=executed,
failed_error=failed_error, failed_error=failed_error,
effective_runtime_dir=effective_runtime_dir, effective_runtime_dir=effective_runtime_dir,
@@ -578,7 +625,7 @@ def run_module_install_plan(
return _applied_module_install_run_result( return _applied_module_install_run_result(
session=session, session=session,
plan=plan, plan=effective_plan,
desired_enabled=desired_enabled, desired_enabled=desired_enabled,
activate_installed_modules=activate_installed_modules, activate_installed_modules=activate_installed_modules,
remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired, remove_uninstalled_modules_from_desired=remove_uninstalled_modules_from_desired,
@@ -1568,9 +1615,11 @@ def _structured_item_commands(
webui_changed = False webui_changed = False
if item.action in PACKAGE_TARGET_ACTIONS: if item.action in PACKAGE_TARGET_ACTIONS:
if item.python_ref: if item.python_ref:
commands.append(_structured_command([sys.executable, "-m", "pip", "install", item.python_ref], source="module-plan.python")) python_source = _verified_artifact_install_ref(item, "python") or item.python_ref
commands.append(_structured_command([sys.executable, "-m", "pip", "install", python_source], source="module-plan.python"))
if item.webui_package and item.webui_ref and webui_root is not None: if item.webui_package and item.webui_ref and webui_root is not None:
commands.append(_structured_command([npm_bin, "pkg", "set", f"dependencies.{item.webui_package}={item.webui_ref}"], cwd=webui_root, source="module-plan.webui")) webui_source = _verified_artifact_install_ref(item, "webui") or item.webui_ref
commands.append(_structured_command([npm_bin, "pkg", "set", f"dependencies.{item.webui_package}={webui_source}"], cwd=webui_root, source="module-plan.webui"))
webui_changed = True webui_changed = True
elif item.action == "uninstall": elif item.action == "uninstall":
if item.python_package: if item.python_package:
@@ -1581,6 +1630,14 @@ def _structured_item_commands(
return tuple(commands), webui_changed return tuple(commands), webui_changed
def _verified_artifact_install_ref(item: ModuleInstallPlanItem, kind: str) -> str | None:
metadata = _artifact_metadata(item.artifact_integrity, kind)
path = _artifact_path(metadata) if metadata is not None else None
if path is None:
return None
return path.as_uri() if kind == "webui" else str(path)
def _structured_webui_followup_commands( def _structured_webui_followup_commands(
*, *,
webui_changed: bool, webui_changed: bool,
@@ -1932,9 +1989,7 @@ def _package_catalog_preflight_issues(
return () return ()
catalog_items = tuple(item for item in package_items if item.source == "catalog") catalog_items = tuple(item for item in package_items if item.source == "catalog")
try: try:
from govoplan_core.core.module_package_catalog import validate_module_package_catalog result = _validate_catalog_for_plan(catalog_items)
result = validate_module_package_catalog()
except Exception as exc: except Exception as exc:
return _catalog_validation_exception_issues(exc, catalog_items=bool(catalog_items)) return _catalog_validation_exception_issues(exc, catalog_items=bool(catalog_items))
issues = list(_catalog_validation_result_issues(result, catalog_items=bool(catalog_items))) issues = list(_catalog_validation_result_issues(result, catalog_items=bool(catalog_items)))
@@ -1942,10 +1997,33 @@ def _package_catalog_preflight_issues(
return tuple(issues) return tuple(issues)
issues.extend(_catalog_warning_issues(result)) issues.extend(_catalog_warning_issues(result))
if catalog_items: if catalog_items:
issues.extend(_catalog_plan_binding_issues(catalog_items, result))
issues.extend(_selected_catalog_interface_issues(catalog_items, result, available)) issues.extend(_selected_catalog_interface_issues(catalog_items, result, available))
return tuple(issues) return tuple(issues)
def _validate_catalog_for_plan(
catalog_items: tuple[ModuleInstallPlanItem, ...],
) -> dict[str, object]:
from govoplan_core.core.module_package_catalog import (
OFFICIAL_MODULE_PACKAGE_CATALOG_URL,
validate_module_package_catalog,
validate_official_module_package_catalog,
)
configured = validate_module_package_catalog()
if configured.get("configured") or not catalog_items:
return configured
sources = {
str(item.catalog.get("source") or "")
for item in catalog_items
if isinstance(item.catalog, Mapping)
}
if sources == {OFFICIAL_MODULE_PACKAGE_CATALOG_URL}:
return validate_official_module_package_catalog()
return configured
def _catalog_validation_exception_issues(exc: Exception, *, catalog_items: bool) -> tuple[ModuleInstallerIssue, ...]: def _catalog_validation_exception_issues(exc: Exception, *, catalog_items: bool) -> tuple[ModuleInstallerIssue, ...]:
severity: IssueSeverity = "blocker" if catalog_items else "warning" severity: IssueSeverity = "blocker" if catalog_items else "warning"
return (ModuleInstallerIssue( return (ModuleInstallerIssue(
@@ -1985,6 +2063,97 @@ def _catalog_warning_issues(result: Mapping[str, object]) -> tuple[ModuleInstall
return tuple(ModuleInstallerIssue("warning", "catalog_warning", str(warning)) for warning in warnings) return tuple(ModuleInstallerIssue("warning", "catalog_warning", str(warning)) for warning in warnings)
def _catalog_plan_binding_issues(
items: tuple[ModuleInstallPlanItem, ...],
validation: Mapping[str, object],
) -> tuple[ModuleInstallerIssue, ...]:
"""Require every trusted plan row to match its signed catalog entry exactly."""
modules = _catalog_modules_by_id(validation)
issues: list[ModuleInstallerIssue] = []
for item in items:
entry = modules.get(item.module_id)
if entry is None or entry.get("action") not in PACKAGE_TARGET_ACTIONS:
issues.append(ModuleInstallerIssue(
"blocker",
"catalog_plan_entry_missing",
f"The validated catalog no longer contains an install or update entry for {item.module_id!r}.",
item.module_id,
))
continue
mismatches = _catalog_plan_entry_mismatches(item, entry, validation)
if mismatches:
issues.append(ModuleInstallerIssue(
"blocker",
"catalog_plan_binding_mismatch",
(
"The saved package plan differs from its validated signed catalog entry "
f"for: {', '.join(mismatches)}. Remove and add the catalog item again."
),
item.module_id,
))
return tuple(issues)
def _catalog_plan_entry_mismatches(
item: ModuleInstallPlanItem,
entry: Mapping[str, object],
validation: Mapping[str, object],
) -> tuple[str, ...]:
mismatches: list[str] = []
for attribute in ("python_package", "python_ref", "webui_package", "webui_ref"):
if getattr(item, attribute) != _catalog_optional_string(entry, attribute):
mismatches.append(attribute)
if _catalog_integrity_identity(item.artifact_integrity) != _catalog_integrity_identity(
entry.get("artifact_integrity")
):
mismatches.append("artifact_integrity")
catalog = item.catalog if isinstance(item.catalog, Mapping) else {}
expected_snapshot = {
"source": validation.get("source") or validation.get("path"),
"channel": validation.get("channel"),
"sequence": validation.get("sequence"),
"signed": bool(validation.get("signed")),
"trusted": bool(validation.get("trusted")),
"key_id": validation.get("key_id"),
}
for attribute, expected in expected_snapshot.items():
actual = catalog.get(attribute)
if actual != expected:
mismatches.append(f"catalog.{attribute}")
return tuple(mismatches)
def _catalog_integrity_identity(value: object) -> dict[str, dict[str, object]]:
if not isinstance(value, Mapping):
return {}
identity: dict[str, dict[str, object]] = {}
for kind in ("python", "webui"):
raw = value.get(kind)
if not isinstance(raw, Mapping):
continue
identity[kind] = {
field: raw.get(field)
for field in (
"ref",
"url",
"filename",
"sha256",
"size",
"integrity",
"sbom_url",
"provenance_url",
"registry_identity",
"git_ref",
"source_commit",
)
if raw.get(field) is not None
}
return identity
def _module_install_target_plan( def _module_install_target_plan(
plan: ModuleInstallPlan, plan: ModuleInstallPlan,
available: Mapping[str, ModuleManifest], available: Mapping[str, ModuleManifest],
@@ -2817,12 +2986,15 @@ def _topological_cycle_ids(incoming: Mapping[str, set[str]]) -> tuple[str, ...]:
def _catalog_modules_for_target_plan( def _catalog_modules_for_target_plan(
planned_items: tuple[ModuleInstallPlanItem, ...], planned_items: tuple[ModuleInstallPlanItem, ...],
) -> dict[str, Mapping[str, object]]: ) -> dict[str, Mapping[str, object]]:
if not any(item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS for item in planned_items): catalog_items = tuple(
item
for item in planned_items
if item.source == "catalog" and item.action in PACKAGE_TARGET_ACTIONS
)
if not catalog_items:
return {} return {}
try: try:
from govoplan_core.core.module_package_catalog import validate_module_package_catalog result = _validate_catalog_for_plan(catalog_items)
result = validate_module_package_catalog()
except Exception: except Exception:
return {} return {}
if result.get("valid") is not True: if result.get("valid") is not True:
@@ -3772,6 +3944,156 @@ def _configured_require_artifact_integrity() -> bool:
return os.getenv("GOVOPLAN_MODULE_INSTALLER_REQUIRE_ARTIFACT_INTEGRITY", "").strip().lower() in {"1", "true", "yes", "on"} return os.getenv("GOVOPLAN_MODULE_INSTALLER_REQUIRE_ARTIFACT_INTEGRITY", "").strip().lower() in {"1", "true", "yes", "on"}
def acquire_catalog_package_artifacts(
plan: ModuleInstallPlan,
*,
runtime_dir: Path,
) -> ModuleInstallPlan:
"""Materialize trusted catalog archives before package mutation."""
items: list[ModuleInstallPlanItem] = []
for item in plan.items:
if item.status != "planned" or item.action not in PACKAGE_TARGET_ACTIONS:
items.append(item)
continue
raw_integrity = item.artifact_integrity
if not isinstance(raw_integrity, Mapping):
items.append(item)
continue
integrity: dict[str, object] = dict(raw_integrity)
changed = False
for kind in ("python", "webui"):
metadata = _artifact_metadata(integrity, kind)
if metadata is None or _artifact_path(metadata) is not None:
continue
if not _catalog_artifact_acquisition_ready(item, metadata):
continue
updated = dict(metadata)
updated["artifact_path"] = str(
_acquire_package_artifact(
metadata,
runtime_dir=runtime_dir,
module_id=item.module_id,
kind=kind,
)
)
integrity[kind] = updated
changed = True
items.append(replace(item, artifact_integrity=integrity) if changed else item)
return replace(plan, items=tuple(items))
def _catalog_artifact_acquisition_ready(
item: ModuleInstallPlanItem,
metadata: Mapping[str, object],
) -> bool:
catalog = item.catalog
if (
item.source != "catalog"
or not isinstance(catalog, Mapping)
or catalog.get("signed") is not True
or catalog.get("trusted") is not True
):
return False
url = _artifact_text(metadata, "url")
filename = _artifact_text(metadata, "filename")
sha256 = _artifact_text(metadata, "sha256")
size = metadata.get("size")
return bool(
url
and url.startswith("https://")
and filename
and Path(filename).name == filename
and sha256
and re.fullmatch(r"[0-9a-f]{64}", sha256.lower())
and isinstance(size, int)
and not isinstance(size, bool)
and 0 < size <= MAX_PACKAGE_ARTIFACT_BYTES
)
def _acquire_package_artifact(
metadata: Mapping[str, object],
*,
runtime_dir: Path,
module_id: str,
kind: str,
) -> Path:
url = validate_http_url(_artifact_text(metadata, "url") or "", label=f"{kind.capitalize()} package URL")
if not url.startswith("https://"):
raise ModuleInstallerError(f"{kind.capitalize()} package URL must use HTTPS.")
filename = _artifact_text(metadata, "filename") or ""
expected_sha256 = (_artifact_text(metadata, "sha256") or "").lower()
expected_size = metadata.get("size")
if (
not filename
or Path(filename).name != filename
or re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._+!-]{0,255}", filename) is None
or re.fullmatch(r"[0-9a-f]{64}", expected_sha256) is None
or not isinstance(expected_size, int)
or isinstance(expected_size, bool)
or not 0 < expected_size <= MAX_PACKAGE_ARTIFACT_BYTES
):
raise ModuleInstallerError(f"Catalog artifact metadata is incomplete for {module_id}/{kind}.")
cache_root = runtime_dir / "artifacts"
_ensure_private_artifact_directory(cache_root)
digest_root = cache_root / expected_sha256
_ensure_private_artifact_directory(digest_root)
target = digest_root / filename
if target.exists() or target.is_symlink():
if target.is_symlink() or not target.is_file():
raise ModuleInstallerError(f"Cached package artifact is not a regular file: {target}")
if target.stat().st_size != expected_size or _sha256_file(target) != expected_sha256:
raise ModuleInstallerError(f"Cached package artifact does not match its catalog identity: {target}")
return target
try:
response = fetch_http(
url,
timeout=float(os.getenv("GOVOPLAN_MODULE_INSTALLER_DOWNLOAD_TIMEOUT_SECONDS", "120")),
label=f"{module_id} {kind} package URL",
max_bytes=min(expected_size + 1, MAX_PACKAGE_ARTIFACT_BYTES),
)
except (OSError, ValueError) as exc:
raise ModuleInstallerError(f"Could not download {module_id} {kind} package: {exc}") from exc
if response.status < 200 or response.status >= 300:
raise ModuleInstallerError(f"Could not download {module_id} {kind} package: HTTP {response.status}.")
if len(response.body) != expected_size or hashlib.sha256(response.body).hexdigest() != expected_sha256:
raise ModuleInstallerError(f"Downloaded {module_id} {kind} package does not match its signed catalog identity.")
temporary_path: Path | None = None
try:
with tempfile.NamedTemporaryFile(
mode="wb",
prefix=f".{filename}.",
suffix=".tmp",
dir=digest_root,
delete=False,
) as handle:
temporary_path = Path(handle.name)
handle.write(response.body)
handle.flush()
os.fsync(handle.fileno())
temporary_path.chmod(0o600)
os.replace(temporary_path, target)
target.chmod(0o600)
except OSError as exc:
if temporary_path is not None:
temporary_path.unlink(missing_ok=True)
raise ModuleInstallerError(f"Could not cache {module_id} {kind} package.") from exc
return target
def _ensure_private_artifact_directory(path: Path) -> None:
if path.is_symlink():
raise ModuleInstallerError(f"Installer artifact cache must not be a symlink: {path}")
path.mkdir(parents=True, mode=0o700, exist_ok=True)
path.chmod(0o700)
if not path.is_dir() or stat.S_IMODE(path.stat().st_mode) != 0o700:
raise ModuleInstallerError(f"Installer artifact cache is not private: {path}")
def _verify_artifact_integrity( def _verify_artifact_integrity(
planned_items: tuple[ModuleInstallPlanItem, ...], planned_items: tuple[ModuleInstallPlanItem, ...],
*, *,
@@ -3839,7 +4161,17 @@ def _verify_artifact_metadata(
} }
if package_name: if package_name:
record["package"] = package_name record["package"] = package_name
for key in ("sha256", "sbom_url", "provenance_url", "registry_identity", "git_ref"): for key in (
"sha256",
"url",
"filename",
"integrity",
"sbom_url",
"provenance_url",
"registry_identity",
"git_ref",
"source_commit",
):
value = _artifact_text(metadata, key) value = _artifact_text(metadata, key)
if value: if value:
record[key] = value record[key] = value
@@ -3860,6 +4192,15 @@ def _verify_artifact_metadata(
item.module_id, item.module_id,
)) ))
return record, tuple(issues) return record, tuple(issues)
if artifact_path is None and _catalog_artifact_acquisition_ready(item, metadata):
record["acquisition_pending"] = True
issues.append(ModuleInstallerIssue(
"info",
"artifact_acquisition_pending",
f"{kind.capitalize()} artifact will be downloaded and verified by the installer daemon before package mutation.",
item.module_id,
))
return record, tuple(issues)
if artifact_path is None: if artifact_path is None:
issues.append(ModuleInstallerIssue( issues.append(ModuleInstallerIssue(
"blocker" if require_verified else "warning", "blocker" if require_verified else "warning",
@@ -6,6 +6,7 @@ from collections import defaultdict
from collections.abc import Mapping from collections.abc import Mapping
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from importlib.resources import files
from pathlib import Path from pathlib import Path
import json import json
import os import os
@@ -29,6 +30,8 @@ from govoplan_core.core.provider_governance import (
from govoplan_core.security.http_fetch import fetch_http_text, is_http_url 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}$")
_ARTIFACT_FILENAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._+!-]{0,255}$")
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",
@@ -36,6 +39,8 @@ CATALOG_MIGRATION_TASK_PHASES = (
"post_migration_backfill", "post_migration_backfill",
"post_migration_verify", "post_migration_verify",
) )
OFFICIAL_MODULE_PACKAGE_CATALOG_URL = "https://govoplan.add-ideas.de/catalogs/v1/channels/stable.json"
OFFICIAL_MODULE_PACKAGE_CATALOG_CHANNEL = "stable"
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
@@ -101,6 +106,18 @@ def validate_module_package_catalog(
return _valid_catalog_result(catalog_source, state) return _valid_catalog_result(catalog_source, state)
def validate_official_module_package_catalog() -> dict[str, object]:
"""Read the public GovOPlaN directory against Core's pinned trust anchor."""
keyring = files("govoplan_core").joinpath("resources/catalog-keyring.json").read_text(encoding="utf-8")
return validate_module_package_catalog(
OFFICIAL_MODULE_PACKAGE_CATALOG_URL,
require_trusted=True,
approved_channels=(OFFICIAL_MODULE_PACKAGE_CATALOG_CHANNEL,),
trusted_keys=_parse_trusted_keys(keyring),
)
def _catalog_validation_state( def _catalog_validation_state(
source: Path | str | None, source: Path | str | None,
*, *,
@@ -323,7 +340,10 @@ def _configured_require_signature() -> bool:
def _configured_approved_channels() -> tuple[str, ...]: def _configured_approved_channels() -> tuple[str, ...]:
value = os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip() value = (
os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS", "").strip()
or os.getenv("GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNEL", "").strip()
)
if not value: if not value:
return () return ()
return tuple(item.strip() for item in value.split(",") if item.strip()) return tuple(item.strip() for item in value.split(",") if item.strip())
@@ -969,20 +989,40 @@ def _normalize_artifact_integrity(value: Any) -> dict[str, object]:
continue continue
if not isinstance(raw, dict): if not isinstance(raw, dict):
raise ValueError(f"Module package catalog artifact_integrity.{key} must be an object.") raise ValueError(f"Module package catalog artifact_integrity.{key} must be an object.")
clean = { clean: dict[str, object] = {
field: text field: text
for field in ( for field in (
"ref", "ref",
"path", "path",
"artifact_path", "artifact_path",
"url",
"filename",
"sha256", "sha256",
"integrity",
"sbom_url", "sbom_url",
"provenance_url", "provenance_url",
"registry_identity", "registry_identity",
"git_ref", "git_ref",
"source_commit",
) )
if (text := _optional_str(raw, field)) if (text := _optional_str(raw, field))
} }
url = clean.get("url")
if isinstance(url, str) and (not is_http_url(url) or not url.startswith("https://")):
raise ValueError(f"Module package catalog artifact_integrity.{key}.url must use HTTPS.")
filename = clean.get("filename")
if isinstance(filename, str) and _ARTIFACT_FILENAME_RE.fullmatch(filename) is None:
raise ValueError(f"Module package catalog artifact_integrity.{key}.filename is invalid.")
sha256 = clean.get("sha256")
if isinstance(sha256, str) and _SHA256_RE.fullmatch(sha256.lower()) is None:
raise ValueError(f"Module package catalog artifact_integrity.{key}.sha256 is invalid.")
if isinstance(sha256, str):
clean["sha256"] = sha256.lower()
size = raw.get("size")
if size is not None:
if not isinstance(size, int) or isinstance(size, bool) or size <= 0 or size > 512 * 1024 * 1024:
raise ValueError(f"Module package catalog artifact_integrity.{key}.size is invalid.")
clean["size"] = size
if clean: if clean:
normalized[key] = clean normalized[key] = clean
return normalized return normalized
@@ -0,0 +1,13 @@
{
"generated_at": "2026-07-11T15:18:35.649400Z",
"keyring_version": "1",
"keys": [
{
"key_id": "release-key-1",
"not_before": "2026-07-11T00:00:00Z",
"public_key": "jOXIlZXytoNJCH8tsmrYRklg6ShpjGXRY0uV3jApRiA=",
"status": "active"
}
],
"purpose": "govoplan module package catalog signatures"
}
+289 -58
View File
@@ -148,6 +148,49 @@ def configure_database(database_url: str):
return configure_database_handle(database_url, dispose_previous=True) return configure_database_handle(database_url, dispose_previous=True)
def _bound_catalog_plan(
plan: ModuleInstallPlan,
entries: list[dict[str, object]],
) -> tuple[ModuleInstallPlan, dict[str, object]]:
"""Build the signed catalog snapshot expected by catalog-plan preflight."""
source = "https://catalog.example.test/stable.json"
snapshot = {
"source": source,
"channel": "stable",
"sequence": 42,
"signed": True,
"trusted": True,
"key_id": "release-key-1",
}
items = {item.module_id: item for item in plan.items}
bound_entries: list[dict[str, object]] = []
for raw in entries:
entry = dict(raw)
item = items[str(entry["module_id"])]
entry.setdefault("action", "install")
for attribute in ("python_package", "python_ref", "webui_package", "webui_ref"):
value = getattr(item, attribute)
if value is not None:
entry.setdefault(attribute, value)
if item.artifact_integrity is not None:
entry.setdefault("artifact_integrity", item.artifact_integrity)
bound_entries.append(entry)
return (
replace(
plan,
items=tuple(replace(item, catalog=snapshot) for item in plan.items),
),
{
"configured": True,
"valid": True,
"warnings": [],
**snapshot,
"modules": bound_entries,
},
)
def _join_route_path(prefix: str, path: str) -> str: def _join_route_path(prefix: str, path: str) -> str:
if not prefix: if not prefix:
return path return path
@@ -1813,22 +1856,18 @@ finally:
data_safety_acknowledged=True, data_safety_acknowledged=True,
), ),
)) ))
plan, validation = _bound_catalog_plan(plan, [{
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "files", "module_id": "files",
"version": "2.0.0", "version": "2.0.0",
"migration_safety": "forward_only", "migration_safety": "forward_only",
"migration_notes": "Database rollback requires restoring the pre-update snapshot.", "migration_notes": "Database rollback requires restoring the pre-update snapshot.",
"recovery_tested": True, "recovery_tested": True,
"recovery_notes": "Snapshot restore was rehearsed on the staging dataset.", "recovery_notes": "Snapshot restore was rehearsed on the staging dataset.",
}], }])
},
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value=validation,
): ):
preflight = module_install_preflight( preflight = module_install_preflight(
plan=plan, plan=plan,
@@ -1918,18 +1957,14 @@ finally:
python_ref="govoplan-mail==1.0.0", python_ref="govoplan-mail==1.0.0",
), ),
)) ))
plan, validation = _bound_catalog_plan(plan, [
{"module_id": "files", "version": "0.9.0", "allow_downgrade": True},
{"module_id": "mail", "version": "1.0.0", "allow_same_version": True},
])
with patch( with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog", "govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={ return_value=validation,
"configured": True,
"valid": True,
"warnings": [],
"modules": [
{"module_id": "files", "version": "0.9.0", "allow_downgrade": True},
{"module_id": "mail", "version": "1.0.0", "allow_same_version": True},
],
},
): ):
preflight = module_install_preflight( preflight = module_install_preflight(
plan=plan, plan=plan,
@@ -1996,20 +2031,16 @@ finally:
python_ref="govoplan-files==2.0.0", python_ref="govoplan-files==2.0.0",
), ),
)) ))
plan, validation = _bound_catalog_plan(plan, [{
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "files", "module_id": "files",
"version": "2.0.0", "version": "2.0.0",
"bridge_release": True, "bridge_release": True,
"bridge_notes": "Keeps both 1.x and 2.x attachment interfaces available.", "bridge_notes": "Keeps both 1.x and 2.x attachment interfaces available.",
}], }])
},
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value=validation,
): ):
preflight = module_install_preflight( preflight = module_install_preflight(
plan=plan, plan=plan,
@@ -2073,21 +2104,17 @@ finally:
data_safety_acknowledged=True, data_safety_acknowledged=True,
), ),
)) ))
plan, validation = _bound_catalog_plan(plan, [{
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "files", "module_id": "files",
"migration_safety": "destructive", "migration_safety": "destructive",
"migration_notes": "Drops obsolete cache tables after exporting the retained documents.", "migration_notes": "Drops obsolete cache tables after exporting the retained documents.",
"recovery_tested": True, "recovery_tested": True,
"recovery_notes": "Restore and forward-recovery path verified on staging.", "recovery_notes": "Restore and forward-recovery path verified on staging.",
}], }])
},
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value=validation,
): ):
preflight = module_install_preflight( preflight = module_install_preflight(
plan=plan, plan=plan,
@@ -2127,14 +2154,7 @@ finally:
python_ref="govoplan-campaign==2.0.0", python_ref="govoplan-campaign==2.0.0",
), ),
)) ))
plan, validation = _bound_catalog_plan(plan, [
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [
{ {
"module_id": "files", "module_id": "files",
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}], "provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
@@ -2147,8 +2167,11 @@ finally:
"version_max_exclusive": "3.0.0", "version_max_exclusive": "3.0.0",
}], }],
}, },
], ])
},
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value=validation,
): ):
preflight = module_install_preflight( preflight = module_install_preflight(
plan=plan, plan=plan,
@@ -2914,6 +2937,144 @@ finally:
self.assertFalse(preflight.allowed) self.assertFalse(preflight.allowed)
self.assertIn("artifact_integrity_required", {issue.code for issue in preflight.issues}) self.assertIn("artifact_integrity_required", {issue.code for issue in preflight.issues})
def test_trusted_catalog_artifact_is_acquired_and_installed_from_verified_cache(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-artifact-download-", dir=_TEST_ROOT))
encoded = b"verified wheel artifact"
digest = hashlib.sha256(encoded).hexdigest()
plan = ModuleInstallPlan(items=(ModuleInstallPlanItem(
module_id="files",
action="install",
source="catalog",
catalog={"signed": True, "trusted": True, "channel": "stable"},
python_package="govoplan-files",
python_ref=f"govoplan-files @ https://packages.example.test/govoplan_files.whl#sha256={digest}",
artifact_integrity={
"python": {
"ref": f"govoplan-files @ https://packages.example.test/govoplan_files.whl#sha256={digest}",
"url": "https://packages.example.test/govoplan_files.whl",
"filename": "govoplan_files-0.1.4-py3-none-any.whl",
"sha256": digest,
"size": len(encoded),
},
},
),))
with patch.dict(os.environ, {"GOVOPLAN_MODULE_INSTALLER_REQUIRE_ARTIFACT_INTEGRITY": "true"}), patch(
"govoplan_core.core.module_installer._package_catalog_preflight_issues",
return_value=(),
):
preflight = module_install_preflight(
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
maintenance_mode=True,
)
self.assertTrue(preflight.allowed, [issue.as_dict() for issue in preflight.issues])
self.assertIn("artifact_acquisition_pending", {issue.code for issue in preflight.issues})
with patch(
"govoplan_core.core.module_installer.fetch_http",
return_value=SimpleNamespace(status=200, body=encoded),
):
acquired = module_installer_module.acquire_catalog_package_artifacts(
plan,
runtime_dir=root / "installer",
)
artifact_path = Path(acquired.items[0].artifact_integrity["python"]["artifact_path"])
self.assertEqual(encoded, artifact_path.read_bytes())
self.assertEqual(0o600, stat.S_IMODE(artifact_path.stat().st_mode))
commands = structured_install_commands(acquired, webui_root=None)
self.assertEqual(str(artifact_path), commands[0]["argv"][-1])
def test_installer_revalidates_the_bundled_official_catalog_when_no_override_is_configured(self) -> None:
from govoplan_core.core.module_package_catalog import OFFICIAL_MODULE_PACKAGE_CATALOG_URL
item = ModuleInstallPlanItem(
module_id="files",
action="install",
source="catalog",
catalog={"source": OFFICIAL_MODULE_PACKAGE_CATALOG_URL, "signed": True, "trusted": True},
python_package="govoplan-files",
python_ref="govoplan-files==0.1.18",
)
official = {"configured": True, "valid": True, "modules": []}
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={"configured": False, "valid": True, "modules": []},
), patch(
"govoplan_core.core.module_package_catalog.validate_official_module_package_catalog",
return_value=official,
) as validate_official:
result = module_installer_module._validate_catalog_for_plan((item,))
self.assertIs(official, result)
validate_official.assert_called_once_with()
def test_catalog_plan_is_bound_to_the_exact_validated_artifact(self) -> None:
digest = "a" * 64
python_ref = f"govoplan-files @ https://packages.example.test/files.whl#sha256={digest}"
integrity = {
"python": {
"ref": python_ref,
"url": "https://packages.example.test/files.whl",
"filename": "govoplan_files-1.2.3-py3-none-any.whl",
"sha256": digest,
"size": 123,
"registry_identity": "govoplan-files@1.2.3",
"git_ref": "v1.2.3",
"source_commit": "b" * 40,
},
}
validation = {
"configured": True,
"valid": True,
"source": "https://catalog.example.test/stable.json",
"channel": "stable",
"sequence": 42,
"signed": True,
"trusted": True,
"key_id": "release-key-1",
"modules": [{
"module_id": "files",
"action": "install",
"python_package": "govoplan-files",
"python_ref": python_ref,
"artifact_integrity": integrity,
}],
}
item = ModuleInstallPlanItem(
module_id="files",
action="install",
source="catalog",
catalog={
"source": validation["source"],
"channel": "stable",
"sequence": 42,
"signed": True,
"trusted": True,
"key_id": "release-key-1",
},
python_package="govoplan-files",
python_ref=python_ref,
artifact_integrity=integrity,
)
self.assertEqual((), module_installer_module._catalog_plan_binding_issues((item,), validation))
tampered = replace(
item,
artifact_integrity={
"python": {
**integrity["python"],
"url": "https://attacker.example.test/files.whl",
},
},
)
issues = module_installer_module._catalog_plan_binding_issues((tampered,), validation)
self.assertEqual(["catalog_plan_binding_mismatch"], [issue.code for issue in issues])
def test_supervised_module_install_rollback_restores_desired_modules(self) -> None: def test_supervised_module_install_rollback_restores_desired_modules(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-desired-rollback-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-installer-desired-rollback-", dir=_TEST_ROOT))
settings = _settings(root) settings = _settings(root)
@@ -3370,7 +3531,10 @@ finally:
"artifact_integrity": { "artifact_integrity": {
"python": { "python": {
"ref": "govoplan-files==0.1.4", "ref": "govoplan-files==0.1.4",
"url": "https://packages.example.test/govoplan-files-0.1.4.whl",
"filename": "govoplan-files-0.1.4.whl",
"sha256": "0" * 64, "sha256": "0" * 64,
"size": 123,
"sbom_url": "https://govoplan.example/sbom/files-0.1.4.spdx.json", "sbom_url": "https://govoplan.example/sbom/files-0.1.4.spdx.json",
"provenance_url": "https://govoplan.example/provenance/files-0.1.4.intoto.jsonl", "provenance_url": "https://govoplan.example/provenance/files-0.1.4.intoto.jsonl",
} }
@@ -3419,6 +3583,7 @@ 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"])
validation = validate_module_package_catalog(catalog_path) validation = validate_module_package_catalog(catalog_path)
self.assertTrue(validation["valid"]) self.assertTrue(validation["valid"])
@@ -3591,15 +3756,73 @@ finally:
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "release" / "generate-release-catalog.py"), str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "release" / "generate-release-catalog.py"),
run_name="govoplan_release_catalog_contract_test", run_name="govoplan_release_catalog_contract_test",
) )
workspace = Path(__file__).resolve().parents[2]
version = "0.1.18"
repositories = {
"govoplan-core": ("govoplan-core", "@govoplan/core-webui", ["server"]),
"govoplan-files": ("govoplan-files", "@govoplan/files-webui", []),
"govoplan-mail": ("govoplan-mail", "@govoplan/mail-webui", []),
"govoplan-campaign": ("govoplan-campaign", "@govoplan/campaign-webui", []),
}
python_packages = []
webui_packages = []
python_lock = []
webui_lock = []
for repository, (package, webui_package, extras) in repositories.items():
commit = subprocess.check_output(
["git", "-C", str(workspace / repository), "rev-parse", f"v{version}^{{commit}}"],
text=True,
).strip()
source = {
"version": version,
"repository": repository,
"tag": f"v{version}",
"commit": commit,
}
python_packages.append({"name": package, "extras": extras, **source})
webui_packages.append({"name": webui_package, **source})
python_lock.append({
"name": package,
"extras": extras,
"filename": f"{package.replace('-', '_')}-{version}-py3-none-any.whl",
"url": f"https://packages.example.test/{package}-{version}.whl",
"sha256": "a" * 64,
"size": 100,
**source,
})
webui_lock.append({
"name": webui_package,
"filename": f"{repository}-{version}.tgz",
"url": f"https://packages.example.test/{repository}-{version}.tgz",
"sha256": "b" * 64,
"size": 100,
"integrity": "sha512-test",
**source,
})
generated_at = generator["datetime"].now(tz=generator["UTC"]) generated_at = generator["datetime"].now(tz=generator["UTC"])
catalog = generator["_catalog_payload"]( catalog = generator["_catalog_payload"](
version="0.1.9", package_set={
tag="v0.1.9", "schema_version": "1",
"release_version": version,
"profile": "base",
"package_set_sha256": "c" * 64,
"python": python_packages,
"webui": webui_packages,
},
package_lock={
"schema_version": "1",
"release_version": version,
"profile": "base",
"package_set_sha256": "c" * 64,
"lock_sha256": "d" * 64,
"python": python_lock,
"webui": webui_lock,
},
channel="test", channel="test",
sequence=1, sequence=1,
generated_at=generated_at, generated_at=generated_at,
expires_at=generated_at + generator["timedelta"](days=1), expires_at=generated_at + generator["timedelta"](days=1),
repository_base="git+ssh://git@example.test/add-ideas", workspace=workspace,
public_base_url="https://example.test", public_base_url="https://example.test",
) )
modules = {item["module_id"]: item for item in catalog["modules"]} modules = {item["module_id"]: item for item in catalog["modules"]}
@@ -3612,7 +3835,7 @@ finally:
"version_max_exclusive": "0.2.0", "version_max_exclusive": "0.2.0",
}, modules["files"]["requires_interfaces"]) }, modules["files"]["requires_interfaces"])
self.assertEqual( self.assertEqual(
["campaigns", "encryption", "records", "search"], ["campaigns", "encryption", "search"],
modules["files"]["optional_dependencies"], modules["files"]["optional_dependencies"],
) )
self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"]) self.assertIn({"name": "mail.campaign_delivery", "version": "0.2.0"}, modules["mail"]["provides_interfaces"])
@@ -3682,11 +3905,19 @@ finally:
) )
self.assertEqual("requires_review", modules["files"]["migration_safety"]) self.assertEqual("requires_review", modules["files"]["migration_safety"])
self.assertIn("migration", modules["files"]["migration_notes"].lower()) self.assertIn("migration", modules["files"]["migration_notes"].lower())
files_version = importlib.import_module( self.assertEqual(version, modules["files"]["version"])
"govoplan_files.backend.manifest" self.assertEqual(
).get_manifest().version f"govoplan-files @ https://packages.example.test/govoplan-files-{version}.whl#sha256={'a' * 64}",
self.assertEqual(files_version, modules["files"]["version"]) modules["files"]["python_ref"],
self.assertIn(f"@v{files_version}", modules["files"]["python_ref"]) )
self.assertEqual(f"v{version}", modules["files"]["source"]["tag"])
self.assertEqual(
subprocess.check_output(
["git", "-C", str(workspace / "govoplan-files"), "rev-parse", f"v{version}^{{commit}}"],
text=True,
).strip(),
modules["files"]["source"]["commit"],
)
def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None: def test_module_package_catalog_validates_remote_url_and_cache_fallback(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT)) root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))