diff --git a/docs/explanation/charm-relation-interfaces.md b/docs/explanation/charm-relation-interfaces.md new file mode 100644 index 000000000..b6b5dfa82 --- /dev/null +++ b/docs/explanation/charm-relation-interfaces.md @@ -0,0 +1,44 @@ +(charm-relation-interfaces)= +# Charm-relation-interfaces + +> See also: {ref}`manage-interfaces` + +[`charm-relation-interfaces`](https://github.com/canonical/charm-relation-interfaces) is a repository containing specifications, databag schemas and interface tests for Juju relation interfaces. In other words, it is the source of truth for data and behavior of providers and requirers of integrations. + +The purpose of this project is to provide uniformity in the landscape of all possible integrations and promote charm interoperability. + +Juju interfaces are untyped, which means that for juju to think two charms can be integrated all it looks at is whether the interface names of the two endpoints you're trying to connect are the same string. But it might be that the two charms have different, incompatible implementations of two different integrations that happen to have the same name. + +In order to prevent two separate charms from rolling their own integration with the same name, and prevent a sprawl of many subtly different interfaces with similar semantics and similar purposes, we introduced `charm-relation-interfaces`. + +## Using `charm-relation-interfaces` + +If you have a charm that provides a service, you should search `charm-relation-interfaces` (or directly charmhub in the future) and see if it exists already, or perhaps a similar one exists that lacks the semantics you need and can be extended to support it. + +Conversely, if the charm you are developing needs some service (a database, an ingress url, an authentication endpoint...) you should search `charm-relation-interfaces` to see if there is an interface you can use, and to find existing charms that provide it. + +There are three actors in play: + +* **the owner of the specification** of the interface, which also owns the tests that can be used to verify "does charm X 'really' support this interface?". This is the `charm-relation-interfaces` repo. +* **the owner of the implementation** of an interface. In practice, this often is the charm that owns the charm library with the reference implementation for an interface. +* **the interface user**: a charm that wants to use the interface (either as requirer or as provider). + +The interface user needs the implementation (typically, the provider also happens to be the owner and so it already has the implementation). This is addressed by `charmcraft fetch-lib`. + +The owner of the implementation needs the specification, to help check that the implementation is in fact compliant. + +## Repository structure + +For each interface, the charm-relation-interfaces repository hosts: +- the **specification**: a semi-formal definition of what the semantics of the interface is, and what its implementations are expected to do in terms of both the provider and the requirer +- a list of **reference charms**: these are the charms that implement this interface, typically, the owner of the charm library providing the original implementation. +- the **schema**: pydantic models unambiguously defining the accepted unit and application databag contents for provider and requirer. +- the **interface tests**: python tests that can be run to verify that a charm complies with the interface specification. + + +## Charm relation interfaces in Charmhub +In the future, Charmhub will have a searchable collection of integration interfaces. +Charmhub will, for all charms using the interface, verify that they implement it correctly (regardless of whether they use the 'official' implementation or they roll their own) in order to give the charm a happy checkmark on `charmhub.io`. In order to do that it will need to fetch the specification (from `charm-relation-interfaces`) *and* the charm repo, because we can't know what implementation they are using: we need the source code. + + + diff --git a/docs/explanation/holistic-vs-delta-charms.md b/docs/explanation/holistic-vs-delta-charms.md new file mode 100644 index 000000000..e7444d646 --- /dev/null +++ b/docs/explanation/holistic-vs-delta-charms.md @@ -0,0 +1,66 @@ +(holistic-vs-delta-charms)= +# Holistic vs delta charms + + +Charm developers have had many discussion about "holistic" charms compared to "delta" charms, and which approach is better. First, let's define those terms: + +* A *delta-based* charm is when the charm handles each kind of Juju hook with a separate handler function, which does the minimum necessary to process that kind of event. +* A *holistic* charm handles some or all Juju hooks using a common code path such as `_update_charm`, which queries the charm config and relation data and "rewrites the world", that is, rewrites application configuration and restarts necessary services. + +Juju itself nudges charm authors in the direction of delta-based charms, because it provides specific event kinds that signal that one "thing" changed: `config-changed` says that a config value changed, `relation-changed` says that relation data has changed, `pebble-ready` signals that the Pebble container is ready, and so on. + +However, this only goes so far: `config-changed` doesn't tell the charm which config keys changed, and `relation-changed` doesn't tell the charm how the relation data changed. + +In addition, the charm may receive an event like `config-changed` before it's ready to handle it, for example, if the container is not yet ready (`pebble-ready` has not yet been triggered). In such cases, charms could try to wait for both events to occur, possibly storing state to track which events have occurred -- but that is error-prone. + +Alternatively, a charm can use a holistic approach and handle both `config-changed` and `pebble-ready` with a single code path, as in this example: + +```python +class MyCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): + super().__init__(framework) + framework.observe(self.on.config_changed, self._update_charm) + framework.observe(self.on['redis'].pebble_ready, self._update_charm) + + def _update_charm(self, _: ops.EventBase): # event parameter isn't used + redis_port = self.config.get('redis-port') + if not redis_port: + # pebble-ready happened first, wait for config-changed + return + + # If both the Pebble container and config are ready, rewrite the + # container's config file and restart Redis if needed. + container = self.unit.get_container('redis') + try: + self._update_redis_config(container, redis_port) + except ops.pebble.ConnectionError: + # config-changed happened first, wait for pebble-ready + return +``` + + +## When to use the holistic approach + +If a charm is waiting for a collection of events, as in the example above, it makes sense to group those events together and handle them holistically, with a single code path. + +In other words, when writing a charm, it's not so much "should the *charm* be holistic?" as "does it make sense for *these events* to be handled holistically?" + +Using the holistic approach is normally centred around configuring an application. Various events that affect configuration use a common handler, to simplify writing an application config file and restarting the application. This is common for events like `config-changed`, `relation-changed`, `secret-changed`, and `pebble-ready`. + +Many existing charms use holistic event handling. A few examples are: + +- [`alertmanager-k8s` uses a `_common_exit_hook` method to unify several event handlers](https://github.com/canonical/alertmanager-k8s-operator/blob/561f1d8eb1dc6e4511c1c0b3cba444a3ec399464/src/charm.py#L390) +- [`hello-kubecon` is a simple charm that handles `config-changed` and `pebble-cready` holistically](https://github.com/jnsgruk/hello-kubecon/blob/dbd133466dde59ee64f20a732a8f3d2e560ec3b8/src/charm.py#L32-L33) +- [`prometheus-k8s` uses a common `_configure` method to handle various events](https://github.com/canonical/prometheus-k8s-operator/blob/84c6a406ed585cdb7ba40e01a258864987d6f67f/src/charm.py#L221-L230) +- [`sdcore-gnbsim-k8s` also uses a common `_configure` method](https://github.com/canonical/sdcore-gnbsim-k8s-operator/blob/ea2afe069346757b1eb6c02de5b4f50f90e81698/src/charm.py#L84-L92) + + +## Which events can be handled holistically? + +Only some events make sense to handle holistically. For example, `remove` is triggered when a unit is about to be terminated, so it doesn't make sense to handle it holistically. + +Similarly, events like `secret-expired` and `secret-rotate` don't make sense to handle holistically, because the charm must do something specific in response to the event. For example, Juju will keep triggering `secret-expired` until the charm creates a new secret revision by calling [`event.secret.set_content()`](https://ops.readthedocs.io/en/latest/#ops.Secret.set_content). + +This is very closely related to [which events can be `defer`red](https://juju.is/docs/sdk/how-and-when-to-defer-events). A good rule of thumb is this: if an event can be deferred, it may make sense to handle it holistically. + +On the other hand, if an event cannot be deferred, the charm cannot handle it holistically. This applies to action "events", `stop`, `remove`, `secret-expired`, `secret-rotate`, and Ops-emitted events such as `collect-status`. \ No newline at end of file diff --git a/docs/explanation/how-and-when-to-defer-events.md b/docs/explanation/how-and-when-to-defer-events.md new file mode 100644 index 000000000..87c8f1e6e --- /dev/null +++ b/docs/explanation/how-and-when-to-defer-events.md @@ -0,0 +1,48 @@ +(how-and-when-to-defer-events)= +# How, and when, to defer events + +Deferring an event is a common pattern, and when used appropriately is a convenient tool for charmers. However, there are limitations to `defer()` - in particular, that the charm has no way to specify when the handler will be re-run, and that event ordering and context move away from the expected pattern. Our advice is that `defer()` is a good solution for some problems, but is best avoided for others. + +## Good: retrying on temporary failure + +If the charm encounters a temporary failure (such as working with a container or an external API), and expects that the failure may be very short lived, our recommendation is to retry several times for up to a second. If the failure continues, but the charm still expects that it will be resolved without any intervention from a human, then deferring the event handler is often a good choice - along with placing the unit or app in waiting status. + +Note that it’s important to consider that when the deferred handler is run again, the Juju context may not be exactly the same as it was when the event was first emitted, so the charm code needs to be aware of this. + +If the temporary failure is because the workload is busy, and the charm is deployed to a Kubernetes sidecar controller, you might be able to avoid the defer using a [Pebble custom notice](https://juju.is/docs/sdk/interact-with-pebble#heading--use-custom-notices-from-the-workload-container). For example, if the code can’t continue because the workload is currently restarting, if you can have a post-completion hook for the restart that executes `pebble notify`, then you can ensure that the charm is ‘woken up’ at the right time to handle the work. + +In the future, we hope to see a Juju ‘request re-emit event’ feature that will let the charm tell Juju when it expects the problem to be resolved. + +## Reconsider: sequencing + +There are some situations where sequencing of units needs to be arranged - for example, to restart replicas before a primary is restarted. Deferring a handler can be used to manage this situation. However, sequencing can also be arranged using a peer relation, and there’s a convenient [rolling-ops charm lib](https://github.com/canonical/charm-rolling-ops) that implements this for you, and we recommend using that approach first. + +Using a peer relation to orchestrate the rolling operation allows for more fine-grained control than a simple defer, and avoids the issue of not having control over when the deferred handler will be re-run. + +## Reconsider: waiting for a collection of events + +It’s common for charms to need a collection of information in order to configure the application (for example, to write a configuration file). For example, the configuration might require a user-set config value, a secret provided by a relation, and a Kubernetes sidecar container to be ready. + +Rather than having the handlers for each of these events (`config-changed`, `secret-changed` and/or `relation-changed`, `pebble-ready`) defer if other parts of the configuration are not yet available, it’s best to have the charm observe all three events and set the unit or app state to waiting, maintenance, or blocked status (or have the `collect-status` handler do this) and return. When the last piece of information is available, the handler that notifies the charm of that will complete the work. This is commonly called the "holistic" event handling pattern. + +Avoiding defer means that there isn’t a queue of deferred handlers that all do the same work - for example, if `config-changed`, `relation-changed`, and `pebble-ready` were all deferred then when they were all ready, they would all run successfully. This is particularly important when the work is expensive - such as an application restart after writing the configuration, so should not be done unnecessarily. + +## OK: waiting without expecting a follow-up event + +In some situations, the charm is waiting for a system to be ready, but it’s not one that will trigger a Juju event (as in the case above). For example, the charm might need the workload application to be fully started up, and that might happen after all of the initial start, `config-changed`, `relation-joined`, `pebble-ready`, etc events. + +Deferring the work here is ok, but it’s important to consider the delay between deferring the event and its eventual re-emitting - it’s not safe to assume that this will be a small period of time, unless you know that another event can be expected. + +For a Kubernetes charm, If the charm is waiting on the workload and it’s possible to have the workload execute a command when it’s ready, then using a [Pebble custom notice](https://juju.is/docs/sdk/interact-with-pebble#heading--use-custom-notices-from-the-workload-container) is much better than deferring. This then becomes another example of “waiting for a collection of events”, described above. + +## Not possible: actions, shutting down, framework generated events, secrets + +In some situations, it’s not possible to defer an event, and attempting to do so will raise a `RuntimeError`. + +In some cases, this is because the events are run with every Juju hook event, such as `pre-commit`, `commit`, and `update-status`. In others, it’s because Juju provides a built-in retry mechanism, such as `secret-expired` and `secret-rotate`. + +With actions, there’s an expectation that the action either succeeds or fails immediately, and there are mechanisms for communicating directly with the user that initiated the action (`event.log` and `event.set_results`). This means that deferring an action event doesn’t make sense. + +Finally, when doing cleanup during the shutdown phase of a charm’s lifecycle, deferring isn’t practical with the current implementation, where it’s tied to future events. For `remove`, for example, the unit will no longer exist after the event, so there will not be any future events that can trigger the deferred one - if there’s work that has to be done before the unit is gone, then you’ll need to enter an error state instead. The stop event is followed by remove, and possibly a few other events, but likewise has few chances to be re-emitted. + +Note that all deferred events vanish when the unit is removed, so the charm code needs to take this into consideration. diff --git a/docs/explanation/index.md b/docs/explanation/index.md new file mode 100644 index 000000000..23826e84a --- /dev/null +++ b/docs/explanation/index.md @@ -0,0 +1,14 @@ +(explanation)= +# Explanation + +```{toctree} +:maxdepth: 1 + +charm-relation-interfaces +testing +interface-tests +holistic-vs-delta-charms +how-and-when-to-defer-events +storedstate-uses-limitations +``` + diff --git a/docs/explanation/interface-tests.md b/docs/explanation/interface-tests.md new file mode 100644 index 000000000..56580e288 --- /dev/null +++ b/docs/explanation/interface-tests.md @@ -0,0 +1,39 @@ +(interface-tests)= +# Interface tests +> See also: {ref}`manage-interfaces` + +Interface tests are tests that verify the compliance of a charm with an interface specification. +Interface specifications, stored in {ref}`charm-relation-interfaces `, are contract definitions that mandate how a charm should behave when integrated with another charm over a registered interface. + +Interface tests will allow `charmhub` to validate the integrations of a charm and verify that your charm indeeed supports "the" `ingress` interface and not just an interface called "ingress", which happens to be the same name as "the official `ingress` interface v2" as registered in charm-relation-interfaces (see [here](https://github.com/canonical/charm-relation-interfaces/tree/main/interfaces/ingress/v2)). + +Also, they allow alternative implementations of an interface to validate themselves against the contractual specification stored in charm-relation-interfaces, and they help verify compliance with multiple versions of an interface. + +An interface test is a contract test powered by {ref}``Scenario` ` and a pytest plugin called [`pytest-interface-tester`](https://github.com/canonical/pytest-interface-tester). An interface test has the following pattern: +1) **GIVEN** an initial state of the relation over the interface under test +2) **WHEN** a specific relation event fires +3) **THEN** the state of the databags is valid (e.g. it satisfies an expected pydantic schema) + +On top of databag state validity, one can check for more elaborate conditions. + +A typical interface test will look like: + +```python +from interface_tester import Tester + +def test_data_published_on_changed_remote_valid(): + """This test verifies that if the remote end has published valid data and we receive a db-relation-changed event, then the schema is satisfied.""" + # GIVEN that we have a relation over "db" and the remote end has published valid data + relation = Relation(endpoint='db', interface='db', + remote_app_data={'model': '"bar"', 'port': '42', 'name': '"remote"', }, + remote_units_data={0: {'host': '"0.0.0.42"', }}) + t = Tester(State(relations=[relation])) + # WHEN the charm receives a db-relation-changed event + state_out = t.run(relation.changed_event) + # THEN the schema is valid + t.assert_schema_valid() +``` + +This allows us to, independently from what charm we are testing, determine if the behavioural specification of this interface is complied with. + + diff --git a/docs/explanation/storedstate-uses-limitations.md b/docs/explanation/storedstate-uses-limitations.md new file mode 100644 index 000000000..5cbf52046 --- /dev/null +++ b/docs/explanation/storedstate-uses-limitations.md @@ -0,0 +1,104 @@ +(storedstate-uses-limitations)= +# StoredState: Uses, Limitations + +... and why charm authors should avoid state when they can. + +## Purpose of this doc + +This is an explanatory doc covering how charm authors might track local state in a Juju unit. We'll cover the Operator Framework's concept of [`StoredState`](https://juju.is/docs/sdk/constructs#heading--stored-state), along with some differences in how it works between machine charms and Kubernetes charms. We'll talk about [Peer Relations](https://juju.is/docs/sdk/relations#heading--peer-relations) as an alternative for storing some kinds of information, and also talk about how charm authors probably should avoid recording state when they can avoid doing so. Relying on the SDK's built in caching facilities is generally the preferred direction for a charm. + +## A trivial example + +We'll begin by setting up a simple scenario. A charm author would like to charm up a (made up) service called `ExampleBlog`. The ideal cloud service is stateless and immutable, but `ExampleBlog` has some state: it can run in either a `production` mode or a `test` mode. + +The standard way to set ExampleBlog's mode is to write either the string `test` or `production` to `/etc/example_blog/mode`, then restart the service. Leaving aside whether this is *advisable* behavior, this is how `ExampleBlog` works, and an `ExampleBlog` veteran user would expect a `ExampleBlog` charm to allow them to toggle modes by writing to that config file. (I sense a patch to upstream brewing, but let's assume, for our example, that we can't dynamically load the config.) + +Here's a simplified charm code snippet that will allow us to toggle the state of an already running instance of `ExampleBlog`. + +```python +def _on_config_changed(self, event): + mode = self.model.config['mode'] + + with open('/etc/example_blog/mode', 'w') as mode_file: + mode_file.write(f'{mode}\n') + + self._restart() +``` + +Assume that `_restart` does something sensible to restart the service -- e.g., calls `service_restart` from the [systemd](https://charmhub.io/operator-libs-linux/libraries/systemd) library in a machine version of this charm. + +## A problematic solution + +The problem with the code as written is that the `ExampleBlog` daemon will restart every time the config-changed hooked fires. That's definitely unwanted downtime! We might be tempted to solve the issue with `StoredState`: + +```python +def __init__(self, *args): + super().__init__(*args) + self._stored.set_default(current_mode="test") + +def _on_config_changed(self, event): + mode = self.model.config['mode'] + if self._stored.current_mode == mode: + return + + with open('/etc/example_blog/mode', 'w') as mode_file: + mode_file.write('{}\n'.format(mode) + + self._restart() + + self._stored.current_mode = mode +``` + +The `StoredState` [docs](https://juju.is/docs/sdk/constructs#heading--stored-state) advise against doing this, for good reason. We have added one to the list of places that attempt to track `ExampleBlog`'s "mode". In addition to the config file on disk, the juju config, and the actual state of the running code, we've added a fourth "instance" of the state: "current_mode" in our `StoredState` object. We've doubled the number of possible states of this part of the system from 8 to 16, without increasing the number of correct states. There are still only two: all set to `test`, or all set to `production`. We have essentially halved the reliability of this part of our code. + +## Differences in StoredState behaviour across substrates + +Let's say the charm is running on Kubernetes, and the container it is running in gets destroyed and recreated. This might happen due to events outside of an operator's control -- perhaps the underlying Kubernetes service rescheduled the pod, for example. In this scenario the `StoredState` will go away, and the flags will be reset. + +Do you see the bug in our example code? We could fix it by setting the initial value in our `StoredState` to something other than `test` or `production`. E.g., `self._stored.set_default(current_mode="unset")`. This will never match the actual intended state, and we'll thus always invoke the codepath that loads the operator's intended state after a pod restart, and write that to the new local disk. + +What if we are tracking some piece of information that *should* survive a pod restart? + +In this case, charm authors can pass `use_juju_for_storage=True` to the charm's `main` routine ([example](https://github.com/canonical/alertmanager-k8s-operator/blob/8371a1424c0a73d62d249ca085edf693c8084279/src/charm.py#L454)). This will allocate some space on the controller to store per unit data, and that data will persist through events that could kill and recreate the underlying pod. Keep in mind that this can cause trouble! In the case of `ExampleBlog`, we clearly would not want the `StoredState` record for "mode" to survive a pod restart -- the correct state is already appropriately stored in Juju's config, and stale state in the controller's storage might result in the charm skipping a necessary config write and restart cycle. + +## Practical suggestions and solutions + +_Most of the time, charm authors should not track state in a charm._ + +More specifically, authors should only use `StoredState` when they are certain that the charm can handle any cache consistency issues, and that tracking the state is actually saving a significant number of unneeded CPU cycles. + +In our example code, for instance, we might think about the fact that `config_changed` hooks, even in a busy cloud, fire with a frequency measured in seconds. It's not particularly expensive to read the contents of a small file every few seconds, and so we might implement the following, which is stateless (or at least, does not hold state in the charm): + +```python +def _on_config_changed(self, event): + with open('/etc/example_blog/mode') as mode_file: + prev_mode = mode_file.read().strip() + if self.model.config['mode'] == prev_mode: + return + + with open('/etc/example_blog/mode', 'w') as mode_file: + mode_file.write(f'{mode}\n') + + self._restart() +``` + +One common scenario where charm authors get tempted to use `StoredState`, when a no-op would be better, is to use `StoredState` to cache information from the Juju model. The Operator Framework already caches information about relations, unit and application names, etc. It reads and loads the charm's config into memory during each hook execution. Authors can simply fetch model and config information as needed, trusting that the Operator Framework is avoiding extra work where it can, and doing extra work to avoid cache coherency issues where it must. + +Another temptation is to track the occurrence of certain events like [`pebble-ready`](https://juju.is/docs/sdk/events#heading--pebble-ready). This is dangerous. The emission of a `pebble-ready` event means that Pebble was up and running when the hook was invoked, but makes no guarantees about the future. Pebble may not remain running -- see the note about the Kubernetes scheduler above -- meaning your `StoredState` contains an invalid cache value which will likely lead to bugs. In cases where charm authors want to perform an action if and only if the workload container is up and running, they should guard against Pebble issues by catching `ops.pebble.ConnectionError`: + +```python +def some_event_handler(event): + try: + self.do_thing_that_assumes_container_running() + except ops.pebble.ConnectionError: + event.defer() + return +``` + +You shouldn't use the container's `can_connect()` method for the same reason - it's a point-in-time check, and Pebble could go away between calling `can_connect()` and when the actual change is executed - ie. you've introduced a race condition. + +In the other cases where state is needed, authors ideally want to relate a charm to a database, attach storage ([see Juju storage](https://juju.is/docs/sdk/storage)), or simply be opinionated, and hard code the single "correct" state into the charm. (Perhaps `ExampleBlog` should always be run in `production` mode when deployed as a charm?) + +In the cases where it is important to share some lightweight configuration data between units of an application, charm author's should look into [peer relations](https://juju.is/docs/sdk/integration#heading--peer-integrations). And in the cases where data must be written to a container's local file system (Canonical's Kubeflow bundle, for example, must do this, because the sheer number of services mean that we run into limitations on attached storage in the underlying cloud), authors should do so mindfully, with an understanding of the pitfalls involved. + +In sum: use state mindfully, with well chosen tools, only when necessary. diff --git a/docs/explanation/testing.md b/docs/explanation/testing.md new file mode 100644 index 000000000..0e2dbb8b6 --- /dev/null +++ b/docs/explanation/testing.md @@ -0,0 +1,112 @@ +(testing)= +# Testing + +Charms should have tests to verify that they are functioning correctly. This document describes some of the various types of testing you may want to consider -- their meaning, recommended coverage, and recommended tooling in the context of a charm. + + + + +## Unit testing + +> See also: {ref}`write-unit-tests-for-a-charm`, {ref}`write-scenario-tests-for-a-charm` + +A **unit test** is a test that targets an individual unit of code (function, method, class, etc.) independently. In the context of a charm, it refers to testing charm code against mock Juju APIs and mocked-out workloads as a way to validate isolated behaviour without external interactions. + +Unit tests are intended to be isolating and fast to complete. These are the tests you would run every time before committing code changes. + +**Coverage.** Unit testing a charm should cover: + +- how relation data is modified as a result of an event +- what pebble services are running as a result of an event +- which configuration files are written and their contents, as a result of an event + +**Tools.** Unit testing a charm can be done using: + +- [`pytest`](https://pytest.org/) and/or [`unittest`](https://docs.python.org/3/library/unittest.html) and +- [`ops.testing.Harness`](https://operator-framework.readthedocs.io/en/latest/#module-ops.testing) and/or {ref}``ops-scenario` ` + + + + + +**Examples.** + +- [https://github.com/canonical/prometheus-k8s-operator/blob/main/tests/unit/test_charm.py](https://github.com/canonical/prometheus-k8s-operator/blob/main/tests/unit/test_charm.py) + +## Interface testing + +In the context of a charm, interface tests help validate charm library behavior without individual charm code against mock Juju APIs. + +> See more: {ref}`interface-tests` + + + +(integration-testing)= +## Integration testing +> See also: {ref}`write-integration-tests-for-a-charm` + +An **integration test** is a test that targets multiple software components in interaction. In the context of a charm, it checks that the charm operates as expected when Juju-deployed by a user in a test model in a real controller. + +Integration tests should be focused on a single charm. Sometimes an integration test requires multiple charms to be deployed for adequate testing, but ideally integration tests should not become end-to-end tests. + +Integration tests typically take significantly longer to run than unit tests. + +**Coverage.** + +* Charm actions +* Charm integrations +* Charm configurations +* That the workload is up and running, and responsive +* Upgrade sequence + * Regression test: upgrade stable/candidate/beta/edge from charmhub with the locally-built charm. + + +```{caution} + +When writing an integration test, it is not sufficient to simply check that Juju reports that running the action was successful; rather, additional checks need to be executed to ensure that whatever the action was intended to achieve worked. + +``` + +**Tools.** + +- [`pytest`](https://pytest.org/) and/or [`unittest`](https://docs.python.org/3/library/unittest.html) and +- [pytest-operator](https://github.com/charmed-kubernetes/pytest-operator) and/or [`zaza`](https://github.com/openstack-charmers/zaza) + + +(pytest-operator)= +### `pytest-operator` + +`pytest-operator` is a Python library that provides Juju plugins for the generic Python library `pytest` to facilitate the {ref}`integration testing ` of charms. + +> See more: [`pytest-operator`](https://github.com/charmed-kubernetes/pytest-operator) + +It builds a fixture called `ops_test` that helps you interact with Juju through constructs that wrap around [`python-libjuju` ](https://pypi.org/project/juju/). + +> See more: +> - [`pytest-operator` > `ops_test`](https://github.com/charmed-kubernetes/pytest-operator/blob/main/docs/reference.md#ops_test) +> - [`pytest` > Fixtures](https://docs.pytest.org/en/6.2.x/fixture.html) + +It also provides convenient markers and command line parameters (e.g., the `@pytest.mark.skip_if_deployed` marker in combination with the `--no-deploy` configuration helps you skip, e.g., a deployment test in the case where you already have a deployment). + + +> See more: +> - [`pytest-operator` > Markers](https://github.com/charmed-kubernetes/pytest-operator/blob/main/docs/reference.md#markers) +> - [`pytest-operator` > Command line parameters](https://github.com/charmed-kubernetes/pytest-operator/blob/main/docs/reference.md#command-line-parameters) + + + +**Examples.** + +- [https://github.com/canonical/prometheus-k8s-operator/blob/main/tests/integration/test_charm.py](https://github.com/canonical/prometheus-k8s-operator/blob/main/tests/integration/test_charm.py) + + diff --git a/docs/howto/get-started-with-charm-testing.md b/docs/howto/get-started-with-charm-testing.md new file mode 100644 index 000000000..7172d5749 --- /dev/null +++ b/docs/howto/get-started-with-charm-testing.md @@ -0,0 +1,336 @@ +(get-started-with-charm-testing)= +# Get started with charm testing + +Testing charm code is an essential part of charming. Here we will see how to get started with it. We will look at the templates we have available and the frameworks we can use to write good unit, integration, and functional tests. + +**What you'll need:** +- knowledge of testing in general +- knowledge of Juju and charms +- knowledge of the Juju models and events, esp. the data involved in a charm's lifecycle + +**What you will learn:** +- What are the starting points for adding tests to a charm? +- What do you typically want to test in a charm? +- How can you do that? + - What can you unit-test? + - How to effectively use the Harness. + - What can you only integration-test? + - What integration testing frameworks are there? + - Where can you apply functional testing? +- How to automate this in a CI pipeline. + +## Charmcraft profiles + +The most popular way to set up a charm project is via `charmcraft init`. For example, to get set up for creating a machine charm, run: + +```text +charmcraft init --profile=machine +``` + +This will provide the following files that you'll use when writing your tests (as well as others for building your charm): + +```text +. +├── pyproject.toml +├── spread.yaml +├── tests +│ ├── integration +│ │ └── test_charm.py +│ ├── spread +│ │ ├── general +│ │ │ └── integration +│ │ │ └── task.yaml +│ │ └── lib +│ │ ├── cloud-config.yaml +│ │ └── test-helpers.sh +│ └── unit +│ └── test_charm.py +└── tox.ini +``` + +There are also profiles for `kubernetes` and for building charms for apps developed with popular frameworks such as Django and Flask. + +> See more: [ Write your first Kubernetes charm for a Flask app](https://juju.is/docs/sdk/write-your-first-kubernetes-charm-for-a-flask-app) + +## Unit testing + +### A charm as an input -> output function + +In production, a charm is an object that comes to life when the Juju agent decides to execute it with a given context (we call that an `event`). +The "inputs" of a charm run are therefore: + + - the event context + - charm configuration + - integration (relation) data + - stored state + +Only the event context is guaranteed to be present. The other input sources are optional, but typically a charm will have at least some config and a few integrations adding to its inputs. + +The charm code executes and typically produces side-effects aimed at its workload (for example: it writes files to a disk, runs commands on a system, or reconfigures a process) or at other charms it integrates with (for example: it writes relation data). We call this 'operating' a workload, and that is what a charm is meant to do. The ways in which a charm operates can be roughly categorised as: + +- system operations (e.g. kill a process, restart a service, write a file, emit to a syslog server, make a HTTP request) +- cloud operations (e.g. deploy a Kubernetes service, launch a VM) +- workload operations (e.g. send a request to a local server, write a config file) +- Juju operations (write relation data) + +If the charm is a machine charm, workload operation calls can be done directly, while if we're talking about a Kubernetes charm, they will likely be mediated by [Pebble](https://github.com/canonical/pebble). + +"Juju operations" are the most 'meta' of them all: they do not affect the workload in and of itself, but they share data which is meant to affect the operation of *other* charms that this charm is integrated with. + + + +### What we are testing when we unit-test + +A 'live', deployed Juju application will have access to all the inputs we discussed above, including environment variables, host system access, and more. Unit tests will typically want to mock all that and focus on mapping inputs to expected outputs. Any combination of the input sources we mentioned above can result in any combination of operations. A few examples of increasing complexity of scenarios we may want to unit test: + + - if this event occurs, assert that the charm emits that system call + - if this event occurs, given this config, assert that the charm writes to the filesystem a config file with this expected content + - if this event occurs, given this relation data and that config value, assert that that system call is made and this relation data is written (to another relation) + +You will notice that the starting point is typically always an event. A charm doesn't do anything unless it's being run, and it is only run when an event occurs. So there is *always* an event context to be mocked. This has important consequences for the unit-testing framework, as we will see below. + +### The harness + +In the charming world, unit testing means using Harness. + +> See more [`ops.testing.Harness`](https://ops.readthedocs.io/en/latest/harness.html#ops.testing.Harness) + +Harness is the 'mocker' for most inputs and outputs you will need. Where a live charm would gather its input through context variables and calls to the Juju api (by running the hook tools), a charm under unit test will gather data via a mocked backend managed by Harness. Where a live charm would produce output by writing files to a filesystem, Harness exposes a mock filesystem the charm will be able to interact with without knowing the difference. More specific outputs, however, will need to be mocked individually. + +A typical test with Harness will look like this: + +- set things up: + - set up the charm and its metadata + - set up the harness + - mock any 'output' callable that you know would misfire or break (e.g. a system call -- you don't want a unittest to reboot your laptop) + - configure the charm + - mock any relation data + - **mock an event** + - get the output + - run assertions on the output + +> Obviously, other flows are possible; for example, where you unit test individual charm methods without going through the whole event context setup, but this is the characteristic one. + +### Understanding Harness + +When you instantiate a `Harness` object, the charm instance does not exist yet. Just like in a live charm, it is possible that when the charm is executed for the first time, the Juju model already has given it storage, relations, some config, or leadership. This delay is meant to give us a chance to simulate this in our test setup. You create a `Harness` object, then you prepare the 'initial state' of the model mock, then you finally initialise the charm and simulate one or more events. + +There are two ways to initialize a harnessed charm: + + * When a charm is deployed, it goes through the Setup phase, a fixed sequence of events. `Harness` has a method, `begin_with_initial_hooks()`, that runs this sequence. + * Alternatively, you can initialise the charm by calling `begin()`. This will instantiate the charm without firing any Setup phase event. + + + +After the Setup phase, the charm goes into Operation. To test operation-phase-related events, the harness provides some methods to simulate the most common scenarios. For example: + + - the cloud admin changes the charm config: `harness.update_config` + - the cloud admin relates this charm to some other: `harness.add_relation` + - a remote unit joins in a relation (e.g. because the cloud admin has scaled up a remote charm): `harness.add_relation_unit` + - a remote unit touches its relation data: `harness.update_relation_data` + - the cloud admin removes a relation: `harness.remove_relation` + - a resource is attached/detached: `harness.attach_storage` / `harness.detach_storage` + - a container becomes ready: `harness.container_pebble_ready` + +Therefore, one typically will not have to manually `.emit()` events, but can rely on the `Harness` utilities and focus on the higher level abstractions that they expose. + +### Writing a test + +The typical way in which we want to structure a test is: + - configure the required inputs + - mock any function or system call you need to + - initialise the charm + - fire some event OR use one of the harness methods to trigger a predefined event sequence + - assert some output matches what is expected, or some function is called with the expected parameters, etc... + +A simple test might look like this: + +```python +from charm import MyCharm +from ops import testing + +def test_pebble_ready_writes_config_file(): + """Test that on pebble-ready, a config file is written""" + harness: testing.Harness[MyCharm] = testing.Harness(MyCharm) + # If you want to mock charm config: + harness.update_config({'foo': 'bar'}) + # If you want to mock charm leadership: + harness.set_leader(True) + + # If you want to mock relation data: + relation_ID = harness.add_relation('relation-name', 'remote-app-name') + harness.add_relation_unit(relation_ID, 'remote-app-name/0') + harness.update_relation_data(relation_ID, 'remote-app-name/0', {'baz': 'qux'}) + + # We are done setting up the inputs. + + harness.begin() + charm = harness.charm # This is a MyCharm instance. + + # This will fire a `-pebble-ready` event. + harness.container_pebble_ready("workload") + + # Suppose that MyCharm has written a YAML config file via Pebble.push(): + container = charm.unit.get_container("workload") + file = "/opt/workload/path_to_config_file.yaml" + config = yaml.safe_load(container.pull(file).read()) + assert config[0]['foo']['bar'] == 'baz' # or whatever +``` + +```{note} + +An important difference between a harnessed charm and a 'live', deployed, charm is that `Harness` holds on to the charm instance between events, while a deployed charm garbage-collects the charm instance between hooks. So if your charm were to set some states in, say, instance attributes, and rely on it on subsequent event handling loops, the unit tests based on the harness would not be able to catch that mistake. An integration test would. + +``` + +## Integration testing + +Where unit testing focuses on black-box method-by-method verification, integration testing focuses on the big picture. Typically integration tests check that the charm does not break (generally this means: blocks with status `blocked` or `error`) when a (mocked) cloud admin performs certain operations. These operations are scripted by using, in order of abstraction: + - shell commands against [the `juju` cli](https://juju.is/docs/olm/juju-cli-commands) + - [`python-libjuju`](https://github.com/juju/python-libjuju), wrapping juju api calls + - [`pytest-operator`](https://github.com/charmed-kubernetes/pytest-operator), a `pytest` plugin wrapping `python-libjuju` + - [`zaza`](https://zaza.readthedocs.io/en/latest/index.html), a testing-framework-agnostic wrapper on top of `python-libjuju` + +Things you typically want to test with integration tests: + - The charm can be packed (i.e. `charmcraft pack` does not error) + - The charm can be deployed (i.e. `juju deploy ./packed_charm.charm` deploys an application that reaches `active` or `waiting` within a reasonable time frame) + +These are 'smoke tests' that should always be present, and are provided for you when using `charmcraft init`. The following are non-smokey, proper integration tests. +- The charm can be related to other applications without erroring + - and the relation has the expected effect on the charm's operation logic +- The charm can be configured + - and the config has the expected effect on the charm's operation logic +- The actions supported by the charm can be executed + - and return the expected results +- Given any combination of the above relations, configs, etc, the charm 'works': the workload it operates does whatever it is supposed to do. + +### Testing with `pytest-operator` + +The integration test template that `charmcraft init` provides includes the starting point for writing integration tests with `pytest-operator` and `python-libjuju`. The `tox.ini` is also configured so that you can run `tox -e integration` to run the tests, provided that you have a cloud (such as LXD or microk8s) and local Juju client. + +The entry point for all `pytest-operator` tests is the `ops_test` fixture. The fixture is a module-scoped context which, on entry, adds to Juju a randomly-named new model and destroys it on exit. All tests in that module, and all interactions with the `ops_test` object, will take place against that model. + +Once you have used `ops_test` to get a model in which to run your integration tests, most of the remaining integration test code will interact with Juju via the `python-libjuju` package. + +> See more: [`python-libjuju`](https://pythonlibjuju.readthedocs.io/en/latest/) + +```{note} + +*Pro tip*: you can prevent `ops_test` from tearing down the model on exit by passing the `--keep-models` argument. This is useful when the tests fail and the logs don't provide a sufficient post-mortem and a real live autopsy is required. + +``` + +Detailed documentation of how to use `ops_test` and `pytest-operator` is out of scope for this document. However, this is an example of a typical integration test: + +```python +async def test_operation(ops_test: OpsTest): + # Tweak the config: + app: Application = ops_test.model.applications.get("tester") + await app.set_config({"my-key": "my-value"}) + + # Add another charm and relate them: + await ops_test.model.deploy('other-app') + await ops_test.model.relate('tester:endpoint1', 'other-charm:endpoint2') + + # Scale it up: + await app.add_unit(2) + + # Remove another app: + await ops_test.model.remove_application('yet-another-app') + + # Run an action on a unit: + unit: Unit = app.units[1] + action = await unit.run('my-action') + assert action.results == + + # What this means depends on the workload: + assert charm_operates_correctly() +``` + +`python-libjuju` has, of course, an API for all inverse operations: remove an app, scale it down, remove a relation... + +A good integration testing suite will check that the charm continues to operate as expected whenever possible, by combining these simple elements. + +## Functional testing + +Some charms represent their workload by means of an object-oriented wrapper, which mediates between operator code and the implementation of operation logic. In such cases, it can be useful to add a third category of tests, namely functional tests, that black-box test that workload wrapper without worrying about the substrate it runs on (the charm, the cloud, the machine or pod...). +For an example charm adopting this strategy, see [parca-operator](https://github.com/jnsgruk/parca-operator). Nowadays, the preferred tool to do functional testing is Scenario. + +> See more: [Scenario](https://github.com/canonical/ops-scenario), {ref}`Write a Scenario test for a charm ` + +## Continuous integration + +Typically, you want the tests to be run automatically against any PR into your repository's main branch, and sometimes, to trigger a new release whenever that succeeds. CD is out of scope for this article, but we will look at how to set up a basic CI. + +Create a file called `.github/workflows/ci.yaml`. For example, to include a `lint` job that runs the `tox` `lint` environment: + +```yaml +name: Tests +on: + workflow_call: + +jobs: + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Install dependencies + run: python3 -m pip install tox + - name: Run linters + run: tox -e lint +``` + +Other `tox` environments can be run similarly; for example unit tests: + +```yaml + unit-test: + name: Unit tests + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Install dependencies + run: python -m pip install tox + - name: Run tests + run: tox -e unit +``` + +Integration tests are a bit more complex, because in order to run those tests, a Juju controller and a cloud in which to deploy it, is required. This example uses a `actions-operator` workflow provided by `charmed-kubernetes` in order to set up `microk8s` and Juju: + +``` + integration-test-microk8s: + name: Integration tests (microk8s) + needs: + - lint + - unit-test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v3 + - name: Setup operator environment + uses: charmed-kubernetes/actions-operator@main + with: + provider: microk8s + - name: Run integration tests + # Set a predictable model name so it can be consumed by charm-logdump-action + run: tox -e integration -- --model testing + - name: Dump logs + uses: canonical/charm-logdump-action@main + if: failure() + with: + app: my-app-name + model: testing +``` + +> You can find more actions, advanced documentation and use cases in [charming-actions](https://github.com/canonical/charming-actions) + +## Conclusion + +We have examined all angles one might take when testing a charm, and given a brief overview of the most popular frameworks for implementing unit and integration tests, all the way to how one would link them up with a CI system to make sure the repository remains clean and tested. + diff --git a/docs/howto/index.md b/docs/howto/index.md new file mode 100644 index 000000000..8269419a2 --- /dev/null +++ b/docs/howto/index.md @@ -0,0 +1,28 @@ +(how-to-guides)= +# How-to guides + +```{toctree} +:maxdepth: 2 + +Manage logs +Run workloads with a charm machines +Run workloads with a charm Kubernetes +Manage storage +Manage resources +Manage actions +Manage configurations +Manage relations +Manage leadership changes +Manage libraries +Manage interfaces +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 integration 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 new file mode 100644 index 000000000..c9e8ed5f2 --- /dev/null +++ b/docs/howto/manage-actions.md @@ -0,0 +1,218 @@ +(manage-actions)= +# How to manage actions + + + +## Implement the feature + +### Declare the action in `charmcraft.yaml` + +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: + description: Take a snapshot of the database. + params: + filename: + type: string + description: The name of the snapshot file. + compression: + type: object + description: The type of compression to use. + properties: + kind: + type: string + enum: + - gzip + - bzip2 + - xz + default: gzip + quality: + description: Compression quality + type: integer + default: 5 + minimum: 0 + maximum: 9 + required: + - filename + 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: + +``` +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: + +``` +def _on_grant_admin_role_action(self, event): + """Handle the grant-admin-role action.""" + # Fetch the user parameter from the ActionEvent params dict + user = event.params["user"] + # Do something useful with it + cmd = ["/usr/bin/myapp", "roles", "system_admin", user] + # Set a log message for the action + event.log(f"Running this command: {' '.join(cmd)}") + granted = subprocess.run(cmd, capture_output=True) + if granted.returncode != 0: + # Fail the action if there is an error + event.fail( + f"Failed to run '{' '.join(cmd)}'. Output was:\n{granted.stderr.decode('utf-8')}" + ) + else: + # Set the results of the action + msg = f"Ran grant-admin-role for user '{user}'" + event.set_results({"result": msg}) +``` + +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: + +```python +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) + + +#### Report that an action has failed + +To report that an action has failed, in the event handler definition, use the fail() method along with a message explaining the failure to be shown to the person running the action. Note that the `fail()` method doesn’t interrupt code execution, so you will usually want to immediately follow the call to `fail()` with a `return`, rather than continue with the event handler. For example: + +```python +def _on_snapshot(self, event: ops.ActionEvent): + filename = event.params['filename'] + kind = event.params['compression']['kind'] + quality = event.params['compression']['quality'] + cmd = ['/usr/bin/do-snapshot', f'--kind={kind}', f'--quality={quality}', filename] + subprocess.run(cmd, capture_output=True) + if granted.returncode != 0: + event.fail( + f"Failed to run {' '.join(cmd)!r}. Output was:\n{granted.stderr.decode('utf-8')}" + ) + ... +``` + +> See more: [`ops.ActionEvent.fail`](https://ops.readthedocs.io/en/latest/#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) + + +#### 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') + self.snapshot_table1() + event.log('Table1 complete') + self.snapshot_table2() + event.log('Table2 complete') + self.snapshot_table3() +``` +> See more: [`ops.ActionEvent.log`](https://ops.readthedocs.io/en/latest/#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' + logger.info("Using %s as the temporary backup filename in task %s", filename, event.id) + self.create_backup(temp_filename) + ... +``` +> See more: [`ops.ActionEvent.id`](https://ops.readthedocs.io/en/latest/#ops.ActionEvent.id) + + +## Test the feature + +> See first: {ref}`get-started-with-charm-testing` + +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. +For example: +```python +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 +``` +> See more: [Scenario action testing](https://github.com/canonical/ops-scenario?tab=readme-ov-file#actions) + + +### 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: + +```python +async def test_logger(ops_test): + app = ops_test.model.applications[APP_NAME] + unit = app.units[0] # Run the action against the first unit. + action = await unit.run_action('snapshot', filename='db-snapshot.tar.gz') + action = await action.wait() + assert action.status == 'completed' + assert action.results['snapshot-size'].isdigit() +``` + diff --git a/docs/howto/manage-configurations.md b/docs/howto/manage-configurations.md new file mode 100644 index 000000000..d80854f85 --- /dev/null +++ b/docs/howto/manage-configurations.md @@ -0,0 +1,124 @@ +(manage-configurations)= +# Manage configurations + + + + +## Implement the feature + +### Define a configuration option + +In the `charmcraft.yaml` file of the charm, under `config.options`, add a configuration definition, including a name, a description, the type, and the default value. The example below shows how to define two configuration options, one called `name` of type `string` and default value `Wiki`, and one called `skin` with type `string` and default value `vector`: + +```text +config: + options: + name: + default: Wiki + description: The name, or Title of the Wiki + type: string + skin: + default: vector + description: skin for the Wiki + 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: + +```python +self.framework.observe(self.on.config_changed, self._on_config_changed) +``` + +Then, in the body of the charm definition, define the event handler. Here you may want to read the current configuration value, validate it (Juju only checks that the *type* is valid), and log it, among other things. Sample code for an option called `server-port`, with type `int`, and default value `8000`: + + ```python +def _on_config_changed(self, event): + port = self.config["server-port"] + + if port == 22: + self.unit.status = ops.BlockedStatus("invalid port number, 22 is reserved for SSH") + return + + 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) + +```{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 + +**If your charm is a Kubernetes charm and the config affects the workload:** Update the Pebble layer to fetch the current configuration value and then restart the Pebble layer. + + + +## Test the feature + +> 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: + +```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}) + + 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) + + state_out = ctx.run("config_changed", scenario.State(config={"server-port": 22})) + + assert isinstance(state_out.unit_status, ops.BlockedStatus) +``` + +### Test-deploy + +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-interfaces.md b/docs/howto/manage-interfaces.md new file mode 100644 index 000000000..cec28e89d --- /dev/null +++ b/docs/howto/manage-interfaces.md @@ -0,0 +1,482 @@ +(manage-interfaces)= +# How to manage interfaces + +(register-an-interface)= +## Register an interface + + +Suppose you have determined that you need to create a new relation interface called `my_fancy_database`. + +Suppose that your interface specification has the following data model: +- the requirer app is supposed to forward a list of tables that it wants to be provisioned by the database provider +- the provider app (the database) at that point will reply with an API endpoint and, for each replica, it will provide a separate secret ID to authenticate the requests + +These are the steps you need to take in order to register it with {ref}``charm-relation-interfaces` `. + + +```{dropdown} Expand to preview some example results + +- [A bare-minimum example](https://github.com/IronCore864/charm-relation-interfaces/tree/my-fancy-database/interfaces/my_fancy_database/v0) +- [A more realistic example](https://github.com/canonical/charm-relation-interfaces/tree/main/interfaces/ingress/v1): + - As you can see from the [`interface.yaml`](https://github.com/canonical/charm-relation-interfaces/blob/main/interfaces/ingress/v1/interface.yaml) file, the [`canonical/traefik-k8s-operator` charm](https://github.com/canonical/traefik-k8s-operator) plays the provider role in the interface. + - The schema of this interface is defined in [`schema.py`](https://github.com/canonical/charm-relation-interfaces/blob/main/interfaces/ingress/v1/schema.py). + - You can find out more information about this interface in the [README](https://github.com/canonical/charm-relation-interfaces/blob/main/interfaces/ingress/v1/README.md). + + +``` + + +### 1. Clone (a fork of) [the `charm-relation-interfaces` repo](https://github.com/canonical/charm-relation-interfaces) and set up an interface specification folder + +```bash +git clone https://github.com/canonical/charm-relation-interfaces +cd /path/to/charm-relation-interfaces +``` + +### 2. Make a copy of the template folder +Copy the template folder to a new folder called the same as your interface (with underscores instead of dashes). + +```bash +cp -r ./interfaces/__template__ ./interfaces/my_fancy_database +``` + +At this point you should see this directory structure: + +``` +# tree ./interfaces/my_fancy_database +./interfaces/my_fancy_database +└── v0 + ├── README.md + ├── interface.yaml + ├── interface_tests + └── schema.py +2 directories, 3 files +``` + +### 3. Edit `interface.yaml` + +Add to `interface.yaml` the charm that owns the reference implementation of the `my_fancy_database` interface. Assuming your `my_fancy_database_charm` plays the `provider` role in the interface, your `interface.yaml` will look like this: + +```yaml +# interface.yaml +providers: + - name: my-fancy-database-operator # same as metadata.yaml's .name + url: https://github.com/your-github-slug/my-fancy-database-operator +``` + +### 4. Edit `schema.py` + +Edit `schema.py` to contain: + +```python +# schema.py + +from interface_tester.schema_base import DataBagSchema +from pydantic import BaseModel, AnyHttpUrl, Field, Json +import typing + + +class ProviderUnitData(BaseModel): + secret_id: str = Field( + description="Secret ID for the key you need in order to query this unit.", + title="Query key secret ID", + examples=["secret:12312323112313123213"], + ) + + +class ProviderAppData(BaseModel): + api_endpoint: AnyHttpUrl = Field( + description="URL to the database's endpoint.", + title="Endpoint API address", + examples=["https://example.com/v1/query"], + ) + + +class ProviderSchema(DataBagSchema): + app: ProviderAppData + unit: ProviderUnitData + + +class RequirerAppData(BaseModel): + tables: Json[typing.List[str]] = Field( + description="Tables that the requirer application needs.", + title="Requested tables.", + examples=[["users", "passwords"]], + ) + + +class RequirerSchema(DataBagSchema): + app: RequirerAppData + # we can omit `unit` because the requirer makes no use of the unit databags +``` + +To verify that things work as they should, you can `pip install pytest-interface-tester` and then run `interface_tester discover --include my_fancy_database` from the `charm-relation-interfaces` root. + +You should see: + +```yaml +- my_fancy_database: + - v0: + - provider: + - + - schema OK + - charms: + - my_fancy_database_charm (https://github.com/your-github-slug/my-fancy-database-operator) custom_test_setup=no + - requirer: + - + - schema OK + - +``` + +In particular pay attention to `schema`. If it says `NOT OK` then there is something wrong with the pydantic model. + +### 5. Edit `README.md` + +Edit the `README.md` file to contain: + +```markdown +# `my_fancy_database` + +## Overview +This relation interface describes the expected behavior between of any charm claiming to be able to interface with a Fancy Database and the Fancy Database itself. +Other Fancy Database-compatible providers can be used interchangeably as well. + +## Usage + +Typically, you can use the implementation of this interface from [this charm library](https://github.com/your_org/my_fancy_database_operator/blob/main/lib/charms/my_fancy_database/v0/fancy.py), although charm developers are free to provide alternative libraries as long as they comply with this interface specification. + +## Direction +The `my_fancy_database` interface implements a provider/requirer pattern. +The requirer is a charm that wishes to act as a Fancy Database Service consumer, and the provider is a charm exposing a Fancy Database (-compatible API). + +/```mermaid +flowchart TD + Requirer -- tables --> Provider + Provider -- endpoint, access_keys --> Requirer +/``` + +## Behavior + +The requirer and the provider must adhere to a certain set of criteria to be considered compatible with the interface. + +### Requirer + +- Is expected to publish a list of tables in the application databag + + +### Provide + +- Is expected to publish an endpoint URL in the application databag +- Is expected to create and grant a Juju Secret containing the access key for each shard and publish its secret ID in the unit databags. + +## Relation Data + +See the {ref}`\[Pydantic Schema\] <12689md>` + + +### Requirer + +The requirer publishes a list of tables to be created, as a json-encoded list of strings. + +#### Example +\```yaml +application_data: { + "tables": "{ref}`'users', 'passwords']" +} +\``` + +### Provider + +The provider publishes an endpoint url and access keys for each shard. + +#### Example +\``` +application_data: { + "api_endpoint": "https://foo.com/query" +}, +units_data : { + "my_fancy_unit/0": { + "secret_id": "secret:12312321321312312332312323" + }, + "my_fancy_unit/1": { + "secret_id": "secret:45646545645645645646545456" + } +} +\``` +``` + +### 6. Add interface tests + +> See more: {ref}`write-tests-for-an-interface` + +### 7. Open a PR to [the `charm-relation-interfaces` repo](https://github.com/canonical/charm-relation-interfaces) + +Finally, open a pull request to the `charm-relation-interfaces` repo and drive it to completion, addressing any feedback or concerns that the maintainers may have. + + +(write-tests-for-an-interface)= +## Write tests for an interface + +> See also: {ref}`interface-tests` + +Suppose you have an interface specification in {ref}`charm-relation-interfaces`, or you are working on one, and you want to add interface tests. These are the steps you need to take. + +We will continue from the running example from {ref}`register-an-interface`. Your starting setup should look like this: + +```text +$ tree ./interfaces/my_fancy_database +./interfaces/my_fancy_database +└── v0 + ├── interface.yaml + ├── interface_tests + ├── README.md + └── schema.py + +2 directories, 3 files +``` + + +### Create the test module + +Add a file to the `interface_tests` directory called `test_provider.py`. +> touch ./interfaces/my_fancy_database/interface_tests/test_provider.py + +### Write a test for the 'negative' path + +Write to `test_provider.py` the code below: + +```python +from interface_tester import Tester +from scenario import State, Relation + + +def test_nothing_happens_if_remote_empty(): + # GIVEN that the remote end has not published any tables + t = Tester( + State( + leader=True, + relations={ + Relation( + endpoint="my-fancy-database", # the name doesn't matter + interface="my_fancy_database", + ) + }, + ) + ) + # WHEN the database charm receives a relation-joined event + state_out = t.run("my-fancy-database-relation-joined") + # THEN no data is published to the (local) databags + t.assert_relation_data_empty() +``` + +This test verifies part of a 'negative' path: it verifies that if the remote end did not (yet) comply with his part of the contract, then our side did not either. + +### Write a test for the 'positive' path + +Append to `test_provider.py` the code below: + +```python +import json + +from interface_tester import Tester +from scenario import State, Relation + + +def test_contract_happy_path(): + # GIVEN that the remote end has requested tables in the right format + tables_json = json.dumps(["users", "passwords"]) + t = Tester( + State( + leader=True, + relations=[ + Relation( + endpoint="my-fancy-database", # the name doesn't matter + interface="my_fancy_database", + remote_app_data={"tables": tables_json}, + ) + ], + ) + ) + # WHEN the database charm receives a relation-changed event + state_out = t.run("my-fancy-database-relation-changed") + # THEN the schema is satisfied (the database charm published all required fields) + t.assert_schema_valid() +``` + +This test verifies that the databags of the 'my-fancy-database' relation are valid according to the pydantic schema you have specified in `schema.py`. + +To check that things work as they should, you can run `interface_tester discover --include my_fancy_database` from the `charm-relation-interfaces` root. + +```{note} + +Note that the `interface_tester` is installed in the previous how-to guide [How to register an interface `. If you haven't done it yet, install it by running: `pip install pytest-interface-tester `. + +``` + +You should see: + +```yaml +- my_fancy_database: + - v0: + - provider: + - test_contract_happy_path + - test_nothing_happens_if_remote_empty + - schema OK + - charms: + - my_fancy_database_charm (https://github.com/your-github-slug/my-fancy-database-operator) custom_test_setup=no + - requirer: + - + - schema OK + - +``` + +In particular, pay attention to the `provider` field. If it says `` then there is something wrong with your setup, and the collector isn't able to find your test or identify it as a valid test. + +Similarly, you can add tests for requirer in `./interfaces/my_fancy_database/v0/interface_tests/test_requirer.py`. Don't forget to edit the `interface.yaml` file in the "requirers" section to add the name of the charm and the URL. See the "Edit `interface.yaml`" section in the previous how-to guide "How to register an interface" for more detail on editing `interface.yaml`. [Here](https://github.com/IronCore864/charm-relation-interfaces/tree/my-fancy-database/interfaces/my_fancy_database/v0) is an example of tests for requirers added. + +### Merge in charm-relation-interfaces + +You are ready to merge this files in the charm-relation-interfaces repository. Open a PR and drive it to completion. + +#### Prepare the charm + +In order to be testable by charm-relation-interfaces, the charm needs to expose and configure a fixture. + +```{note} + +This is because the `fancy-database` interface specification is only supported if the charm is well-configured and has leadership, since it will need to publish data to the application databag. +Also, interface tests are Scenario tests and as such they are mock-based: there is no cloud substrate running, no Juju, no real charm unit in the background. So you need to patch out all calls that cannot be mocked by Scenario, as well as provide enough mocks through State so that the charm is 'ready' to support the interface you are testing. + +``` + +Go to the Fancy Database charm repository root. + +```text +cd path/to/my-fancy-database-operator +``` + +Create a `conftest.py` file under `tests/interface`: + +> mkdir ./tests/interface +> touch ./tests/interface/conftest.py + +Write in `conftest.py`: + +```python +import pytest +from charm import MyFancyDatabaseCharm +from interface_tester import InterfaceTester +from scenario.state import State + + +@pytest.fixture +def interface_tester(interface_tester: InterfaceTester): + interface_tester.configure( + charm_type=MyFancyDatabaseCharm, + state_template=State( + leader=True, # we need leadership + ), + ) + # this fixture needs to yield (NOT RETURN!) interface_tester again + yield interface_tester +``` + +```{note} + +This fixture overrides a homonym pytest fixture that comes with `pytest-interface-tester`. + +``` + + +````{note} + +You can configure the fixture name, as well as its location, but that needs to happen in the `charm-relation-interfaces` repo. Example: +``` +providers: + - name: my-fancy-database-provider + url: YOUR_REPO_URL + test_setup: + location: tests/interface/conftest.py + identifier: database_tester +``` + +```` + + +#### Verifying the `interface_tester` configuration + +To verify that the fixture is good enough to pass the interface tests, run the `run_matrix.py` script from the `charm-relation-interfaces` repo: + +```bash +cd path/to/charm-relation-interfaces +python run_matrix.py --include my_fancy_database +``` + +If you run this test, unless you have already merged the interface tests PR to `charm-relation-interfaces`, it will fail with some error message telling you that it's failing to collect the tests for the interface, because by default, `pytest-interface-tester` will try to find tests in the `canonical/charm-relation-interfaces` repo's `main` branch. + +To run tests with a branch in your forked repo, run: + +```bash +cd path/to/my-forked/charm-relation-interfaces +python run_matrix.py --include my_fancy_database --repo https://github.com/your-github-slug/charm-relation-interfaces --branch my-fancy-database +``` + +```{note} + +In the above command, remember to replace `your-github-slug` to your own slug, change the repo name accordingly (if you have renamed the forked repo), and update the `my-fance-database` branch name from the above command to the branch that contains your tests. + +``` + +Now the tests should be collected and executed. You should get similar output to the following: + +```bash +INFO:root:Running tests for interface: my_fancy_database +INFO:root:Running tests for version: v0 +INFO:root:Running tests for role: provider + +... + ++++ Results +++ +{ + "my_fancy_database": { + "v0": { + "provider": { + "my-fancy-database-operator": true + }, + "requirer": { + "my-fancy-database-operator": true + } + } + } +} +``` + +For reference, [here](https://github.com/IronCore864/my-fancy-database-operator) is an example of a bare minimum `my-fancy-database-operator` charm to make the test pass. In the charm, application relation data and unit relation data are set according to our definition (see the beginning part of the previous how-to guide "How to register an interface". + +### Troubleshooting and debugging the tests + +#### Your charm is missing some configurations/mocks + +Solution to this is to add the missing mocks/patches to the `interface_tester` fixture in `conftest.py`. +Essentially, you need to make it so that the charm runtime 'thinks' that everything is normal and ready to process and accept the interface you are testing. +This may mean mocking the presence and connectivity of a container, system calls, substrate API calls, and more. +If you have scenario or unittests in your codebase, you most likely already have all the necessary patches scattered around and it's a matter of collecting them. + +Remember that if you run your tests using `run_matrix.py` locally, in your troubleshooting you need to point `interface.yaml` to the branch where you committed your changes as `run_matrix` fetches the charm repositories in order to run the charms: + +```text +requirers: + - name: my-fancy-database-operator + url: https://my-fancy-database-operator-repo + branch: branch-with-my-conftest-changes +``` +Remember, however, to merge the changes first in the operator repository before merging the pull request to `charm-relation-interfaces`. + +> See more: +> +> [Here](https://github.com/IronCore864/my-fancy-database-operator) is a minimum charm that both provides and requires the `my_fancy_database` interface from this how-to guide and [this](https://github.com/IronCore864/my-fancy-database-operator/blob/main/tests/interface/conftest.py) is an example of the bare minimum of `conftest.py`. See the content of `test_provider.py` [here in a forked repo](https://github.com/IronCore864/charm-relation-interfaces/blob/my-fancy-database/interfaces/my_fancy_database/v0/interface_tests/test_provider.py) and `test_requirer.py` [here](https://github.com/IronCore864/charm-relation-interfaces/blob/my-fancy-database/interfaces/my_fancy_database/v0/interface_tests/test_requirer.py). +> +> For a more realistic reference, refer to the [`test_provider.py`](https://github.com/canonical/charm-relation-interfaces/blob/main/interfaces/ingress/v1/interface_tests/test_provider.py) for the ingress interface defined in the `charm-relation-interfaces` repository, and check out the [`traefik-k8s-operator` charm](https://github.com/canonical/traefik-k8s-operator) for its content of the [`conftest.py`](https://github.com/canonical/traefik-k8s-operator/blob/main/tests/interface/conftest.py) file. + + + diff --git a/docs/howto/manage-leadership-changes.md b/docs/howto/manage-leadership-changes.md new file mode 100644 index 000000000..1546f056b --- /dev/null +++ b/docs/howto/manage-leadership-changes.md @@ -0,0 +1,150 @@ +(manage-leadership-changes)= +# Manage leadership changes + + + +## Implement response to leadership changes + +### Observe the `leader-elected` 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 `leader-elected` event and pair that with an event handler. For example: + +```python +self.framework.observe(self.on.leader_elected, self._on_leader_elected) +``` + +> See more: [`ops.LeaderElectedEvent`](https://ops.readthedocs.io/en/latest/#ops.LeaderElectedEvent) + +Now, in the body of the charm definition, define the event handler. For example, the handler below will update a configuration file: + +```python +def _on_leader_elected(self, event: ops.LeaderElectedEvent): + self.reconfigure(leader=self.unit) +``` + +> Examples: [Tempo reconfiguring ingress on leadership change](https://github.com/canonical/tempo-k8s-operator/blob/3f94027b6173f436968a4736a1f2d89a1f17b2e1/src/charm.py#L263), [Kubeflow Dashboard using a holistic handler to configure on leadership change and other events](https://github.com/canonical/kubeflow-dashboard-operator/blob/02caa736a6ea8986b8cba23b63c08a12aaedb86c/src/charm.py#L82) + +To have the leader notify other units about leadership changes, change data in a peer relation. + +> See more: [Peer Relations](https://juju.is/docs/juju/relation#heading--peer) + +[note status="Use the peer relation rather than `leader-setting-changed`"] +In the past, this was done by observing a `leader-setting-changed` event, which is now deprecated. +[/note] + +Commonly, other event handlers will need to check for leadership. For example, +only the leader unit can change charm application secrets, so checks for +leadership are needed to guard against non-leaders. For example: + +```python +if self.unit.is_leader(): + secret = self.model.get_secret(label="my-label") + secret.set_content({"username": "user", "password": "pass"}) +``` + +Note that Juju guarantees leadership for only 30 seconds after a `leader-elected` +event or an `is-leader` check. If the charm code may run longer, then extra +`is_leader()` calls should be made to ensure that the unit is still the leader. + +## Test response to leadership changes + +> 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: + +```python +class MyCharm(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + if self.unit.is_leader(): + self.unit.status = ops.ActiveStatus('I rule') + else: + self.unit.status = ops.ActiveStatus('I am ruled') + + +@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') +``` + + +## Write integration tests + +> See first: {ref}`write-integration-tests-for-a-charm` + +Juju is in sole control over which unit is the leader, so leadership changes are +not usually tested with integration tests. If this is required, then the test +needs to remove the leader unit (machine charms) or run `juju_stop_unit` in the +charm container (Kubernetes charms). The test then needs to wait up to 60 seconds +for Juju to elect a new leader. + +More commonly, an integration test might want to verify that leader and non-leader behaviour is +as expected. For example: + +```python +async def get_leader_unit(ops_test, app, model=None): + """Utility method to get the current leader unit.""" + leader_unit = None + if model is None: + model = ops_test.model + for unit in model.applications[app].units: + if await unit.is_leader_from_status(): + leader_unit = unit + break + + return leader_unit +``` + +> 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 new file mode 100644 index 000000000..11d04c0f2 --- /dev/null +++ b/docs/howto/manage-libraries.md @@ -0,0 +1,228 @@ +(manage-libraries)= +# Manage libraries + + +## Write a library + +When you're writing libraries, instead of callbacks, you can use custom events; that'll result in a more `ops`-native-feeling API. A custom event is, from a technical standpoint, an EventBase subclass that can be emitted at any point throughout the charm's lifecycle. These events are therefore totally unknown to Juju. They are essentially charm-internal, and can be useful to abstract certain conditional workflows and wrap the toplevel Juju event so it can be observed independently. + +For example, suppose you have a charm lib wrapping a relation endpoint. The wrapper might want to check that the remote end has sent valid data and, if that is the case, communicate it to the charm. For example, suppose that you have a `DatabaseRequirer` object, and the charm using it is interested in knowing when the database is ready. The `DatabaseRequirer` then will be: + +```python +class DatabaseReadyEvent(ops.charm.EventBase): + """Event representing that the database is ready.""" + + +class DatabaseRequirerEvents(ops.framework.ObjectEvents): + """Container for Database Requirer events.""" + ready = ops.charm.EventSource(DatabaseReadyEvent) + +class DatabaseRequirer(ops.framework.Object): + on = DatabaseRequirerEvents() + + def __init__(self, charm, ...): + [...] + self.framework.observe(self.charm.on.database_relation_changed, self._on_db_changed) + + def _on_db_changed(self, e): + if [...]: # check remote end has sent valid data + self.on.ready.emit() +``` + + +## Write tests for a library with Scenario + +In this guide we will go through how to write Scenario tests for a charm library we are developing: + +`/lib/charms/my_charm/v0/my_lib.py` + +The intended behaviour of this library (requirer side) is to copy data from the provider app databags and collate it in the own application databag. +The requirer side library does not interact with any lifecycle event; it only listens to relation events. + +### Setup + +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` + + +### Base test + +```python +# `/tests/scenario/test_my_lib.py` +import pytest +import ops +from scenario import Context, State +from lib.charms.my_Charm.v0.my_lib import MyObject + +class MyTestCharm(ops.CharmBase): + META = { + "name": "my-charm" + } + def __init__(self, framework): + super().__init__(framework) + self.obj = MyObject(self) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + pass + + +@pytest.fixture +def context(): + return 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) +``` + +### 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: + +```python +# `/tests/scenario/test_my_lib.py` +import pytest +import ops +from scenario import Context, State, Relation +from lib.charms.my_Charm.v0.my_lib import MyObject + + +@pytest.fixture(params=["foo", "bar"]) +def endpoint(request): + return request.param + + +@pytest.fixture +def my_charm_type(endpoint): + class MyTestCharm(ops.CharmBase): + META = { + "name": "my-charm", + "requires": + {endpoint: {"interface": "my_interface"}} + } + + def __init__(self, framework): + super().__init__(framework) + self.obj = MyObject(self, endpoint=endpoint) + framework.observe(self.on.start, self._on_start) + + def _on_start(self, _): + pass + + return MyTestCharm + + +@pytest.fixture +def context(my_charm_type): + return 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) + + +@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 + for relation in state_out.relations: + assert not relation.local_app_data # remote side didn't publish any data. + + +@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) + # act + state_out: State = context.run(relations[0].changed_event, 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))} +``` + +### Advanced use cases + +#### 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: + +```python +import pytest +import ops +from scenario import Context, State, Relation +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) + + with context.manager(relations[0].changed_event, 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 + + # assert + charm = mgr.charm # the MyTestCharm instance ops is working with + obj: MyObject = charm.obj + assert obj.data == [ + f"my-data-{n}" for n in range(n_relations) + ] +``` + + +## Use a library + + +Fetch the library. + + + +In your `src/charm.py`, observe the custom events it puts at your disposal. For example, a database library may have provided a `database_relation_ready` event -- a high-level wrapper around the relevant `juju` relation events -- so you use it to manage the database integration in your charm as below: + +```python + +class MyCharm(CharmBase): + def __init__(...): + [...] + self.db_requirer = DatabaseRequirer(self) + self.framework.observe(self.db_requirer.on.ready, self._on_db_ready) +``` + + diff --git a/docs/howto/manage-logs.md b/docs/howto/manage-logs.md new file mode 100644 index 000000000..c2e9fe6e0 --- /dev/null +++ b/docs/howto/manage-logs.md @@ -0,0 +1,68 @@ +(how-to-log-a-message-in-a-charm)= +# How to log a message in a charm + + + + + +To log a message in a charm, import Python's `logging` module, then use the `getLogger()` function with the desired level. For example: + + +```python +import logging +# ... +logger = logging.getLogger(__name__) + +class HelloOperatorCharm(ops.CharmBase): + # ... + + def _on_config_changed(self, _): + current = self.config["thing"] + if current not in self._stored.things: + # Note the use of the logger here + logger.debug("found a new thing: %r", current) + self._stored.things.append(current) +``` + + +> See more: +> - [`logging`](https://docs.python.org/3/library/logging.html), [`logging.getLogger()`](https://docs.python.org/3/library/logging.html#logging.getLogger) +> - [`logging.getLogger().critical()`](https://docs.python.org/3/library/logging.html#logging.Logger.critical) +> - [`logging.getLogger().error()`](https://docs.python.org/3/library/logging.html#logging.Logger.error) +> - [`logging.getLogger().warning()`](https://docs.python.org/3/library/logging.html#logging.Logger.warning) +> - [`logging.getLogger().info()`](https://docs.python.org/3/library/logging.html#logging.Logger.info) +> - [`logging.getLogger().debug()`](https://docs.python.org/3/library/logging.html#logging.Logger.debug) + +Juju automatically picks up logs from charm code that uses the Python [logging facility](https://docs.python.org/3/library/logging.html), so we can use the Juju [`debug-log` command](https://juju.is/docs/juju/juju-debug-log) to display logs for a model. Note that it shows logs from the charm code (charm container), but not the workload container. Read ["Use `juju debug-log`"](https://juju.is/docs/sdk/get-logs-from-a-kubernetes-charm#heading--use-juju-debug-log) for more information. + +Besides logs, `stderr` is also captured by Juju. So, if a charm generates a warning, it will also end up in Juju's debug log. This behaviour is consistent between K8s charms and machine charms. + +**Tips for good practice:** + +- Note that some logging is performed automatically by the Juju controller, for example when an event handler is called. Try not to replicate this behaviour in your own code. + +- Keep developer specific logging to a minimum, and use `logger.debug()` for such output. If you are debugging a problem, ensure you comment out or remove large information dumps (such as config files, etc.) from the logging once you are finished. + +- When passing messages to the logger, do not build the strings yourself. Allow the logger to do this for you as required by the specified log level. That is: + + + +```python +# Do this! +logger.info("Got some information %s", info) +# Don't do this +logger.info("Got some information {}".format(info)) +# Or this ... +logger.info(f"Got some more information {more_info}") +``` diff --git a/docs/howto/manage-relations.md b/docs/howto/manage-relations.md new file mode 100644 index 000000000..ee882d2eb --- /dev/null +++ b/docs/howto/manage-relations.md @@ -0,0 +1,345 @@ +(manage-relations)= +# How to manage relations + +To add integration capabilities to a charm, you’ll have to define the relation in your charm’s charmcraft.yaml file and then add relation event handlers in your charm’s `src/charm.py` file. + + + +## Implement the feature + +### Declare the relation endpoint + +To integrate with another charm, or with itself (to communicate with other units of the same charm), declare the required and optional relations in your charm’s `charmcraft.yaml` file. + +```{caution} + + +**If you're using an existing interface:** + +Make sure to consult [the `charm-relations-interfaces` repository](https://github.com/canonical/charm-relation-interfaces) for guidance about how to implement them correctly. + +**If you're defining a new interface:** + +Make sure to add your interface to [the `charm-relations-interfaces` repository](https://github.com/canonical/charm-relation-interfaces). + + +``` + +To exchange data with other units of the same charm, define one or more `peers` endpoints including an interface name for each. Each peer relation must have an endpoint, which your charm will use to refer to the relation (as [`ops.Relation.name`](https://ops.readthedocs.io/en/latest/#ops.Relation.name)). + +```yaml +peers: + replicas: + interface: charm_gossip +``` + +To exchange data with another charm, define a `provides` or `requires` endpoint including an interface name. By convention, the interface name should be unique in the ecosystem. Each relation must have an endpoint, which your charm will use to refer to the relation (as [`ops.Relation.name`](https://ops.readthedocs.io/en/latest/#ops.Relation.name)). + +```yaml +provides: + smtp: + interface: smtp +``` + +```yaml +requires: + db: + interface: postgresql + limit: 1 +``` + +Note that implementing a cross-model relation is done in the same way as one between applications in the same model. The ops library does not distinguish between data from a different model or from the same model as the one the charm is deployed to. + +Which side of the relation is the “provider” or the “requirer” is often arbitrary, but if one side has a workload that is a server and the other a client, then the server side should be the provider. This becomes important for how Juju sets up network permissions in cross-model relations. + + +If the relation is with a subordinate charm, make sure to set the `scope` field to `container`. + +```yaml +requires: + log-forwarder: + interface: rsyslog-forwarder + scope: container +``` + +Other than this, implement a subordinate relation in the same way as any other relation. Note however that subordinate units cannot see each other’s peer data. + +> See also: [Charm taxonomy](https://juju.is/docs/sdk/charm-taxonomy#heading--subordinate-charms) + +### Add code to use a relation + +#### Using a charm library + +For most integrations, you will now want to progress with using the charm library recommended by the charm that you are integrating with. Read the documentation for the other charm on Charmhub and follow the instructions, which will typically involve adding a requirer object in your charm’s `__init__` and then observing custom events. + +In most cases, the charm library will handle observing the Juju relation events, and your charm will only need to interact with the library’s custom API. Come back to this guide when you are ready to add tests. + +> See more: [Charmhub](https://charmhub.io) + +#### Implementing your own interface + +If you are developing your own interface - most commonly for charm-specific peer data exchange, then you will need to observe the Juju relation events and add appropriate handlers. + +##### Set up a relation + +To do initial setup work when a charm is first integrated with another charm (or, in the case of a peer relation, when a charm is first deployed) your charm will need to observe the relation-created event. For example, a charm providing a database relation might need to create the database and credentials, so that the requirer charm can use the database. In the `src/charm.py` file, in the `__init__` function of your charm, set up `relation-created` event observers for the relevant relations and pair those with an event handler. + +The name of the event to observe is combined with the name of the endpoint. With an endpoint named “db”, to observe `relation-created`, our code would look like: + +```python +framework.observe(self.on.db_relation_created, self._on_db_relation_created) +``` + +Now, in the body of the charm definition, define the event handler. In this example, if we are the leader unit, then we create a database and pass the credentials to use it to the charm on the other side via the relation data: + +```python +def _on_db_relation_created(self, event: ops.RelationCreatedEvent): + if not self.unit.is_leader(): + return + credentials = self.create_database(event.app.name) + event.relation.data[event.app].update(credentials) +``` + +The event object that is passed to the handler has a `relation` property, which contains an [`ops.Relation`](https://ops.readthedocs.io/en/latest/#ops.Relation) object. Your charm uses this object to find out about the relation (such as which units are included, in the [`.units` attribute](https://ops.readthedocs.io/en/latest/#ops.Relation.units), or whether the relation is broken, in the [`.active` attribute](https://ops.readthedocs.io/en/latest/#ops.Relation.active)) and to get and set data in the relation databag. + +> See more: [`ops.RelationCreatedEvent`](https://ops.readthedocs.io/en/latest/#ops.RelationCreatedEvent) + +To do additional setup work when each unit joins the relation (both when the charms are first integrated and when additional units are added to the charm), your charm will need to observe the `relation-joined` event. In the `src/charm.py` file, in the `__init__` function of your charm, set up `relation-joined` event observers for the relevant relations and pair those with an event handler. For example: + +```python +framework.observe(self.on.smtp_relation_joined, self._on_smtp_relation_joined) +``` + +Now, in the body of the charm definition, define the event handler. In this example, a “smtp_credentials” key is set in the unit data with the ID of a secret: + +```python +def _on_smtp_relation_joined(self, event: ops.RelationJoinedEvent): + smtp_credentials_secret_id = self.create_smtp_user(event.unit.name) + event.relation.data[event.unit]["smtp_credentials"] = smtp_credentials_secret_id +``` + +> See more: [`ops.RelationJoinedEvent`](https://ops.readthedocs.io/en/latest/#ops.RelationJoinedEvent) + +##### Exchange data with other units + +To use data received through the relation, have your charm observe the `relation-changed` event. In the `src/charm.py` file, in the `__init__` function of your charm, set up `relation-changed` event observers for each of the defined relations. For example: + +```python +framework.observe(self.on.replicas_relation_changed, self._update_configuration) +``` + +> See more: [[`ops.RelationChangedEvent`](https://ops.readthedocs.io/en/latest/#ops.RelationChangedEvent)](https://discourse.charmhub.io/t/relation-name-relation-changed-event/6475), [`juju` | Relation (integration)](https://juju.is/docs/juju/relation#heading--permissions-around-relation-databags) + +Most of the time, you should use the same holistic handler as when receiving other data, such as `secret-changed` and `config-changed`. To access the relation(s) in your holistic handler, use the [`ops.Model.get_relation`](https://ops.readthedocs.io/en/latest/#ops.Model.get_relation) method or [`ops.Model.relations`](https://ops.readthedocs.io/en/latest/#ops.Model.relations) attribute. + +> See also: {ref}`holistic-vs-delta-charms` + +If your change will have at most one relation on the endpoint, to get the `Relation` object use `Model.get_relation`; for example: + +```python +rel = self.model.get_relation("db") +if not rel: + # Handle the case where the relation does not yet exist. +``` + +If your charm may have multiple relations on the endpoint, to get the relation objects use `Model.relations` rather than `Model.get_relation` with the relation ID; for example: + +```python +for rel in self.model.relations.get('smtp', ()): + # Do something with the relation object. +``` + +Once your charm has the relation object, it can be used in exactly the same way as when received from an event. + +Now, in the body of the charm definition, define the holistic event handler. In this example, we check if the relation exists yet, and for a provided secret using the ID provided in the relation data, and if we have both of those then we push that into a workload configuration: + +```python +def _update_configuration(self, _: ops.Eventbase): + # This handles secret-changed and relation-changed. + db_relation = self.model.get_relation('db') + if not db_relation: + # We’re not integrated with the database charm yet. + return + secret_id = db_relation.data[self.model.app]['credentials'] + if not secret_id: + # The credentials haven’t been added to the relation by the remote app yet. + return + secret_contents = self.model.get_secret(id=secret_id).get_contents(refresh=True) + self.push_configuration( + username=secret['username'], + password=secret['password'], + ) +``` + +##### Exchange data across the various relations + +To add data to the relation databag, use the [`.data` attribute](https://ops.readthedocs.io/en/latest/#ops.Relation.data) much as you would a dictionary, after selecting whether to write to the app databag (leaders only) or unit databag. For example, to copy a value from the charm config to the relation data: + +```python +def _on_config_changed(self, event: ops.ConfigChangedEvent): + if relation := self.model.get_relation('ingress'): + relation.data[self.app]["domain"] = self.model.config["domain"] +``` + +To read data from the relation databag, again use the `.data` attribute, selecting the appropriate databag, and then using it as if it were a regular dictionary. + +The charm can inspect the contents of the remote unit databags: + +```python +def _on_database_relation_changed(self, event: ops.RelationChangedEvent): + remote_units_databags = { + event.relation.data[unit] for unit in event.relation.units if unit.app is not self.app + } +``` + +Or the peer unit databags: + +```python +def _on_database_relation_changed(self, e: ops.RelationChangedEvent): + peer_units_databags = { + event.relation.data[unit] for unit in event.relation.units if unit.app is self.app + } +``` + +Or the remote leader databag: + +```python +def _on_database_relation_changed(self, event: ops.RelationChangedEvent): + remote_app_databag = event.relation.data[relation.app] +``` + +Or the local application databag: + +```python +def _on_database_relation_changed(self, event: ops.RelationChangedEvent): + local_app_databag = event.relation.data[self.app] +``` + +Or the local unit databag: + +```python +def _on_database_relation_changed(self, event: ops.RelationChangedEvent): + local_unit_databag = event.relation.data[self.unit] +``` + +If the charm does not have permission to do an operation (e.g. because it is not the leader unit), an exception will be raised. + +##### Clean up when a relation is removed + +To do clean-up work when a unit in the relation is removed (for example, removing per-unit credentials), have your charm observe the `relation-departed` event. In the `src/charm.py` file, in the `__init__` function of your charm, set up `relation-departed` event observers for the relevant relations and pair those with an event handler. For example: + +```python +framework.observe(self.on.smtp_relation_departed, self._on_smtp_relation_departed) +``` + +Now, in the body of the charm definition, define the event handler. For example: + +```python +def _on_smtp_relation_departed(self, event: ops.RelationDepartedEvent): + if self.unit != event.departing_unit: + self.remove_smtp_user(event.unit.name) +``` + +> See more: [ops.RelationDepartedEvent](https://ops.readthedocs.io/en/latest/#ops.RelationDepartedEvent) + +To clean up after a relation is entirely removed, have your charm observe the `relation-broken` event. In the `src/charm.py` file, in the `__init__` function of your charm, set up `relation-broken` events for the relevant relations and pair those with an event handler. For example: + +```python +framework.observe(self.on.db_relation_broken, self._on_db_relation_broken) +``` + +Now, in the body of the charm definition, define the event handler. For example: + +```python +def _on_db_relation_broken(self, event: ops.RelationBrokenEvent): + if not self.is_leader(): + return + self.drop_database(event.app.name) +``` + +> See more: [ops.RelationBrokenEvent](https://ops.readthedocs.io/en/latest/#ops.RelationBrokenEvent) + + +## Test the feature + +### Write unit tests + +To write unit tests covering your charm’s behaviour when working with relations, in your `unit/test_charm.py` file, create a `Harness` object and use it to simulate adding and removing relations, or the remote app providing data. For example: + +```python +@pytest.fixture() +def harness(): + harness = testing.Harness(MyCharm) + yield harness + harness.cleanup() + +def test_new_smtp_relation(harness): + # Before the test begins, we have integrated a remote app + # with this charm, so we call add_relation() before begin(). + relation_id = harness.add_relation('smtp', 'consumer_app') + harness.begin() + # For the test, we simulate a unit joining the relation. + harness.add_relation_unit() + assert 'smtp_credentials’ in harness.get_relation_data(relation_id, 'consumer_app/0' ) + +def test_db_relation_broken(harness): + relation_id = harness.add_relation('db', 'postgresql') + harness.begin() + harness.remove_relation(relation_id) + assert harness.charm.get_db() is None + +def test_receive_db_credentials(harness): + relation_id = harness.add_relation('db', 'postgresql') + harness.begin() + harness.update_relation_data(relation_id, harness.charm.app, {'credentials-id': 'secret:xxx'}) + assert harness.charm.db_tables_created() +``` + +> See more: [ops.testing.Harness](https://ops.readthedocs.io/en/latest/harness.html#ops.testing.Harness) + +### Write scenario tests + +For each relation event that your charm observes, write at least one Scenario test. Create a `Relation` object that defines the relation, include that in the input state, run the relation event, and assert that the output state is what you’d expect. For example: + +```python +ctx = scenario.Context(MyCharm) +relation = scenario.Relation(id=1, endpoint='smtp', remote_units_data={1: {}}) +state_in = scenario.State(relations=[relation]) +state_out = context.run(relation.joined_event(remote_unit_id=1), state=state_in) +assert 'smtp_credentials' in state_out.relations[0].remote_units_data[1] +``` + +> See more: [Scenario Relations](https://github.com/canonical/ops-scenario/#relations) + +### Write integration tests + +Write an integration test that verifies that your charm behaves as expected in a real Juju environment. Other than when testing peer relations, as well as deploying your own charm, the test needs to deploy a second application, either the real charm or a facade that provides enough functionality to test against. + +The pytest-operator plugin provides methods to deploy multiple charms. For example: + +```python +# This assumes that your integration tests already include the standard +# build and deploy test that the charmcraft profile provides. + +@pytest.mark.abort_on_fail +async def test_active_when_deploy_db_facade(ops_test: OpsTest): + await ops_test.model.deploy('facade') + await ops_test.model.integrate(APP_NAME + ':postgresql', 'facade:provide-postgresql') + + present_facade('postgresql', model=ops_test.model_name, + app_data={ + 'credentials': 'secret:abc', + }) + + await ops_test.model.wait_for_idle( + apps=[APP_NAME], + status='active', + timeout=600, + ) +``` + +> See more: [`pytest-operator`](https://pypi.org/project/pytest-operator/) + + diff --git a/docs/howto/manage-resources.md b/docs/howto/manage-resources.md new file mode 100644 index 000000000..d32cef5fa --- /dev/null +++ b/docs/howto/manage-resources.md @@ -0,0 +1,72 @@ +(manage-resources)= +# How to manage resources + + + +## Implement the feature + + + +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: + +```yaml +resources: + my-resource: + type: file + filename: somefile.txt + 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: + +```python +# ... +import logging +import ops +# ... +logger = logging.getLogger(__name__) + +def _on_config_changed(self, event): + # Get the path to the file resource named 'my-resource' + try: + resource_path = self.model.resources.fetch("my-resource") + except ops.ModelError as e: + self.unit.status = ops.BlockedStatus( + "Something went wrong when claiming resource 'my-resource; " + "run `juju debug-log` for more info'" + ) + # might actually be worth it to just reraise this exception and let the charm error out; + # depends on whether we can recover from this. + logger.error(e) + return + except NameError as e: + self.unit.status = ops.BlockedStatus( + "Resource 'my-resource' not found; " + "did you forget to declare it in charmcraft.yaml?" + ) + logger.error(e) + return + + # Open the file and read it + with open(resource_path, "r") as f: + content = f.read() + # 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. + + +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: + +```text +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 new file mode 100644 index 000000000..4c304f3a5 --- /dev/null +++ b/docs/howto/manage-secrets.md @@ -0,0 +1,379 @@ +(manage-secrets)= +# How to manage secrets + + + +> Added in `ops 2.0.0`, `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. + +## Secret owner charm + +> By its nature, the content in this section only applies to *charm* secrets. + +### Add and grant access to a secret + +Before secrets, the owner charm might have looked as below: + +```python +class MyDatabaseCharm(ops.CharmBase): + def __init__(self, *args, **kwargs): + ... # other setup + self.framework.observe(self.on.database_relation_joined, + self._on_database_relation_joined) + + ... # other methods and event handlers + + def _on_database_relation_joined(self, event: ops.RelationJoinedEvent): + event.relation.data[self.app]['username'] = 'admin' + event.relation.data[self.app]['password'] = 'admin' # don't do this at home +``` + +With secrets, this can be rewritten as: + +```python +class MyDatabaseCharm(ops.CharmBase): + def __init__(self, *args, **kwargs): + ... # other setup + self.framework.observe(self.on.database_relation_joined, + self._on_database_relation_joined) + + ... # other methods and event handlers + + def _on_database_relation_joined(self, event: ops.RelationJoinedEvent): + content = { + 'username': 'admin', + 'password': 'admin', + } + secret = self.app.add_secret(content) + secret.grant(event.relation) + event.relation.data[self.app]['secret-id'] = secret.id +``` + +Note that: +- We call `add_secret` on `self.app` (the application). That is because we want the secret to be owned by this application, not by this unit. If we wanted to create a secret owned by the unit, we'd call `self.unit.add_secret` instead. +- 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), + + + +### 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 +class MyDatabaseCharm(ops.CharmBase): + + ... # as before + + def _rotate_webserver_secret(self, secret): + content = secret.get_content() + secret.set_content({ + 'username': content['username'], # keep the same username + 'password': _generate_new_secure_password(), # something stronger than 'admin' + }) +``` + +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. + + +### 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). +- An expiration date (for example, in two months from now). + +Here is what the code would look like: + +```python +class MyDatabaseCharm(ops.CharmBase): + def __init__(self, *args, **kwargs): + ... # other setup + self.framework.observe(self.on.secret_rotate, + self._on_secret_rotate) + + ... # as before + + def _on_database_relation_joined(self, event: ops.RelationJoinedEvent): + content = { + 'username': 'admin', + 'password': 'admin', + } + secret = self.app.add_secret(content, + label='secret-for-webserver-app', + rotate=SecretRotate.DAILY) + + def _on_secret_rotate(self, event: ops.SecretRotateEvent): + # this will be called once per day. + if event.secret.label == 'secret-for-webserver-app': + self._rotate_webserver_secret(event.secret) +``` + +Or, for secret expiration: + +```python +class MyDatabaseCharm(ops.CharmBase): + def __init__(self, *args, **kwargs): + ... # other setup + self.framework.observe(self.on.secret_expired, + self._on_secret_expired) + + ... # as before + + def _on_database_relation_joined(self, event: ops.RelationJoinedEvent): + content = { + 'username': 'admin', + 'password': 'admin', + } + secret = self.app.add_secret(content, + label='secret-for-webserver-app', + expire=datetime.timedelta(days=42)) # this can also be an absolute datetime + + def _on_secret_expired(self, event: ops.SecretExpiredEvent): + # this will be called only once, 42 days after the relation-joined event. + if event.secret.label == 'secret-for-webserver-app': + self._rotate_webserver_secret(event.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 +class MyDatabaseCharm(ops.CharmBase): + ... + + # called from an event handler + def _remove_webserver_secret(self): + secret = self.model.get_secret(label='secret-for-webserver-app') + secret.remove_all_revisions() +``` + +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. + +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. + +A typical implementation of the `secret-remove` event would look like: + +```python +class MyDatabaseCharm(ops.CharmBase): + + ... # as before + + def __init__(self, *args, **kwargs): + ... # other setup + self.framework.observe(self.on.secret_remove, + self._on_secret_remove) + + def _on_secret_remove(self, event: ops.SecretRemoveEvent): + # 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): + + ... # as before + + # called from an event handler + def _revoke_webserver_secret_access(self, relation): + secret = self.model.get_secret(label='secret-for-webserver-app') + secret.revoke(relation) +``` + +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. +> +> A secret owner charm is also an observer of the secret, so this applies to it too. + +### Start tracking the latest secret revision + +Before secrets, the code in the secret observer charm may have looked something like this: + +```python +class MyWebserverCharm(ops.CharmBase): + def __init__(self, *args, **kwargs): + ... # other setup + self.framework.observe(self.on.database_relation_changed, + self._on_database_relation_changed) + + ... # other methods and event handlers + + def _on_database_relation_changed(self, event: ops.RelationChangedEvent): + username = event.relation.data[event.app]['username'] + password = event.relation.data[event.app]['password'] + self._configure_db_credentials(username, password) +``` + +With secrets, the code would become: + +```python +class MyWebserverCharm(ops.CharmBase): + def __init__(self, *args, **kwargs): + ... # other setup + self.framework.observe(self.on.database_relation_changed, + self._on_database_relation_changed) + + ... # other methods and event handlers + + def _on_database_relation_changed(self, event: ops.RelationChangedEvent): + secret_id = event.relation.data[event.app]['secret-id'] + secret = self.model.get_secret(id=secret_id) + content = secret.get_content() + self._configure_db_credentials(content['username'], content['password']) +``` + +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) + +### 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: + +```python +class MyWebserverCharm(ops.CharmBase): + + ... # as before + + def _on_database_relation_changed(self, event: ops.RelationChangedEvent): + secret_id = event.relation.data[event.app]['secret-id'] + secret = self.model.get_secret(id=secret_id, label='database-secret') + content = secret.get_content() + self._configure_db_credentials(content['username'], content['password']) + + def _on_secret_changed(self, event: ops.SecretChangedEvent): + if event.secret.label == 'database-secret': + content = event.secret.get_content(refresh=True) + self._configure_db_credentials(content['username'], content['password']) + elif event.secret.label == 'my-other-secret': + self._handle_other_secret_changed(event.secret) + else: + pass # ignore other labels (or log a warning) +``` + +As shown above, when the web server charm calls `get_secret` it can specify an observer-specific label for that secret; Juju will attach this label to the secret at that point. Normally `get_secret` is called for the first time in a relation-changed event; the label is applied then, and subsequently used in a secret-changed event. + +Labels are unique to the charm (the observer in this case): if you attempt to attach a label to two different secrets from the same application (whether it's the on the observer side or the owner side) and give them the same label, the framework will raise a `ModelError`. + +Whenever a charm receives an event concerning a secret for which it has set a label, the label will be present on the secret object exposed by the framework. + +The owner of the secret can do the same. When a secret is added, you can specify a label for the newly-created secret: + +```python +class MyDatabaseCharm(ops.CharmBase): + + ... # as before + + def _on_database_relation_joined(self, event: ops.RelationJoinedEvent): + content = { + 'username': 'admin', + 'password': 'admin', + } + secret = self.app.add_secret(content, label='secret-for-webserver-app') + secret.grant(event.relation) + event.relation.data[event.unit]['secret-id'] = secret.id +``` + +If a secret has been labelled in this way, the charm can retrieve the secret object at any time by calling `get_secret` with the "label" argument. This way, a charm can perform any secret management operation even if all it knows is the label. The secret ID is normally only used to exchange a reference to the secret *between* applications. Within a single application, all you need is the secret label. + +So, having labelled the secret on creation, the database charm could add a new revision as follows: + +```python + def _rotate_webserver_secret(self): + secret = self.model.get_secret(label='secret-for-webserver-app') + 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) + +#### When to use labels + +When should you use labels? A label is basically the secret's *name* (local to the charm), so whenever a charm has, or is observing, multiple secrets you should label them. This allows you to distinguish between secrets, for example, in the `SecretChangedEvent` shown above. + +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. + + + + +### Peek at a new secret revision + +Sometimes, before reconfiguring to use a new credential revision, the observer charm may want to peek at its contents (for example, to ensure that they are valid). Use `peek_content` for that: + +```python + def _on_secret_changed(self, event: ops.SecretChangedEvent): + content = event.secret.peek_content() + if not self._valid_password(content.get('password')): + logger.warning('Invalid credentials! Not updating to new revision.') + return + content = event.secret.get_content(refresh=True) + ... +``` + +> See more: [`ops.Secret.peek_content()`](https://ops.readthedocs.io/en/latest/#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 +class MyWebserverCharm(ops.CharmBase): + def __init__(self, *args, **kwargs): + ... # other setup + self.framework.observe(self.on.secret_changed, + self._on_secret_changed) + + ... # as before + + def _on_secret_changed(self, event: ops.SecretChangedEvent): + content = event.secret.get_content(refresh=True) + self._configure_db_credentials(content['username'], content['password']) +``` + + +> See more: [`ops.Secret.get_content()`](https://ops.readthedocs.io/en/latest/#ops.Secret.get_content) + + + +
+ +**Contributors:** @ppasotti, @tmihoc, @tony-meyer, @wallyworld diff --git a/docs/howto/manage-storage.md b/docs/howto/manage-storage.md new file mode 100644 index 000000000..6cb867ebc --- /dev/null +++ b/docs/howto/manage-storage.md @@ -0,0 +1,249 @@ +(manage-storage)= +# How to manage storage + + + +## Implement the feature + +### Declare the storage + +To define the storage that can be provided to the charm, define a `storage` section in `charmcraft.yaml` that lists the storage volumes and information about each storage. For example, for a transient filesystem storage mounted to `/cache/` that is at least 1GB in size: + +```yaml +storage: + local-cache: + type: filesystem + description: Somewhere to cache files locally. + location: /cache/ + minimum-size: 1G + properties: + - transient +``` + +For Kubernetes charms, you also need to define where on the workload container the volume will be mounted. For example, to mount a similar cache filesystem in `/var/cache/`: + +```yaml +storage: + local-cache: + type: filesystem + description: Somewhere to cache files locally. + # The location is not required here, because it defines the location on + # the charm container, not the workload container. + minimum-size: 1G + properties: + - transient + +containers: + web-service: + resource: app-image + mounts: + - storage: local-cache + 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: + +``` +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) + +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. + +Now, in the body of the charm definition, define the event handler, or adjust an existing holistic one. For example, to provide the location of the attached storage to the workload configuration: + +``` +def _update_configuration(self, event: ops.EventBase): + """Update the workload configuration.""" + cache = self.model.storages["cache"] + if cache.location is None: + # This must be one of the other events. Return and wait for the storage-attached event. + logger.info("Storage is not yet ready.") + return + try: + self.push_configuration(cache_dir=cache.location) + except ops.pebble.ConnectionError: + # Pebble isn't ready yet. Return and wait for the pebble-ready event. + logger.info("Pebble is not yet ready.") + return +``` + +> Examples: [ZooKeeper ensuring that permission and ownership is correct](https://github.com/canonical/zookeeper-operator/blob/106f9c2cd9408a172b0e93f741d8c9f860c4c38e/src/charm.py#L247), [Kafka configuring additional storage](https://github.com/canonical/kafka-k8s-operator/blob/25cc5dd87bc2246c38fc511ac9c52f35f75f6513/src/charm.py#L298) + +### Observe the detaching 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 detaching event associated with your storage and pair that with an event handler. For example: + +``` +self.framework.observe(self.on.cache_storage_detaching, self._on_storage_detaching) +``` + +> See more: [`ops.StorageDetachingEvent`](https://ops.readthedocs.io/en/latest/#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: + +``` +def _on_storage_detaching(self, event: ops.StorageDetachingEvent): + """Handle the storage being detached.""" + self.unit.status = ops.ActiveStatus("Caching disabled; provide storage to boost performance) +``` + +> Examples: [MySQL handling cluster management](https://github.com/canonical/mysql-k8s-operator/blob/4c575b478b7ae2a28b09dde9cade2d3370dd4db6/src/charm.py#L823), [MongoDB updating the set before storage is removed](https://github.com/canonical/mongodb-operator/blob/b33d036173f47c68823e08a9f03189dc534d38dc/src/charm.py#L596) + +### Request additional storage + +```{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` +method. The storage must be defined in the metadata as allowing multiple, for +example: + +```yaml +storage: + scratch: + type: filesystem + location: /scratch + multiple: 1-10 +``` + +For example, if the charm needs to request two additional units of this storage: + +```python +self.model.storages.request("scratch", 2) +``` + +The storage will not be available immediately after that call - the charm should +observe the `storage-attached` event and handle any remaining setup once Juju +has attached the new storage. + +## Test the feature + +> See first: {ref}`get-started-with-charm-testing` + +You'll want to add three levels of 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: + +```python +# Some charm with a 'foo' filesystem-type storage defined in its metadata: +ctx = scenario.Context(MyCharm) +storage = scenario.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: + foo = mgr.charm.model.storages["foo"][0] + loc = foo.location + path = loc / "myfile.txt" + assert path.exists() + assert path.read_text() == "helloworld" + + myfile = loc / "path.py" + myfile.write_text("helloworlds") + +# Verify that the contents are as expected afterwards. +assert ( + storage.get_filesystem(ctx) / "path.py" +).read_text() == "helloworlds" +``` + +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()) + +# 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, +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 +this case would be: + +``` +ctx = scenario.Context(MyCharm) +foo_0 = scenario.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])) + +foo_1 = scenario.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])) +``` + +> See more: [Scenario storage testing](https://github.com/canonical/ops-scenario/#storage) + + +### Write integration tests + +> See first: {ref}`write-integration-tests-for-a-charm` + +To verify that adding and removing storage works correctly against a real Juju instance, write an integration test with `pytest_operator`. For example: + +```python +# This assumes there is a previous test that handles building and deploying. +async def test_storage_attaching(ops_test): + # Add a 1GB "cache" storage: + await ops_test.model.applications[APP_NAME].units[0].add_storage("cache", size=1024*1024) + + await ops_test.model.wait_for_idle( + apps=[APP_NAME], status="active", timeout=600 + ) + + # Assert that the storage is being used appropriately. +``` diff --git a/docs/howto/manage-the-charm-version.md b/docs/howto/manage-the-charm-version.md new file mode 100644 index 000000000..3bf6df17a --- /dev/null +++ b/docs/howto/manage-the-charm-version.md @@ -0,0 +1,87 @@ +(manage-the-charm-version)= +# How to manage the charm version + +## Implement the feature + +Charms can specify a version of the charm itself, so that a Juju admin can track +the installed version of the charm back to the source tree that it was built +from. + +To set the version, in the root directory of your charm (at the same level as +the `charmcraft.yaml` file) add a file called `version` (no extension). The +content of the file is the version string, which is typically a +major.minor.patch style version that's manually updated, or a version control +hash identifier. + +For example, using the hash of the latest HEAD commit as the version: + +```shell +$ git rev-parse HEAD > version +$ ls +lib src tox.ini charmcraft.yaml LICENSE requirements.txt tests version +$ cat version +0522e1fd009dac78adb3d0652d91a1e8ff7982ae +``` + +```{note} + +Normally, your publishing workflow would take care of updating this file, so +that it will automatically match the revision that you're publishing. Generally, +using a version control revision is the best choice, as it unambiguously +identifies the code that was used to build the charm. + +``` + +Juju admins using your charm can find this information with `juju status` in the +YAML or JSON formats in the `applications..charm-version` field. If +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 + +> See first: {ref}`get-started-with-charm-testing` + +Since the version isn't set by the charm code itself, you'll want to test that +the version is correctly set with an integration test, and don't need to write +a unit test. + +> See first: {ref}`write-integration-tests-for-a-charm` + +To verify that setting the charm version works correctly in an integration test, +in your `tests/integration/test_charm.py` file, add a new test after the +`test_build_and_deploy` one that `charmcraft init` provides. In this test, get +the status of the model, and check the `charm_version` attribute of the unit. +For example: + +```python +# `charmcraft init` will provide this test for you. +async def test_build_and_deploy(ops_test: OpsTest): + # Build and deploy charm from local source folder + charm = await ops_test.build_charm(".") + + # Deploy the charm and wait for active/idle status + await asyncio.gather( + ops_test.model.deploy(charm, application_name=APP_NAME), + ops_test.model.wait_for_idle( + apps=[APP_NAME], status="active", raise_on_blocked=True, timeout=1000 + ), + ) + +async def test_charm_version_is_set(ops_test: OpsTest): + # Verify that the charm version has been set. + status = await ops_test.model.get_status() + version = status.applications[APP_NAME].charm_version + expected_version = subprocess.check_output(["git", "rev-parse", "HEAD"]).decode("utf8") + assert version == expected_version +``` + + + +> 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/manage-the-workload-version.md b/docs/howto/manage-the-workload-version.md new file mode 100644 index 000000000..96613461c --- /dev/null +++ b/docs/howto/manage-the-workload-version.md @@ -0,0 +1,154 @@ +(how-to-set-the-workload-version)= +# How to set the workload version + +## Implement the feature + +Applications modelled by charms have their own version; each application +will have its own versioning scheme, and its own way of accessing that +information. To make things easier for Juju admins, the charm should expose the +workload version through Juju - it will be visible in `juju status` (in the +default tabular view, in the application table, in the "Version" column; in the +JSON or YAML format, under `applications..version`). + +```{note} + +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 +`-pebble-ready` event, and the version can be retrieved and passed +to Juju at that point. If the workload cannot immediately provide a version +string, then your charm will need to do this in a later event instead. + +For machine charms, the workload should be available in the `start` event, so +you can retrieve the version from it and pass it to Juju in a `start` event +handler. In this case, if you don't already have a `start` handler, in the +`src/charm.py` file, in the `__init__` function of your charm, set up an +observer for the `start` event and pair that with an event handler. For example: + +```python +self.framework.observe(self.on.start, self._on_start) +``` + +> See more: [`ops.StartEvent`](https://ops.readthedocs.io/en/latest/#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 +(machine charms) or Pebble exec (Kubernetes charms) call or HTTP request. For +example: + +```python +def _on_start(self, event: ops.StartEvent): + # The workload exposes the version via HTTP at /version + version = requests.get("http://localhost:8000/version").text + self.unit.set_workload_version(version) +``` + +> See more: [`ops.Unit.set_workload_version`](https://ops.readthedocs.io/en/latest/#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) + +## Test the feature + +> 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. + + +### 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 +new test that verifies the workload version is set. For example: + +```python +def test_workload_version_is_set(): + ctx = scenario.Context(MyCharm, meta={"name": "foo"}) + # 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( + "webserver", + exec_mock={("/bin/server", "--version"): scenario.ExecOutput(stdout="1.2\n")}, + ) + out = ctx.run('start', scenario.State(containers=[container])) + assert out.workload_version == "1.2" +``` + +### Write integration tests + +> See first: {ref}`write-integration-tests-for-a-charm` + +To verify that setting the workload version works correctly in an integration test, get the status +of the model, and check the `workload_version` attribute of the unit. In your +`tests/integration/test_charm.py` file, after the `test_build_and_deploy` test +that `charmcraft init` provides, add a new test that verifies the workload +version is set. For example: + +```python +# `charmcraft init` will provide you with this test. +async def test_build_and_deploy(ops_test: OpsTest): + # Build and deploy charm from local source folder + charm = await ops_test.build_charm(".") + + # Deploy the charm and wait for active/idle status + await asyncio.gather( + ops_test.model.deploy(charm, application_name=APP_NAME), + ops_test.model.wait_for_idle( + apps=[APP_NAME], status="active", raise_on_blocked=True, timeout=1000 + ), + ) + +async def test_workload_version_is_set(ops_test: OpsTest): + # Verify that the workload version has been set. + status = await ops_test.model.get_status() + version = status.applications[APP_NAME].units[f"{APP_NAME}/0"].workload_version + # We'll need to update this version every time we upgrade to a new workload + # version. If the workload has an API or some other way of getting the + # version, the test should get it from there and use that to compare to the + # unit setting. + assert version == "3.14" +``` + + + +> 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 new file mode 100644 index 000000000..b06e80feb --- /dev/null +++ b/docs/howto/run-workloads-with-a-charm-kubernetes.md @@ -0,0 +1,938 @@ +(run-workloads-with-a-charm-kubernetes)= +# How to run workloads with a charm - Kubernetes + +The recommended way to create charms for Kubernetes is using the sidecar pattern with the workload container running Pebble. + +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. + +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. + + +```{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. + +``` + +## Set up the workload container + +### Configure Juju to set up Pebble in the workload container + + + +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. + +```{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: + +```yaml +# ... +containers: + myapp: + resource: myapp-image + redis: + resource: redis-image + +resources: + myapp-image: + type: oci-image + description: OCI image for my application + redis-image: + type: oci-image + description: OCI image for Redis +# ... +``` + +```{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. + +If multiple containers are specified in `charmcraft.yaml` (as above), each Pod will contain an instance of every specified container. Using the example above, each Pod would be created with a total of 3 running containers: + +- a container running the `myapp-image` +- 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): + +- `myapp_pebble_ready` +- `redis_pebble_ready`. + + +Consider the following example snippet from a `charmcraft.yaml`: + +```yaml +# ... +containers: + pause: + resource: pause-image + +resources: + pause-image: + type: oci-image + description: Docker image for google/pause +# ... +``` + +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)). + +```{note} + +In many cases, using the container's specified entrypoint may be desired. You can find the original entrypoint of an image locally like so: + +`$ 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: + +```python +# ... +import ops +# ... + +class PauseCharm(ops.CharmBase): + # ... + def __init__(self, framework): + super().__init__(framework) + # Set a friendly name for your charm. This can be used with the Operator + # framework to reference the container, add layers, or interact with + # providers/consumers easily. + self.name = "pause" + # This event is dynamically determined from the service name + # in ops.pebble.Layer + # + # If you set self.name as above and use it in the layer definition following this + # example, the event will be _pebble_ready + framework.observe(self.on.pause_pebble_ready, self._on_pause_pebble_ready) + # ... + + def _on_pause_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: + """Handle the pebble_ready event""" + # You can get a reference to the container from the PebbleReadyEvent + # directly with: + # container = event.workload + # + # The preferred method is through get_container() + container = self.unit.get_container(self.name) + # Add our initial config layer, combining with any existing layer + container.add_layer(self.name, self._pause_layer(), combine=True) + # Start the services that specify 'startup: enabled' + container.autostart() + self.unit.status = ops.ActiveStatus() + + def _pause_layer(self) -> ops.pebble.Layer: + """Returns Pebble configuration layer for google/pause""" + return ops.pebble.Layer( + { + "summary": "pause layer", + "description": "pebble config layer for google/pause", + "services": { + self.name: { + "override": "replace", + "summary": "pause service", + "command": "/pause", + "startup": "enabled", + } + }, + } + ) +# ... +``` + +A common method for configuring container workloads is by manipulating environment variables. The layering in Pebble makes this easy. Consider the following extract from a `config-changed` callback which combines a new overlay layer (containing some environment configuration) with the current Pebble layer and restarts the workload: + +```python +# ... +import ops +# ... +def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: + """Handle the config changed event.""" + # Get a reference to the container so we can manipulate it + container = self.unit.get_container(self.name) + + # Create a new config layer - specify 'override: merge' in + # the 'pause' service definition to overlay with existing layer + layer = ops.pebble.Layer( + { + "services": { + "pause": { + "override": "merge", + "environment": { + "TIMEOUT": self.model.config["timeout"], + }, + } + }, + } + ) + + try: + # Add the layer to Pebble + container.add_layer(self.name, layer, combine=True) + logging.debug("Added config layer to Pebble plan") + + # Tell Pebble to update the plan, which will restart any services if needed. + container.replan() + logging.info("Updated pause service") + # All is well, set an ActiveStatus + self.unit.status = ops.ActiveStatus() + except ops.pebble.PathError, ops.pebble.ProtocolError, ops.pebble.ConnectionError: + # handle errors (for example: the container might not be ready yet) + ..... +``` + +In this example, each time a `config-changed` event is fired, a new overlay layer is created that only includes the environment config, populated using the charm’s config. Pebble will ensure that that the application is only restarted if the configuration has changed. + +### 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". + +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. + + +#### 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. + +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) + +#### 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. + +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: + +```python +class MyCharm(ops.CharmBase): + ... + + def _on_config_changed(self, event): + container = self.unit.get_container("main") + plan = container.get_plan() + if not plan.services: + layer = {"services": ...} + container.add_layer("layer", layer) + container.start("svc") + ... +``` + + +## 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. + +In the context of Juju sidecar charms, Pebble is run with the `--hold` argument, which prevents it from automatically starting the services marked with `startup: enabled`. This is to give the charm full control over when the services in Pebble's configuration are actually started. + +### Replan + +After adding a configuration layer to the plan (details below), you need to call `replan` to make any changes to `services` take effect. When you execute replan, Pebble will automatically restart any services that have changed, respecting dependency order. If the services are already running, it will stop them first using the normal [stop sequence](#start-and-stop). + +The reason for replan is so that you as a user have control over when the (potentially high-impact) action of stopping and restarting your services takes place. + +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: + +```python +class SnappassTestCharm(ops.CharmBase): + ... + + def _start_snappass(self): + container = self.unit.containers["snappass"] + snappass_layer = { + "services": { + "snappass": { + "override": "replace", + "summary": "snappass service", + "command": "snappass", + "startup": "enabled", + } + }, + } + container.add_layer("snappass", snappass_layer, combine=True) + container.replan() + self.unit.status = ops.ActiveStatus() +``` + +### 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. + +> See more: [`ops.Container`](https://ops.readthedocs.io/en/latest/#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: + +```python +class MyCharm(ops.CharmBase): + ... + + def _on_pebble_ready(self, event): + container = event.workload + container.start('mysql') + + def _on_backup_action(self, event): + container = self.unit.get_container('main') + try: + container.stop('mysql') + do_mysql_backup() + container.start('mysql') + except ops.pebble.ProtocolError, ops.pebble.PathError, ops.pebble.ConnectionError: + # handle Pebble errors +``` + +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. + +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. + +Here is a modification to the start/stop example that checks whether the service is running before stopping it: + +```python +class MyCharm(ops.CharmBase): + ... + + def _on_backup_action(self, event): + container = self.unit.get_container('main') + is_running = container.get_service('mysql').is_running() + if is_running: + container.stop('mysql') + do_mysql_backup() + if is_running: + container.start('mysql') +``` + +### 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: + +```python +container.send_signal('SIGHUP', 'nginx', 'redis') +``` + +This will raise an `APIError` if any of the services are not in the plan or are not currently running. + +### View service logs + +Pebble stores service logs (stdout and stderr from services) in a ring buffer accessible via the `pebble logs` command. Each log line is prefixed with the timestamp and service name, using the format `2021-05-03T03:55:49.654Z [snappass] ...`. Pebble allocates a ring buffer of 100KB per service (not one ring to rule them all), and overwrites the oldest logs in the buffer when it fills up. + +When running under Juju, the Pebble server is started with the `--verbose` flag, which means it also writes these logs to Pebble's own stdout. That in turn is accessible via Kubernetes using the `kubectl logs` command. For example, to view the logs for the "redis" container, you could run: + +``` +microk8s kubectl logs -n snappass snappass-test-0 -c redis +``` + +In the command line above, "snappass" is the namespace (Juju model name), "snappass-test-0" is the pod, and "redis" the specific container defined by the charm configuration. + +### Configure service auto-restart + +From Juju version 2.9.22, 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. + +You can configure this behavior in the layer configuration, specified under each service. Here is an example showing the complete list of auto-restart options with their defaults: + +```yaml +services: + server: + override: replace + command: python3 app.py + + # auto-restart options (showing defaults) + on-success: restart # can also be "shutdown" or "ignore" + on-failure: restart # can also be "shutdown" or "ignore" + backoff-delay: 500ms + backoff-factor: 2.0 + backoff-limit: 30s +``` + +The `on-success` action is performed if the service exits with a zero exit code, and the `on-failure` action is performed if it exits with a nonzero code. The actions are defined as follows: + +* `restart`: automatically restart the service after the current backoff delay. This is the default. +* `shutdown`: shut down the Pebble server. Because Pebble is the container's "PID 1" process, this will cause the container to terminate -- useful if you want Kubernetes to restart the container. +* `ignore`: do nothing (apart from logging the failure). + +The backoff delay between restarts is calculated using an exponential backoff: `next = current * backoff_factor`, with `current` starting at the configured `backoff-delay`. If `next` is greater than `backoff-limit`, it is capped at `backoff-limit`. With the defaults, the delays (in seconds) will be: 0.5, 1, 2, 4, 8, 16, 30, 30, and so on. + +The `backoff-factor` must be greater than or equal to 1.0. If the factor is set to 1.0, `next` will equal `current`, so the delay will remain constant. + +Just before delaying, a small random time jitter of 0-10% of the delay is added (the current delay is not updated). For example, if the current delay value is 2 seconds, the actual delay will be between 2.0 and 2.2 seconds. + +## 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. + +Each check can be one of three types. The types and their success criteria are: + +* `http`: an HTTP `GET` request to the URL specified must return an HTTP 2xx status code. +* `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: + +```yaml +checks: + up: + override: replace + level: alive # optional, but required for liveness/readiness probes + period: 10s # this is the default + timeout: 3s # this is the default + threshold: 3 # this is the default + exec: + command: service nginx status + + online: + override: replace + level: ready + tcp: + port: 8080 + + http-test: + override: replace + http: + url: http://localhost:8080/test +``` + +Each check is performed with the specified `period` (the default is 10 seconds apart), and is considered an error if a `timeout` happens before the check responds -- for example, before the HTTP request is complete or before the command finishes executing. + +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. + +### 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 +class PostgresCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): + super().__init__(framework) + # Note that "db" is the workload container's name + framework.observe(self.on["db"].pebble_check_failed, self._on_pebble_check_failed) + framework.observe(self.on["db"].pebble_check_recovered, self._on_pebble_check_recovered) + + def _on_pebble_check_failed(self, event: ops.PebbleCheckFailedEvent): + if event.info.name == "http-test": + logger.warning("The http-test has started failing!") + self.unit.status = ops.ActiveStatus("Degraded functionality ...") + + elif event.info == "online": + logger.error("The service is no longer online!") + + def _on_pebble_check_recovered(self, event: ops.PebbleCheckRecoveredEvent): + if event.info.name == "http-test": + logger.warning("The http-test has stopped failing!") + self.unit.status = ops.ActiveStatus() + + elif event.info == "online": + logger.error("The service is online again!") +``` + +All check events have an `info` property with the details of the check's current status. Note that, by the time that the charm receives the event, the status of the check may have changed (for example, passed again after failing). If the response to the check failing is light (such as changing the status), then it's fine to rely on the status of the check at the time the event was triggered — there will be a subsequent check-recovered event, and the status will quickly flick back to the correct one. If the response is heavier (such as restarting a service with an adjusted configuration), then the two events should share a common handler and check the current status via the `info` property; for example: + +```python +class PostgresCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): + super().__init__(framework) + # Note that "db" is the workload container's name + framework.observe(self.on["db"].pebble_check_failed, self._on_pebble_check_failed) + framework.observe(self.on["db"].pebble_check_recovered, self._on_pebble_check_recovered) + + def _on_pebble_check_failed(self, event: ops.PebbleCheckFailedEvent): + if event.info.name != "up": + # For now, we ignore the other tests. + return + if event.info.status == ops.pebble.CheckStatus.DOWN: + self.activate_alternative_configuration() + else: + self.activate_main_configuration() +``` + +### 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`. + +Here is a code example that checks whether the `uptime` check is healthy, and writes an error log if not: + +```python +container = self.unit.get_container('main') +check = container.get_check('uptime') +if check.status != ops.pebble.CheckStatus.UP: + logger.error('Uh oh, uptime check unhealthy: %s', check) +``` + +### Check auto-restart + +To enable Pebble auto-restart behavior based on a check, use the `on-check-failure` map in the service configuration. For example, to restart the "server" service when the "http-test" check fails, use the following configuration: + +```yaml +services: + server: + override: merge + on-check-failure: + http-test: restart # can also be "shutdown" or "ignore" (the default) +``` + +### 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. + +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/). + +When Juju creates a sidecar charm container, it initialises the Kubernetes liveness and readiness probes to hit the `/v1/health` endpoint with `?level=alive` and `?level=ready` filters, respectively. + +Ready implies alive, and not alive implies not ready. If you've configured an "alive" check but no "ready" check, and the "alive" check is unhealthy, `/v1/health?level=ready` will report unhealthy as well, and the Kubernetes readiness probe will act on that. + +If there are no checks configured, Pebble returns HTTP 200 so the liveness and readiness probes are successful by default. To use this feature, you must explicitly create checks with `level: alive` or `level: ready` in the layer configuration. + +Consider the K8s liveness success (`level=alive` check) to mean "Pebble is alive" rather than "the application is fully alive" (and failure to mean "this container needs to die"). For charms that take a long time to start, you should not have a `level=alive` check (if Pebble's running, it will report alive to K8s), and instead use an ordinary Pebble check (without a `level`) in conjunction with `on-check-failure: restart`. That way Pebble itself has full control over restarting the service in question. + +### Test checks + +> Added in Scenario 7.0 + +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: + +```python +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}) + + state_out = ctx.run(ctx.on.pebble_check_failed(container, check_info), state_in) + + assert state_out... +``` + +## Manage files in the workload container + +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): + +```python +config = """ +port = 7777 +max_connections = 1000 +""" +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. + +### 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()`. + +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: + +```python +# Update port to 8888 and restart service +config = container.pull('/etc/pg/postgresql.conf').read() +if 'port =' not in config: + config += '\nport = 8888\n' +container.push('/etc/pg/postgresql.conf', config) +container.restart('postgresql') +``` + +If you specify the keyword argument `encoding=None` on the `pull()` call, reads from the returned file-like object will return `bytes`. The default is `encoding='utf-8'`, which will decode the file's bytes from UTF-8 so that reads return a Python `str`. + +### 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. + +```python +# copy "/source/dir/[files]" into "/destination/dir/[files]" +container.push_path('/source/dir', '/destination') + +# copy "/source/dir/[files]" into "/destination/[files]" +container.push_path('/source/dir/*', '/destination') +``` + +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. + +```python +# copy "/source/dir/[files]" into "/destination/dir/[files]" +container.pull_path('/source/dir', '/destination') + +# copy "/source/dir/[files]" into "/destination/[files]" +container.pull_path('/source/dir/*', '/destination') +``` + +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: + +```python +infos = container.list_files('/etc', pattern='*.conf') +total_size = sum(f.size for f in infos) +logger.info('total size of config files: %d', total_size) +names = set(f.name for f in infos) +if 'host.conf' not in names: + raise Exception('This charm requires /etc/host.conf!') +``` + +If you want information about the directory itself (instead of its contents), call `list_files(path, itself=True)`. + +### 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: + +```python +container.make_dir('/etc/pg', user='postgres', group='postgres') +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: + +```python +# Delete Apache access log +container.remove_path('/var/log/apache/access.log') +# Blow away /tmp/mysubdir and all files under it +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: + +```python +# if /tmp/myfile exists +container.exists('/tmp/myfile') # True +container.isdir('/tmp/myfile') # False + +# if /tmp/mydir exists +container.exists('/tmp/mydir') # True +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. + +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. + +For example, to back up a PostgreSQL database, you might use `pg_dump`: + +```python +process = container.exec(['pg_dump', 'mydb'], timeout=5*60) +sql, warnings = process.wait_output() +if warnings: + for line in warnings.splitlines(): + logger.warning('pg_dump: %s', line.strip()) +# 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 [`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. + +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). + +It's okay to let these exceptions bubble up: Juju will mark the hook as failed and re-run it automatically. However, if you want fine-grained control over error handling, you can catch the `ExecError` and inspect its attributes. For example: + +```python +process = container.exec(['cat', '--bad-arg']) +try: + stdout, _ = process.wait_output() + logger.info(stdout) +except ops.pebble.ExecError as e: + logger.error('Exited with code %d. Stderr:', e.exit_code) + for line in e.stderr.splitlines(): + logger.error(' %s', line) +``` + +That will log something like this: + +```text +Exited with code 1. Stderr: + cat: unrecognized option '--bad-arg' + Try 'cat --help' for more information. +``` + +### 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: + +* `environment`: a dict of environment variables to pass to the process +* `working_dir`: working directory to run the command in +* `timeout`: command timeout in seconds +* `user_id`, `user`, `group_id`, `group`: UID/username and GID/group name to run command as +* `service_context`: run the command in the context of the specified service + +Here is a (contrived) example showing the use of most of these parameters: + +```python +process = container.exec( + ['/bin/sh', '-c', 'echo HOME=$HOME, PWD=$PWD, FOO=$FOO'], + environment={'FOO': 'bar'}, + working_dir='/tmp', + timeout=5.0, + user='bob', + group='staff', +) +stdout, _ = process.wait_output() +logger.info('Output: %r', stdout) +``` + +This will execute the echo command in a shell and log something like `Output: 'HOME=/home/bob, PWD=/tmp, FOO=bar\n'`. + +The `service_context` option allows you to specify the name of a service to "inherit" context from. Specifically, inherit its environment variables, user/group settings, and working directory. The other exec options (`user_id`, `user`, `group_id`, `group`, `working_dir`) will override the service's settings; `environment` will be merged on top of the service’s environment. + +Here's an example that uses the `service_context` option: + +```python +# Use environment, user/group, and working_dir from "database" service +process = container.exec(['pg_dump', 'mydb'], service_context='database') +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: + +```python +process = container.exec(['tr', 'a-z', 'A-Z'], + stdin='This is\na test\n') +stdout, _ = process.wait_output() +logger.info('Output: %r', stdout) +``` + +By default, input is sent and output is received as Unicode using the UTF-8 encoding. You can change this with the `encoding` parameter (which defaults to `utf-8`). The most common case is to set `encoding=None`, which means "use raw bytes", in which case `stdin` must be a bytes object and `wait_output()` returns bytes objects. + +For example, the following will log `Output: b'\x01\x02'`: + +```python +process = container.exec(['cat'], stdin=b'\x01\x02', + encoding=None) +stdout, _ = process.wait_output() +logger.info('Output: %r', stdout) +``` + +You can also pass [file-like objects](https://docs.python.org/3/glossary.html#term-file-object) using the `stdin`, `stdout`, and `stderr` parameters. These can be real files, streams, `io.StringIO` instances, and so on. When the `stdout` and `stderr` parameters are specified, call the `ExecProcess.wait` method instead of `wait_output`, as output is being written, not returned. + +For example, to pipe standard input from a file to the command, and write the result to a file, you could use the following: + +```python +with open('LICENSE.txt') as stdin: + with open('output.txt', 'w') as stdout: + process = container.exec( + ['tr', 'a-z', 'A-Z'], + stdin=stdin, + stdout=stdout, + stderr=sys.stderr, + ) + process.wait() +# use result in "output.txt" +``` + +For advanced uses, you can also perform streaming I/O by reading from and writing to the `stdin` and `stdout` attributes of the `ExecProcess` instance. For example, to stream lines to a process and log the results as they come back, use something like the following: + +```python +process = container.exec(['cat']) + +# Thread that sends data to process's stdin +def stdin_thread(): + try: + for line in ['one\n', '2\n', 'THREE\n']: + process.stdin.write(line) + process.stdin.flush() + time.sleep(1) + finally: + process.stdin.close() +threading.Thread(target=stdin_thread).start() + +# Log from stdout stream as output is received +for line in process.stdout: + logging.info('Output: %s', line.strip()) + +# Will return immediately as stdin was closed above +process.wait() +``` + +That will produce the following logs: + +``` +Output: 'one\n' +Output: '2\n' +Output: 'THREE\n' +``` + +Caution: it's easy to get threading wrong and cause deadlocks, so it's best to use `wait_output` or pass file-like objects to `exec` instead if possible. + +### 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: + +```python +process = container.exec(['sleep', '10']) +time.sleep(1) +process.send_signal(signal.SIGTERM) +process.wait() +``` + +Note that because sleep will exit via a signal, `wait()` will raise an `ExecError` with an exit code of 143 (128+SIGTERM): + +``` +Traceback (most recent call last): + ... +ops.pebble.ExecError: non-zero exit code 143 executing ['sleep', '10'] +``` + +## Use custom notices from the workload container + +### Record a notice + +To record a custom notice, use the `pebble notify` CLI command. For example, the workload might have a script to back up the database and then record a notice: + +```sh +pg_dump mydb >/tmp/mydb.sql +/charm/bin/pebble notify canonical.com/postgresql/backup-done path=/tmp/mydb.sql +``` + +The first argument to `pebble notify` is the key, which must be in the format `/`. The caller can optionally provide map data arguments in `=` format; this example shows a single data argument named `path`. + +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) + +### Respond to a notice + +To have the charm respond to a notice, observe the `pebble_custom_notice` event and switch on the notice's `key`: + +```python +class PostgresCharm(ops.CharmBase): + def __init__(self, framework: ops.Framework): + super().__init__(framework) + # Note that "db" is the workload container's name + framework.observe(self.on["db"].pebble_custom_notice, self._on_pebble_custom_notice) + + def _on_pebble_custom_notice(self, event: ops.PebbleCustomNoticeEvent) -> None: + if event.notice.key == "canonical.com/postgresql/backup-done": + path = event.notice.last_data["path"] + logger.info("Backup finished, copying %s to the cloud", path) + f = event.workload.pull(path, encoding=None) + s3_bucket.upload_fileobj(f, "db-backup.sql") + + elif event.notice.key == "canonical.com/postgresql/other-thing": + 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). + +### 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`. + +### 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: + +```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"} + ) + + # 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") +``` + + diff --git a/docs/howto/run-workloads-with-a-charm-machines.md b/docs/howto/run-workloads-with-a-charm-machines.md new file mode 100644 index 000000000..0e68ee858 --- /dev/null +++ b/docs/howto/run-workloads-with-a-charm-machines.md @@ -0,0 +1,81 @@ +(run-workloads-with-a-charm-machines)= +# How to run workloads with a charm - machines + +There are several ways your charm might start a workload, depending on the type of charm you’re authoring. + + +For a machine charm, it is likely that packages will need to be fetched, installed and started to provide the desired charm functionality. This can be achieved by interacting with the system’s package manager, ensuring that package and service status is maintained by reacting to events accordingly. + +It is important to consider which events to respond to in the context of your charm. A simple example might be: + +```python +# ... +from subprocess import check_call, CalledProcessError +# ... +class MachineCharm(ops.CharmBase): + #... + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.install, self._on_install) + self.framework.observe(self.on.start, self._on_start) + # ... + + def _on_install(self, event: ops.InstallEvent) -> None: + """Handle the install event""" + try: + # Install the openssh-server package using apt-get + check_call(["apt-get", "install", "-y", "openssh-server"]) + except ops.CalledProcessError as e: + # If the command returns a non-zero return code, put the charm in blocked state + logger.debug("Package install failed with return code %d", e.returncode) + self.unit.status = ops.BlockedStatus("Failed to install packages") + + def _on_start(self, event: ops.StartEvent) -> None: + """Handle the start event""" + try: + # Enable the ssh systemd unit, and start it + check_call(["systemctl", "enable", "--now", "openssh-server"]) + except ops.CalledProcessError as e: + # If the command returns a non-zero return code, put the charm in blocked state + logger.debug("Starting systemd unit failed with return code %d", e.returncode) + self.unit.status = ops.BlockedStatus("Failed to start/enable ssh service") + return + + # Everything is awesome + self.unit.status = ops.ActiveStatus() +``` + +If the machine is likely to be long-running and endure multiple upgrades throughout its life, it may be prudent to ensure the package is installed more regularly, and handle the case where it needs upgrading or reinstalling. Consider this excerpt from the [ubuntu-advantage charm code](https://git.launchpad.net/charm-ubuntu-advantage/tree/src/charm.py) (with some additional comments): + +```python +class UbuntuAdvantageCharm(ops.CharmBase): + """Charm to handle ubuntu-advantage installation and configuration""" + _state = ops.StoredState() + + def __init__(self, *args): + super().__init__(*args) + self._state.set_default(hashed_token=None, package_needs_installing=True, ppa=None) + self.framework.observe(self.on.config_changed, self.config_changed) + + def config_changed(self, event): + """Install and configure ubuntu-advantage tools and attachment""" + logger.info("Beginning config_changed") + self.unit.status = ops.MaintenanceStatus("Configuring") + # Helper method to ensure a custom PPA from charm config is present on the system + self._handle_ppa_state() + # Helper method to ensure latest package is installed + self._handle_package_state() + # Handle some ubuntu-advantage specific configuration + self._handle_token_state() + # Set the unit status using a helper _handle_status_state + if isinstance(self.unit.status, ops.BlockedStatus): + return + self._handle_status_state() + logger.info("Finished config_changed") + +``` + +In the example above, the package install status is ensured each time the charm's `config-changed` event fires, which should ensure correct state throughout the charm's deployed lifecycle. diff --git a/docs/howto/turn-a-hooks-based-charm-into-an-ops-charm.md b/docs/howto/turn-a-hooks-based-charm-into-an-ops-charm.md new file mode 100644 index 000000000..aabacff51 --- /dev/null +++ b/docs/howto/turn-a-hooks-based-charm-into-an-ops-charm.md @@ -0,0 +1,304 @@ +(turn-a-hooks-based-charm-into-an-ops-charm)= +# How to turn a hooks-based charm into an ops charm + + + +Suppose you have a hooks-based charm and you decide to rewrite it using the Ops framework in Python. + +The core concept tying hooks to the Ops framework is that hooks are no longer scripts stored in files that are named like the event they are meant to respond to; hooks are, instead, blocks of Python code that are best written as methods of a class. The class represents the charm; the methods, the operator logic to be executed as specific events occur. + +Here we'll look at just how to do that. You will learn how to: + - look at a simple hooks-based charm to understand its relationship with the Juju state machine; +- map that forward to the Ops framework; +- translate some shell commands to their Ops counterparts; +- translate some more shell commands by using some handy charm libraries. + + +This guide will refer to a local LXD cloud and a machine charm, but you can easily generalize the approach to Kubernetes. + + +## Analyse the charm + + +We start by looking at the charm we intend to translate; as an example, we will take [microsample](https://github.com/erik78se/charm-microsample), an educational charm, because it is simple and includes a number of hooks, while implementing little-to-no business logic (the charm does very little). + +From the charm root directory we see: + +```text +$ tree . +. +├── charmcraft.yaml +├── config.yaml +├── copyright +├── hooks +│ ├── config-changed +│ ├── install +│ ├── start +│ ├── stop +│ ├── update-status +│ ├── upgrade-charm +│ ├── website-relation-broken +│ ├── website-relation-changed +│ ├── website-relation-departed +│ └── website-relation-joined +├── icon.svg +├── LICENSE +├── metadata.yaml +├── microsample-ha.png +├── README.md +└── revision +``` + +By looking at the `hooks` folder, we can already tell that there are two categories of hooks we'll need to port; +- core lifecycle hooks: + - `config-changed` + - `install` + - `start` + - `stop` + - `update-status` + - `upgrade-charm` +- hooks related to a `website` relation: + - `website-relation-*` + +If we look at `metadata.yaml` in fact we'll see: +```yaml +provides: + website: + interface: http +``` + +### Setting up the stage + +If we look at `charmcraft.yaml`, we'll see a section: +```yaml +parts: + microsample: + plugin: dump + source: . + prime: + - LICENSE + - README.md + - config.yaml + - copyright + - hooks + - icon.svg + - metadata.yaml +``` +This is a spec required to make charmcraft work in 'legacy mode' and support older charm frameworks, such as the hooks charm we are working with. +As such, if we take a look at the packed `.charm` file, we'll see that the files and folders listed in 'prime' are copied over one-to-one in the archive. + +If we remove that section, run `charmcraft pack`, and then attempt to deploy the charm, the command will fail with a +```bash +Processing error: Failed to copy '/root/stage/src': no such file or directory. +``` + +### The plan + +````{dropdown} Detour: exploration of a lazy approach which turns out to be a bad idea. + +The minimal-effort solution in this case could be to create a file `/src/charm.py` and translate the built-in `self.on` event hooks to subprocess calls to the Bash event hooks as-they-are. Roughly: + +```python +#!/usr/bin/env python3 +import os +import ops + +class Microsample(ops.CharmBase): + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.config_changed, lambda _: os.popen('../hooks/config-changed')) + self.framework.observe(self.on.install, lambda _: os.popen('../hooks/install')) + self.framework.observe(self.on.start, lambda _: os.popen('../hooks/start')) + self.framework.observe(self.on.stop, lambda _: os.popen('../hooks/stop')) + # etc... + +if __name__ == "__main__": + main(ops.Microsample) + +``` +This is obviously horrible, but we can verify that it actually works and it is a useful exercise to verify that the only difference between the hooks-based charm and this Ops charm is the syntax by which the developer has to map hook names to handler scripts. + +```{important} + + +We need a few preparatory steps:\ + • Add a `requirements.txt` file to ensure that the charm's Python environment will install for us the `ops` package.\ +• Modify the install hook to install `snap` for us, which is used in the script.\ +• In practice we cannot bind lambdas to `observe`, we need to write dedicated _methods_ for that.\ +• We need to figure out the required environment variables for the commands to work, which is not trivial.\ +\ +A more detailed explanation of this process is worthy of its own how-to guide, so we'll skip to the punchline here: it works. Check out [this branch](https://github.com/PietroPasotti/hooks-to-ops/tree/1-sh-charm) and see for yourself. + +``` + +```` + +It is in our interest to move the handler logic for each `/hooks/` to `Microsample._on_`, for several reasons: +- We can avoid code duplication by accessing shared data via the CharmBase interface provided through `self`. +- The code is all in one place, easier to maintain. +- We automatically have one Python object we can test, instead of going back and forth between Bash scripts and Python wrappers. +- We can use [the awesome testing Harness](https://juju.is/docs/sdk/testing). + +So let's do that. + +The idea is to turn those bash scripts into Python code we can call from aptly-named `Microsample` methods; but does it always make sense to do so? We'll see in a minute. + +### Step 1: Move script contents as-they-are into dedicated charm methods + + +```python +class Microsample(ops.CharmBase): + def __init__(self, framework): + super().__init__(framework) + framework.observe(self.on.install, self._on_install) + framework.observe(self.on.config_changed, self._on_config_changed) + framework.observe(self.on.start, self._on_start) + # etc ... +``` +Let's begin with `install`. +The `/hooks/install` script checks if a snap package is installed; if not, it installs it. We need to still reach out to a shell to grab the `snap` package info and install the package, but we can have the logic and the status management in Python, which is nice. We use `subprocessing.check_call` to reach out to the OS. And yes, there is a better way to do this, we'll get to that later. + +```python + def _on_install(self, _event): + snapinfo_cmd = Popen("snap info microsample".split(" "), + stdout=subprocess.PIPE) + output = check_output("grep -c 'installed'".split(" "), + stdin=snapinfo_cmd.stdout) + is_microsample_installed = bool(output.decode("ascii").strip()) + + if not is_microsample_installed: + self.unit.status = ops.MaintenanceStatus("installing microsample") + out = check_call("snap install microsample --edge") + + self.unit.status = ops.ActiveStatus() +``` + +For `on-start` and `on-stop`, which are simple instructions to `systemctl` to start/stop the `microsample` service, we can copy over the commands as they are: + +```python + def _on_start(self, _event): # noqa + check_call("systemctl start snap.microsample.microsample.service".split(' ')) + + def _on_stop(self, _event): # noqa + check_call("systemctl stop snap.microsample.microsample.service".split(' ')) +``` +In a couple of places in the scripts, `sleep 3` calls ensure that the service has some time to come up; however, this might get the charm stuck in the waiting loop if for whatever reason the service does NOT come up, so it is quite risky and we are not going to do that. Instead, we are going to rely on the fact that if other event handlers were to fail because of the service not being up, they would handle that case appropriately (e.g., defer the event if necessary). + +The rest of the translation is pretty straightforward. However, it is still useful to note a few things about relations, logging, and environment variables, which we do below. + +#### Wrapping the `website` relation + +`ops.model.Relation` provides a neat wrapper for the juju relation object. +We are going to add a helper method: + +```python + def _get_website_relation(self) -> ops.model.Relation: + # WARNING: would return None if called too early, e.g. during install + return self.model.get_relation("website") +``` + +That allows us to fetch the Relation wherever we need it and access its contents or mutate them in a natural way: +```python + def _on_website_relation_joined(self, _event): + relation = self._get_website_relation() + relation.data[self.unit].update( + {"hostname": self.private_address, + "port": self.port} + ) +``` + +Note how `relation.data` provides an interface to the relation databag (more on that [here](https://juju.is/docs/sdk/relations#heading--relation-data)) and we need to select which part of that bag to access by passing an `ops.model.Unit` instance. + +#### Logging + +Every maintainable charm will have some form of logging integrated; in a few places in the Bash scripts we see calls to a `juju-log` command; we can replace them with simple `logger.log` calls; such as in +```python + def _on_website_relation_departed(self, _event): # noqa + logger.debug("%s departed website relation", self.unit.name) +``` +Where `logger = logging.getLogger(__name__)`. + +#### Environment variables + +Some of the Bash scripts read environment variables such as `$JUJU_REMOTE_UNIT`, `$JUJU_UNIT_NAME` ; of course we could do + +```python +JUJU_UNIT_NAME = os.environ["JUJU_UNIT_NAME"] +``` + +but `CharmBase` exposes a `.unit` attribute we can read this information from, instead of grabbing it off the environment; this makes for more readable code. +So, wherever we need the juju unit name, we can write `self.unit.name` (that will get you `microsample/0` for example) or if you are actually after the *application name*, you can write `self.unit.app.name` (and get `microsample` back, without the unit index suffix). + +The resulting code at this stage can be inspected at [this branch](https://github.com/PietroPasotti/hooks-to-ops/tree/2-py-charm). + +### Step 2: Clean up snap & systemd code + +In the `_on_install` method we had translated one-to-one the calls to `snap info` to check whether the snap was installed or not; we can however use one more Linux lib for that: + +`charmcraft fetch-lib charms.operator_libs_linux.v1.snap` + +Then we can replace all that `Popen` piping with simpler calls into the lib's API; `_on_install `becomes: +```python + def _on_install(self, _event): + microsample_snap = snap.SnapCache()["microsample"] + if not microsample_snap.present: + self.unit.status = ops.MaintenanceStatus("installing microsample") + microsample_snap.ensure(snap.SnapState.Latest, channel="edge") + + self.wait_service_active() + self.unit.status = ops.ActiveStatus() + +``` + +Similarly all that string parsing we were doing to get a hold of the snap version, can be simplified by grabbing the `microsample_snap.channel` (not quite the same, but for the purposes of this charm, it is close enough). + +```python + def _get_microsample_version(self): + microsample_snap = snap.SnapCache()["microsample"] + return microsample_snap.channel +``` + +Also, we can interact with the microsample service via the `operator_libs_linux.v0` charm library, which wraps `systemd` and allows us to write simply: + +```python + def _on_start(self, _event): # noqa + systemd.service_start("snap.microsample.microsample.service") + + def _on_stop(self, _event): # noqa + systemd.service_stop("snap.microsample.microsample.service") +``` + +```{note} + +To install:\ +`charmcraft fetch-lib charms.operator_libs_linux.v0.systemd`\ +`charmcraft fetch-lib charms.operator_libs_linux.v1.snap`\ + +To use, add line to imports: +`from charms.operator_libs_linux.v0 import systemd` \ +`from charms.operator_libs_linux.v1 import snap` + +``` + +By inspecting more closely the flow of the events, we realize that not all of the event handlers that we currently subscribe to are necessary. For example, the relation data is going to be set once the relation is joined, but nothing needs to be done when the relation changes or is broken/departed. Since it depends on configurable values, however, we will need to make sure that the `config-changed` handler also keeps the relation data up to date. + +Furthermore we can get rid of the `start` handler, since the `snap.ensure()` call will also start the service for us on `install`. Similarly we can strip away most of the calls in `_on_upgrade_charm` (originally invoking multiple other hooks) and only call `snap.ensure(...)`. + +The final result can be inspected at [this branch](https://github.com/PietroPasotti/hooks-to-ops/tree/3-py-final). + + +## Closing notes + +We have seen how to turn a hooks-based charm to one using the state-of-the-art Ops framework---this basically boils down to moving code from files in a folder to methods in a `CharmBase` subclass. +That is, this is what it amounts to _to a developer_. But what about _the system_? + +The fact is, hooks charms can be written in Assembly, or any other language, so long as the shebang references something (a command / an interpreter) known to the script runner. The starting charm was as a result very lightweight, since it is written in Bash and that is included in the base Linux image. + +Ops charms, on the other hand, are Python charms. As such, even though Ops is not especially large in and of itself, Ops charms bring a virtual environment with them. That makes the resulting charm package somewhat heavier. That might be a consideration when the charm target is a resource-constrained system. + +### Todo's / disclaimers + + +Above, the `website` relation has not been tested; implementing the `Requires` part of it is also left as an exercise to the reader. diff --git a/docs/howto/write-integration-tests-for-a-charm.md b/docs/howto/write-integration-tests-for-a-charm.md new file mode 100644 index 000000000..a0801d2cf --- /dev/null +++ b/docs/howto/write-integration-tests-for-a-charm.md @@ -0,0 +1,410 @@ +(write-integration-tests-for-a-charm)= +# How to write integration tests for a charm + +> See also: {ref}`testing` + +This document shows how to write integration tests for a charm. + +```{important} + +Integration testing is only one part of a comprehensive testing strategy. See {ref}`How to test a charm ` for unit testing and {ref}`How to write a functional test ` for functional tests. + +``` + +The instructions all use the Juju `python-libjuju` client, either through the `pytest-operator` library or directly. + +> See more: [`python-libjuju`](https://github.com/charmed-kubernetes/pytest-operator), {ref}`pytest-operator` + +## Prepare your environment + +In order to run integrations tests you will need to have your environment set up with `tox` installed. + + + +## Prepare the `tox.ini` configuration file + +Check that the next information is in your `tox.ini` file. If you initialised the charm with `charmcraft init` it should already be there. + +``` +[testenv:integration] +description = Run integration tests +deps = + pytest + juju + pytest-operator + -r {tox_root}/requirements.txt +commands = + pytest -v \ + -s \ + --tb native \ + --log-cli-level=INFO \ + {posargs} \ + {[vars]tests_path}/integration +``` + +## Create a test file + +By convention, integration tests are kept in the charm’s source tree, in a directory called `tests/integration`. + +If you initialised the charm with `charmcraft init`, your charm directory should already contain a `tests/integration/test_charm.py` file. Otherwise, create this directory structure manually (the test file can be called whatever you wish) and, inside the `.py` file, import `pytest` and, from the `pytest_operator.plugin`, the `OpsTest` class provided by the `ops_test` fixture: + +``` +import pytest +from pytest_operator.plugin import OpsTest +``` + +The `ops_test` fixture is your entry point to the `pytest-operator` library, and the preferred way of interacting with Juju in integration tests. This fixture will create a model for each test file -- if you write two tests that should not share a model, make sure to place them in different files. + +## Build your tests + +```{note} + +Use `pytest` custom markers to toggle which types of tests are being run so you can skip the destructive parts and focus on the business logic tests. See more: [Discourse | Pasotti: Classify tests with pytest custom markers for quick integration testing iterations](https://discourse.charmhub.io/t/classify-tests-with-pytest-custom-markers-for-quick-integration-testing-iterations/14006). + +``` + +### Test build and deploy + +To build and deploy the current charm, in your integration test file, add the function below: + +```python +@pytest.mark.skip_if_deployed +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest): + charm = await ops_test.build_charm(".") + app = await ops_test.model.deploy(charm) + + await ops_test.model.wait_for_idle(status="active", timeout=60) +``` + +Tests run sequentially in the order they are written in the file. It can be useful to put tests that build and deploy applications in the top of the file as the applications can be used by other tests. For that reason, adding extra checks or `asserts` in this test is not recommended. + +The decorator `@pytest.mark.abort_on_fail` abort all next tests if something goes wrong. With the decorator `@pytest.mark.skip_if_deployed` you can skip that test if a `--model` is passed as a command line parameter (see {ref}`run-your-tests` for more information). + +`ops_test.build_charm` builds the charm with charmcraft. `ops_test.model` is an instance of `python-libjuju` 's [Model](https://pythonlibjuju.readthedocs.io/en/latest/api/juju.model.html#juju.model.Model) class that reference the active model tracked by `pytest-operator` for the current module. + +As an alternative to `wait_for_idle`, you can explicitly block until the application status is `active` or `error` and then assert that it is `active`. + +``` + await ops_test.model.block_until(lambda: app.status in ("active", "error"), timeout=60,) + assert app.status, "active" +``` + +> Example implementations: [charm-coredns](https://github.com/charmed-kubernetes/charm-coredns/blob/b1d83b6a31200924fefcd288336bc1f9323c6a72/tests/integration/test_integration.py#L21), [charm-calico](https://github.com/charmed-kubernetes/charm-calico/blob/e1dfdda92fefdba90b7b7e5247fbc861c34ad146/tests/integration/test_calico_integration.py#L18) + +> See more: +> - [`pytest-operator` | `ops_test.build_charm`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L1020) +> - [`python-libjuju` | `model.deploy `](https://github.com/juju/python-libjuju/blob/2581b0ced1df6201c6b7fd8cc0b20dcfa9d97c51/juju/model.py#L1658) + +### Deploy your charm with resources + + + +A charm can require `file` or `oci-image` `resources` to work, that can be provided to `ops_test.model.deploy`. In Charmhub, resources have revision numbers. For file resources already stored in Charmhub, you can use `ops_test.download_resources`: + +```python +async def test_build_and_deploy(ops_test: OpsTest): + charm = await ops_test.build_charm(".") + arch_resources = ops_test.arch_specific_resources(charm) + resources = await ops_test.download_resources(charm, resources=arch_resources) + app = await ops_test.model.deploy(charm, resources=resources) + await ops_test.model.wait_for_idle(status="active", timeout=60) +``` + +You can also reference a file resource on the filesystem. You can also use [`ops_test.build_resources`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L1073) to build file resources from a build script. + +For `oci-images` you can reference an image registry. +``` + ... + resources = {"resource_name": "localhost:32000/image_name:latest"} + app = await ops_test.model.deploy(charm, resources=resources) + ... +``` + +> Example implementations: [kubernetes-control-plane](https://github.com/charmed-kubernetes/charm-kubernetes-control-plane/blob/8769db394bf377a03ce94066307ecf831b88ad17/tests/integration/test_kubernetes_control_plane_integration.py#L41), [synapse-operator](https://github.com/canonical/synapse-operator/blob/eb44f4959a00040f08b98470f8b17cae4cc616da/tests/integration/conftest.py#L119), [prometheus-k8s](https://github.com/canonical/prometheus-k8s-operator/blob/d29f323343a1e4906a8c71104fcd1de817b2c2e6/tests/integration/test_remote_write_with_zinc.py#L27) + +> +> See more: +> - [`pytest-operator` | `build_resources`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L1073) +> - [`pytest-operator` | `download_resources`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L1101) +> - [`python-libjuju` | `model.deploy`](https://github.com/juju/python-libjuju/blob/2581b0ced1df6201c6b7fd8cc0b20dcfa9d97c51/juju/model.py#L1658) + + +### Test a relation + +To test an integration between two applications, you can just integrate them through +the model. Both applications have to be deployed beforehand. + +``` + ... +async def test_my_integration(ops_test: OpsTest): + # both application_1 and application_2 have to be deployed + # in the current test or a previous one. + await ops_test.model.integrate("application_1:relation_name_1", "application_2:relation_name_2") + await ops_test.model.wait_for_idle(status="active", timeout=60) + # check any assertion here + .... +``` + +> Example implementations: [slurmd-operator](https://github.com/canonical/slurmd-operator/blob/ffb24b05bec1b10cc512c060a4739358bfea0df0/tests/integration/test_charm.py#L89) + +> See more: [`python-libjuju` | `model.integrate`](https://github.com/juju/python-libjuju/blob/2581b0ced1df6201c6b7fd8cc0b20dcfa9d97c51/juju/model.py#L1476) + +### Test a configuration + + + +You can set a configuration option in your application and check its results. + +``` +async def test_config_changed(ops_test: OpsTest): + ... + await ops_test.model.applications["synapse"].set_config({"server_name": "invalid_name"}) + # In this case, when setting server_name to "invalid_name" + # we could for example expect a blocked status. + await ops_test.model.wait_for_idle(status="blocked", timeout=60) + .... +``` +> See also: https://discourse.charmhub.io/t/how-to-add-a-configuration-option-to-a-charm/4458 +> +> See also: [python-libjuju | application.set_config](https://github.com/juju/python-libjuju/blob/2581b0ced1df6201c6b7fd8cc0b20dcfa9d97c51/juju/application.py#L591) + + + +### Test an action + + + +You can execute an action on a unit and get its results. + +```text +async def test_run_action(ops_test: OpsTest): + action_register_user = await ops_test.model.applications["myapp"].units[0].run_action("register-user", username="ubuntu") + await action_register_user.wait() + assert action_register_user.status == "completed" + password = action_register_user.results["user-password"] + # We could for example check here that we can login with the new user +``` + +> See also: [python-libjuju | unit.run_action](https://github.com/juju/python-libjuju/blob/2581b0ced1df6201c6b7fd8cc0b20dcfa9d97c51/juju/unit.py#L274) + +### Interact with the workload + +To interact with the workload, you need to have access to it. This is dependent on many aspects of your application, environment and network topology. + +You can get information from your application or unit addresses using `await ops_test.model.get_status`. That way, if your application exposes a public address you can reference it. You can also try to connect to a unit address or public address. + +```text +async def test_workload_connectivity(ops_test: OpsTest): + status = await ops_test.model.get_status() + address = status.applications['my_app'].public_address + # Or you can try to connect to a concrete unit + # address = status.applications['my_app'].units['my_app/0'].public_address + # address = status.applications['my_app'].units['my_app/0'].address + appurl = f"http://{address}/" + r = requests.get(appurl) + assert r.status_code == 200 +``` + +How you can connect to a private or public address is dependent on your configuration, so you may need a different approach. + +> Example implementations: [mongodb-k8s-operator](https://github.com/canonical/mongodb-k8s-operator/blob/8b9ebbee3f225ca98175c25781f1936dc4a62a7d/tests/integration/metrics_tests/test_metrics.py#L33), [tempo-k8s-operator](https://github.com/canonical/tempo-k8s-operator/blob/78a1143d99af99a1a56fe9ff82b1a3563e4fd2f7/tests/integration/test_integration.py#L69), [synapse](https://github.com/canonical/synapse-operator/blob/eb44f4959a00040f08b98470f8b17cae4cc616da/tests/integration/conftest.py#L170) + + + +### Run a subprocess command within Juju context + +You can run a command within the Juju context with: + +```text + ... + command = ["microk8s", "version"] + returncode, stdout, stderr = await ops_test.run(*command, check=True) + ... +``` + +You can similarly invoke the Juju CLI. This can be useful for cases where `python-libjuju` sees things differently than the Juju CLI. By default the environment variable `JUJU_MODEL` is set, +so you don't need to include the `-m` parameter. + +``` + .... + command = ["secrets"] + returncode, stdout, stderr = await ops_test.juju(*command, check=True) + .... +``` + +> Example implementations: [prometheus-k8s-operator](https://github.com/canonical/prometheus-k8s-operator/blob/d29f323343a1e4906a8c71104fcd1de817b2c2e6/tests/integration/conftest.py#L86), [hardware-observer-operator](https://github.com/canonical/hardware-observer-operator/blob/08c50798ca1c133a5d8ba5c889e0bcb09771300b/tests/functional/conftest.py#L14) + + +> See more: +> - [`pytest-operator` | `run`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L576) +> - [`pytest-operator` | `juju`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L624) + +### Use several models + +You can use `pytest-operator` with several models, in the same cloud or in +different clouds. This way you can, for example, integrate machine charms +with Kubernetes charms easily. + +You can track a new model with: + +``` + new_model = await ops_test.track_model("model_alias", + cloud_name="cloud_name", + credential_name="credentials") +``` + +`track_model` will track a model with alias `model_alias` (not the real model name). It maybe necessary to use `credential_name` if you do not use the same cloud that the controller. + +Using the new alias, you can switch context to the new created model, similar to `juju switch` command: + +``` + with ops_test.model_context("model_alias"): + # Here ops_test.model relates to the model referred by + # You can now use ops_test.model and it will apply to the model in the context +``` + +`pytest-operator` will handle the new created model by default. If you want to, you can remove it from the controller at any point: + +``` + await ops_test.forget_model("model_alias") +``` + +> Example implementations: [`charm-kubernetes-autoscaler`](https://github.com/charmed-kubernetes/charm-kubernetes-autoscaler/blob/8f4ddf5d66802ade73ed3aab2bb8d09fd9e4d63a/tests/integration/test_kubernetes_autoscaler.py#L31) + + + + +### Deploy a bundle + +```{note} + +It is not recommended to use `ops_test.build_bundle` and `ops_test.deploy_bundle` until this [issue](https://github.com/charmed-kubernetes/pytest-operator/issues/98) is closed, as it uses `juju-bundle` which is outdated. You can deploy bundles using `ops_test.model.deploy` or `ops_test.juju`. + +``` + + +### Render bundles and charms + +`pytest-operator` has utilities to template your charms and bundles using Jinja2. + +To render a kubernetes bundle with your current charm, create the file `./test/integration/bundle.yaml.j2` with this content: +``` +bundle: kubernetes +applications: + my-app: + charm: {{ charm }} + scale: {{ scale }} +``` + +You can now add the next integration test that will build an deploy the bundle with the current charm: +``` +async def test_build_and_deploy_bundle(ops_test: OpsTest): + charm = await ops_test.build_charm(".") + + bundle = ops_test.render_bundle( + 'tests/integration/bundle.yaml.j2', + charm=charm, + scale=1, + ) + juju_cmd = ["deploy", str(bundle)] + rc, stdout, stderr = await ops_test.juju(*juju_cmd) +``` + + +> Example implementations: [`hardware-observer-operator`](https://github.com/canonical/hardware-observer-operator/blob/47a79eb2872f6222099e7f48b8daafe8d20aa946/tests/functional/test_charm.py#L57) + + + +### Speed up `update_status` with `fast_forward` + +If your charm code depends on the `update_status` event, you can speed up its +firing rate with `fast_forward`. Inside the new async context you can put any code that will benefit from the new refresh rate so your test may execute faster. + +``` + ... + app = await ops_test.model.deploy(charm) + + async with ops_test.fast_forward(): + await ops_test.model.wait_for_idle(status="active", timeout=120) + .... +``` + +> Example implementations [`postgresql-k8s-operator`](https://github.com/canonical/postgresql-k8s-operator/blob/69b2c138fa6b974883aa6d3d15a3315189d321d8/tests/integration/ha_tests/test_upgrade.py#L58), [`synapse-operator`](https://github.com/canonical/synapse-operator/blob/05c00bb7666197d04f1c025c36d8339b10b64a1a/tests/integration/test_charm.py#L249) + + +> See more: +> - [`pytest-operator` | `fast_forward`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L1400) + +(run-your-tests)= +## Run your tests + +By default you can run all your tests with: + +``` +tox -e integration +``` + +These tests will use the context of the current controller in Juju, and by default will create a new model per module, that will be destroyed when the test is finished. The cloud, controller and model name can be specified with the parameters `--cloud`, `--controller` and `--model` parameters. + +If you specify the model name and do not delete the model on test tear down with the parameter `--keep-models`, you can reuse a model from a previous test run, as in the next example: +``` +# in the initial execution, the new model will be created +tox -e integration -- --keep-models --model test-example-model +# in the next execution it will reuse the model created previously: +tox -e integration -- --keep-models --model test-example-model --no-deploy +``` + +The parameter `--no-deploy` will skip tests decorated with `@pytest.mark.skip_if_deployed`. That way you can iterate faster on integration tests, as applications can be deployed only once. + +There are different ways of specifying a subset of tests to run using `pytest`. With the `-k` option you can specify different expressions. For example, the next command will run all tests in the `test_charm.py` file except `test_one` function. +``` +tox -e integration -- tests/integration/test_charm.py -k "not test_one" +``` + + + +> See more: +> - [`pytest-operator` | `skip_if_deployed`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L139) +> - [`pytest | How to invoke pytest`](https://docs.pytest.org/en/7.1.x/how-to/usage.html) + +## Generate crash dumps + +To generate crash dumps, you need the `juju-crashdump` tool . + + +You can install it with `sudo snap install --classic juju-crashdump`. + +By default, when tests are run, a crash dump file will be created in the current directory if a test fails and if `--keep-models` is `false`. This crash dump file will include the current configuration and also Juju logs. + +You can disable crash dump generation with `--crash-dump=never`. To always create a crash dump file (even when tests do not fail) to a specific location run: + +``` +tox -e integration -- --crash-dump=always --crash-dump-output=/tmp +``` + +> See more: +> - [`juju-crashdump`](https://github.com/juju/juju-crashdump) +> - [`pytest-operator` | `--crash-dump`](https://github.com/charmed-kubernetes/pytest-operator/blob/ab50fc20320d3ea3d8a37495f92a004531a4023f/pytest_operator/plugin.py#L97) diff --git a/docs/howto/write-scenario-tests-for-a-charm.md b/docs/howto/write-scenario-tests-for-a-charm.md new file mode 100644 index 000000000..17d3c697e --- /dev/null +++ b/docs/howto/write-scenario-tests-for-a-charm.md @@ -0,0 +1,79 @@ +(write-scenario-tests-for-a-charm)= +# How to write scenario tests for a charm + +First of all, install scenario: + +`pip install ops-scenario` + +Then, open a new `test_foo.py` file where you will put the test code. + +```python +# import the necessary objects from scenario and ops +from scenario import State, Context +import ops +``` + + +Then declare a new charm type: +```python +class MyCharm(ops.CharmBase): + pass +``` +And finally we can write a test function. The test code should use a Context object to encapsulate the charm type being tested (`MyCharm`) and any necessary metadata, then declare the initial `State` the charm will be presented when run, and `run` the context with an `event` and that initial state as parameters. +In code: + +```python +def test_charm_runs(): + # arrange: + # create a Context to specify what code we will be running + ctx = Context(MyCharm, meta={'name': 'my-charm'}) + # and create a State to specify what simulated data the charm being run will access + state_in = State() + # act: + # ask the context to run an event, e.g. 'start', with the state we have previously created + state_out = ctx.run(ctx.on.start(), state_in) + # assert: + # verify that the output state looks like you expect it to + assert state_out.status.unit.name == 'unknown' +``` + +> See more: +> - [State](https://ops.readthedocs.io/en/latest/state-transition-testing.html#ops.testing.State) +> - [Context](https://ops.readthedocs.io/en/latest/state-transition-testing.html#ops.testing.Context) + +```{note} + +If you like using unittest, you should rewrite this as a method of some TestCase subclass. +``` + +## Mocking beyond the State + +If you wish to use Scenario to test an existing charm type, you will probably need to mock out certain calls that are not covered by the `State` data structure. +In that case, you will have to manually mock, patch or otherwise simulate those calls on top of what Scenario does for you. + +For example, suppose that the charm we're testing uses the `KubernetesServicePatch`. To update the test above to mock that object, modify the test file to contain: + +```python +import pytest +from unittest import patch + +@pytest.fixture +def my_charm(): + with patch("charm.KubernetesServicePatch"): + yield MyCharm +``` + +Then you should rewrite the test to pass the patched charm type to the Context, instead of the unpatched one. In code: +```python +def test_charm_runs(my_charm): + # arrange: + # create a Context to specify what code we will be running + ctx = Context(my_charm, meta={'name': 'my-charm'}) + # ... +``` + +```{note} + +If you use pytest, you should put the `my_charm` fixture in a toplevel `conftest.py`, as it will likely be shared between all your scenario tests. + +``` diff --git a/docs/howto/write-unit-tests-for-a-charm.md b/docs/howto/write-unit-tests-for-a-charm.md new file mode 100644 index 000000000..d8edb8285 --- /dev/null +++ b/docs/howto/write-unit-tests-for-a-charm.md @@ -0,0 +1,195 @@ +(write-unit-tests-for-a-charm)= +# How to write unit tests for a charm + +The Ops library provides a testing harness, so you can check your charm does the right thing in different scenarios without having to create a full deployment. When you run `charmcraft init`, the template charm it creates includes some sample tests, along with a `tox.ini` file; use `tox` to run the tests and to get a short report of unit test coverage. + +## Testing basics + +Here’s a minimal example, taken from the `charmcraft init` template with some additional comments: + +```python +# Import Ops library's testing harness +import ops +import ops.testing +import pytest +# Import your charm class +from charm import TestCharmCharm + + +@pytest.fixture +def harness(): + # Instantiate the Ops library's test harness + harness = ops.testing.Harness(TestCharmCharm) + # Set a name for the testing model created by Harness (optional). + # Cannot be called after harness.begin() + harness.set_model_name("testing") + # Instantiate an instance of the charm (harness.charm) + harness.begin() + yield harness + # Run Harness' cleanup method on teardown + harness.cleanup() + + +def test_config_changed(harness: ops.testing.Harness[TestCharmCharm]): + # Test initialisation of shared state in the charm + assert list(harness.charm._stored.things) == [] + + # Simulates the update of config, triggers a config-changed event + harness.update_config({"things": "foo"}) + # Test the config-changed method stored the update in state + assert list(harness.charm._stored.things) == ["foo"] + +``` + +We use [`pytest` unit testing framework](https://docs.pytest.org) (Python’s standard [unit testing framework](https://docs.python.org/3/library/unittest.html) is a valid alternative), augmenting it with [`Harness`](https://ops.readthedocs.io/en/latest/#ops.testing.Harness), the Ops library's testing harness. [`Harness`](https://ops.readthedocs.io/en/latest/#ops.testing.Harness) provides some convenient mechanisms for mocking charm events and processes. + +A common pattern is to specify some minimal `metadata.yaml` content for testing like this: + +```python +harness = Harness(TestCharmCharm, meta=''' + name: test-app + peers: + cluster: + interface: cluster + requires: + db: + interface: sql + ''') +harness.begin() +... +``` + +When using `Harness.begin()` you are responsible for manually triggering events yourself via other harness calls: + +```python +... +# Add a relation and trigger relation-created. +harness.add_relation('db', 'postgresql') # , +# Add a peer relation and trigger relation-created +harness.add_relation('cluster', 'test-app') # , +``` + +Notably, specifying relations in `charmcraft.yaml` does not automatically make them created by the +harness. If you have e.g. code that accesses relation data, you must manually add those relations +(including peer relations) for the harness to provide access to that relation data to your charm. + +In some cases it may be useful to start the test harness and fire the same hooks that Juju would fire on deployment. This can be achieved using the `begin_with_initial_hooks()` method , to be used in place of the `begin()` method. This method will trigger the events: `install -> relation-created -> config-changed -> start -> relation-joined` depending on whether any relations have been created prior calling `begin_with_initial_hooks()`. An example of this is shown in the [testing relations](https://juju.is/docs/sdk/relations) section. + +Using the `harness` variable, we can simulate various events in the charm’s lifecycle: + +```python +# Update the harness to set the active unit as a "leader" (the default value is False). +# This will trigger a leader-elected event +harness.set_leader(True) +# Update config. +harness.update_config({"foo": "bar", "baz": "qux"}) +# Disable hooks if we're about to do something that would otherwise cause a hook +# to fire such as changing configuration or setting a leader, but you don't want +# those hooks to fire. +harness.disable_hooks() +# Update config +harness.update_config({"foo": "quux"}) +# Re-enable hooks +harness.enable_hooks() +# Set the status of the active unit. We'd need "from ops.model import BlockedStatus". +harness.charm.unit.status = BlockedStatus("Testing") +``` + +Any of your charm’s properties and methods (including event callbacks) can be accessed using +`harness.charm`. You can check out the [harness API +docs](https://ops.readthedocs.io/en/latest/index.html#ops.testing.Harness) for more ways to use the +harness to trigger other events and to test your charm (e.g. triggering leadership-related events, +testing pebble events and sidecar container interactions, etc.). + + +## Testing log output + +Charm authors can also test for desired log output. Should a charm author create log messages in the standard form: + +```python +# ... +logger = logging.getLogger(__name__) + + +class SomeCharm(ops.CharmBase): +# ... + def _some_method(self): + logger.info("some message") +# ... +``` + +The above logging output could be tested like so: + +```python +# The caplog fixture is available in all pytest's tests +def test_logs(harness, caplog): + harness.charm._some_method() + with caplog.at_level(logging.INFO): + assert [rec.message for rec in caplog.records] == ["some message"] +``` + +## Simulating container networking + +> Added in 1.4, changed in version 2.0 + +In `ops` 1.4, functionality was added to the Harness to more accurately track connections to workload containers. As of `ops` 2.0, this behaviour is enabled and simulated by default (prior to 2.0, you had to enable it by setting `ops.testing.SIMULATE_CAN_CONNECT` to True before creating Harness instances). + +Containers normally start in a disconnected state, and any interaction with the remote container (push, pull, add_layer, and so on) will raise an `ops.pebble.ConnectionError`. + +To mark a container as connected, +you can either call [`harness.set_can_connect(container, True)`](https://ops.readthedocs.io/en/latest/#ops.testing.Harness.set_can_connect), or you can call [`harness.container_pebble_ready(container)`](https://ops.readthedocs.io/en/latest/#ops.testing.Harness.container_pebble_ready) if you want to mark the container as connected *and* trigger its pebble-ready event. + +However, if you're using [`harness.begin_with_initial_hooks()`](https://ops.readthedocs.io/en/latest/#ops.testing.Harness.begin_with_initial_hooks) in your tests, that will automatically call `container_pebble_ready()` for all containers in the charm's metadata, so you don't have to do it manually. + +If you have a hook that pushes a file to the container, like this: + +```python +def _config_changed(event): + c = self.unit.get_container('foo') + c.push(...) + self.config_value = ... +``` + +Your old testing code won't work: + +```python +@fixture +def harness(): + harness = Harness(ops.CharmBase, meta=""" + name: test-app + containers: + foo: + resource: foo-image + """) + harness.begin() + yield harness + harness.cleanup() + +def test_something(harness): + c = harness.model.unit.get_container('foo') + + # THIS NOW FAILS WITH A ConnectionError: + harness.update_config(key_values={'the-answer': 42}) +``` + +Which suggests that your `_config_changed` hook should probably use [`Container.can_connect()`](https://ops.readthedocs.io/en/latest/#ops.Container.can_connect): + +```python +def _config_changed(event): + c = self.unit.get_container('foo') + if not c.can_connect(): + # wait until we can connect + event.defer() + return + c.push(...) + self.config_value = ... +``` + +Now you can test both connection states: + +``` +harness.update_config(key_values={'the-answer': 42}) # can_connect is False +harness.container_pebble_ready('foo') # set can_connect to True +assert 42 == harness.charm.config_value +``` + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 000000000..3ca57e1d7 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,93 @@ +# Ops documentation + +```{toctree} +:maxdepth: 2 +:hidden: true + +tutorial/index +howto/index +reference/index +explanation/index +``` + + + +The Ops library (`ops`) is a Python framework for writing and testing Juju charms. + +> [See it on PyPI](https://pypi.org/project/ops/) + +The library provides: + +- {ref}`ops_main_entry_point`, used to initialise and run your charm +- {ref}`ops`, the API to respond to Juju events and manage the application +- {ref}`ops_pebble`, the Pebble client, a low-level API for Kubernetes containers +- {ref}`ops_testing`, the recommended API for unit testing charms +- {ref}`ops_testing_harness`, the deprecated API for unit testing charms + +You can structure your charm however you like, but with the `ops` library, you get a framework that promotes consistency and readability by following best practices. It also helps you organise your code better by separating different aspects of the charm, such as managing the application's state, handling integrations with other services, and making the charm easier to test. + + +--------- + +## In this documentation + +````{grid} 1 1 2 2 + +```{grid-item-card} [Tutorial](tutorial/index) +:link: tutorial/index +:link-type: doc + +**Start here**: a hands-on introduction to `ops` for new users +``` + +```{grid-item-card} [How-to guides](/index) +:link: howto/index +:link-type: doc + +**Step-by-step guides** covering key operations and common tasks +``` + +```` + + +````{grid} 1 1 2 2 +:reverse: + +```{grid-item-card} [Reference](/index) +:link: reference/index +:link-type: doc + +**Technical information** - specifications, APIs, architecture +``` + +```{grid-item-card} [Explanation](/index) +:link: explanation/index +:link-type: doc + +**Discussion and clarification** of key topics +``` + +```` + + +--------- + + +## Project and community + +Ops is a member of the Ubuntu family. It’s an open source project that warmly welcomes community projects, contributions, suggestions, fixes and constructive feedback. + +* **[Read our code of conduct](https://ubuntu.com/community/ethos/code-of-conduct)**: +As a community we adhere to the Ubuntu code of conduct. + +* **[Get support](https://discourse.charmhub.io/)**: +Discourse is the go-to forum for all questions Ops. + +* **[Join our online chat](https://matrix.to/#/#charmhub-ops:ubuntu.com)**: +Meet us in the #charmhub-charmdev channel on Matrix. + +* **[Report bugs](https://github.com/canonical/operator/)**: +We want to know about the problems so we can fix them. + +* **[Contribute docs](https://github.com/canonical/operator/tree/main/docs)**: +The documentation sources on GitHub. diff --git a/docs/index.rst b/docs/index.rst deleted file mode 100644 index 8ac0abcee..000000000 --- a/docs/index.rst +++ /dev/null @@ -1,64 +0,0 @@ -API reference -============= - -The `ops` library is a Python framework for writing and testing Juju charms. - - See more: `Charm SDK documentation `_ - -The library (`available on PyPI`_) provides: - -- :ref:`ops_module`, the API to respond to Juju events and manage the application; -- :ref:`ops_main_entry_point`, used to initialise and run your charm; -- :doc:`ops.pebble `, the Pebble client, a low-level API for Kubernetes containers; -- the APIs for unit testing charms in a simulated environment: - - - :doc:`State-transition testing `. This is the - recommended approach (it was previously known as 'Scenario'). - - :doc:`Harness `. This is a deprecated framework, and has issues, - particularly with resetting the charm state between Juju events. - -You can structure your charm however you like, but with the `ops` library, you -get a framework that promotes consistency and readability by following best -practices. It also helps you organise your code better by separating different -aspects of the charm, such as managing the application's state, handling -integrations with other services, and making the charm easier to test. - -.. _available on PyPI: https://pypi.org/project/ops/ - -.. toctree:: - :hidden: - :maxdepth: 2 - - self - pebble - state-transition-testing - harness - -.. _ops_module: - -ops ---- - -.. automodule:: ops - :exclude-members: main - -.. _ops_main_entry_point: - -ops.main entry point --------------------- - -The main entry point to initialise and run your charm. - -.. autofunction:: ops.main - -legacy main module ------------------- - -.. automodule:: ops.main - :noindex: - - -Indices -======= - -* :ref:`genindex` diff --git a/docs/pebble.rst b/docs/pebble.rst deleted file mode 100644 index b5f6e8180..000000000 --- a/docs/pebble.rst +++ /dev/null @@ -1,6 +0,0 @@ -.. _ops_pebble_module: - -Pebble client -============= - -.. automodule:: ops.pebble diff --git a/docs/reference/index.md b/docs/reference/index.md new file mode 100644 index 000000000..196c5eb6c --- /dev/null +++ b/docs/reference/index.md @@ -0,0 +1,13 @@ +(reference)= +# Reference + +```{toctree} +:maxdepth: 1 + +ops-main-entrypoint +ops +pebble +ops-testing +ops-testing-harness +``` + diff --git a/docs/reference/ops-main-entrypoint.rst b/docs/reference/ops-main-entrypoint.rst new file mode 100644 index 000000000..e4b1519ca --- /dev/null +++ b/docs/reference/ops-main-entrypoint.rst @@ -0,0 +1,16 @@ +.. _ops_main_entry_point: + +`ops.main` entry point +====================== + +The main entry point to initialise and run your charm. + +.. autofunction:: ops.main + +legacy main module +------------------ + +.. automodule:: ops.main + :noindex: + + diff --git a/docs/harness.rst b/docs/reference/ops-testing-harness.rst similarity index 92% rename from docs/harness.rst rename to docs/reference/ops-testing-harness.rst index a03cded49..4ee02a5ec 100644 --- a/docs/harness.rst +++ b/docs/reference/ops-testing-harness.rst @@ -1,7 +1,7 @@ -.. _harness: +.. _ops_testing_harness: -Harness (legacy unit testing) -============================= +`ops.testing.Harness` (legacy unit testing) +=========================================== .. deprecated:: 2.17 The Harness framework is deprecated and will be moved out of the base diff --git a/docs/state-transition-testing.rst b/docs/reference/ops-testing.rst similarity index 98% rename from docs/state-transition-testing.rst rename to docs/reference/ops-testing.rst index 156848023..16da0e5d5 100644 --- a/docs/state-transition-testing.rst +++ b/docs/reference/ops-testing.rst @@ -1,7 +1,7 @@ -.. _state-transition-tests: +.. _ops_testing: -Unit testing (was: Scenario) -============================ +`ops.testing` (was: Scenario) +============================= Install ops with the ``testing`` extra to use this API; for example: ``pip install ops[testing]`` diff --git a/docs/reference/ops.rst b/docs/reference/ops.rst new file mode 100644 index 000000000..91d44277d --- /dev/null +++ b/docs/reference/ops.rst @@ -0,0 +1,8 @@ +.. _ops: + +`ops` +===== + +.. automodule:: ops + :exclude-members: main + diff --git a/docs/reference/pebble.rst b/docs/reference/pebble.rst new file mode 100644 index 000000000..e826b591d --- /dev/null +++ b/docs/reference/pebble.rst @@ -0,0 +1,6 @@ +.. _ops_pebble: + +`ops.pebble` +============ + +.. automodule:: ops.pebble diff --git a/docs/resources/create_a_minimal_kubernetes_charm.png b/docs/resources/create_a_minimal_kubernetes_charm.png new file mode 100644 index 000000000..2b7e7fffa Binary files /dev/null and b/docs/resources/create_a_minimal_kubernetes_charm.png differ diff --git a/docs/resources/integrate_your_charm_with_postgresql.png b/docs/resources/integrate_your_charm_with_postgresql.png new file mode 100644 index 000000000..c17393597 Binary files /dev/null and b/docs/resources/integrate_your_charm_with_postgresql.png differ diff --git a/docs/resources/observe_your_charm_with_cos_lite.png b/docs/resources/observe_your_charm_with_cos_lite.png new file mode 100644 index 000000000..3c5a58c45 Binary files /dev/null and b/docs/resources/observe_your_charm_with_cos_lite.png differ diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/#expose-the-version-of-the-application-behind-your-charm.md# b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/#expose-the-version-of-the-application-behind-your-charm.md# new file mode 100644 index 000000000..2cc19f05e --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/#expose-the-version-of-the-application-behind-your-charm.md# @@ -0,0 +1,124 @@ +(expose-the-version-of-the-application-behind-your-charm)= +# Expose the version of the application behind your charm + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Expose the version of the application behind your charm +> +> **See previous: {ref}`Make your charm configurable `** + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + +``` +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 02_make_your_charm_configurable +git checkout -b 03_set_workload_version +``` + +```` + +In this chapter of the tutorial you will learn how to expose the version of the application (workload) run by the charm -- something that a charm user might find it useful to know. + +**Contents:** + +1. [Define functions to collect the workload application version and set it in the charm](#heading--define-functions-to-collect-the-workload-application-version-and-set-it-in-the-charm) +1. [Declare Python dependencies](#heading--declare-python-dependencies) +1. [Validate your charm](#heading--validate-your-charm) +1. [Review the final code](#heading--review-the-final-code) + +

Define functions to collect the workload application version and set it in the charm

+ +As a first step we need to add two helper functions that will send an HTTP request to our application to get its version. If the container is available, we can send a request using the `requests` Python library and then add class methods to parse the JSON output to get a version string, as shown below: + +- Import the `requests` Python library: + +``` +import requests +``` + +- Add the following class methods: + + +```python +@property +def version(self) -> str: + """Reports the current workload (FastAPI app) version.""" + try: + if self.container.get_services(self.pebble_service_name): + return self._request_version() + # Catching Exception is not ideal, but we don't care much for the error here, and just + # default to setting a blank version since there isn't much the admin can do! + except Exception as e: + logger.warning("unable to get version from API: %s", str(e), exc_info=True) + return "" + +def _request_version(self) -> str: + """Helper for fetching the version from the running workload using the API.""" + resp = requests.get(f"http://localhost:{self.config['server-port']}/version", timeout=10) + return resp.json()["version"] +``` + +Next, we need to update the `_update_layer_and_restart` method to set our workload version. Insert the following lines before setting `ActiveStatus`: + +```python +# Add workload version in Juju status. +self.unit.set_workload_version(self.version) +``` + +

Declare Python dependencies

+ + +Since we've added a third party Python dependency into our project, we need to list it in `requirements.txt`. Edit the file to add the following line: + +``` +requests~=2.28 +``` + +Next time you run `charmcraft` it will fetch this new dependency into the charm package. + + +

Validate your charm

+ +We've exposed the workload version behind our charm. Let's test that it's working! + +First, repack and refresh your charm: + +``` +charmcraft pack +juju refresh \ + --path="./demo-api-charm_ubuntu-22.04-amd64.charm" \ + demo-api-charm --force-units --resource \ + demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1 +``` + +Our charm should fetch the application version and forward it to `juju`. Run `juju status` to check: + +```text +juju status +``` + +Indeed, the version of our workload is now displayed -- see the App block, the Version column: + +```text +Model Controller Cloud/Region Version SLA Timestamp +charm-model tutorial-controller microk8s/localhost 3.0.0 unsupported 12:37:27+01:00 + +App Version Status Scale Charm Channel Rev Address Exposed Message +demo-api-charm 1.0.1 active 1 demo-api-charm 0 10.152.183.233 no + +Unit Workload Agent Address Ports Message +demo-api-charm/0* active idle 10.1.157.75 +``` + +

Review the final code

+ + +For the full code see: [03_set_workload_version](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/03_set_workload_version) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/02_make_your_charm_configurable...03_set_workload_version) + + +> **See next: {ref}`Integrate your charm with PostgreSQL `** + +> Contributors: @beliaev-maksim \ No newline at end of file diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md new file mode 100644 index 000000000..c595a6184 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/create-a-minimal-kubernetes-charm.md @@ -0,0 +1,431 @@ +(create-a-minimal-kubernetes-charm)= +# Create a minimal Kubernetes charm + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Create a minimal Kubernetes charm +> +> **See previous: {ref}`Set up your development environment `** + + + + + +As you already know from your knowledge of Juju, when you deploy a Kubernetes charm, the following things happen: + +1. The Juju controller provisions a pod with two containers, one for the Juju unit agent and the charm itself and one container for each application workload container that is specified in the `containers` field of a file in the charm that is called `charmcraft.yaml`. +1. The same Juju controller injects Pebble -- a lightweight, API-driven process supervisor -- into each workload container and overrides the container entrypoint so that Pebble starts when the container is ready. +1. When the Kubernetes API reports that a workload container is ready, the Juju controller informs the charm that the instance of Pebble in that container is ready. At that point, the charm knows that it can start communicating with Pebble. +1. Typically, at this point the charm will make calls to Pebble so that Pebble can configure and start the workload and begin operations. + +> Note: In the past, the containers were specified in a `metadata.yaml` file, but the modern practice is that all charm specification is in a single `charmcraft.yaml` file. + + + + + + + +All subsequent workload management happens in the same way -- the Juju controller sends events to the charm and the charm responds to these events by managing the workload application in various ways via Pebble. The picture below illustrates all of this for a simple case where there is just one workload container. + + +![Create a minimal Kubernetes charm](../../resources/create_a_minimal_kubernetes_charm.png) + + +As a charm developer, your first job is to use this knowledge to create the basic structure and content for your charm: + + - descriptive files (e.g., YAML configuration files like the `charmcraft.yaml` file mentioned above) that give Juju, Python, or Charmcraft various bits of information about your charm, and +- executable files (like the `src/charm.py` file that we will see shortly) where you will use Ops-enriched Python to write all the logic of your charm. + + +## Set the basic information, requirements, and workload for your charm + +Create a file called `charmcraft.yaml`. This is a file that describes metadata such as the charm name, purpose, environment constraints, workload containers, etc., in short, all the information that tells Juju what it can do with your charm. + +In this file, do all of the following: + +First, add basic information about your charm: + +```text +name: demo-api-charm +title: | + demo-fastapi-k8s +description: | + This is a demo charm built on top of a small Python FastAPI server. + This charm could be related to PostgreSQL charm and COS Lite bundle (Canonical Observability Stack). +summary: | + FastAPI Demo charm for Kubernetes +``` + +Second, add an environment constraint assuming the latest major Juju version and a Kubernetes-type cloud: + +```text +assumes: + - juju >= 3.1 + - k8s-api +``` + +Third, describe the workload container, as below. Below, `demo-server` is the name of the container, and `demo-server-image` is the name of its OCI image. + +```text +containers: + demo-server: + resource: demo-server-image +``` + + +Fourth, describe the workload container resources, as below. The name of the resource below, `demo-server-image`, is the one you defined above. + +```text +resources: + # An OCI image resource for each container listed above. + # You may remove this if your charm will run without a workload sidecar container. + demo-server-image: + type: oci-image + description: OCI image from GitHub Container Repository + # The upstream-source field is ignored by Juju. It is included here as a reference + # so the integration testing suite knows which image to deploy during testing. This field + # is also used by the 'canonical/charming-actions' Github action for automated releasing. + upstream-source: ghcr.io/canonical/api_demo_server:1.0.1 +``` + + + +## Define the charm initialisation and application services + + + +Create a file called `requirements.txt`. This is a file that describes all the required external Python dependencies that will be used by your charm. + + +In this file, declare the `ops` dependency, as below. At this point you're ready to start using constructs from the Ops library. + +``` +ops >= 2.11 +``` + + +Create a file called `src/charm.py`. This is the file that you will use to write all the Python code that you want your charm to execute in response to events it receives from the Juju controller. + + +This file needs to be executable. One way you can do this is: + +```text +chmod a+x src/charm.py +``` + +In this file, do all of the following: + +First, add a shebang to ensure that the file is directly executable. Then, import the `ops` package to access the`CharmBase` class and the `main` function. Next, use `CharmBase` to create a charm class `FastAPIDemoCharm` and then invoke this class in the `main` function of Ops. As you can see, a charm is a pure Python class that inherits from the CharmBase class of Ops and which we pass to the `main` function defined in the `ops.main` module. + +```python +#!/usr/bin/env python3 + +import ops + +class FastAPIDemoCharm(ops.CharmBase): + """Charm the service.""" + + def __init__(self, framework: ops.Framework) -> None: + super().__init__(framework) + +if __name__ == "__main__": # pragma: nocover + ops.main(FastAPIDemoCharm) + +``` + + +Now, in the `__init__` function of your charm class, use Ops constructs to add an observer for when the Juju controller informs the charm that the Pebble in its workload container is up and running, as below. As you can see, the observer is a function that takes as an argument an event and an event handler. The event name is created automatically by Ops for each container on the template `-pebble-ready`. The event handler is a method in your charm class that will be executed when the event is fired; in this case, you will use it to tell Pebble how to start your application. + +```python +framework.observe(self.on.demo_server_pebble_ready, self._on_demo_server_pebble_ready) +``` + + +```{important} + +**Generally speaking:** A charm class is a collection of event handling methods. When you want to install, remove, upgrade, configure, etc., an application, Juju sends information to your charm. Ops translates this information into events and your job is to write event handlers + +``` + +```{tip} + +**Pro tip:** Use `__init__` to hold references (pointers) to other `Object`s or immutable state only. That is because a charm is reinitialised on every event. + +``` + + + + +Next, define the event handler, as follows: + +We'll use the `ActiveStatus` class to set the charm status to active. Note that almost everything you need to define your charm is in the `ops` package that you imported earlier - there's no need to add additional imports. + +Use `ActiveStatus` as well as further Ops constructs to define the event handler, as below. As you can see, what is happening is that, from the `event` argument, you extract the workload container object in which you add a custom layer. Once the layer is set you replan your service and set the charm status to active. + + + + +```python +def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: + """Define and start a workload using the Pebble API. + + Change this example to suit your needs. You'll need to specify the right entrypoint and + environment configuration for your specific workload. + + Learn more about interacting with Pebble at at https://juju.is/docs/sdk/pebble + Learn more about Pebble layers at + https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/layers + """ + # Get a reference the container attribute on the PebbleReadyEvent + container = event.workload + # Add initial Pebble config layer using the Pebble API + container.add_layer("fastapi_demo", self._pebble_layer, combine=True) + # Make Pebble reevaluate its plan, ensuring any services are started if enabled. + container.replan() + # Learn more about statuses in the SDK docs: + # https://juju.is/docs/sdk/status + self.unit.status = ops.ActiveStatus() +``` + +The custom Pebble layer that you just added is defined in the `self._pebble_layer` property. Update this property to match your application, as follows: + +In the `__init__` method of your charm class, name your service to `fastapi-service` and add it as a class attribute : + +``` +self.pebble_service_name = "fastapi-service" +``` + +Finally, define the `pebble_layer` function as below. The `command` variable represents a command line that should be executed in order to start our application. + +```python +@property +def _pebble_layer(self) -> ops.pebble.Layer: + """A Pebble layer for the FastAPI demo services.""" + command = ' '.join( + [ + 'uvicorn', + 'api_demo_server.app:app', + '--host=0.0.0.0', + '--port=8000', + ] + ) + pebble_layer: ops.pebble.LayerDict = { + 'summary': 'FastAPI demo service', + 'description': 'pebble config layer for FastAPI demo server', + 'services': { + self.pebble_service_name: { + 'override': 'replace', + 'summary': 'fastapi demo', + 'command': command, + 'startup': 'enabled', + } + }, + } + return ops.pebble.Layer(pebble_layer) +``` + + + +## Add logger functionality + +In the `src/charm.py` file, in the imports section, import the Python `logging` module and define a logger object, as below. This will allow you to read log data in `juju`. + +``` +import logging + +# Log messages can be retrieved using juju debug-log +logger = logging.getLogger(__name__) +``` + +## Tell Charmcraft how to build your charm + +In the same `charmcraft.yaml` file you created earlier, you need to describe all the information needed for Charmcraft to be able to pack your charm. In this file, do the following: + +First, add the block below. This will identify your charm as a charm (as opposed to something else you might know from using Juju, namely, a bundle). + +``` +type: charm +``` + + +Also add the block below. This declares that your charm will build and run charm on Ubuntu 22.04. + +``` +bases: + - build-on: + - name: ubuntu + channel: "22.04" + run-on: + - name: ubuntu + channel: "22.04" +``` + + +Aaaand that's it! Time to validate your charm! + +```{tip} + +Once you've mastered the basics, you can speed things up by navigating to your empty charm project directory and running `charmcraft init --profile kubernetes`. This will create all the files above and more, along with helpful descriptor keys and code scaffolding. + +``` + + +## Validate your charm + +First, ensure that you are inside the Multipass Ubuntu VM, in the `~/fastapi-demo` folder: + +``` +multipass shell charm-dev +cd ~/fastapi-demo +``` + +Now, pack your charm project directory into a `.charm` file, as below. This will produce a `.charm` file. In our case it was named `demo-api-charm_ubuntu-22.04-amd64.charm`; yours should be named similarly, though the name might vary slightly depending on your architecture. + +``` +charmcraft pack +# Packed demo-api-charm_ubuntu-22.04-amd64.charm +``` + +```{important} + +If packing failed - perhaps you forgot to make the charm.py executable earlier - you may need to run `charmcraft clean` before re-running `charmcraft pack`. `charmcraft` will generally detect when files have changed, but will miss only file attributes changing. + +``` + +```{important} + +**Did you know?** A `.charm` file is really just a zip file of your charm files and code dependencies that makes it more convenient to share, publish, and retrieve your charm contents. + +``` + + + + + + + +Deploy the `.charm` file, as below. Juju will create a Kubernetes `StatefulSet` named after your application with one replica. + +```text +juju deploy ./demo-api-charm_ubuntu-22.04-amd64.charm --resource \ + demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1 +``` + + +```{important} + +**If you've never deployed a local charm (i.e., a charm from a location on your machine) before:**
As you may know, when you deploy a charm from Charmhub it is sufficient to run `juju deploy `. However, to deploy a local charm you need to explicitly define a `--resource` parameter with the same resource name and resource upstream source as in the `charmcraft.yaml`. + +``` + + +Monitor your deployment: + +```text +juju status --watch 1s +``` + +When all units are settled down, you should see the output below, where `10.152.183.215` is the IP of the K8s Service and `10.1.157.73` is the IP of the pod. + +``` +Model Controller Cloud/Region Version SLA Timestamp +welcome-k8s tutorial-controller microk8s/localhost 3.0.0 unsupported 13:38:19+01:00 + +App Version Status Scale Charm Channel Rev Address Exposed Message +demo-api-charm active 1 demo-api-charm 1 10.152.183.215 no + +Unit Workload Agent Address Ports Message +demo-api-charm/0* active idle 10.1.157.73 +``` + +Now, validate that the app is running and reachable by sending an HTTP request as below, where `10.1.157.73` is the IP of our pod and `8000` is the default application port. + +``` +curl 10.1.157.73:8000/version +``` + +You should see a JSON string with the version of the application: + +``` +{"version":"1.0.0"} +``` + + +```{dropdown} Expand if you wish to inspect your deployment further + + +1. Run: + +```text +kubectl get namespaces +``` + +You should see that Juju has created a namespace called `welcome-k8s`. + +2. Try: + +```text +kubectl -n welcome-k8s get pods +``` + +You should see that your application has been deployed in a pod that has 2 containers running in it, one for the charm and one for the application. The containers talk to each other via the Pebble API using the UNIX socket. + +```text +NAME READY STATUS RESTARTS AGE +modeloperator-5df6588d89-ghxtz 1/1 Running 3 (7d2h ago) 13d +demo-api-charm-0 2/2 Running 0 7d2h +``` + +3. Check also: + +```text +kubectl -n welcome-k8s describe pod demo-api-charm-0 +``` +In the output you should see the definition for both containers. You'll be able to verify that the default command and arguments for our application container (`demo-server`) have been displaced by the Pebble service. You should be able to verify the same for the charm container (`charm`). + +**Congratulations, you've successfully created a minimal Kubernetes charm!** + + +## Review the final code + +For the full code see: [01_create_minimal_charm](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/01_create_minimal_charm) + +For a comparative view of the code before and after our edits see: +[Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/main...01_create_minimal_charm) + + + +>**See next: {ref}`Make your charm configurable `** + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md new file mode 100644 index 000000000..313e64482 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-operational-tasks-via-actions.md @@ -0,0 +1,166 @@ +(expose-operational-tasks-via-actions)= +# Expose operational tasks via actions + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Expose operational tasks via actions +> +> **See previous: {ref}`Preserve your charm's data `** + + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + + +```text +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 05_preserve_charm_data +git checkout -b 06_create_actions +``` + +```` + +A charm should ideally cover all the complex operational logic within the code, to help avoid the need for manual human intervention. + +Unfortunately, that is not always possible. As a charm developer, it is thus useful to know that you can also expose charm operational tasks to the charm user by defining special methods called `actions`. + +This can be done by adding an `actions` section in your `charmcraft.yaml` file and then adding action event handlers to the `src/charm.py` file. + +In this part of the tutorial we will follow this process to add an action that will allow a charm user to view the current database access points and, if set, also the username and the password. + + +## Define the actions + +Open the `charmcraft.yaml` file and add to it a block defining an action, as below. As you can see, the action is called `get-db-info` and it is intended to help the user access database authentication information. The action has a single parameter, `show-password`; if set to `True`, it will show the username and the password. + +```yaml +actions: + get-db-info: + description: Fetches Database authentication information + params: + show-password: + description: "Show username and password in output information" + type: boolean + default: False +``` + + +## Define the action event handlers + +Open the `src/charm.py` file. + +In the charm `__init__` method, add an action event observer, as below. As you can see, the name of the event consists of the name defined in the `charmcraft.yaml` file (`get-db-info`) and the word `action`. + +```python +# events on custom actions that are run via 'juju run-action' +framework.observe(self.on.get_db_info_action, self._on_get_db_info_action) +``` + + + + + +Now, define the action event handler, as below: First, read the value of the parameter defined in the `charmcraft.yaml` file (`show-password`). Then, use the `fetch_postgres_relation_data` method (that we defined in a previous chapter) to read the contents of the database relation data and, if the parameter value read earlier is `True`, add the username and password to the output. Finally, use `event.set_results` to attach the results to the event that has called the action; this will print the output to the terminal. + +If we are not able to get the data (for example, if the charm has not yet been integrated with the postgresql-k8s application) then we use the `fail` method of the event to let the user know. + + + +```python +def _on_get_db_info_action(self, event: ops.ActionEvent) -> None: + """This method is called when "get_db_info" action is called. It shows information about + database access points by calling the `fetch_postgres_relation_data` method and creates + an output dictionary containing the host, port, if show_password is True, then include + username, and password of the database. + If PSQL charm is not integrated, the output is set to "No database connected". + + Learn more about actions at https://juju.is/docs/sdk/actions + """ + show_password = event.params['show-password'] # see charmcraft.yaml + db_data = self.fetch_postgres_relation_data() + if not db_data: + event.fail('No database connected') + return + output = { + 'db-host': db_data.get('db_host', None), + 'db-port': db_data.get('db_port', None), + } + if show_password: + output.update( + { + 'db-username': db_data.get('db_username', None), + 'db-password': db_data.get('db_password', None), + } + ) + event.set_results(output) +``` + +## Validate your charm + +First, repack and refresh your charm: + +```text +charmcraft pack +juju refresh \ + --path="./demo-api-charm_ubuntu-22.04-amd64.charm" \ + demo-api-charm --force-units --resource \ + demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1 +``` + + +Next, test that the basic action invocation works: + +```text +juju run demo-api-charm/0 get-db-info --wait 1m +``` + +It might take a few seconds, but soon you should see an output similar to the one below, showing the database host and port: + +```text +Running operation 13 with 1 task + - task 14 on unit-demo-api-charm-0 + +Waiting for task 14... +db-host: postgresql-k8s-primary.model2.svc.cluster.local +db-port: "5432" +``` + +Now, test that the action parameter (`show-password`) works as well by setting it to `True`: + +```text +juju run demo-api-charm/0 get-db-info show-password=True --wait 1m +``` + +The output should now include the username and the password: +``` +Running operation 15 with 1 task + - task 16 on unit-demo-api-charm-0 + +Waiting for task 16... +db-host: postgresql-k8s-primary.model2.svc.cluster.local +db-password: RGv80aF9WAJJtExn +db-port: "5432" +db-username: relation_id_4 +``` + + +Congratulations, you now know how to expose operational tasks via actions! + +## Review the final code + +For the full code see: [06_create_actions](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/06_create_actions) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/05_preserve_charm_data...06_create_actions) + + +> **See next: {ref}`Observe your charm with COS Lite `** diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-the-version-of-the-application-behind-your-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-the-version-of-the-application-behind-your-charm.md new file mode 100644 index 000000000..a8cc788a3 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/expose-the-version-of-the-application-behind-your-charm.md @@ -0,0 +1,118 @@ +(expose-the-version-of-the-application-behind-your-charm)= +# Expose the version of the application behind your charm + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Expose the version of the application behind your charm +> +> **See previous: {ref}`Make your charm configurable `** + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + +``` +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 02_make_your_charm_configurable +git checkout -b 03_set_workload_version +``` + +```` + +In this chapter of the tutorial you will learn how to expose the version of the application (workload) run by the charm -- something that a charm user might find it useful to know. + + +## Define functions to collect the workload application version and set it in the charm + +As a first step we need to add two helper functions that will send an HTTP request to our application to get its version. If the container is available, we can send a request using the `requests` Python library and then add class methods to parse the JSON output to get a version string, as shown below: + +- Import the `requests` Python library: + +```python +import requests +``` + +- Add the following class methods: + + +```python +@property +def version(self) -> str: + """Reports the current workload (FastAPI app) version.""" + try: + if self.container.get_services(self.pebble_service_name): + return self._request_version() + # Catching Exception is not ideal, but we don't care much for the error here, and just + # default to setting a blank version since there isn't much the admin can do! + except Exception as e: + logger.warning("unable to get version from API: %s", str(e), exc_info=True) + return "" + +def _request_version(self) -> str: + """Helper for fetching the version from the running workload using the API.""" + resp = requests.get(f"http://localhost:{self.config['server-port']}/version", timeout=10) + return resp.json()["version"] +``` + +Next, we need to update the `_update_layer_and_restart` method to set our workload version. Insert the following lines before setting `ActiveStatus`: + +```python +# Add workload version in Juju status. +self.unit.set_workload_version(self.version) +``` + +## Declare Python dependencies + + +Since we've added a third party Python dependency into our project, we need to list it in `requirements.txt`. Edit the file to add the following line: + +``` +requests~=2.28 +``` + +Next time you run `charmcraft` it will fetch this new dependency into the charm package. + + +## Validate your charm + +We've exposed the workload version behind our charm. Let's test that it's working! + +First, repack and refresh your charm: + +```text +charmcraft pack +juju refresh \ + --path="./demo-api-charm_ubuntu-22.04-amd64.charm" \ + demo-api-charm --force-units --resource \ + demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1 +``` + +Our charm should fetch the application version and forward it to `juju`. Run `juju status` to check: + +```text +juju status +``` + +Indeed, the version of our workload is now displayed -- see the App block, the Version column: + +```text +Model Controller Cloud/Region Version SLA Timestamp +charm-model tutorial-controller microk8s/localhost 3.0.0 unsupported 12:37:27+01:00 + +App Version Status Scale Charm Channel Rev Address Exposed Message +demo-api-charm 1.0.1 active 1 demo-api-charm 0 10.152.183.233 no + +Unit Workload Agent Address Ports Message +demo-api-charm/0* active idle 10.1.157.75 +``` + +## Review the final code + + +For the full code see: [03_set_workload_version](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/03_set_workload_version) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/02_make_your_charm_configurable...03_set_workload_version) + + +> **See next: {ref}`Integrate your charm with PostgreSQL `** + + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/index.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/index.md new file mode 100644 index 000000000..e44dfb485 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/index.md @@ -0,0 +1,84 @@ +(from-zero-to-hero-write-your-first-kubernetes-charm)= +# From zero to hero: Write your first Kubernetes charm + +This tutorial will introduce you to the official way to write a Kubernetes charm --- that is, how to equip an application with all the operational logic that it needs so that you can manage it on any Kubernetes cloud with just a few commands, using Juju. + +```{important} + +**Did you know?** Writing a charm is also known as 'charming'! + +``` + + + + + +**What you'll need:** + +- A working station, e.g., a laptop with amd64 architecture. +- Familiarity with Juju. +- Familiarity with the Python programming language, Object-Oriented Programming, event handlers. +- Understanding of Kubernetes fundamentals. + + +**What you'll do:** + + + + +```{toctree} +:maxdepth: 1 + +set-up-your-development-environment +study-your-application +create-a-minimal-kubernetes-charm +make-your-charm-configurable +expose-the-version-of-the-application-behind-your-charm +integrate-your-charm-with-postgresql +preserve-your-charms-data +expose-operational-tasks-via-actions +observe-your-charm-with-cos-lite +write-unit-tests-for-your-charm +write-scenario-tests-for-your-charm +write-integration-tests-for-your-charm +open-a-kubernetes-port-in-your-charm +publish-your-charm-on-charmhub +``` + + + +(tutorial-kubernetes-next-steps)= +## Next steps + +By the end of this tutorial you will have built a machine charm and evolved it in a number of typical ways. But there is a lot more to explore: + +| If you are wondering... | visit... | +|-------------------------|----------------------| +| "How do I...?" | {ref}`how-to-guides` | +| "What is...?" | {ref}`reference` | +| "Why...?", "So what?" | {ref}`explanation` | + + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md new file mode 100644 index 000000000..e6e711488 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/integrate-your-charm-with-postgresql.md @@ -0,0 +1,390 @@ +(integrate-your-charm-with-postgresql)= +# Integrate your charm with PostgreSQL + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Integrate your charm with PostgreSQL +> +> **See previous: {ref}`Expose the version of the application behind your charm `** + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + + +```text +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 03_set_workload_version +git checkout -b 04_integrate_with_psql +``` + +```` + +A charm often requires or supports relations to other charms. For example, to make our application fully functional we need to connect it to the PostgreSQL database. In this chapter of the tutorial we will update our charm so that it can be integrated with the existing [PostgreSQL charm](https://charmhub.io/postgresql-k8s?channel=14/stable). + + + +## Fetch the required database interface charm libraries + +Navigate to your charm directory and fetch the [data_interfaces](https://charmhub.io/data-platform-libs/libraries/data_interfaces) charm library from Charmhub: + +``` +ubuntu@charm-dev:~/fastapi-demo$ charmcraft fetch-lib charms.data_platform_libs.v0.data_interfaces +``` + +Your charm directory should now contain the structure below: + +```text +lib +└── charms + └── data_platform_libs + └── v0 + └── data_interfaces.py +``` + +Well done, you've got everything you need to set up a database relation! + + +## Define the charm relation interface + + + +Now, time to define the charm relation interface. + +First, find out the name of the interface that PostgreSQL offers for other charms to connect to it. According to the [documentation of the PostgreSQL charm](https://charmhub.io/postgresql-k8s?channel=14/stable), the interface is called `postgresql_client`. + +Next, open the `charmcraft.yaml` file of your charm and, before the `containers` section, define a relation endpoint using a `requires` block, as below. This endpoint says that our charm is requesting a relation called `database` over an interface called `postgresql_client` with a maximum number of supported connections of 1. (Note: Here, `database` is a custom relation name, though in general we recommend sticking to default recommended names for each charm.) + +```yaml +requires: + database: + interface: postgresql_client + limit: 1 +``` + +That will tell `juju` that our charm can be integrated with charms that provide the same `postgresql_client` interface, for example, the official PostgreSQL charm. + + +Import the database interface libraries and define database event handlers + +We now need to implement the logic that wires our application to a database. When a relation between our application and the data platform is formed, the provider side (i.e., the data platform) will create a database for us and it will provide us with all the information we need to connect to it over the relation -- e.g., username, password, host, port, etc. On our side, we nevertheless still need to set the relevant environment variables to point to the database and restart the service. + +To do so, we need to update our charm “src/charm.py” to do all of the following: + +* Import the `DataRequires` class from the interface library; this class represents the relation data exchanged in the client-server communication. +* Define the event handlers that will be called during the relation lifecycle. +* Bind the event handlers to the observed relation events. + +### Import the database interface libraries + + +First, at the top of the file, import the database interfaces library: + +```python +# Import the 'data_interfaces' library. +# The import statement omits the top-level 'lib' directory +# because 'charmcraft pack' copies its contents to the project root. +from charms.data_platform_libs.v0.data_interfaces import DatabaseCreatedEvent +from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires +``` + +````{important} + +You might have noticed that despite the charm library being placed in the `lib/charms/...`, we are importing it via: + +```python +from charms.data_platform_libs ... +``` +and not + +```python +from lib.charms.data_platform_libs... +``` + +The former is not resolvable by default but everything works fine when the charm is deployed. Why? Because the `dispatch` script in the packed charm sets the `PYTHONPATH` environment variable to include the `lib` directory when it executes your `src/charm.py` code. This tells python it can check the `lib` directory when looking for modules and packages at import time. + + + +If you're experiencing issues with your IDE or just trying to run the `charm.py` file on your own, make sure to set/update `PYTHONPATH` to include `lib` directory as well. + +```bash +# from the charm project directory (~/fastapi-demo), set +export PYTHONPATH=lib +# or update +export PYTHONPATH=lib:$PYTHONPATH +``` + +```` + + +### Add relation event observers + +Next, in the `__init__` method, define a new instance of the 'DatabaseRequires' class. This is required to set the right permissions scope for the PostgreSQL charm. It will create a new user with a password and a database with the required name (below, `names_db`), and limit the user permissions to only this particular database (that is, below, `names_db`). + + +```python +# The 'relation_name' comes from the 'charmcraft.yaml file'. +# The 'database_name' is the name of the database that our application requires. +# See the application documentation in the GitHub repository. +self.database = DatabaseRequires(self, relation_name="database", database_name="names_db") +``` + +Now, add event observers for all the database events: + +```python +# See https://charmhub.io/data-platform-libs/libraries/data_interfaces +framework.observe(self.database.on.database_created, self._on_database_created) +framework.observe(self.database.on.endpoints_changed, self._on_database_created) +``` + +### Fetch the database authentication data + +Now we need to extract the database authentication data and endpoints information. We can do that by adding a `fetch_postgres_relation_data` method to our charm class. Inside this method, we first retrieve relation data from the PostgreSQL using the `fetch_relation_data` method of the `database` object. We then log the retrieved data for debugging purposes. Next we process any non-empty data to extract endpoint information, the username, and the password and return this process data as a dictionary. Finally, we ensure that, if no data is retrieved, we return an empty dictionary, so that the caller knows that the database is not yet ready. + +```python +def fetch_postgres_relation_data(self) -> Dict[str, str]: + """Fetch postgres relation data. + + This function retrieves relation data from a postgres database using + the `fetch_relation_data` method of the `database` object. The retrieved data is + then logged for debugging purposes, and any non-empty data is processed to extract + endpoint information, username, and password. This processed data is then returned as + a dictionary. If no data is retrieved, the unit is set to waiting status and + the program exits with a zero status code.""" + relations = self.database.fetch_relation_data() + logger.debug('Got following database data: %s', relations) + for data in relations.values(): + if not data: + continue + logger.info('New PSQL database endpoint is %s', data['endpoints']) + host, port = data['endpoints'].split(':') + db_data = { + 'db_host': host, + 'db_port': port, + 'db_username': data['username'], + 'db_password': data['password'], + } + return db_data + return {} +``` + +Since `ops` supports Python 3.8, this tutorial used type annotations compatible with 3.8. If you're following along with this chapter, you'll need to import the following from the `typing` module: +```python +from typing import Dict, Optional +``` +```{important} + +The version of Python that your charm will use is determined in your `charmcraft.yaml`. In this case, we've specified Ubuntu 22.04, which means the charm will actually be running on Python 3.10, so we could have used some more recent Python features, like using the builtin `dict` instead of `Dict`, and the `|` operator for unions, allowing us to write (e.g.) `str | None` instead of `Optional[str]`. This will likely be updated in a future version of this tutorial. + +``` + + +### Share the authentication information with your application + +Our application consumes database authentication information in the form of environment variables. Let's update the Pebble service definition with an `environment` key and let's set this key to a dynamic value -- the class property `self.app_environment`. Your `_pebble_layer` property should look as below: + +```python + @property + def _pebble_layer(self) -> ops.pebble.Layer: + """A Pebble layer for the FastAPI demo services.""" + command = ' '.join( + [ + 'uvicorn', + 'api_demo_server.app:app', + '--host=0.0.0.0', + f"--port={self.config['server-port']}", + ] + ) + pebble_layer: ops.pebble.LayerDict = { + 'summary': 'FastAPI demo service', + 'description': 'pebble config layer for FastAPI demo server', + 'services': { + self.pebble_service_name: { + 'override': 'replace', + 'summary': 'fastapi demo', + 'command': command, + 'startup': 'enabled', + 'environment': self.app_environment, + } + }, + } + return ops.pebble.Layer(pebble_layer) +``` + +Now, let's define this property such that, every time it is called, it dynamically fetches database authentication data and also prepares the output in a form that our application can consume, as below: + +```python +@property +def app_environment(self) -> Dict[str, Optional[str]]: + """This property method creates a dictionary containing environment variables + for the application. It retrieves the database authentication data by calling + the `fetch_postgres_relation_data` method and uses it to populate the dictionary. + If any of the values are not present, it will be set to None. + The method returns this dictionary as output. + """ + db_data = self.fetch_postgres_relation_data() + if not db_data: + return {} + env = { + 'DEMO_SERVER_DB_HOST': db_data.get('db_host', None), + 'DEMO_SERVER_DB_PORT': db_data.get('db_port', None), + 'DEMO_SERVER_DB_USER': db_data.get('db_username', None), + 'DEMO_SERVER_DB_PASSWORD': db_data.get('db_password', None), + } + return env +``` + +Finally, let's define the method that is called on the database created event: + +```python +def _on_database_created(self, event: DatabaseCreatedEvent) -> None: + """Event is fired when postgres database is created.""" + self._update_layer_and_restart() +``` + +The diagram below illustrates the workflow for the case where the database integration exists and for the case where it does not: + +![Integrate your charm with PostgreSQL](../../resources/integrate_your_charm_with_postgresql.png) + + +## Update the unit status to reflect the integration state + +Now that the charm is getting more complex, there are many more cases where the unit status needs to be set. It's often convenient to do this in a more declarative fashion, which is where the collect-status event can be used. + + +> Read more: [Events > Collect App Status and Collect Unit Status](https://juju.is/docs/sdk/events-collect-app-status-and-collect-unit-status) + +In your charm's `__init__` add a new observer: + +```python +framework.observe(self.on.collect_unit_status, self._on_collect_status) +``` + +And define a method that does the various checks, adding appropriate statuses. The library will take care of selecting the 'most significant' status for you. + +```python +def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: + port = self.config['server-port'] + if port == 22: + event.add_status(ops.BlockedStatus('Invalid port number, 22 is reserved for SSH')) + if not self.model.get_relation('database'): + # We need the user to do 'juju integrate'. + event.add_status(ops.BlockedStatus('Waiting for database relation')) + elif not self.database.fetch_relation_data(): + # We need the charms to finish integrating. + event.add_status(ops.WaitingStatus('Waiting for database relation')) + try: + status = self.container.get_service(self.pebble_service_name) + except (ops.pebble.APIError, ops.ModelError): + event.add_status(ops.MaintenanceStatus('Waiting for Pebble in workload container')) + else: + if not status.is_running(): + event.add_status(ops.MaintenanceStatus('Waiting for the service to start up')) + # If nothing is wrong, then the status is active. + event.add_status(ops.ActiveStatus()) +``` + +We also want to clean up the code to remove the places where we're setting the status outside of this method, other than anywhere we're wanting a status to show up *during* the event execution (such as `MaintenanceStatus`). In `_on_config_changed`, change the port 22 check to: + +```python + if port == 22: + # The collect-status handler will set the status to blocked. + logger.debug('Invalid port number, 22 is reserved for SSH;) +``` + +And remove the `self.unit.status = WaitingStatus` line from `_update_layer_and_restart` (similarly replacing it with a logging line if you prefer). + +## Validate your charm + + +Time to check the results! + +First, repack and refresh your charm: + +```text +charmcraft pack +juju refresh \ + --path="./demo-api-charm_ubuntu-22.04-amd64.charm" \ + demo-api-charm --force-units --resource \ + demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1 +``` + +Next, deploy the `postgresql-k8s` charm: + +```text +juju deploy postgresql-k8s --channel=14/stable --trust +``` + +Now, integrate our charm with the newly deployed `postgresql-k8s` charm: + +```text +juju integrate postgresql-k8s demo-api-charm +``` + +> Read more: [Integration](https://juju.is/docs/olm/integration), [`juju integrate`](https://juju.is/docs/olm/juju-integrate) + + +Finally, run: + +```text +juju status --relations --watch 1s +``` + +You should see both applications get to the `active` status, and also that the `postgresql-k8s` charm has a relation to the `demo-api-charm` over the `postgresql_client` interface, as below: + +```text +Model Controller Cloud/Region Version SLA Timestamp +charm-model tutorial-controller microk8s/localhost 3.0.0 unsupported 13:50:39+01:00 + +App Version Status Scale Charm Channel Rev Address Exposed Message +demo-api-charm 0.0.9 active 1 demo-api-charm 1 10.152.183.233 no +postgresql-k8s active 1 postgresql-k8s 14/stable 29 10.152.183.195 no Primary + +Unit Workload Agent Address Ports Message +demo-api-charm/0* active idle 10.1.157.90 +postgresql-k8s/0* active idle 10.1.157.92 Primary + +Relation provider Requirer Interface Type Message +postgresql-k8s:database demo-api-charm:database postgresql_client regular +postgresql-k8s:database-peers postgresql-k8s:database-peers postgresql_peers peer +postgresql-k8s:restart postgresql-k8s:restart rolling_op peer +``` + +The relation appears to be up and running, but we should also test that it's working as intended. First, let's try to write something to the database by posting some name to the database via API using `curl` as below -- where `10.1.157.90` is a pod IP and `8000` is our app port. You can repeat the command for multiple names. + +```text +curl -X 'POST' \ + 'http://10.1.157.90:8000/addname/' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -d 'name=maksim' +``` + +```{important} + +If you changed the `server-port` config value in the previous section, don't forget to change it back to 8000 before doing this! + +``` + +Second, let's try to read something from the database by running: + +```text +curl 10.1.157.90:8000/names +``` + +This should produce something similar to the output below (of course, with the names that *you* decided to use): + +```text +{"names":{"1":"maksim","2":"simon"}} +``` + +Congratulations, your integration with PostgreSQL is functional! + +## Review the final code + +For the full code see: [04_integrate_with_psql](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/04_integrate_with_psql) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/03_set_workload_version...04_integrate_with_psql) + +> **See next: {ref}`Preserve your charm's data `** diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md new file mode 100644 index 000000000..87ceadb39 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/make-your-charm-configurable.md @@ -0,0 +1,211 @@ +(make-your-charm-configurable)= +# Make your charm configurable + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Make your charm configurable +> +> **See previous: {ref}`Create a minimal Kubernetes charm `** + + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + +```bash +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 01_create_minimal_charm +git checkout -b 02_make_your_charm_configurable +``` + +```` + +A charm might have a specific configuration that the charm developer might want to expose to the charm user so that the latter can change specific settings during runtime. + +As a charm developer, it is thus important to know how to make your charm configurable. + +This can be done by defining a charm configuration in a file called `charmcraft.yaml` and then adding configuration event handlers ('hooks') in the `src/charm.py` file. + +In this part of the tutorial you will update your charm to make it possible for a charm user to change the port on which the workload application is available. + + + +## Define the configuration options + + +To begin with, let's define the options that will be available for configuration. + +In the `charmcraft.yaml` file you created earlier, define a configuration option, as below. The name of your configurable option is going to be `server-port`. The `default` value is `8000` -- this is the value you're trying to allow a charm user to configure. + + + +```yaml +config: + options: + server-port: + default: 8000 + description: Default port on which FastAPI is available + type: int +``` + +## Define the configuration event handlers + + + +Open your `src/charm.py` file. + +In the `__init__` function, add an observer for the `config_changed` event and pair it with an `_on_config_changed` handler: + +```python +framework.observe(self.on.config_changed, self._on_config_changed) +``` + +Now, define the handler, as below. First, read the `self.config` attribute to get the new value of the setting. Then, validate that this value is allowed (or block the charm otherwise). Next, let's log the value to the logger. Finally, since configuring something like a port affects the way we call our workload application, we also need to update our pebble configuration, which we will do via a newly created method `_update_layer_and_restart` that we will define shortly. + +```python +def _on_config_changed(self, event: ops.ConfigChangedEvent) -> None: + port = self.config['server-port'] # see charmcraft.yaml + + if port == 22: + self.unit.status = ops.BlockedStatus('invalid port number, 22 is reserved for SSH') + return + + logger.debug("New application port is requested: %s", port) + self._update_layer_and_restart() +``` + +```{caution} + +A charm does not know which configuration option has been changed. Thus, make sure to validate all the values. This is especially important since multiple values can be changed in one call. + +``` + +In the `__init__` function, add a new attribute to define a container object for your workload: + +```python +# see 'containers' in charmcraft.yaml +self.container = self.unit.get_container('demo-server') +``` + +Create a new method, as below. This method will get the current Pebble layer configuration and compare the new and the existing service definitions -- if they differ, it will update the layer and restart the service. + +```python +def _update_layer_and_restart(self) -> None: + """Define and start a workload using the Pebble API. + + You'll need to specify the right entrypoint and environment + configuration for your specific workload. Tip: you can see the + standard entrypoint of an existing container using docker inspect + Learn more about interacting with Pebble at https://juju.is/docs/sdk/pebble + Learn more about Pebble layers at + https://canonical-pebble.readthedocs-hosted.com/en/latest/reference/layers + """ + + # Learn more about statuses in the SDK docs: + # https://juju.is/docs/sdk/status + self.unit.status = ops.MaintenanceStatus('Assembling Pebble layers') + try: + # Get the current pebble layer config + services = self.container.get_plan().to_dict().get('services', {}) + if services != self._pebble_layer.to_dict().get('services', {}): + # Changes were made, add the new layer + self.container.add_layer('fastapi_demo', self._pebble_layer, combine=True) + logger.info("Added updated layer 'fastapi_demo' to Pebble plan") + + self.container.restart(self.pebble_service_name) + logger.info(f"Restarted '{self.pebble_service_name}' service") + + self.unit.status = ops.ActiveStatus() + except ops.pebble.APIError: + self.unit.status = ops.MaintenanceStatus('Waiting for Pebble in workload container') +``` + +Now, crucially, update the `_pebble_layer` property to make the layer definition dynamic, as shown below. This will replace the static port `8000` with `f"--port={self.config['server-port']}"`. + +```python +command = ' '.join( + [ + 'uvicorn', + 'api_demo_server.app:app', + '--host=0.0.0.0', + f"--port={self.config['server-port']}", + ] +) +``` + +As you may have noticed, the new `_update_layer_and_restart` method looks like a more advanced variant of the existing `_on_demo_server_pebble_ready` method. Remove the body of the `_on_demo_server_pebble_ready` method and replace it a call to `_update_layer_and_restart` like this: + +```python +def _on_demo_server_pebble_ready(self, event: ops.PebbleReadyEvent) -> None: + self._update_layer_and_restart() +``` + +## Validate your charm + +First, repack and refresh your charm: + +``` +charmcraft pack +juju refresh \ + --path="./demo-api-charm_ubuntu-22.04-amd64.charm" \ + demo-api-charm --force-units --resource \ + demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1 +``` + + +Now, check the available configuration options: + +```text +juju config demo-api-charm +``` + +Our newly defined `server-port` option is there. Let's try to configure it to something else, e.g., `5000`: + +```text +juju config demo-api-charm server-port=5000 +``` + +Now, let's validate that the app is actually running and reachable on the new port by sending the HTTP request below, where `10.1.157.74` is the IP of our pod and `5000` is the new application port: + +```text +curl 10.1.157.74:5000/version +``` + +You should see JSON string with the version of the application: `{"version":"1.0.0"}` + +Let's also verify that our invalid port number check works by setting the port to `22` and then running `juju status`: + +```text +juju config demo-api-charm server-port=22 +juju status +``` + +As expected, the application is indeed in the `blocked` state: + +```text +Model Controller Cloud/Region Version SLA Timestamp +charm-model tutorial-controller microk8s/localhost 3.0.0 unsupported 18:19:24+01:00 + +App Version Status Scale Charm Channel Rev Address Exposed Message +demo-api-charm blocked 1 demo-api-charm 2 10.152.183.215 no invalid port number, 22 is reserved for SSH + +Unit Workload Agent Address Ports Message +demo-api-charm/0* blocked idle 10.1.157.74 invalid port number, 22 is reserved for SSH +``` + + +Congratulations, you now know how to make your charm configurable! + + +## Review the final code + +For the full code see: [02_make_your_charm_configurable](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/02_make_your_charm_configurable) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/01_create_minimal_charm...02_make_your_charm_configurable) + + +> **See next: {ref}`Expose the version of the application behind your charm `** diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md new file mode 100644 index 000000000..90f8345ca --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/observe-your-charm-with-cos-lite.md @@ -0,0 +1,471 @@ +(observe-your-charm-with-cos-lite)= +# Observe your charm with COS Lite + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Observe your charm with COS Lite +> +> **See previous: {ref}`Expose your charm's operational tasks via actions `** + + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + + +```text +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 06_create_actions +git checkout -b 07_cos_integration +``` + +```` + +In a production deployment it is essential to observe and monitor the health of your application. A charm user will want to be able to collect real time metrics and application logs, set up alert rules, and visualise any collected data in a neat form on a dashboard. + +Our application is prepared for that -- as you might recall, it uses [`starlette-exporter`](https://pypi.org/project/starlette-exporter/) to generate real-time application metrics and to expose them via a `/metrics` endpoint that is designed to be scraped by [Prometheus](https://prometheus.io/). As a charm developer, you'll want to use that to make your charm observable. + +In the charming universe, what you would do is deploy the existing [Canonical Observability Stack (COS) lite bundle](https://charmhub.io/cos-lite) -- a convenient collection of charms that includes all of [Prometheus](https://charmhub.io/prometheus-k8s), [Loki](https://charmhub.io/loki-k8s), and [Grafana](https://charmhub.io/grafana-k8s) -- and then integrate your charm with Prometheus to collect real-time application metrics; with Loki to collect application logs; and with Grafana to create dashboards and visualise collected data. + + +In this part of the tutorial we will follow this process to collect various metrics and logs about your application and visualise them on a dashboard. + +## Integrate with Prometheus + +Follow the steps below to make your charm capable of integrating with the existing [Prometheus](https://charmhub.io/prometheus-k8s) charm. This will enable your charm user to collect real-time metrics about your application. + + +### Fetch the Prometheus interface libraries + +Ensure you're in your Multipass Ubuntu VM, in your charm project directory. + +Then, satisfy the interface library requirement of the Prometheus charm by fetching the [`prometheus_scrape`](https://charmhub.io/prometheus-k8s/libraries/prometheus_scrape) library: + +```text +ubuntu@charm-dev:~/fastapi-demo$ charmcraft fetch-lib charms.prometheus_k8s.v0.prometheus_scrape +``` + +Also satisfy the dependency requirements of the `prometheus_scrape` library by fetching the [`juju_topology`](https://charmhub.io/observability-libs/libraries/juju_topology) library: + +```text +ubuntu@charm-dev:~/fastapi-demo$ charmcraft fetch-lib charms.observability_libs.v0.juju_topology +``` + +Your charm directory should now include the structure below: + +```text +lib +└── charms + ├── observability_libs + │ └── v0 + │ └── juju_topology.py + └── prometheus_k8s + └── v0 + └── prometheus_scrape.py +``` + +Note: When you rebuild your charm with `charmcraft pack`, Charmcraft will copy the contents of the top `lib` directory to the project root. Thus, to import this library in your code, use just `charms.prometheus_k8s.v0.prometheus_scrape`. + +### Define the Prometheus relation interface + +In your `charmcraft.yaml` file, before the `peers` block, add a `provides` endpoint with relation name `metrics-endpoint` and interface name `prometheus_scrape`, as below. This declares that your charm can offer services to other charms over the `prometheus-scrape` interface. In short, that your charm is open to integrations with, for example, the official Prometheus charm. (Note: `metrics-endpoint` is the default relation name recommended by the `prometheus_scrape` interface library.) + +```yaml +provides: + metrics-endpoint: + interface: prometheus_scrape +``` + +## Import the Prometheus interface libraries and set up Prometheus scraping + +In your `src/charm.py` file, do the following: + +First, at the top of the file, import the `prometheus_scrape` library: + +```text +from charms.prometheus_k8s.v0.prometheus_scrape import MetricsEndpointProvider +``` + +Now, in your charm's `__init__` method, initialise the `MetricsEndpointProvider` instance with the desired scrape target, as below. Note that this uses the relation name that you specified earlier in the `charmcraft.yaml` file. Also, reflecting the fact that you've made your charm's port configurable (see previous chapter {ref}`Make the charm configurable `), the target job is set to be consumed from config. The URL path is not included because it is predictable (defaults to /metrics), so the Prometheus library uses it automatically. The last line, which sets the `refresh_event` to the `config_change` event, ensures that the Prometheus charm will change its scraping target every time someone changes the port configuration. Overall, this code will allow your application to be scraped by Prometheus once they've been integrated. + +```python +self._prometheus_scraping = MetricsEndpointProvider( + self, + relation_name="metrics-endpoint", + jobs=[{"static_configs": [{"targets": [f"*:{self.config['server-port']}"]}]}], + refresh_event=self.on.config_changed, +) +``` + + + +Congratulations, your charm is ready to be integrated with Prometheus! + +## Integrate with Loki + +Follow the steps below to make your charm capable of integrating with the existing [Loki](https://charmhub.io/loki-k8s) charm. This will enable your charm user to collect application logs. + +### Fetch the Loki interface libraries + + + +Ensure you're in your Multipass Ubuntu VM, in your charm folder. + +Then, satisfy the interface library requirements of the Loki charm by fetching the {ref}``loki_push_api` <7814md>` library: + +```text +ubuntu@charm-dev:~/fastapi-demo$ charmcraft fetch-lib charms.loki_k8s.v0.loki_push_api +``` + +This should add to your charm directory the structure below: + +```text +lib +└── charms + ├── loki_k8s + │ └── v0 + │ └── loki_push_api.py +``` + +Note: The `loki_push_api` library also depends on the [`juju_topology`](https://charmhub.io/observability-libs/libraries/juju_topology) library, but you have already fetched it above for Prometheus. + +Note: When you rebuild your charm with `charmcraft pack`, Charmcraft will copy the contents of the top `lib` directory to the project root. Thus, to import this library in your code, use just `charms.loki_k8s.v0.loki_push_api`. + +### Define the Loki relation interface + +In your `charmcraft.yaml` file, beneath your existing `requires` endpoint, add another `requires` endpoint with relation name `log-proxy` and interface name `loki_push_api`. This declares that your charm can optionally make use of services from other charms over the `loki_push_api` interface. In short, that your charm is open to integrations with, for example, the official Loki charm. (Note: `log-proxy` is the default relation name recommended by the `loki_push_api` interface library.) + + +```yaml +requires: + database: + interface: postgresql_client + limit: 1 + log-proxy: + interface: loki_push_api + limit: 1 +``` + + +## Import the Loki interface libraries and set up the Loki API + +In your `src/charm.py` file, do the following: + +First, import the `loki_push_api` lib: + +```python +from charms.loki_k8s.v0.loki_push_api import LogProxyConsumer +``` + +Then, in your charm's `__init__` method, initialise the `LogProxyConsumer` instance with the defined log files, as shown below. The `log-proxy` relation name comes from the `charmcraft.yaml` file and the`demo_server.log` file is the file where the application dumps logs. Overall this code ensures that your application can push logs to Loki (or any other charms that implement the `loki_push_api`). + +```python +self._logging = LogProxyConsumer( + self, relation_name="log-proxy", log_files=["demo_server.log"] +) +``` + +Congratulations, your charm can now also integrate with Loki! + +## Integrate with Grafana + +Follow the steps below to make your charm capable of integrating with the existing [Grafana](https://charmhub.io/grafana-k8s) charm. This will allow your charm user to visualise the data collected from Prometheus and Loki. + +### Fetch the Grafana interface libraries + +Ensure you're in your Multipass Ubuntu VM, in your charm folder. + +Then, satisfy the interface requirement of the Grafana charm by fetching the [grafana_dashboard](https://charmhub.io/grafana-k8s/libraries/grafana_dashboard) library: + +```text +ubuntu@charm-dev:~/fastapi-demo$ charmcraft fetch-lib charms.grafana_k8s.v0.grafana_dashboard +``` + +Your charm directory should now include the structure below: + +```text +lib +└── charms + ├── grafana_k8s + │ └── v0 + │ └── grafana_dashboard.py +``` + +Note: When you rebuild your charm with `charmcraft pack`, Charmcraft will copy the contents of the top `lib` directory to the project root. Thus, to import this library in your code, use just `charms.grafana_k8s.v0.grafana_dashboard`. + +Note: The `grafana_dashboard` library also depends on the [`juju_topology`](https://charmhub.io/observability-libs/libraries/juju_topology) library, but you have already fetched it above for Prometheus. + +### Define the Grafana relation interface + +In your `charmcraft.yaml` file, add another `provides` endpoint with relation name `grafana-dashboard` and interface name `grafana_dashboard`, as below. This declares that your charm can offer services to other charms over the `grafana-dashboard` interface. In short, that your charm is open to integrations with, for example, the official Grafana charm. (Note: Here `grafana-dashboard` endpoint is the default relation name recommended by the `grafana_dashboard` library.) + +```yaml +provides: + metrics-endpoint: + interface: prometheus_scrape + grafana-dashboard: + interface: grafana_dashboard +``` + +### Import the Grafana interface libraries and set up the Grafana dashboards + +In your `src/charm.py` file, do the following: + +First, at the top of the file, import the `grafana_dashboard` lib: + +```python +from charms.grafana_k8s.v0.grafana_dashboard import GrafanaDashboardProvider +``` + +Now, in your charm's `__init__` method, initialise the `GrafanaDashboardProvider` instance, as below. The `grafana-dashboard` is the relation name you defined earlier in your `charmcraft.yaml` file. Overall, this code states that your application supports the Grafana interface. + +```python +# Provide grafana dashboards over a relation interface +self._grafana_dashboards = GrafanaDashboardProvider(self, relation_name="grafana-dashboard") +``` + +Now, in your `src` directory, create a subdirectory called `grafana_dashboards` and, in this directory, create a file called `FastAPI-Monitoring.json.tmpl` with the following content: +[FastAPI-Monitoring.json.tmpl|attachment](https://discourse.charmhub.io/uploads/short-url/6hSGcAA6n20qyStzLFeekgkzqCc.tmpl) (7.7 KB) . Once your charm has been integrated with Grafana, the `GrafanaDashboardProvider` you defined just before will take this file as well as any other files defined in this directory and put them into a Grafana files tree to be read by Grafana. + +```{important} + +**How to build a Grafana dashboard is beyond the scope of this tutorial. However, if you'd like to get a quick idea:** The dashboard template file was created by manually building a Grafana dashboard using the Grafana web UI, then exporting it to a JSON file and updating the `datasource` `uid` for Prometheus and Loki from constant values to the dynamic variables `"${prometheusds}"` and `"${lokids}"`, respectively. + +``` + +## Specify packages required to build + +When packing a charm, charmcraft uses source distributions for the dependencies. When a charm has a dependency that includes a binary package charmcraft will build the package, but may require additional packages to be installed. + +The `cos-lite` packages include a dependency that has Rust code, and the default charmcraft build environment does not have a Rust compiler, so you need to instruct charmcraft to install one for the build. In your `charmcraft.yaml` file, add a new `parts` section: + +```yaml +parts: + charm: + build-packages: + # Required for the cos-lite packages, which have a Rust dependency. + - cargo +``` + +## Validate your charm + +Open a shell in your Multipass Ubuntu VM, navigate inside your project directory, and run all of the following. + +First, repack and refresh your charm: + +```text +charmcraft pack +juju refresh \ + --path="./demo-api-charm_ubuntu-22.04-amd64.charm" \ + demo-api-charm --force-units --resource \ + demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1 +``` + +Next, test your charm's ability to integrate with Prometheus, Loki, and Grafana by following the steps below. + + +### Deploy COS Lite + +Create a Juju model called `cos-lite` and, to this model, deploy the Canonical Observability Stack bundle [`cos-lite`](https://charmhub.io/topics/canonical-observability-stack), as below. This will deploy all the COS applications (`alertmanager`, `catalogue`, `grafana`, `loki`, `prometheus`, `traefik`), already suitably integrated with one another. Note that these also include the applications that you've been working to make your charm integrate with -- Prometheus, Loki, and Grafana. + +```text +juju add-model cos-lite +juju deploy cos-lite --trust +``` + +```{important} + + +**Why put COS Lite in a separate model?** Because (1) it is always a good idea to separate logically unrelated applications in different models and (2) this way you can observe applications across all your models. PS In a production-grade scenario you would actually even want to put your COS Lite in a separate *cloud* (i.e., Kubernetes cluster). This is recommended, for example, to ensure proper hardware resource allocation. + +``` + + +### Expose the application integration endpoints + +Once all the COS Lite applications are deployed and settled down (you can monitor this by using `juju status --watch 2s`), expose the integration points you are interested in for your charm -- `loki:logging`, `grafana-dashboard`, and `metrics-endpoint` -- as below. + +```text +juju offer prometheus:metrics-endpoint +juju offer loki:logging +juju offer grafana:grafana-dashboard +``` + +Validate that the offers have been successfully created by running: + +```text +juju find-offers cos-lite +``` + +You should something similar to the output below: + +```text +Store URL Access Interfaces +tutorial-controller admin/cos-lite.loki admin loki_push_api:logging +tutorial-controller admin/cos-lite.prometheus admin prometheus_scrape:metrics-endpoint +tutorial-controller admin/cos-lite.grafana admin grafana_dashboard:grafana-dashboard +``` + +As you might notice from your knowledge of Juju, this is essentially preparing these endpoints, which exist in the `cos-lite` model, for a cross-model relation with your charm, which you've deployed to the `charm-model` model. + +## Integrate your charm with COS Lite + +Now switch back to the charm model and integrate your charm with the exposed endpoints, as below. This effectively integrates your application with Prometheus, Loki, and Grafana. + +```text +juju switch charm-model +juju integrate demo-api-charm admin/cos-lite.grafana +juju integrate demo-api-charm admin/cos-lite.loki +juju integrate demo-api-charm admin/cos-lite.prometheus +``` + +

Access your applications from the host machine

+ +```{important} + +The power of Grafana lies in the way it allows you to visualise metrics on a dashboard. Thus, in the general case you will want to open the Grafana Web UI in a web browser. However, you are now working in a headless VM that does not have any user interface. This means that you will need to open Grafana in a web browser on your host machine. To do this, you will need to add IP routes to the Kubernetes (MicroK8s) network inside of our VM. You can skip this step if you have decided to follow this tutorial directly on your host machine. + +``` + + +First, run: + +```text +juju status -m cos-lite +``` + +This should result in an output similar to the one below: + +```text +Model Controller Cloud/Region Version SLA Timestamp +cos-lite tutorial-controller microk8s/localhost 3.0.0 unsupported 18:05:07+01:00 + +App Version Status Scale Charm Channel Rev Address Exposed Message +alertmanager 0.23.0 active 1 alertmanager-k8s stable 36 10.152.183.70 no +catalogue active 1 catalogue-k8s stable 4 10.152.183.19 no +grafana 9.2.1 active 1 grafana-k8s stable 52 10.152.183.132 no +loki 2.4.1 active 1 loki-k8s stable 47 10.152.183.207 no +prometheus 2.33.5 active 1 prometheus-k8s stable 79 10.152.183.196 no +traefik active 1 traefik-k8s stable 93 10.152.183.83 no +``` + +From this output, from the `Address` column, retrieve the IP address for each app to obtain the Kubernetes service IP address range. Make a note of each as well as the range. (In our output we got the `10.152.183.0-10.152.183.255` range.) + +```{caution} + +Do not mix up Apps and Units -- Units represent Kubernetes pods while Apps represent Kubernetes Services. Note: The charm should be programmed to support Services. + +``` + +Now open a terminal on your host machine and run: + +```text +multipass info charm-dev +``` + +This should result in an output similar to the one below: + +```text +Name: charm-dev +State: Running +IPv4: 10.112.13.157 + 10.49.132.1 + 10.1.157.64 +Release: Ubuntu 22.04.1 LTS +Image hash: 1d24e397489d (Ubuntu 22.04 LTS) +Load: 0.31 0.25 0.28 +Disk usage: 15.9G out of 19.2G +Memory usage: 2.1G out of 7.8G +Mounts: /home/maksim/fastapi-demo => ~/fastapi-demo + UID map: 1000:default + GID map: 1000:default +``` + +From this output, retrieve your Multipass Ubuntu VM's network IP address. In our case it is `10.112.13.157`. + +Now, also on your host machine, run the code below. Until the next reboot, this will forward all the traffic for your Kubernetes Service IP range via the network on your VM. This will allow you, for example, to view your Grafana dashboards in a web browser inside your VM, as we do in the next step. + +```text +sudo ip route add 10.152.183.0/24 via 10.112.13.157 +``` + +## Log in to Grafana + +In a terminal inside your VM, do all of the following: + +First, run `juju status` again to retrieve the IP address of your Grafana service. For us it is `http://10.152.183.132:3000` (see the output above). + +Now, use `juju run` to retrieve your Grafana password, as shown below. + +```text +juju run grafana/0 -m cos-lite get-admin-password --wait 1m +``` + +Now, on your host machine, open a web browser, enter the Grafana IP address, and use the username "admin" and your Grafana password to log in. + +### Inspect the dashboards + +In your Grafana web page, do all of the following: + +Click `FastAPI Monitoring`. You should see the Grafana Dashboard that we uploaded to the `grafana_dashboards` directory of your charm. + + + +Next, in the `Juju model` drop down field, select `charm-model`. + +Now, call a couple of API points on the application, as below. To produce some successful requests and some requests with code 500 (internal server error), call several times, in any order. + +```text +curl 10.1.157.94:8000/names +``` + +and + +```text +curl 10.1.157.94:8000/error +``` + +where `10.1.157.94` is the IP of our application unit (pod). + + + +In a while you should see the following data appearing on the dashboard: + +1. HTTP Request Duration Percentiles. This dashboard is based on the data from Prometheus and will allow you to see what fraction of requests takes what amount of time. +2. Percent of failed requests per 2 minutes time frame. In your case this will be a ratio of all the requests and the requests submitted to the `/error` path (i.e., the ones that cause the Internal Server Error). +3. Logs from your application that were collected by Loki and forwarded to Grafana. Here you can see some INFO level logs and ERROR logs with traceback from Python when you were calling the `/error` path. + + +![Observe your charm with COS Lite](../../resources/observe_your_charm_with_cos_lite.png) + + +```{important} + +If you are interested in the Prometheus metrics produced by your application that were used to build these dashboards you can run following command in your VM: `curl :8000/metrics` +Also, you can reach Prometheus in your web browser (similar to Grafana) at `http://:9090/graph` . + +``` + + +## Review the final code + + +For the full code see: [07_cos_integration](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/07_cos_integration) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/06_create_actions...07_cos_integration) + +> **See next: {ref}`Write units tests for your charm `** + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/open-a-kubernetes-port-in-your-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/open-a-kubernetes-port-in-your-charm.md new file mode 100644 index 000000000..a0588e693 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/open-a-kubernetes-port-in-your-charm.md @@ -0,0 +1,359 @@ +(open-a-kubernetes-port-in-your-charm)= +# Open a Kubernetes port in your charm + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Open a Kubernetes port in your charm +> +> **See previous: {ref}`Write integration tests for your charm `** + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + +```bash +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 10_integration_testing +git checkout -b 11_open_port_k8s_service +``` + +```` + +A deployed charm should be consistently accessible via a stable URL on a cloud. + +However, our charm is currently accessible only at the IP pod address and, if the pod gets recycled, the IP address will change as well. + +> See earlier chapter: {ref}`Make your charm configurable ` + +In Kubernetes you can make a service permanently reachable under a stable URL on the cluster by exposing a service port via the `ClusterIP`. In Juju 3.1+, you can take advantage of this by using the `Unit.set_ports()` method. + +> Read more: [ClusterIP](https://kubernetes.io/docs/concepts/services-networking/service/#type-clusterip) + +In this chapter of the tutorial you will extend the existing `server-port` configuration option to use Juju `open-port` functionality to expose a Kubernetes service port. Building on your experience from the previous testing chapters, you will also write tests to check that the new feature you've added works as intended. + + +## Add a Kubernetes service port to your charm + +In your `src/charm.py` file, do all of the following: + +In the `_on_config_changed` method, add a new method: + +```python +self._handle_ports() +``` + +Then, in the definition of the `FastAPIDemoCharm` class, define the method: + +```python +def _handle_ports(self) -> None: + port = cast(int, self.config['server-port']) + self.unit.set_ports(port) +``` + +> See more: [`ops.Unit.set_ports`](https://ops.readthedocs.io/en/latest/#ops.Unit.set_ports) + + +## Test the new feature + + +### Write a unit test + + +```{important} + +**If you've skipped straight to this chapter:**
Note that it builds on the earlier unit testing chapter. To catch up, see: {ref}`Write unit tests for your charm `. + +``` + +Let's write a unit test to verify that the port is opened. Open `tests/unit/test_charm.py` and add the following test function to the file. + +```python +@pytest.mark.parametrize( + 'port,expected_status', + [ + (22, ops.BlockedStatus('Invalid port number, 22 is reserved for SSH')), + (1234, ops.BlockedStatus('Waiting for database relation')), + ], +) +def test_port_configuration( + monkeypatch, harness: ops.testing.Harness[FastAPIDemoCharm], port, expected_status +): + # Given + monkeypatch.setattr(FastAPIDemoCharm, 'version', '1.0.1') + harness.container_pebble_ready('demo-server') + # When + harness.update_config({'server-port': port}) + harness.evaluate_status() + currently_opened_ports = harness.model.unit.opened_ports() + port_numbers = {port.port for port in currently_opened_ports} + server_port_config = harness.model.config.get('server-port') + unit_status = harness.model.unit.status + # Then + if port == 22: + assert server_port_config not in port_numbers + else: + assert server_port_config in port_numbers + assert unit_status == expected_status +``` + +```{important} + +**Tests parametrisation**
Note that we used the `parametrize` decorator to run a single test against multiple sets of arguments. Adding a new test case, like making sure that the error message is informative given a negative or too big port number, would be as simple as extending the list in the decorator call. +See [How to parametrize fixtures and test functions](https://docs.pytest.org/en/8.0.x/how-to/parametrize.html). + +``` + +Time to run the tests! + +In your Multipass Ubuntu VM shell, run the unit test: + +``` +ubuntu@charm-dev:~/fastapi-demo$ tox -re unit +``` + +If successful, you should get an output similar to the one below: + +```bash +$ tox -re unit +unit: remove tox env folder /home/ubuntu/fastapi-demo/.tox/unit +unit: install_deps> python -I -m pip install cosl 'coverage[toml]' pytest -r /home/ubuntu/fastapi-demo/requirements.txt +unit: commands[0]> coverage run --source=/home/ubuntu/fastapi-demo/src -m pytest --tb native -v -s /home/ubuntu/fastapi-demo/tests/unit +========================================= test session starts ========================================= +platform linux -- Python 3.10.13, pytest-8.0.2, pluggy-1.4.0-- /home/ubuntu/fastapi-demo/.tox/unit/bin/python +cachedir: .tox/unit/.pytest_cache +rootdir: /home/ubuntu/fastapi-demo +collected 3 items + +tests/unit/test_charm.py::test_pebble_layer PASSED +tests/unit/test_charm.py::test_port_configuration[22-expected_status0] PASSED +tests/unit/test_charm.py::test_port_configuration[1234-expected_status1] PASSED + +========================================== 3 passed in 0.21s ========================================== +unit: commands[1]> coverage report +Name Stmts Miss Cover +---------------------------------- +src/charm.py 122 43 65% +---------------------------------- +TOTAL 122 43 65% + unit: OK (6.00=setup[5.43]+cmd[0.49,0.09] seconds) + congratulations :) (6.04 seconds) +``` + +### Write a scenario test + +Let's also write a scenario test! Add this test to your `tests/scenario/test_charm.py` file: + +```python +def test_open_port(monkeypatch: MonkeyPatch): + monkeypatch.setattr('charm.LogProxyConsumer', Mock()) + monkeypatch.setattr('charm.MetricsEndpointProvider', Mock()) + monkeypatch.setattr('charm.GrafanaDashboardProvider', Mock()) + + # Use scenario.Context to declare what charm we are testing. + ctx = scenario.Context( + FastAPIDemoCharm, + meta={ + 'name': 'demo-api-charm', + 'containers': {'demo-server': {}}, + 'peers': {'fastapi-peer': {'interface': 'fastapi_demo_peers'}}, + 'requires': { + 'database': { + 'interface': 'postgresql_client', + } + }, + }, + config={ + 'options': { + 'server-port': { + 'default': 8000, + } + } + }, + actions={ + 'get-db-info': {'params': {'show-password': {'default': False, 'type': 'boolean'}}} + }, + ) + state_in = scenario.State( + leader=True, + relations=[ + scenario.Relation( + endpoint='database', + interface='postgresql_client', + remote_app_name='postgresql-k8s', + local_unit_data={}, + remote_app_data={ + 'endpoints': '127.0.0.1:5432', + 'username': 'foo', + 'password': 'bar', + }, + ), + scenario.PeerRelation( + endpoint='fastapi-peer', + peers_data={'unit_stats': {'started_counter': '0'}}, + ), + ], + containers=[ + scenario.Container(name='demo-server', can_connect=True), + ], + ) + state1 = ctx.run('config_changed', state_in) + assert len(state1.opened_ports) == 1 + assert state1.opened_ports[0].port == 8000 + assert state1.opened_ports[0].protocol == 'tcp' +``` + +In your Multipass Ubuntu VM shell, run your scenario test as below: + +```bash +ubuntu@charm-dev:~/fastapi-demo$ tox -re scenario +``` + +If successful, this should yield: + +```bash +scenario: remove tox env folder /home/ubuntu/fastapi-demo/.tox/scenario +scenario: install_deps> python -I -m pip install cosl 'coverage[toml]' ops-scenario pytest -r /home/ubuntu/fastapi-demo/requirements.txt +scenario: commands[0]> coverage run --source=/home/ubuntu/fastapi-demo/src -m pytest --tb native -v -s /home/ubuntu/fastapi-demo/tests/scenario +========================================= test session starts ========================================= +platform linux -- Python 3.10.13, pytest-8.0.2, pluggy-1.4.0 -- /home/ubuntu/fastapi-demo/.tox/scenario/bin/python +cachedir: .tox/scenario/.pytest_cache +rootdir: /home/ubuntu/fastapi-demo +collected 2 items + +tests/scenario/test_charm.py::test_get_db_info_action PASSED +tests/scenario/test_charm.py::test_open_port PASSED + +========================================== 2 passed in 0.31s ========================================== +scenario: commands[1]> coverage report +Name Stmts Miss Cover +---------------------------------- +src/charm.py 122 22 82% +---------------------------------- +TOTAL 122 22 82% + scenario: OK (6.66=setup[5.98]+cmd[0.59,0.09] seconds) + congratulations :) (6.69 seconds) +``` + +### Write an integration test + +In your `tests/integration` directory, create a `helpers.py` file with the following contents: + +```python +import socket +from pytest_operator.plugin import OpsTest + + +async def get_address(ops_test: OpsTest, app_name: str, unit_num: int = 0) -> str: + """Get the address for a the k8s service for an app.""" + status = await ops_test.model.get_status() + k8s_service_address = status['applications'][app_name].public_address + return k8s_service_address + + +def is_port_open(host: str, port: int) -> bool: + """check if a port is opened in a particular host""" + try: + with socket.create_connection((host, port), timeout=5): + return True # If connection succeeds, the port is open + except (ConnectionRefusedError, TimeoutError): + return False # If connection fails, the port is closed +``` + +In your existing `tests/integration/test_charm.py` file, import the methods defined in `helpers.py`: + +```python +from helpers import is_port_open, get_address +``` + +Now add the test case that will cover open ports: + +```python +@pytest.mark.abort_on_fail +async def test_open_ports(ops_test: OpsTest): + """Verify that setting the server-port in charm's config correctly adjust k8s service + + Assert blocked status in case of port 22 and active status for others + """ + app = ops_test.model.applications.get('demo-api-charm') + + # Get the k8s service address of the app + address = await get_address(ops_test=ops_test, app_name=APP_NAME) + # Validate that initial port is opened + assert is_port_open(address, 8000) + + # Set Port to 22 and validate app going to blocked status with port not opened + await app.set_config({'server-port': '22'}) + (await ops_test.model.wait_for_idle(apps=[APP_NAME], status='blocked', timeout=120),) + assert not is_port_open(address, 22) + + # Set Port to 6789 "Dummy port" and validate app going to active status with port opened + await app.set_config({'server-port': '6789'}) + (await ops_test.model.wait_for_idle(apps=[APP_NAME], status='active', timeout=120),) + assert is_port_open(address, 6789) +``` +In your Multipass Ubuntu VM shell, run the test as below: + +```bash +ubuntu@charm-dev:~/fastapi-demo$ tox -re integration +``` + +This test will take longer as a new model needs to be created. If successful, it should yield something similar to the output below: + +```bash +==================================== 3 passed in 234.15s (0:03:54) ==================================== + integration: OK (254.77=setup[19.55]+cmd[235.22] seconds) + congratulations :) (254.80 seconds) +``` + +## Validate your charm + +Congratulations, you've added a new feature to your charm, and also written tests to ensure that it will work properly. Time to give this feature a test drive! + +In your Multipass VM, repack and refresh your charm as below: + +```bash +ubuntu@charm-dev:~/fastapi-demo$ charmcraft pack +juju refresh \ + --path="./demo-api-charm_ubuntu-22.04-amd64.charm" \ + demo-api-charm --force-units --resource \ + demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1 +``` + +Watch your charm deployment status change until deployment settles down: + +``` +juju status --watch 1s +``` + +Use `kubectl` to list the available services and verify that `demo-api-charm` service exposes the `ClusterIP` on the expected port: + + +```bash +$ kubectl get services -n charm-model +NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE +modeloperator ClusterIP 10.152.183.231 17071/TCP 34m +demo-api-charm-endpoints ClusterIP None 19m +demo-api-charm ClusterIP 10.152.183.92 65535/TCP,8000/TCP 19m +postgresql-k8s-endpoints ClusterIP None 18m +postgresql-k8s ClusterIP 10.152.183.162 5432/TCP,8008/TCP 18m +postgresql-k8s-primary ClusterIP 10.152.183.109 8008/TCP,5432/TCP 18m +postgresql-k8s-replicas ClusterIP 10.152.183.29 8008/TCP,5432/TCP 18m +patroni-postgresql-k8s-config ClusterIP None 17m +``` + +Finally, `curl` the `ClusterIP` to verify that the `version` endpoint responds on the expected port: + +```bash +$ curl 10.152.183.92:8000/version +{"version":"1.0.1"} +``` + +Congratulations, your service now exposes an external port that is independent of any pod / node restarts! + +## Review the final code + +For the full code see: [11_open_port_k8s_service](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/11_open_port_k8s_service) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/10_integration_testing...11_open_port_k8s_service) + +> **See next: {ref}`Publish your charm on Charmhub `** + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/preserve-your-charms-data.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/preserve-your-charms-data.md new file mode 100644 index 000000000..49dc7a924 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/preserve-your-charms-data.md @@ -0,0 +1,179 @@ +(preserve-your-charms-data)= +# Preserve your charm's data + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Preserve your charm's data +> +> **See previous: {ref}`Integrate your charm with PostgreSQL `** + + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + +```bash +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 04_integrate_with_psql +git checkout -b 05_preserve_charm_data +``` + +```` + + +Charms are stateless applications. That is, they are reinitialised for every event and do not retain information from previous executions. This means that, if an accident occurs and the Kubernetes pod dies, you will also lose any information you may have collected. + +In many cases that is not a problem. However, there are situations where it may be necessary to maintain information from previous runs and to retain the state of the application. As a charm author you should thus know how to preserve state. + +There are a few strategies you can adopt here: + +First, you can use an Ops construct called `Stored State`. With this strategy you can store data on the local unit (at least, so long as your `main` function doesn't set `use_juju_for_storage` to `True`). However, if your Kubernetes pod dies, your unit also dies, and thus also the data. For this reason this strategy is generally not recommended. + +> Read more: [`ops.StoredState`](https://ops.readthedocs.io/en/latest/#ops.StoredState), {ref}`StoredState: Uses, Limitations ` + +Second, you can make use of the Juju notion of 'peer relations' and 'data bags' and set up a peer relation data bag. This will help you store the information in the Juju's database backend. + + + + +Third, when you have confidential data, you can use Juju secrets (from Juju 3.1 onwards). + + + + +In this chapter we will adopt the second strategy, that is, we will store charm data in a peer relation databag. (We will explore the third strategy in a different scenario in the next chapter.) We will illustrate this strategy with an artificial example where we save the counter of how many times the application pod has been restarted. + +## Define a peer relation + +The first thing you need to do is define a peer relation. Update the `charmcraft.yaml` file to add a `peers` block before the `requires` block, as below (where `fastapi-peer` is a custom name for the peer relation and `fastapi_demo_peers` is a custom name for the peer relation interface): + +```yaml +peers: + fastapi-peer: + interface: fastapi_demo_peers +``` + + + +## Set and get data from the peer relation databag + +Now, you need a way to set and get data from the peer relation databag. For that you need to update the `src/charm.py` file as follows: + +First, define some helper methods that will allow you to read and write from the peer relation databag: + +```python +@property +def peers(self) -> Optional[ops.Relation]: + """Fetch the peer relation.""" + return self.model.get_relation(PEER_NAME) + +def set_peer_data(self, key: str, data: JSONData) -> None: + """Put information into the peer data bucket instead of `StoredState`.""" + peers = cast(ops.Relation, self.peers) + peers.data[self.app][key] = json.dumps(data) + +def get_peer_data(self, key: str) -> Dict[str, JSONData]: + """Retrieve information from the peer data bucket instead of `StoredState`.""" + if not self.peers: + return {} + data = self.peers.data[self.app].get(key, '') + if not data: + return {} + return json.loads(data) +``` + +This block uses the built-in `json` module of Python, so you need to import that as well. You also need to define a global variable called `PEER_NAME = "fastapi-peer"`, to match the name of the peer relation defined in `charmcraft.yaml` file. We'll also need to import some additional types from `typing`, and define a type alias for JSON data. Update your imports to include the following: + +```python +import json +from typing import Dict, List, Optional, Union, cast +``` +Then define our global and type alias as follows: + +```python +PEER_NAME = 'fastapi-peer' + +JSONData = Union[ + Dict[str, 'JSONData'], + List['JSONData'], + str, + int, + float, + bool, + None, +] +``` + +Next, you need to add a method that updates a counter for the number of times a Kubernetes pod has been started. Let's make it retrieve the current count of pod starts from the 'unit_stats' peer relation data, increment the count, and then update the 'unit_stats' data with the new count, as below: + +```python +def _count(self, event: ops.StartEvent) -> None: + """This function updates a counter for the number of times a K8s pod has been started. + + It retrieves the current count of pod starts from the 'unit_stats' peer relation data, + increments the count, and then updates the 'unit_stats' data with the new count. + """ + unit_stats = self.get_peer_data('unit_stats') + counter = cast(str, unit_stats.get('started_counter', '0')) + self.set_peer_data('unit_stats', {'started_counter': int(counter) + 1}) +``` + +Finally, you need to call this method and update the peer relation data every time the pod is started. For that, define another event observer in the `__init__` method, as below: + +```python +framework.observe(self.on.start, self._count) +``` + +## Validate your charm + +First, repack and refresh your charm: + +```bash +charmcraft pack +juju refresh \ + --path="./demo-api-charm_ubuntu-22.04-amd64.charm" \ + demo-api-charm --force-units --resource \ + demo-server-image=ghcr.io/canonical/api_demo_server:1.0.1 +``` + + +Next, run `juju status` to make sure the application is refreshed and started, then investigate the relation data as below: + +```bash +juju show-unit demo-api-charm/0 +``` + +The output should include the following lines related to our peer relation: + +```bash + relation-info: + - relation-id: 25 + endpoint: fastapi-peer + related-endpoint: fastapi-peer + application-data: + unit_stats: '{"started_counter": 1}' +``` + +Now, simulate a Kubernetes pod crash by deleting the charm pod: + +```bash +microk8s kubectl --namespace=charm-model delete pod demo-api-charm-0 +``` + +Finally, check the peer relation again. You should see that the `started_counter` has been incremented by one. Good job, you've preserved your application data across restarts! + +## Review the final code + + +For the full code see: [05_preserve_charm_data](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/05_preserve_charm_data) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/04_integrate_with_psql...05_preserve_charm_data) + + +> **See next: {ref}`Expose your charm's operational tasks via actions `** + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/publish-your-charm-on-charmhub.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/publish-your-charm-on-charmhub.md new file mode 100644 index 000000000..7d49aafe6 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/publish-your-charm-on-charmhub.md @@ -0,0 +1,192 @@ +(publish-your-charm-on-charmhub)= +# Publish your charm on Charmhub + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Pushing your charm to charmhub +> +> **See previous: {ref}`Open a Kubernetes port in your charm `** + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + +```bash +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 11_open_port_k8s_service +``` + +```` + +In this tutorial you've done a lot of work, and the result is an increasingly functional charm. + +You can enjoy this charm on your own, or pass it around to friends, but why not share it with the whole world? + +The Canonical way to share a charm publicly is to publish it on [Charmhub](https://charmhub.io/). Aside from making your charm more visible, this also means you can deploy it more easily, as Charmhub is the default source for `juju deploy`. Besides, Charmcraft is there to support you every step of the way. + +In this chapter of the tutorial you will use Charmcraft to release your charm on Charmhub. + + +## Log in to Charmhub + +```{caution} + +**You will need an Ubuntu SSO account.**
+If you don't have one yet, sign up on https://login.ubuntu.com/+login + +``` + +```{note} + +Logging into Charmhub is typically a simple matter of running `charmcraft login` . However, here we are within a Multipass VM, so we have to take some extra steps. + +``` + + +On your Multipass VM, run the code below: + +```bash +ubuntu@charm-dev:~/fastapi-demo$ charmcraft login --export ~/secrets.auth +``` + +Once you've put in your login information, you should see something similar to the output below: + +```text +Opening an authorization web page in your browser. +If it does not open, please open this URL: + https://api.jujucharms.com/identity/login?did=48d45d919ca2b897a81470dc5e98b1a3e1e0b521b2fbcd2e8dfd414fd0e3fa96 +``` + +Copy-paste the provided web link into your web browser. Use your Ubuntu SSO to log in. + +When you're done, you should see in your terminal the following: + +```text +Login successful. Credentials exported to '~/secrets.auth'. +``` + +Now set an environment variable with the new token: + +```bash +export CHARMCRAFT_AUTH=$(cat ~/secrets.auth) +``` + +Well done, you're now logged in to Charmhub! + +## Register your charm's name + +On your Multipass VM, generate a random 8-digit hexadecimal hash, then view it in the shell: + +```text +random_hash=$(cat /dev/urandom | tr -dc 'a-f0-9' | head -c 8) +echo "Random 8-digit hash: $random_hash" +``` +```{important} + +Naming your charm is usually less random than that. However, here we are in a tutorial setting, so you just need to make sure to pick a unique name, any name. + +``` + +Navigate to the `charmcraft.yaml` file of your charm and update the `name` field with the randomly generated name. + +Once done, prepare the charm for upload by executing `charmcraft pack` . This command will create a compressed file with the updated name prefix, as discussed earlier. + +Now pass this hash as the name to register for your charm on Charmhub: + +```bash +$ charmcraft register +Congrats! You are now the publisher of '' +``` + +You're all set! + +## Upload the charm and its resources + +On your Multipass VM, run the code below. (The argument to `charmcraft upload` is the filepath to the `.charm` file.) + +```text +charmcraft upload _ubuntu-22.04-amd64.charm +Revision 1 of created +``` + +```{note} + +Every time a new binary is uploaded for a charm, a new revision is created on Charmhub. We can verify its current status easily by running `charmcraft revisions `. + +``` + + +Now upload the charm's resource -- in your case, the `demo-server-image` OCI image specified in your charm's `charmcraft.yaml` as follows: + + + +First, pull it locally: + +```text +docker pull ghcr.io/canonical/api_demo_server:1.0.1 +``` + +Then, take note of the image ID: + +```text +docker images ghcr.io/canonical/api_demo_server +``` + +This should output something similar to the output below: + +```text +REPOSITORY TAG IMAGE ID CREATED SIZE +ghcr.io/canonical/api_demo_server 1.0.1 6 months ago 532MB +``` + +Finally, upload the image as below, specifying first the charm name, then the image name, then a flag with the image digest: + +```text +charmcraft upload-resource demo-server-image --image= +``` + +Sample output: + +```text +Revision 1 created of resource 'demo-server-image' for charm ''. +``` + +All set! + +## Release the charm + +Release your charm as below. + +```{important} + +**Do not worry:**
+While releasing a charm to Charmhub gives it a public URL, the charm will not appear in the Charmhub search results until it has passed formal review. + +``` + + +```text +$ charmcraft release --revision=1 --channel=beta --resource=demo-server-image:1 +Revision 1 of charm '` released to beta +``` + +This releases it into a channel so it can become available for downloading. + +Just in case, also check your charm's status: + +```text +$ charmcraft status +Track Base Channel Version Revision Resources +latest ubuntu 22.04 (amd64) stable - - - + candidate - - - + beta 1 1 demo-server-image (r1) + edge ↑ ↑ ↑ +``` + +Congratulations, your charm has now been published to charmhub.io! + +You can view it at any time at `charmhub.io/`. + + + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/set-up-your-development-environment.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/set-up-your-development-environment.md new file mode 100644 index 000000000..752ce81ec --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/set-up-your-development-environment.md @@ -0,0 +1,30 @@ +(set-up-your-development-environment)= +# Set up your development environment + +> {ref}`From zero to hero: Write your first Kubernetes charm ` > Set up your development environment +> +> **See previous: {ref}`Study your application `** + +In this chapter of the tutorial you will set up your development environment. + +You will need a charm directory, the various tools in the charm SDK, Juju, and a Kubernetes cloud. And it’s a good idea if you can do all your work in an isolated development environment. + +You can get all of this by following our generic development setup guide, with some annotations. + +> See [`juju` | Set up your environment automatically](https://juju.is/docs/juju/set-up--tear-down-your-test-environment#tear-down-automatically, with the following changes: +> - At the directory step, call your directory `fastapi-demo`. +> - At the VM setup step, call your VM `charm-dev` and also set up Docker: +> 1. `sudo addgroup --system docker` +> 1. `sudo adduser $USER docker` +> 1. `newgrp docker` +> 1. `sudo snap install docker`. +> - At the cloud selection step, choose `microk8s`. +> - At the mount step: Make sure to read the box with tips on how to edit files locally while running them inside the VM!

+> All set! + + + +Congratulations, your development environment is now ready! + +> **See next: {ref}`Create a minimal Kubernetes charm `** + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/study-your-application.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/study-your-application.md new file mode 100644 index 000000000..5b47de81e --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/study-your-application.md @@ -0,0 +1,105 @@ +(study-your-application)= +# Study your application + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Study your application + +A charm is an application packaged with all the logic it needs to operate in the cloud. + +As such, if you want to charm an application, the first thing you need to understand is the application itself. + +Of course, what exactly you'll need to know and how exactly you'll have to go about getting this knowledge will very much depend on the application. + +In this part of the tutorial we will choose an application for you and tell you all you need to know about it to start charming. Our demo app is called 'FastAPI Demo' and we have designed it specifically for this tutorial so that, by creating a Kubernetes charm for it, you can master all the fundamentals of Kubernetes charming. + + + + + + +## Features + +The FastAPI app was built using the Python [FastAPI](https://fastapi.tiangolo.com/) framework to deliver a very simple web server. It offers a couple of API endpoints that the user can interact with. + +The app also has a connection to a [PostgreSQL](https://www.postgresql.org/) database. It provides users with an API to create a table with user names, add a name to the database, and get all the names from the database. + +Additionally, our app uses [starlette-exporter](https://pypi.org/project/starlette-exporter/) to generate real-time application metrics and to expose them via a `/metrics` endpoint that is designed to be scraped by [Prometheus](https://prometheus.io/). + +Finally, every time a user interacts with the database, our app writes logging information to the log file and also streams it to the stdout. + +To summarize, our demo application is a minimal but real-life-like application that has external API endpoints, performs database read and write operations, and collects real-time metrics and logs for observability purposes. + + +## Structure + +The app source code is hosted at https://github.com/canonical/api_demo_server . + +As you can see [here](https://github.com/canonical/api_demo_server/tree/master/api_demo_server), the app consists of primarily the following two files: + +- `app.py`, which describes the API endpoints and the logging definition, and +- `database.py`, which describes the interaction with the PostgreSQL database. + +Furthermore, as you can see [here](https://github.com/canonical/api_demo_server/tree/master?tab=readme-ov-file#configuration-via-environment-variables), the application provides a way to configure the output logging file and the database access points (IP, port, username, password) via environment variables: + +- `DEMO_SERVER_LOGFILE` +- `DEMO_SERVER_DB_HOST` +- `DEMO_SERVER_DB_PORT` +- `DEMO_SERVER_DB_USER` +- `DEMO_SERVER_DB_PASSWORD` + + +## API endpoints + +The application is set up such that, once deployed, you can access the deployed IP on port 8000. Specifically: + + +||| +|-|-| +| To get Prometheus metrics: | `http://:8000/metrics` | +| To get a Swagger UI to interact with API: |`http://:8000/docs`| + + + +## OCI image + +Our app's OCI image is at + +https://github.com/canonical/api_demo_server/pkgs/container/api_demo_server + + + + +> **See next: {ref}`Set up your development environment `** + + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/write-integration-tests-for-your-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/write-integration-tests-for-your-charm.md new file mode 100644 index 000000000..0540e77c7 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/write-integration-tests-for-your-charm.md @@ -0,0 +1,204 @@ +(write-integration-tests-for-your-charm)= +# Write integration tests for your charm + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Write integration tests for your charm +> +> **See previous: {ref}`Write scenario tests for your charm `** + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + +```bash +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 09_scenario_test +git checkout -b 10_integration_testing +``` + +```` + +A charm should function correctly not just in a mocked environment but also in a real deployment. + +For example, it should be able to pack, deploy, and integrate without throwing exceptions or getting stuck in a `waiting` or a `blocked` status -- that is, it should correctly reach a status of `active` or `idle`. + +You can ensure this by writing integration tests for your charm. In the charming world, these are usually written with the [`pytest-operator`](https://github.com/charmed-kubernetes/pytest-operator) library. + +In this chapter you will write two small integration tests -- one to check that the charm packs and deploys correctly and one to check that the charm integrates successfully with the PostgreSQL database. + +## Prepare your test environment + +In your `tox.ini` file, add the following new environment: + +``` +[testenv:integration] +description = Run integration tests +deps = + pytest + juju + pytest-operator + -r {tox_root}/requirements.txt +commands = + pytest -v \ + -s \ + --tb native \ + --log-cli-level=INFO \ + {posargs} \ + {[vars]tests_path}/integration +``` + +## Prepare your test directory + +Create a `tests/integration` directory: +```bash +mkdir ~/fastapi-demo/tests/integration + +``` + +## Write and run a pack-and-deploy integration test + +Let's begin with the simplest possible integration test, a [smoke test](https://en.wikipedia.org/wiki/Smoke_testing_(software)). This test will build and deploy the charm and verify that the installation hooks finish without any error. + + +In your `tests/integration` directory, create a file `test_charm.py` and add the following test case: + +```python +import asyncio +import logging +from pathlib import Path + +import pytest +import yaml +from pytest_operator.plugin import OpsTest + +logger = logging.getLogger(__name__) + +METADATA = yaml.safe_load(Path('./charmcraft.yaml').read_text()) +APP_NAME = METADATA['name'] + + +@pytest.mark.abort_on_fail +async def test_build_and_deploy(ops_test: OpsTest): + """Build the charm-under-test and deploy it together with related charms. + + Assert on the unit status before any relations/configurations take place. + """ + # Build and deploy charm from local source folder + charm = await ops_test.build_charm('.') + resources = { + 'demo-server-image': METADATA['resources']['demo-server-image']['upstream-source'] + } + + # Deploy the charm and wait for blocked/idle status + # The app will not be in active status as this requires a database relation + await asyncio.gather( + ops_test.model.deploy(charm, resources=resources, application_name=APP_NAME), + ops_test.model.wait_for_idle( + apps=[APP_NAME], status='blocked', raise_on_blocked=False, timeout=120 + ), + ) +``` + +In your Multipass Ubuntu VM, run the test: + +```bash +tox -e integration +``` + +The test takes some time to run as the `pytest-operator` running in the background will add a new model to an existing cluster (whose presence it assumes). If successful, it'll verify that your charm can pack and deploy as expected. + +## Write and run an integrate-with-database integration test + +The charm requires a database to be functional. Let's verify that this behaviour works as intended. For that, we need to deploy a database to the test cluster and integrate both applications. Finally, we should check that the charm reports an active status. + +In your `tests/integration/test_charm.py` file add the following test case: + +```python +@pytest.mark.abort_on_fail +async def test_database_integration(ops_test: OpsTest): + """Verify that the charm integrates with the database. + + Assert that the charm is active if the integration is established. + """ + await ops_test.model.deploy( + application_name='postgresql-k8s', + entity_url='postgresql-k8s', + channel='14/stable', + ) + await ops_test.model.integrate(f'{APP_NAME}', 'postgresql-k8s') + await ops_test.model.wait_for_idle( + apps=[APP_NAME], status='active', raise_on_blocked=False, timeout=120 + ) +``` + + +```{important} + +But if you run the one and then the other (as separate `pytest ...` invocations, then two separate models will be created unless you pass `--model=some-existing-model` to inform pytest-operator to use a model you provide. + +``` + +In your Multipass Ubuntu VM, run the test again: + + +```bash +ubuntu@charm-dev:~/fastapi-demo$ tox -e integration + +``` + +The test may again take some time to run. + +```{tip} + +**Pro tip:** To make things faster, use the `--model=` to inform `pytest-operator` to use the model it has created for the first test. Otherwise, charmers often have a way to cache their pack or deploy results; an example is https://github.com/canonical/spellbook . + +``` + +When it's done, the output should show two passing tests: + +```bash +... + demo-api-charm/0 [idle] waiting: Waiting for database relation +INFO juju.model:model.py:2759 Waiting for model: + demo-api-charm/0 [idle] active: +PASSED +-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- live log teardown -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- +INFO pytest_operator.plugin:plugin.py:783 Model status: + +Model Controller Cloud/Region Version SLA Timestamp +test-charm-2ara main-controller microk8s/localhost 3.1.5 unsupported 09:45:56+02:00 + +App Version Status Scale Charm Channel Rev Address Exposed Message +demo-api-charm 1.0.1 active 1 demo-api-charm 0 10.152.183.99 no +postgresql-k8s 14.7 active 1 postgresql-k8s 14/stable 73 10.152.183.50 no + +Unit Workload Agent Address Ports Message +demo-api-charm/0* active idle 10.1.208.77 +postgresql-k8s/0* active idle 10.1.208.107 + +INFO pytest_operator.plugin:plugin.py:789 Juju error logs: + + +INFO pytest_operator.plugin:plugin.py:877 Resetting model test-charm-2ara... +INFO pytest_operator.plugin:plugin.py:866 Destroying applications demo-api-charm +INFO pytest_operator.plugin:plugin.py:866 Destroying applications postgresql-k8s +INFO pytest_operator.plugin:plugin.py:882 Not waiting on reset to complete. +INFO pytest_operator.plugin:plugin.py:855 Forgetting main... + + +========================================================================================================================================================================== 2 passed in 290.23s (0:04:50) ========================================================================================================================================================================== + integration: OK (291.01=setup[0.04]+cmd[290.97] seconds) + congratulations :) (291.05 seconds) +``` + +Congratulations, with this integration test you have verified that your charms relation to PostgreSQL works as well! + +## Review the final code + +For the full code see: [10_integration_testing](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/09_scenario_test) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/09_scenario_test...10_integration_testing) + +> **See next: {ref}`Open a Kubernetes port in your charm `** + + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/write-scenario-tests-for-your-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/write-scenario-tests-for-your-charm.md new file mode 100644 index 000000000..c401c4e16 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/write-scenario-tests-for-your-charm.md @@ -0,0 +1,204 @@ +(write-scenario-tests-for-your-charm)= +# Write scenario tests for your charm + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Write scenario tests for your charm +> +> **See previous: {ref}`Write unit tests for your charm `** + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + +``` +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 08_unit_testing +git checkout -b 09_scenario_testing +``` + +```` + +In the previous chapter we checked the basic functionality of our charm by writing unit tests. + +However, there is one more type of test to cover, namely: state transition tests. + +In the charming world the current recommendation is to write state transition tests with the 'scenario' model popularised by the {ref}``ops-scenario` ` library. + +```{note} + Scenario is a state-transition testing SDK for operator framework charms. +``` + +In this chapter you will write a scenario test to check that the `get_db_info` action that you defined in an earlier chapter behaves as expected. + + +## Prepare your test environment + +Install `ops-scenario`: + +```bash +pip install ops-scenario +``` +In your project root's existing `tox.ini` file, add the following: + +``` +... + +[testenv:scenario] +description = Run scenario tests +deps = + pytest + cosl + ops-scenario ~= 7.0 + coverage[toml] + -r {tox_root}/requirements.txt +commands = + coverage run --source={[vars]src_path} \ + -m pytest \ + --tb native \ + -v \ + -s \ + {posargs} \ + {[vars]tests_path}/scenario + coverage report +``` + +And adjust the `env_list` so that the Scenario tests will run with a plain `tox` command: + +``` +env_list = unit, scenario +``` + +## Prepare your test directory + +By convention, scenario tests are kept in a separate directory, `tests/scenario`. Create it as below: + +``` +mkdir -p tests/scenario +cd tests/scenario +``` + + +## Write your scenario test + +In your `tests/scenario` directory, create a new file `test_charm.py` and add the test below. This test will check the behaviour of the `get_db_info` action that you set up in a previous chapter. It will first set up the test context by setting the appropriate metadata, then define the input state, then run the action and, finally, check if the results match the expected values. + +```python +from unittest.mock import Mock + +import scenario +from pytest import MonkeyPatch + +from charm import FastAPIDemoCharm + + +def test_get_db_info_action(monkeypatch: MonkeyPatch): + monkeypatch.setattr('charm.LogProxyConsumer', Mock()) + monkeypatch.setattr('charm.MetricsEndpointProvider', Mock()) + monkeypatch.setattr('charm.GrafanaDashboardProvider', Mock()) + + # Use scenario.Context to declare what charm we are testing. + # Note that Scenario will automatically pick up the metadata from + # your charmcraft.yaml file, so you typically could just do + # `ctx = scenario.Context(FastAPIDemoCharm)` here, but the full + # version is included here as an example. + ctx = scenario.Context( + FastAPIDemoCharm, + meta={ + 'name': 'demo-api-charm', + 'containers': {'demo-server': {}}, + 'peers': {'fastapi-peer': {'interface': 'fastapi_demo_peers'}}, + 'requires': { + 'database': { + 'interface': 'postgresql_client', + } + }, + }, + config={ + 'options': { + 'server-port': { + 'default': 8000, + } + } + }, + actions={ + 'get-db-info': {'params': {'show-password': {'default': False, 'type': 'boolean'}}} + }, + ) + + # Declare the input state. + state_in = scenario.State( + leader=True, + relations={ + scenario.Relation( + endpoint='database', + interface='postgresql_client', + remote_app_name='postgresql-k8s', + local_unit_data={}, + remote_app_data={ + 'endpoints': '127.0.0.1:5432', + 'username': 'foo', + 'password': 'bar', + }, + ), + }, + containers={ + scenario.Container('demo-server', can_connect=True), + }, + ) + + # Run the action with the defined state and collect the output. + ctx.run(ctx.on.action('get-db-info', params={'show-password': True}), state_in) + + assert ctx.action_results == { + 'db-host': '127.0.0.1', + 'db-port': '5432', + 'db-username': 'foo', + 'db-password': 'bar', + } +``` + + +## Run the test + +In your Multipass Ubuntu VM shell, run your scenario test as below: + +```bash +ubuntu@charm-dev:~/juju-sdk-tutorial-k8s$ tox -e scenario +``` + +You should get an output similar to the one below: + +```bash +scenario: commands[0]> coverage run --source=/home/tameyer/code/juju-sdk-tutorial-k8s/src -m pytest --tb native -v -s /home/tameyer/code/juju-sdk-tutorial-k8s/tests/scenario +======================================= test session starts ======================================== +platform linux -- Python 3.11.9, pytest-8.3.3, pluggy-1.5.0 -- /home/tameyer/code/juju-sdk-tutorial-k8s/.tox/scenario/bin/python +cachedir: .tox/scenario/.pytest_cache +rootdir: /home/tameyer/code/juju-sdk-tutorial-k8s +plugins: anyio-4.6.0 +collected 1 item + +tests/scenario/test_charm.py::test_get_db_info_action PASSED + +======================================== 1 passed in 0.19s ========================================= +scenario: commands[1]> coverage report +Name Stmts Miss Cover +---------------------------------- +src/charm.py 129 57 56% +---------------------------------- +TOTAL 129 57 56% + scenario: OK (6.89=setup[6.39]+cmd[0.44,0.06] seconds) + congratulations :) (6.94 seconds) +``` + +Congratulations, you have written your first scenario test! + +## Review the final code + + +For the full code see: [09_scenario_testing](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/09_scenario_test) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/08_unit_testing...09_scenario_test) + +> **See next: {ref}`Write integration tests for your charm `** + + diff --git a/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/write-unit-tests-for-your-charm.md b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/write-unit-tests-for-your-charm.md new file mode 100644 index 000000000..2f9ff9cc4 --- /dev/null +++ b/docs/tutorial/from-zero-to-hero-write-your-first-kubernetes-charm/write-unit-tests-for-your-charm.md @@ -0,0 +1,200 @@ +(write-unit-tests-for-your-charm)= +# Write unit tests for your charm + +> {ref}`From Zero to Hero: Write your first Kubernetes charm ` > Write a unit test for your charm +> +> **See previous: {ref}`Observe your charm with COS Lite `** + +````{important} + +This document is part of a series, and we recommend you follow it in sequence. However, you can also jump straight in by checking out the code from the previous branches: + +```bash +git clone https://github.com/canonical/juju-sdk-tutorial-k8s.git +cd juju-sdk-tutorial-k8s +git checkout 07_cos_integration +git checkout -b 08_unit_testing +``` + +```` + +When you're writing a charm, you will want to ensure that it will behave reliably as intended. + +For example, that the various components -- relation data, pebble services, or configuration files -- all behave as expected in response to an event. + +You can ensure all this by writing a rich battery of units tests. In the context of a charm we recommended using [`pytest`](https://pytest.org/) (but [`unittest`](https://docs.python.org/3/library/unittest.html) can also be used) and especially the operator framework's built-in testing library -- [`ops.testing.Harness`](https://ops.readthedocs.io/en/latest/harness.html#module-ops.testing). We will be using the Python testing tool [`tox`](https://tox.wiki/en/4.14.2/index.html) to automate our testing and set up our testing environment. + +In this chapter you will write a simple unit test to check that your workload container is initialised correctly. + + +## Prepare your test environment + +Create a file called `tox.ini` in your charm project's root directory and add the following to configure your test environment: + +``` +[tox] +no_package = True +skip_missing_interpreters = True +min_version = 4.0.0 +env_list = unit + +[vars] +src_path = {tox_root}/src +tests_path = {tox_root}/tests + +[testenv] +set_env = + PYTHONPATH = {tox_root}/lib:{[vars]src_path} + PYTHONBREAKPOINT=pdb.set_trace + PY_COLORS=1 +pass_env = + PYTHONPATH + CHARM_BUILD_DIR + MODEL_SETTINGS + +[testenv:unit] +description = Run unit tests +deps = + pytest + cosl + coverage[toml] + -r {tox_root}/requirements.txt +commands = + coverage run --source={[vars]src_path} \ + -m pytest \ + --tb native \ + -v \ + -s \ + {posargs} \ + {[vars]tests_path}/unit + coverage report +``` +> Read more: [`tox.ini`](https://tox.wiki/en/latest/config.html#tox-ini) + + +## Prepare your test directory + +In your project root, create a `tests/unit` directory: + +```bash +mkdir -p tests/unit +``` + +### Write your unit test + +In your `tests/unit` directory, create a file called `test_charm.py`. + +In this file, do all of the following: + +First, add the necessary imports: + +```python +import ops +import ops.testing +import pytest + +from charm import FastAPIDemoCharm +``` + +Then, add a test [fixture](https://docs.pytest.org/en/7.1.x/how-to/fixtures.html) that sets up the testing harness and makes sure that it will be cleaned up after each test: + +```python +@pytest.fixture +def harness(): + harness = ops.testing.Harness(FastAPIDemoCharm) + harness.begin() + yield harness + harness.cleanup() + +``` + +Finally, add a first test case as a function, as below. As you can see, this test case is used to verify that the deployment of the `fastapi-service` within the `demo-server` container is configured correctly and that the service is started and running as expected when the container is marked as `pebble-ready`. It also checks that the unit's status is set to active without any error messages. Note that we mock some methods of the charm because they do external calls that are not represented in the state of this unit test. + +```python +def test_pebble_layer( + monkeypatch: pytest.MonkeyPatch, harness: ops.testing.Harness[FastAPIDemoCharm] +): + monkeypatch.setattr(FastAPIDemoCharm, 'version', '1.0.0') + # Expected plan after Pebble ready with default config + expected_plan = { + 'services': { + 'fastapi-service': { + 'override': 'replace', + 'summary': 'fastapi demo', + 'command': 'uvicorn api_demo_server.app:app --host=0.0.0.0 --port=8000', + 'startup': 'enabled', + # Since the environment is empty, Layer.to_dict() will not + # include it. + } + } + } + + # Simulate the container coming up and emission of pebble-ready event + harness.container_pebble_ready('demo-server') + harness.evaluate_status() + + # Get the plan now we've run PebbleReady + updated_plan = harness.get_container_pebble_plan('demo-server').to_dict() + service = harness.model.unit.get_container('demo-server').get_service('fastapi-service') + # Check that we have the plan we expected: + assert updated_plan == expected_plan + # Check the service was started: + assert service.is_running() + # Ensure we set a BlockedStatus with appropriate message: + assert isinstance(harness.model.unit.status, ops.BlockedStatus) + assert 'Waiting for database' in harness.model.unit.status.message +``` + + +> Read more: [`ops.testing`](https://ops.readthedocs.io/en/latest/harness.html#module-ops.testing) + +## Run the test + +In your Multipass Ubuntu VM shell, run your unit test as below: + +```bash +ubuntu@charm-dev:~/fastapi-demo$ tox -e unit +``` + +You should get an output similar to the one below: + +```bash +unit: commands[0]> coverage run --source=/home/ubuntu/fastapi-demo/src -m pytest --tb native -v -s /home/ubuntu/fastapi-demo/tests/unit +=============================================================================================================================================================================== test session starts =============================================================================================================================================================================== +platform linux -- Python 3.10.13, pytest-8.0.2, pluggy-1.4.0 -- /home/ubuntu/fastapi-demo/.tox/unit/bin/python +cachedir: .tox/unit/.pytest_cache +rootdir: /home/ubuntu/fastapi-demo +collected 1 item + +tests/unit/test_charm.py::test_pebble_layer PASSED + +================================================================================================================================================================================ 1 passed in 0.30s ================================================================================================================================================================================ +unit: commands[1]> coverage report +Name Stmts Miss Cover +---------------------------------- +src/charm.py 118 49 58% +---------------------------------- +TOTAL 118 49 58% + unit: OK (0.99=setup[0.04]+cmd[0.78,0.16] seconds) + congratulations :) (1.02 seconds) +``` + +Congratulations, you have now successfully implemented your first unit test! + +```{caution} + +As you can see in the output, the current tests cover 58% of the charm code. In a real-life scenario make sure to cover much more! + +``` + +## Review the final code + +For the full code see: [08_unit_testing](https://github.com/canonical/juju-sdk-tutorial-k8s/tree/08_unit_testing) + +For a comparative view of the code before and after this doc see: [Comparison](https://github.com/canonical/juju-sdk-tutorial-k8s/compare/07_cos_integration...08_unit_testing) + +> **See next: {ref}`Write scenario tests for your charm `** + + + + diff --git a/docs/tutorial/index.md b/docs/tutorial/index.md new file mode 100644 index 000000000..bef71fe7b --- /dev/null +++ b/docs/tutorial/index.md @@ -0,0 +1,12 @@ +(tutorial)= +# Tutorials + +Writing a machine charm is not so different from writing a Kubernetes charm, but it *is* a little bit different. As such, our tutorial comes in two basic flavors, for machines and for Kubernetes. The choice is yours! + + +```{toctree} +:maxdepth: 1 + +write-your-first-machine-charm +from-zero-to-hero-write-your-first-kubernetes-charm/index +``` diff --git a/docs/tutorial/write-your-first-machine-charm.md b/docs/tutorial/write-your-first-machine-charm.md new file mode 100644 index 000000000..f3c8588df --- /dev/null +++ b/docs/tutorial/write-your-first-machine-charm.md @@ -0,0 +1,543 @@ +(write-your-first-machine-charm)= +# Write your first machine charm + +In this tutorial you will write a [machine charm](https://juju.is/docs/juju/charmed-operator) for Juju using (Charmcraft and) Ops. + + +**What you'll need:** +- A workstation, e.g., a laptop, with amd64 architecture and which has sufficient resources to launch a virtual machine with 4 CPUs, 8 GB RAM, and 50 GB disk space +- Familiarity with Linux +- Familiarity with Juju. +- Familiarity with object-oriented programming in Python + +**What you'll do:** + +Study your application. Use Charmcraft and Ops to build a basic charm and test-deploy it with Juju and a localhost LXD-based cloud. Repeat the steps to evolve the charm so it can become increasingly more sophisticated. + + + +```{note} + +Should you get stuck at any point: Don't hesitate to get in touch on [Matrix](https://matrix.to/#/#charmhub-charmdev:ubuntu.com) or [Discourse](https://discourse.charmhub.io/). + +``` + +## Study your application + +In this tutorial we will be writing a charm for Microsample (`microsample`) -- a small educational application that delivers a Flask microservice. + +The application has been packaged and published as a snap ([https://snapcraft.io/microsample](https://snapcraft.io/microsample)). We will write our charm such that `juju deploy` will install it from this snap. This will make workload installation straightforward and upgrades automatic (as they will happen automatically through `snapd`). + +The application snap has been released into multiple channels -- `edge`, `beta`, `candidate`, and `stable`. We will write our charm such that a user can choose the channel they prefer by running `juju deploy microsample channel=`. + +The application has other features that we can exploit, but for now this is enough to get us started with a simple charm. + + +## Set up your development environment + + +> See [Juju | Set up your development environment automatically](https://juju.is/docs/juju/set-up--tear-down-your-test-environment#set-up-automatically) for instructions on how to set up your development environment so that it's ready for you to test-deploy your charm. At the charm directory step, call it `microsample-vm`. At the cloud step, choose LXD. + +```{important} + +- Going forward: + - Use your host machine (on Linux, `cd ~/microsample-vm`) to create and edit your charm files. This will allow you to use your favorite local editor. + - Use the Multipass VM shell (on Linux, `ubuntu@charm-dev:~$ cd ~/microsample-vm`) to run Charmcraft and Juju commands. + + +- At any point: + - To exit the shell, press `mod key + C` or type `exit`. + - To stop the VM after exiting the VM shell, run `multipass stop charm-dev`. + - To restart the VM and re-open a shell into it, type `multipass shell charm-dev`. + +``` + + +## Enable `juju deploy microsample-vm` + + +Let's charm our `microsample` application into a `microsample-vm` charm such that a user can successfully install it on any machine cloud simply by running `juju deploy microsample-vm`! + +In your Multipass VM shell, enter your charm directory, run `charmcraft init --profile machine` to initialise the file tree structure for your machine charm, and inspect the result. Sample session: + +```text +# Enter your charm directory: +ubuntu@charm-dev:~$ cd microsample-vm/ + +# Initialise the charm tree structure: +ubuntu@charm-dev:~/microsample-vm$ charmcraft init --profile machine +Charmed operator package file and directory tree initialised. + +Now edit the following package files to provide fundamental charm metadata +and other information: + +charmcraft.yaml +src/charm.py +README.md + +# Inspect the result: +ubuntu@charm-dev:~/microsample-vm$ ls -R +.: +CONTRIBUTING.md README.md pyproject.toml src tox.ini +LICENSE charmcraft.yaml requirements.txt tests + +./src: +charm.py + +./tests: +integration unit + +./tests/integration: +test_charm.py + +./tests/unit: +test_charm.py + +``` + + + +In your local editor, open the `charmcraft.yaml` file and customise its contents as below (you only have to edit the `title`, `summary`, and `description`): + +```yaml +# (Required) +name: microsample-vm + +# (Required) +type: charm + +# (Recommended) +title: Microsample VM Charm + +# (Required) +summary: A charm that deploys the microsample snap and allows for a configuration of the snap channel via juju config. + +# (Required) +description: | + A machine charm for the Microsample application, built on top of the `microsample` snap. + + The charm allows you to deploy the application via `juju deploy`. + It also defines a channel config that allows you to choose which snap channel to install from during deployment. + + This charm makes it easy to deploy the Microsample application on any machine cloud. + + The primary value of this charm is educational -- beginner machine charms can study it to learn how to build a machine charm. + +# (Required for 'charm' type) +bases: + - build-on: + - name: ubuntu + channel: "22.04" + run-on: + - name: ubuntu + channel: "22.04" + +``` + + +Now open the `src/charm.py` file and update it as below (you'll have to add an import statement for `os` and an observer and handler for the `install` event -- in the definition of which you'll be using `os` and `ops`). + +```python +#!/usr/bin/env python3 +import os +import logging +import ops + +logger = logging.getLogger(__name__) + +class MicrosampleVmCharm(ops.CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.start, self._on_start) + self.framework.observe(self.on.install, self._on_install) + + def _on_start(self, event: ops.StartEvent): + """Handle start event.""" + self.unit.status = ops.ActiveStatus() + + def _on_install(self, event: ops.InstallEvent): + """Handle install event.""" + self.unit.status = ops.MaintenanceStatus("Installing microsample snap") + os.system(f"snap install microsample --channel edge") + self.unit.status = ops.ActiveStatus("Ready") + + +if __name__ == "__main__": # pragma: nocover + ops.main(MicrosampleVmCharm) # type: ignore +``` + + + +Next, in your Multipass VM shell, inside your project directory, run `charmcraft pack` to pack the charm. It may take a few minutes the first time around but, when it's done, your charm project should contain a `.charm` file. Sample session: + + +```text +# Pack the charm into a '.charm' file: +ubuntu@charm-dev:~/microsample-vm$ charmcraft pack +Created 'microsample-vm_ubuntu-22.04-amd64.charm'. +Charms packed: + microsample-vm_ubuntu-22.04-amd64.charm + +# Inspect the results -- your charm's root directory should contain a .charm file: +ubuntu@charm-dev:~/microsample-vm$ ls +CONTRIBUTING.md charmcraft.yaml requirements.txt tox.ini +LICENSE microsample-vm_ubuntu-22.04-amd64.charm src +README.md pyproject.toml tests +``` + + + +Now, open a new shell into your Multipass VM and use it to configure the Juju log verbosity levels and to start a live debug session: + +```text +# Set your logging verbosity level to `DEBUG`: +ubuntu@charm-dev:~$ juju model-config logging-config="=WARNING;unit=DEBUG" + +# Start a live debug session: +ubuntu@charm-dev:~$ juju debug-log +``` + +In your old VM shell, use Juju to deploy your charm. If all has gone well, you should see your App and Unit -- Workload status show as `active`: + +```text +# Deploy the Microsample VM charm as the 'microsample' application: +ubuntu@charm-dev:~/microsample-vm$ juju deploy ./microsample-vm_ubuntu-22.04-amd64.charm microsample +Located local charm "microsample-vm", revision 0 +Deploying "microsample" from local charm "microsample-vm", revision 0 on ubuntu@22.04/stable + +# Check the deployment status +# (use --watch 1s to update it automatically at 1s intervals): +ubuntu@charm-dev:~/microsample-vm$ juju status +Model Controller Cloud/Region Version SLA Timestamp +welcome-lxd lxd localhost/localhost 3.1.6 unsupported 12:49:26+01:00 + +App Version Status Scale Charm Channel Rev Exposed Message +microsample active 1 microsample-vm 0 no + +Unit Workload Agent Machine Public address Ports Message +microsample/0* active idle 1 10.122.219.101 + +Machine State Address Inst id Base AZ Message +1 started 10.122.219.101 juju-f25b73-1 ubuntu@22.04 Running + + +``` + +Finally, test that the service works by executing `curl` on your application unit: + +```text +ubuntu@charm-dev:~/microsample-vm$ juju exec --unit microsample/0 -- "curl -s http://localhost:8080" +Online +``` + +```{note} + +1. Fix the code in `src/charm.py`. +2. Rebuild the charm: `charmcraft pack` +3. Refresh the application from the repacked charm: `juju refresh microsample --path=./microsample-vm_ubuntu-22.04-amd64.charm --force-units` +4. Let the model know the issue is resolved (fixed): `juju resolved microsample/0`. + +``` + + + + + +```{note} + +The template content from `charmcraft init` was sufficient for the charm to pack and deploy successfully. However, our goal here was to make it run successfully, that is, to actually install the `microsample` application on our LXD cloud. With the edits above, this goal has been achieved. + +``` + + +## Enable `juju deploy microsample-vm --config channel=` + +Let's now evolve our charm so that a user can successfully choose which version of `microsample` they want installed by running `juju config microsample-vm channel=`! + +In your local editor, in your `charmcraft.yaml` file, define the configuration option as below: + +```yaml +config: + options: + channel: + description: | + Channel for the microsample snap. + default: "edge" + type: string +``` + + + +Then, in the `src/charm.py` file, update the `_on_install` function to make use of the new configuration option, as below: + +```python +def _on_install(self, event: ops.InstallEvent): + """Handle install event.""" + self.unit.status = ops.MaintenanceStatus("Installing microsample snap") + channel = self.config.get('channel') + if channel in ('beta', 'edge', 'candidate', 'stable'): + os.system(f"snap install microsample --{channel}") + self.unit.status = ops.ActiveStatus("Ready") + else: + self.unit.status = ops.BlockedStatus("Invalid channel configured.") +``` + +Now, in your Multipass VM shell, inside your project directory, pack the charm, refresh it in the Juju model, and inspect the results: + +```text + +# Pack the charm: +ubuntu@charm-dev:~/microsample-vm$ charmcraft pack +Created 'microsample-vm_ubuntu-22.04-amd64.charm'. +Charms packed: + microsample-vm_ubuntu-22.04-amd64.charm + +# Refresh the application from the repacked charm: +ubuntu@charm-dev:~/microsample-vm$ juju refresh microsample --path=./microsample-vm_ubuntu-22.04-amd64.charm +Added local charm "microsample-vm", revision 1, to the model + +# Verify that the new configuration option is available: +ubuntu@charm-dev:~/microsample-vm$ juju config microsample +application: microsample +application-config: + trust: + default: false + description: Does this application have access to trusted credentials + source: default + type: bool + value: false +charm: microsample-vm +settings: + channel: + default: edge + description: | + Channel for the microsample snap. + source: default + type: string + value: edge + +``` + +Back to the `src/charm.py` file, in the `__init__` function of your charm, observe the `config-changed` event and pair it with an event handler: + +```text +self.framework.observe(self.on.config_changed, self._on_config_changed) +``` + + +Next, in the body of the charm definition, define the event handler, as below: + +```python +def _on_config_changed(self, event: ops.ConfigChangedEvent): + channel = self.config.get('channel') + if channel in ('beta', 'edge', 'candidate', 'stable'): + os.system(f"snap refresh microsample --{channel}") + self.unit.status = ops.ActiveStatus("Ready at '%s'" % channel) + else: + self.unit.status = ops.BlockedStatus("Invalid channel configured.") +``` + +Now, in your Multipass VM shell, inside your project directory, pack the charm, refresh it in the Juju model, and inspect the results: + +```text +# Pack the charm: +ubuntu@charm-dev:~/microsample-vm$ charmcraft pack +Created 'microsample-vm_ubuntu-22.04-amd64.charm'. +Charms packed: + microsample-vm_ubuntu-22.04-amd64.charm + +# Refresh the application: +ubuntu@charm-dev:~/microsample-vm$ juju refresh microsample --path=./microsample-vm_ubuntu-22.04-amd64.charm +Added local charm "microsample-vm", revision 2, to the model + +# Change the 'channel' config to 'beta': +ubuntu@charm-dev:~/microsample-vm$ juju config microsample channel=beta + +# Inspect the Message column +# ('Ready at beta' is what we expect to see if the snap channel has been changed to 'beta'): +ubuntu@charm-dev:~/microsample-vm$ juju status +Model Controller Cloud/Region Version SLA Timestamp +welcome-lxd lxd localhost/localhost 3.1.6 unsupported 13:54:53+01:00 + +App Version Status Scale Charm Channel Rev Exposed Message +microsample active 1 microsample-vm 2 no Ready at 'beta' + +Unit Workload Agent Machine Public address Ports Message +microsample/0* active idle 1 10.122.219.101 Ready at 'beta' + +Machine State Address Inst id Base AZ Message +1 started 10.122.219.101 juju-f25b73-1 ubuntu@22.04 Running +``` + +Congratulations, your charm users can now deploy the application from a specific channel! + +> See more: {ref}`manage-configurations` + + +## Enable `juju status` with `App Version` + +Let's evolve our charm so that a user can see which version of the application has been installed simply by running `juju status`! + +In your local editor, update the `requirements.txt` file as below (you'll have to add the `requests` and `requests-unixsocket` lines): + +```text +ops ~= 2.5 +requests==2.28.1 +requests-unixsocket==0.3.0 +``` + + + +Then, in your `src/charm.py` file, import the `requests_unixsocket` package, update the `_on_config_changed` function to set the workload version to the output of a function `_getWorkloadVersion`, and define the function to retrieve the Microsample workload version from the `snapd` API via a Unix socket, as below: + +```python +#!/usr/bin/env python3 +# Copyright 2023 Ubuntu +# See LICENSE file for licensing details. + +"""Charm the application.""" + +import os +import logging +import ops +import requests_unixsocket + +logger = logging.getLogger(__name__) + + +class MicrosampleVmCharm(ops.CharmBase): + """Charm the application.""" + + def __init__(self, *args): + super().__init__(*args) + self.framework.observe(self.on.start, self._on_start) + self.framework.observe(self.on.install, self._on_install) + self.framework.observe(self.on.config_changed, self._on_config_changed) + + def _on_start(self, event: ops.StartEvent): + """Handle start event.""" + self.unit.status = ops.ActiveStatus() + + def _on_install(self, event: ops.InstallEvent): + """Handle install event.""" + self.unit.status = ops.MaintenanceStatus("Installing microsample snap") + channel = self.config.get('channel') + if channel in ('beta', 'edge', 'candidate', 'stable'): + os.system(f"snap install microsample --{channel}") + self.unit.status = ops.ActiveStatus("Ready") + else: + self.unit.status = ops.BlockedStatus("Invalid channel configured.") + + def _on_config_changed(self, event: ops.ConfigChangedEvent): + channel = self.config.get('channel') + if channel in ('beta', 'edge', 'candidate', 'stable'): + os.system(f"snap refresh microsample --{channel}") + workload_version = self._getWorkloadVersion() + self.unit.set_workload_version(workload_version) + self.unit.status = ops.ActiveStatus("Ready at '%s'" % channel) + else: + self.unit.status = ops.BlockedStatus("Invalid channel configured.") + + def _getWorkloadVersion(self): + """Get the microsample workload version from the snapd API via unix-socket""" + snap_name = "microsample" + snapd_url = f"http+unix://%2Frun%2Fsnapd.socket/v2/snaps/{snap_name}" + session = requests_unixsocket.Session() + # Use the requests library to send a GET request over the Unix domain socket + response = session.get(snapd_url) + # Check if the request was successful + if response.status_code == 200: + data = response.json() + workload_version = data["result"]["version"] + else: + workload_version = "unknown" + print(f"Failed to retrieve Snap apps. Status code: {response.status_code}") + + # Return the workload version + return workload_version + +if __name__ == "__main__": # pragma: nocover + ops.main(MicrosampleVmCharm) # type: ignore +``` + + + +Finally, in your Multipass VM shell, pack the charm, refresh it in Juju, and check the Juju status -- it should now show the version of your workload. + +```text +# Pack the charm: +ubuntu@charm-dev:~/microsample-vm$ charmcraft pack +Created 'microsample-vm_ubuntu-22.04-amd64.charm'. +Charms packed: + microsample-vm_ubuntu-22.04-amd64.charm + +# Refresh the application: +ubuntu@charm-dev:~/microsample-vm$ juju refresh microsample --path=./microsample-vm_ubuntu-22.04-amd64.charm +Added local charm "microsample-vm", revision 3, to the model + +# Verify that the App Version now shows the version: +ubuntu@charm-dev:~/microsample-vm$ juju status +Model Controller Cloud/Region Version SLA Timestamp +welcome-lxd lxd localhost/localhost 3.1.6 unsupported 14:04:39+01:00 + +App Version Status Scale Charm Channel Rev Exposed Message +microsample 0+git.49ff7aa active 1 microsample-vm 3 no Ready at 'beta' + +Unit Workload Agent Machine Public address Ports Message +microsample/0* active idle 1 10.122.219.101 Ready at 'beta' + +Machine State Address Inst id Base AZ Message +1 started 10.122.219.101 juju-f25b73-1 ubuntu@22.04 Running +``` + +Congratulations, your charm user can view the version of the workload deployed from your charm! + + + +## Tear things down + +> See [Juju | Tear down your development environment automatically](https://juju.is/docs/juju/set-up--tear-down-your-test-environment#tear-down-automatically) + + + +(tutorial-machines-next-steps)= +## Next steps + +By the end of this tutorial you will have built a machine charm and evolved it in a number of typical ways. But there is a lot more to explore: + +| If you are wondering... | visit... | +|-------------------------|----------------------| +| "How do I...?" | {ref}`how-to-guides` | +| "What is...?" | {ref}`reference` | +| "Why...?", "So what?" | {ref}`explanation` | + + diff --git a/test/test_infra.py b/test/test_infra.py index 47eaec39a..0e4ac0ced 100644 --- a/test/test_infra.py +++ b/test/test_infra.py @@ -76,7 +76,10 @@ def test_ops_testing_doc(): expected_names.add('Container') found_names: typing.Set[str] = set() - for test_doc in ('docs/harness.rst', 'docs/state-transition-testing.rst'): + for test_doc in ( + 'docs/reference/ops-testing-harness.rst', + 'docs/reference/ops-testing.rst', + ): with open(test_doc) as testing_doc: found_names.update({ line.split(prefix, 1)[1].strip()