From 88f883eac6c2d0b615aab7d5637c09b4c6970baf Mon Sep 17 00:00:00 2001 From: charlie4284 Date: Tue, 18 Aug 2026 06:12:25 +0000 Subject: [PATCH 1/2] feat(garm): drain resources before application removal --- charms/garm/src/charm.py | 14 ++++++++++++++ charms/garm/tests/unit/test_charm.py | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+) 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..f2f5da97 100644 --- a/charms/garm/tests/unit/test_charm.py +++ b/charms/garm/tests/unit/test_charm.py @@ -499,6 +499,24 @@ 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): + """Application removal drains GARM resources before the charm is removed.""" + 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): + """Removal fails rather than orphaning resources when credentials are unavailable.""" + 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 ): From cb0aca40f330f3b6f1e159518d5a7b111a373a2d Mon Sep 17 00:00:00 2001 From: charlie4284 Date: Wed, 19 Aug 2026 03:51:34 +0000 Subject: [PATCH 2/2] test(garm): document remove tests with AAA --- charms/garm/tests/unit/test_charm.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/charms/garm/tests/unit/test_charm.py b/charms/garm/tests/unit/test_charm.py index f2f5da97..54276a65 100644 --- a/charms/garm/tests/unit/test_charm.py +++ b/charms/garm/tests/unit/test_charm.py @@ -500,7 +500,11 @@ def test_missing_configurator_relation_prunes_orphaned_scalesets( def test_remove_runs_garm_cleanup_before_charm_termination(ctx: Context, garm_api: _GarmApiMocks): - """Application removal drains GARM resources before the charm is removed.""" + """ + 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())) @@ -510,7 +514,11 @@ def test_remove_runs_garm_cleanup_before_charm_termination(ctx: Context, garm_ap def test_remove_refuses_without_admin_credentials(ctx: Context, garm_api: _GarmApiMocks): - """Removal fails rather than orphaning resources when credentials are unavailable.""" + """ + 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())