diff --git a/examples/django/charm/charmcraft.yaml b/examples/django/charm/charmcraft.yaml index 10e0b16..05bf5df 100644 --- a/examples/django/charm/charmcraft.yaml +++ b/examples/django/charm/charmcraft.yaml @@ -53,7 +53,7 @@ config: 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=secret-string && juju grant-secret my-django-secret-key django-k8s`, + `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: diff --git a/examples/fastapi/charm/charmcraft.yaml b/examples/fastapi/charm/charmcraft.yaml index a26a3f5..fc19128 100644 --- a/examples/fastapi/charm/charmcraft.yaml +++ b/examples/fastapi/charm/charmcraft.yaml @@ -53,7 +53,7 @@ config: 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=secret-string && juju grant-secret my-secret-key fastapi-k8s`, + `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 diff --git a/examples/flask/charmcraft.yaml b/examples/flask/charmcraft.yaml index d1da6b6..292dba0 100644 --- a/examples/flask/charmcraft.yaml +++ b/examples/flask/charmcraft.yaml @@ -60,7 +60,7 @@ config: 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=secret-string && juju grant-secret my-flask-secret-key flask-k8s`, + `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: diff --git a/examples/go/charm/charmcraft.yaml b/examples/go/charm/charmcraft.yaml index a7cfa5b..01e6585 100644 --- a/examples/go/charm/charmcraft.yaml +++ b/examples/go/charm/charmcraft.yaml @@ -44,7 +44,7 @@ config: 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=secret-string && juju grant-secret my-secret-key go-k8s`, + `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 diff --git a/paas_app_charmer/charm.py b/paas_app_charmer/charm.py index 59c1cf2..558e01d 100644 --- a/paas_app_charmer/charm.py +++ b/paas_app_charmer/charm.py @@ -143,8 +143,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) - if ops.JujuVersion.from_environ().has_secrets: - self.framework.observe(self.on.secret_changed, self._on_secret_changed) + 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, @@ -188,21 +187,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. - - Args: - _event: the config-changed event that triggers this callback function. - """ + def _on_config_changed(self, _: ops.EventBase) -> None: + """Configure the application pebble service layer.""" self.restart() @block_if_invalid_config - def _on_secret_changed(self, _event: ops.EventBase) -> None: - """Configure the application Pebble service layer. - - Args: - _event: the secret-changed event that triggers this callback function. - """ + def _on_secret_changed(self, _: ops.EventBase) -> None: + """Configure the application Pebble service layer.""" self.restart() @block_if_invalid_config @@ -223,12 +214,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: @@ -371,67 +358,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() @@ -451,16 +438,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/django/charm.py b/paas_app_charmer/django/charm.py index 802aec8..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, model_validator, 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]: @@ -44,27 +48,6 @@ def allowed_hosts_to_list(cls, value: str | None) -> typing.List[str]: return [] return [h.strip() for h in value.split(",")] - @model_validator(mode="before") - @classmethod - def secret_key_id(cls, data: dict[str, str | int | bool | dict[str, str] | None]) -> dict: - """Read the new *-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. - """ - if "django-secret-key-id" in data and data["django-secret-key-id"]: - secret_value = typing.cast(dict[str, str], data["django-secret-key-id"]) - if "value" not in secret_value: - raise ValueError("django-secret-key-id missing 'value' key in the secret content") - data["django-secret-key"] = secret_value["value"] - return data - class Charm(GunicornBase): """Django Charm service. diff --git a/paas_app_charmer/fastapi/charm.py b/paas_app_charmer/fastapi/charm.py index 4e21dae..e0071d9 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, model_validator +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,26 +39,7 @@ 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_validator(mode="before") - @classmethod - def secret_key_id(cls, data: dict[str, str | int | bool | dict[str, str] | None]) -> dict: - """Read the new *-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. - """ - if "app-secret-key-id" in data and data["app-secret-key-id"]: - secret_value = typing.cast(dict[str, str], data["app-secret-key-id"]) - if "value" not in secret_value: - raise ValueError("app-secret-key-id missing 'value' key in the secret content") - data["app-secret-key"] = secret_value["value"] - return data + model_config = ConfigDict(extra="ignore") class Charm(PaasCharm): diff --git a/paas_app_charmer/flask/charm.py b/paas_app_charmer/flask/charm.py index 611ce97..ca83a31 100644 --- a/paas_app_charmer/flask/charm.py +++ b/paas_app_charmer/flask/charm.py @@ -4,17 +4,17 @@ """Flask Charm service.""" import logging import pathlib -import typing import ops -from pydantic import BaseModel, Extra, Field, field_validator, model_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: @@ -29,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) @@ -44,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 @@ -58,27 +60,6 @@ def to_upper(value: str) -> str: """ return value.upper() - @model_validator(mode="before") - @classmethod - def secret_key_id(cls, data: dict[str, str | int | bool | dict[str, str] | None]) -> dict: - """Read the new *-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. - """ - if "flask-secret-key-id" in data and data["flask-secret-key-id"]: - secret_value = typing.cast(dict[str, str], data["flask-secret-key-id"]) - if "value" not in secret_value: - raise ValueError("flask-secret-key-id missing 'value' key in the secret content") - data["flask-secret-key"] = secret_value["value"] - return data - class Charm(GunicornBase): """Flask Charm service. diff --git a/paas_app_charmer/framework.py b/paas_app_charmer/framework.py new file mode 100644 index 0000000..6919121 --- /dev/null +++ b/paas_app_charmer/framework.py @@ -0,0 +1,47 @@ +# 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. + """ + 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 + assert secret_key_config_name + 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 33be273..b0b17bc 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, model_validator +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,28 +31,7 @@ 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_validator(mode="before") - @classmethod - def secret_key_id( - cls, data: dict[str, str | int | bool | float | dict[str, str] | None] - ) -> dict: - """Read the new *-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. - """ - if "app-secret-key-id" in data and data["app-secret-key-id"]: - secret_value = typing.cast(dict[str, str], data["app-secret-key-id"]) - if "value" not in secret_value: - raise ValueError("app-secret-key-id missing 'value' key in the secret content") - data["app-secret-key"] = secret_value["value"] - return data + model_config = ConfigDict(extra="ignore") class Charm(PaasCharm): diff --git a/tests/unit/flask/test_charm_state.py b/tests/unit/flask/test_charm_state.py index 7f0bbec..6702914 100644 --- a/tests/unit/flask/test_charm_state.py +++ b/tests/unit/flask/test_charm_state.py @@ -314,7 +314,6 @@ def test_flask_secret_key_id(): assert: framework_config in the charm state should contain the value of the secret. """ config = copy.copy(DEFAULT_CHARM_CONFIG) - config["flask-secret-key"] = "foo" config["flask-secret-key-id"] = "secret://id" charm = unittest.mock.MagicMock( config=config, @@ -364,3 +363,33 @@ def test_flask_secret_key_id_no_value(): charm=charm, 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"] = "secret://id" + charm = unittest.mock.MagicMock( + config=config, + framework_config_class=Charm.framework_config_class, + model=unittest.mock.MagicMock( + get_secret=unittest.mock.MagicMock( + return_value=unittest.mock.MagicMock( + get_content=unittest.mock.MagicMock(return_value={"foobar": "foobar"}), + ) + ), + ), + ) + with pytest.raises(CharmConfigInvalidError) as exc: + CharmState.from_charm( + framework="flask", + framework_config=Charm.get_framework_config(charm), + secret_storage=SECRET_STORAGE_MOCK, + charm=charm, + database_requirers={}, + )