Fence and reconcile module lifecycle effects
This commit is contained in:
+105
-3
@@ -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")
|
||||
|
||||
@@ -50,7 +50,13 @@ def _identity(node: str, incarnation: str) -> RuntimeIdentity:
|
||||
)
|
||||
|
||||
|
||||
def _start(factory, identity, *, key: str = "build-1"):
|
||||
def _start(
|
||||
factory,
|
||||
identity,
|
||||
*,
|
||||
key: str = "build-1",
|
||||
block_unresolved_resource: bool = False,
|
||||
):
|
||||
return begin_durable_recovery_operation(
|
||||
factory,
|
||||
identity=identity,
|
||||
@@ -68,6 +74,7 @@ def _start(factory, identity, *, key: str = "build-1"):
|
||||
lease_resource_key="campaign:build:version-1",
|
||||
resource_type="campaign_version",
|
||||
resource_id="version-1",
|
||||
block_unresolved_resource=block_unresolved_resource,
|
||||
)
|
||||
|
||||
|
||||
@@ -273,6 +280,36 @@ def test_other_runtime_cannot_use_an_active_fence() -> None:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_unresolved_predecessor_can_block_new_effects_on_same_resource() -> None:
|
||||
engine, factory = _fixture()
|
||||
try:
|
||||
started = _start(
|
||||
factory,
|
||||
_identity("worker-1", "incarnation-1"),
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
assert started.operation is not None
|
||||
started.operation.unresolved(
|
||||
status=RecoveryStatus.OUTCOME_UNKNOWN,
|
||||
summary="Provider outcome is unknown",
|
||||
evidence={"request_sent": True},
|
||||
failure_summary="Reconcile before retry",
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
RecoveryOperationStateConflict,
|
||||
match="outcome_unknown",
|
||||
):
|
||||
_start(
|
||||
factory,
|
||||
_identity("worker-2", "incarnation-2"),
|
||||
key="build-2",
|
||||
block_unresolved_resource=True,
|
||||
)
|
||||
finally:
|
||||
engine.dispose()
|
||||
|
||||
|
||||
def test_expired_crash_fence_is_taken_over_as_recovery_required() -> None:
|
||||
engine, factory = _fixture()
|
||||
try:
|
||||
|
||||
@@ -10,11 +10,18 @@ from govoplan_core.core.modules import (
|
||||
ModuleManifest,
|
||||
)
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.core.recovery import RecoveryCheckpoint, RecoveryOperation
|
||||
from govoplan_core.core.runtime_coordination import (
|
||||
DistributedLease,
|
||||
RuntimeIdentity,
|
||||
bind_process_runtime_identity,
|
||||
)
|
||||
from govoplan_core.core.workflows import (
|
||||
CAPABILITY_WORKFLOW_DEFINITION_CONTRIBUTIONS,
|
||||
WorkflowDefinitionContribution,
|
||||
)
|
||||
from govoplan_core.db.session import configure_database, reset_database
|
||||
from govoplan_core.db.base import Base
|
||||
from govoplan_core.db.session import configure_database, get_database, reset_database
|
||||
|
||||
|
||||
class _ContributionProvider:
|
||||
@@ -30,8 +37,28 @@ class _ContributionProvider:
|
||||
class WorkflowContributionLifecycleTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
configure_database("sqlite:///:memory:")
|
||||
database = get_database()
|
||||
Base.metadata.create_all(
|
||||
bind=database.engine,
|
||||
tables=[
|
||||
DistributedLease.__table__,
|
||||
RecoveryOperation.__table__,
|
||||
RecoveryCheckpoint.__table__,
|
||||
],
|
||||
)
|
||||
bind_process_runtime_identity(
|
||||
RuntimeIdentity(
|
||||
installation_id="test-installation",
|
||||
node_id="test-node",
|
||||
incarnation="test-incarnation",
|
||||
role="test",
|
||||
software_version="test",
|
||||
composition_hash="0" * 64,
|
||||
)
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
bind_process_runtime_identity(None)
|
||||
reset_database(dispose=True)
|
||||
|
||||
def test_active_graph_change_reconciles_module_workflow_baselines(self) -> None:
|
||||
|
||||
Reference in New Issue
Block a user