diff --git a/docs/resources/integrate_your_charm_with_postgresql.png b/docs/resources/integrate_your_charm_with_postgresql.png deleted file mode 100644 index c17393597..000000000 Binary files a/docs/resources/integrate_your_charm_with_postgresql.png and /dev/null differ 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 index 9dbb1d1dc..9cf5cc946 100644 --- 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 @@ -84,7 +84,7 @@ To do so, we need to update our charm `src/charm.py` to do all of the following: ### Import the database interface libraries -First, at the top of the file, import the database interfaces library: +At the top of `src/charm.py`, import the database interfaces library: ```python # Import the 'data_interfaces' library. @@ -126,7 +126,7 @@ 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`). +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 @@ -135,29 +135,54 @@ Next, in the `__init__` method, define a new instance of the 'DatabaseRequires' self.database = DatabaseRequires(self, relation_name='database', database_name='names_db') ``` -Now, add event observers for all the database events: +Next, 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) +framework.observe(self.database.on.database_created, self._on_database_endpoint) +framework.observe(self.database.on.endpoints_changed, self._on_database_endpoint) ``` +Finally, define the method that is called on the database events: + +```python +def _on_database_endpoint( + self, _: DatabaseCreatedEvent | DatabaseEndpointsChangedEvent +) -> None: + """Event is fired when postgres database is created or endpoint is changed.""" + self._replan_workload() +``` + +We now need to make sure that our application knows how to access the database. + ### 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. +Our application consumes database authentication data in the form of environment variables. Let's define a method that prepares database authentication data in that form: + +```python +def get_app_environment(self) -> dict[str, str]: + """Return a dictionary of environment variables for the application.""" + db_data = self.fetch_postgres_relation_data() + if not db_data: + return {} + env = { + key: value + for key, value in { + '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), + }.items() + if value is not None + } + return env +``` + +This method depends on the following method, which extracts the database authentication data: ```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. - """ + """Retrieve relation data from a postgres database.""" relations = self.database.fetch_relation_data() logger.debug('Got following database data: %s', relations) for data in relations.values(): @@ -175,12 +200,14 @@ def fetch_postgres_relation_data(self) -> dict[str, str]: return {} ``` -### Share the authentication information with your application +### Share the authentication data 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. Update the `_update_layer_and_restart()` method to read in the environment and pass it in when creating the Pebble layer: +Let's change the Pebble service definition to include a dynamic `environment` key. + +First, update `_replan_workload()` to provide environment variables when creating the Pebble layer: ```python -def _update_layer_and_restart(self) -> None: +def _replan_workload(self) -> None: """Define and start a workload using the Pebble API. You'll need to specify the right entrypoint and environment @@ -216,9 +243,9 @@ def _update_layer_and_restart(self) -> None: logger.info('Unable to connect to Pebble: %s', e) ``` -We've also removed three `self.unit.status = ` lines. We'll handle replacing those shortly. +We removed three `self.unit.status = ` lines from this version of the method. We'll handle replacing those shortly. -Now, update your `_get_pebble_layer()` method to use the passed environment: +Next, update `_get_pebble_layer()` to put the environment variables in the Pebble layer: ```python def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebble.Layer: @@ -247,47 +274,14 @@ def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebbl return ops.pebble.Layer(pebble_layer) ``` -Now, let's define this method 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 -def get_app_environment(self) -> dict[str, str]: - """Prepare environment variables for the application. +With these changes, we've made sure that our application knows how to access the database. - 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 = { - key: value - for key, value in { - '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), - }.items() - if value is not None - } - return env -``` - -Finally, let's define the method that is called on the database created event: - -```python -def _on_database_created( - self, _: DatabaseCreatedEvent | DatabaseEndpointsChangedEvent -) -> None: - """Event is fired when postgres database is created or endpoint is changed.""" - self._update_layer_and_restart() -``` +When Pebble starts or restarts the service: -The diagram below illustrates the workflow for the case where the database relation exists and for the case where it does not: +* If there's a database relation and database authentication data is available from the relation, our application can get the database authentication data from environment variables. +* Otherwise, the service environment is empty, so our application can't get database authentication data. In this case, we'd like the unit to show `blocked` or `maintenance` status, depending on whether the Juju user needs to take action. -![Integrate your charm with PostgreSQL](../../resources/integrate_your_charm_with_postgresql.png) +We'll now make sure that the unit status is set correctly. (integrate-your-charm-with-postgresql-update-unit-status)= ## Update the unit status to reflect the relation state @@ -328,7 +322,7 @@ def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: 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`). If you missed doing so above, in `_update_layer_and_restart`, remove the lines: +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`). If you missed doing so above, in `_replan_workload`, remove the lines: ```python self.unit.status = ops.ActiveStatus() 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 index 6afa1e720..02559efb3 100644 --- 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 @@ -75,10 +75,10 @@ Now, define the handler, as below. Since configuring something like a port affec ```python def _on_config_changed(self, _: ops.ConfigChangedEvent) -> None: - self._update_layer_and_restart() + self._replan_workload() ``` -We'll define `_update_layer_and_restart` shortly. +We'll define `_replan_workload` shortly. ```{caution} @@ -95,7 +95,7 @@ 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: +def _replan_workload(self) -> None: """Define and start a workload using the Pebble API. You'll need to specify the right entrypoint and environment @@ -130,7 +130,7 @@ def _update_layer_and_restart(self) -> None: self.unit.status = ops.MaintenanceStatus('Waiting for Pebble in workload container') ``` -When the config is loaded as part of creating the Pebble layer, if the config is invalid (in our case, if the port is set to 22), then a `ValueError` will be raised. The `_update_layer_and_restart` method handles that by logging the error and setting the status of the unit to blocked, letting the Juju user know that they need to take action. +When the config is loaded as part of creating the Pebble layer, if the config is invalid (in our case, if the port is set to 22), then a `ValueError` will be raised. The `_replan_workload` method handles that by logging the error and setting the status of the unit to blocked, letting the Juju user know that they need to take action. Now, crucially, update the `_get_pebble_layer` method to make the layer definition dynamic, as shown below. This will replace the static port `8000` with the port passed to the method. @@ -160,11 +160,11 @@ def _get_pebble_layer(self, port: int) -> ops.pebble.Layer: return ops.pebble.Layer(pebble_layer) ``` -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: +As you may have noticed, the new `_replan_workload` 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 `_replan_workload` like this: ```python def _on_demo_server_pebble_ready(self, _: ops.PebbleReadyEvent) -> None: - self._update_layer_and_restart() + self._replan_workload() ``` ## Validate your charm diff --git a/examples/k8s-2-configurable/src/charm.py b/examples/k8s-2-configurable/src/charm.py index c088fe53b..0fc76e678 100755 --- a/examples/k8s-2-configurable/src/charm.py +++ b/examples/k8s-2-configurable/src/charm.py @@ -53,12 +53,12 @@ def __init__(self, framework: ops.Framework) -> None: framework.observe(self.on.config_changed, self._on_config_changed) def _on_demo_server_pebble_ready(self, _: ops.PebbleReadyEvent) -> None: - self._update_layer_and_restart() + self._replan_workload() def _on_config_changed(self, _: ops.ConfigChangedEvent) -> None: - self._update_layer_and_restart() + self._replan_workload() - def _update_layer_and_restart(self) -> None: + def _replan_workload(self) -> None: """Define and start a workload using the Pebble API. You'll need to specify the right entrypoint and environment diff --git a/examples/k8s-3-postgresql/src/charm.py b/examples/k8s-3-postgresql/src/charm.py index eab79f255..b4a48f723 100755 --- a/examples/k8s-3-postgresql/src/charm.py +++ b/examples/k8s-3-postgresql/src/charm.py @@ -66,14 +66,14 @@ def __init__(self, framework: ops.Framework) -> None: # The 'database_name' is the name of the database that our application requires. self.database = DatabaseRequires(self, relation_name='database', database_name='names_db') # 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) + framework.observe(self.database.on.database_created, self._on_database_endpoint) + framework.observe(self.database.on.endpoints_changed, self._on_database_endpoint) def _on_demo_server_pebble_ready(self, _: ops.PebbleReadyEvent) -> None: - self._update_layer_and_restart() + self._replan_workload() def _on_config_changed(self, _: ops.ConfigChangedEvent) -> None: - self._update_layer_and_restart() + self._replan_workload() def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: try: @@ -96,13 +96,13 @@ def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: # If nothing is wrong, then the status is active. event.add_status(ops.ActiveStatus()) - def _on_database_created( + def _on_database_endpoint( self, _: DatabaseCreatedEvent | DatabaseEndpointsChangedEvent ) -> None: """Event is fired when postgres database is created or endpoint is changed.""" - self._update_layer_and_restart() + self._replan_workload() - def _update_layer_and_restart(self) -> None: + def _replan_workload(self) -> None: """Define and start a workload using the Pebble API. You'll need to specify the right entrypoint and environment @@ -163,14 +163,7 @@ def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebbl return ops.pebble.Layer(pebble_layer) def get_app_environment(self) -> dict[str, str]: - """Prepare environment variables for the application. - - 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. - """ + """Return a dictionary of environment variables for the application.""" db_data = self.fetch_postgres_relation_data() if not db_data: return {} @@ -187,15 +180,7 @@ def get_app_environment(self) -> dict[str, str]: return env 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. - """ + """Retrieve relation data from a postgres database.""" relations = self.database.fetch_relation_data() logger.debug('Got following database data: %s', relations) for data in relations.values(): diff --git a/examples/k8s-4-action/src/charm.py b/examples/k8s-4-action/src/charm.py index 212359952..da6d50960 100755 --- a/examples/k8s-4-action/src/charm.py +++ b/examples/k8s-4-action/src/charm.py @@ -75,16 +75,16 @@ def __init__(self, framework: ops.Framework) -> None: # The 'database_name' is the name of the database that our application requires. self.database = DatabaseRequires(self, relation_name='database', database_name='names_db') # 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) + framework.observe(self.database.on.database_created, self._on_database_endpoint) + framework.observe(self.database.on.endpoints_changed, self._on_database_endpoint) # Events on charm actions that are run via 'juju run'. framework.observe(self.on.get_db_info_action, self._on_get_db_info_action) def _on_demo_server_pebble_ready(self, _: ops.PebbleReadyEvent) -> None: - self._update_layer_and_restart() + self._replan_workload() def _on_config_changed(self, _: ops.ConfigChangedEvent) -> None: - self._update_layer_and_restart() + self._replan_workload() def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: try: @@ -107,11 +107,11 @@ def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: # If nothing is wrong, then the status is active. event.add_status(ops.ActiveStatus()) - def _on_database_created( + def _on_database_endpoint( self, _: DatabaseCreatedEvent | DatabaseEndpointsChangedEvent ) -> None: """Event is fired when postgres database is created.""" - self._update_layer_and_restart() + self._replan_workload() def _on_get_db_info_action(self, event: ops.ActionEvent) -> None: """Return information about the integrated database. @@ -143,7 +143,7 @@ def _on_get_db_info_action(self, event: ops.ActionEvent) -> None: ) event.set_results(output) - def _update_layer_and_restart(self) -> None: + def _replan_workload(self) -> None: """Define and start a workload using the Pebble API. You'll need to specify the right entrypoint and environment @@ -204,14 +204,7 @@ def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebbl return ops.pebble.Layer(pebble_layer) def get_app_environment(self) -> dict[str, str]: - """Prepare environment variables for the application. - - 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. - """ + """Return a dictionary of environment variables for the application.""" db_data = self.fetch_postgres_relation_data() if not db_data: return {} @@ -228,15 +221,7 @@ def get_app_environment(self) -> dict[str, str]: return env 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. - """ + """Retrieve relation data from a postgres database.""" relations = self.database.fetch_relation_data() logger.debug('Got following database data: %s', relations) for data in relations.values(): diff --git a/examples/k8s-5-observe/src/charm.py b/examples/k8s-5-observe/src/charm.py index 44dfe5eeb..f1d1c71dc 100755 --- a/examples/k8s-5-observe/src/charm.py +++ b/examples/k8s-5-observe/src/charm.py @@ -78,8 +78,8 @@ def __init__(self, framework: ops.Framework) -> None: # The 'database_name' is the name of the database that our application requires. self.database = DatabaseRequires(self, relation_name='database', database_name='names_db') # 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) + framework.observe(self.database.on.database_created, self._on_database_endpoint) + framework.observe(self.database.on.endpoints_changed, self._on_database_endpoint) # Events on charm actions that are run via 'juju run'. framework.observe(self.on.get_db_info_action, self._on_get_db_info_action) # Enable pushing application logs to Loki. @@ -102,10 +102,10 @@ def __init__(self, framework: ops.Framework) -> None: ) def _on_demo_server_pebble_ready(self, _: ops.PebbleReadyEvent) -> None: - self._update_layer_and_restart() + self._replan_workload() def _on_config_changed(self, _: ops.ConfigChangedEvent) -> None: - self._update_layer_and_restart() + self._replan_workload() def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: try: @@ -128,11 +128,11 @@ def _on_collect_status(self, event: ops.CollectStatusEvent) -> None: # If nothing is wrong, then the status is active. event.add_status(ops.ActiveStatus()) - def _on_database_created( + def _on_database_endpoint( self, _: DatabaseCreatedEvent | DatabaseEndpointsChangedEvent ) -> None: """Event is fired when postgres database is created.""" - self._update_layer_and_restart() + self._replan_workload() def _on_get_db_info_action(self, event: ops.ActionEvent) -> None: """Return information about the integrated database. @@ -164,7 +164,7 @@ def _on_get_db_info_action(self, event: ops.ActionEvent) -> None: ) event.set_results(output) - def _update_layer_and_restart(self) -> None: + def _replan_workload(self) -> None: """Define and start a workload using the Pebble API. You'll need to specify the right entrypoint and environment @@ -225,14 +225,7 @@ def _get_pebble_layer(self, port: int, environment: dict[str, str]) -> ops.pebbl return ops.pebble.Layer(pebble_layer) def get_app_environment(self) -> dict[str, str]: - """Prepare environment variables for the application. - - 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. - """ + """Return a dictionary of environment variables for the application.""" db_data = self.fetch_postgres_relation_data() if not db_data: return {} @@ -249,15 +242,7 @@ def get_app_environment(self) -> dict[str, str]: return env 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. - """ + """Retrieve relation data from a postgres database.""" relations = self.database.fetch_relation_data() logger.debug('Got following database data: %s', relations) for data in relations.values():