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_azure/requires.py b/ops/ops/interface_azure/requires.py new file mode 100644 index 0000000..bd7ae57 --- /dev/null +++ b/ops/ops/interface_azure/requires.py @@ -0,0 +1,329 @@ +# Copyright 2024 Canonical Ltd. +# See LICENSE file for licensing details. +"""Implementation of azure 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.request import urlopen, Request + + +log = logging.getLogger(__name__) + +# block size to read data from Azure metadata service +# (realistically, just needs to be bigger than ~20 chars) +READ_BLOCK_SIZE = 2048 + +# https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service +METADATA_URL = "http://169.254.169.254/metadata/instance?api-version=2017-12-01" # noqa +METADATA_HEADERS = {"Metadata": "true"} + + +def _request(url): + req = Request(url, headers=METADATA_HEADERS) + with urlopen(req) as fd: + return fd.read(READ_BLOCK_SIZE).decode("utf8").strip() + + +class AzureIntegrationRequires(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.azure = AzureIntegrationRequires(self) + ... + + def request_azure_integration(): + self.azure.request_instance_tags({ + 'tag1': 'value1', + 'tag2': None, + }) + azure.request_load_balancer_management() + # ... + + def check_azure_integration(): + if self.azure.is_ready(): + update_config_enable_azure() + ``` + """ + + _stored = ops.StoredState() + + def __init__(self, charm: ops.CharmBase, endpoint="azure"): + 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(vm_metadata=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 Azure 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 Azure 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, + "vm-id": self.vm_id, + "vm-name": self.vm_name, + "vm-location": self.vm_location, + "res-group": self.resource_group, + "subscription-id": self.subscription_id, + "model-uuid": os.environ["JUJU_MODEL_UUID"], + } + log.info( + "%s is vm_id=%s (vm_name=%s) in vm-location=%s", + self.charm.unit.name, + self.vm_id, + self.vm_name, + self.vm_location, + ) + self._request(info) + + @cached_property + def vm_metadata(self): + """This unit's metadata.""" + if self._stored.vm_metadata is None: + self._stored.vm_metadata = json.loads(_request(METADATA_URL)) + return self._stored.vm_metadata + + @property + def vm_id(self): + """This unit's instance-id.""" + return self.vm_metadata["compute"]["vmId"] + + @property + def vm_name(self): + """ + This unit's instance name. + """ + return self.vm_metadata["compute"]["name"] + + @property + def vm_location(self): + """ + The location (region) the instance is running in. + """ + return self.vm_metadata["compute"]["location"] + + @property + def resource_group(self): + """ + The resource group this unit is in. + """ + return self.vm_metadata["compute"]["resourceGroupName"] + + @property + def subscription_id(self): + """ + The ID of the Azure Subscription this unit is in. + """ + return self.vm_metadata["compute"]["subscriptionId"] + + @property + def resource_group_location(self): + """ + The location (region) the resource group is in. + """ + return self._received["resource-group-location"] + + @property + def vnet_name(self): + """ + The name of the virtual network the instance is in. + """ + return self._received["vnet-name"] + + @property + def vnet_resource_group(self): + """ + The name of the virtual network the instance is in. + """ + return self._received["vnet-resource-group"] + + @property + def subnet_name(self): + """ + The name of the subnet the instance is in. + """ + return self._received["subnet-name"] + + @property + def security_group_name(self): + """ + The name of the security group attached to the cluster's subnet. + """ + return self._received["security-group-name"] + + @property + def security_group_resource_group(self): + return self._received["security-group-resource-group"] + + @property + def managed_identity(self): + return self._received["use-managed-identity"] + + @property + def aad_client_id(self): + return self._received["aad-client"] + + @property + def aad_client_secret(self): + return self._received["aad-client-secret"] + + @property + def tenant_id(self): + return self._received["tenant-id"] + + @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.vm_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 + + 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-tags": dict(tags)}) + + """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_loadbalancer_management(self): + """ + Request the ability to manage networking. + """ + self._request({"enable-loadbalancer-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/azure_sent.yaml b/ops/tests/data/azure_sent.yaml new file mode 100644 index 0000000..c0ce229 --- /dev/null +++ b/ops/tests/data/azure_sent.yaml @@ -0,0 +1,23 @@ +egress-subnets: 172.31.39.57/32 +enable-acm-readonly: "true" +enable-acm-fullaccess: "true" +enable-autoscaling-readonly: "true" +enable-block-storage-management: "true" +enable-dns: "true" +enable-instance-inspection: "true" +enable-instance-modification: "true" +enable-loadbalancer-management: "true" +enable-network-management: "true" +enable-security-management: "true" +enable-region-readonly: "true" +ingress-address: 172.31.39.57 +instance-tags: '{"tag1": "val1", "tag2": "val2"}' +enable-object-storage-management: "true" +enable-object-storage-access: "true" +private-address: 172.31.39.57 +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/data/from_integrator.yaml b/ops/tests/data/from_integrator.yaml new file mode 100644 index 0000000..5d831ee --- /dev/null +++ b/ops/tests/data/from_integrator.yaml @@ -0,0 +1,11 @@ +completed: '{"i-abcdefghijklmnopq": "e0effe5f0504c3fb6ddad1bf44ddf57b682689b7cbb51634030e2109440e2869"}' +resource-group-location: rgl +vnet-name: vn +vnet-resource-group: vrg +subnet-name: sn +security-group-name: sgn +security-group-resource-group: sgrg +use-managed-identity: umi +aad-client: aci +aad-client-secret: acs +tenant-id: ti \ No newline at end of file diff --git a/ops/tests/unit/test_ops_requires.py b/ops/tests/unit/test_ops_requires.py new file mode 100644 index 0000000..422b765 --- /dev/null +++ b/ops/tests/unit/test_ops_requires.py @@ -0,0 +1,157 @@ +# 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_azure.requires import AzureIntegrationRequires, METADATA_URL +import os + + +class MyCharm(ops.CharmBase): + azure_meta = ops.RelationMeta( + ops.RelationRole.requires, "azure", {"interface": "azure-integration"} + ) + + def __init__(self, framework: ops.Framework): + super().__init__(framework) + self.azure = AzureIntegrationRequires(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.azure_meta.relation_name: MyCharm.azure_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_azure.requires.urlopen") as urlopen: + + def urlopener(req): + if req.full_url == METADATA_URL: + resp = dict( + compute=dict( + vmId="i-abcdefghijklmnopq", + name="n", + location="l", + resourceGroupName="rgn", + subscriptionId="si", + ) + ) + return io.BytesIO(json.dumps(resp).encode()) + + urlopen.side_effect = urlopener + yield urlopen + + +@pytest.fixture() +def integrator_data(): + yield yaml.safe_load(Path("tests/data/from_integrator.yaml").open()) + + +@pytest.fixture() +def sent_data(): + yield yaml.safe_load(Path("tests/data/azure_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.azure.is_ready is False + assert "Missing" in harness.charm.azure.evaluate_relation(event) + + rel_id = harness.add_relation("azure", "remote") + assert harness.charm.azure.is_ready is False + + rel = harness.model.get_relation("azure", rel_id) + harness.add_relation_unit(rel_id, "remote/0") + event = ops.RelationJoinedEvent(None, rel) + assert "Waiting" in harness.charm.azure.evaluate_relation(event) + + event = ops.RelationChangedEvent(None, rel) + harness.update_relation_data(rel_id, "remote/0", {"completed": "{}"}) + assert "Waiting" in harness.charm.azure.evaluate_relation(event) + + if event_type: + harness.remove_relation(rel_id) + event = event_type(None, rel) + assert "Missing" in harness.charm.azure.evaluate_relation(event) + + +def test_is_ready_success(harness, integrator_data): + chksum = json.loads(integrator_data["completed"])["i-abcdefghijklmnopq"] + completed = '{"i-abcdefghijklmnopq": "%s"}' % chksum + harness.add_relation("azure", "remote", unit_data={"completed": completed}) + assert harness.charm.azure.is_ready is True + event = ops.ConfigChangedEvent(None) + assert harness.charm.azure.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_loadbalancer_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("azure", "remote") + method = getattr(harness.charm.azure, 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 + + +@pytest.mark.parametrize( + "method_name, expected", + [ + ("resource_group_location", "rgl"), + ("vnet_name", "vn"), + ("vnet_resource_group", "vrg"), + ("subnet_name", "sn"), + ("security_group_name", "sgn"), + ("security_group_resource_group", "sgrg"), + ("managed_identity", "umi"), + ("aad_client_id", "aci"), + ("aad_client_secret", "acs"), + ("tenant_id", "ti"), + ], +) +def test_received_data(harness, method_name, expected, integrator_data): + harness.add_relation("azure", "remote", unit_data=integrator_data) + assert getattr(harness.charm.azure, method_name) == expected diff --git a/ops/tox.ini b/ops/tox.ini new file mode 100644 index 0000000..a132262 --- /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_azure' \ + --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 71d6661..8e9001a 100644 --- a/provides.py +++ b/provides.py @@ -46,21 +46,19 @@ 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 all_requests(self): """ A list of all requests that have been made. """ - if not hasattr(self, '_all_requests'): + if not hasattr(self, "_all_requests"): self._all_requests = [ - IntegrationRequest(unit) - for unit in self.all_joined_units + IntegrationRequest(unit) for unit in self.all_joined_units ] return self._all_requests @@ -70,8 +68,8 @@ def requests(self): A list of the new or updated #IntegrationRequests that have been made. """ - if not hasattr(self, '_requests'): - is_changed = attrgetter('is_changed') + if not hasattr(self, "_requests"): + is_changed = attrgetter("is_changed") self._requests = list(filter(is_changed, self.all_requests)) return self._requests @@ -87,12 +85,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 @@ -102,7 +104,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 = [] @@ -110,6 +112,7 @@ class IntegrationRequest: """ A request for integration from a single remote unit. """ + def __init__(self, unit): self._unit = unit @@ -119,11 +122,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,8 +134,9 @@ def is_changed(self): Whether this request has changed since the last time it was marked completed (if ever). """ - if not all([self.charm, self.vm_id, self.vm_name, - self.resource_group, self._requested]): + if not all( + [self.charm, self.vm_id, self.vm_name, self.resource_group, self._requested] + ): return False return self._completed.get(self.vm_id) != self._requested @@ -142,26 +146,35 @@ def mark_completed(self): """ completed = self._completed completed[self.vm_id] = self._requested - self._to_publish['completed'] = completed # have to explicitly update - - def send_additional_metadata(self, resource_group_location, - vnet_name, vnet_resource_group, - subnet_name, security_group_name, - security_group_resource_group, - use_managed_identity=True, aad_client=None, - aad_secret=None, tenant_id=None): - self._to_publish.update({ - 'resource-group-location': resource_group_location, - 'vnet-name': vnet_name, - 'vnet-resource-group': vnet_resource_group, - 'subnet-name': subnet_name, - 'security-group-name': security_group_name, - 'security-group-resource-group': security_group_resource_group, - 'use-managed-identity': use_managed_identity, - 'aad-client': aad_client, - 'aad-client-secret': aad_secret, - 'tenant-id': tenant_id - }) + self._to_publish["completed"] = completed # have to explicitly update + + def send_additional_metadata( + self, + resource_group_location, + vnet_name, + vnet_resource_group, + subnet_name, + security_group_name, + security_group_resource_group, + use_managed_identity=True, + aad_client=None, + aad_secret=None, + tenant_id=None, + ): + self._to_publish.update( + { + "resource-group-location": resource_group_location, + "vnet-name": vnet_name, + "vnet-resource-group": vnet_resource_group, + "subnet-name": subnet_name, + "security-group-name": security_group_name, + "security-group-resource-group": security_group_resource_group, + "use-managed-identity": use_managed_identity, + "aad-client": aad_client, + "aad-client-secret": aad_secret, + "tenant-id": tenant_id, + } + ) @property def relation_id(self): @@ -189,35 +202,35 @@ def charm(self): """ The charm name reported for this request. """ - return self._unit.received['charm'] + return self._unit.received["charm"] @property def vm_id(self): """ The instance ID reported for this request. """ - return self._unit.received['vm-id'] + return self._unit.received["vm-id"] @property def vm_name(self): """ The instance name reported for this request. """ - return self._unit.received['vm-name'] + return self._unit.received["vm-name"] @property def resource_group(self): """ The resource group reported for this request. """ - return self._unit.received['res-group'] + return self._unit.received["res-group"] @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_tags(self): @@ -225,61 +238,60 @@ def instance_tags(self): Mapping of tag 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-tags', {})) + return dict(self._unit.received.get("instance-tags", {})) @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_loadbalancer_management(self): """ Flag indicating whether the ability to manage networking was requested. """ - return bool(self._unit.received['enable-loadbalancer-management']) - + return bool(self._unit.received["enable-loadbalancer-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 3ff232c..bc91d4e 100644 --- a/requires.py +++ b/requires.py @@ -18,7 +18,6 @@ are requested. It should not be removed by the charm. """ - import json import os import random @@ -71,9 +70,12 @@ def azure_integration_ready(): update_config_enable_azure() ``` """ + # https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service - _metadata_url = 'http://169.254.169.254/metadata/instance?api-version=2017-12-01' # noqa - _metadata_headers = {'Metadata': 'true'} + _metadata_url = ( + "http://169.254.169.254/metadata/instance?api-version=2017-12-01" # noqa + ) + _metadata_headers = {"Metadata": "true"} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -102,54 +104,56 @@ def is_changed(self): """ Whether or not the request for this instance has changed. """ - return data_changed(self.expand_name('all-data'), [ - self.aad_client_id, - self.aad_client_secret, - self.managed_identity, - self.resource_group, - self.resource_group_location, - self.security_group_name, - self.security_group_resource_group, - self.subnet_name, - self.tenant_id, - self.vnet_name, - self.vnet_resource_group - ]) - - @when('endpoint.{endpoint_name}.joined') + return data_changed( + self.expand_name("all-data"), + [ + self.aad_client_id, + self.aad_client_secret, + self.managed_identity, + self.resource_group, + self.resource_group_location, + self.security_group_name, + self.security_group_resource_group, + self.subnet_name, + self.tenant_id, + self.vnet_name, + self.vnet_resource_group, + ], + ) + + @when("endpoint.{endpoint_name}.joined") def send_instance_info(self): - self._to_publish['charm'] = hookenv.charm_name() - self._to_publish['vm-id'] = self.vm_id - self._to_publish['vm-name'] = self.vm_name - self._to_publish['res-group'] = self.resource_group - self._to_publish['model-uuid'] = os.environ['JUJU_MODEL_UUID'] + self._to_publish["charm"] = hookenv.charm_name() + self._to_publish["vm-id"] = self.vm_id + self._to_publish["vm-name"] = self.vm_name + self._to_publish["res-group"] = self.resource_group + 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. - was_ready = is_flag_set(self.expand_name('ready')) - toggle_flag(self.expand_name('ready'), self.is_ready) + was_ready = is_flag_set(self.expand_name("ready")) + toggle_flag(self.expand_name("ready"), self.is_ready) if self.is_ready and was_ready and self.is_changed: - set_flag(self.expand_name('ready.changed')) - clear_flag(self.expand_name('changed')) + set_flag(self.expand_name("ready.changed")) + 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 vm_metadata(self): if self._vm_metadata is None: - cache_key = self.expand_name('vm-metadata') + cache_key = self.expand_name("vm-metadata") cached = unitdata.kv().get(cache_key) if cached: self._vm_metadata = cached else: - req = Request(self._metadata_url, - headers=self._metadata_headers) + req = Request(self._metadata_url, headers=self._metadata_headers) with urlopen(req) as fd: - metadata = fd.read(READ_BLOCK_SIZE).decode('utf8').strip() + metadata = fd.read(READ_BLOCK_SIZE).decode("utf8").strip() self._vm_metadata = json.loads(metadata) unitdata.kv().set(cache_key, self._vm_metadata) return self._vm_metadata @@ -159,106 +163,106 @@ def vm_id(self): """ This unit's instance ID. """ - return self.vm_metadata['compute']['vmId'] + return self.vm_metadata["compute"]["vmId"] @property def vm_name(self): """ This unit's instance name. """ - return self.vm_metadata['compute']['name'] + return self.vm_metadata["compute"]["name"] @property def vm_location(self): """ The location (region) the instance is running in. """ - return self.vm_metadata['compute']['location'] + return self.vm_metadata["compute"]["location"] @property def resource_group(self): """ The resource group this unit is in. """ - return self.vm_metadata['compute']['resourceGroupName'] + return self.vm_metadata["compute"]["resourceGroupName"] @property def resource_group_location(self): """ The location (region) the resource group is in. """ - return self._received['resource-group-location'] + return self._received["resource-group-location"] @property def subscription_id(self): """ The ID of the Azure Subscription this unit is in. """ - return self.vm_metadata['compute']['subscriptionId'] + return self.vm_metadata["compute"]["subscriptionId"] @property def vnet_name(self): """ The name of the virtual network the instance is in. """ - return self._received['vnet-name'] + return self._received["vnet-name"] @property def vnet_resource_group(self): """ The name of the virtual network the instance is in. """ - return self._received['vnet-resource-group'] + return self._received["vnet-resource-group"] @property def subnet_name(self): """ The name of the subnet the instance is in. """ - return self._received['subnet-name'] + return self._received["subnet-name"] @property def security_group_name(self): """ The name of the security group attached to the cluster's subnet. """ - return self._received['security-group-name'] + return self._received["security-group-name"] @property 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.vm_id) + requested = self._to_publish["requested"] + completed = self._received.get("completed", {}).get(self.vm_id) return requested and requested == completed @property def security_group_resource_group(self): - return self._received['security-group-resource-group'] + return self._received["security-group-resource-group"] @property def managed_identity(self): - return self._received['use-managed-identity'] + return self._received["use-managed-identity"] @property def aad_client_id(self): - return self._received['aad-client'] + return self._received["aad-client"] @property def aad_client_secret(self): - return self._received['aad-client-secret'] + return self._received["aad-client-secret"] @property def tenant_id(self): - return self._received['tenant-id'] + return self._received["tenant-id"] 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 tag_instance(self, tags): """ @@ -267,52 +271,52 @@ def tag_instance(self, tags): # Parameters `tags` (dict): Mapping of tags names to values. """ - self._request({'instance-tags': dict(tags)}) + self._request({"instance-tags": dict(tags)}) 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_loadbalancer_management(self): """ Request the ability to manage networking. """ - self._request({'enable-loadbalancer-management': True}) + self._request({"enable-loadbalancer-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 c11843c..041b30b 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 -- {posargs} + +[flake8] +max-line-length = 88 +extend-ignore = E203