Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions charms/garm/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
CredentialSpec,
GithubReconciler,
)
from resource_cleanup import GarmResourceCleanup
from scaleset_reconciler import ScalesetReconciler, ScalesetSpec

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we add this removal flow to an existing integration test (if any)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea, although I think we may have skipped that to reduce the cost of heavy integration test. If it doesn't exist, i'll test it via staging.


@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"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the base_url be a constant?

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.

Expand Down
26 changes: 26 additions & 0 deletions charms/garm/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just a question, is there a way to assert the state, ie that the actual resource is removed, rather than just asserting that a method is called?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup, that would be:

  1. write a fake client with internal state
  2. inject the fake client and assert the internal state after testing.

However, I think this isn't quite worth the effort here because we may end up testing the fake client's implementation.



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
):
Expand Down
Loading