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
+304 -73
View File
@@ -148,6 +148,49 @@ def configure_database(database_url: str):
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:
if not prefix:
return path
@@ -1813,22 +1856,18 @@ finally:
data_safety_acknowledged=True,
),
))
plan, validation = _bound_catalog_plan(plan, [{
"module_id": "files",
"version": "2.0.0",
"migration_safety": "forward_only",
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
"recovery_tested": True,
"recovery_notes": "Snapshot restore was rehearsed on the staging dataset.",
}])
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "files",
"version": "2.0.0",
"migration_safety": "forward_only",
"migration_notes": "Database rollback requires restoring the pre-update snapshot.",
"recovery_tested": True,
"recovery_notes": "Snapshot restore was rehearsed on the staging dataset.",
}],
},
return_value=validation,
):
preflight = module_install_preflight(
plan=plan,
@@ -1918,18 +1957,14 @@ finally:
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(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"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},
],
},
return_value=validation,
):
preflight = module_install_preflight(
plan=plan,
@@ -1996,20 +2031,16 @@ finally:
python_ref="govoplan-files==2.0.0",
),
))
plan, validation = _bound_catalog_plan(plan, [{
"module_id": "files",
"version": "2.0.0",
"bridge_release": True,
"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={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "files",
"version": "2.0.0",
"bridge_release": True,
"bridge_notes": "Keeps both 1.x and 2.x attachment interfaces available.",
}],
},
return_value=validation,
):
preflight = module_install_preflight(
plan=plan,
@@ -2073,21 +2104,17 @@ finally:
data_safety_acknowledged=True,
),
))
plan, validation = _bound_catalog_plan(plan, [{
"module_id": "files",
"migration_safety": "destructive",
"migration_notes": "Drops obsolete cache tables after exporting the retained documents.",
"recovery_tested": True,
"recovery_notes": "Restore and forward-recovery path verified on staging.",
}])
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "files",
"migration_safety": "destructive",
"migration_notes": "Drops obsolete cache tables after exporting the retained documents.",
"recovery_tested": True,
"recovery_notes": "Restore and forward-recovery path verified on staging.",
}],
},
return_value=validation,
):
preflight = module_install_preflight(
plan=plan,
@@ -2127,28 +2154,24 @@ finally:
python_ref="govoplan-campaign==2.0.0",
),
))
plan, validation = _bound_catalog_plan(plan, [
{
"module_id": "files",
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
},
{
"module_id": "campaigns",
"requires_interfaces": [{
"name": "files.attachments",
"version_min": "2.0.0",
"version_max_exclusive": "3.0.0",
}],
},
])
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [
{
"module_id": "files",
"provides_interfaces": [{"name": "files.attachments", "version": "2.0.0"}],
},
{
"module_id": "campaigns",
"requires_interfaces": [{
"name": "files.attachments",
"version_min": "2.0.0",
"version_max_exclusive": "3.0.0",
}],
},
],
},
return_value=validation,
):
preflight = module_install_preflight(
plan=plan,
@@ -2914,6 +2937,144 @@ finally:
self.assertFalse(preflight.allowed)
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:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-desired-rollback-", dir=_TEST_ROOT))
settings = _settings(root)
@@ -3370,7 +3531,10 @@ finally:
"artifact_integrity": {
"python": {
"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,
"size": 123,
"sbom_url": "https://govoplan.example/sbom/files-0.1.4.spdx.json",
"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(123, catalog[0]["artifact_integrity"]["python"]["size"])
validation = validate_module_package_catalog(catalog_path)
self.assertTrue(validation["valid"])
@@ -3591,15 +3756,73 @@ finally:
str(Path(__file__).resolve().parents[2] / "govoplan" / "tools" / "release" / "generate-release-catalog.py"),
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"])
catalog = generator["_catalog_payload"](
version="0.1.9",
tag="v0.1.9",
package_set={
"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",
sequence=1,
generated_at=generated_at,
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",
)
modules = {item["module_id"]: item for item in catalog["modules"]}
@@ -3612,7 +3835,7 @@ finally:
"version_max_exclusive": "0.2.0",
}, modules["files"]["requires_interfaces"])
self.assertEqual(
["campaigns", "encryption", "records", "search"],
["campaigns", "encryption", "search"],
modules["files"]["optional_dependencies"],
)
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.assertIn("migration", modules["files"]["migration_notes"].lower())
files_version = importlib.import_module(
"govoplan_files.backend.manifest"
).get_manifest().version
self.assertEqual(files_version, modules["files"]["version"])
self.assertIn(f"@v{files_version}", modules["files"]["python_ref"])
self.assertEqual(version, modules["files"]["version"])
self.assertEqual(
f"govoplan-files @ https://packages.example.test/govoplan-files-{version}.whl#sha256={'a' * 64}",
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:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-remote-", dir=_TEST_ROOT))