Skip to content
This repository has been archived by the owner on Oct 16, 2024. It is now read-only.

Commit

Permalink
Add secret configurations (#46)
Browse files Browse the repository at this point in the history
  • Loading branch information
weiiwang01 authored Sep 30, 2024
1 parent ae969f5 commit 52676dd
Show file tree
Hide file tree
Showing 19 changed files with 389 additions and 63 deletions.
8 changes: 8 additions & 0 deletions examples/django/charm/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<secret-string> && 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.
Expand Down
8 changes: 8 additions & 0 deletions examples/fastapi/charm/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<secret-string> && 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.
Expand Down
11 changes: 11 additions & 0 deletions examples/flask/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<secret-string> && 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.
Expand All @@ -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
Expand Down
8 changes: 8 additions & 0 deletions examples/go/charm/charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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=<secret-string> && 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.
Expand Down
13 changes: 10 additions & 3 deletions paas_app_charmer/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
75 changes: 44 additions & 31 deletions paas_app_charmer/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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__)

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()

Expand All @@ -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()
13 changes: 6 additions & 7 deletions paas_app_charmer/charm_state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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.
Expand All @@ -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 = {
Expand All @@ -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
),
Expand Down Expand Up @@ -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:
Expand Down
8 changes: 6 additions & 2 deletions paas_app_charmer/django/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,27 +8,31 @@
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:
debug: whether Django debug mode is enabled.
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]:
Expand Down
Loading

0 comments on commit 52676dd

Please sign in to comment.