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
16 changes: 13 additions & 3 deletions kepler/src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand All @@ -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."""
Expand Down
49 changes: 47 additions & 2 deletions kepler/tests/integration/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=<value>`` 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}"
)
36 changes: 34 additions & 2 deletions kepler/tests/unit/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")