diff --git a/kepler/src/charm.py b/kepler/src/charm.py index f31b740..ed0b669 100755 --- a/kepler/src/charm.py +++ b/kepler/src/charm.py @@ -31,6 +31,7 @@ def __init__(self, framework: ops.Framework) -> None: super().__init__(framework) self.pebble_service_name = "fastapi-service" framework.observe(self.on["demo-server"].pebble_ready, self._on_demo_server_pebble_ready) + framework.observe(self.on.config_changed, self._on_config_changed) def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: """Define and start a workload using the Pebble API.""" @@ -40,9 +41,18 @@ def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: container.add_layer("fastapi_demo", self._get_pebble_layer(), combine=True) # Make Pebble reevaluate its plan, ensuring any services are started if enabled. container.replan() - # Learn more about statuses at - # https://documentation.ubuntu.com/juju/3.6/reference/status/ - self.unit.status = ops.ActiveStatus() + # Reflect the current config in the unit status so that a config-changed + # event on this unit is observable via `juju status`. + self._update_status() + + def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: + """Update the unit status to reflect the current log-level config.""" + self._update_status() + + def _update_status(self) -> None: + """Set the unit status to an active status reporting the log-level config.""" + log_level = self.config.get("log-level", "info") + self.unit.status = ops.ActiveStatus(f"log-level={log_level}") def _get_pebble_layer(self) -> ops.pebble.Layer: """Pebble layer for the FastAPI demo services.""" diff --git a/kepler/tests/integration/test_charm.py b/kepler/tests/integration/test_charm.py index f3852e5..69d61c1 100644 --- a/kepler/tests/integration/test_charm.py +++ b/kepler/tests/integration/test_charm.py @@ -31,11 +31,56 @@ APP_NAME = METADATA["name"] +def _all_units_report(status: jubilant.Status, app: str, message: str) -> bool: + """Report whether every unit of *app* is active with the given workload status message. + + This is used as a ``juju.wait`` ready condition so that the wait does not return until the + config-changed event has actually been processed on every unit and the status message + updated — not merely until the units are idle. + """ + app_info = status.apps.get(app) + if app_info is None: + return False + if app_info.app_status.current != "active": + return False + if not app_info.units: + return False + for unit in app_info.units.values(): + if unit.workload_status.current != "active": + return False + if unit.workload_status.message != message: + return False + return True + + @pytest.mark.juju_setup def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): - """Deploy the charm under test.""" + """Deploy the charm under test with two units.""" resources = { "demo-server-image": METADATA["resources"]["demo-server-image"]["upstream-source"] } - juju.deploy(charm, app=APP_NAME, resources=resources) + juju.deploy(charm, app=APP_NAME, resources=resources, num_units=2) juju.wait(jubilant.all_active) + + +def test_all_units_get_config_changed(juju: jubilant.Juju): + """Verify that a config change fires config-changed on every unit, not just the leader. + + The charm sets its unit status to ``log-level=`` in the config-changed handler. + After changing the ``log-level`` config, every unit's workload status message should + reflect the new value. If only the leader received config-changed, the non-leader unit + would still show the old value. + """ + expected = "log-level=debug" + juju.config(APP_NAME, {"log-level": "debug"}) + juju.wait(lambda status: _all_units_report(status, APP_NAME, expected)) + + status = juju.status() + units = status.apps[APP_NAME].units + assert len(units) == 2, f"expected 2 units, got {len(units)}" + + for unit_name, unit in units.items(): + message = unit.workload_status.message + assert message == expected, ( + f"unit {unit_name} did not get config-changed: status message is {message!r}" + ) diff --git a/kepler/tests/unit/test_charm.py b/kepler/tests/unit/test_charm.py index 6e5f2b6..de2f40e 100644 --- a/kepler/tests/unit/test_charm.py +++ b/kepler/tests/unit/test_charm.py @@ -44,10 +44,42 @@ def test_pebble_layer(): # Check that we have the plan we expected: assert state_out.get_container(container.name).plan == expected_plan - # Check the unit is active: - assert state_out.unit_status == testing.ActiveStatus() + # Check the unit is active and reports the default log-level: + assert state_out.unit_status == testing.ActiveStatus("log-level=info") # Check the service was started: assert ( state_out.get_container(container.name).service_statuses["fastapi-service"] == ops.pebble.ServiceStatus.ACTIVE ) + + +def test_config_changed_updates_status(): + """A config-changed event updates the unit status to reflect the new log-level.""" + ctx = testing.Context(KosmosCharm) + container = testing.Container(name="demo-server", can_connect=True) + state_in = testing.State( + containers={container}, + leader=True, + config={"log-level": "debug"}, + ) + state_out = ctx.run(ctx.on.config_changed(), state_in) + assert state_out.unit_status == testing.ActiveStatus("log-level=debug") + + +def test_config_changed_survives_pebble_ready(): + """The status set by config-changed survives a subsequent pebble-ready event. + + This mirrors the real event sequence on a unit: config-changed and pebble-ready + both fire during the initial deploy, and both handlers set the status from the + same config value, so the observable is preserved across the full sequence. + """ + ctx = testing.Context(KosmosCharm) + container = testing.Container(name="demo-server", can_connect=True) + state = testing.State( + containers={container}, + leader=True, + config={"log-level": "debug"}, + ) + state = ctx.run(ctx.on.config_changed(), state) + state = ctx.run(ctx.on.pebble_ready(container), state) + assert state.unit_status == testing.ActiveStatus("log-level=debug")