diff --git a/.github/workflows/main.yaml b/.github/workflows/main.yaml new file mode 100644 index 0000000..7f2e06e --- /dev/null +++ b/.github/workflows/main.yaml @@ -0,0 +1,18 @@ +name: Run tests with Tox + +on: [pull_request] + +jobs: + call-inclusive-naming-check: + name: Inclusive naming + uses: canonical-web-and-design/Inclusive-naming/.github/workflows/woke.yaml@main + with: + fail-on-error: "true" + + lint-unit: + name: Lint Unit + uses: charmed-kubernetes/workflows/.github/workflows/lint-unit.yaml@main + with: + python: "['3.8', '3.9', '3.10', '3.11', '3.12']" + needs: + - call-inclusive-naming-check diff --git a/.gitignore b/.gitignore index 5f9f2c5..712b257 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .tox __pycache__ *.pyc +*.egg-info +**/.coverage \ No newline at end of file diff --git a/ops/ops/interface_gcp/requires.py b/ops/ops/interface_gcp/requires.py new file mode 100644 index 0000000..976e7b2 --- /dev/null +++ b/ops/ops/interface_gcp/requires.py @@ -0,0 +1,249 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +"""Implementation of gcp interface. + +This only implements the requires side, currently, since the providers +is still using the Reactive Charm framework self. +""" +import json +from hashlib import sha256 +import logging +import ops +import os +from functools import cached_property +from typing import Mapping, Optional +from urllib.parse import urljoin +from urllib.request import urlopen, Request + + +log = logging.getLogger(__name__) + +# block size to read data from GCP metadata service +# (realistically, just needs to be bigger than ~20 chars) +READ_BLOCK_SIZE = 2048 + +# https://cloud.google.com/compute/docs/storing-retrieving-metadata +METADATA_URL = "http://metadata.google.internal/computeMetadata/v1/" +INSTANCE_URL = urljoin(METADATA_URL, "instance/name") +ZONE_URL = urljoin(METADATA_URL, "instance/zone") +METADATA_HEADERS = {"Metadata-Flavor": "Google"} + + +def _request(url): + req = Request(url, headers=METADATA_HEADERS) + with urlopen(req) as fd: + return fd.read(READ_BLOCK_SIZE).decode("utf8") + + +class GCPIntegrationRequires(ops.Object): + """ + + Interface to request integration access. + + Note that due to resource limits and permissions granularity, policies are + limited to being applied at the charm level. That means that, if any + permissions are requested (i.e., any of the enable methods are called), + what is granted will be the sum of those ever requested by any instance of + the charm on this cloud. + + Labels, on the other hand, will be instance specific. + + Example usage: + + ```python + + class MyCharm(ops.CharmBase): + + def __init__(self, *args): + super().__init__(*args) + self.gcp = GCPIntegrationRequires(self) + ... + + def request_gcp_integration(): + self.gcp.request_instance_tags({ + 'tag1': 'value1', + 'tag2': None, + }) + gcp.request_load_balancer_management() + # ... + + def check_gcp_integration(): + if self.gcp.is_ready(): + update_config_enable_gcp() + ``` + """ + + _stored = ops.StoredState() + + def __init__(self, charm: ops.CharmBase, endpoint="gcp"): + super().__init__(charm, f"relation-{endpoint}") + self.endpoint = endpoint + self.charm = charm + + events = charm.on[endpoint] + self.framework.observe(events.relation_joined, self.send_instance_info) + self._stored.set_default(instance_id=None, zone=None) + + @property + def relation(self) -> Optional[ops.Relation]: + """The relation to the integrator, or None.""" + relations = self.charm.model.relations.get(self.endpoint) + return relations[0] if relations else None + + @property + def _received(self) -> Mapping[str, str]: + """ + Helper to streamline access to received data since we expect to only + ever be connected to a single GCP integration application with a + single unit. + """ + if self.relation and self.relation.units: + return self.relation.data[list(self.relation.units)[0]] + return {} + + @property + def _to_publish(self): + """ + Helper to streamline access to received data since we expect to only + ever be connected to a single GCP integration application with a + single unit. + """ + if self.relation: + return self.relation.data[self.charm.model.unit] + return {} + + def send_instance_info(self, _): + info = { + "charm": self.charm.meta.name, + "instance": self.instance_id, + "model-uuid": os.environ["JUJU_MODEL_UUID"], + "zone": self.zone, + } + log.info( + "%s is instance=%s in zone=%s", + self.charm.unit.name, + self.instance_id, + self.zone, + ) + self._request(info) + + @cached_property + def instance_id(self): + """This unit's instance-id.""" + if self._stored.instance_id is None: + self._stored.instance_id = _request(INSTANCE_URL) + return self._stored.instance_id + + @cached_property + def zone(self): + """The zone this unit is in.""" + if self._stored.zone is None: + zone = _request(ZONE_URL) + self._stored.zone = zone.strip().split("/")[-1] + return self._stored.zone + + @property + def is_ready(self): + """ + Whether or not the request for this instance has been completed. + """ + requested = self._to_publish.get("requested") + completed = json.loads(self._received.get("completed", "{}")).get( + self.instance_id + ) + ready = bool(requested and requested == completed) + if not requested: + log.warning("Local end has yet to request integration") + if not completed: + log.warning("Remote end has yet to calculate a response") + elif not ready: + log.warning( + "Waiting for completed=%s to be requested=%s", completed, requested + ) + return ready + + @property + def credentials(self): + return self._received["credentials"] + + def evaluate_relation(self, event) -> Optional[str]: + """Determine if relation is ready.""" + no_relation = not self.relation or ( + isinstance(event, ops.RelationBrokenEvent) + and event.relation is self.relation + ) + if no_relation: + return f"Missing required {self.endpoint}" + if not self.is_ready: + return f"Waiting for {self.endpoint}" + return None + + @property + def _expected_hash(self): + def from_json(s: str): + try: + return json.loads(s) + except json.decoder.JSONDecodeError: + return s + + to_sha = {key: from_json(val) for key, val in self._to_publish.items()} + return sha256(json.dumps(to_sha, sort_keys=True).encode()).hexdigest() + + def _request(self, keyvals): + kwds = {key: json.dumps(val) for key, val in keyvals.items()} + self._to_publish.update(**kwds) + self._to_publish["requested"] = self._expected_hash + + def tag_instance(self, tags): + """ + Request that the given tags be applied to this instance. + + # Parameters + `tags` (dict): Mapping of tag names to values (or `None`). + """ + self._request({"instance-labels": dict(tags)}) + + label_instance = tag_instance + """Alias for tag_instance""" + + def enable_instance_inspection(self): + """ + Request the ability to inspect instances. + """ + self._request({"enable-instance-inspection": True}) + + def enable_network_management(self): + """ + Request the ability to manage networking. + """ + self._request({"enable-network-management": True}) + + def enable_security_management(self): + """ + Request the ability to manage security (e.g., firewalls). + """ + self._request({"enable-security-management": True}) + + def enable_block_storage_management(self): + """ + Request the ability to manage block storage. + """ + self._request({"enable-block-storage-management": True}) + + def enable_dns_management(self): + """ + Request the ability to manage DNS. + """ + self._request({"enable-dns": True}) + + def enable_object_storage_access(self): + """ + Request the ability to access object storage. + """ + self._request({"enable-object-storage-access": True}) + + def enable_object_storage_management(self): + """ + Request the ability to manage object storage. + """ + self._request({"enable-object-storage-management": True}) diff --git a/ops/pyproject.toml b/ops/pyproject.toml new file mode 100644 index 0000000..02233cf --- /dev/null +++ b/ops/pyproject.toml @@ -0,0 +1,28 @@ +[build-system] +requires = ["setuptools>=61.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "ops.interface_gcp" +version = "0.1.0" +authors = [ + {name="Canonical Kubernetes", email="k8s-crew@lists.canonical.com"}, +] +description = "Charm library for installing and configuring gcp integration" +readme = "README.md" +requires-python = ">=3.8" +dependencies = [ + "ops", + "packaging", +] +classifiers = [ + "License :: OSI Approved :: Apache Software License", + "Programming Language :: Python :: 3", +] + +[project.urls] +"Homepage" = "https://github.com/charmed-kubernetes/interace-gcp-integration" +"Bug Tracker" = "https://github.com/charmed-kubernetes/interace-gcp-integration/issues" + +[tool.setuptools.packages.find] +include = ["ops.*"] diff --git a/ops/tests/data/gcp_sent.yaml b/ops/tests/data/gcp_sent.yaml new file mode 100644 index 0000000..e63e876 --- /dev/null +++ b/ops/tests/data/gcp_sent.yaml @@ -0,0 +1,17 @@ +egress-subnets: 172.31.39.57/32 +ingress-address: 172.31.39.57 +private-address: 172.31.39.57 +enable-block-storage-management: "true" +enable-dns: "true" +enable-instance-inspection: "true" +enable-network-management: "true" +enable-security-management: "true" +instance-labels: '{"tag1": "val1", "tag2": "val2"}' +enable-object-storage-management: "true" +enable-object-storage-access: "true" +zone: '"us-east-1"' +requested: "true" +charm: "test" +instance: '"i-abcdefghijklmnopq"' +zone: '"us-east1"' +model-uuid: '"cf67b90e-7201-4f23-8c0a-e1f453f1dc2e"' diff --git a/ops/tests/unit/test_ops_requires.py b/ops/tests/unit/test_ops_requires.py new file mode 100644 index 0000000..f0fd87b --- /dev/null +++ b/ops/tests/unit/test_ops_requires.py @@ -0,0 +1,124 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +import io +import json +import unittest.mock as mock +from pathlib import Path + +import pytest +import yaml +import ops +import ops.testing +from ops.interface_gcp.requires import GCPIntegrationRequires, INSTANCE_URL, ZONE_URL +import os + + +class MyCharm(ops.CharmBase): + gcp_meta = ops.RelationMeta( + ops.RelationRole.requires, "gcp", {"interface": "gcp-integration"} + ) + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + self.gcp = GCPIntegrationRequires(self) + + +@pytest.fixture(autouse=True) +def juju_enviro(): + with mock.patch.dict( + os.environ, {"JUJU_MODEL_UUID": "cf67b90e-7201-4f23-8c0a-e1f453f1dc2e"} + ): + yield + + +@pytest.fixture(scope="function") +def harness(): + harness = ops.testing.Harness(MyCharm) + harness.framework.meta.name = "test" + harness.framework.meta.relations = { + MyCharm.gcp_meta.relation_name: MyCharm.gcp_meta + } + harness.set_model_name("test/0") + harness.begin_with_initial_hooks() + yield harness + + +@pytest.fixture(autouse=True) +def mock_url(): + with mock.patch("ops.interface_gcp.requires.urlopen") as urlopen: + + def urlopener(req): + if req.full_url == INSTANCE_URL: + return io.BytesIO(b"i-abcdefghijklmnopq") + elif req.full_url == ZONE_URL: + return io.BytesIO(b"us-east1") + + urlopen.side_effect = urlopener + yield urlopen + + +@pytest.fixture() +def sent_data(): + yield yaml.safe_load(Path("tests/data/gcp_sent.yaml").open()) + + +@pytest.mark.parametrize( + "event_type", [None, ops.RelationBrokenEvent], ids=["unrelated", "dropped relation"] +) +def test_is_ready_no_relation(harness, event_type): + event = ops.ConfigChangedEvent(None) + assert harness.charm.gcp.is_ready is False + assert "Missing" in harness.charm.gcp.evaluate_relation(event) + + rel_id = harness.add_relation("gcp", "remote") + assert harness.charm.gcp.is_ready is False + + rel = harness.model.get_relation("gcp", rel_id) + harness.add_relation_unit(rel_id, "remote/0") + event = ops.RelationJoinedEvent(None, rel) + assert "Waiting" in harness.charm.gcp.evaluate_relation(event) + + event = ops.RelationChangedEvent(None, rel) + harness.update_relation_data(rel_id, "remote/0", {"completed": "{}"}) + assert "Waiting" in harness.charm.gcp.evaluate_relation(event) + + if event_type: + harness.remove_relation(rel_id) + event = event_type(None, rel) + assert "Missing" in harness.charm.gcp.evaluate_relation(event) + + +def test_is_ready_success(harness): + chksum = "72bd9ed0dbf680ea356bca7d78f1cda9e6484227f0d270b0602128ecb622686f" + completed = '{"i-abcdefghijklmnopq": "%s"}' % chksum + harness.add_relation("gcp", "remote", unit_data={"completed": completed}) + assert harness.charm.gcp.is_ready is True + event = ops.ConfigChangedEvent(None) + assert harness.charm.gcp.evaluate_relation(event) is None + + +@pytest.mark.parametrize( + "method_name, args", + [ + ("tag_instance", 'tags={"tag1": "val1", "tag2": "val2"}'), + ("enable_instance_inspection", None), + ("enable_network_management", None), + ("enable_security_management", None), + ("enable_block_storage_management", None), + ("enable_dns_management", None), + ("enable_object_storage_access", None), + ("enable_object_storage_management", None), + ], +) +def test_request_simple(harness, method_name, args, sent_data): + rel_id = harness.add_relation("gcp", "remote") + method = getattr(harness.charm.gcp, method_name) + kwargs = {} + if args: + kw, val = args.split("=") + kwargs[kw] = json.loads(val) + method(**kwargs) + data = harness.get_relation_data(rel_id, harness.charm.unit.name) + assert data.pop("requested") + for each, value in data.items(): + assert sent_data[each] == value diff --git a/ops/tox.ini b/ops/tox.ini new file mode 100644 index 0000000..74e3c0b --- /dev/null +++ b/ops/tox.ini @@ -0,0 +1,27 @@ +[tox] +envlist = unit + +[vars] +tst_path = {toxinidir}/tests + +[testenv] +basepython = python3 +setenv = + PYTHONPATH = {toxinidir} + +[testenv:unit] +deps = + pytest-cov + pytest-html +commands = + pytest \ + -vv \ + --log-cli-level=INFO \ + --cov='{envsitepackagesdir}/ops/interface_gcp' \ + --cov-report=term-missing \ + --tb=native \ + {posargs:{[vars]tst_path}/unit} + +[flake8] +exclude=.tox +max-line-length = 88 \ No newline at end of file diff --git a/provides.py b/provides.py index ba34b0d..db7f7dd 100644 --- a/provides.py +++ b/provides.py @@ -46,11 +46,10 @@ def handle_requests(): ``` """ - @when('endpoint.{endpoint_name}.changed') + @when("endpoint.{endpoint_name}.changed") def check_requests(self): - toggle_flag(self.expand_name('requests-pending'), - len(self.requests) > 0) - clear_flag(self.expand_name('changed')) + toggle_flag(self.expand_name("requests-pending"), len(self.requests) > 0) + clear_flag(self.expand_name("changed")) @property def requests(self): @@ -58,10 +57,9 @@ def requests(self): A list of the new or updated #IntegrationRequests that have been made. """ - if not hasattr(self, '_requests'): - all_requests = [IntegrationRequest(unit) - for unit in self.all_joined_units] - is_changed = attrgetter('is_changed') + if not hasattr(self, "_requests"): + all_requests = [IntegrationRequest(unit) for unit in self.all_joined_units] + is_changed = attrgetter("is_changed") self._requests = list(filter(is_changed, all_requests)) return self._requests @@ -77,12 +75,16 @@ def get_departed_charms(self): Get a list of all charms that have had all units depart since the last time this was called. """ - joined_charms = {unit.received['charm'] - for unit in self.all_joined_units - if unit.received['charm']} - departed_charms = [unit.received['charm'] - for unit in self.all_departed_units - if unit.received['charm'] not in joined_charms] + joined_charms = { + unit.received["charm"] + for unit in self.all_joined_units + if unit.received["charm"] + } + departed_charms = [ + unit.received["charm"] + for unit in self.all_departed_units + if unit.received["charm"] not in joined_charms + ] self.all_departed_units.clear() return departed_charms @@ -92,7 +94,7 @@ def mark_completed(self): """ for request in self.requests: request.mark_completed() - clear_flag(self.expand_name('requests-pending')) + clear_flag(self.expand_name("requests-pending")) self._requests = [] @@ -100,6 +102,7 @@ class IntegrationRequest: """ A request for integration from a single remote unit. """ + def __init__(self, unit): self._unit = unit @@ -109,11 +112,11 @@ def _to_publish(self): @property def _completed(self): - return self._to_publish.get('completed', {}) + return self._to_publish.get("completed", {}) @property def _requested(self): - return self._unit.received['requested'] + return self._unit.received["requested"] @property def is_changed(self): @@ -131,20 +134,20 @@ def mark_completed(self): """ completed = self._completed completed[self.instance] = self._requested - self._to_publish['completed'] = completed # have to explicitly update + self._to_publish["completed"] = completed # have to explicitly update def set_credentials(self, credentials): """ Set the credentials for this request. """ - self._unit.relation.to_publish['credentials'] = credentials + self._unit.relation.to_publish["credentials"] = credentials @property def has_credentials(self): """ Whether or not credentials have been set via `set_credentials`. """ - return 'credentials' in self._unit.relation.to_publish + return "credentials" in self._unit.relation.to_publish @property def relation_id(self): @@ -172,28 +175,28 @@ def charm(self): """ The charm name reported for this request. """ - return self._unit.received['charm'] + return self._unit.received["charm"] @property def instance(self): """ The instance name reported for this request. """ - return self._unit.received['instance'] + return self._unit.received["instance"] @property def zone(self): """ The zone reported for this request. """ - return self._unit.received['zone'] + return self._unit.received["zone"] @property def model_uuid(self): """ The UUID of the model containing the application making this request. """ - return self._unit.received['model-uuid'] + return self._unit.received["model-uuid"] @property def instance_labels(self): @@ -201,53 +204,53 @@ def instance_labels(self): Mapping of label names to values to apply to this instance. """ # uses dict() here to make a copy, just to be safe - return dict(self._unit.received.get('instance-labels', {})) + return dict(self._unit.received.get("instance-labels", {})) @property def requested_instance_inspection(self): """ Flag indicating whether the ability to inspect instances was requested. """ - return bool(self._unit.received['enable-instance-inspection']) + return bool(self._unit.received["enable-instance-inspection"]) @property def requested_network_management(self): """ Flag indicating whether the ability to manage networking was requested. """ - return bool(self._unit.received['enable-network-management']) + return bool(self._unit.received["enable-network-management"]) @property def requested_security_management(self): """ Flag indicating whether security management was requested. """ - return bool(self._unit.received['enable-security-management']) + return bool(self._unit.received["enable-security-management"]) @property def requested_block_storage_management(self): """ Flag indicating whether block storage management was requested. """ - return bool(self._unit.received['enable-block-storage-management']) + return bool(self._unit.received["enable-block-storage-management"]) @property def requested_dns_management(self): """ Flag indicating whether DNS management was requested. """ - return bool(self._unit.received['enable-dns-management']) + return bool(self._unit.received["enable-dns-management"]) @property def requested_object_storage_access(self): """ Flag indicating whether object storage access was requested. """ - return bool(self._unit.received['enable-object-storage-access']) + return bool(self._unit.received["enable-object-storage-access"]) @property def requested_object_storage_management(self): """ Flag indicating whether object storage management was requested. """ - return bool(self._unit.received['enable-object-storage-management']) + return bool(self._unit.received["enable-object-storage-management"]) diff --git a/requires.py b/requires.py index bbd191f..2bcef82 100644 --- a/requires.py +++ b/requires.py @@ -18,7 +18,6 @@ are requested. It should not be removed by the charm. """ - import os import random import string @@ -70,11 +69,12 @@ def gcp_integration_ready(): update_config_enable_gcp() ``` """ + # https://cloud.google.com/compute/docs/storing-retrieving-metadata - _metadata_url = 'http://metadata.google.internal/computeMetadata/v1/' - _instance_url = urljoin(_metadata_url, 'instance/name') - _zone_url = urljoin(_metadata_url, 'instance/zone') - _metadata_headers = {'Metadata-Flavor': 'Google'} + _metadata_url = "http://metadata.google.internal/computeMetadata/v1/" + _instance_url = urljoin(_metadata_url, "instance/name") + _zone_url = urljoin(_metadata_url, "instance/zone") + _metadata_headers = {"Metadata-Flavor": "Google"} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -99,23 +99,23 @@ def _to_publish(self): """ return self.relations[0].to_publish - @when('endpoint.{endpoint_name}.joined') + @when("endpoint.{endpoint_name}.joined") def send_instance_info(self): - self._to_publish['charm'] = hookenv.charm_name() - self._to_publish['instance'] = self.instance - self._to_publish['zone'] = self.zone - self._to_publish['model-uuid'] = os.environ['JUJU_MODEL_UUID'] + self._to_publish["charm"] = hookenv.charm_name() + self._to_publish["instance"] = self.instance + self._to_publish["zone"] = self.zone + self._to_publish["model-uuid"] = os.environ["JUJU_MODEL_UUID"] - @when('endpoint.{endpoint_name}.changed') + @when("endpoint.{endpoint_name}.changed") def check_ready(self): # My middle name is ready. No, that doesn't sound right. # I eat ready for breakfast. - toggle_flag(self.expand_name('ready'), self.is_ready) - clear_flag(self.expand_name('changed')) + toggle_flag(self.expand_name("ready"), self.is_ready) + clear_flag(self.expand_name("changed")) - @when_not('endpoint.{endpoint_name}.joined') + @when_not("endpoint.{endpoint_name}.joined") def remove_ready(self): - clear_flag(self.expand_name('ready')) + clear_flag(self.expand_name("ready")) @property def instance(self): @@ -123,15 +123,14 @@ def instance(self): This unit's instance name. """ if self._instance is None: - cache_key = self.expand_name('instance') + cache_key = self.expand_name("instance") cached = unitdata.kv().get(cache_key) if cached: self._instance = cached else: - req = Request(self._instance_url, - headers=self._metadata_headers) + req = Request(self._instance_url, headers=self._metadata_headers) with urlopen(req) as fd: - instance = fd.read(READ_BLOCK_SIZE).decode('utf8').strip() + instance = fd.read(READ_BLOCK_SIZE).decode("utf8").strip() self._instance = instance unitdata.kv().set(cache_key, self._instance) return self._instance @@ -142,16 +141,15 @@ def zone(self): The zone this unit is in. """ if self._zone is None: - cache_key = self.expand_name('zone') + cache_key = self.expand_name("zone") cached = unitdata.kv().get(cache_key) if cached: self._zone = cached else: - req = Request(self._zone_url, - headers=self._metadata_headers) + req = Request(self._zone_url, headers=self._metadata_headers) with urlopen(req) as fd: - zone = fd.read(READ_BLOCK_SIZE).decode('utf8').strip() - self._zone = zone.split('/')[-1] + zone = fd.read(READ_BLOCK_SIZE).decode("utf8").strip() + self._zone = zone.split("/")[-1] unitdata.kv().set(cache_key, self._zone) return self._zone @@ -160,20 +158,20 @@ def is_ready(self): """ Whether or not the request for this instance has been completed. """ - requested = self._to_publish['requested'] - completed = self._received.get('completed', {}).get(self.instance) + requested = self._to_publish["requested"] + completed = self._received.get("completed", {}).get(self.instance) return requested and requested == completed @property def credentials(self): - return self._received['credentials'] + return self._received["credentials"] def _request(self, keyvals): alphabet = string.ascii_letters + string.digits - nonce = ''.join(random.choice(alphabet) for _ in range(8)) + nonce = "".join(random.choice(alphabet) for _ in range(8)) self._to_publish.update(keyvals) - self._to_publish['requested'] = nonce - clear_flag(self.expand_name('ready')) + self._to_publish["requested"] = nonce + clear_flag(self.expand_name("ready")) def label_instance(self, labels): """ @@ -182,46 +180,46 @@ def label_instance(self, labels): # Parameters `labels` (dict): Mapping of labels names to values. """ - self._request({'instance-labels': dict(labels)}) + self._request({"instance-labels": dict(labels)}) def enable_instance_inspection(self): """ Request the ability to inspect instances. """ - self._request({'enable-instance-inspection': True}) + self._request({"enable-instance-inspection": True}) def enable_network_management(self): """ Request the ability to manage networking. """ - self._request({'enable-network-management': True}) + self._request({"enable-network-management": True}) def enable_security_management(self): """ Request the ability to manage security (e.g., firewalls). """ - self._request({'enable-security-management': True}) + self._request({"enable-security-management": True}) def enable_block_storage_management(self): """ Request the ability to manage block storage. """ - self._request({'enable-block-storage-management': True}) + self._request({"enable-block-storage-management": True}) def enable_dns_management(self): """ Request the ability to manage DNS. """ - self._request({'enable-dns': True}) + self._request({"enable-dns": True}) def enable_object_storage_access(self): """ Request the ability to access object storage. """ - self._request({'enable-object-storage-access': True}) + self._request({"enable-object-storage-access": True}) def enable_object_storage_management(self): """ Request the ability to manage object storage. """ - self._request({'enable-object-storage-management': True}) + self._request({"enable-object-storage-management": True}) diff --git a/tox.ini b/tox.ini index fcec9c1..f0cd276 100644 --- a/tox.ini +++ b/tox.ini @@ -3,6 +3,7 @@ envlist = py3 skipsdist = true [testenv] +allowlist_externals=tox basepython=python3 envdir={toxworkdir}/py3 deps= @@ -12,3 +13,24 @@ deps= [testenv:docs] commands=python make_docs + +[testenv:format] +envdir = {toxworkdir}/lint +deps = black +commands = black {toxinidir}/requires.py {toxinidir}/provides.py {toxinidir}/ops + +[testenv:lint] +deps = + flake8 + black +commands = + flake8 {toxinidir}/requires.py {toxinidir}/provides.py {toxinidir}/ops + black --check {toxinidir}/requires.py {toxinidir}/provides.py {toxinidir}/ops + +[testenv:unit] +deps = +commands=tox -c {toxinidir}/ops/ -re unit + +[flake8] +max-line-length = 88 +extend-ignore = E203