diff --git a/examples/django/charm/charmcraft.yaml b/examples/django/charm/charmcraft.yaml index f9e498c..05bf5df 100644 --- a/examples/django/charm/charmcraft.yaml +++ b/examples/django/charm/charmcraft.yaml @@ -48,6 +48,14 @@ config: for any other security related needs by your Django application. This configuration will set the DJANGO_SECRET_KEY environment variable. type: string + django-secret-key-id: + description: >- + This configuration is similar to `django-secret-key`, but instead accepts a Juju user secret ID. + The secret should contain a single key, "value", which maps to the actual Django secret key. + To create the secret, run the following command: + `juju add-secret my-django-secret-key value= && juju grant-secret my-django-secret-key django-k8s`, + and use the outputted secret ID to configure this option. + type: secret webserver-keepalive: description: Time in seconds for webserver to wait for requests on a Keep-Alive connection. diff --git a/examples/fastapi/charm/charmcraft.yaml b/examples/fastapi/charm/charmcraft.yaml index 4f743f5..fc19128 100644 --- a/examples/fastapi/charm/charmcraft.yaml +++ b/examples/fastapi/charm/charmcraft.yaml @@ -47,6 +47,14 @@ config: type: string description: Long secret you can use for sessions, csrf or any other thing where you need a random secret shared by all units + app-secret-key-id: + type: secret + description: >- + This configuration is similar to `app-secret-key`, but instead accepts a Juju user secret ID. + The secret should contain a single key, "value", which maps to the actual secret key. + To create the secret, run the following command: + `juju add-secret my-secret-key value= && juju grant-secret my-secret-key fastapi-k8s`, + and use the outputted secret ID to configure this option. user-defined-config: type: string description: Example of a user defined configuration. diff --git a/examples/flask/charmcraft.yaml b/examples/flask/charmcraft.yaml index ca7894f..292dba0 100644 --- a/examples/flask/charmcraft.yaml +++ b/examples/flask/charmcraft.yaml @@ -55,6 +55,14 @@ config: will set the FLASK_SECRET_KEY environment variable. Run `app.config.from_prefixed_env()` in your Flask application in order to receive this configuration. type: string + flask-secret-key-id: + description: >- + This configuration is similar to `flask-secret-key`, but instead accepts a Juju user secret ID. + The secret should contain a single key, "value", which maps to the actual Flask secret key. + To create the secret, run the following command: + `juju add-secret my-flask-secret-key value= && juju grant-secret my-flask-secret-key flask-k8s`, + and use the outputted secret ID to configure this option. + type: secret flask-session-cookie-secure: description: Set the secure attribute in the Flask application cookies. This configuration will set the FLASK_SESSION_COOKIE_SECURE environment variable. @@ -74,6 +82,9 @@ config: webserver-workers: description: The number of webserver worker processes for handling requests. type: int + secret-test: + description: A test configuration option for testing user provided Juju secrets. + type: secret containers: flask-app: resource: flask-app-image diff --git a/examples/go/charm/charmcraft.yaml b/examples/go/charm/charmcraft.yaml index 54d1cc3..01e6585 100644 --- a/examples/go/charm/charmcraft.yaml +++ b/examples/go/charm/charmcraft.yaml @@ -38,6 +38,14 @@ config: type: string description: Long secret you can use for sessions, csrf or any other thing where you need a random secret shared by all units + app-secret-key-id: + type: secret + description: >- + This configuration is similar to `app-secret-key`, but instead accepts a Juju user secret ID. + The secret should contain a single key, "value", which maps to the actual secret key. + To create the secret, run the following command: + `juju add-secret my-secret-key value= && juju grant-secret my-secret-key go-k8s`, + and use the outputted secret ID to configure this option. user-defined-config: type: string description: Example of a user defined configuration. diff --git a/paas_app_charmer/app.py b/paas_app_charmer/app.py index e547260..f13181e 100644 --- a/paas_app_charmer/app.py +++ b/paas_app_charmer/app.py @@ -2,7 +2,7 @@ # See LICENSE file for licensing details. """Provide the base generic class to represent the application.""" - +import collections import json import logging import pathlib @@ -133,9 +133,16 @@ def gen_environment(self) -> dict[str, str]: Returns: A dictionary representing the application environment variables. """ - config = self._charm_state.app_config prefix = self.configuration_prefix - env = {f"{prefix}{k.upper()}": encode_env(v) for k, v in config.items()} + env = {} + for app_config_key, app_config_value in self._charm_state.app_config.items(): + if isinstance(app_config_value, collections.abc.Mapping): + for k, v in app_config_value.items(): + env[f"{prefix}{app_config_key.upper()}_{k.replace('-', '_').upper()}"] = ( + encode_env(v) + ) + else: + env[f"{prefix}{app_config_key.upper()}"] = encode_env(app_config_value) framework_config = self._charm_state.framework_config framework_config_prefix = self.framework_config_prefix diff --git a/paas_app_charmer/charm.py b/paas_app_charmer/charm.py index 510a57c..a74f30a 100644 --- a/paas_app_charmer/charm.py +++ b/paas_app_charmer/charm.py @@ -4,6 +4,7 @@ """The base charm class for all application charms.""" import abc import logging +import typing import ops from charms.data_platform_libs.v0.data_interfaces import DatabaseRequiresEvent @@ -21,7 +22,7 @@ from paas_app_charmer.observability import Observability from paas_app_charmer.rabbitmq import RabbitMQRequires from paas_app_charmer.secret_storage import KeySecretStorage -from paas_app_charmer.utils import build_validation_error_message +from paas_app_charmer.utils import build_validation_error_message, config_get_with_secret logger = logging.getLogger(__name__) @@ -143,6 +144,7 @@ def __init__(self, framework: ops.Framework, framework_name: str) -> None: self._on_secret_storage_relation_changed, ) self.framework.observe(self.on.update_status, self._on_update_status) + self.framework.observe(self.on.secret_changed, self._on_secret_changed) for database, database_requirer in self._database_requirers.items(): self.framework.observe( database_requirer.on.database_created, @@ -173,7 +175,14 @@ def get_framework_config(self) -> BaseModel: """ # Will raise an AttributeError if it the attribute framework_config_class does not exist. framework_config_class = self.framework_config_class - config = dict(self.config.items()) + charm_config = {k: config_get_with_secret(self, k) for k in self.config.keys()} + config = typing.cast( + dict, + { + k: v.get_content(refresh=True) if isinstance(v, ops.Secret) else v + for k, v in charm_config.items() + }, + ) try: return framework_config_class.model_validate(config) except ValidationError as exc: @@ -186,12 +195,13 @@ def _container(self) -> Container: return self.unit.get_container(self._workload_config.container_name) @block_if_invalid_config - def _on_config_changed(self, _event: ops.EventBase) -> None: - """Configure the application pebble service layer. + def _on_config_changed(self, _: ops.EventBase) -> None: + """Configure the application pebble service layer.""" + self.restart() - Args: - _event: the config-changed event that triggers this callback function. - """ + @block_if_invalid_config + def _on_secret_changed(self, _: ops.EventBase) -> None: + """Configure the application Pebble service layer.""" self.restart() @block_if_invalid_config @@ -212,12 +222,8 @@ def _on_rotate_secret_key_action(self, event: ops.ActionEvent) -> None: self.restart() @block_if_invalid_config - def _on_secret_storage_relation_changed(self, _event: ops.RelationEvent) -> None: - """Handle the secret-storage-relation-changed event. - - Args: - _event: the action event that triggers this callback. - """ + def _on_secret_storage_relation_changed(self, _: ops.RelationEvent) -> None: + """Handle the secret-storage-relation-changed event.""" self.restart() def update_app_and_unit_status(self, status: ops.StatusBase) -> None: @@ -328,9 +334,16 @@ def _create_charm_state(self) -> CharmState: saml_relation_data = None if self._saml and (saml_data := self._saml.get_relation_data()): saml_relation_data = saml_data.to_relation_data() - + charm_config = {k: config_get_with_secret(self, k) for k in self.config.keys()} + config = typing.cast( + dict, + { + k: v.get_content(refresh=True) if isinstance(v, ops.Secret) else v + for k, v in charm_config.items() + }, + ) return CharmState.from_charm( - charm=self, + config=config, framework=self._framework_name, framework_config=self.get_framework_config(), secret_storage=self._secret_storage, @@ -360,67 +373,67 @@ def _on_update_status(self, _: ops.HookEvent) -> None: self.restart() @block_if_invalid_config - def _on_mysql_database_database_created(self, _event: DatabaseRequiresEvent) -> None: + def _on_mysql_database_database_created(self, _: DatabaseRequiresEvent) -> None: """Handle mysql's database-created event.""" self.restart() @block_if_invalid_config - def _on_mysql_database_endpoints_changed(self, _event: DatabaseRequiresEvent) -> None: + def _on_mysql_database_endpoints_changed(self, _: DatabaseRequiresEvent) -> None: """Handle mysql's endpoints-changed event.""" self.restart() @block_if_invalid_config - def _on_mysql_database_relation_broken(self, _event: ops.RelationBrokenEvent) -> None: + def _on_mysql_database_relation_broken(self, _: ops.RelationBrokenEvent) -> None: """Handle mysql's relation-broken event.""" self.restart() @block_if_invalid_config - def _on_postgresql_database_database_created(self, _event: DatabaseRequiresEvent) -> None: + def _on_postgresql_database_database_created(self, _: DatabaseRequiresEvent) -> None: """Handle postgresql's database-created event.""" self.restart() @block_if_invalid_config - def _on_postgresql_database_endpoints_changed(self, _event: DatabaseRequiresEvent) -> None: + def _on_postgresql_database_endpoints_changed(self, _: DatabaseRequiresEvent) -> None: """Handle mysql's endpoints-changed event.""" self.restart() @block_if_invalid_config - def _on_postgresql_database_relation_broken(self, _event: ops.RelationBrokenEvent) -> None: + def _on_postgresql_database_relation_broken(self, _: ops.RelationBrokenEvent) -> None: """Handle postgresql's relation-broken event.""" self.restart() @block_if_invalid_config - def _on_mongodb_database_database_created(self, _event: DatabaseRequiresEvent) -> None: + def _on_mongodb_database_database_created(self, _: DatabaseRequiresEvent) -> None: """Handle mongodb's database-created event.""" self.restart() @block_if_invalid_config - def _on_mongodb_database_endpoints_changed(self, _event: DatabaseRequiresEvent) -> None: + def _on_mongodb_database_endpoints_changed(self, _: DatabaseRequiresEvent) -> None: """Handle mysql's endpoints-changed event.""" self.restart() @block_if_invalid_config - def _on_mongodb_database_relation_broken(self, _event: ops.RelationBrokenEvent) -> None: + def _on_mongodb_database_relation_broken(self, _: ops.RelationBrokenEvent) -> None: """Handle postgresql's relation-broken event.""" self.restart() @block_if_invalid_config - def _on_redis_relation_updated(self, _event: DatabaseRequiresEvent) -> None: + def _on_redis_relation_updated(self, _: DatabaseRequiresEvent) -> None: """Handle redis's database-created event.""" self.restart() @block_if_invalid_config - def _on_s3_credential_changed(self, _event: ops.HookEvent) -> None: + def _on_s3_credential_changed(self, _: ops.HookEvent) -> None: """Handle s3 credentials-changed event.""" self.restart() @block_if_invalid_config - def _on_s3_credential_gone(self, _event: ops.HookEvent) -> None: + def _on_s3_credential_gone(self, _: ops.HookEvent) -> None: """Handle s3 credentials-gone event.""" self.restart() @block_if_invalid_config - def _on_saml_data_available(self, _event: ops.HookEvent) -> None: + def _on_saml_data_available(self, _: ops.HookEvent) -> None: """Handle saml data available event.""" self.restart() @@ -440,16 +453,16 @@ def _on_pebble_ready(self, _: ops.PebbleReadyEvent) -> None: self.restart() @block_if_invalid_config - def _on_rabbitmq_connected(self, _event: ops.HookEvent) -> None: + def _on_rabbitmq_connected(self, _: ops.HookEvent) -> None: """Handle rabbitmq connected event.""" self.restart() @block_if_invalid_config - def _on_rabbitmq_ready(self, _event: ops.HookEvent) -> None: + def _on_rabbitmq_ready(self, _: ops.HookEvent) -> None: """Handle rabbitmq ready event.""" self.restart() @block_if_invalid_config - def _on_rabbitmq_departed(self, _event: ops.HookEvent) -> None: + def _on_rabbitmq_departed(self, _: ops.HookEvent) -> None: """Handle rabbitmq departed event.""" self.restart() diff --git a/paas_app_charmer/charm_state.py b/paas_app_charmer/charm_state.py index d320a9c..022450b 100644 --- a/paas_app_charmer/charm_state.py +++ b/paas_app_charmer/charm_state.py @@ -9,7 +9,6 @@ from dataclasses import dataclass, field from typing import Optional -import ops from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires from pydantic import BaseModel, Extra, Field, ValidationError, ValidationInfo, field_validator @@ -52,7 +51,7 @@ def __init__( # pylint: disable=too-many-arguments *, framework: str, is_secret_storage_ready: bool, - app_config: dict[str, int | str | bool] | None = None, + app_config: dict[str, int | str | bool | dict[str, str]] | None = None, framework_config: dict[str, int | str] | None = None, secret_key: str | None = None, integrations: "IntegrationsState | None" = None, @@ -81,7 +80,7 @@ def __init__( # pylint: disable=too-many-arguments def from_charm( # pylint: disable=too-many-arguments cls, *, - charm: ops.CharmBase, + config: dict[str, bool | int | float | str | dict[str, str]], framework: str, framework_config: BaseModel, secret_storage: KeySecretStorage, @@ -95,7 +94,7 @@ def from_charm( # pylint: disable=too-many-arguments """Initialize a new instance of the CharmState class from the associated charm. Args: - charm: The charm instance associated with this state. + config: The charm configuration. framework: The framework name. framework_config: The framework specific configurations. secret_storage: The secret storage manager associated with the charm. @@ -111,7 +110,7 @@ def from_charm( # pylint: disable=too-many-arguments """ app_config = { k.replace("-", "_"): v - for k, v in charm.config.items() + for k, v in config.items() if not any(k.startswith(prefix) for prefix in (f"{framework}-", "webserver-", "app-")) } app_config = { @@ -128,7 +127,7 @@ def from_charm( # pylint: disable=too-many-arguments return cls( framework=framework, framework_config=framework_config.dict(exclude_none=True), - app_config=typing.cast(dict[str, str | int | bool], app_config), + app_config=typing.cast(dict[str, str | int | bool | dict[str, str]], app_config), secret_key=( secret_storage.get_secret_key() if secret_storage.is_initialized else None ), @@ -163,7 +162,7 @@ def framework_config(self) -> dict[str, str | int | bool]: return self._framework_config @property - def app_config(self) -> dict[str, str | int | bool]: + def app_config(self) -> dict[str, str | int | bool | dict[str, str]]: """Get the value of user-defined application configurations. Returns: diff --git a/paas_app_charmer/django/charm.py b/paas_app_charmer/django/charm.py index d492721..8f72005 100644 --- a/paas_app_charmer/django/charm.py +++ b/paas_app_charmer/django/charm.py @@ -8,14 +8,15 @@ import typing import ops -from pydantic import BaseModel, Extra, Field, validator +from pydantic import ConfigDict, Field, validator from paas_app_charmer._gunicorn.charm import GunicornBase +from paas_app_charmer.framework import FrameworkConfig logger = logging.getLogger(__name__) -class DjangoConfig(BaseModel, extra=Extra.ignore): +class DjangoConfig(FrameworkConfig): """Represent Django builtin configuration values. Attrs: @@ -23,12 +24,15 @@ class DjangoConfig(BaseModel, extra=Extra.ignore): secret_key: a secret key that will be used for security related needs by your Django application. allowed_hosts: a list of host/domain names that this Django site can serve. + model_config: Pydantic model configuration. """ debug: bool | None = Field(alias="django-debug", default=None) secret_key: str | None = Field(alias="django-secret-key", default=None, min_length=1) allowed_hosts: str | None = Field(alias="django-allowed-hosts", default=[]) + model_config = ConfigDict(extra="ignore") + @validator("allowed_hosts") @classmethod def allowed_hosts_to_list(cls, value: str | None) -> typing.List[str]: diff --git a/paas_app_charmer/fastapi/charm.py b/paas_app_charmer/fastapi/charm.py index 744852a..673080b 100644 --- a/paas_app_charmer/fastapi/charm.py +++ b/paas_app_charmer/fastapi/charm.py @@ -7,13 +7,14 @@ import typing import ops -from pydantic import BaseModel, Extra, Field +from pydantic import ConfigDict, Field from paas_app_charmer.app import App, WorkloadConfig from paas_app_charmer.charm import PaasCharm +from paas_app_charmer.framework import FrameworkConfig -class FastAPIConfig(BaseModel, extra=Extra.ignore): +class FastAPIConfig(FrameworkConfig): """Represent FastAPI builtin configuration values. Attrs: @@ -25,6 +26,7 @@ class FastAPIConfig(BaseModel, extra=Extra.ignore): metrics_path: path where the metrics are collected app_secret_key: a secret key that will be used for securely signing the session cookie and can be used for any other security related needs by your Flask application. + model_config: Pydantic model configuration. """ uvicorn_port: int = Field(alias="webserver-port", default=8080, gt=0) @@ -37,6 +39,8 @@ class FastAPIConfig(BaseModel, extra=Extra.ignore): metrics_path: str | None = Field(alias="metrics-path", default=None, min_length=1) app_secret_key: str | None = Field(alias="app-secret-key", default=None, min_length=1) + model_config = ConfigDict(extra="ignore") + class Charm(PaasCharm): """FastAPI Charm service. diff --git a/paas_app_charmer/flask/charm.py b/paas_app_charmer/flask/charm.py index 69fb6a1..ca83a31 100644 --- a/paas_app_charmer/flask/charm.py +++ b/paas_app_charmer/flask/charm.py @@ -6,14 +6,15 @@ import pathlib import ops -from pydantic import BaseModel, Extra, Field, field_validator +from pydantic import ConfigDict, Field, field_validator from paas_app_charmer._gunicorn.charm import GunicornBase +from paas_app_charmer.framework import FrameworkConfig logger = logging.getLogger(__name__) -class FlaskConfig(BaseModel, extra=Extra.ignore): +class FlaskConfig(FrameworkConfig): """Represent Flask builtin configuration values. Attrs: @@ -28,6 +29,7 @@ class FlaskConfig(BaseModel, extra=Extra.ignore): session_cookie_secure: set the secure attribute in the Flask application cookies. preferred_url_scheme: use this scheme for generating external URLs when not in a request context in the Flask application. + model_config: Pydantic model configuration. """ env: str | None = Field(alias="flask-env", default=None, min_length=1) @@ -43,6 +45,7 @@ class FlaskConfig(BaseModel, extra=Extra.ignore): preferred_url_scheme: str | None = Field( alias="flask-preferred-url-scheme", default=None, pattern="(?i)^(HTTP|HTTPS)$" ) + model_config = ConfigDict(extra="ignore") @field_validator("preferred_url_scheme") @staticmethod diff --git a/paas_app_charmer/framework.py b/paas_app_charmer/framework.py new file mode 100644 index 0000000..6799bf5 --- /dev/null +++ b/paas_app_charmer/framework.py @@ -0,0 +1,49 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. + +"""Framework related base classes.""" +import typing + +import pydantic + + +class FrameworkConfig(pydantic.BaseModel): + """Base class for framework config models.""" + + @pydantic.model_validator(mode="before") + @classmethod + def secret_key_id(cls, data: dict[str, str | int | bool | dict[str, str] | None]) -> dict: + """Read the *-secret-key-id style configuration. + + Args: + data: model input. + + Returns: + modified input with *-secret-key replaced by the secret content of *-secret-key-id. + + Raises: + ValueError: if the *-secret-key-id is invalid. + NotImplementedError: ill-formed subclasses. + """ + secret_key_field = "secret_key" + if secret_key_field not in cls.model_fields: + secret_key_field = "app_secret_key" + secret_key_config_name = cls.model_fields[secret_key_field].alias + if not secret_key_config_name: + raise NotImplementedError("framework configuration secret_key field has no alias") + secret_key_id_config_name = f"{secret_key_config_name}-id" + if data.get(secret_key_id_config_name): + if data.get(secret_key_config_name): + raise ValueError( + f"{secret_key_id_config_name} and {secret_key_config_name} " + "are defined in the same time" + ) + secret_value = typing.cast(dict[str, str], data[secret_key_id_config_name]) + if "value" not in secret_value: + raise ValueError( + f"{secret_key_id_config_name} missing 'value' key in the secret content" + ) + if len(secret_value) > 1: + raise ValueError(f"{secret_key_id_config_name} secret contains multiple values") + data[secret_key_config_name] = secret_value["value"] + return data diff --git a/paas_app_charmer/go/charm.py b/paas_app_charmer/go/charm.py index af11dc4..cea2678 100644 --- a/paas_app_charmer/go/charm.py +++ b/paas_app_charmer/go/charm.py @@ -7,13 +7,14 @@ import typing import ops -from pydantic import BaseModel, Extra, Field +from pydantic import ConfigDict, Field from paas_app_charmer.app import App, WorkloadConfig from paas_app_charmer.charm import PaasCharm +from paas_app_charmer.framework import FrameworkConfig -class GoConfig(BaseModel, extra=Extra.ignore): +class GoConfig(FrameworkConfig): """Represent Go builtin configuration values. Attrs: @@ -22,6 +23,7 @@ class GoConfig(BaseModel, extra=Extra.ignore): metrics_path: path where the metrics are collected secret_key: a secret key that will be used for securely signing the session cookie and can be used for any other security related needs by your Flask application. + model_config: Pydantic model configuration. """ port: int = Field(alias="app-port", default=8080, gt=0) @@ -29,6 +31,8 @@ class GoConfig(BaseModel, extra=Extra.ignore): metrics_path: str | None = Field(alias="metrics-path", default=None, min_length=1) secret_key: str | None = Field(alias="app-secret-key", default=None, min_length=1) + model_config = ConfigDict(extra="ignore") + class Charm(PaasCharm): """Go Charm service. diff --git a/paas_app_charmer/utils.py b/paas_app_charmer/utils.py index 609a50d..5197045 100644 --- a/paas_app_charmer/utils.py +++ b/paas_app_charmer/utils.py @@ -2,10 +2,14 @@ # See LICENSE file for licensing details. """Generic utility functions.""" - +import functools import itertools +import os +import pathlib +import typing import ops +import yaml from pydantic import ValidationError @@ -51,3 +55,51 @@ def enable_pebble_log_forwarding() -> bool: return True except ImportError: return False + + +@functools.lru_cache +def _config_metadata(charm_dir: pathlib.Path) -> dict: + """Get charm configuration metadata for the given charm directory. + + Args: + charm_dir: Path to the charm directory. + + Returns: + The charm configuration metadata. + + Raises: + ValueError: if the charm_dir input is invalid. + """ + config_file = charm_dir / "config.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text()) + config_file = charm_dir / "charmcraft.yaml" + if config_file.exists(): + return yaml.safe_load(config_file.read_text())["config"] + raise ValueError("charm configuration metadata doesn't exist") + + +def config_get_with_secret( + charm: ops.CharmBase, key: str +) -> str | int | bool | float | ops.Secret | None: + """Get charm configuration values. + + This function differs from ``ops.CharmBase.config.get`` in that for secret-typed configuration + options, it returns the secret object instead of the secret ID in the configuration + value. In other instances, this function is equivalent to ops.CharmBase.config.get. + + Args: + charm: The charm instance. + key: The configuration option key. + + Returns: + The configuration value. + """ + metadata = _config_metadata(pathlib.Path(os.getcwd())) + config_type = metadata["options"][key]["type"] + if config_type != "secret": + return charm.config.get(key) + secret_id = charm.config.get(key) + if secret_id is None: + return None + return charm.model.get_secret(id=typing.cast(str, secret_id)) diff --git a/tests/integration/flask/conftest.py b/tests/integration/flask/conftest.py index 6949ef6..33c3e63 100644 --- a/tests/integration/flask/conftest.py +++ b/tests/integration/flask/conftest.py @@ -187,6 +187,34 @@ async def update_config(model: Model, request: FixtureRequest, flask_app: Applic await model.wait_for_idle(apps=[flask_app.name]) +@pytest_asyncio.fixture +async def update_secret_config(model: Model, request: FixtureRequest, flask_app: Application): + """Update a secret flask application configuration. + + This fixture must be parameterized with changing charm configurations. + """ + orig_config = {k: v.get("value") for k, v in (await flask_app.get_config()).items()} + request_config = {} + for secret_config_option, secret_value in request.param.items(): + secret_id = await model.add_secret( + secret_config_option, [f"{k}={v}" for k, v in secret_value.items()] + ) + await model.grant_secret(secret_config_option, flask_app.name) + request_config[secret_config_option] = secret_id + await flask_app.set_config(request_config) + await model.wait_for_idle(apps=[flask_app.name]) + + yield request_config + + await flask_app.set_config( + {k: v for k, v in orig_config.items() if k in request_config and v is not None} + ) + await flask_app.reset_config([k for k in request_config if orig_config[k] is None]) + for secret_name in request_config: + await model.remove_secret(secret_name) + await model.wait_for_idle(apps=[flask_app.name]) + + @pytest.fixture(scope="module", name="localstack_address") def localstack_address_fixture(pytestconfig: Config): """Provides localstack IP address to be used in the integration test.""" diff --git a/tests/integration/flask/test_charm.py b/tests/integration/flask/test_charm.py index ec50ca9..a5788e5 100644 --- a/tests/integration/flask/test_charm.py +++ b/tests/integration/flask/test_charm.py @@ -122,6 +122,43 @@ async def test_flask_config( ) +@pytest.mark.parametrize( + "update_secret_config, excepted_config", + [ + pytest.param( + {"secret-test": {"bar": "bar", "foo-bar": "foo-bar"}}, + {"SECRET_TEST_BAR": "bar", "SECRET_TEST_FOO_BAR": "foo-bar"}, + id="user-secret", + ), + pytest.param( + {"flask-secret-key-id": {"value": "secret-foobar"}}, + {"SECRET_KEY": "secret-foobar"}, + id="secret_key", + ), + ], + indirect=["update_secret_config"], +) +@pytest.mark.usefixtures("update_secret_config") +async def test_flask_secret_config( + flask_app: Application, + get_unit_ips: typing.Callable[[str], typing.Awaitable[tuple[str, ...]]], + excepted_config: dict, +): + """ + arrange: build and deploy the flask charm, and change secret configurations. + act: query flask environment variables from the Flask server. + assert: the flask environment variables should match secret configuration values. + """ + for unit_ip in await get_unit_ips(flask_app.name): + for config_key, config_value in excepted_config.items(): + assert ( + requests.get( + f"http://{unit_ip}:{WORKLOAD_PORT}/config/{config_key}", timeout=10 + ).json() + == config_value + ) + + @pytest.mark.parametrize( "update_config, invalid_configs", [ diff --git a/tests/unit/django/test_charm.py b/tests/unit/django/test_charm.py index 42900d5..2c44bde 100644 --- a/tests/unit/django/test_charm.py +++ b/tests/unit/django/test_charm.py @@ -56,7 +56,7 @@ def test_django_config(harness: Harness, config: dict, env: dict) -> None: secret_storage.get_secret_key.return_value = "test" harness.update_config(config) charm_state = CharmState.from_charm( - charm=harness.charm, + config=harness.charm.config, framework="django", framework_config=harness.charm.get_framework_config(), secret_storage=secret_storage, diff --git a/tests/unit/flask/test_charm.py b/tests/unit/flask/test_charm.py index 286726c..219e288 100644 --- a/tests/unit/flask/test_charm.py +++ b/tests/unit/flask/test_charm.py @@ -45,7 +45,7 @@ def test_flask_pebble_layer(harness: Harness) -> None: secret_storage.get_secret_key.return_value = test_key charm_state = CharmState.from_charm( framework_config=Charm.get_framework_config(harness.charm), - charm=harness.charm, + config=harness.charm.config, framework="flask", secret_storage=secret_storage, database_requirers={}, diff --git a/tests/unit/flask/test_charm_state.py b/tests/unit/flask/test_charm_state.py index 26dab21..6e46d84 100644 --- a/tests/unit/flask/test_charm_state.py +++ b/tests/unit/flask/test_charm_state.py @@ -17,7 +17,7 @@ # this is a unit test file # pylint: disable=protected-access -DEFAULT_CHARM_CONFIG = {"webserver-wsgi-path": "app:app", "flask-preferred-url-scheme": "HTTPS"} +DEFAULT_CHARM_CONFIG = {"flask-preferred-url-scheme": "HTTPS"} SECRET_STORAGE_MOCK = unittest.mock.MagicMock(is_initialized=True) SECRET_STORAGE_MOCK.get_secret_key.return_value = "" @@ -57,7 +57,7 @@ def test_charm_state_flask_config(charm_config: dict, flask_config: dict) -> Non framework="flask", framework_config=Charm.get_framework_config(charm), secret_storage=SECRET_STORAGE_MOCK, - charm=charm, + config=config, database_requirers={}, ) assert charm_state.framework_config == flask_config @@ -89,7 +89,7 @@ def test_charm_state_invalid_flask_config(charm_config: dict) -> None: CharmState.from_charm( framework_config=Charm.get_framework_config(charm), secret_storage=SECRET_STORAGE_MOCK, - charm=charm, + config=config, database_requirers={}, ) for config_key in charm_config: @@ -123,7 +123,7 @@ def test_s3_integration(s3_connection_info, expected_s3_parameters): config.update(config) charm = unittest.mock.MagicMock(config=config) charm_state = CharmState.from_charm( - charm=charm, + config=config, framework_config=Charm.get_framework_config(charm), framework="flask", secret_storage=SECRET_STORAGE_MOCK, @@ -145,7 +145,7 @@ def test_s3_integration_raises(): charm = unittest.mock.MagicMock(config=config) with pytest.raises(CharmConfigInvalidError) as exc: charm_state = CharmState.from_charm( - charm=charm, + config=config, framework_config=Charm.get_framework_config(charm), framework="flask", secret_storage=SECRET_STORAGE_MOCK, @@ -187,7 +187,7 @@ def test_saml_integration(): config.update(config) charm = unittest.mock.MagicMock(config=config) charm_state = CharmState.from_charm( - charm=charm, + config=config, framework_config=Charm.get_framework_config(charm), framework="flask", secret_storage=SECRET_STORAGE_MOCK, @@ -259,7 +259,7 @@ def test_saml_integration_invalid(saml_app_relation_data, error_messages): charm = unittest.mock.MagicMock(config=config) with pytest.raises(CharmConfigInvalidError) as exc: charm_state = CharmState.from_charm( - charm=charm, + config=config, framework_config=Charm.get_framework_config(charm), framework="flask", secret_storage=SECRET_STORAGE_MOCK, @@ -268,3 +268,75 @@ def test_saml_integration_invalid(saml_app_relation_data, error_messages): ) for message in error_messages: assert message in str(exc) + + +def test_secret_configuration(): + """ + arrange: prepare a juju secret configuration. + act: set secret-test charm configurations. + assert: app_config in the charm state should contain the value of the secret configuration. + """ + config = copy.copy(DEFAULT_CHARM_CONFIG) + config["secret-test"] = {"foo": "foo", "bar": "bar", "foo-bar": "foobar"} + charm = unittest.mock.MagicMock( + config=config, + framework_config_class=Charm.framework_config_class, + ) + charm_state = CharmState.from_charm( + framework="flask", + framework_config=Charm.get_framework_config(charm), + secret_storage=SECRET_STORAGE_MOCK, + config=config, + database_requirers={}, + ) + assert "secret_test" in charm_state.app_config + assert charm_state.app_config["secret_test"] == { + "bar": "bar", + "foo": "foo", + "foo-bar": "foobar", + } + + +def test_flask_secret_key_id_no_value(): + """ + arrange: Prepare an invalid flask-secret-key-id secret. + act: Try to build CharmState. + assert: It should raise CharmConfigInvalidError. + """ + config = copy.copy(DEFAULT_CHARM_CONFIG) + config["flask-secret-key-id"] = {"value": "foobar"} + charm = unittest.mock.MagicMock( + config=config, + framework_config_class=Charm.framework_config_class, + ) + with pytest.raises(CharmConfigInvalidError) as exc: + CharmState.from_charm( + framework="flask", + framework_config=Charm.get_framework_config(charm), + secret_storage=SECRET_STORAGE_MOCK, + config=config, + database_requirers={}, + ) + + +def test_flask_secret_key_id_duplication(): + """ + arrange: Provide both the flask-secret-key-id and flask-secret-key configuration. + act: Try to build CharmState. + assert: It should raise CharmConfigInvalidError. + """ + config = copy.copy(DEFAULT_CHARM_CONFIG) + config["flask-secret-key"] = "test" + config["flask-secret-key-id"] = {"value": "foobar"} + charm = unittest.mock.MagicMock( + config=config, + framework_config_class=Charm.framework_config_class, + ) + with pytest.raises(CharmConfigInvalidError) as exc: + CharmState.from_charm( + framework="flask", + framework_config=Charm.get_framework_config(charm), + secret_storage=SECRET_STORAGE_MOCK, + config=config, + database_requirers={}, + ) diff --git a/tests/unit/flask/test_flask_app.py b/tests/unit/flask/test_flask_app.py index 86b2ac3..d296a47 100644 --- a/tests/unit/flask/test_flask_app.py +++ b/tests/unit/flask/test_flask_app.py @@ -26,6 +26,11 @@ pytest.param({"permanent_session_lifetime": 1}, {}, id="permanent_session_lifetime"), pytest.param({"debug": True}, {}, id="debug"), pytest.param({"application_root": "/"}, {"application_root": "/foo"}, id="duplicate"), + pytest.param( + {"application_root": "/"}, + {"secret_test": {"foo": "foo", "foo-bar": "foobar"}}, + id="secrets", + ), ], ) def test_flask_env(flask_config: dict, app_config: dict, database_migration_mock): @@ -52,10 +57,24 @@ def test_flask_env(flask_config: dict, app_config: dict, database_migration_mock env = flask_app.gen_environment() assert env["FLASK_SECRET_KEY"] == "foobar" del env["FLASK_SECRET_KEY"] - assert env == { - f"FLASK_{k.upper()}": v if isinstance(v, str) else json.dumps(v) - for k, v in flask_config.items() - } + expected_env = {} + for config_key, config_value in app_config.items(): + if isinstance(config_value, dict): + for secret_key, secret_value in config_value.items(): + expected_env[ + f"FLASK_{config_key.replace('-', '_').upper()}_{secret_key.replace('-', '_').upper()}" + ] = secret_value + else: + expected_env[f"FLASK_{config_key.replace('-', '_').upper()}"] = ( + config_value if isinstance(config_value, str) else json.dumps(config_value) + ) + expected_env.update( + { + f"FLASK_{k.upper()}": v if isinstance(v, str) else json.dumps(v) + for k, v in flask_config.items() + } + ) + assert env == expected_env HTTP_PROXY_TEST_PARAMS = [