From 6bf6f58b022d1f47fb6b8ce04b64fca83cf7b493 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 27 Jul 2026 19:57:45 +1200 Subject: [PATCH 1/3] feat(profile): add pause and resume actions to the machine init profile Declare pause and resume actions in charmcraft.yaml and handle them in the charm. The pause action stops the workload and leaves the unit deployed in maintenance status; resume starts it again. Both are imperative operations with no config equivalent, which is what actions are for. The workload module gains a stop() stub alongside the existing install() and start(). Unit tests drive each action through ops.testing.Context; an integration test runs the pair against a deployed unit with jubilant. --- .../templates/init-machine/charmcraft.yaml.j2 | 18 ++++++++++++++++ .../templates/init-machine/src/charm.py.j2 | 13 ++++++++++++ .../templates/init-machine/src/workload.py.j2 | 6 ++++++ .../tests/integration/test_charm.py.j2 | 8 +++++++ .../init-machine/tests/unit/test_charm.py.j2 | 21 +++++++++++++++++++ 5 files changed, 66 insertions(+) diff --git a/charmcraft/templates/init-machine/charmcraft.yaml.j2 b/charmcraft/templates/init-machine/charmcraft.yaml.j2 index 640dd8b1f..0102a29b8 100644 --- a/charmcraft/templates/init-machine/charmcraft.yaml.j2 +++ b/charmcraft/templates/init-machine/charmcraft.yaml.j2 @@ -47,3 +47,21 @@ config: Acceptable values are: "info", "debug", "warning", "error" and "critical" default: "info" type: string + +# (Optional) Actions that an operator can run against a deployed unit. +# More information on this section at: +# https://documentation.ubuntu.com/charmcraft/stable/reference/files/charmcraft-yaml-file/#actions +# General actions documentation: +# https://documentation.ubuntu.com/juju/3.6/reference/action/ +actions: + # Example actions to take a unit out of service and put it back again. + pause: + description: | + Stop the workload, leaving the unit deployed. + + Use this to take a unit out of service, for example before performing + maintenance on the machine. Run the resume action to start the workload + again. + resume: + description: | + Start the workload again after running the pause action. diff --git a/charmcraft/templates/init-machine/src/charm.py.j2 b/charmcraft/templates/init-machine/src/charm.py.j2 index ec9ec3554..290b976f1 100644 --- a/charmcraft/templates/init-machine/src/charm.py.j2 +++ b/charmcraft/templates/init-machine/src/charm.py.j2 @@ -21,6 +21,8 @@ class {{ class_name }}(ops.CharmBase): super().__init__(framework) framework.observe(self.on.install, self._on_install) framework.observe(self.on.start, self._on_start) + framework.observe(self.on["pause"].action, self._on_pause_action) + framework.observe(self.on["resume"].action, self._on_resume_action) def _on_install(self, event: ops.InstallEvent): """Install the workload on the machine.""" @@ -35,6 +37,17 @@ class {{ class_name }}(ops.CharmBase): self.unit.set_workload_version(version) self.unit.status = ops.ActiveStatus() + def _on_pause_action(self, event: ops.ActionEvent): + """Handle the pause action: stop the workload, but leave the unit deployed.""" + {{ workload_module }}.stop() + self.unit.status = ops.MaintenanceStatus("paused: run the resume action to start again") + + def _on_resume_action(self, event: ops.ActionEvent): + """Handle the resume action: start the workload again.""" + self.unit.status = ops.MaintenanceStatus("starting workload") + {{ workload_module }}.start() + self.unit.status = ops.ActiveStatus() + if __name__ == "__main__": # pragma: nocover ops.main({{ class_name }}) diff --git a/charmcraft/templates/init-machine/src/workload.py.j2 b/charmcraft/templates/init-machine/src/workload.py.j2 index 61b52d011..fc364c718 100644 --- a/charmcraft/templates/init-machine/src/workload.py.j2 +++ b/charmcraft/templates/init-machine/src/workload.py.j2 @@ -25,6 +25,12 @@ def start() -> None: # Ideally, this function should only return once the workload is ready to use. +def stop() -> None: + """Stop the workload (by running a command, for example).""" + # You'll need to implement this function. + # Ideally, this function should only return once the workload has stopped. + + # Functions for interacting with the workload, for example over HTTP: diff --git a/charmcraft/templates/init-machine/tests/integration/test_charm.py.j2 b/charmcraft/templates/init-machine/tests/integration/test_charm.py.j2 index fd4153b18..51e7f4eca 100644 --- a/charmcraft/templates/init-machine/tests/integration/test_charm.py.j2 +++ b/charmcraft/templates/init-machine/tests/integration/test_charm.py.j2 @@ -23,6 +23,14 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): juju.wait(jubilant.all_active) +def test_pause_and_resume(charm: pathlib.Path, juju: jubilant.Juju): + """Take the unit out of service with the pause action, then put it back.""" + juju.run("{{ name }}/0", "pause") + juju.wait(jubilant.all_maintenance) + juju.run("{{ name }}/0", "resume") + juju.wait(jubilant.all_active) + + # If you implement {{ workload_module }}.get_version in the charm source, # remove the @pytest.mark.skip line to enable this test. # Alternatively, remove this test if you don't need it. diff --git a/charmcraft/templates/init-machine/tests/unit/test_charm.py.j2 b/charmcraft/templates/init-machine/tests/unit/test_charm.py.j2 index 1bb51e05b..c75351f0c 100644 --- a/charmcraft/templates/init-machine/tests/unit/test_charm.py.j2 +++ b/charmcraft/templates/init-machine/tests/unit/test_charm.py.j2 @@ -24,3 +24,24 @@ def test_start(monkeypatch: pytest.MonkeyPatch): # Assert: assert state_out.workload_version is not None assert state_out.unit_status == testing.ActiveStatus() + + +def test_pause_action(): + """Test that the unit reports being paused after the pause action runs.""" + # Arrange: + ctx = testing.Context({{ class_name }}) + # Act: + state_out = ctx.run(ctx.on.action("pause"), testing.State()) + # Assert: + assert isinstance(state_out.unit_status, testing.MaintenanceStatus) + + +def test_resume_action(): + """Test that the unit is active again after the resume action runs.""" + # Arrange: + ctx = testing.Context({{ class_name }}) + state_in = testing.State(unit_status=testing.MaintenanceStatus("paused")) + # Act: + state_out = ctx.run(ctx.on.action("resume"), state_in) + # Assert: + assert state_out.unit_status == testing.ActiveStatus() From 237745abc8c3a836fd0998a0f2ab69cd3962a896 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 27 Jul 2026 19:58:59 +1200 Subject: [PATCH 2/3] feat(profile): add a restart action to the kubernetes init profile Declare a restart action in charmcraft.yaml and handle it in the charm: restart the Pebble service, wait for the workload to be ready, and set the unit active again. Rather than checking 'can_connect' first, the handler catches ops.pebble.ConnectionError, so there's no window in which the container stops being reachable between the check and the restart. Unit tests cover both the happy path and the unreachable-container failure, which shows how event.fail() surfaces in ops.testing. An integration test runs the action against a deployed unit with jubilant. --- .../init-kubernetes/charmcraft.yaml.j2 | 14 ++++++ .../templates/init-kubernetes/src/charm.py.j2 | 16 +++++++ .../tests/integration/test_charm.py.j2 | 6 +++ .../tests/unit/test_charm.py.j2 | 44 +++++++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/charmcraft/templates/init-kubernetes/charmcraft.yaml.j2 b/charmcraft/templates/init-kubernetes/charmcraft.yaml.j2 index d487d5213..0b1ae17f4 100644 --- a/charmcraft/templates/init-kubernetes/charmcraft.yaml.j2 +++ b/charmcraft/templates/init-kubernetes/charmcraft.yaml.j2 @@ -49,6 +49,20 @@ config: default: "info" type: string +# (Optional) Actions that an operator can run against a deployed unit. +# More information on this section at: +# https://documentation.ubuntu.com/charmcraft/stable/reference/files/charmcraft-yaml-file/#actions +# General actions documentation: +# https://documentation.ubuntu.com/juju/3.6/reference/action/ +actions: + # An example action to restart the workload in the container. + restart: + description: | + Restart the workload service in the container. + + Use this to apply a change that the workload only picks up on startup, + or to recover a workload that has stopped responding. + # Your workload's containers. # https://documentation.ubuntu.com/charmcraft/stable/reference/files/charmcraft-yaml-file/#containers containers: diff --git a/charmcraft/templates/init-kubernetes/src/charm.py.j2 b/charmcraft/templates/init-kubernetes/src/charm.py.j2 index 5fbc7bd1e..f884da3a2 100755 --- a/charmcraft/templates/init-kubernetes/src/charm.py.j2 +++ b/charmcraft/templates/init-kubernetes/src/charm.py.j2 @@ -23,6 +23,7 @@ class {{ class_name }}(ops.CharmBase): def __init__(self, framework: ops.Framework): super().__init__(framework) framework.observe(self.on["some_container"].pebble_ready, self._on_pebble_ready) + framework.observe(self.on["restart"].action, self._on_restart_action) self.container = self.unit.get_container("some-container") def _on_pebble_ready(self, event: ops.PebbleReadyEvent): @@ -51,6 +52,21 @@ class {{ class_name }}(ops.CharmBase): self.unit.set_workload_version(version) self.unit.status = ops.ActiveStatus() + def _on_restart_action(self, event: ops.ActionEvent): + """Handle the restart action.""" + self.unit.status = ops.MaintenanceStatus("restarting workload") + try: + self.container.restart(SERVICE_NAME) + self.wait_for_ready() + except ops.pebble.ConnectionError: + # Pebble isn't running yet, or the container is going away. + # Checking with 'can_connect' before restarting wouldn't help: the + # container can stop being reachable between the check and the restart. + self.unit.status = ops.WaitingStatus("waiting for the workload container") + event.fail("cannot connect to the workload container") + return + self.unit.status = ops.ActiveStatus() + def is_ready(self) -> bool: """Check whether the workload is ready to use.""" # We'll first check whether all Pebble services are running. diff --git a/charmcraft/templates/init-kubernetes/tests/integration/test_charm.py.j2 b/charmcraft/templates/init-kubernetes/tests/integration/test_charm.py.j2 index a120ee1d2..319cc4147 100644 --- a/charmcraft/templates/init-kubernetes/tests/integration/test_charm.py.j2 +++ b/charmcraft/templates/init-kubernetes/tests/integration/test_charm.py.j2 @@ -29,6 +29,12 @@ def test_deploy(charm: pathlib.Path, juju: jubilant.Juju): juju.wait(jubilant.all_active) +def test_restart(charm: pathlib.Path, juju: jubilant.Juju): + """Restart the workload with the restart action.""" + juju.run("{{ name }}/0", "restart") + juju.wait(jubilant.all_active) + + # If you implement {{ workload_module }}.get_version in the charm source, # remove the @pytest.mark.skip line to enable this test. # Alternatively, remove this test if you don't need it. diff --git a/charmcraft/templates/init-kubernetes/tests/unit/test_charm.py.j2 b/charmcraft/templates/init-kubernetes/tests/unit/test_charm.py.j2 index 449362e00..4288166b1 100644 --- a/charmcraft/templates/init-kubernetes/tests/unit/test_charm.py.j2 +++ b/charmcraft/templates/init-kubernetes/tests/unit/test_charm.py.j2 @@ -93,3 +93,47 @@ def test_pebble_ready_service_not_ready(): # Act & assert: with pytest.raises(testing.errors.UncaughtCharmError): ctx.run(ctx.on.pebble_ready(container_in), state_in) + + +def test_restart_action(): + """Test that the restart action starts the service and leaves the unit active.""" + # Arrange: + ctx = testing.Context({{ class_name }}) + check_in = testing.CheckInfo( + CHECK_NAME, + level=ops.pebble.CheckLevel.READY, + status=ops.pebble.CheckStatus.UP, # Simulate the Pebble check passing. + ) + container_in = testing.Container( + "some-container", + can_connect=True, + layers={"base": MOCK_LAYER}, + service_statuses={SERVICE_NAME: ops.pebble.ServiceStatus.INACTIVE}, + check_infos={check_in}, + ) + state_in = testing.State(containers={container_in}) + + # Act: + state_out = ctx.run(ctx.on.action("restart"), state_in) + + # Assert: + container_out = state_out.get_container(container_in.name) + assert container_out.service_statuses[SERVICE_NAME] == ops.pebble.ServiceStatus.ACTIVE + assert state_out.unit_status == testing.ActiveStatus() + + +def test_restart_action_container_not_ready(): + """Test that the restart action fails when the container can't be reached.""" + # Arrange: + ctx = testing.Context({{ class_name }}) + container_in = testing.Container("some-container", can_connect=False) + state_in = testing.State(containers={container_in}) + + # Act & assert: + with pytest.raises(testing.ActionFailed) as exc_info: + ctx.run(ctx.on.action("restart"), state_in) + assert exc_info.value.message == "cannot connect to the workload container" + assert exc_info.value.state is not None + assert exc_info.value.state.unit_status == testing.WaitingStatus( + "waiting for the workload container" + ) From cdd0e5c269f9d31d1c4f76f596c1182fdee2c4fc Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Mon, 27 Jul 2026 20:23:47 +1200 Subject: [PATCH 3/3] docs: add 4.5 release notes entry for the profile example actions --- docs/release-notes/charmcraft-4.5.rst | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/release-notes/charmcraft-4.5.rst b/docs/release-notes/charmcraft-4.5.rst index 246d8448c..eacb669c6 100644 --- a/docs/release-notes/charmcraft-4.5.rst +++ b/docs/release-notes/charmcraft-4.5.rst @@ -54,6 +54,17 @@ Minor features Charmcraft 4.5 brings the following minor changes. +Example actions in the machine and Kubernetes profiles +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Charms created with the ``machine`` profile now declare ``pause`` and ``resume`` +actions, which stop and start the workload without removing the unit. Charms created +with the ``kubernetes`` profile now declare a ``restart`` action, which restarts the +workload's Pebble service. + +Both profiles scaffold unit and integration tests for their actions. + + ~~~~~~~~~~~