Skip to content

Commit 122ed19

Browse files
authored
Feat(Block on missing config) (#42)
* Feat(Interface): Interface commit. * Chore(): Implementation, half-working unit test. * Chore(): Remove changes from charmcraft.yaml * Chore(): Improve implementation. * Chore(test): Fix unit tests. * Chore(lint): Format code * Chore(test): Fix unit test. * Chore(test): Change implementation. Add a bunch of unit test. * Chore(lint): Fix linting errors. * Chore(lint): Fix linting errors. * Chore(): Add non-optional to default example. * Chore(test): Fix proxy test * Chore(test): Add unit tests(WIP) * Chore(test): Update unit tests. * Chore(test): Update minor stuff. * Chore(): Updated changelog. * Chore(): Address comments. * Chore(): Cleanup. * Chore(): Cleanup. * Chore(): Cleanup. * Chore(lint): Make linter happy. * Chore(lint): Fix newline. * Chore(): Address comments. * Chore(lint): Fix loc type. * chore(test): Make it clear. * chore(utils): Fix None prefix.
1 parent 52187d4 commit 122ed19

File tree

30 files changed

+1260
-75
lines changed

30 files changed

+1260
-75
lines changed

.github/workflows/integration_test.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ jobs:
1414
extra-arguments: -x --localstack-address 172.17.0.1
1515
pre-run-script: localstack-installation.sh
1616
charmcraft-channel: latest/edge
17-
modules: '["test_charm.py", "test_cos.py", "test_database.py", "test_db_migration.py", "test_django.py", "test_django_integrations.py", "test_fastapi.py", "test_go.py", "test_integrations.py", "test_proxy.py", "test_workers.py", "test_tracing.py"]'
17+
modules: '["test_charm.py", "test_cos.py", "test_database.py", "test_db_migration.py", "test_django.py", "test_django_integrations.py", "test_fastapi.py", "test_go.py", "test_integrations.py", "test_proxy.py", "test_workers.py", "test_tracing.py", "test_config.py"]'
1818
rockcraft-channel: latest/edge
1919
juju-channel: ${{ matrix.juju-version }}
2020
channel: 1.29-strict/stable

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## v1.3.0 - 2025-02-24
11+
12+
### Changed
13+
14+
* Added support for non-optional configuration options.
15+
1016
## v1.2.3 - 2025-02-07
1117

1218
### Changed

examples/fastapi/charm/charmcraft.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ config:
5858
user-defined-config:
5959
type: string
6060
description: Example of a user defined configuration.
61+
non-optional-string:
62+
description: Example of a non-optional string configuration option.
63+
type: string
64+
optional: False
6165
containers:
6266
app:
6367
resource: app-image

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
# See LICENSE file for licensing details.
33
[project]
44
name = "paas-charm"
5-
version = "1.2.3"
5+
version = "1.3.0"
66
description = "Companion library for 12-factor app support in Charmcraft & Rockcraft."
77
readme = "README.md"
88
authors = [

src/paas_charm/app.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ def gen_environment(self) -> dict[str, str]:
140140
"""
141141
prefix = self.configuration_prefix
142142
env = {}
143-
for app_config_key, app_config_value in self._charm_state.app_config.items():
143+
for app_config_key, app_config_value in self._charm_state.user_defined_config.items():
144144
if isinstance(app_config_value, collections.abc.Mapping):
145145
for k, v in app_config_value.items():
146146
env[f"{prefix}{app_config_key.upper()}_{k.replace('-', '_').upper()}"] = (

src/paas_charm/charm.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -283,11 +283,13 @@ def get_framework_config(self) -> BaseModel:
283283
for k, v in charm_config.items()
284284
},
285285
)
286+
286287
try:
287288
return framework_config_class.model_validate(config)
288289
except ValidationError as exc:
289-
error_message = build_validation_error_message(exc, underscore_to_dash=False)
290-
raise CharmConfigInvalidError(f"invalid configuration: {error_message}") from exc
290+
error_messages = build_validation_error_message(exc)
291+
logger.error(error_messages.long)
292+
raise CharmConfigInvalidError(error_messages.short) from exc
291293

292294
@property
293295
def _container(self) -> Container:

src/paas_charm/charm_state.py

Lines changed: 111 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,29 @@
44
"""This module defines the CharmState class which represents the state of the charm."""
55
import logging
66
import os
7+
import pathlib
78
import re
89
import typing
910
from dataclasses import dataclass, field
1011
from typing import Optional, Type, TypeVar
1112

1213
from charms.data_platform_libs.v0.data_interfaces import DatabaseRequires
1314
from charms.redis_k8s.v0.redis import RedisRequires
14-
from pydantic import BaseModel, Extra, Field, ValidationError, ValidationInfo, field_validator
15+
from pydantic import (
16+
BaseModel,
17+
Extra,
18+
Field,
19+
ValidationError,
20+
ValidationInfo,
21+
create_model,
22+
field_validator,
23+
)
1524

1625
from paas_charm.databases import get_uri
1726
from paas_charm.exceptions import CharmConfigInvalidError
1827
from paas_charm.rabbitmq import RabbitMQRequires
1928
from paas_charm.secret_storage import KeySecretStorage
20-
from paas_charm.utils import build_validation_error_message
29+
from paas_charm.utils import build_validation_error_message, config_metadata
2130

2231
logger = logging.getLogger(__name__)
2332

@@ -55,7 +64,7 @@ class CharmState: # pylint: disable=too-many-instance-attributes
5564
5665
Attrs:
5766
framework_config: the value of the framework specific charm configuration.
58-
app_config: user-defined configurations for the application.
67+
user_defined_config: user-defined configurations for the application.
5968
secret_key: the charm managed application secret key.
6069
is_secret_storage_ready: whether the secret storage system is ready.
6170
proxy: proxy information.
@@ -66,7 +75,7 @@ def __init__( # pylint: disable=too-many-arguments
6675
*,
6776
framework: str,
6877
is_secret_storage_ready: bool,
69-
app_config: dict[str, int | str | bool | dict[str, str]] | None = None,
78+
user_defined_config: dict[str, int | str | bool | dict[str, str]] | None = None,
7079
framework_config: dict[str, int | str] | None = None,
7180
secret_key: str | None = None,
7281
integrations: "IntegrationsState | None" = None,
@@ -77,15 +86,15 @@ def __init__( # pylint: disable=too-many-arguments
7786
Args:
7887
framework: the framework name.
7988
is_secret_storage_ready: whether the secret storage system is ready.
80-
app_config: User-defined configuration values for the application configuration.
89+
user_defined_config: User-defined configuration values for the application.
8190
framework_config: The value of the framework application specific charm configuration.
8291
secret_key: The secret storage manager associated with the charm.
8392
integrations: Information about the integrations.
8493
base_url: Base URL for the service.
8594
"""
8695
self.framework = framework
8796
self._framework_config = framework_config if framework_config is not None else {}
88-
self._app_config = app_config if app_config is not None else {}
97+
self._user_defined_config = user_defined_config if user_defined_config is not None else {}
8998
self._is_secret_storage_ready = is_secret_storage_ready
9099
self._secret_key = secret_key
91100
self.integrations = integrations or IntegrationsState()
@@ -116,16 +125,27 @@ def from_charm( # pylint: disable=too-many-arguments
116125
117126
Return:
118127
The CharmState instance created by the provided charm.
128+
129+
Raises:
130+
CharmConfigInvalidError: If some parameter in invalid.
119131
"""
120-
app_config = {
132+
user_defined_config = {
121133
k.replace("-", "_"): v
122134
for k, v in config.items()
123-
if not any(k.startswith(prefix) for prefix in (f"{framework}-", "webserver-", "app-"))
135+
if is_user_defined_config(k, framework)
124136
}
125-
app_config = {
126-
k: v for k, v in app_config.items() if k not in framework_config.dict().keys()
137+
user_defined_config = {
138+
k: v for k, v in user_defined_config.items() if k not in framework_config.dict().keys()
127139
}
128140

141+
app_config_class = app_config_class_factory(framework)
142+
try:
143+
app_config_class(**user_defined_config)
144+
except ValidationError as exc:
145+
error_messages = build_validation_error_message(exc, underscore_to_dash=True)
146+
logger.error(error_messages.long)
147+
raise CharmConfigInvalidError(error_messages.short) from exc
148+
129149
saml_relation_data = None
130150
if integration_requirers.saml and (
131151
saml_data := integration_requirers.saml.get_relation_data()
@@ -153,7 +173,9 @@ def from_charm( # pylint: disable=too-many-arguments
153173
return cls(
154174
framework=framework,
155175
framework_config=framework_config.dict(exclude_none=True),
156-
app_config=typing.cast(dict[str, str | int | bool | dict[str, str]], app_config),
176+
user_defined_config=typing.cast(
177+
dict[str, str | int | bool | dict[str, str]], user_defined_config
178+
),
157179
secret_key=(
158180
secret_storage.get_secret_key() if secret_storage.is_initialized else None
159181
),
@@ -188,13 +210,13 @@ def framework_config(self) -> dict[str, str | int | bool]:
188210
return self._framework_config
189211

190212
@property
191-
def app_config(self) -> dict[str, str | int | bool | dict[str, str]]:
213+
def user_defined_config(self) -> dict[str, str | int | bool | dict[str, str]]:
192214
"""Get the value of user-defined application configurations.
193215
194216
Returns:
195217
The value of user-defined application configurations.
196218
"""
197-
return self._app_config
219+
return self._user_defined_config
198220

199221
@property
200222
def secret_key(self) -> str:
@@ -351,9 +373,10 @@ def generate_relation_parameters(
351373
try:
352374
return parameter_type.parse_obj(relation_data)
353375
except ValidationError as exc:
354-
error_message = build_validation_error_message(exc)
376+
error_messages = build_validation_error_message(exc)
377+
logger.error(error_messages.long)
355378
raise CharmConfigInvalidError(
356-
f"Invalid {parameter_type.__name__} configuration: {error_message}"
379+
f"Invalid {parameter_type.__name__}: {error_messages.short}"
357380
) from exc
358381

359382

@@ -458,3 +481,76 @@ def validate_signing_certificate_exists(cls, certs: str, _: ValidationInfo) -> s
458481
if not certificate:
459482
raise ValueError("Missing x509certs. There should be at least one certificate.")
460483
return certificate
484+
485+
486+
def _create_config_attribute(option_name: str, option: dict) -> tuple[str, tuple]:
487+
"""Create the configuration attribute.
488+
489+
Args:
490+
option_name: Name of the configuration option.
491+
option: The configuration option data.
492+
493+
Raises:
494+
ValueError: raised when the option type is not valid.
495+
496+
Returns:
497+
A tuple constructed from attribute name and type.
498+
"""
499+
option_name = option_name.replace("-", "_")
500+
optional = option.get("optional") is not False
501+
config_type_str = option.get("type")
502+
503+
config_type: type[bool] | type[int] | type[float] | type[str] | type[dict]
504+
match config_type_str:
505+
case "boolean":
506+
config_type = bool
507+
case "int":
508+
config_type = int
509+
case "float":
510+
config_type = float
511+
case "string":
512+
config_type = str
513+
case "secret":
514+
config_type = dict
515+
case _:
516+
raise ValueError(f"Invalid option type: {config_type_str}.")
517+
518+
type_tuple: tuple = (config_type, Field())
519+
if optional:
520+
type_tuple = (config_type | None, None)
521+
522+
return (option_name, type_tuple)
523+
524+
525+
def app_config_class_factory(framework: str) -> type[BaseModel]:
526+
"""App config class factory.
527+
528+
Args:
529+
framework: The framework name.
530+
531+
Returns:
532+
Constructed app config class.
533+
"""
534+
config_options = config_metadata(pathlib.Path(os.getcwd()))["options"]
535+
model_attributes = dict(
536+
_create_config_attribute(option_name, config_options[option_name])
537+
for option_name in config_options
538+
if is_user_defined_config(option_name, framework)
539+
)
540+
# mypy doesn't like the model_attributes dict
541+
return create_model("AppConfig", **model_attributes) # type: ignore[call-overload]
542+
543+
544+
def is_user_defined_config(option_name: str, framework: str) -> bool:
545+
"""Check if a config option is user defined.
546+
547+
Args:
548+
option_name: Name of the config option.
549+
framework: The framework name.
550+
551+
Returns:
552+
True if user defined config options, false otherwise.
553+
"""
554+
return not any(
555+
option_name.startswith(prefix) for prefix in (f"{framework}-", "webserver-", "app-")
556+
)

src/paas_charm/utils.py

Lines changed: 51 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -13,29 +13,67 @@
1313
from pydantic import ValidationError
1414

1515

16+
class ValidationErrorMessage(typing.NamedTuple):
17+
"""Class carrying status message and error log for pydantic validation errors.
18+
19+
Attrs:
20+
short: Short error message to show in status message.
21+
long: Detailed error message for logging.
22+
"""
23+
24+
short: str
25+
long: str
26+
27+
1628
def build_validation_error_message(
1729
exc: ValidationError, prefix: str | None = None, underscore_to_dash: bool = False
18-
) -> str:
19-
"""Build a str with a list of error fields from a pydantic exception.
30+
) -> ValidationErrorMessage:
31+
"""Build a ValidationErrorMessage for error logging.
2032
2133
Args:
2234
exc: ValidationError exception instance.
2335
prefix: Prefix to append to the error field names.
2436
underscore_to_dash: Replace underscores to dashes in the error field names.
2537
2638
Returns:
27-
The curated list of error fields ready to be used in an error message.
39+
The ValidationErrorMessage for error logging..
2840
"""
29-
error_fields_unique = set(
30-
itertools.chain.from_iterable(error["loc"] for error in exc.errors())
41+
fields = set(
42+
(
43+
(
44+
f'{prefix if prefix else ""}{".".join(str(loc) for loc in error["loc"])}'
45+
if error["loc"]
46+
else ""
47+
),
48+
error["msg"],
49+
)
50+
for error in exc.errors()
3151
)
32-
error_fields = (str(error_field) for error_field in error_fields_unique)
33-
if prefix:
34-
error_fields = (f"{prefix}{error_field}" for error_field in error_fields)
52+
3553
if underscore_to_dash:
36-
error_fields = (error_field.replace("_", "-") for error_field in error_fields)
37-
error_field_str = " ".join(error_fields)
38-
return error_field_str
54+
fields = {(key.replace("_", "-"), value) for key, value in fields}
55+
56+
missing_fields = {}
57+
invalid_fields = {}
58+
59+
for loc, msg in fields:
60+
if "required" in msg.lower():
61+
missing_fields[loc] = msg
62+
else:
63+
invalid_fields[loc] = msg
64+
65+
short_str_missing = f"missing options: {', '.join(missing_fields)}" if missing_fields else ""
66+
short_str_invalid = f"invalid options: {', '.join(invalid_fields)}" if invalid_fields else ""
67+
short_str = f"{short_str_missing}\
68+
{', ' if missing_fields and invalid_fields else ''}{short_str_invalid}"
69+
70+
long_str_lines = "\n".join(
71+
f"- {key}: {value}"
72+
for key, value in itertools.chain(missing_fields.items(), invalid_fields.items())
73+
)
74+
long_str = f"invalid configuration:\n{long_str_lines}"
75+
76+
return ValidationErrorMessage(short=short_str, long=long_str)
3977

4078

4179
def enable_pebble_log_forwarding() -> bool:
@@ -58,7 +96,7 @@ def enable_pebble_log_forwarding() -> bool:
5896

5997

6098
@functools.lru_cache
61-
def _config_metadata(charm_dir: pathlib.Path) -> dict:
99+
def config_metadata(charm_dir: pathlib.Path) -> dict:
62100
"""Get charm configuration metadata for the given charm directory.
63101
64102
Args:
@@ -95,7 +133,7 @@ def config_get_with_secret(
95133
Returns:
96134
The configuration value.
97135
"""
98-
metadata = _config_metadata(pathlib.Path(os.getcwd()))
136+
metadata = config_metadata(pathlib.Path(os.getcwd()))
99137
config_type = metadata["options"][key]["type"]
100138
if config_type != "secret":
101139
return charm.config.get(key)

0 commit comments

Comments
 (0)