From dea45c1f94e13786745f4a3b56028699cc64b935 Mon Sep 17 00:00:00 2001 From: Tony Meyer Date: Thu, 19 Dec 2024 17:12:52 +1300 Subject: [PATCH] docs: update how-to guides to use the new unit testing framework (#1507) Adjustments to the how-to guides: * Links to the API reference are updated to the new location. I assume that these can be references that will automatically resolve somehow, but I'm not sure how to do that, so I opened #1506 and just fixed them as full links for now (at least they'll work again). * A few minor whitespace cleanups. * Removed all the Harness sections, moving the Scenario sections up to be "unit tests" * Updated the Scenario sections to use ops.testing and Scenario 7 * Updated the secrets how-to to be more explicit about needing to remove revisions if you create new ones. [Live preview](https://ops--1507.org.readthedocs.build/en/1507/) --- docs/howto/index.md | 4 +- docs/howto/manage-actions.md | 70 +++---- docs/howto/manage-configurations.md | 46 +---- docs/howto/manage-leadership-changes.md | 50 +---- docs/howto/manage-libraries.md | 111 ++++++----- docs/howto/manage-logs.md | 4 +- docs/howto/manage-relations.md | 78 ++------ docs/howto/manage-resources.md | 8 +- docs/howto/manage-secrets.md | 46 ++--- docs/howto/manage-storage.md | 80 +++----- docs/howto/manage-the-charm-version.md | 1 - docs/howto/manage-the-workload-version.md | 54 ++--- .../run-workloads-with-a-charm-kubernetes.md | 184 ++++++++---------- 13 files changed, 254 insertions(+), 482 deletions(-) diff --git a/docs/howto/index.md b/docs/howto/index.md index 8269419a2..2da6e5f66 100644 --- a/docs/howto/index.md +++ b/docs/howto/index.md @@ -19,9 +19,9 @@ Manage secrets Manage the charm version Manage the workload version Get started with charm testing -Write unit tests for a charm -Write scenario tests for a charm +Write unit tests for a charm Write integration tests for a charm +Write legacy unit tests for a charm Turn a hooks-based charm into an ops charm ``` diff --git a/docs/howto/manage-actions.md b/docs/howto/manage-actions.md index c9e8ed5f2..c4c03c05b 100644 --- a/docs/howto/manage-actions.md +++ b/docs/howto/manage-actions.md @@ -11,6 +11,7 @@ To tell users what actions can be performed on the charm, define an `actions` section in `charmcraft.yaml` that lists the actions and information about each action. The actions should include a short description that explains what running the action will do. Normally, all parameters that can be passed to the action are also included here, including the type of parameter and any default value. You can also specify that some parameters are required when the action is run. For example: + ```yaml actions: snapshot: @@ -41,8 +42,6 @@ actions: additionalProperties: false ``` - - ### Observe the action event and define an event handler In the `src/charm.py` file, in the `__init__` function of your charm, set up an observer for the action event associated with your action and pair that with an event handler. For example: @@ -51,10 +50,9 @@ In the `src/charm.py` file, in the `__init__` function of your charm, set up an self.framework.observe(self.on.grant_admin_role_action, self._on_grant_admin_role_action) ``` - Now, in the body of the charm definition, define the action event handler. For example: -``` +```python def _on_grant_admin_role_action(self, event): """Handle the grant-admin-role action.""" # Fetch the user parameter from the ActionEvent params dict @@ -77,7 +75,6 @@ def _on_grant_admin_role_action(self, event): More detail below: - #### Use action params To make use of action parameters, either ones that the user has explicitly passed, or default values, use the `params` attribute of the event object that is passed to the handler. This is a dictionary of parameter name (string) to parameter value. For example: @@ -87,8 +84,8 @@ def _on_snapshot(self, event: ops.ActionEvent): filename = event.params["filename"] ... ``` -> See more: [`ops.ActionEvent.params`](https://ops.readthedocs.io/en/latest/#ops.ActionEvent.params) +> See more: [`ops.ActionEvent.params`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.ActionEvent.params) #### Report that an action has failed @@ -108,22 +105,24 @@ def _on_snapshot(self, event: ops.ActionEvent): ... ``` -> See more: [`ops.ActionEvent.fail`](https://ops.readthedocs.io/en/latest/#ops.ActionEvent.fail) +> See more: [`ops.ActionEvent.fail`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.ActionEvent.fail) #### Return the results of an action To pass back the results of an action to the user, use the `set_results` method of the action event. These will be displayed in the `juju run` output. For example: + ```python def _on_snapshot(self, event: ops.ActionEvent): size = self.do_snapshot(event.params['filename']) event.set_results({'snapshot-size': size}) ``` -> See more: [`ops.ActionEvent.set_results`](https://ops.readthedocs.io/en/latest/#ops.ActionEvent.set_results) +> See more: [`ops.ActionEvent.set_results`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.ActionEvent.set_results) #### Log the progress of an action In a long-running action, to give the user updates on progress, use the `.log()` method of the action event. This is sent back to the user, via Juju, in real-time, and appears in the output of the `juju run` command. For example: + ```python def _on_snapshot(self, event: ops.ActionEvent): event.log('Starting snapshot') @@ -133,12 +132,13 @@ def _on_snapshot(self, event: ops.ActionEvent): event.log('Table2 complete') self.snapshot_table3() ``` -> See more: [`ops.ActionEvent.log`](https://ops.readthedocs.io/en/latest/#ops.ActionEvent.log) +> See more: [`ops.ActionEvent.log`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.ActionEvent.log)) #### Record the ID of an action task When a unique ID is needed for the action task - for example, for logging or creating temporary files, use the `.id` attribute of the action event. For example: + ```python def _on_snapshot(self, event: ops.ActionEvent): temp_filename = f'backup-{event.id}.tar.gz' @@ -146,8 +146,7 @@ def _on_snapshot(self, event: ops.ActionEvent): self.create_backup(temp_filename) ... ``` -> See more: [`ops.ActionEvent.id`](https://ops.readthedocs.io/en/latest/#ops.ActionEvent.id) - +> See more: [`ops.ActionEvent.id`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.ActionEvent.id) ## Test the feature @@ -155,53 +154,29 @@ def _on_snapshot(self, event: ops.ActionEvent): What you need to do depends on what kind of tests you want to write. - ### Write unit tests -> See first: {ref}`write-unit-tests-for-a-charm` - -When using Harness for unit tests, use the `run_action` method to verify that charm actions have the expected behaviour. This method will either raise an `ActionFailed` exception (if the charm used the `event.fail()` method) or return an `ActionOutput` object. These can be used to verify the failure message, logs, and results of the action. For example: - -```python -def test_backup_action(): - harness = ops.testing.Harness() - harness.begin() - try: - out = harness.run_action('snapshot', {'filename': 'db-snapshot.tar.gz'}) - except ops.testing.ActionFailed as e: - assert "Could not backup because" in e.message - else: - assert out.logs == ['Starting snapshot', 'Table1 complete', 'Table2 complete'] - assert 'snapshot-size' in out.results - finally: - harness.cleanup() - - -``` - -> See more: [`ops.testing.Harness.run_action`](https://ops.readthedocs.io/en/latest/#ops.testing.Harness.run_action) - - -### Write scenario tests > See first: {ref}`write-scenario-tests-for-a-charm` -When using Scenario for unit tests, to verify that the charm state is as expected after executing an action, use the `run_action` method of the Scenario `Context` object. The method returns an `ActionOutput` object that contains any logs and results that the charm set. +To verify that the charm state is as expected after executing an action, use the `run` method of the `Context` object, with `ctx.on.action`. The context contains any logs and results that the charm set. + For example: + ```python +from ops import testing + def test_backup_action(): - action = scenario.Action('snapshot', params={'filename': 'db-snapshot.tar.gz'}) - ctx = scenario.Context(MyCharm) - out = ctx.run_action(action, scenario.State()) - assert out.logs == ['Starting snapshot', 'Table1 complete', 'Table2 complete'] - if out.success: - assert 'snapshot-size' in out.results - else: - assert 'Failed to run' in out.failure + ctx = testing.Context(MyCharm) + ctx.run(ctx.on.action('snapshot', params={'filename': 'db-snapshot.tar.gz'}), testing.State()) + assert ctx.action_logs == ['Starting snapshot', 'Table1 complete', 'Table2 complete'] + assert 'snapshot-size' in ctx.action_results ``` -> See more: [Scenario action testing](https://github.com/canonical/ops-scenario?tab=readme-ov-file#actions) + +> See more: [`Context.action_logs`](https://ops.readthedocs.io/en/latest/reference/ops-testing.html#ops.testing.Context.action_logs), [`Context.action_results`](https://ops.readthedocs.io/en/latest/reference/ops-testing.html#ops.testing.Context.action_results), [`ActionFailed`](https://ops.readthedocs.io/en/latest/reference/ops-testing.html#ops.testing.ActionFailed) ### Write integration tests + > See first: {ref}`write-integration-tests-for-a-charm` To verify that an action works correctly against a real Juju instance, write an integration test with `pytest_operator`. For example: @@ -215,4 +190,3 @@ async def test_logger(ops_test): assert action.status == 'completed' assert action.results['snapshot-size'].isdigit() ``` - diff --git a/docs/howto/manage-configurations.md b/docs/howto/manage-configurations.md index d80854f85..ab8a6b854 100644 --- a/docs/howto/manage-configurations.md +++ b/docs/howto/manage-configurations.md @@ -25,7 +25,6 @@ config: type: string ``` - ### Observe the `config-changed` event and define the event handler In the `src/charm.py` file of the charm project, in the `__init__` function of the charm, set up an observer for the config changed event and pair that with an event handler: @@ -47,14 +46,14 @@ def _on_config_changed(self, event): logger.debug("New application port is requested: %s", port) self._update_layer_and_restart(None) ``` -> See more: [`ops.CharmBase.config`](https://ops.readthedocs.io/en/latest/#ops.CharmBase.config) + +> See more: [`ops.CharmBase.config`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.CharmBase.config) ```{caution} - Multiple configuration values can be changed at one time through Juju, resulting in only one `config_changed` event. Thus, your charm code must be able to process more than one config value changing at a time. - If `juju config` is run with values the same as the current configuration, the `config_changed` event will not run. Therefore, if you have a single config value, there is no point in tracking its previous value -- the event will only be triggered if the value changes. - Configuration cannot be changed from within the charm code. Charms, by design, aren't able to mutate their own configuration by themselves (e.g., in order to ignore an admin-provided configuration), or to configure other applications. In Ops, one typically interacts with config via a read-only facade. - ``` ### (If applicable) Update and restart the Pebble layer @@ -69,56 +68,27 @@ https://github.com/canonical/juju-sdk-tutorial-k8s/compare/01_create_minimal_cha > See first: {ref}`get-started-with-charm-testing` -You'll want to add two levels of tests: unit and scenario. - ### Write unit tests > See first: {ref}`write-unit-tests-for-a-charm` -To use a unit test to verify that the configuration change is handled correct, the test needs to trigger the `config-changed` event and then check that the update method was called. In your `tests/unit/test_charm.py` file, add the following test functions to the file: +To verify that the `config-changed` event validates the port, pass the new config to the `State`, and, after running the event, check the unit status. For example, in your `tests/unit/test_charm.py` file, add the following test function: ```python -def test_invalid_port_configuration(): - harness = ops.testing.Harness() - harness.begin() - - harness.update_config({"server-port": 22}) - assert isinstance(harness.model.unit.status, ops.BlockedStatus) - -def test_port_configuration(monkeypatch): - update_called = False - def mock_update(*args): - update_called = True - monkeypatch.setattr(MyCharm, "_update_layer_and_restart", mock_update) - - harness = ops.testing.Harness() - harness.begin() - - harness.update_config({"server-port": 8080}) +from ops import testing - assert update_called -``` - -### Write scenario tests - -> See first: {ref}`write-scenario-tests-for-a-charm` - -To use a Scenario test to verify that the `config-changed` event validates the port, pass the new config to the `State`, and, after running the event, check the unit status. For example, in your `tests/scenario/test_charm.py` file, add the following test function: - -```python def test_open_port(): - ctx = scenario.Context(MyCharm) + ctx = testing.Context(MyCharm) - state_out = ctx.run("config_changed", scenario.State(config={"server-port": 22})) + state_out = ctx.run(ctx.on.config_changed(), testing.State(config={"server-port": 22})) - assert isinstance(state_out.unit_status, ops.BlockedStatus) + assert isinstance(state_out.unit_status, testingZ.BlockedStatus) ``` -### Test-deploy +### Manually test To verify that the configuration option works as intended, pack your charm, update it in the Juju model, and run `juju config` followed by the name of the application deployed by your charm and then your newly defined configuration option key set to some value. For example, given the `server-port` key defined above, you could try: ```text juju config server-port=4000 ``` - diff --git a/docs/howto/manage-leadership-changes.md b/docs/howto/manage-leadership-changes.md index 1546f056b..d2f48065d 100644 --- a/docs/howto/manage-leadership-changes.md +++ b/docs/howto/manage-leadership-changes.md @@ -15,7 +15,7 @@ In the `src/charm.py` file, in the `__init__` function of your charm, set up an self.framework.observe(self.on.leader_elected, self._on_leader_elected) ``` -> See more: [`ops.LeaderElectedEvent`](https://ops.readthedocs.io/en/latest/#ops.LeaderElectedEvent) +> See more: [`ops.LeaderElectedEvent`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.LeaderElectedEvent) Now, in the body of the charm definition, define the event handler. For example, the handler below will update a configuration file: @@ -52,48 +52,11 @@ event or an `is-leader` check. If the charm code may run longer, then extra > See first: {ref}`get-started-with-charm-testing` - ### Write unit tests -> See first: {ref}`write-unit-tests-for-a-charm` - -When using Harness for unit tests, use the `set_leader()` method to control whether the unit is the leader. For example, to verify that leadership change is handled correctly: - -```python -@pytest.fixture() -def harness(): - yield ops.testing.Harness(MyCharm) - harness.cleanup() - - -def test_new_leader(harness): - # Before the test, the unit is not leader. - harness.set_leader(False) - harness.begin() - # Simulate Juju electing the unit as leader. - harness.set_leader(True) - # Assert that it was handled correctly. - assert ... - - -def test_leader_sets_secrets(harness): - # The unit is the leader throughout the test, and no leader-elected event - # is emitted. - harness.set_leader(True) - harness.begin() - secret_id = harness.add_model_secret(APP_NAME, content={"secret": "sssh"}) - harness.update_config(secret_option=secret_id) - # Assert that the config-changed handler set additional secret metadata: - assert ... -``` - -> See more: [`ops.testing.Harness.set_leader`](https://ops.readthedocs.io/en/latest/harness.html#ops.testing.Harness.set_leader) - -## Write scenario tests - > See first: {ref}`write-scenario-tests-for-a-charm` -When using Scenario for unit tests, pass the leadership status to the `State`. For example: +To verify behaviour when leadership has changed, pass the leadership status to the `State`. For example: ```python class MyCharm(ops.CharmBase): @@ -110,12 +73,11 @@ class MyCharm(ops.CharmBase): @pytest.mark.parametrize('leader', (True, False)) def test_status_leader(leader): - ctx = scenario.Context(MyCharm, meta={"name": "foo"}) - out = ctx.run('start', scenario.State(leader=leader)) - assert out.unit_status == ops.ActiveStatus('I rule' if leader else 'I am ruled') + ctx = testing.Context(MyCharm, meta={"name": "foo"}) + out = ctx.run(ctx.on.start(), testing.State(leader=leader)) + assert out.unit_status == testing.ActiveStatus('I rule' if leader else 'I am ruled') ``` - ## Write integration tests > See first: {ref}`write-integration-tests-for-a-charm` @@ -146,5 +108,3 @@ async def get_leader_unit(ops_test, app, model=None): > Examples: [Zookeeper testing upgrades](https://github.com/canonical/zookeeper-operator/blob/106f9c2cd9408a172b0e93f741d8c9f860c4c38e/tests/integration/test_upgrade.py#L22), [postgresql testing password rotation action](https://github.com/canonical/postgresql-k8s-operator/blob/62645caa89fd499c8de9ac3e5e9598b2ed22d619/tests/integration/test_password_rotation.py#L38) > See more: [`juju.unit.Unit.is_leader_from_status`](https://pythonlibjuju.readthedocs.io/en/latest/api/juju.unit.html#juju.unit.Unit.is_leader_from_status) - - diff --git a/docs/howto/manage-libraries.md b/docs/howto/manage-libraries.md index 11d04c0f2..436a8ed7a 100644 --- a/docs/howto/manage-libraries.md +++ b/docs/howto/manage-libraries.md @@ -32,9 +32,9 @@ class DatabaseRequirer(ops.framework.Object): ``` -## Write tests for a library with Scenario +## Write tests for a library -In this guide we will go through how to write Scenario tests for a charm library we are developing: +In this guide we will go through how to write tests for a charm library we are developing: `/lib/charms/my_charm/v0/my_lib.py` @@ -45,16 +45,15 @@ The requirer side library does not interact with any lifecycle event; it only li Assuming you have a library file already set up and ready to go (see `charmcraft create-lib` otherwise), you now need to -`pip install ops-scenario` and create a test file in `/tests/scenario/test_my_lib.py` - +`pip install ops[testing]` and create a test file in `/tests/unit/test_my_lib.py` ### Base test ```python -# `/tests/scenario/test_my_lib.py` +# `/tests/unit/test_my_lib.py` import pytest import ops -from scenario import Context, State +from ops import testing from lib.charms.my_Charm.v0.my_lib import MyObject class MyTestCharm(ops.CharmBase): @@ -72,31 +71,30 @@ class MyTestCharm(ops.CharmBase): @pytest.fixture def context(): - return Context(MyTestCharm, meta=MyTestCharm.META) + return testing.Context(MyTestCharm, meta=MyTestCharm.META) @pytest.mark.parametrize('event', ( 'start', 'install', 'stop', 'remove', 'update-status', #... )) def test_charm_runs(context, event): - """Verify that MyObject can initialize and process any event except relation events.""" - # arrange - state_in = State() - # act - context.run(event, state_in) + """Verify that MyObject can initialise and process any event except relation events.""" + # Arrange: + state_in = testing.State() + # Act: + context.run(getattr(context.on, event), state_in) ``` ### Simple use cases #### Relation endpoint wrapper lib -If `MyObject` is a relation endpoint wrapper such as [`traefik's ingress-per-unit`](https://github.com/canonical/traefik-k8s-operator/blob/main/lib/charms/traefik_k8s/v1/ingress_per_unit.py) lib, -a frequent pattern is to allow customizing the name of the endpoint that the object is wrapping. We can write a scenario test like so: +If `MyObject` is a relation endpoint wrapper such as [`traefik's ingress-per-unit`](https://github.com/canonical/traefik-k8s-operator/blob/main/lib/charms/traefik_k8s/v1/ingress_per_unit.py) lib, a frequent pattern is to allow customizing the name of the endpoint that the object is wrapping. We can write a test like so: ```python -# `/tests/scenario/test_my_lib.py` +# `/tests/unit/test_my_lib.py` import pytest import ops -from scenario import Context, State, Relation +from ops import testing from lib.charms.my_Charm.v0.my_lib import MyObject @@ -127,27 +125,32 @@ def my_charm_type(endpoint): @pytest.fixture def context(my_charm_type): - return Context(my_charm_type, meta=my_charm_type.META) + return testing.Context(my_charm_type, meta=my_charm_type.META) def test_charm_runs(context): """Verify that the charm executes regardless of how we name the requirer endpoint.""" - # arrange - state_in = State() - # act - context.run('start', state_in) + # Arrange: + state_in = testing.State() + # Act: + context.run(context.on.start(), state_in) @pytest.mark.parametrize('n_relations', (1, 2, 7)) def test_charm_runs_with_relations(context, endpoint, n_relations): """Verify that the charm executes when there are one or more relations on the endpoint.""" - # arrange - state_in = State(relations=[ - Relation(endpoint=endpoint, interface='my-interface', remote_app_name=f"remote_{n}") for n in range(n_relations) - ]) - # act - state_out = context.run('start', state_in) - # assert + # Arrange: + state_in = testing.State(relations={ + testing.Relation( + endpoint=endpoint, + interface='my-interface', + remote_app_name=f"remote_{n}", + ) + for n in range(n_relations + }) + # Act: + state_out = context.run(context.on.start(), state_in) + # Assert: for relation in state_out.relations: assert not relation.local_app_data # remote side didn't publish any data. @@ -155,14 +158,19 @@ def test_charm_runs_with_relations(context, endpoint, n_relations): @pytest.mark.parametrize('n_relations', (1, 2, 7)) def test_relation_changed_behaviour(context, endpoint, n_relations): """Verify that the charm lib does what it should on relation changed.""" - # arrange - relations = [Relation( - endpoint=endpoint, interface='my-interface', remote_app_name=f"remote_{n}", - remote_app_data={"foo": f"my-data-{n}"} - ) for n in range(n_relations)] - state_in = State(relations=relations) + # Arrange: + relations = { + Relation( + endpoint=endpoint, + interface='my-interface', + remote_app_name=f"remote_{n}", + remote_app_data={"foo": f"my-data-{n}"}, + ) + for n in range(n_relations) + } + state_in = testing.State(relations=relations) # act - state_out: State = context.run(relations[0].changed_event, state_in) + state_out: testing.State = context.run(context.on.relation_changed(relations[0]), state_in) # assert for relation in state_out.relations: assert relation.local_app_data == {"collation": ';'.join(f"my-data-{n}" for n in range(n_relations))} @@ -173,30 +181,35 @@ def test_relation_changed_behaviour(context, endpoint, n_relations): #### Testing internal (charm-facing) library APIs Suppose that `MyObject` has a `data` method that exposes to the charm a list containing the remote databag contents (the `my-data-N` we have seen above). -We can use `scenario.Context.manager` to run code within the lifetime of the Context like so: +We can use `Context` as a context manager to run code within the lifetime of the Context like so: ```python import pytest import ops -from scenario import Context, State, Relation +from ops import testing from lib.charms.my_Charm.v0.my_lib import MyObject @pytest.mark.parametrize('n_relations', (1, 2, 7)) def test_my_object_data(context, endpoint, n_relations): """Verify that the charm lib does what it should on relation changed.""" - # arrange - relations = [Relation( - endpoint=endpoint, interface='my-interface', remote_app_name=f"remote_{n}", - remote_app_data={"foo": f"my-data-{n}"} - ) for n in range(n_relations)] - state_in = State(relations=relations) + # Arrange: + relations = { + Relation( + endpoint=endpoint, + interface='my-interface', + remote_app_name=f"remote_{n}", + remote_app_data={"foo": f"my-data-{n}"}, + ) + for n in range(n_relations) + } + state_in = testing.State(relations=relations) - with context.manager(relations[0].changed_event, state_in) as mgr: - # act + with context(context.on.relation_changed(relations[0]), state_in) as mgr: + # Act: state_out = mgr.run() # this will emit the event on the charm - # control is handed back to us before ops is torn down + # Control is handed back to us before ops is torn down. - # assert + # Assert: charm = mgr.charm # the MyTestCharm instance ops is working with obj: MyObject = charm.obj assert obj.data == [ @@ -204,10 +217,8 @@ def test_my_object_data(context, endpoint, n_relations): ] ``` - ## Use a library - Fetch the library. -In your charm's `src/charm.py` file, use Ops to fetch the path to the resource and then manipulate it as needed. +In your charm's `src/charm.py` file, use `ops` to fetch the path to the resource and then manipulate it as needed. For example, suppose your `charmcraft.yaml` file contains this simple resource definition: @@ -23,7 +23,7 @@ resources: description: test resource ``` -In your charm's `src/charm.py` you can now use [`Model.resources.fetch()`](https://ops.readthedocs.io/en/latest/#ops.Resources.fetch) to get the path to the resource, then manipulate it as needed. For example: +In your charm's `src/charm.py` you can now use [`Model.resources.fetch()`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Resources.fetch) to get the path to the resource, then manipulate it as needed. For example: ```python # ... @@ -59,8 +59,7 @@ def _on_config_changed(self, event): # do something ``` -The [`fetch()`](https://ops.readthedocs.io/en/latest/#ops.Resources.fetch) method will raise a [`NameError`](https://docs.python.org/3/library/exceptions.html#NameError) if the resource does not exist, and returns a Python [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) object to the resource if it does. - +The [`fetch()`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Resources.fetch) method will raise a [`NameError`](https://docs.python.org/3/library/exceptions.html#NameError) if the resource does not exist, and returns a Python [`Path`](https://docs.python.org/3/library/pathlib.html#pathlib.Path) object to the resource if it does. Note: During development, it may be useful to specify the resource at deploy time to facilitate faster testing without the need to publish a new charm/resource in between minor fixes. In the below snippet, we create a simple file with some text content, and pass it to the Juju controller to use in place of any published `my-resource` resource: @@ -69,4 +68,3 @@ echo "TEST" > /tmp/somefile.txt charmcraft pack juju deploy ./my-charm.charm --resource my-resource=/tmp/somefile.txt ``` - diff --git a/docs/howto/manage-secrets.md b/docs/howto/manage-secrets.md index 4c304f3a5..0ea003e84 100644 --- a/docs/howto/manage-secrets.md +++ b/docs/howto/manage-secrets.md @@ -5,7 +5,7 @@ > See first: [`juju` | Secret](https://juju.is/docs/juju/secret), [`juju` | Manage secrets](https://juju.is/docs/juju/manage-secrets), [`charmcraft` | Manage secrets]() --> -> Added in `ops 2.0.0`, `juju 3.0.2` +> Added in `Juju 3.0.2` This document shows how to use secrets in a charm -- both when the charm is the secret owner as well as when it is merely an observer. @@ -57,13 +57,10 @@ Note that: - The only data shared in plain text is the secret ID (a locator URI). The secret ID can be publicly shared. Juju will ensure that only remote apps/units to which the secret has explicitly been granted by the owner will be able to fetch the actual secret payload from that ID. - The secret needs to be granted to a remote entity (app or unit), and that always goes via a relation instance. By passing a relation to `grant` (in this case the event's relation), we are explicitly declaring the scope of the secret -- its lifetime will be bound to that of this relation instance. -> See more: [`ops.Application.add_secret()`](https://ops.readthedocs.io/en/latest/#ops.Application.add_secret), - - +> See more: [`ops.Application.add_secret()`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Application.add_secret) ### Create a new secret revision - To create a new secret revision, the owner charm must call `secret.set_content()` and pass in the new payload: ```python @@ -81,12 +78,14 @@ class MyDatabaseCharm(ops.CharmBase): This will inform Juju that a new revision is available, and Juju will inform all observers tracking older revisions that a new one is available, by means of a `secret-changed` hook. +```{caution} +If your charm creates new revisions, it **must** also add a handler for the `secret-remove` event, and call `remove_revision` in it. If not, old revisions will continually build up in the secret backend. See more: {ref}`howto-remove-a-secret` +``` ### Change the rotation policy or the expiration date of a secret Typically you want to rotate a secret periodically to contain the damage from a leak, or to avoid giving hackers too much time to break the encryption. - A charm can configure a secret, at creation time, to have one or both of: - A rotation policy (weekly, monthly, daily, and so on). @@ -144,12 +143,9 @@ class MyDatabaseCharm(ops.CharmBase): self._rotate_webserver_secret(event.secret) ``` - - - +(howto-remove-a-secret)= ### Remove a secret - To remove a secret (effectively destroying it for good), the owner needs to call `secret.remove_all_revisions`. Regardless of the logic leading to the decision of when to remove a secret, the code will look like some variation of the following: ```python @@ -164,10 +160,9 @@ class MyDatabaseCharm(ops.CharmBase): After this is called, the observer charm will get a `ModelError` whenever it attempts to get the secret. In general, the presumption is that the observer charm will take the absence of the relation as indication that the secret is gone as well, and so will not attempt to get it. - ### Remove a single secret revision -Removing a single secret revision is a more common (and less drastic!) operation than removing all revisions. +Removing a single secret revision is a more common (and less drastic!) operation than removing all revisions. If your charm creates new revisions of secrets, it **must** implement a `secret-remove` handler that calls `remove_revision`. Typically, the owner will remove a secret revision when it receives a `secret-remove` event -- that is, when that specific revision is no longer tracked by any observer. If a secret owner did remove a revision while it was still being tracked by observers, they would get a `ModelError` when they tried to get the secret. @@ -184,16 +179,16 @@ class MyDatabaseCharm(ops.CharmBase): self._on_secret_remove) def _on_secret_remove(self, event: ops.SecretRemoveEvent): - # all observers are done with this revision, remove it + # All observers are done with this revision, remove it: event.secret.remove_revision(event.revision) ``` - ### Revoke a secret For whatever reason, the owner of a secret can decide to revoke access to the secret to a remote entity. That is done by calling `secret.revoke`, and is the inverse of `secret.grant`. An example of usage might look like: + ```python class MyDatabaseCharm(ops.CharmBase): @@ -207,7 +202,6 @@ class MyDatabaseCharm(ops.CharmBase): Just like when the owner granted the secret, we need to pass a relation to the `revoke` call, making it clear what scope this action is to be applied to. - ## Secret observer charm > This applies to both charm and user secrets, though for user secrets the story starts with the charm defining a configuration option of type `secret`, and the secret is not acquired through relation data but rather by the configuration option being set to the secret's URI. @@ -255,12 +249,10 @@ Note that: - The observer charm gets a secret via the model (not its app/unit). Because it's the owner who decides who the secret is granted to, the ownership of a secret is not an observer concern. The observer code can rightfully assume that, so long as a secret ID is shared with it, the owner has taken care to grant and scope the secret in such a way that the observer has the rights to inspect its contents. - The charm first gets the secret object from the model, then gets the secret's content (a dict) and accesses individual attributes via the dict's items. - -> See more: [`ops.Secret.get_content()`](https://ops.readthedocs.io/en/latest/#ops.Secret.get_content) +> See more: [`ops.Secret.get_content()`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Secret.get_content) ### Label the secrets you're observing - Sometimes a charm will observe multiple secrets. In the `secret-changed` event handler above, you might ask yourself: How do I know which secret has changed? The answer lies with **secret labels**: a label is a charm-local name that you can assign to a secret. Let's go through the following code: @@ -318,7 +310,7 @@ So, having labelled the secret on creation, the database charm could add a new r secret.set_content(...) # pass a new revision payload, as before ``` -> See more: [`ops.Model.get_secret()`](https://ops.readthedocs.io/en/latest/#ops.Model.get_secret) +> See more: [`ops.Model.get_secret()`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Model.get_secret) #### When to use labels @@ -326,12 +318,9 @@ When should you use labels? A label is basically the secret's *name* (local to t Most charms that use secrets have a fixed number of secrets each with a specific meaning, so the charm author should give them meaningful labels like `database-credential`, `tls-cert`, and so on. Think of these as "pets" with names. -In rare cases, however, a charm will have a set of secrets all with the same meaning: for example, a set of TLS certificates that are all equally valid. In this case it doesn't make sense to label them -- think of them as "cattle". To distinguish between secrets of this kind, you can use the [`Secret.unique_identifier`](https://ops.readthedocs.io/en/latest/#ops.Secret.unique_identifier) property, added in ops 2.6.0. - -Note that [`Secret.id`](https://ops.readthedocs.io/en/latest/#ops.Secret.id), despite the name, is not really a unique ID, but a locator URI. We call this the "secret ID" throughout Juju and in the original secrets specification -- it probably should have been called "uri", but the name stuck. - - +In rare cases, however, a charm will have a set of secrets all with the same meaning: for example, a set of TLS certificates that are all equally valid. In this case it doesn't make sense to label them -- think of them as "cattle". To distinguish between secrets of this kind, you can use the [`Secret.unique_identifier`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Secret.unique_identifier) property. +Note that [`Secret.id`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Secret.id), despite the name, is not really a unique ID, but a locator URI. We call this the "secret ID" throughout Juju and in the original secrets specification -- it probably should have been called "uri", but the name stuck. ### Peek at a new secret revision @@ -347,12 +336,10 @@ Sometimes, before reconfiguring to use a new credential revision, the observer c ... ``` -> See more: [`ops.Secret.peek_content()`](https://ops.readthedocs.io/en/latest/#ops.Secret.peek_content) - +> See more: [`ops.Secret.peek_content()`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Secret.peek_content) ### Start tracking a different secret revision - To update to a new revision, the web server charm will typically subscribe to the `secret-changed` event and call `get_content` with the "refresh" argument set (refresh asks Juju to start tracking the latest revision for this observer). ```python @@ -369,10 +356,7 @@ class MyWebserverCharm(ops.CharmBase): self._configure_db_credentials(content['username'], content['password']) ``` - -> See more: [`ops.Secret.get_content()`](https://ops.readthedocs.io/en/latest/#ops.Secret.get_content) - - +> See more: [`ops.Secret.get_content()`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Secret.get_content)
diff --git a/docs/howto/manage-storage.md b/docs/howto/manage-storage.md index 6cb867ebc..5cf2686f4 100644 --- a/docs/howto/manage-storage.md +++ b/docs/howto/manage-storage.md @@ -43,8 +43,6 @@ containers: location: /var/cache ``` - - ### Observe the `storage-attached` event and define an event handler In the `src/charm.py` file, in the `__init__` function of your charm, set up an observer for the `storage-attached` event associated with your storage and pair that with an event handler, typically a holistic one. For example: @@ -53,7 +51,7 @@ In the `src/charm.py` file, in the `__init__` function of your charm, set up an self.framework.observe(self.on.cache_storage_attached, self._update_configuration) ``` -> See more: [`ops.StorageAttachedEvent`](https://ops.readthedocs.io/en/latest/#ops.StorageAttachedEvent), [Juju SDK | Holistic vs delta charms](https://juju.is/docs/sdk/holistic-vs-delta-charms) +> See more: [`ops.StorageAttachedEvent`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.StorageAttachedEvent), [Juju SDK | Holistic vs delta charms](https://juju.is/docs/sdk/holistic-vs-delta-charms) Storage volumes will be automatically mounted into the charm container at either the path specified in the `location` field in the metadata, or the default location `/var/lib/juju/storage/`. However, your charm code should not hard-code the location, and should instead use the `.location` property of the storage object. @@ -85,7 +83,7 @@ In the `src/charm.py` file, in the `__init__` function of your charm, set up an self.framework.observe(self.on.cache_storage_detaching, self._on_storage_detaching) ``` -> See more: [`ops.StorageDetachingEvent`](https://ops.readthedocs.io/en/latest/#ops.StorageDetachingEvent) +> See more: [`ops.StorageDetachingEvent`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.StorageDetachingEvent) Now, in the body of the charm definition, define the event handler, or adjust an existing holistic one. For example, to warn users that data won't be cached: @@ -102,7 +100,6 @@ def _on_storage_detaching(self, event: ops.StorageDetachingEvent): ```{note} Juju only supports adding multiple instances of the same storage volume on machine charms. Kubernetes charms may only have a single instance of each volume. - ``` If the charm needs additional units of a storage, it can request that with the `storages.request` @@ -131,59 +128,25 @@ has attached the new storage. > See first: {ref}`get-started-with-charm-testing` -You'll want to add three levels of tests: +You'll want to add unit and integration tests: ### Write unit tests -> See first: {ref}`write-unit-tests-for-a-charm` - -When using Harness for unit tests, use the `add_storage()` method to simulate Juju adding storage to the charm. You can either have the method also simulate attaching the storage, or do that explicitly with the `attach_storage()` method. In this example, we verify that the charm responds as expected to storage attached and detaching events: - -```python -@pytest.fixture() -def harness(): - yield ops.testing.Harness(MyCharm) - harness.cleanup() - - -def test_storage_attached(harness): - # Add one instance of the expected storage to the charm. This is before `.begin()` is called, - # so will not trigger any events. - storage_id = harness.add_storage("cache", 1) - harness.begin() - # Simulate Juju attaching the storage, which will trigger a storage-attached event on the charm. - harness.attach_storage(storage_id) - # Assert that it was handled correctly. - assert ... - - -def test_storage_detaching(harness): - storage_id = harness.add_storage("cache", 1, attach=True) - harness.begin() - # Simulate the harness being detached (.remove_storage() would simulate it being removed - # entirely). - harness.remove_storage(storage_id) - # Assert that it was handled correctly. - assert ... -``` - -> See more: [`ops.testing.Harness.add_storage`](https://ops.readthedocs.io/en/latest/harness.html#ops.testing.Harness.add_storage), [`ops.testing.Harness.attach_storage`](https://ops.readthedocs.io/en/latest/harness.html#ops.testing.Harness.attach_storage), [`ops.testing.Harness.detach_storage`](https://ops.readthedocs.io/en/latest/harness.html#ops.testing.Harness.detach_storage), [`ops.testing.harness.remove_storage`](https://ops.readthedocs.io/en/latest/harness.html#ops.testing.Harness.remove_storage) - -### Write scenario tests - > See first: {ref}`write-scenario-tests-for-a-charm` -When using Scenario for unit tests, to verify that the charm state is as expected after storage changes, use the `run` method of the Scenario `Context` object. For example, to provide the charm with mock storage: +To verify that the charm state is as expected after storage changes, use the `run` method of the `Context` object. For example, to provide the charm with mock storage: ```python +from ops import testing + # Some charm with a 'foo' filesystem-type storage defined in its metadata: -ctx = scenario.Context(MyCharm) -storage = scenario.Storage("foo") +ctx = testing.Context(MyCharm) +storage = testing.Storage("foo") # Set up storage with some content: (storage.get_filesystem(ctx) / "myfile.txt").write_text("helloworld") -with ctx.manager("update-status", scenario.State(storage=[storage])) as mgr: +with ctx(ctx.on.update_status(), testing.State(storages={storage})) as mgr: foo = mgr.charm.model.storages["foo"][0] loc = foo.location path = loc / "myfile.txt" @@ -193,9 +156,11 @@ with ctx.manager("update-status", scenario.State(storage=[storage])) as mgr: myfile = loc / "path.py" myfile.write_text("helloworlds") + state_out = mgr.run() + # Verify that the contents are as expected afterwards. assert ( - storage.get_filesystem(ctx) / "path.py" + state_out.get_storage(storage.name).get_filesystem(ctx) / "path.py" ).read_text() == "helloworlds" ``` @@ -203,31 +168,30 @@ If a charm requests adding more storage instances while handling some event, you can inspect that from the `Context.requested_storage` API. ```python -ctx = scenario.Context(MyCharm) -ctx.run('some-event-that-will-request-more-storage', scenario.State()) +ctx = testing.Context(MyCharm) +ctx.run(ctx.on.some_event_that_will_request_more_storage(), testing.State()) # The charm has requested two 'foo' storage volumes to be provisioned: assert ctx.requested_storages['foo'] == 2 ``` -Requesting storage volumes has no other consequence in Scenario. In real life, +Requesting storage volumes has no other consequence in the unit test. In real life, this request will trigger Juju to provision the storage and execute the charm -again with foo-storage-attached. So a natural follow-up Scenario test suite for +again with foo-storage-attached. So a natural follow-up test suite for this case would be: ``` -ctx = scenario.Context(MyCharm) -foo_0 = scenario.Storage('foo') +ctx = testing.Context(MyCharm) +foo_0 = testing.Storage('foo') # The charm is notified that one of the storage volumes it has requested is ready: -ctx.run(foo_0.attached_event, State(storage=[foo_0])) +ctx.run(ctx.on.storage_attached(foo_0), testing.State(storages={foo_0})) -foo_1 = scenario.Storage('foo') +foo_1 = testing.Storage('foo') # The charm is notified that the other storage is also ready: -ctx.run(foo_1.attached_event, State(storage=[foo_0, foo_1])) +ctx.run(ctx.on.storage_attached(foo_1), testing.State(storages={foo_0, foo_1})) ``` -> See more: [Scenario storage testing](https://github.com/canonical/ops-scenario/#storage) - +> See more: [`ops.testing.Storage`](https://ops.readthedocs.io/en/latest/reference/ops-testing.html#ops.testing.Storage) ### Write integration tests diff --git a/docs/howto/manage-the-charm-version.md b/docs/howto/manage-the-charm-version.md index 3bf6df17a..b17a1b12b 100644 --- a/docs/howto/manage-the-charm-version.md +++ b/docs/howto/manage-the-charm-version.md @@ -39,7 +39,6 @@ there is no version, the key will not be present in the status output. Note that this is distinct from the charm **revision**, which is set when uploading a charm to CharmHub (or when deploying/refreshing for local charms). - > Examples: [`container-log-archive-charm` sets `version` to a version control hash](https://git.launchpad.net/container-log-archive-charm/tree/) ## Test the feature diff --git a/docs/howto/manage-the-workload-version.md b/docs/howto/manage-the-workload-version.md index 96613461c..8c698000f 100644 --- a/docs/howto/manage-the-workload-version.md +++ b/docs/howto/manage-the-workload-version.md @@ -16,7 +16,6 @@ If the charm has not set the workload version, then the field will not be present in JSON or YAML format, and if the version string is too long or contains particular characters then it will not be displayed in the tabular format. - ``` For Kubernetes charms, the workload is typically started in the @@ -34,7 +33,7 @@ observer for the `start` event and pair that with an event handler. For example: self.framework.observe(self.on.start, self._on_start) ``` -> See more: [`ops.StartEvent`](https://ops.readthedocs.io/en/latest/#ops.StartEvent) +> See more: [`ops.StartEvent`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.StartEvent) Now, in the body of the charm definition, define the event handler. Typically, the workload version is retrieved from the workload itself, with a subprocess @@ -48,7 +47,7 @@ def _on_start(self, event: ops.StartEvent): self.unit.set_workload_version(version) ``` -> See more: [`ops.Unit.set_workload_version`](https://ops.readthedocs.io/en/latest/#ops.Unit.set_workload_version) +> See more: [`ops.Unit.set_workload_version`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Unit.set_workload_version) > Examples: [`jenkins-k8s` sets the workload version after getting it from the Jenkins package](https://github.com/canonical/jenkins-k8s-operator/blob/29e9b652714bd8314198965c41a60f5755dd381c/src/charm.py#L115), [`discourse-k8s` sets the workload version after getting it via an exec call](https://github.com/canonical/discourse-k8s-operator/blob/f523b29f909c69da7b9510b581dfcc2309698222/src/charm.py#L581), [`synapse` sets the workload version after getting it via an API call](https://github.com/canonical/synapse-operator/blob/778bcd414644c922373d542a304be14866835516/src/charm.py#L265) @@ -56,58 +55,28 @@ def _on_start(self, event: ops.StartEvent): > See first: [Get started with charm testing](https://juju.is/docs/sdk/get-started-with-charm-testing) -You'll want to add three levels of tests, unit, scenario, and integration. - +You'll want to add unit and integration tests. ### Write unit tests -> See first: {ref}`write-unit-tests-for-a-charm` - -To verify the workload version is set in a unit test, use the -`ops.testing.Harness.get_workload_version()` method to -get the version that the charm set. In your `tests/unit/test_charm.py` file, -add a new test to verify the workload version is set; for example: - -```python -# You may already have this fixture to use in other tests. -@pytest.fixture() -def harness(): - yield ops.testing.Harness(MyCharm) - harness.cleanup() - -def test_start(harness): - # Suppose that the charm gets the workload version by running the command - # `/bin/server --version` in the container. Firstly, we mock that out: - harness.handle_exec("webserver", ["/bin/server", "--version"], result="1.2\n") - # begin_with_initial_hooks will trigger the 'start' event, and we expect - # the charm's 'start' handler to set the workload version. - harness.begin_with_initial_hooks() - assert harness.get_workload_version() == "1.2" -``` - -> See more: [`ops.testing.Harness.get_workload_version`](https://ops.readthedocs.io/en/latest/harness.html#ops.testing.Harness.get_workload_version) - -> Examples: [grafana-k8s checking the workload version](https://github.com/canonical/grafana-k8s-operator/blob/1c80f746f8edeae6fd23ddf31eed45f5b88c06b4/tests/unit/test_charm.py#L283) (and the [earlier mocking](https://github.com/canonical/grafana-k8s-operator/blob/1c80f746f8edeae6fd23ddf31eed45f5b88c06b4/tests/unit/test_charm.py#L127)), [sdcore-webui checks both that the version is set when it is available, and not set when not](https://github.com/canonical/sdcore-webui-k8s-operator/blob/1a66ad3f623d665657d04ad556139439f4733a28/tests/unit/test_charm.py#L447) - - -### Write scenario tests - > See first: {ref}`write-scenario-tests-for-a-charm` -To verify the workload version is set using Scenario, retrieve the workload -version from the `State`. In your `tests/scenario/test_charm.py` file, add a +To verify the workload version is set in a unit test, retrieve the workload +version from the `State`. In your `tests/unit/test_charm.py` file, add a new test that verifies the workload version is set. For example: ```python +from ops import testing + def test_workload_version_is_set(): - ctx = scenario.Context(MyCharm, meta={"name": "foo"}) + ctx = testing.Context(MyCharm) # Suppose that the charm gets the workload version by running the command # `/bin/server --version` in the container. Firstly, we mock that out: - container = scenario.Container( + container = testing.Container( "webserver", - exec_mock={("/bin/server", "--version"): scenario.ExecOutput(stdout="1.2\n")}, + execs={testing.Exec(["/bin/server", "--version"], stdout="1.2\n")}, ) - out = ctx.run('start', scenario.State(containers=[container])) + out = ctx.run(ctx.on.start(), testing.State(containers={container})) assert out.workload_version == "1.2" ``` @@ -151,4 +120,3 @@ No "see more" link: this is not currently documented in the pylibjuju docs. --> > Examples: [synapse checking that the unit's workload version matches the one reported by the server](https://github.com/canonical/synapse-operator/blob/778bcd414644c922373d542a304be14866835516/tests/integration/test_charm.py#L139) - diff --git a/docs/howto/run-workloads-with-a-charm-kubernetes.md b/docs/howto/run-workloads-with-a-charm-kubernetes.md index b06e80feb..d1140b4ed 100644 --- a/docs/howto/run-workloads-with-a-charm-kubernetes.md +++ b/docs/howto/run-workloads-with-a-charm-kubernetes.md @@ -5,17 +5,16 @@ The recommended way to create charms for Kubernetes is using the sidecar pattern Pebble is a lightweight, API-driven process supervisor designed for use with charms. If you specify the `containers` field in a charm's `charmcraft.yaml`, Juju will deploy the charm code in a sidecar container, with Pebble running as the workload container's `ENTRYPOINT`. -When the workload container starts up, Juju fires a [`PebbleReadyEvent`](https://ops.readthedocs.io/en/latest/#ops.PebbleReadyEvent), which can be handled using [`Framework.observe`](https://ops.readthedocs.io/en/latest/#ops.Framework.observe). This gives the charm author access to `event.workload`, a [`Container`](https://ops.readthedocs.io/en/latest/#ops.Container) instance. +When the workload container starts up, Juju fires a [`PebbleReadyEvent`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.PebbleReadyEvent), which can be handled using [`Framework.observe`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Framework.observe). This gives the charm author access to `event.workload`, a [`Container`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container) instance. The `Container` class has methods to modify the Pebble configuration "plan", start and stop services, read and write files, and run commands. These methods use the Pebble API, which communicates from the charm container to the workload container using HTTP over a Unix domain socket. -The rest of this document provides details of how a charm interacts with the workload container via Pebble, using the Python Operator Framework [`Container`](https://ops.readthedocs.io/en/latest/#ops.Container) methods. +The rest of this document provides details of how a charm interacts with the workload container via Pebble, using `ops` [`Container`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container) methods. ```{note} -The [`Container.pebble`](https://ops.readthedocs.io/en/latest/#ops.Container.pebble) property returns the [`pebble.Client`](https://ops.readthedocs.io/en/latest/#ops.pebble.Client) instance for the given container. - +The [`Container.pebble`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.pebble) property returns the [`pebble.Client`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.Client) instance for the given container. ``` ## Set up the workload container @@ -24,12 +23,11 @@ The [`Container.pebble`](https://ops.readthedocs.io/en/latest/#ops.Container.peb -The preferred way to run workloads on Kubernetes with charms is to start your workload with [Pebble](https://github.com/canonical/pebble). You do not need to modify upstream container images to make use of Pebble for managing your workload. The Juju controller automatically injects Pebble into workload containers using an [Init Container](https://kubernetes.io/docs/concepts/workloads/pods/init-containers/) and [Volume Mount](https://kubernetes.io/docs/concepts/storage/volumes/). The entrypoint of the container is overridden so that Pebble starts first and is able to manage running services. Charms communicate with the Pebble API using a UNIX socket, which is mounted into both the charm and workload containers. +The preferred way to run workloads on Kubernetes with charms is to start your workload with [Pebble](https://canonical-pebble.readthedocs-hosted.com/). You do not need to modify upstream container images to make use of Pebble for managing your workload. The Juju controller automatically injects Pebble into workload containers using an [Init Container](https://kubernetes.io/docs/concepts/workloads/pods/init-containers/) and [Volume Mount](https://kubernetes.io/docs/concepts/storage/volumes/). The entrypoint of the container is overridden so that Pebble starts first and is able to manage running services. Charms communicate with the Pebble API using a UNIX socket, which is mounted into both the charm and workload containers. ```{note} By default, you'll find the Pebble socket at `/var/lib/pebble/default/pebble.sock` in the workload container, and `/charm//pebble.sock` in the charm container. - ``` Most Kubernetes charms will need to define a `containers` map in their `charmcraft.yaml` in order to start a workload with a known OCI image: @@ -55,7 +53,6 @@ resources: ```{note} In some cases, you may wish not to specify a `containers` map, which will result in an "operator-only" charm. These can be useful when writing "integrator charms" (sometimes known as "proxy charms"), which are used to represent some external service in the Juju model. - ``` For each container, a resource of type `oci-image` must also be specified. The resource is used to inform the Juju controller how to find the correct OCI-compliant container image for your workload on Charmhub. @@ -66,12 +63,11 @@ If multiple containers are specified in `charmcraft.yaml` (as above), each Pod w - a container running the `redis-image` - a container running the charm code -The Juju controller emits [`PebbleReadyEvent`](https://ops.readthedocs.io/en/latest/#ops.PebbleReadyEvent)s to charms when Pebble has initialised its API in a container. These events are named `_pebble_ready`. Using the example above, the charm would receive two Pebble related events (assuming the Pebble API starts correctly in each workload): +The Juju controller emits [`PebbleReadyEvent`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.PebbleReadyEvent)s to charms when Pebble has initialised its API in a container. These events are named `_pebble_ready`. Using the example above, the charm would receive two Pebble related events (assuming the Pebble API starts correctly in each workload): - `myapp_pebble_ready` - `redis_pebble_ready`. - Consider the following example snippet from a `charmcraft.yaml`: ```yaml @@ -87,7 +83,7 @@ resources: # ... ``` -Once the containers are initialised, the charm needs to tell Pebble how to start the workload. Pebble uses a series of "layers" for its configuration. Layers contain a description of the processes to run, along with the path and arguments to the executable, any environment variables to be specified for the running process and any relevant process ordering (more information available in the Pebble [README](https://github.com/canonical/pebble)). +Once the containers are initialised, the charm needs to tell Pebble how to start the workload. Pebble uses a series of "layers" for its configuration. Layers contain a description of the processes to run, along with the path and arguments to the executable, any environment variables to be specified for the running process and any relevant process ordering (more information available in the [Pebble documentation](https://canonical-pebble.readthedocs-hosted.com)). ```{note} @@ -95,10 +91,9 @@ In many cases, using the container's specified entrypoint may be desired. You ca `$ docker pull ` `$ docker inspect ` - ``` -When using an OCI-image that is not built specifically for use with Pebble, layers are defined at runtime using Pebble’s API. Recall that when Pebble has initialised in a container (and the API is ready), the Juju controller emits a [`PebbleReadyEvent`](https://ops.readthedocs.io/en/latest/#ops.PebbleReadyEvent) event to the charm. Often it is in the callback bound to this event that layers are defined, and services started: +When using an OCI-image that is not built specifically for use with Pebble, layers are defined at runtime using Pebble’s API. Recall that when Pebble has initialised in a container (and the API is ready), the Juju controller emits a [`PebbleReadyEvent`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.PebbleReadyEvent) event to the charm. Often it is in the callback bound to this event that layers are defined, and services started: ```python # ... @@ -199,30 +194,31 @@ In this example, each time a `config-changed` event is fired, a new overlay laye ### Configure a Pebble layer -Pebble services are [configured by means of layers](https://github.com/canonical/pebble#layer-specification), with higher layers adding to or overriding lower layers, forming the effective Pebble configuration, or "plan". +Pebble services are [configured by means of layers](https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/layer-specification/), with higher layers adding to or overriding lower layers, forming the effective Pebble configuration, or "plan". When a workload container is created and Pebble starts up, it looks in `/var/lib/pebble/default/layers` (if that exists) for configuration layers already present in the container image, such as `001-layer.yaml`. If there are existing layers there, that becomes the starting configuration, otherwise Pebble is happy to start with an empty configuration, meaning no services. In the latter case, Pebble is configured dynamically via the API by adding layers at runtime. -See the [layer specification](https://github.com/canonical/pebble#layer-specification) for more details. - +See the [layer specification](https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/layer-specification/) for more details. #### Add a configuration layer -To add a configuration layer, call [`Container.add_layer`](https://ops.readthedocs.io/en/latest/#ops.Container.add_layer) with a label for the layer, and the layer's contents as a YAML string, Python dict, or [`pebble.Layer`](https://ops.readthedocs.io/en/latest/#ops.pebble.Layer) object. +To add a configuration layer, call [`Container.add_layer`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.add_layer) with a label for the layer, and the layer's contents as a YAML string, Python dict, or [`pebble.Layer`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.Layer) object. You can see an example of `add_layer` under the ["Replan" heading](#replan). The `combine=True` argument tells Pebble to combine the named layer into an existing layer of that name (or add a layer if none by that name exists). Using `combine=True` is common when dynamically adding layers. Because `combine=True` combines the layer with an existing layer of the same name, it's normally used with `override: replace` in the YAML service configuration. This means replacing the entire service configuration with the fields in the new layer. -If you're adding a single layer with `combine=False` (default option) on top of an existing base layer, you may want to use `override: merge` in the service configuration. This will merge the fields specified with the service by that name in the base layer. [See an example of overriding a layer.](https://github.com/canonical/pebble#layer-override-example) +If you're adding a single layer with `combine=False` (default option) on top of an existing base layer, you may want to use `override: merge` in the service configuration. This will merge the fields specified with the service by that name in the base layer. [See an example of overriding a layer.](https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/layers/#layer-override) #### Fetch the effective plan -Charm authors can also introspect the current plan using [`Container.get_plan`](https://ops.readthedocs.io/en/latest/#ops.Container.get_plan). It returns a [`pebble.Plan`](https://ops.readthedocs.io/en/latest/#ops.pebble.Plan) object whose `services` attribute maps service names to [`pebble.Service`](https://ops.readthedocs.io/en/latest/#ops.pebble.Service) instances. +Charm authors can also introspect the current plan using [`Container.get_plan`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.get_plan). It returns a [`pebble.Plan`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.Plan) object whose `services` attribute maps service names to [`pebble.Service`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.Service) instances. -Below is an example of how you might use `get_plan` to introspect the current configuration, and only add the layer with its services if they haven't been added already: +It is not necessary to use `get_plan` to determine whether the plan has changed and start services accordingly. If you call [`replan`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.replan), then Pebble will take care of this for you. + +Below is an example of how you might use `get_plan` to introspect the current configuration, and log the active services: ```python class MyCharm(ops.CharmBase): @@ -230,15 +226,13 @@ class MyCharm(ops.CharmBase): def _on_config_changed(self, event): container = self.unit.get_container("main") + container.replan() plan = container.get_plan() - if not plan.services: - layer = {"services": ...} - container.add_layer("layer", layer) - container.start("svc") + for service in plan.services: + logger.info('Service: %s', service) ... ``` - ## Control and monitor services in the workload container The main purpose of Pebble is to control and monitor services, which are usually long-running processes like web servers and databases. @@ -253,7 +247,7 @@ The reason for replan is so that you as a user have control over when the (poten Replan also starts the services that are marked as `startup: enabled` in the configuration plan, if they're not running already. -Call [`Container.replan`](https://ops.readthedocs.io/en/latest/#ops.Container.replan) to execute the replan procedure. For example: +Call [`Container.replan`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.replan) to execute the replan procedure. For example: ```python class SnappassTestCharm(ops.CharmBase): @@ -278,13 +272,13 @@ class SnappassTestCharm(ops.CharmBase): ### Check container health -The Ops library provides a way to ensure that your container is healthy. In the `Container` class, `Container.can_connect()` can be used if you only need to know that Pebble is responding at a specific point in time - for example to update a status message. This should *not* be used to guard against later Pebble operations, because that introduces a race condition where Pebble might be responsive when `can_connect()` is called, but is not when the later operation is executed. Instead, charms should always include `try`/`except` statements around Pebble operations, to avoid the unit going into error state. +`ops` provides a way to ensure that your container is healthy. In the `Container` class, `Container.can_connect()` can be used if you only need to know that Pebble is responding at a specific point in time - for example to update a status message. This should *not* be used to guard against later Pebble operations, because that introduces a race condition where Pebble might be responsive when `can_connect()` is called, but is not when the later operation is executed. Instead, charms should always include `try`/`except` statements around Pebble operations, to avoid the unit going into error state. -> See more: [`ops.Container`](https://ops.readthedocs.io/en/latest/#ops.Container) +> See more: [`ops.Container`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container) ### Start and stop -To start (or stop) one or more services by name, use the [`start`](https://ops.readthedocs.io/en/latest/#ops.Container.start) and [`stop`](https://ops.readthedocs.io/en/latest/#ops.Container.stop) methods. Here's an example of how you might stop and start a database service during a backup action: +To start (or stop) one or more services by name, use the [`start`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.start) and [`stop`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.stop) methods. Here's an example of how you might stop and start a database service during a backup action: ```python class MyCharm(ops.CharmBase): @@ -306,13 +300,13 @@ class MyCharm(ops.CharmBase): It's not an error to start a service that's already started, or stop one that's already stopped. These actions are *idempotent*, meaning they can safely be performed more than once, and the service will remain in the same state. -When Pebble starts a service, Pebble waits one second to ensure the process doesn't exit too quickly -- if the process exits within one second, the start operation raises an error and the service remains stopped. +When Pebble starts a service, it waits one second to ensure the process doesn't exit too quickly. In Juju 3.6.0 and earlier, if the process exits within one second, the start operation raises an error and the service remains stopped. In Juju 3.6.1 and later, the operation will still raise an error, but Pebble will continue to try starting the service. To stop a service, Pebble first sends `SIGTERM` to the service's process group to try to stop the service gracefully. If the process has not exited after 5 seconds, Pebble sends `SIGKILL` to the process group. If the process still doesn't exit after another 5 seconds, the stop operation raises an error. If the process exits any time before the 10 seconds have elapsed, the stop operation succeeds. ### Fetch service status -You can use the [`get_service`](https://ops.readthedocs.io/en/latest/#ops.Container.get_service) and [`get_services`](https://ops.readthedocs.io/en/latest/#ops.Container.get_services) methods to fetch the current status of one service or multiple services, respectively. The returned [`ServiceInfo`](https://ops.readthedocs.io/en/latest/#ops.pebble.ServiceInfo) objects provide a `status` attribute with various states, or you can use the [`ServiceInfo.is_running`](https://ops.readthedocs.io/en/latest/#ops.pebble.ServiceInfo.is_running) method. +You can use the [`get_service`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.get_service) and [`get_services`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.get_services) methods to fetch the current status of one service or multiple services, respectively. The returned [`ServiceInfo`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.ServiceInfo) objects provide a `status` attribute with various states, or you can use the [`ServiceInfo.is_running`](https://ops.readthedocs.io/en/latest/#ops.pebble.ServiceInfo.is_running) method. Here is a modification to the start/stop example that checks whether the service is running before stopping it: @@ -332,7 +326,7 @@ class MyCharm(ops.CharmBase): ### Send signals to services -From Juju version 2.9.22, you can use the [`Container.send_signal`](https://ops.readthedocs.io/en/latest/#ops.Container.send_signal) method to send a signal to one or more services. For example, to send `SIGHUP` to the hypothetical "nginx" and "redis" services: +You can use the [`Container.send_signal`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.send_signal) method to send a signal to one or more services. For example, to send `SIGHUP` to the hypothetical "nginx" and "redis" services: ```python container.send_signal('SIGHUP', 'nginx', 'redis') @@ -354,7 +348,7 @@ In the command line above, "snappass" is the namespace (Juju model name), "snapp ### Configure service auto-restart -From Juju version 2.9.22, Pebble automatically restarts services when they exit unexpectedly. +Pebble automatically restarts services when they exit unexpectedly. By default, Pebble will automatically restart a service when it exits (with either a zero or nonzero exit code). In addition, Pebble implements an exponential backoff delay and a small random jitter time between restarts. @@ -388,7 +382,7 @@ Just before delaying, a small random time jitter of 0-10% of the delay is added ## Perform health checks on the workload container -From Juju version 2.9.26, Pebble supports adding custom health checks: first, to allow Pebble itself to restart services when certain checks fail, and second, to allow Kubernetes to restart containers when specified checks fail. +Pebble supports adding custom health checks: first, to allow Pebble itself to restart services when certain checks fail, and second, to allow Kubernetes to restart containers when specified checks fail. Each check can be one of three types. The types and their success criteria are: @@ -396,7 +390,6 @@ Each check can be one of three types. The types and their success criteria are: * `tcp`: opening the given TCP port must be successful. * `exec`: executing the specified command must yield a zero exit code. - ### Check configuration Checks are configured in the layer configuration using the top-level field `checks`. Here's an example showing the three different types of checks: @@ -428,13 +421,12 @@ Each check is performed with the specified `period` (the default is 10 seconds a A check is considered healthy until it's had `threshold` errors in a row (the default is 3). At that point, the `on-check-failure` action will be triggered, and the health endpoint will return an error response (both are discussed below). When the check succeeds again, the failure count is reset. -See the [layer specification](https://github.com/canonical/pebble#layer-specification) for more details about the fields and options for different types of checks. +See the [layer specification](https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/layer-specification/) for more details about the fields and options for different types of checks. ### Respond to a check failing or recovering > Added in `ops 2.15` and `juju 3.6` - To have the charm respond to a check reaching the failure threshold, or passing again afterwards, observe the `pebble_check_failed` and `pebble_check_recovered` events and switch on the info's `name`: ```python @@ -484,7 +476,7 @@ class PostgresCharm(ops.CharmBase): ### Fetch check status -You can use the [`get_check`](https://ops.readthedocs.io/en/latest/#ops.Container.get_check) and [`get_checks`](https://ops.readthedocs.io/en/latest/#ops.Container.get_checks) methods to fetch the current status of one check or multiple checks, respectively. The returned [`CheckInfo`](https://ops.readthedocs.io/en/latest/#ops.pebble.CheckInfo) objects provide various attributes, most importantly a `status` attribute which will be either `UP` or `DOWN`. +You can use the [`get_check`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.get_check) and [`get_checks`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.get_checks) methods to fetch the current status of one check or multiple checks, respectively. The returned [`CheckInfo`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.CheckInfo) objects provide various attributes, most importantly a `status` attribute which will be either `UP` or `DOWN`. Here is a code example that checks whether the `uptime` check is healthy, and writes an error log if not: @@ -509,7 +501,7 @@ services: ### Check health endpoint and probes -As of Juju version 2.9.26, Pebble includes an HTTP `/v1/health` endpoint that allows a user to query the health of configured checks, optionally filtered by check level with the query string `?level=` This endpoint returns an HTTP 200 status if the checks are healthy, HTTP 502 otherwise. +Pebble includes an HTTP `/v1/health` endpoint that allows a user to query the health of configured checks, optionally filtered by check level with the query string `?level=` This endpoint returns an HTTP 200 status if the checks are healthy, HTTP 502 otherwise. Each check can optionally specify a `level` of "alive" or "ready". These have semantic meaning: "alive" means the check or the service it's connected to is up and running; "ready" means it's properly accepting network traffic. These correspond to Kubernetes ["liveness" and "readiness" probes](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/). @@ -523,16 +515,19 @@ Consider the K8s liveness success (`level=alive` check) to mean "Pebble is alive ### Test checks -> Added in Scenario 7.0 +> Added in ops 2.17 -To test charms that use Pebble check events, use the Scenario `CheckInfo` class and the emit the appropriate event. For example, to simulate the "http-test" check failing, the charm test could do the following: +To test charms that use Pebble check events, use the `CheckInfo` class and the emit the appropriate event. For example, to simulate the "http-test" check failing, the charm test could do the following: ```python +import ops +from ops import testing + def test_http_check_failing(): - ctx = scenario.Context(PostgresCharm) - check_info = scenario.CheckInfo("http-test", failures=3, status=ops.pebble.CheckStatus.DOWN) - container = scenario.Container("db", check_infos={check_info}) - state_in = scenario.State(containers={container}) + ctx = testing.Context(PostgresCharm) + check_info = testing.CheckInfo("http-test", failures=3, status=ops.pebble.CheckStatus.DOWN) + container = testing.Container("db", check_infos={check_info}) + state_in = testing.State(containers={container}) state_out = ctx.run(ctx.on.pebble_check_failed(container, check_info), state_in) @@ -543,10 +538,9 @@ def test_http_check_failing(): Pebble's files API allows charm authors to read and write files on the workload container. You can write files ("push"), read files ("pull"), list files in a directory, make directories, and delete files or directories. - ### Push -Probably the most useful operation is [`Container.push`](https://ops.readthedocs.io/en/latest/#ops.Container.push), which allows you to write a file to the workload, for example, a PostgreSQL configuration file. You can use `push` as follows (note that this code would be inside a charm event handler): +Probably the most useful operation is [`Container.push`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.push), which allows you to write a file to the workload, for example, a PostgreSQL configuration file. You can use `push` as follows (note that this code would be inside a charm event handler): ```python config = """ @@ -558,11 +552,11 @@ container.push('/etc/pg/postgresql.conf', config, make_dirs=True) The `make_dirs=True` flag tells `push` to create the intermediate directories if they don't already exist (`/etc/pg` in this case). -There are many additional features, including the ability to send raw bytes (by providing a Python `bytes` object as the second argument) and write data from a file-like object. You can also specify permissions and the user and group for the file. See the [API documentation](https://ops.readthedocs.io/en/latest/#ops.Container.push) for details. +There are many additional features, including the ability to send raw bytes (by providing a Python `bytes` object as the second argument) and write data from a file-like object. You can also specify permissions and the user and group for the file. See the [API documentation](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.push) for details. ### Pull -To read a file from the workload, use [`Container.pull`](https://ops.readthedocs.io/en/latest/#ops.Container.pull), which returns a file-like object that you can `read()`. +To read a file from the workload, use [`Container.pull`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.pull), which returns a file-like object that you can `read()`. The files API doesn't currently support update, so to update a file you can use `pull` to perform a read-modify-write operation, for example: @@ -579,9 +573,7 @@ If you specify the keyword argument `encoding=None` on the `pull()` call, reads ### Push recursive -> Added in 1.5 - -To copy several files to the workload, use [`Container.push_path`](https://ops.readthedocs.io/en/latest/#ops.Container.push_path), which copies files recursively into a specified destination directory. The API docs contain detailed examples of source and destination semantics and path handling. +To copy several files to the workload, use [`Container.push_path`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.push_path), which copies files recursively into a specified destination directory. The API docs contain detailed examples of source and destination semantics and path handling. ```python # copy "/source/dir/[files]" into "/destination/dir/[files]" @@ -595,9 +587,7 @@ A trailing "/*" on the source directory is the only supported globbing/matching. ### Pull recursive -> Added in 1.5 - -To copy several files to the workload, use [`Container.pull_path`](https://ops.readthedocs.io/en/latest/#ops.Container.pull_path), which copies files recursively into a specified destination directory. The API docs contain detailed examples of source and destination semantics and path handling. +To copy several files to the workload, use [`Container.pull_path`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.pull_path), which copies files recursively into a specified destination directory. The API docs contain detailed examples of source and destination semantics and path handling. ```python # copy "/source/dir/[files]" into "/destination/dir/[files]" @@ -611,7 +601,7 @@ A trailing "/*" on the source directory is the only supported globbing/matching. ### List files -To list the contents of a directory or return stat-like information about one or more files, use [`Container.list_files`](https://ops.readthedocs.io/en/latest/#ops.Container.list_files). It returns a list of [`pebble.FileInfo`](https://ops.readthedocs.io/en/latest/#ops.pebble.FileInfo) objects for each entry (file or directory) in the given path, optionally filtered by a glob pattern. For example: +To list the contents of a directory or return stat-like information about one or more files, use [`Container.list_files`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.list_files). It returns a list of [`pebble.FileInfo`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.FileInfo) objects for each entry (file or directory) in the given path, optionally filtered by a glob pattern. For example: ```python infos = container.list_files('/etc', pattern='*.conf') @@ -626,7 +616,7 @@ If you want information about the directory itself (instead of its contents), ca ### Create directory -To create a directory, use [`Container.make_dir`](https://ops.readthedocs.io/en/latest/#ops.Container.make_dir). It takes an optional `make_parents=True` argument (like `mkdir -p`), as well as optional permissions and user/group arguments. Some examples: +To create a directory, use [`Container.make_dir`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.make_dir). It takes an optional `make_parents=True` argument (like `mkdir -p`), as well as optional permissions and user/group arguments. Some examples: ```python container.make_dir('/etc/pg', user='postgres', group='postgres') @@ -635,7 +625,7 @@ container.make_dir('/some/other/nested/dir', make_parents=True) ### Remove path -To delete a file or directory, use [`Container.remove_path`](https://ops.readthedocs.io/en/latest/#ops.Container.remove_path). If a directory is specified, it must be empty unless `recursive=True` is specified, in which case the entire directory tree is deleted, recursively (like `rm -r`). For example: +To delete a file or directory, use [`Container.remove_path`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.remove_path). If a directory is specified, it must be empty unless `recursive=True` is specified, in which case the entire directory tree is deleted, recursively (like `rm -r`). For example: ```python # Delete Apache access log @@ -646,9 +636,7 @@ container.remove_path('/tmp/mysubdir', recursive=True) ### Check file and directory existence -> Added in 1.4 - -To check if a paths exists you can use [`Container.exists`](https://ops.readthedocs.io/en/latest/#ops.Container.exists) for directories or files and [`Container.isdir`](https://ops.readthedocs.io/en/latest/#ops.Container.isdir) for directories. These functions are analogous to python's `os.path.isdir` and `os.path.exists` functions. For example: +To check if a paths exists you can use [`Container.exists`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.exists) for directories or files and [`Container.isdir`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.is_dir) for directories. These functions are analogous to python's `os.path.isdir` and `os.path.exists` functions. For example: ```python # if /tmp/myfile exists @@ -662,9 +650,9 @@ container.isdir('/tmp/mydir') # True ## Run commands on the workload container -From Juju 2.9.17, Pebble includes an API for executing arbitrary commands on the workload container: the [`Container.exec`](https://ops.readthedocs.io/en/latest/#ops.Container.exec) method. It supports sending stdin to the process and receiving stdout and stderr, as well as more advanced options. +Pebble includes an API for executing arbitrary commands on the workload container: the [`Container.exec`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.exec) method. It supports sending stdin to the process and receiving stdout and stderr, as well as more advanced options. -To run simple commands and receive their output, call `Container.exec` to start the command, and then use the returned [`Process`](https://ops.readthedocs.io/en/latest/#ops.pebble.ExecProcess) object's [`wait_output`](https://ops.readthedocs.io/en/latest/#ops.pebble.ExecProcess.wait_output) method to wait for it to finish and collect its output. +To run simple commands and receive their output, call `Container.exec` to start the command, and then use the returned [`Process`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.ExecProcess) object's [`wait_output`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.ExecProcess.wait_output) method to wait for it to finish and collect its output. For example, to back up a PostgreSQL database, you might use `pg_dump`: @@ -677,12 +665,11 @@ if warnings: # do something with "sql" ``` - ### Handle errors -The `exec` method raises a [`pebble.APIError`](https://ops.readthedocs.io/en/latest/#ops.pebble.APIError) if basic checks fail and the command can't be executed at all, for example, if the executable is not found. +The `exec` method raises a [`pebble.APIError`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.APIError) if basic checks fail and the command can't be executed at all, for example, if the executable is not found. -The [`ExecProcess.wait`](https://ops.readthedocs.io/en/latest/#ops.pebble.ExecProcess.wait) and [`ExecProcess.wait_output`](https://ops.readthedocs.io/en/latest/#ops.pebble.ExecProcess.wait_output) methods raise [`pebble.ChangeError`](https://ops.readthedocs.io/en/latest/#ops.pebble.ChangeError) if there was an error starting or running the process, and [`pebble.ExecError`](https://ops.readthedocs.io/en/latest/#ops.pebble.ExecError) if the process exits with a non-zero exit code. +The [`ExecProcess.wait`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.ExecProcess.wait) and [`ExecProcess.wait_output`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.ExecProcess.wait_output) methods raise [`pebble.ChangeError`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.ChangeError) if there was an error starting or running the process, and [`pebble.ExecError`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.ExecError) if the process exits with a non-zero exit code. In the case where the process exits via a signal (such as SIGTERM or SIGKILL), the exit code will be 128 plus the signal number. SIGTERM's signal number is 15, so a process terminated via SIGTERM would give exit code 143 (128+15). @@ -709,7 +696,7 @@ Exited with code 1. Stderr: ### Use command options -The `Container.exec` method has various options (see [full API documentation](https://ops.readthedocs.io/en/latest/#ops.pebble.Client.exec)), including: +The `Container.exec` method has various options (see [full API documentation](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.Client.exec)), including: * `environment`: a dict of environment variables to pass to the process * `working_dir`: working directory to run the command in @@ -746,7 +733,7 @@ process.wait_output() ### Use input/output options -The simplest way of receiving standard output and standard error is by using the [`ExecProcess.wait_output`](https://ops.readthedocs.io/en/latest/#ops.pebble.ExecProcess.wait_output) method as shown below. The simplest way of sending standard input to the program is as a string, using the `stdin` parameter to `exec`. For example: +The simplest way of receiving standard output and standard error is by using the [`ExecProcess.wait_output`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.ExecProcess.wait_output) method as shown below. The simplest way of sending standard input to the program is as a string, using the `stdin` parameter to `exec`. For example: ```python process = container.exec(['tr', 'a-z', 'A-Z'], @@ -819,7 +806,7 @@ Caution: it's easy to get threading wrong and cause deadlocks, so it's best to u ### Send signals to a running command -To send a signal to the running process, use [`ExecProcess.send_signal`](https://ops.readthedocs.io/en/latest/#ops.pebble.ExecProcess.send_signal) with a signal number or name. For example, the following will terminate the "sleep 10" process after one second: +To send a signal to the running process, use [`ExecProcess.send_signal`](https://ops.readthedocs.io/en/latest/reference/pebble.html#ops.pebble.ExecProcess.send_signal) with a signal number or name. For example, the following will terminate the "sleep 10" process after one second: ```python process = container.exec(['sleep', '10']) @@ -851,7 +838,7 @@ The first argument to `pebble notify` is the key, which must be in the format `< The `pebble notify` command has an optional `--repeat-after` flag, which tells Pebble to only allow the notice to repeat after the specified duration (the default is to repeat for every occurrence). If the caller says `--repeat-after=1h`, Pebble will prevent the notice with the same type and key from repeating within an hour -- useful to avoid the charm waking up too often when a notice occurs frequently. -> See more: [GitHub | Pebble > Notices > `pebble notify`](https://github.com/canonical/pebble#notices) +> See more: [GitHub | Pebble > Notices > `pebble notify`](https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/cli-commands/#reference-pebble-notify-command) ### Respond to a notice @@ -875,52 +862,53 @@ class PostgresCharm(ops.CharmBase): logger.info("Handling other thing") ``` -All notice events have a [`notice`](https://ops.readthedocs.io/en/latest/#ops.PebbleNoticeEvent.notice) property with the details of the notice recorded. That is used in the example above to switch on the notice `key` and look at its `last_data` (to determine the backup's path). +All notice events have a [`notice`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.PebbleNoticeEvent.notice) property with the details of the notice recorded. That is used in the example above to switch on the notice `key` and look at its `last_data` (to determine the backup's path). ### Fetch notices A charm can also query for notices using the following two `Container` methods: -* [`get_notice`](https://ops.readthedocs.io/en/latest/#ops.Container.get_notice), which gets a single notice by unique ID (the value of `notice.id`). -* [`get_notices`](https://ops.readthedocs.io/en/latest/#ops.Container.get_notices), which returns all notices by default, and allows filtering notices by specific attributes such as `key`. +* [`get_notice`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.get_notice), which gets a single notice by unique ID (the value of `notice.id`). +* [`get_notices`](https://ops.readthedocs.io/en/latest/reference/ops.html#ops.Container.get_notices), which returns all notices by default, and allows filtering notices by specific attributes such as `key`. ### Test notices -To test charms that use Pebble Notices, use the [`Harness.pebble_notify`](https://ops.readthedocs.io/en/latest/#ops.testing.Harness.pebble_notify) method to simulate recording a notice with the given details. For example, to simulate the "backup-done" notice handled above, the charm tests could do the following: +To test charms that use Pebble Notices, use the [`pebble_custom_notice`](https://ops.readthedocs.io/en/latest/reference/ops-testing.html#ops.testing.CharmEvents.pebble_custom_notice) method to simulate recording a notice with the given details. For example, to simulate the "backup-done" notice handled above, the charm tests could do the following: ```python -class TestCharm(unittest.TestCase): - @patch("charm.s3_bucket.upload_fileobj") - def test_backup_done(self, upload_fileobj): - harness = ops.testing.Harness(PostgresCharm) - self.addCleanup(harness.cleanup) - harness.begin() - harness.set_can_connect("db", True) - - # Pretend backup file has been written - root = harness.get_filesystem_root("db") - (root / "tmp").mkdir() - (root / "tmp" / "mydb.sql").write_text("BACKUP") - - # Notify to record the notice and fire the event - harness.pebble_notify( - "db", "canonical.com/postgresql/backup-done", data={"path": "/tmp/mydb.sql"} - ) +from ops import testing + +@patch('charm.s3_bucket.upload_fileobj') +def test_backup_done(upload_fileobj): + # Arrange: + ctx = testing.Context(PostgresCharm) + + notice = testing.Notice( + 'canonical.com/postgresql/backup-done', + last_data={'path': '/tmp/mydb.sql'}, + ) + container = testing.Container('db', can_connect=True, notices=[notice]) + root = container.get_filesystem() + (root / "tmp").mkdir() + (root / "tmp" / "mydb.sql").write_text("BACKUP") + state_in = testing.State(containers={container}) + + # Act: + state_out = ctx.run(ctx.on.pebble_custom_notice(container, notice), state_in) - # Ensure backup content was "uploaded" to S3 - upload_fileobj.assert_called_once() - upload_f, upload_key = upload_fileobj.call_args.args - self.assertEqual(upload_f.read(), b"BACKUP") - self.assertEqual(upload_key, "db-backup.sql") + # Assert: + upload_fileobj.assert_called_once() + upload_f, upload_key = upload_fileobj.call_args.args + self.assertEqual(upload_f.read(), b"BACKUP") + self.assertEqual(upload_key, "db-backup.sql") ```