Fence and reconcile module lifecycle effects

This commit is contained in:
2026-08-03 07:02:07 +02:00
parent b962f6756e
commit 729b84d3af
12 changed files with 1166 additions and 34 deletions
+105 -3
View File
@@ -20,7 +20,7 @@ from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, insert, inspect
from sqlalchemy import Column, Integer, MetaData, Table, create_engine, insert, inspect, select
from sqlalchemy.orm import Session
# Keep the default app import side effect from bootstrapping a development DB.
@@ -93,6 +93,13 @@ from govoplan_core.core.configuration_packages import (
validate_configuration_package_catalog,
)
from govoplan_core.core.module_license import issue_module_license, module_license_decision, module_license_diagnostics, validate_module_license
from govoplan_core.core.recovery import (
RecoveryCheckpoint,
RecoveryOperation,
RecoveryStatus,
verify_recovery_evidence_chain,
)
from govoplan_core.core.runtime_coordination import DistributedLease
from govoplan_core.core.module_package_catalog import (
module_package_catalog,
record_module_package_catalog_acceptance,
@@ -2607,7 +2614,15 @@ finally:
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
Base.metadata.create_all(
bind=database.engine,
tables=[
SystemSettings.__table__,
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
],
)
metadata = MetaData()
table = Table("retirement_example", metadata, Column("id", Integer, primary_key=True))
metadata.create_all(bind=database.engine)
@@ -2658,6 +2673,9 @@ finally:
database_url=settings.database_url,
runtime_dir=root / "installer",
)
recovery = session.execute(select(RecoveryOperation)).scalar_one()
self.assertEqual(RecoveryStatus.SUCCEEDED.value, recovery.status)
self.assertTrue(verify_recovery_evidence_chain(session, recovery.id))
self.assertEqual("applied", result.status)
self.assertFalse(inspect(database.engine).has_table("retirement_example"))
@@ -2813,7 +2831,15 @@ finally:
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(bind=database.engine, tables=[SystemSettings.__table__])
Base.metadata.create_all(
bind=database.engine,
tables=[
SystemSettings.__table__,
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
],
)
def fake_run(*_args, **kwargs):
argv = tuple(_args[0]) if _args else ()
@@ -2846,11 +2872,78 @@ finally:
restored_desired = saved_desired_enabled_modules(session, ("tenancy", "access"))
restored_plan = saved_module_install_plan(session)
recovery = session.execute(select(RecoveryOperation)).scalar_one()
self.assertEqual(RecoveryStatus.RECOVERED.value, recovery.status)
self.assertTrue(verify_recovery_evidence_chain(session, recovery.id))
self.assertEqual("rolled-back", result.status)
self.assertEqual(("tenancy", "access"), restored_desired)
self.assertEqual(("planned",), tuple(item.status for item in restored_plan.items))
def test_module_installer_blocks_after_unresolved_package_effect(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-unresolved-", dir=_TEST_ROOT))
settings = _settings(root)
configure_database(settings.database_url)
database = get_database()
Base.metadata.create_all(
bind=database.engine,
tables=[
SystemSettings.__table__,
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
],
)
def fail_package_install(*args, **_kwargs):
argv = tuple(args[0]) if args else ()
if any("govoplan-example==0.1.4" in str(item) for item in argv):
return SimpleNamespace(returncode=1, stdout="", stderr="install failed")
return SimpleNamespace(returncode=0, stdout="", stderr="")
with database.session() as session:
save_maintenance_mode(session, MaintenanceMode(enabled=True))
plan = save_module_install_plan(session, [{
"module_id": "example",
"action": "install",
"python_package": "govoplan-example",
"python_ref": "govoplan-example==0.1.4",
}])
session.commit()
with patch(
"govoplan_core.core.module_installer.subprocess.run",
side_effect=fail_package_install,
):
result = run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url=settings.database_url,
runtime_dir=root / "installer",
)
self.assertEqual("failed", result.status)
recovery = session.execute(select(RecoveryOperation)).scalar_one()
self.assertEqual(RecoveryStatus.RECOVERY_REQUIRED.value, recovery.status)
self.assertTrue(verify_recovery_evidence_chain(session, recovery.id))
with self.assertRaisesRegex(
module_installer_module.ModuleInstallerError,
"already recovery_required",
):
run_module_install_plan(
session=session,
plan=plan,
available=available_module_manifests(),
current_enabled=("tenancy", "access"),
desired_enabled=("tenancy", "access"),
database_url=settings.database_url,
runtime_dir=root / "installer",
)
def test_module_installer_external_database_backup_command_is_recorded(self) -> None:
root = Path(tempfile.mkdtemp(prefix="govoplan-installer-external-backup-", dir=_TEST_ROOT))
settings = _settings(root)
@@ -4216,6 +4309,15 @@ finally:
app, _settings_obj = self._app_for_modules(())
lifecycle = getattr(app.state, "govoplan_lifecycle", None)
self.assertIsNotNone(lifecycle)
database = get_database()
Base.metadata.create_all(
bind=database.engine,
tables=[
DistributedLease.__table__,
RecoveryOperation.__table__,
RecoveryCheckpoint.__table__,
],
)
with TestClient(app) as client:
response = client.get("/api/v1/platform/modules")