Skip to content
Draft
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
2 changes: 1 addition & 1 deletion charms/garm-configurator/tox.toml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ commands = [

[env.unit]
description = "Run unit tests"
deps = ["pytest", "coverage[toml]", "ops-scenario==8.8.1", "-r requirements.txt"]
deps = ["pytest", "coverage[toml]", "ops-scenario==8.8.2", "-r requirements.txt"]
commands = [
[
"coverage",
Expand Down
160 changes: 123 additions & 37 deletions charms/garm/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,36 @@ def _parse_pre_install_scripts(raw: str) -> dict[str, str]:
return {}


_PAAS_CHARM_HOOKS: typing.Final[tuple[str, ...]] = (
"_on_config_changed",
"_on_rotate_secret_key_action",
"_on_secret_changed",
"_on_secret_storage_relation_changed",
"_on_secret_storage_relation_departed",
"_on_postgresql_database_database_created",
"_on_postgresql_database_endpoints_changed",
"_on_postgresql_database_relation_broken",
"_on_ingress_ready",
"_on_ingress_revoked",
"_on_pebble_ready",
"_on_update_status",
)


def _validate_paas_charm_hook_contract() -> None:
"""Fail loudly if the pinned paas-charm lifecycle hooks have changed."""
base_classes = paas_charm.go.Charm.__mro__
missing = [
hook
for hook in _PAAS_CHARM_HOOKS
if not any(hook in base_class.__dict__ for base_class in base_classes)
]
if missing:
raise RuntimeError(
"Unsupported paas-charm lifecycle API; missing expected hooks: " + ", ".join(missing)
)


class GarmCharm(paas_charm.go.Charm):
"""GARM charm — manages the GARM service via Pebble."""

Expand All @@ -129,50 +159,102 @@ def __init__(self, *args: typing.Any) -> None:
Args:
args: Passed through to CharmBase.
"""
_validate_paas_charm_hook_contract()
super().__init__(*args)
self.framework.observe(self.on.install, self._reconcile)
self.framework.observe(self.on.leader_elected, self._reconcile)
self.framework.observe(
self.on[GARM_CONFIGURATOR_RELATION_NAME].relation_joined,
self._reconcile,
)
self.framework.observe(
self.on[GARM_CONFIGURATOR_RELATION_NAME].relation_changed,
self._reconcile,
)
self.framework.observe(
self.on[GARM_CONFIGURATOR_RELATION_NAME].relation_departed,
self._reconcile,
)
self.framework.observe(
self.on[GARM_CONFIGURATOR_RELATION_NAME].relation_broken,
self._reconcile,
)
for event in (
self.on.install,
self.on.leader_elected,
self.on.update_status,
):
self.framework.observe(event, self._reconcile)

for relation_events in (
self.on[GARM_CONFIGURATOR_RELATION_NAME],
self.on[DEBUG_SSH_INTEGRATION_NAME],
):
for event in (
relation_events.relation_joined,
relation_events.relation_changed,
relation_events.relation_departed,
relation_events.relation_broken,
):
self.framework.observe(event, self._reconcile)

self.framework.observe(self.on.get_credentials_action, self._on_get_credentials_action)
self.framework.observe(
self.on[DEBUG_SSH_INTEGRATION_NAME].relation_joined,
self._reconcile,
)
self.framework.observe(
self.on[DEBUG_SSH_INTEGRATION_NAME].relation_changed,
self._reconcile,
)
self.framework.observe(
self.on[DEBUG_SSH_INTEGRATION_NAME].relation_departed,
self._reconcile,
)
self.framework.observe(
self.on[DEBUG_SSH_INTEGRATION_NAME].relation_broken,
self._reconcile,
)
self.framework.observe(self.on.update_status, self._reconcile)
self.framework.observe(self.on.remove, self._on_remove)

def _is_tearing_down(self) -> bool:
"""Return whether Juju plans no remaining units for the local application."""
return self.app.planned_units() == 0

def _reconcile(self, event: ops.EventBase) -> None:
"""Reconcile GARM, or handle local teardown before normal state construction."""
if self._is_tearing_down():
self._teardown(event)
return
self._normal_reconcile(event)

def _reconcile_with_migrations(self, event: ops.EventBase) -> None:
"""Reconcile a database event, preserving the teardown gate."""
if self._is_tearing_down():
self._teardown(event)
return
self._normal_reconcile_with_migrations(event)
Comment thread
yanksyoon marked this conversation as resolved.

def _teardown(self, event: ops.EventBase) -> None:
"""Handle a local teardown event without normal reconciliation."""
logger.info(
"Skipping normal GARM reconciliation for %s during local teardown",
event.handle.kind,
)

@block_if_invalid_data
def _reconcile(self, _: ops.EventBase) -> None:
"""Reconcile charm state."""
def _normal_reconcile(self, _: ops.EventBase) -> None:
"""Reconcile active GARM charm state."""
self.restart()

@block_if_invalid_data
def _normal_reconcile_with_migrations(self, _: ops.EventBase) -> None:
"""Reconcile active GARM state and rerun database migrations."""
self.restart(rerun_migrations=True)

def _route_reconcile(self, event: ops.EventBase) -> None:
"""Route an inherited framework event through GARM's teardown gate."""
self._reconcile(event)

# PaasCharm.__init__ resolves these hook names dynamically. These aliases keep the
# teardown check before block_if_invalid_data without registering duplicate observers.
# The compatibility check above makes this adapter fail loudly when the base API changes.
_on_config_changed = _route_reconcile
_on_secret_changed = _route_reconcile
_on_secret_storage_relation_changed = _route_reconcile
_on_secret_storage_relation_departed = _route_reconcile
_on_postgresql_database_relation_broken = _route_reconcile
_on_ingress_ready = _route_reconcile
_on_ingress_revoked = _route_reconcile
_on_pebble_ready = _route_reconcile
Comment thread
yanksyoon marked this conversation as resolved.

def _route_reconcile_with_migrations(self, event: ops.EventBase) -> None:
"""Route an inherited database event through GARM's migration gate."""
Comment thread
yanksyoon marked this conversation as resolved.
self._reconcile_with_migrations(event)

_on_postgresql_database_database_created = _route_reconcile_with_migrations
_on_postgresql_database_endpoints_changed = _route_reconcile_with_migrations

def _on_update_status(self, event: ops.HookEvent) -> None:
"""Run the framework update-status handler only while active."""
if self._is_tearing_down():
logger.info("Skipping update-status handling during local teardown")
return
super()._on_update_status(event)

def _on_rotate_secret_key_action(self, event: ops.ActionEvent) -> None:
"""Reject secret rotation during teardown before the base decorator runs."""
if self._is_tearing_down():
event.fail("cannot rotate the secret key during local teardown")
return
super()._on_rotate_secret_key_action(event)
Comment thread
yanksyoon marked this conversation as resolved.

def _on_remove(self, _: ops.RemoveEvent) -> None:
"""Drain GARM resources before Juju removes the application."""
if not self.unit.is_leader():
Expand Down Expand Up @@ -287,6 +369,10 @@ def restart(self, rerun_migrations: bool = False) -> None:
Args:
rerun_migrations: Passed through to the parent restart.
"""
if self._is_tearing_down():
logger.info("Skipping GARM workload restart during local teardown")
return

self._ensure_secrets()

if not self.is_ready():
Expand Down
Loading
Loading