Add trusted public module catalog installs
This commit is contained in:
+304
-73
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user