Skip to content

Commit 5d4e6aa

Browse files
authored
Merge pull request #701 from cderici/storage
#701 #### Description This adds the implementation of storage. In particular, it introduces the following functions: `unit.add_storage()` `unit.attach_storage()` `model.create_storage_pool()` `unit.detach_storage()` `model.list_storage()` `model.list_storage_pools()` `model.remove_storage()` `model.remove_storage_pool()` `model.show_storage()` `model.update_storage_pool()` Also adds the `--attach-storage` parameter to the `application.add_unit`. Fixes #694 and #695 #### QA Steps A couple of new tests are added to test the main functionality of storages and pools. In particular, all the following should pass: ``` tox -e integration -- tests/integration/test_model.py::test_add_storage ``` ``` tox -e integration -- tests/integration/test_model.py::test_list_storage ``` ``` tox -e integration -- tests/integration/test_model.py::test_storage_pools ``` #### Notes & Discussion `attach_storage` & `detach_storage` seem to work inconsistently on Juju's side, we're currently investigating, possibly a launchpad bug is coming, which makes it difficult/impossible to test their behavior here, so we skip the tests for these at the moment.
2 parents 871c10c + 0ad1ae1 commit 5d4e6aa

5 files changed

Lines changed: 267 additions & 3 deletions

File tree

juju/application.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,12 @@ async def relate(self, local_relation, remote_relation):
126126

127127
return await self.model.relate(local_relation, remote_relation)
128128

129-
async def add_unit(self, count=1, to=None):
129+
async def add_unit(self, count=1, to=None, attach_storage=[]):
130130
"""Add one or more units to this application.
131131
132132
:param int count: Number of units to add
133+
:param [str] attach_storage: Existing storage to attach to the deployed unit
134+
(not available on k8s models)
133135
:param str to: Placement directive, e.g.::
134136
'23' - machine 23
135137
'lxc:7' - new lxc container on machine 7
@@ -153,6 +155,7 @@ async def add_unit(self, count=1, to=None):
153155
application=self.name,
154156
placement=parse_placement(to) if to else None,
155157
num_units=count,
158+
attach_storage=attach_storage,
156159
)
157160

158161
return await jasyncio.gather(*[

juju/model.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -888,6 +888,123 @@ async def reset(self, force=False):
888888
lambda: len(self.machines) == 0
889889
)
890890

891+
async def create_storage_pool(self, name, provider_type, attributes=""):
892+
"""Create or define a storage pool.
893+
894+
:param str name: a pool name
895+
:param str provider_type: provider type (defaults to "kubernetes" for
896+
Kubernetes models)
897+
:param str attributes: attributes for configuration as space-separated pairs,
898+
e.g. tags, size, path, etc.
899+
:return:
900+
"""
901+
_attrs = [splt.split("=") for splt in attributes.split()]
902+
903+
storage_facade = client.StorageFacade.from_connection(self.connection())
904+
return await storage_facade.CreatePool(pools=[client.StoragePool(
905+
name=name,
906+
provider=provider_type,
907+
attrs=dict(_attrs)
908+
)])
909+
910+
async def remove_storage_pool(self, name):
911+
"""Remove an existing storage pool.
912+
913+
:param str name:
914+
:return:
915+
"""
916+
storage_facade = client.StorageFacade.from_connection(self.connection())
917+
return await storage_facade.RemovePool(pools=[client.StoragePoolDeleteArg(name)])
918+
919+
async def update_storage_pool(self, name, attributes=""):
920+
""" Update storage pool attributes.
921+
922+
:param name:
923+
:param attributes: "key=value key=value ..."
924+
:return:
925+
"""
926+
_attrs = dict([splt.split("=") for splt in attributes.split()])
927+
if len(_attrs) == 0:
928+
raise JujuError("Expected at least one attribute to update")
929+
930+
storage_facade = client.StorageFacade.from_connection(self.connection())
931+
return await storage_facade.UpdatePool(pools=[client.StoragePool(
932+
name=name,
933+
attrs=_attrs,
934+
)])
935+
936+
async def list_storage(self, filesystem=False, volume=False):
937+
"""Lists storage details.
938+
939+
:param bool filesystem: List filesystem storage
940+
:param bool volume: List volume storage
941+
:return:
942+
"""
943+
storage_facade = client.StorageFacade.from_connection(self.connection())
944+
945+
if filesystem and volume:
946+
raise JujuError("--filesystem and --volume can not be used together")
947+
if filesystem:
948+
_res = await storage_facade.ListFilesystems(filters=[client.FilesystemFilter()])
949+
elif volume:
950+
_res = await storage_facade.ListVolumes(filters=[client.VolumeFilter()])
951+
else:
952+
_res = await storage_facade.ListStorageDetails(filters=[client.StorageFilter()])
953+
954+
err = _res.results[0].error
955+
res = _res.results[0].result
956+
957+
if err is not None:
958+
raise JujuError(err.message)
959+
960+
return [details.serialize() for details in res]
961+
962+
async def show_storage_details(self, *storage_ids):
963+
"""Shows storage instance information.
964+
965+
:param []str storage_ids:
966+
:return:
967+
"""
968+
if not storage_ids:
969+
raise JujuError("Expected at least one storage ID")
970+
971+
storage_facade = client.StorageFacade.from_connection(self.connection())
972+
res = await storage_facade.StorageDetails(entities=[client.Entity(tag.storage(s)) for s in storage_ids])
973+
return [s.result.serialize() for s in res.results]
974+
975+
async def list_storage_pools(self):
976+
"""List storage pools.
977+
978+
:return:
979+
"""
980+
# TODO (cderici): Filter on pool type, name.
981+
storage_facade = client.StorageFacade.from_connection(self.connection())
982+
res = await storage_facade.ListPools(filters=[client.StoragePoolFilter()])
983+
err = res.results[0].error
984+
if err:
985+
raise JujuError(err.message)
986+
return [p.serialize() for p in res.results[0].storage_pools]
987+
988+
async def remove_storage(self, *storage_ids, force=False, destroy_storage=False):
989+
"""Removes storage from the model.
990+
991+
:param bool force: Remove storage even if it is currently attached
992+
:param bool destroy_storage: Remove the storage and destroy it
993+
:param []str storage_ids:
994+
:return:
995+
"""
996+
if not storage_ids:
997+
raise JujuError("Expected at least one storage ID")
998+
999+
storage_facade = client.StorageFacade.from_connection(self.connection())
1000+
ret = await storage_facade.Remove(storage=[client.RemoveStorageInstance(
1001+
destroy_storage=destroy_storage,
1002+
force=force,
1003+
tag=s,
1004+
) for s in storage_ids])
1005+
if ret.results[0].error:
1006+
raise JujuError(ret.results[0].error.message)
1007+
8911008
async def remove_application(self, app_name, block_until_done=False):
8921009
"""Removes the given application from the model.
8931010

juju/tag.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ def application(app_name):
4444
return _prefix('application-', app_name)
4545

4646

47+
def storage(app_name):
48+
return _prefix('storage-', app_name)
49+
50+
4751
def unit(unit_name):
4852
return _prefix('unit-', unit_name.replace('/', '-'))
4953

juju/unit.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import pyrfc3339
44

5-
from juju.errors import JujuAPIError
5+
from juju.errors import JujuAPIError, JujuError
66

77
from . import model, tag
88
from .annotationhelper import _get_annotations, _set_annotations
@@ -120,6 +120,72 @@ async def resolved(self, retry=False):
120120
retry=retry,
121121
tags={'entities': [{'tag': self.tag}]})
122122

123+
async def add_storage(self, storage_name, pool=None, count=1, size=1024):
124+
"""Creates a storage and adds it to this unit.
125+
126+
:param: str storage_name: Name of the storage
127+
:param: str pool: the storage pool to provision storage instances from. Must
128+
be a name from 'juju storage-pools'. The default pool is available via
129+
executing 'juju model-config storage-default-block-source'.
130+
:param: int count: the number of storage instances to provision from <storage-pool> of
131+
<size>. Must be a positive integer. The default count is "1". May be restricted
132+
by the charm, which can specify a maximum number of storage instances per unit.
133+
:param: int size: the required size of the storage instance, in MiB.
134+
135+
:return: []str storage_tags
136+
"""
137+
constraints = client.StorageConstraints(count=count)
138+
if pool:
139+
constraints = client.StorageConstraints(pool=pool, count=count, size=size)
140+
141+
storage_facade = client.StorageFacade.from_connection(self.connection)
142+
res = await storage_facade.AddToUnit(storages=[client.StorageAddParams(
143+
name=storage_name,
144+
unit=tag.unit(self.name),
145+
storage=constraints,
146+
)])
147+
result = res.results[0]
148+
if result.error is not None:
149+
raise JujuError("{}".format(result.error))
150+
storage_details = result.result
151+
return storage_details.storage_tags
152+
153+
async def attach_storage(self, storage_ids=[]):
154+
"""Attaches existing storage to this unit.
155+
156+
:param [str] storage_ids: existing storage ids to attach to the unit
157+
:return:
158+
"""
159+
if not storage_ids:
160+
raise JujuError("Expected a storage ID to be attached to unit {}".format(self.name))
161+
162+
storage_facade = client.StorageFacade.from_connection(self.connection)
163+
return await storage_facade.Attach(ids=[client.StorageAttachmentId(
164+
storage_tag=tag.storage(s_id),
165+
unit_tag=self.tag,
166+
) for s_id in storage_ids])
167+
168+
async def detach_storage(self, *storage_ids, force=False):
169+
"""Detaches storage from units.
170+
171+
:param bool force: Forcefully detach storage
172+
:param [str] storage_ids:
173+
:return:
174+
"""
175+
if not storage_ids:
176+
raise JujuError("Expected at least one storage ID")
177+
178+
storage_facade = client.StorageFacade.from_connection(self.connection)
179+
ret = await storage_facade.DetachStorage(
180+
force=force,
181+
ids=client.StorageAttachmentIds(ids=[client.StorageAttachmentId(
182+
storage_tag=tag.storage(s),
183+
unit_tag=self.tag,
184+
) for s in storage_ids])
185+
)
186+
if ret.results[0].error:
187+
raise JujuError(ret.results[0].error.message)
188+
123189
async def run(self, command, timeout=None):
124190
"""Run command on this unit.
125191

tests/integration/test_model.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import pylxd
1414
import pytest
15-
from juju import jasyncio
15+
from juju import jasyncio, tag
1616
from juju.client import client
1717
from juju.errors import JujuError, JujuUnitError, JujuConnectionError
1818
from juju.model import Model, ModelObserver
@@ -1051,3 +1051,77 @@ async def test_model_cache_update(event_loop):
10511051
await model.disconnect()
10521052
await m.disconnect()
10531053
await controller.destroy_models(model_name)
1054+
1055+
1056+
@base.bootstrapped
1057+
@pytest.mark.asyncio
1058+
async def test_add_storage(event_loop):
1059+
async with base.CleanModel() as model:
1060+
app = await model.deploy('postgresql')
1061+
await model.wait_for_idle(status="active")
1062+
unit = app.units[0]
1063+
ret = await unit.add_storage("pgdata")
1064+
assert any([tag.storage("pgdata") in s for s in ret])
1065+
1066+
1067+
@base.bootstrapped
1068+
@pytest.mark.asyncio
1069+
async def test_detach_storage(event_loop):
1070+
pytest.skip('detach/attach_storage inconsistent on Juju side, unable to test')
1071+
async with base.CleanModel() as model:
1072+
app = await model.deploy('postgresql')
1073+
await model.wait_for_idle(status="active")
1074+
unit = app.units[0]
1075+
storage_ids = await unit.add_storage("pgdata")
1076+
storage_id = storage_ids[0]
1077+
await jasyncio.sleep(5)
1078+
1079+
_storage_details_1 = await model.show_storage_details(storage_id)
1080+
storage_details_1 = _storage_details_1[0]
1081+
assert 'unit-postgresql-0' in storage_details_1['attachments']
1082+
1083+
await unit.detach_storage(storage_id, force=True)
1084+
await jasyncio.sleep(20)
1085+
1086+
_storage_details_2 = await model.show_storage_details(storage_id)
1087+
storage_details_2 = _storage_details_2[0]
1088+
assert ('unit-postgresql-0' not in storage_details_2['attachments']) or \
1089+
storage_details_2['attachments']['unit-postgresql-0'].life == 'dying'
1090+
1091+
# remove_storage
1092+
await model.remove_storage(storage_id, force=True)
1093+
await jasyncio.sleep(10)
1094+
storages = await model.list_storage()
1095+
assert all([storage_id not in s['storage-tag'] for s in storages])
1096+
1097+
1098+
@base.bootstrapped
1099+
@pytest.mark.asyncio
1100+
async def test_list_storage(event_loop):
1101+
async with base.CleanModel() as model:
1102+
app = await model.deploy('postgresql')
1103+
await model.wait_for_idle(status="active")
1104+
unit = app.units[0]
1105+
await unit.add_storage("pgdata")
1106+
storages = await model.list_storage()
1107+
await model.list_storage(filesystem=True)
1108+
await model.list_storage(volume=True)
1109+
1110+
assert any([tag.storage("pgdata") in s['storage-tag'] for s in storages])
1111+
1112+
1113+
@base.bootstrapped
1114+
@pytest.mark.asyncio
1115+
async def test_storage_pools(event_loop):
1116+
async with base.CleanModel() as model:
1117+
await model.deploy('postgresql')
1118+
await model.wait_for_idle(status="active")
1119+
1120+
await model.create_storage_pool("test-pool", "lxd")
1121+
pools = await model.list_storage_pools()
1122+
assert "test-pool" in [p['name'] for p in pools]
1123+
1124+
await model.remove_storage_pool("test-pool")
1125+
await jasyncio.sleep(5)
1126+
pools = await model.list_storage_pools()
1127+
assert "test-pool" not in [p['name'] for p in pools]

0 commit comments

Comments
 (0)