Skip to content

Commit 99dcd1f

Browse files
authored
Merge pull request #1213 from james-garner-canonical/2024-11/feat/unify-handling-of-storage-constraints-in-deploy
#1213 #### Description This PR unifies storage constraint parsing into a single method (`juju.constraints.parse_storage_constraints`), which is called in a single place (`juju.model.Model._deploy`), allowing both bundle and model deployments to specify storage constraints using either the [juju storage constraint directive format](https://juju.is/docs/juju/storage-constraint) (e.g. `{'label': 'ebs,100G'}`) or pre-parsed dictionaries (e.g. `{'label': {'count': 1, 'pool': 'ebs', 'size': 102400}`). #### QA Steps Unit tests have been updated to reflect the new parsing location. Integration tests have been added to verify that model deployment can request storage using either format. The existing bundle integration tests should continue to pass. #### Notes & Discussion This PR resolves the issues with storage constraint parsing identified in: - #1052 - #1075 #1052 was initially addressed in #1053, which was included in the [3.5.2.0](https://github.com/juju/python-libjuju/releases/tag/3.5.2.0) release. This allowed bundle deployments (using `juju.bundle.AddApplicationChange.run`) to correctly handle storage constraints specified as strings in the [juju storage constraint directive format](https://juju.is/docs/juju/storage-constraint). Unfortunately, this erroneously required that model deployments (using `juju.model.Model.deploy`) also use this string format, instead of the parsed dictionary format that was previously accepted. This was noticed in #1075 and initially fixed in #1105, which was merged into `main` but never released. This fix moved parsing of storage constraint strings to bundle deployment exclusively. Due to the interim period in which `3.5.2` has (incorrectly) required model deployments to use the string format, I think the best fix at this point is to allow both bundle deployments and model deployments to use either format, and parse them into the parsed dictionary format in a single place in `juju.model.Model._deploy` (the private method called by both bundle and model deployments). After merging, let's look at getting these changes out in a `3.5.2.2` bugfix release.
2 parents f176d82 + 69aa052 commit 99dcd1f

6 files changed

Lines changed: 148 additions & 132 deletions

File tree

juju/bundle.py

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Copyright 2023 Canonical Ltd.
22
# Licensed under the Apache V2, see LICENCE file for details.
3+
from __future__ import annotations
34

45
import base64
56
import io
@@ -8,7 +9,7 @@
89
import zipfile
910
from contextlib import closing
1011
from pathlib import Path
11-
from typing import Dict, Optional
12+
from typing import TYPE_CHECKING, Mapping, cast
1213

1314
import requests
1415
import yaml
@@ -17,12 +18,15 @@
1718
from . import jasyncio, utils
1819
from .client import client
1920
from .constraints import parse as parse_constraints
20-
from .constraints import parse_storage_constraint
2121
from .errors import JujuError
2222
from .origin import Channel, Source
2323
from .url import URL, Schema
2424
from .utils import get_base_from_origin_or_channel
2525

26+
if TYPE_CHECKING:
27+
from .constraints import StorageConstraintDict
28+
from .model import Model
29+
2630
log = logging.getLogger(__name__)
2731

2832

@@ -599,7 +603,8 @@ class AddApplicationChange(ChangeInfo):
599603
:options: holds application options.
600604
:constraints: optional application constraints.
601605
:storage: optional storage constraints, in the form of `{label: constraint}`.
602-
The label is a string specified by the charm, while the constraint is a string following
606+
The label is a string specified by the charm, while the constraint is
607+
either a constraints.StorageConstraintDict, or a string following
603608
`the juju storage constraint directive format <https://juju.is/docs/juju/storage-constraint>`_,
604609
specifying the storage pool, number of volumes, and size of each volume.
605610
:devices: optional devices constraints.
@@ -610,7 +615,7 @@ class AddApplicationChange(ChangeInfo):
610615
application's charm.
611616
"""
612617

613-
storage: Optional[Dict[str, str]]
618+
storage: Mapping[str, str | StorageConstraintDict] | None = None
614619

615620
@staticmethod
616621
def method():
@@ -627,7 +632,8 @@ async def run(self, context):
627632
# NB: this should really be handled by the controller when generating the
628633
# bundle change plan, and this short-term workaround may be missing some
629634
# aspects of the logic which the CLI client contains to handle edge cases.
630-
if self.application in context.model.applications:
635+
model = cast("Model", context.model) # pyright: ignore[reportUnknownMemberType]
636+
if self.application in model.applications:
631637
log.debug("Skipping %s; already in model", self.application)
632638
return self.application
633639

@@ -637,9 +643,9 @@ async def run(self, context):
637643
if self.options is not None:
638644
options = self.options
639645
if context.trusted:
640-
if context.model.info.agent_version < client.Number.from_json("2.4.0"):
646+
if model.info.agent_version < client.Number.from_json("2.4.0"):
641647
raise NotImplementedError(
642-
f"trusted is not supported on model version {context.model.info.agent_version}"
648+
f"trusted is not supported on model version {model.info.agent_version}"
643649
)
644650
options["trust"] = "true"
645651

@@ -676,22 +682,19 @@ async def run(self, context):
676682
.get("resources", {})
677683
)
678684
if Schema.CHARM_HUB.matches(url.schema):
679-
resources = await context.model._add_charmhub_resources(
685+
resources = await model._add_charmhub_resources(
680686
self.application, charm, origin, overrides=self.resources
681687
)
682688

683-
await context.model._deploy(
689+
await model._deploy(
684690
charm_url=charm,
685691
application=self.application,
686692
series=self.series,
687693
config=options,
688694
constraints=self.constraints,
689695
endpoint_bindings=self.endpoint_bindings,
690696
resources=resources,
691-
storage={
692-
label: parse_storage_constraint(constraint)
693-
for label, constraint in (self.storage or {}).items()
694-
},
697+
storage=self.storage,
695698
channel=self.channel,
696699
devices=self.devices,
697700
num_units=self.num_units,

juju/constraints.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
#
2020

2121
import re
22-
from typing import Dict, List, Optional, TypedDict, Union
22+
from typing import Dict, List, Mapping, Optional, TypedDict, Union
2323

2424
from typing_extensions import NotRequired, Required
2525

@@ -190,6 +190,25 @@ def parse_storage_constraint(constraint: str) -> StorageConstraintDict:
190190
return storage
191191

192192

193+
def parse_storage_constraints(
194+
constraints: Optional[Mapping[str, Union[str, StorageConstraintDict]]] = None,
195+
) -> Dict[str, StorageConstraintDict]:
196+
if constraints is None:
197+
return {}
198+
parsed: dict[str, StorageConstraintDict] = {}
199+
for label, storage_constraint in constraints.items():
200+
if isinstance(storage_constraint, str):
201+
parsed[label] = parse_storage_constraint(storage_constraint)
202+
elif isinstance(storage_constraint, dict): # pyright: ignore[reportUnnecessaryIsInstance]
203+
parsed[label] = storage_constraint
204+
else:
205+
raise ValueError(
206+
f"Unexpected constraint {storage_constraint!r}"
207+
f" for label {label!r} in {constraints}"
208+
)
209+
return parsed
210+
211+
193212
DEVICE = re.compile(
194213
# original regex:
195214
# '^(?P<count>[0-9]+)?(?:^|,)(?P<type>[^,]+)(?:$|,(?!$))(?P<attrs>(?:[^=]+=[^;]+)+)*$'

juju/model.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from .client import client, connection, connector
3333
from .client.overrides import Caveat, Macaroon
3434
from .constraints import parse as parse_constraints
35+
from .constraints import parse_storage_constraints
3536
from .controller import ConnectedController, Controller
3637
from .delta import get_entity_class, get_entity_delta
3738
from .errors import (
@@ -61,6 +62,7 @@
6162
if TYPE_CHECKING:
6263
from .application import Application
6364
from .client._definitions import FullStatus
65+
from .constraints import StorageConstraintDict
6466
from .machine import Machine
6567
from .relation import Relation
6668
from .remoteapplication import ApplicationOffer, RemoteApplication
@@ -1788,7 +1790,7 @@ async def deploy(
17881790
resources=None,
17891791
series=None,
17901792
revision=None,
1791-
storage=None,
1793+
storage: Mapping[str, str | StorageConstraintDict] | None = None,
17921794
to=None,
17931795
devices=None,
17941796
trust=False,
@@ -1813,7 +1815,11 @@ async def deploy(
18131815
:param str series: Series on which to deploy DEPRECATED: use --base (with Juju 3.1)
18141816
:param int revision: specifying a revision requires a channel for future upgrades for charms.
18151817
For bundles, revision and channel are mutually exclusive.
1816-
:param dict storage: Storage constraints TODO how do these look?
1818+
:param dict storage: optional storage constraints, in the form of `{label: constraint}`.
1819+
The label is a string specified by the charm, while the constraint is
1820+
a constraints.StorageConstraintsDict, or a string following
1821+
`the juju storage constraint directive format <https://juju.is/docs/juju/storage-constraint>`_,
1822+
specifying the storage pool, number of volumes, and size of each volume.
18171823
:param to: Placement directive as a string. For example:
18181824
18191825
'23' - place on machine 23
@@ -1829,8 +1835,6 @@ async def deploy(
18291835
:param str[] attach_storage: Existing storage to attach to the deployed unit
18301836
(not available on k8s models)
18311837
"""
1832-
if storage:
1833-
storage = {k: client.Constraints(**v) for k, v in storage.items()}
18341838
if trust and (self.info.agent_version < client.Number.from_json("2.4.0")):
18351839
raise NotImplementedError(
18361840
f"trusted is not supported on model version {self.info.agent_version}"
@@ -2251,7 +2255,7 @@ async def _deploy(
22512255
constraints,
22522256
endpoint_bindings,
22532257
resources,
2254-
storage,
2258+
storage: Mapping[str, str | StorageConstraintDict] | None,
22552259
channel=None,
22562260
num_units=None,
22572261
placement=None,
@@ -2261,9 +2265,18 @@ async def _deploy(
22612265
force=False,
22622266
server_side_deploy=False,
22632267
):
2264-
"""Logic shared between `Model.deploy` and `BundleHandler.deploy`."""
2268+
"""Logic shared between `Model.deploy` and `BundleHandler.deploy`.
2269+
2270+
:param dict storage: optional storage constraints, in the form of `{label: constraint}`.
2271+
The label is a string specified by the charm, while the constraint is
2272+
either a constraints.StorageConstraintDict, or a string following
2273+
`the juju storage constraint directive format <https://juju.is/docs/juju/storage-constraint>`_,
2274+
specifying the storage pool, number of volumes, and size of each volume.
2275+
"""
22652276
log.info("Deploying %s", charm_url)
22662277

2278+
storage = parse_storage_constraints(storage)
2279+
22672280
trust = config.get("trust", False)
22682281
# stringify all config values for API, and convert to YAML
22692282
config = {k: str(v) for k, v in config.items()}

tests/integration/test_model.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,42 @@ async def test_model_name():
4545
await model.disconnect()
4646

4747

48+
@base.bootstrapped
49+
async def test_deploy_with_storage_unparsed():
50+
async with base.CleanModel() as model:
51+
await model.deploy(
52+
"postgresql",
53+
storage={"pgdata": "1G"},
54+
)
55+
await model.wait_for_idle(status="active")
56+
storages = await model.list_storage()
57+
assert len(storages) == 1
58+
[storage] = storages
59+
# size information isn't exposed, so can't assert on that
60+
assert storage["owner-tag"].startswith(tag.unit("postgresql"))
61+
assert storage["storage-tag"].startswith(tag.storage("pgdata"))
62+
assert storage["life"] == "alive"
63+
assert storage["status"].status == "attached"
64+
65+
66+
@base.bootstrapped
67+
async def test_deploy_with_storage_preparsed():
68+
async with base.CleanModel() as model:
69+
await model.deploy(
70+
"postgresql",
71+
storage={"pgdata": {"size": 1024, "count": 1}},
72+
)
73+
await model.wait_for_idle(status="active")
74+
storages = await model.list_storage()
75+
assert len(storages) == 1
76+
[storage] = storages
77+
# size information isn't exposed, so can't assert on that
78+
assert storage["owner-tag"].startswith(tag.unit("postgresql"))
79+
assert storage["storage-tag"].startswith(tag.storage("pgdata"))
80+
assert storage["life"] == "alive"
81+
assert storage["status"].status == "attached"
82+
83+
4884
@base.bootstrapped
4985
@pytest.mark.bundle
5086
async def test_deploy_local_bundle_dir():

0 commit comments

Comments
 (0)