Skip to content

Commit fd2a4f5

Browse files
authored
DPE-2655 profile limit option (#244)
* port over from k8s * fix unit tests
1 parent be904a1 commit fd2a4f5

File tree

9 files changed

+603
-129
lines changed

9 files changed

+603
-129
lines changed

config.yaml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,3 +34,9 @@ options:
3434
minimal running performance.
3535
type: string
3636
default: production
37+
profile-limit-memory:
38+
type: int
39+
description: |
40+
Amount of memory in Megabytes to limit PostgreSQL and associated process to.
41+
If unset, this will be decided according to the default memory limit in the selected profile.
42+
Only comes into effect when the `production` profile is selected.
Lines changed: 354 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,354 @@
1+
# Copyright 2023 Canonical Ltd.
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
r"""Library to provide simple API for promoting typed, validated and structured dataclass in charms.
16+
17+
Dict-like data structure are often used in charms. They are used for config, action parameters
18+
and databag. This library aims at providing simple API for using pydantic BaseModel-derived class
19+
in charms, in order to enhance:
20+
* Validation, by embedding custom business logic to validate single parameters or even have
21+
validators that acts across different fields
22+
* Parsing, by loading data into pydantic object we can both allow for other types (e.g. float) to
23+
be used in configuration/parameters as well as specify even nested complex objects for databags
24+
* Static typing checks, by moving from dict-like object to classes with typed-annotated properties,
25+
that can be statically checked using mypy to ensure that the code is correct.
26+
27+
Pydantic models can be used on:
28+
29+
* Charm Configuration (as defined in config.yaml)
30+
* Actions parameters (as defined in actions.yaml)
31+
* Application/Unit Databag Information (thus making it more structured and encoded)
32+
33+
34+
## Creating models
35+
36+
Any data-structure can be modeled using dataclasses instead of dict-like objects (e.g. storing
37+
config, action parameters and databags). Within pydantic, we can define dataclasses that provides
38+
also parsing and validation on standard dataclass implementation:
39+
40+
```python
41+
42+
from charms.data_platform_libs.v0.data_models import BaseConfigModel
43+
44+
class MyConfig(BaseConfigModel):
45+
46+
my_key: int
47+
48+
@validator("my_key")
49+
def is_lower_than_100(cls, v: int):
50+
if v > 100:
51+
raise ValueError("Too high")
52+
53+
```
54+
55+
This should allow to collapse both parsing and validation as the dataclass object is parsed and
56+
created:
57+
58+
```python
59+
dataclass = MyConfig(my_key="1")
60+
61+
dataclass.my_key # this returns 1 (int)
62+
dataclass["my_key"] # this returns 1 (int)
63+
64+
dataclass = MyConfig(my_key="102") # this returns a ValueError("Too High")
65+
```
66+
67+
## Charm Configuration Model
68+
69+
Using the class above, we can implement parsing and validation of configuration by simply
70+
extending our charms using the `TypedCharmBase` class, as shown below.
71+
72+
```python
73+
class MyCharm(TypedCharmBase[MyConfig]):
74+
config_type = MyConfig
75+
76+
# everywhere in the code you will have config property already parsed and validate
77+
def my_method(self):
78+
self.config: MyConfig
79+
```
80+
81+
## Action parameters
82+
83+
In order to parse action parameters, we can use a decorator to be applied to action event
84+
callbacks, as shown below.
85+
86+
```python
87+
@validate_params(PullActionModel)
88+
def _pull_site_action(
89+
self, event: ActionEvent,
90+
params: Optional[Union[PullActionModel, ValidationError]] = None
91+
):
92+
if isinstance(params, ValidationError):
93+
# handle errors
94+
else:
95+
# do stuff
96+
```
97+
98+
Note that this changes the signature of the callbacks by adding an extra parameter with the parsed
99+
counterpart of the `event.params` dict-like field. If validation fails, we return (not throw!) the
100+
exception, to be handled (or raised) in the callback.
101+
102+
## Databag
103+
104+
In order to parse databag fields, we define a decorator to be applied to base relation event
105+
callbacks.
106+
107+
```python
108+
@parse_relation_data(app_model=AppDataModel, unit_model=UnitDataModel)
109+
def _on_cluster_relation_joined(
110+
self, event: RelationEvent,
111+
app_data: Optional[Union[AppDataModel, ValidationError]] = None,
112+
unit_data: Optional[Union[UnitDataModel, ValidationError]] = None
113+
) -> None:
114+
...
115+
```
116+
117+
The parameters `app_data` and `unit_data` refers to the databag of the entity which fired the
118+
RelationEvent.
119+
120+
When we want to access to a relation databag outsides of an action, it can be useful also to
121+
compact multiple databags into a single object (if there are no conflicting fields), e.g.
122+
123+
```python
124+
125+
class ProviderDataBag(BaseClass):
126+
provider_key: str
127+
128+
class RequirerDataBag(BaseClass):
129+
requirer_key: str
130+
131+
class MergedDataBag(ProviderDataBag, RequirerDataBag):
132+
pass
133+
134+
merged_data = get_relation_data_as(
135+
MergedDataBag, relation.data[self.app], relation.data[relation.app]
136+
)
137+
138+
merged_data.requirer_key
139+
merged_data.provider_key
140+
141+
```
142+
143+
The above code can be generalized to other kinds of merged objects, e.g. application and unit, and
144+
it can be extended to multiple sources beyond 2:
145+
146+
```python
147+
merged_data = get_relation_data_as(
148+
MergedDataBag, relation.data[self.app], relation.data[relation.app], ...
149+
)
150+
```
151+
152+
"""
153+
154+
import json
155+
from functools import reduce, wraps
156+
from typing import Callable, Generic, MutableMapping, Optional, Type, TypeVar, Union
157+
158+
import pydantic
159+
from ops.charm import ActionEvent, CharmBase, RelationEvent
160+
from ops.model import RelationDataContent
161+
from pydantic import BaseModel, ValidationError
162+
163+
# The unique Charmhub library identifier, never change it
164+
LIBID = "cb2094c5b07d47e1bf346aaee0fcfcfe"
165+
166+
# Increment this major API version when introducing breaking changes
167+
LIBAPI = 0
168+
169+
# Increment this PATCH version before using `charmcraft publish-lib` or reset
170+
# to 0 if you are raising the major API version
171+
LIBPATCH = 4
172+
173+
PYDEPS = ["ops>=2.0.0", "pydantic>=1.10,<2"]
174+
175+
G = TypeVar("G")
176+
T = TypeVar("T", bound=BaseModel)
177+
AppModel = TypeVar("AppModel", bound=BaseModel)
178+
UnitModel = TypeVar("UnitModel", bound=BaseModel)
179+
180+
DataBagNativeTypes = (int, str, float)
181+
182+
183+
class BaseConfigModel(BaseModel):
184+
"""Class to be used for defining the structured configuration options."""
185+
186+
def __getitem__(self, x):
187+
"""Return the item using the notation instance[key]."""
188+
return getattr(self, x.replace("-", "_"))
189+
190+
191+
class TypedCharmBase(CharmBase, Generic[T]):
192+
"""Class to be used for extending config-typed charms."""
193+
194+
config_type: Type[T]
195+
196+
@property
197+
def config(self) -> T:
198+
"""Return a config instance validated and parsed using the provided pydantic class."""
199+
translated_keys = {k.replace("-", "_"): v for k, v in self.model.config.items()}
200+
return self.config_type(**translated_keys)
201+
202+
203+
def validate_params(cls: Type[T]):
204+
"""Return a decorator to allow pydantic parsing of action parameters.
205+
206+
Args:
207+
cls: Pydantic class representing the model to be used for parsing the content of the
208+
action parameter
209+
"""
210+
211+
def decorator(
212+
f: Callable[[CharmBase, ActionEvent, Union[T, ValidationError]], G]
213+
) -> Callable[[CharmBase, ActionEvent], G]:
214+
@wraps(f)
215+
def event_wrapper(self: CharmBase, event: ActionEvent):
216+
try:
217+
params = cls(
218+
**{key.replace("-", "_"): value for key, value in event.params.items()}
219+
)
220+
except ValidationError as e:
221+
params = e
222+
return f(self, event, params)
223+
224+
return event_wrapper
225+
226+
return decorator
227+
228+
229+
def write(relation_data: RelationDataContent, model: BaseModel):
230+
"""Write the data contained in a domain object to the relation databag.
231+
232+
Args:
233+
relation_data: pointer to the relation databag
234+
model: instance of pydantic model to be written
235+
"""
236+
for key, value in model.dict(exclude_none=False).items():
237+
if value:
238+
relation_data[key.replace("_", "-")] = (
239+
str(value)
240+
if any(isinstance(value, _type) for _type in DataBagNativeTypes)
241+
else json.dumps(value)
242+
)
243+
else:
244+
relation_data[key.replace("_", "-")] = ""
245+
246+
247+
def read(relation_data: MutableMapping[str, str], obj: Type[T]) -> T:
248+
"""Read data from a relation databag and parse it into a domain object.
249+
250+
Args:
251+
relation_data: pointer to the relation databag
252+
obj: pydantic class representing the model to be used for parsing
253+
"""
254+
return obj(
255+
**{
256+
field_name: (
257+
relation_data[parsed_key]
258+
if field.outer_type_ in DataBagNativeTypes
259+
else json.loads(relation_data[parsed_key])
260+
)
261+
for field_name, field in obj.__fields__.items()
262+
# pyright: ignore[reportGeneralTypeIssues]
263+
if (parsed_key := field_name.replace("_", "-")) in relation_data
264+
if relation_data[parsed_key]
265+
}
266+
)
267+
268+
269+
def parse_relation_data(
270+
app_model: Optional[Type[AppModel]] = None, unit_model: Optional[Type[UnitModel]] = None
271+
):
272+
"""Return a decorator to allow pydantic parsing of the app and unit databags.
273+
274+
Args:
275+
app_model: Pydantic class representing the model to be used for parsing the content of the
276+
app databag. None if no parsing ought to be done.
277+
unit_model: Pydantic class representing the model to be used for parsing the content of the
278+
unit databag. None if no parsing ought to be done.
279+
"""
280+
281+
def decorator(
282+
f: Callable[
283+
[
284+
CharmBase,
285+
RelationEvent,
286+
Optional[Union[AppModel, ValidationError]],
287+
Optional[Union[UnitModel, ValidationError]],
288+
],
289+
G,
290+
]
291+
) -> Callable[[CharmBase, RelationEvent], G]:
292+
@wraps(f)
293+
def event_wrapper(self: CharmBase, event: RelationEvent):
294+
try:
295+
app_data = (
296+
read(event.relation.data[event.app], app_model)
297+
if app_model is not None and event.app
298+
else None
299+
)
300+
except pydantic.ValidationError as e:
301+
app_data = e
302+
303+
try:
304+
unit_data = (
305+
read(event.relation.data[event.unit], unit_model)
306+
if unit_model is not None and event.unit
307+
else None
308+
)
309+
except pydantic.ValidationError as e:
310+
unit_data = e
311+
312+
return f(self, event, app_data, unit_data)
313+
314+
return event_wrapper
315+
316+
return decorator
317+
318+
319+
class RelationDataModel(BaseModel):
320+
"""Base class to be used for creating data models to be used for relation databags."""
321+
322+
def write(self, relation_data: RelationDataContent):
323+
"""Write data to a relation databag.
324+
325+
Args:
326+
relation_data: pointer to the relation databag
327+
"""
328+
return write(relation_data, self)
329+
330+
@classmethod
331+
def read(cls, relation_data: RelationDataContent) -> "RelationDataModel":
332+
"""Read data from a relation databag and parse it as an instance of the pydantic class.
333+
334+
Args:
335+
relation_data: pointer to the relation databag
336+
"""
337+
return read(relation_data, cls)
338+
339+
340+
def get_relation_data_as(
341+
model_type: Type[AppModel],
342+
*relation_data: RelationDataContent,
343+
) -> Union[AppModel, ValidationError]:
344+
"""Return a merged representation of the provider and requirer databag into a single object.
345+
346+
Args:
347+
model_type: pydantic class representing the merged databag
348+
relation_data: list of RelationDataContent of provider/requirer/unit sides
349+
"""
350+
try:
351+
app_data = read(reduce(lambda x, y: dict(x) | dict(y), relation_data, {}), model_type)
352+
except pydantic.ValidationError as e:
353+
app_data = e
354+
return app_data

0 commit comments

Comments
 (0)