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" + ) 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() 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. + + ~~~~~~~~~~~