Prepare GovOPlaN self-hosted release workflow
Some checks failed
Dependency Audit / dependency-audit (push) Failing after 50s
Module Matrix / module-matrix (push) Failing after 49s

This commit is contained in:
2026-07-10 21:57:22 +02:00
parent 8dd5123aab
commit 94236a7d7e
51 changed files with 3576 additions and 803 deletions

View File

@@ -57,6 +57,7 @@ from govoplan_core.core.module_installer import (
list_module_installer_requests,
module_installer_daemon_status,
module_install_preflight,
module_manifest_compatibility_issues,
module_installer_lock_status,
queue_module_installer_request,
read_module_installer_request,
@@ -96,7 +97,7 @@ from govoplan_core.core.module_package_catalog import (
)
from govoplan_core.core.modules import FrontendModule, FrontendRoute, MigrationRetirementPlan, ModuleCompatibility, ModuleUninstallGuardResult
from govoplan_core.core.module_guards import drop_table_retirement_provider
from govoplan_core.core.modules import MigrationSpec, ModuleManifest
from govoplan_core.core.modules import MigrationSpec, ModuleInterfaceProvider, ModuleInterfaceRequirement, ModuleManifest
from govoplan_core.core.registry import PlatformRegistry, RegistryError
from govoplan_core.admin.models import SystemSettings
from govoplan_core.db.base import Base
@@ -108,7 +109,7 @@ from govoplan_core.server.app import create_app
from govoplan_core.server.config import GovoplanServerConfig
from govoplan_core.server.registry import available_module_manifests, build_platform_registry
from govoplan_core.server.route_validation import RouteCollisionError
from govoplan_core.tenancy.scope import create_scope_tables
from govoplan_core.tenancy.scope import Tenant, create_scope_tables
from govoplan_files.backend.storage.backends import LocalFilesystemStorageBackend, StorageBackendError
_MANIFEST_PROJECT_MODULES = {
@@ -557,6 +558,14 @@ finally:
self.assertEqual(200, login.status_code, login.text)
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
tenancy_switch = client.post(
"/api/v1/tenancy/switch-tenant",
headers=headers,
json={"tenant_id": login.json()["tenant"]["id"]},
)
self.assertEqual(200, tenancy_switch.status_code, tenancy_switch.text)
self.assertEqual(login.json()["tenant"]["id"], tenancy_switch.json()["tenant_id"])
created = client.post(
"/api/v1/admin/tenants",
headers=headers,
@@ -572,11 +581,69 @@ finally:
updated = client.patch(
f"/api/v1/admin/tenants/{tenant_id}",
headers=headers,
json={"name": f"Lifecycle {name} Updated", "is_active": False},
json={"name": f"Lifecycle {name} Updated"},
)
self.assertEqual(200, updated.status_code, updated.text)
self.assertEqual(f"Lifecycle {name} Updated", updated.json()["name"])
self.assertFalse(updated.json()["is_active"])
current_tenant_retire = client.request(
"DELETE",
f"/api/v1/admin/tenants/{login.json()['tenant']['id']}",
headers=headers,
json={"mode": "retire", "reason": "active tenant guard"},
)
self.assertEqual(409, current_tenant_retire.status_code, current_tenant_retire.text)
plan = client.get(
f"/api/v1/admin/tenants/{tenant_id}/deletion-plan",
headers=headers,
)
self.assertEqual(200, plan.status_code, plan.text)
self.assertTrue(plan.json()["allowed"])
self.assertFalse(plan.json()["destructive_supported"])
destructive = client.request(
"DELETE",
f"/api/v1/admin/tenants/{tenant_id}",
headers=headers,
json={"mode": "destroy", "reason": "not supported"},
)
self.assertEqual(409, destructive.status_code, destructive.text)
issue_codes = {item["code"] for item in destructive.json()["detail"]["plan"]["issues"]}
self.assertIn("tenant_data_present", issue_codes)
with database.session() as session:
empty_tenant = Tenant(
slug=f"empty-destroy-{name}",
name=f"Empty Destroy {name}",
settings={},
is_active=True,
)
session.add(empty_tenant)
session.commit()
empty_tenant_id = empty_tenant.id
destroyed = client.request(
"DELETE",
f"/api/v1/admin/tenants/{empty_tenant_id}",
headers=headers,
json={"mode": "destroy", "reason": "empty tenant cleanup"},
)
self.assertEqual(200, destroyed.status_code, destroyed.text)
self.assertEqual("destroy", destroyed.json()["plan"]["action"])
self.assertTrue(destroyed.json()["plan"]["destructive_supported"])
retired = client.request(
"DELETE",
f"/api/v1/admin/tenants/{tenant_id}",
headers=headers,
json={"mode": "retire", "reason": "module permutation test"},
)
self.assertEqual(200, retired.status_code, retired.text)
retired_item = retired.json()["item"]
self.assertFalse(retired_item["is_active"])
self.assertEqual("retired", retired_item["settings"]["lifecycle"]["state"])
self.assertEqual("module permutation test", retired_item["settings"]["lifecycle"]["reason"])
def test_registry_rejects_missing_required_capability(self) -> None:
registry = PlatformRegistry()
@@ -590,6 +657,68 @@ finally:
with self.assertRaisesRegex(RegistryError, "requires unavailable capability"):
registry.validate()
def test_registry_resolves_required_interface_ranges(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="files",
name="Files",
version="1.4.0",
provides_interfaces=(ModuleInterfaceProvider(name="files.spaces", version="1.4.0"),),
))
registry.register(ModuleManifest(
id="campaigns",
name="Campaigns",
version="1.1.0",
requires_interfaces=(
ModuleInterfaceRequirement(
name="files.spaces",
version_min="1.0.0",
version_max_exclusive="2.0.0",
),
),
))
snapshot = registry.validate()
self.assertEqual({"files", "campaigns"}, {manifest.id for manifest in snapshot.manifests})
def test_registry_rejects_missing_required_interface(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="campaigns",
name="Campaigns",
version="1.1.0",
requires_interfaces=(ModuleInterfaceRequirement(name="files.spaces", version_min="1.0.0"),),
))
with self.assertRaisesRegex(RegistryError, "requires unavailable interface"):
registry.validate()
def test_registry_rejects_incompatible_optional_interface_provider(self) -> None:
registry = PlatformRegistry()
registry.register(ModuleManifest(
id="files",
name="Files",
version="2.0.0",
provides_interfaces=(ModuleInterfaceProvider(name="files.spaces", version="2.0.0"),),
))
registry.register(ModuleManifest(
id="campaigns",
name="Campaigns",
version="1.1.0",
requires_interfaces=(
ModuleInterfaceRequirement(
name="files.spaces",
version_min="1.0.0",
version_max_exclusive="2.0.0",
optional=True,
),
),
))
with self.assertRaisesRegex(RegistryError, "available providers"):
registry.validate()
def test_access_and_organizations_do_not_hard_depend_on_tenancy(self) -> None:
manifests = available_module_manifests()
@@ -630,11 +759,79 @@ finally:
saved = saved_module_install_plan(session)
self.assertEqual(("files",), tuple(item.module_id for item in saved.items))
self.assertEqual("manual", saved.items[0].source)
self.assertEqual((
"python -m pip install 'govoplan-files @ git+ssh://git.example.invalid/add-ideas/govoplan-files.git@v0.1.4'",
"npm pkg set 'dependencies.@govoplan/files-webui=git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.4' && npm install",
), module_install_plan_commands(saved))
def test_module_install_plan_preserves_catalog_source(self) -> None:
item = normalize_module_install_plan_item({
"module_id": "files",
"action": "install",
"source": "catalog",
"catalog": {
"channel": "stable",
"sequence": 8,
"key_id": "release",
},
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
})
self.assertEqual("catalog", item.source)
self.assertEqual("catalog", item.as_dict()["source"])
self.assertEqual("stable", item.catalog["channel"] if item.catalog else None)
self.assertEqual(8, item.as_dict()["catalog"]["sequence"])
def test_admin_catalog_install_plan_records_catalog_provenance(self) -> None:
app, _settings_obj = self._app_for_modules(("tenancy", "admin"))
database = get_database()
create_scope_tables(database.engine)
Base.metadata.create_all(bind=database.engine)
with database.session() as session:
bootstrap_dev_data(session, user_password="test-admin")
root = Path(tempfile.mkdtemp(prefix="govoplan-admin-module-catalog-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({
"channel": "stable",
"sequence": 11,
"generated_at": "2026-07-10T00:00:00Z",
"modules": [{
"module_id": "files",
"name": "Files",
"version": "0.1.6",
"action": "install",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.6",
"webui_package": "@govoplan/files-webui",
"webui_ref": "git+ssh://git.example.invalid/add-ideas/govoplan-files.git#v0.1.6",
}],
}), encoding="utf-8")
with TestClient(app) as client:
login = client.post(
"/api/v1/auth/login",
json={"email": "admin@example.local", "password": "test-admin"},
)
self.assertEqual(200, login.status_code, login.text)
headers = {"Authorization": f"Bearer {login.json()['access_token']}"}
with patch.dict(os.environ, {
"GOVOPLAN_MODULE_PACKAGE_CATALOG": str(catalog_path),
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "false",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_APPROVED_CHANNELS": "",
}):
planned = client.post("/api/v1/admin/system/modules/install-plan/catalog/files", headers=headers)
self.assertEqual(200, planned.status_code, planned.text)
item = planned.json()["items"][0]
self.assertEqual("files", item["module_id"])
self.assertEqual("catalog", item["source"])
self.assertEqual("stable", item["catalog"]["channel"])
self.assertEqual(11, item["catalog"]["sequence"])
self.assertEqual(str(catalog_path), item["catalog"]["source"])
def test_module_install_plan_rejects_local_dependency_refs(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-install-plan-local-", dir=_TEST_ROOT))
settings = _settings(root)
@@ -768,6 +965,166 @@ finally:
self.assertIn("core_version_too_low", {issue.code for issue in preflight.issues})
self.assertTrue(preflight.checklist)
def test_module_installer_preflight_reports_catalog_warnings_before_activation(self) -> None:
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="files",
action="install",
python_package="govoplan-files",
python_ref="govoplan-files==0.1.4",
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": ["Catalog providers do not satisfy files.campaign_attachments."],
},
):
preflight = module_install_preflight(
plan=plan,
available={},
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertTrue(preflight.allowed)
self.assertIn("catalog_warning", {issue.code for issue in preflight.issues})
self.assertTrue(any("files.campaign_attachments" in issue.message for issue in preflight.issues))
def test_module_installer_preflight_blocks_invalid_catalog_sourced_install(self) -> None:
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="files",
action="install",
source="catalog",
python_package="govoplan-files",
python_ref="govoplan-files==0.1.4",
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": False,
"error": "Module package catalog must be signed by a trusted key.",
"warnings": [],
},
):
preflight = module_install_preflight(
plan=plan,
available={},
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertFalse(preflight.allowed)
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
self.assertIn("catalog_validation_failed", blockers)
def test_module_installer_preflight_warns_for_invalid_catalog_during_manual_install(self) -> None:
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="files",
action="install",
python_package="govoplan-files",
python_ref="govoplan-files==0.1.4",
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": False,
"error": "Module package catalog must be signed by a trusted key.",
"warnings": [],
},
):
preflight = module_install_preflight(
plan=plan,
available={},
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertTrue(preflight.allowed)
warnings = {issue.code for issue in preflight.issues if issue.severity == "warning"}
self.assertIn("catalog_validation_failed", warnings)
def test_module_installer_preflight_blocks_unsatisfied_catalog_interface_for_selected_install(self) -> None:
plan = ModuleInstallPlan(items=(
ModuleInstallPlanItem(
module_id="campaigns",
action="install",
source="catalog",
python_package="govoplan-campaign",
python_ref="govoplan-campaign==0.1.4",
),
))
with patch(
"govoplan_core.core.module_package_catalog.validate_module_package_catalog",
return_value={
"configured": True,
"valid": True,
"warnings": [],
"modules": [{
"module_id": "campaigns",
"requires_interfaces": [{
"name": "files.campaign_attachments",
"version_min": "1.0.0",
"version_max_exclusive": "2.0.0",
}],
}],
},
):
preflight = module_install_preflight(
plan=plan,
available={},
current_enabled=(),
desired_enabled=(),
maintenance_mode=True,
)
self.assertFalse(preflight.allowed)
blockers = {issue.code for issue in preflight.issues if issue.severity == "blocker"}
self.assertIn("catalog_required_interface_missing", blockers)
def test_module_installer_reports_interface_contract_issues(self) -> None:
available = {
"files": ModuleManifest(
id="files",
name="Files",
version="2.0.0",
provides_interfaces=(ModuleInterfaceProvider(name="files.spaces", version="2.0.0"),),
),
"campaigns": ModuleManifest(
id="campaigns",
name="Campaigns",
version="1.1.0",
requires_interfaces=(
ModuleInterfaceRequirement(
name="files.spaces",
version_min="1.0.0",
version_max_exclusive="2.0.0",
),
ModuleInterfaceRequirement(name="mail.delivery", version_min="1.0.0"),
),
),
}
issues = module_manifest_compatibility_issues(available)
self.assertIn("interface_version_mismatch", {issue.code for issue in issues})
self.assertIn("required_interface_missing", {issue.code for issue in issues})
def test_module_installer_preflight_requires_python_package_for_install_rollback(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-install-rollback-", dir=_TEST_ROOT))
settings = _settings(root)
@@ -1379,6 +1736,8 @@ finally:
"version": "0.1.4",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==0.1.4",
"provides_interfaces": [{"name": "files.spaces", "version": "1.2.0"}],
"requires_interfaces": [{"name": "access.directory", "version_min": "1.0.0", "optional": True}],
"artifact_integrity": {
"python": {
"ref": "govoplan-files==0.1.4",
@@ -1396,12 +1755,48 @@ finally:
self.assertEqual("files", catalog[0]["module_id"])
self.assertEqual("install", catalog[0]["action"])
self.assertEqual(["official"], catalog[0]["tags"])
self.assertEqual([{"name": "files.spaces", "version": "1.2.0"}], catalog[0]["provides_interfaces"])
self.assertEqual(
[{"name": "access.directory", "optional": True, "version_min": "1.0.0"}],
catalog[0]["requires_interfaces"],
)
self.assertEqual("0" * 64, catalog[0]["artifact_integrity"]["python"]["sha256"])
validation = validate_module_package_catalog(catalog_path)
self.assertTrue(validation["valid"])
self.assertEqual("files", validation["modules"][0]["module_id"])
def test_module_package_catalog_warns_about_interface_range_mismatch(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-interfaces-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({
"modules": [
{
"module_id": "files",
"version": "2.0.0",
"python_package": "govoplan-files",
"python_ref": "govoplan-files==2.0.0",
"provides_interfaces": [{"name": "files.spaces", "version": "2.0.0"}],
},
{
"module_id": "campaigns",
"version": "1.1.0",
"python_package": "govoplan-campaign",
"python_ref": "govoplan-campaign==1.1.0",
"requires_interfaces": [{
"name": "files.spaces",
"version_min": "1.0.0",
"version_max_exclusive": "2.0.0",
}],
},
],
}), encoding="utf-8")
validation = validate_module_package_catalog(catalog_path)
self.assertTrue(validation["valid"])
self.assertTrue(any("catalog providers" in warning for warning in validation["warnings"]))
def test_module_package_catalog_validation_reports_bad_entries(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
@@ -1412,6 +1807,54 @@ finally:
self.assertFalse(validation["valid"])
self.assertIn("module_id", str(validation["error"]))
def test_module_package_catalog_rejects_invalid_interface_ranges(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-invalid-interface-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({
"modules": [{
"module_id": "campaigns",
"python_package": "govoplan-campaign",
"python_ref": "govoplan-campaign==1.1.0",
"requires_interfaces": [{
"name": "files.spaces",
"version_min": "2.0.0",
"version_max_exclusive": "2.0.0",
}],
}],
}), encoding="utf-8")
validation = validate_module_package_catalog(catalog_path)
self.assertFalse(validation["valid"])
self.assertIn("invalid interface range", str(validation["error"]))
def test_module_package_catalog_requires_signature_by_default_in_production(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-production-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
catalog_path.write_text(json.dumps({
"channel": "stable",
"sequence": 1,
"modules": [{"module_id": "files"}],
}), encoding="utf-8")
with patch.dict(os.environ, {
"APP_ENV": "production",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "",
}):
validation = validate_module_package_catalog(catalog_path)
self.assertFalse(validation["valid"])
self.assertIn("signed", str(validation["error"]))
with patch.dict(os.environ, {
"APP_ENV": "production",
"GOVOPLAN_MODULE_PACKAGE_CATALOG_REQUIRE_SIGNATURE": "false",
}):
relaxed = validate_module_package_catalog(catalog_path)
self.assertTrue(relaxed["valid"], relaxed["error"])
self.assertTrue(any("unsigned" in warning for warning in relaxed["warnings"]))
def test_module_package_catalog_validates_signed_approved_channel(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-module-package-catalog-signed-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
@@ -1454,6 +1897,47 @@ finally:
self.assertTrue(validation["trusted"])
self.assertEqual("stable", validation["channel"])
def test_release_catalog_generator_reads_manifest_interface_contracts(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-release-catalog-generator-", dir=_TEST_ROOT))
catalog_path = root / "catalog.json"
result = subprocess.run(
[
sys.executable,
"scripts/generate-release-catalog.py",
"--version",
"0.1.6",
"--catalog-output",
str(catalog_path),
],
cwd=str(Path(__file__).resolve().parents[1]),
env=os.environ.copy(),
text=True,
capture_output=True,
check=False,
)
self.assertEqual(0, result.returncode, result.stderr)
catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
modules = {item["module_id"]: item for item in catalog["modules"]}
self.assertIn({"name": "files.campaign_attachments", "version": "0.1.6"}, modules["files"]["provides_interfaces"])
self.assertIn({
"name": "campaigns.access",
"optional": True,
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
}, modules["files"]["requires_interfaces"])
self.assertIn({"name": "mail.campaign_delivery", "version": "0.1.6"}, modules["mail"]["provides_interfaces"])
self.assertIn({
"name": "files.campaign_attachments",
"optional": True,
"version_min": "0.1.0",
"version_max_exclusive": "0.2.0",
}, modules["campaigns"]["requires_interfaces"])
self.assertEqual("0.1.6", modules["files"]["version"])
self.assertIn("@v0.1.6", modules["files"]["python_ref"])
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))
catalog_path = root / "catalog.json"