commit 8f1974b09f76083ad120de970ffc58b703633eb8 Author: Liam Young Date: Mon Jan 31 12:53:45 2022 +0000 First cut diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..8ef84fc --- /dev/null +++ b/.flake8 @@ -0,0 +1,9 @@ +[flake8] +max-line-length = 99 +select: E,W,F,C,N +exclude: + venv + .git + build + dist + *.egg_info diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..de9170b --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +venv/ +build/ +*.charm +*.swp + +.coverage +__pycache__/ +*.py[cod] +.tox +.stestr/ diff --git a/.jujuignore b/.jujuignore new file mode 100644 index 0000000..6ccd559 --- /dev/null +++ b/.jujuignore @@ -0,0 +1,3 @@ +/venv +*.py[cod] +*.charm diff --git a/.stestr.conf b/.stestr.conf new file mode 100644 index 0000000..5fcccac --- /dev/null +++ b/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./unit_tests +top_dir=./ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..44a1057 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,34 @@ +# sunbeam-ovn-relay-operator + +## Developing + +Create and activate a virtualenv with the development requirements: + + virtualenv -p python3 venv + source venv/bin/activate + pip install -r requirements-dev.txt + +## Code overview + +TEMPLATE-TODO: +One of the most important things a consumer of your charm (or library) +needs to know is what set of functionality it provides. Which categories +does it fit into? Which events do you listen to? Which libraries do you +consume? Which ones do you export and how are they used? + +## Intended use case + +TEMPLATE-TODO: +Why were these decisions made? What's the scope of your charm? + +## Roadmap + +If this Charm doesn't fulfill all of the initial functionality you were +hoping for or planning on, please add a Roadmap or TODO here + +## Testing + +The Python operator framework includes a very nice harness for testing +operator behaviour without full deployment. Just `run_tests`: + + ./run_tests diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d645695 --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..bfbf10b --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +# sunbeam-ovn-relay-operator + +## Description + +TODO: Describe your charm in a few paragraphs of Markdown + +## Usage + +TODO: Provide high-level usage, such as required config or relations + + +## Relations + +TODO: Provide any relations which are provided or required by your charm + +## OCI Images + +TODO: Include a link to the default image your charm uses + +## Contributing + +Please see the [Juju SDK docs](https://juju.is/docs/sdk) for guidelines +on enhancements to this charm following best practice guidelines, and +`CONTRIBUTING.md` for developer guidance. diff --git a/actions.yaml b/actions.yaml new file mode 100644 index 0000000..88e6195 --- /dev/null +++ b/actions.yaml @@ -0,0 +1,2 @@ +# NOTE: no actions yet! +{ } diff --git a/charmcraft.yaml b/charmcraft.yaml new file mode 100644 index 0000000..65ce952 --- /dev/null +++ b/charmcraft.yaml @@ -0,0 +1,19 @@ +# Learn more about charmcraft.yaml configuration at: +# https://juju.is/docs/sdk/charmcraft-config +type: "charm" +bases: + - build-on: + - name: "ubuntu" + channel: "20.04" + run-on: + - name: "ubuntu" + channel: "20.04" +parts: + charm: + build-packages: + - git + - libffi-dev + - libssl-dev + charm-python-packages: + - setuptools < 58 + - cryptography < 3.4 diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..2e4e76d --- /dev/null +++ b/config.yaml @@ -0,0 +1,11 @@ +# Copyright 2022 liam +# See LICENSE file for licensing details. +# + +options: + os-public-hostname: + default: ovn-relay.juju + description: | + The hostname or address of the internal endpoints that should be advertised + in the neutron image provider. + type: string diff --git a/lib/charms/observability_libs/v0/kubernetes_service_patch.py b/lib/charms/observability_libs/v0/kubernetes_service_patch.py new file mode 100644 index 0000000..a3fb910 --- /dev/null +++ b/lib/charms/observability_libs/v0/kubernetes_service_patch.py @@ -0,0 +1,280 @@ +# Copyright 2021 Canonical Ltd. +# See LICENSE file for licensing details. + +"""# KubernetesServicePatch Library. + +This library is designed to enable developers to more simply patch the Kubernetes Service created +by Juju during the deployment of a sidecar charm. When sidecar charms are deployed, Juju creates a +service named after the application in the namespace (named after the Juju model). This service by +default contains a "placeholder" port, which is 65536/TCP. + +When modifying the default set of resources managed by Juju, one must consider the lifecycle of the +charm. In this case, any modifications to the default service (created during deployment), will +be overwritten during a charm upgrade. + +When initialised, this library binds a handler to the parent charm's `install` and `upgrade_charm` +events which applies the patch to the cluster. This should ensure that the service ports are +correct throughout the charm's life. + +The constructor simply takes a reference to the parent charm, and a list of tuples that each define +a port for the service, where each tuple contains: + +- a name for the port +- port for the service to listen on +- optionally: a targetPort for the service (the port in the container!) +- optionally: a nodePort for the service (for NodePort or LoadBalancer services only!) +- optionally: a name of the service (in case service name needs to be patched as well) + +## Getting Started + +To get started using the library, you just need to fetch the library using `charmcraft`. **Note +that you also need to add `lightkube` and `lightkube-models` to your charm's `requirements.txt`.** + +```shell +cd some-charm +charmcraft fetch-lib charms.observability_libs.v0.kubernetes_service_patch +echo <<-EOF >> requirements.txt +lightkube +lightkube-models +EOF +``` + +Then, to initialise the library: + +For ClusterIP services: +```python +# ... +from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.service_patcher = KubernetesServicePatch(self, [(f"{self.app.name}", 8080)]) + # ... +``` + +For LoadBalancer/NodePort services: +```python +# ... +from charms.observability_libs.v0.kubernetes_service_patch import KubernetesServicePatch + +class SomeCharm(CharmBase): + def __init__(self, *args): + # ... + self.service_patcher = KubernetesServicePatch( + self, [(f"{self.app.name}", 443, 443, 30666)], "LoadBalancer" + ) + # ... +``` + +Additionally, you may wish to use mocks in your charm's unit testing to ensure that the library +does not try to make any API calls, or open any files during testing that are unlikely to be +present, and could break your tests. The easiest way to do this is during your test `setUp`: + +```python +# ... + +@patch("charm.KubernetesServicePatch", lambda x, y: None) +def setUp(self, *unused): + self.harness = Harness(SomeCharm) + # ... +``` +""" + +import logging +from types import MethodType +from typing import Literal, Sequence, Tuple, Union + +from lightkube import ApiError, Client +from lightkube.models.core_v1 import ServicePort, ServiceSpec +from lightkube.models.meta_v1 import ObjectMeta +from lightkube.resources.core_v1 import Service +from lightkube.types import PatchType +from ops.charm import CharmBase +from ops.framework import Object + +logger = logging.getLogger(__name__) + +# The unique Charmhub library identifier, never change it +LIBID = "0042f86d0a874435adef581806cddbbb" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 6 + +PortDefinition = Union[Tuple[str, int], Tuple[str, int, int], Tuple[str, int, int, int]] +ServiceType = Literal["ClusterIP", "LoadBalancer"] + + +class KubernetesServicePatch(Object): + """A utility for patching the Kubernetes service set up by Juju.""" + + def __init__( + self, + charm: CharmBase, + ports: Sequence[PortDefinition], + service_name: str = None, + service_type: ServiceType = "ClusterIP", + additional_labels: dict = None, + additional_selectors: dict = None, + additional_annotations: dict = None, + ): + """Constructor for KubernetesServicePatch. + + Args: + charm: the charm that is instantiating the library. + ports: a list of tuples (name, port, targetPort, nodePort) for every service port. + service_name: allows setting custom name to the patched service. If none given, + application name will be used. + service_type: desired type of K8s service. Default value is in line with ServiceSpec's + default value. + additional_labels: Labels to be added to the kubernetes service (by default only + "app.kubernetes.io/name" is set to the service name) + additional_selectors: Selectors to be added to the kubernetes service (by default only + "app.kubernetes.io/name" is set to the service name) + additional_annotations: Annotations to be added to the kubernetes service. + """ + super().__init__(charm, "kubernetes-service-patch") + self.charm = charm + self.service_name = service_name if service_name else self._app + self.service = self._service_object( + ports, + service_name, + service_type, + additional_labels, + additional_selectors, + additional_annotations, + ) + + # Make mypy type checking happy that self._patch is a method + assert isinstance(self._patch, MethodType) + # Ensure this patch is applied during the 'install' and 'upgrade-charm' events + self.framework.observe(charm.on.install, self._patch) + self.framework.observe(charm.on.upgrade_charm, self._patch) + + def _service_object( + self, + ports: Sequence[PortDefinition], + service_name: str = None, + service_type: ServiceType = "ClusterIP", + additional_labels: dict = None, + additional_selectors: dict = None, + additional_annotations: dict = None, + ) -> Service: + """Creates a valid Service representation. + + Args: + ports: a list of tuples of the form (name, port) or (name, port, targetPort) + or (name, port, targetPort, nodePort) for every service port. If the 'targetPort' + is omitted, it is assumed to be equal to 'port', with the exception of NodePort + and LoadBalancer services, where all port numbers have to be specified. + service_name: allows setting custom name to the patched service. If none given, + application name will be used. + service_type: desired type of K8s service. Default value is in line with ServiceSpec's + default value. + additional_labels: Labels to be added to the kubernetes service (by default only + "app.kubernetes.io/name" is set to the service name) + additional_selectors: Selectors to be added to the kubernetes service (by default only + "app.kubernetes.io/name" is set to the service name) + additional_annotations: Annotations to be added to the kubernetes service. + + Returns: + Service: A valid representation of a Kubernetes Service with the correct ports. + """ + if not service_name: + service_name = self._app + labels = {"app.kubernetes.io/name": self._app} + if additional_labels: + labels.update(additional_labels) + selector = {"app.kubernetes.io/name": self._app} + if additional_selectors: + selector.update(additional_selectors) + return Service( + apiVersion="v1", + kind="Service", + metadata=ObjectMeta( + namespace=self._namespace, + name=service_name, + labels=labels, + annotations=additional_annotations, # type: ignore[arg-type] + ), + spec=ServiceSpec( + selector=selector, + ports=[ + ServicePort( + name=p[0], + port=p[1], + targetPort=p[2] if len(p) > 2 else p[1], # type: ignore[misc] + nodePort=p[3] if len(p) > 3 else None, # type: ignore[arg-type, misc] + ) + for p in ports + ], + type=service_type, + ), + ) + + def _patch(self, _) -> None: + """Patch the Kubernetes service created by Juju to map the correct port. + + Raises: + PatchFailed: if patching fails due to lack of permissions, or otherwise. + """ + if not self.charm.unit.is_leader(): + return + + client = Client() + try: + if self.service_name != self._app: + self._delete_and_create_service(client) + client.patch(Service, self.service_name, self.service, patch_type=PatchType.MERGE) + except ApiError as e: + if e.status.code == 403: + logger.error("Kubernetes service patch failed: `juju trust` this application.") + else: + logger.error("Kubernetes service patch failed: %s", str(e)) + else: + logger.info("Kubernetes service '%s' patched successfully", self._app) + + def _delete_and_create_service(self, client: Client): + service = client.get(Service, self._app, namespace=self._namespace) + service.metadata.name = self.service_name # type: ignore[attr-defined] + service.metadata.resourceVersion = service.metadata.uid = None # type: ignore[attr-defined] # noqa: E501 + client.delete(Service, self._app, namespace=self._namespace) + client.create(service) + + def is_patched(self) -> bool: + """Reports if the service patch has been applied. + + Returns: + bool: A boolean indicating if the service patch has been applied. + """ + client = Client() + # Get the relevant service from the cluster + service = client.get(Service, name=self.service_name, namespace=self._namespace) + # Construct a list of expected ports, should the patch be applied + expected_ports = [(p.port, p.targetPort) for p in self.service.spec.ports] + # Construct a list in the same manner, using the fetched service + fetched_ports = [(p.port, p.targetPort) for p in service.spec.ports] # type: ignore[attr-defined] # noqa: E501 + return expected_ports == fetched_ports + + @property + def _app(self) -> str: + """Name of the current Juju application. + + Returns: + str: A string containing the name of the current Juju application. + """ + return self.charm.app.name + + @property + def _namespace(self) -> str: + """The Kubernetes namespace we're running in. + + Returns: + str: A string containing the name of the current Kubernetes namespace. + """ + with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "r") as f: + return f.read().strip() diff --git a/lib/charms/sunbeam_ovn_central_operator/v0/ovsdb.py b/lib/charms/sunbeam_ovn_central_operator/v0/ovsdb.py new file mode 100644 index 0000000..f1ddf3b --- /dev/null +++ b/lib/charms/sunbeam_ovn_central_operator/v0/ovsdb.py @@ -0,0 +1,202 @@ +"""TODO: Add a proper docstring here. + +This is a placeholder docstring for this charm library. Docstrings are +presented on Charmhub and updated whenever you push a new version of the +library. + +Complete documentation about creating and documenting libraries can be found +in the SDK docs at https://juju.is/docs/sdk/libraries. + +See `charmcraft publish-lib` and `charmcraft fetch-lib` for details of how to +share and consume charm libraries. They serve to enhance collaboration +between charmers. Use a charmer's libraries for classes that handle +integration with their charm. + +Bear in mind that new revisions of the different major API versions (v0, v1, +v2 etc) are maintained independently. You can continue to update v0 and v1 +after you have pushed v3. + +Markdown is supported, following the CommonMark specification. +""" + +import logging +import typing +from ops.framework import ( + StoredState, + EventBase, + ObjectEvents, + EventSource, + Object, +) + +# The unique Charmhub library identifier, never change it +LIBID = "19e5a5857acd4a94a4a759d173d18232" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 1 + + +# TODO: add your code here! Happy coding! +class OVSDBCMSConnectedEvent(EventBase): + """OVSDBCMS connected Event.""" + + pass + + +class OVSDBCMSReadyEvent(EventBase): + """OVSDBCMS ready for use Event.""" + + pass + + +class OVSDBCMSGoneAwayEvent(EventBase): + """OVSDBCMS relation has gone-away Event""" + + pass + + +class OVSDBCMSServerEvents(ObjectEvents): + """Events class for `on`""" + + connected = EventSource(OVSDBCMSConnectedEvent) + ready = EventSource(OVSDBCMSReadyEvent) + goneaway = EventSource(OVSDBCMSGoneAwayEvent) + + +class OVSDBCMSRequires(Object): + """ + OVSDBCMSRequires class + """ + + on = OVSDBCMSServerEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name: str): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_ovsdb_cms_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_ovsdb_cms_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_departed, + self._on_ovsdb_cms_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_ovsdb_cms_relation_broken, + ) + + def _on_ovsdb_cms_relation_joined(self, event): + """OVSDBCMS relation joined.""" + logging.debug("OVSDBCMSRequires on_joined") + self.on.connected.emit() + + def bound_addresses(self): + return self.get_all_unit_values("bound-address") + + def remote_ready(self): + return all(self.bound_addresses()) + + def _on_ovsdb_cms_relation_changed(self, event): + """OVSDBCMS relation changed.""" + logging.debug("OVSDBCMSRequires on_changed") + if self.remote_ready(): + self.on.ready.emit() + + def _on_ovsdb_cms_relation_broken(self, event): + """OVSDBCMS relation broken.""" + logging.debug("OVSDBCMSRequires on_broken") + self.on.goneaway.emit() + + def get_all_unit_values(self, key: str) -> typing.List[str]: + """Retrieve value for key from all related units.""" + values = [] + relation = self.framework.model.get_relation(self.relation_name) + for unit in relation.units: + values.append(relation.data[unit].get(key)) + return values + + + +class OVSDBCMSClientConnectedEvent(EventBase): + """OVSDBCMS connected Event.""" + + pass + + +class OVSDBCMSClientReadyEvent(EventBase): + """OVSDBCMS ready for use Event.""" + + pass + + +class OVSDBCMSClientGoneAwayEvent(EventBase): + """OVSDBCMS relation has gone-away Event""" + + pass + + +class OVSDBCMSClientEvents(ObjectEvents): + """Events class for `on`""" + + connected = EventSource(OVSDBCMSClientConnectedEvent) + ready = EventSource(OVSDBCMSClientReadyEvent) + goneaway = EventSource(OVSDBCMSClientGoneAwayEvent) + + +class OVSDBCMSProvides(Object): + """ + OVSDBCMSProvides class + """ + + on = OVSDBCMSClientEvents() + _stored = StoredState() + + def __init__(self, charm, relation_name): + super().__init__(charm, relation_name) + self.charm = charm + self.relation_name = relation_name + self.framework.observe( + self.charm.on[relation_name].relation_joined, + self._on_ovsdb_cms_relation_joined, + ) + self.framework.observe( + self.charm.on[relation_name].relation_changed, + self._on_ovsdb_cms_relation_changed, + ) + self.framework.observe( + self.charm.on[relation_name].relation_broken, + self._on_ovsdb_cms_relation_broken, + ) + + def _on_ovsdb_cms_relation_joined(self, event): + """Handle ovsdb-cms joined.""" + logging.debug("OVSDBCMSProvides on_joined") + self.on.connected.emit() + + def _on_ovsdb_cms_relation_changed(self, event): + """Handle ovsdb-cms changed.""" + logging.debug("OVSDBCMSProvides on_changed") + self.on.ready.emit() + + def _on_ovsdb_cms_relation_broken(self, event): + """Handle ovsdb-cms broken.""" + logging.debug("OVSDBCMSProvides on_departed") + self.on.goneaway.emit() + + def set_unit_data(self, settings: typing.Dict[str, str]) -> None: + """Publish settings on the peer unit data bag.""" + relation = self.framework.model.get_relation(self.relation_name) + for k, v in settings.items(): + relation.data[self.model.unit][k] = v + diff --git a/metadata.yaml b/metadata.yaml new file mode 100644 index 0000000..23081c2 --- /dev/null +++ b/metadata.yaml @@ -0,0 +1,30 @@ +name: sunbeam-ovn-relay-operator +summary: Relay for Open Virtual Network for Open vSwitch +maintainer: OpenStack Charmers +description: | + In sert description +tags: +- networking + +containers: + ovsdb-server: + resource: ovn-sb-db-server-image + +resources: + ovn-sb-db-server-image: + type: oci-image + description: OCI image + +requires: + ovsdb-cms: + interface: ovsdb-cms + certificates: + interface: tls-certificates + +provides: + ovsdb-cms-relay: + interface: ovsdb-cms + +peers: + peers: + interface: ovn-relay-peer diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..4f2a3f5 --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +coverage +flake8 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..642a7bb --- /dev/null +++ b/requirements.txt @@ -0,0 +1,11 @@ +ops +jinja2 +git+https://opendev.org/openstack/charm-ops-openstack#egg=ops_openstack +git+https://github.com/openstack-charmers/advanced-sunbeam-openstack#egg=advanced_sunbeam_openstack +lightkube +# These are only needeed if the charm relates to ceph +git+https://github.com/openstack/charm-ops-interface-ceph-client#egg=interface_ceph_client +# Charmhelpers is only present as interface_ceph_client uses it. +git+https://github.com/juju/charm-helpers.git#egg=charmhelpers +cryptography < 3.4 +git+https://opendev.org/openstack/charm-ops-interface-tls-certificates#egg=interface_tls_certificates diff --git a/run_tests b/run_tests new file mode 100755 index 0000000..90db638 --- /dev/null +++ b/run_tests @@ -0,0 +1,17 @@ +#!/bin/sh -e +# Copyright 2022 liam +# See LICENSE file for licensing details. + +if [ -z "$VIRTUAL_ENV" -a -d venv/ ]; then + . venv/bin/activate +fi + +if [ -z "$PYTHONPATH" ]; then + export PYTHONPATH="lib:src" +else + export PYTHONPATH="lib:src:$PYTHONPATH" +fi + +flake8 +coverage run --branch --source=src -m unittest -v "$@" +coverage report -m diff --git a/src/charm.py b/src/charm.py new file mode 100755 index 0000000..7e900e1 --- /dev/null +++ b/src/charm.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +# Copyright 2022 liam +# See LICENSE file for licensing details. +# +# Learn more at: https://juju.is/docs/sdk + +"""Charm the service. + +Refer to the following post for a quick-start guide that will help you +develop a new k8s charm using the Operator Framework: + + https://discourse.charmhub.io/t/4208 +""" + +import logging +from typing import List + +from ops.charm import CharmBase +from ops.framework import StoredState +from ops.main import main + +import advanced_sunbeam_openstack.charm as sunbeam_charm +import advanced_sunbeam_openstack.container_handlers as sunbeam_chandlers +import advanced_sunbeam_openstack.core as sunbeam_core + +import advanced_sunbeam_openstack.config_contexts as sunbeam_ctxts + +from charms.observability_libs.v0.kubernetes_service_patch \ + import KubernetesServicePatch + +logger = logging.getLogger(__name__) + +OVSDB_SERVER = "ovsdb-server" + + +class OVNRelayPebbleHandler(sunbeam_chandlers.OVNPebbleHandler): + + @property + def wrapper_script(self): + return '/root/ovn-relay-wrapper.sh' + + @property + def service_description(self): + return 'OVN Relay' + + +class OVNRelayOperatorCharm(sunbeam_charm.OSBaseOperatorCharm): + """Charm the service.""" + + _state = StoredState() + + def __init__(self, framework): + super().__init__(framework) + self.service_patcher = KubernetesServicePatch( + self, + [ + ('southboundrelay', 6642), + ] + ) + self.configure_charm(None) + + def get_pebble_handlers(self): + pebble_handlers = [ + OVNRelayPebbleHandler( + self, + OVSDB_SERVER, + 'ovn-relay', + self.container_configs, + self.template_dir, + self.openstack_release, + self.configure_charm)] + return pebble_handlers + + @property + def config_contexts(self) -> List[sunbeam_ctxts.ConfigContext]: + """Configuration contexts for the operator.""" + contexts = super().config_contexts + contexts.append( + sunbeam_ctxts.OVNDBConfigContext(self, "ovs_db")) + return contexts + + +class OVNRelayWallabyOperatorCharm(OVNRelayOperatorCharm): + + openstack_release = 'wallaby' + +if __name__ == "__main__": + # Note: use_juju_for_storage=True required per + # https://github.com/canonical/operator/issues/506 + main(OVNRelayWallabyOperatorCharm, use_juju_for_storage=True) diff --git a/src/templates/cert_host.j2 b/src/templates/cert_host.j2 new file mode 100644 index 0000000..0d92c10 --- /dev/null +++ b/src/templates/cert_host.j2 @@ -0,0 +1,2 @@ +# {{ certificates }} +{{ certificates.cert }} diff --git a/src/templates/key_host.j2 b/src/templates/key_host.j2 new file mode 100644 index 0000000..63a9aeb --- /dev/null +++ b/src/templates/key_host.j2 @@ -0,0 +1 @@ +{{ certificates.key }} diff --git a/src/templates/ovn-central.crt.j2 b/src/templates/ovn-central.crt.j2 new file mode 100644 index 0000000..00cd0da --- /dev/null +++ b/src/templates/ovn-central.crt.j2 @@ -0,0 +1 @@ +{{ certificates.ca_cert }} diff --git a/src/templates/ovn-relay-wrapper.sh.j2 b/src/templates/ovn-relay-wrapper.sh.j2 new file mode 100644 index 0000000..b13ccd8 --- /dev/null +++ b/src/templates/ovn-relay-wrapper.sh.j2 @@ -0,0 +1,32 @@ +#!/usr/bin/bash + +WRAPPER_LOG=/var/log/ovn/service_wrapper.log + +function log_msg() +{ + msg=$1 + echo "$(date): $msg" >> $WRAPPER_LOG +} + +function stop_svc() +{ + log_msg "Stopping" +} + +log_msg "Starting" +REMOTES={{ ovsdb_cms.db_sb_connection_strs }} +# ovsdb-server --remote=ptcp:6642:172.16.0.1 relay:OVN_Southbound:$REMOTES + +ovsdb-server \ + -vconsole:emer \ + -vsyslog:err \ + -vfile:info \ + -c {{ ovs_db.ovn_cert }} \ + -C {{ ovs_db.ovn_ca_cert }} \ + -p {{ ovs_db.ovn_key }} \ + --no-chdir \ + --log-file=/var/log/ovn/ovn-relay.log \ + --pidfile=/var/run/ovn/ovn-relay.pid \ + --remote=ptcp:6642:{{ ovsdb_cms.local_address }} relay:OVN_Southbound:$REMOTES + +trap stop_svc EXIT diff --git a/test-requirements.txt b/test-requirements.txt new file mode 100644 index 0000000..8057d2c --- /dev/null +++ b/test-requirements.txt @@ -0,0 +1,17 @@ +# This file is managed centrally. If you find the need to modify this as a +# one-off, please don't. Intead, consult #openstack-charms and ask about +# requirements management in charms via bot-control. Thank you. +charm-tools>=2.4.4 +coverage>=3.6 +mock>=1.2 +flake8>=2.2.4,<=2.4.1 +pyflakes==2.1.1 +stestr>=2.2.0 +requests>=2.18.4 +psutil +# oslo.i18n dropped py35 support +oslo.i18n<4.0.0 +git+https://github.com/openstack-charmers/zaza.git#egg=zaza +git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack +pytz # workaround for 14.04 pip/tox +pyudev # for ceph-* charm unit tests (not mocked?) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_charm.py b/tests/test_charm.py new file mode 100644 index 0000000..96b00eb --- /dev/null +++ b/tests/test_charm.py @@ -0,0 +1,66 @@ +# Copyright 2022 liam +# See LICENSE file for licensing details. +# +# Learn more about testing at: https://juju.is/docs/sdk/testing + +import unittest +from unittest.mock import Mock + +from charm import SunbeamOvnRelayOperatorCharm +from ops.model import ActiveStatus +from ops.testing import Harness + + +class TestCharm(unittest.TestCase): + def setUp(self): + self.harness = Harness(SunbeamOvnRelayOperatorCharm) + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def test_config_changed(self): + self.assertEqual(list(self.harness.charm._stored.things), []) + self.harness.update_config({"thing": "foo"}) + self.assertEqual(list(self.harness.charm._stored.things), ["foo"]) + + def test_action(self): + # the harness doesn't (yet!) help much with actions themselves + action_event = Mock(params={"fail": ""}) + self.harness.charm._on_fortune_action(action_event) + + self.assertTrue(action_event.set_results.called) + + def test_action_fail(self): + action_event = Mock(params={"fail": "fail this"}) + self.harness.charm._on_fortune_action(action_event) + + self.assertEqual(action_event.fail.call_args, [("fail this",)]) + + def test_httpbin_pebble_ready(self): + # Check the initial Pebble plan is empty + initial_plan = self.harness.get_container_pebble_plan("httpbin") + self.assertEqual(initial_plan.to_yaml(), "{}\n") + # Expected plan after Pebble ready with default config + expected_plan = { + "services": { + "httpbin": { + "override": "replace", + "summary": "httpbin", + "command": "gunicorn -b 0.0.0.0:80 httpbin:app -k gevent", + "startup": "enabled", + "environment": {"thing": "🎁"}, + } + }, + } + # Get the httpbin container from the model + container = self.harness.model.unit.get_container("httpbin") + # Emit the PebbleReadyEvent carrying the httpbin container + self.harness.charm.on.httpbin_pebble_ready.emit(container) + # Get the plan now we've run PebbleReady + updated_plan = self.harness.get_container_pebble_plan("httpbin").to_dict() + # Check we've got the plan we expected + self.assertEqual(expected_plan, updated_plan) + # Check the service was started + service = self.harness.model.unit.get_container("httpbin").get_service("httpbin") + self.assertTrue(service.is_running()) + # Ensure we set an ActiveStatus with no message + self.assertEqual(self.harness.model.unit.status, ActiveStatus()) diff --git a/tox.ini b/tox.ini new file mode 100644 index 0000000..31301b8 --- /dev/null +++ b/tox.ini @@ -0,0 +1,134 @@ +# Operator charm (with zaza): tox.ini + +[tox] +envlist = pep8,py3 +skipsdist = True +# NOTE: Avoid build/test env pollution by not enabling sitepackages. +sitepackages = False +# NOTE: Avoid false positives by not skipping missing interpreters. +skip_missing_interpreters = False +# NOTES: +# * We avoid the new dependency resolver by pinning pip < 20.3, see +# https://github.com/pypa/pip/issues/9187 +# * Pinning dependencies requires tox >= 3.2.0, see +# https://tox.readthedocs.io/en/latest/config.html#conf-requires +# * It is also necessary to pin virtualenv as a newer virtualenv would still +# lead to fetching the latest pip in the func* tox targets, see +# https://stackoverflow.com/a/38133283 +requires = pip < 20.3 + virtualenv < 20.0 +# NOTE: https://wiki.canonical.com/engineering/OpenStack/InstallLatestToxOnOsci +minversion = 3.2.0 + +[testenv] +setenv = VIRTUAL_ENV={envdir} + PYTHONHASHSEED=0 + CHARM_DIR={envdir} +install_command = + pip install {opts} {packages} +commands = stestr run --slowest {posargs} +whitelist_externals = + git + add-to-archive.py + bash + charmcraft +passenv = HOME TERM CS_* OS_* TEST_* +deps = -r{toxinidir}/test-requirements.txt + +[testenv:py35] +basepython = python3.5 +# python3.5 is irrelevant on a focal+ charm. +commands = /bin/true + +[testenv:py36] +basepython = python3.6 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:py37] +basepython = python3.7 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:py38] +basepython = python3.8 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:py3] +basepython = python3 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt + +[testenv:pep8] +basepython = python3 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +commands = flake8 {posargs} src unit_tests tests + +[testenv:cover] +# Technique based heavily upon +# https://github.com/openstack/nova/blob/master/tox.ini +basepython = python3 +deps = -r{toxinidir}/requirements.txt + -r{toxinidir}/test-requirements.txt +setenv = + {[testenv]setenv} + PYTHON=coverage run +commands = + coverage erase + stestr run --slowest {posargs} + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml + coverage report + +[coverage:run] +branch = True +concurrency = multiprocessing +parallel = True +source = + . +omit = + .tox/* + */charmhelpers/* + unit_tests/* + +[testenv:venv] +basepython = python3 +commands = {posargs} + +[testenv:build] +basepython = python3 +deps = -r{toxinidir}/build-requirements.txt +commands = + charmcraft build + +[testenv:func-noop] +basepython = python3 +commands = + functest-run-suite --help + +[testenv:func] +basepython = python3 +commands = + functest-run-suite --keep-model + +[testenv:func-smoke] +basepython = python3 +commands = + functest-run-suite --keep-model --smoke + +[testenv:func-dev] +basepython = python3 +commands = + functest-run-suite --keep-model --dev + +[testenv:func-target] +basepython = python3 +commands = + functest-run-suite --keep-model --bundle {posargs} + +[flake8] +# Ignore E902 because the unit_tests directory is missing in the built charm. +ignore = E402,E226,E902