diff --git a/charms/garm/src/charm.py b/charms/garm/src/charm.py index 855c3b58..c2e4552f 100755 --- a/charms/garm/src/charm.py +++ b/charms/garm/src/charm.py @@ -34,6 +34,7 @@ CredentialSpec, GithubReconciler, ) +from resource_cleanup import GarmResourceCleanup from scaleset_reconciler import ScalesetReconciler, ScalesetSpec logger = logging.getLogger(__name__) @@ -162,12 +163,25 @@ def __init__(self, *args: typing.Any) -> None: self._reconcile, ) self.framework.observe(self.on.update_status, self._reconcile) + self.framework.observe(self.on.remove, self._on_remove) @block_if_invalid_data def _reconcile(self, _: ops.EventBase) -> None: """Reconcile charm state.""" self.restart() + def _on_remove(self, _: ops.RemoveEvent) -> None: + """Drain GARM resources before Juju removes the application.""" + admin_creds = self._get_admin_credentials() + if not admin_creds: + raise RuntimeError("GARM admin credentials are unavailable; refusing removal") + + base_url = f"http://127.0.0.1:{GARM_PORT}/api/v1" + auth_client = GarmAuthenticatedClient.from_login( + base_url, admin_creds["username"], admin_creds["password"] + ) + GarmResourceCleanup(auth_client).run() + def _on_get_credentials_action(self, event: ops.ActionEvent) -> None: """Return the GARM admin credentials to the operator. diff --git a/charms/garm/tests/unit/test_charm.py b/charms/garm/tests/unit/test_charm.py index 281226a6..54276a65 100644 --- a/charms/garm/tests/unit/test_charm.py +++ b/charms/garm/tests/unit/test_charm.py @@ -499,6 +499,32 @@ def test_missing_configurator_relation_prunes_orphaned_scalesets( assert out.unit_status == ops.WaitingStatus("Waiting for garm-configurator relation") +def test_remove_runs_garm_cleanup_before_charm_termination(ctx: Context, garm_api: _GarmApiMocks): + """ + arrange: A ready charm with GARM admin credentials. + act: Emit the application remove event. + assert: Cleanup runs with the authenticated client before removal completes. + """ + with patch("charm.GarmResourceCleanup") as cleanup_cls: + out = ctx.run(ctx.on.remove(), _state(secrets=_owned_secrets())) + + cleanup_cls.assert_called_once_with(garm_api.auth_client) + cleanup_cls.return_value.run.assert_called_once_with() + assert out is not None + + +def test_remove_refuses_without_admin_credentials(ctx: Context, garm_api: _GarmApiMocks): + """ + arrange: A charm without GARM admin credentials. + act: Emit the application remove event. + assert: Removal fails and no authenticated client is created. + """ + with pytest.raises(UncaughtCharmError, match="credentials are unavailable"): + ctx.run(ctx.on.remove(), _state()) + + garm_api.auth.from_login.assert_not_called() + + def test_unpopulated_configurator_relation_does_not_prune( ctx: Context, garm_api: _GarmApiMocks, caplog: pytest.LogCaptureFixture ):