From ccfacbbb3d3bd025f48f2a1434e0b6bdeae64ead Mon Sep 17 00:00:00 2001 From: sousaedu Date: Wed, 4 Nov 2020 21:44:01 +0000 Subject: [PATCH] Refactoring RO Charm to use Operator Framework This refactoring work includes tests. Note 1: old charm is in ro-k8s folder. Note 2: relation-departed is currently not tested because there is no function to remove a relation in the Testing Harness. There is currently one issue open and the Charmcraft team should provide feedback soon. Change-Id: Ia97de802aec2c4e10a1d2c86ba2515d3f17f55af Signed-off-by: sousaedu --- installers/charm/ro/.gitignore | 28 ++ installers/charm/ro/.yamllint.yaml | 33 ++ installers/charm/ro/README.md | 23 ++ installers/charm/ro/config.yaml | 46 +++ installers/charm/ro/metadata.yaml | 50 +++ installers/charm/ro/requirements.txt | 23 ++ installers/charm/ro/src/charm.py | 218 ++++++++++++ installers/charm/ro/src/pod_spec.py | 275 +++++++++++++++ installers/charm/ro/tests/__init__.py | 31 ++ installers/charm/ro/tests/test_charm.py | 353 +++++++++++++++++++ installers/charm/ro/tests/test_pod_spec.py | 389 +++++++++++++++++++++ installers/charm/ro/tox.ini | 81 +++++ 12 files changed, 1550 insertions(+) create mode 100644 installers/charm/ro/.gitignore create mode 100644 installers/charm/ro/.yamllint.yaml create mode 100644 installers/charm/ro/README.md create mode 100644 installers/charm/ro/config.yaml create mode 100644 installers/charm/ro/metadata.yaml create mode 100644 installers/charm/ro/requirements.txt create mode 100755 installers/charm/ro/src/charm.py create mode 100644 installers/charm/ro/src/pod_spec.py create mode 100644 installers/charm/ro/tests/__init__.py create mode 100644 installers/charm/ro/tests/test_charm.py create mode 100644 installers/charm/ro/tests/test_pod_spec.py create mode 100644 installers/charm/ro/tox.ini diff --git a/installers/charm/ro/.gitignore b/installers/charm/ro/.gitignore new file mode 100644 index 00000000..aa3848a0 --- /dev/null +++ b/installers/charm/ro/.gitignore @@ -0,0 +1,28 @@ +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +venv +.vscode +build +ro.charm +.coverage +.stestr +cover diff --git a/installers/charm/ro/.yamllint.yaml b/installers/charm/ro/.yamllint.yaml new file mode 100644 index 00000000..c20ac8d6 --- /dev/null +++ b/installers/charm/ro/.yamllint.yaml @@ -0,0 +1,33 @@ +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +--- +extends: default + +yaml-files: + - "*.yaml" + - "*.yml" + - ".yamllint" +ignore: | + .tox + build/ + mod/ + lib/ diff --git a/installers/charm/ro/README.md b/installers/charm/ro/README.md new file mode 100644 index 00000000..9cf42000 --- /dev/null +++ b/installers/charm/ro/README.md @@ -0,0 +1,23 @@ + + +# RO operator Charm for Kubernetes + +## Requirements diff --git a/installers/charm/ro/config.yaml b/installers/charm/ro/config.yaml new file mode 100644 index 00000000..af61a101 --- /dev/null +++ b/installers/charm/ro/config.yaml @@ -0,0 +1,46 @@ +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +options: + enable_ng_ro: + description: Enable NG-RO + type: boolean + default: true + database_commonkey: + description: Database COMMON KEY + type: string + default: osm + log_level: + description: "Log Level" + type: string + default: "INFO" + vim_database: + type: string + description: "The database name." + default: "mano_vim_db" + ro_database: + type: string + description: "The database name." + default: "mano_db" + openmano_tenant: + type: string + description: "Openmano Tenant" + default: "osm" diff --git a/installers/charm/ro/metadata.yaml b/installers/charm/ro/metadata.yaml new file mode 100644 index 00000000..f29f4bc6 --- /dev/null +++ b/installers/charm/ro/metadata.yaml @@ -0,0 +1,50 @@ +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +name: ro +summary: OSM Resource Orchestrator (RO) +description: | + A CAAS charm to deploy OSM's Resource Orchestrator (RO). +series: + - kubernetes +tags: + - kubernetes + - osm + - ro +min-juju-version: 2.8.0 +deployment: + type: stateless + service: cluster +resources: + image: + type: oci-image + description: OSM docker image for RO + upstream-source: "opensourcemano/ro:8" +provides: + ro: + interface: osm-ro +requires: + kafka: + interface: kafka + mongodb: + interface: mongodb + mysql: + interface: mysql diff --git a/installers/charm/ro/requirements.txt b/installers/charm/ro/requirements.txt new file mode 100644 index 00000000..a26601fe --- /dev/null +++ b/installers/charm/ro/requirements.txt @@ -0,0 +1,23 @@ +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +ops +git+https://github.com/juju-solutions/resource-oci-image/@c5778285d332edf3d9a538f9d0c06154b7ec1b0b#egg=oci-image diff --git a/installers/charm/ro/src/charm.py b/installers/charm/ro/src/charm.py new file mode 100755 index 00000000..8e6d5764 --- /dev/null +++ b/installers/charm/ro/src/charm.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +import logging +from typing import Dict, List, NoReturn + +from ops.charm import CharmBase +from ops.framework import EventBase, StoredState +from ops.main import main +from ops.model import ActiveStatus, Application, BlockedStatus, MaintenanceStatus, Unit +from oci_image import OCIImageResource, OCIImageResourceError + +from pod_spec import make_pod_spec + +logger = logging.getLogger(__name__) + +RO_PORT = 9090 + + +class RelationsMissing(Exception): + def __init__(self, missing_relations: List): + self.message = "" + if missing_relations and isinstance(missing_relations, list): + self.message += f'Waiting for {", ".join(missing_relations)} relation' + if "," in self.message: + self.message += "s" + + +class RelationDefinition: + def __init__(self, relation_name: str, keys: List, source_type): + if source_type != Application and source_type != Unit: + raise TypeError( + "source_type should be ops.model.Application or ops.model.Unit" + ) + self.relation_name = relation_name + self.keys = keys + self.source_type = source_type + + +def check_missing_relation_data( + data: Dict, + expected_relations_data: List[RelationDefinition], +): + missing_relations = [] + for relation_data in expected_relations_data: + if not all( + f"{relation_data.relation_name}_{k}" in data for k in relation_data.keys + ): + missing_relations.append(relation_data.relation_name) + if missing_relations: + raise RelationsMissing(missing_relations) + + +def get_relation_data( + charm: CharmBase, + relation_data: RelationDefinition, +) -> Dict: + data = {} + relation = charm.model.get_relation(relation_data.relation_name) + if relation: + self_app_unit = ( + charm.app if relation_data.source_type == Application else charm.unit + ) + expected_type = relation_data.source_type + for app_unit in relation.data: + if app_unit != self_app_unit and isinstance(app_unit, expected_type): + if all(k in relation.data[app_unit] for k in relation_data.keys): + for k in relation_data.keys: + data[f"{relation_data.relation_name}_{k}"] = relation.data[ + app_unit + ].get(k) + break + return data + + +class RoCharm(CharmBase): + """RO Charm.""" + + state = StoredState() + + def __init__(self, *args) -> NoReturn: + """RO Charm constructor.""" + super().__init__(*args) + + # Internal state initialization + self.state.set_default(pod_spec=None) + + self.port = RO_PORT + self.image = OCIImageResource(self, "image") + + # Registering regular events + self.framework.observe(self.on.start, self.configure_pod) + self.framework.observe(self.on.config_changed, self.configure_pod) + + # Registering required relation events + self.framework.observe(self.on.kafka_relation_changed, self.configure_pod) + self.framework.observe(self.on.mongodb_relation_changed, self.configure_pod) + self.framework.observe(self.on.mysql_relation_changed, self.configure_pod) + + # Registering required relation departed events + self.framework.observe(self.on.kafka_relation_departed, self.configure_pod) + self.framework.observe(self.on.mongodb_relation_departed, self.configure_pod) + self.framework.observe(self.on.mysql_relation_departed, self.configure_pod) + + # Registering required relation broken events + self.framework.observe(self.on.kafka_relation_broken, self.configure_pod) + self.framework.observe(self.on.mongodb_relation_broken, self.configure_pod) + self.framework.observe(self.on.mysql_relation_broken, self.configure_pod) + + # Registering provided relation events + self.framework.observe(self.on.ro_relation_joined, self._publish_ro_info) + + def _publish_ro_info(self, event: EventBase) -> NoReturn: + """Publishes RO information. + + Args: + event (EventBase): RO relation event. + """ + if self.unit.is_leader(): + rel_data = { + "host": self.model.app.name, + "port": str(RO_PORT), + } + for k, v in rel_data.items(): + event.relation.data[self.app][k] = v + + @property + def relations_requirements(self): + if self.model.config["enable_ng_ro"]: + return [ + RelationDefinition("kafka", ["host", "port"], Unit), + RelationDefinition("mongodb", ["connection_string"], Unit), + ] + else: + return [ + RelationDefinition( + "mysql", ["host", "port", "user", "password", "root_password"], Unit + ) + ] + + def get_relation_state(self): + relation_state = {} + for relation_requirements in self.relations_requirements: + data = get_relation_data(self, relation_requirements) + relation_state = {**relation_state, **data} + check_missing_relation_data(relation_state, self.relations_requirements) + return relation_state + + def configure_pod(self, _=None) -> NoReturn: + """Assemble the pod spec and apply it, if possible. + + Args: + event (EventBase): Hook or Relation event that started the + function. + """ + if not self.unit.is_leader(): + self.unit.status = ActiveStatus("ready") + return + + relation_state = None + try: + relation_state = self.get_relation_state() + except RelationsMissing as exc: + logger.exception("Relation missing error") + self.unit.status = BlockedStatus(exc.message) + return + + self.unit.status = MaintenanceStatus("Assembling pod spec") + + # Fetch image information + try: + self.unit.status = MaintenanceStatus("Fetching image information") + image_info = self.image.fetch() + except OCIImageResourceError: + self.unit.status = BlockedStatus("Error fetching image information") + return + + try: + pod_spec = make_pod_spec( + image_info, + self.model.config, + relation_state, + self.model.app.name, + self.port, + ) + except ValueError as exc: + logger.exception("Config/Relation data validation error") + self.unit.status = BlockedStatus(str(exc)) + return + + if self.state.pod_spec != pod_spec: + self.model.pod.set_spec(pod_spec) + self.state.pod_spec = pod_spec + + self.unit.status = ActiveStatus("ready") + + +if __name__ == "__main__": + main(RoCharm) diff --git a/installers/charm/ro/src/pod_spec.py b/installers/charm/ro/src/pod_spec.py new file mode 100644 index 00000000..b54710c6 --- /dev/null +++ b/installers/charm/ro/src/pod_spec.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +import logging +from typing import Any, Dict, List, NoReturn + +logger = logging.getLogger(__name__) + + +def _validate_data( + config_data: Dict[str, Any], relation_data: Dict[str, Any] +) -> NoReturn: + """Validates passed information. + + Args: + config_data (Dict[str, Any]): configuration information. + relation_data (Dict[str, Any]): relation information + + Raises: + ValueError: when config and/or relation data is not valid. + """ + config_validators = { + "enable_ng_ro": lambda value, _: isinstance(value, bool), + "database_commonkey": lambda value, values: ( + isinstance(value, str) and len(value) > 0 + ) + if values.get("enable_ng_ro", True) + else True, + "log_level": lambda value, _: isinstance(value, str) + and value in ("INFO", "DEBUG"), + "vim_database": lambda value, values: ( + isinstance(value, str) and len(value) > 0 + ) + if not values.get("enable_ng_ro", True) + else True, + "ro_database": lambda value, values: (isinstance(value, str) and len(value) > 0) + if not values.get("enable_ng_ro", True) + else True, + "openmano_tenant": lambda value, values: ( + isinstance(value, str) and len(value) > 0 + ) + if not values.get("enable_ng_ro", True) + else True, + } + relation_validators = { + "kafka_host": lambda value, _: (isinstance(value, str) and len(value) > 0) + if config_data.get("enable_ng_ro", True) + else True, + "kafka_port": lambda value, _: (isinstance(value, int) and value > 0) + if config_data.get("enable_ng_ro", True) + else True, + "mongodb_connection_string": lambda value, _: ( + isinstance(value, str) and value.startswith("mongodb://") + ) + if config_data.get("enable_ng_ro", True) + else True, + "mysql_host": lambda value, _: (isinstance(value, str) and len(value) > 0) + if not config_data.get("enable_ng_ro", True) + else True, + "mysql_port": lambda value, _: (isinstance(value, int) and value > 0) + if not config_data.get("enable_ng_ro", True) + else True, + "mysql_user": lambda value, _: (isinstance(value, str) and len(value) > 0) + if not config_data.get("enable_ng_ro", True) + else True, + "mysql_password": lambda value, _: (isinstance(value, str) and len(value) > 0) + if not config_data.get("enable_ng_ro", True) + else True, + "mysql_root_password": lambda value, _: ( + isinstance(value, str) and len(value) > 0 + ) + if not config_data.get("enable_ng_ro", True) + else True, + } + problems = [] + + for key, validator in config_validators.items(): + valid = validator(config_data.get(key), config_data) + + if not valid: + problems.append(key) + + for key, validator in relation_validators.items(): + valid = validator(relation_data.get(key), relation_data) + + if not valid: + problems.append(key) + + if len(problems) > 0: + raise ValueError("Errors found in: {}".format(", ".join(problems))) + + +def _make_pod_ports(port: int) -> List[Dict[str, Any]]: + """Generate pod ports details. + + Args: + port (int): port to expose. + + Returns: + List[Dict[str, Any]]: pod port details. + """ + return [{"name": "ro", "containerPort": port, "protocol": "TCP"}] + + +def _make_pod_envconfig( + config: Dict[str, Any], relation_state: Dict[str, Any] +) -> Dict[str, Any]: + """Generate pod environment configuration. + + Args: + config (Dict[str, Any]): configuration information. + relation_state (Dict[str, Any]): relation state information. + + Returns: + Dict[str, Any]: pod environment configuration. + """ + envconfig = { + # General configuration + "OSMRO_LOG_LEVEL": config["log_level"], + } + + if config.get("enable_ng_ro", True): + # Kafka configuration + envconfig["OSMRO_MESSAGE_DRIVER"] = "kafka" + envconfig["OSMRO_MESSAGE_HOST"] = relation_state["kafka_host"] + envconfig["OSMRO_MESSAGE_PORT"] = relation_state["kafka_port"] + + # MongoDB configuration + envconfig["OSMRO_DATABASE_DRIVER"] = "mongo" + envconfig["OSMRO_DATABASE_URI"] = relation_state["mongodb_connection_string"] + envconfig["OSMRO_DATABASE_COMMONKEY"] = config["database_commonkey"] + else: + envconfig["RO_DB_HOST"] = relation_state["mysql_host"] + envconfig["RO_DB_OVIM_HOST"] = relation_state["mysql_host"] + envconfig["RO_DB_PORT"] = relation_state["mysql_port"] + envconfig["RO_DB_OVIM_PORT"] = relation_state["mysql_port"] + envconfig["RO_DB_USER"] = relation_state["mysql_user"] + envconfig["RO_DB_OVIM_USER"] = relation_state["mysql_user"] + envconfig["RO_DB_PASSWORD"] = relation_state["mysql_password"] + envconfig["RO_DB_OVIM_PASSWORD"] = relation_state["mysql_password"] + envconfig["RO_DB_ROOT_PASSWORD"] = relation_state["mysql_root_password"] + envconfig["RO_DB_OVIM_ROOT_PASSWORD"] = relation_state["mysql_root_password"] + envconfig["RO_DB_NAME"] = config["ro_database"] + envconfig["RO_DB_OVIM_NAME"] = config["vim_database"] + envconfig["OPENMANO_TENANT"] = config["openmano_tenant"] + + return envconfig + + +def _make_startup_probe() -> Dict[str, Any]: + """Generate startup probe. + + Returns: + Dict[str, Any]: startup probe. + """ + return { + "exec": {"command": ["/usr/bin/pgrep", "python3"]}, + "initialDelaySeconds": 60, + "timeoutSeconds": 5, + } + + +def _make_readiness_probe(port: int) -> Dict[str, Any]: + """Generate readiness probe. + + Args: + port (int): service port. + + Returns: + Dict[str, Any]: readiness probe. + """ + return { + "httpGet": { + "path": "/openmano/tenants", + "port": port, + }, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + } + + +def _make_liveness_probe(port: int) -> Dict[str, Any]: + """Generate liveness probe. + + Args: + port (int): service port. + + Returns: + Dict[str, Any]: liveness probe. + """ + return { + "httpGet": { + "path": "/openmano/tenants", + "port": port, + }, + "initialDelaySeconds": 600, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + } + + +def make_pod_spec( + image_info: Dict[str, str], + config: Dict[str, Any], + relation_state: Dict[str, Any], + app_name: str = "ro", + port: int = 9090, +) -> Dict[str, Any]: + """Generate the pod spec information. + + Args: + image_info (Dict[str, str]): Object provided by + OCIImageResource("image").fetch(). + config (Dict[str, Any]): Configuration information. + relation_state (Dict[str, Any]): Relation state information. + app_name (str, optional): Application name. Defaults to "ro". + port (int, optional): Port for the container. Defaults to 9090. + + Returns: + Dict[str, Any]: Pod spec dictionary for the charm. + """ + if not image_info: + return None + + _validate_data(config, relation_state) + + ports = _make_pod_ports(port) + env_config = _make_pod_envconfig(config, relation_state) + startup_probe = _make_startup_probe() + readiness_probe = _make_readiness_probe(port) + liveness_probe = _make_liveness_probe(port) + + return { + "version": 3, + "containers": [ + { + "name": app_name, + "imageDetails": image_info, + "imagePullPolicy": "Always", + "ports": ports, + "envConfig": env_config, + "kubernetes": { + "startupProbe": startup_probe, + "readinessProbe": readiness_probe, + "livenessProbe": liveness_probe, + }, + } + ], + "kubernetesResources": { + "ingressResources": [], + }, + } diff --git a/installers/charm/ro/tests/__init__.py b/installers/charm/ro/tests/__init__.py new file mode 100644 index 00000000..d0d973ae --- /dev/null +++ b/installers/charm/ro/tests/__init__.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +"""Init mocking for unit tests.""" + +import sys +import mock + +sys.path.append("src") + +oci_image = mock.MagicMock() +sys.modules["oci_image"] = oci_image diff --git a/installers/charm/ro/tests/test_charm.py b/installers/charm/ro/tests/test_charm.py new file mode 100644 index 00000000..cdc384f8 --- /dev/null +++ b/installers/charm/ro/tests/test_charm.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +from typing import NoReturn +import unittest +from ops.model import BlockedStatus + +from ops.testing import Harness + +from charm import RoCharm + + +class TestCharm(unittest.TestCase): + """RO Charm unit tests.""" + + def setUp(self) -> NoReturn: + """Test setup""" + self.harness = Harness(RoCharm) + self.harness.set_leader(is_leader=True) + self.harness.begin() + + def test_on_start_without_relations_ng_ro(self) -> NoReturn: + """Test installation without any relation.""" + self.harness.charm.on.start.emit() + + # Verifying status + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) + + # Verifying status message + self.assertGreater(len(self.harness.charm.unit.status.message), 0) + self.assertTrue( + self.harness.charm.unit.status.message.startswith("Waiting for ") + ) + self.assertIn("kafka", self.harness.charm.unit.status.message) + self.assertIn("mongodb", self.harness.charm.unit.status.message) + self.assertTrue(self.harness.charm.unit.status.message.endswith(" relations")) + + def test_on_start_without_relations_no_ng_ro(self) -> NoReturn: + """Test installation without any relation.""" + self.harness.update_config({"enable_ng_ro": False}) + + self.harness.charm.on.start.emit() + + # Verifying status + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) + + # Verifying status message + self.assertGreater(len(self.harness.charm.unit.status.message), 0) + self.assertTrue( + self.harness.charm.unit.status.message.startswith("Waiting for ") + ) + self.assertIn("mysql", self.harness.charm.unit.status.message) + self.assertTrue(self.harness.charm.unit.status.message.endswith(" relation")) + + def test_on_start_with_relations_ng_ro(self) -> NoReturn: + """Test deployment with NG-RO.""" + expected_result = { + "version": 3, + "containers": [ + { + "name": "ro", + "imageDetails": self.harness.charm.image.fetch(), + "imagePullPolicy": "Always", + "ports": [ + { + "name": "ro", + "containerPort": 9090, + "protocol": "TCP", + } + ], + "envConfig": { + "OSMRO_LOG_LEVEL": "INFO", + "OSMRO_MESSAGE_DRIVER": "kafka", + "OSMRO_MESSAGE_HOST": "kafka", + "OSMRO_MESSAGE_PORT": 9090, + "OSMRO_DATABASE_DRIVER": "mongo", + "OSMRO_DATABASE_URI": "mongodb://mongo", + "OSMRO_DATABASE_COMMONKEY": "osm", + }, + "kubernetes": { + "startupProbe": { + "exec": {"command": ["/usr/bin/pgrep", "python3"]}, + "initialDelaySeconds": 60, + "timeoutSeconds": 5, + }, + "readinessProbe": { + "httpGet": { + "path": "/openmano/tenants", + "port": 9090, + }, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + }, + "livenessProbe": { + "httpGet": { + "path": "/openmano/tenants", + "port": 9090, + }, + "initialDelaySeconds": 600, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + }, + }, + } + ], + "kubernetesResources": {"ingressResources": []}, + } + + self.harness.charm.on.start.emit() + + # Initializing the kafka relation + relation_id = self.harness.add_relation("kafka", "kafka") + self.harness.add_relation_unit(relation_id, "kafka/0") + self.harness.update_relation_data( + relation_id, + "kafka/0", + { + "host": "kafka", + "port": 9090, + }, + ) + + # Initializing the mongodb relation + relation_id = self.harness.add_relation("mongodb", "mongodb") + self.harness.add_relation_unit(relation_id, "mongodb/0") + self.harness.update_relation_data( + relation_id, + "mongodb/0", + { + "connection_string": "mongodb://mongo", + }, + ) + + # Verifying status + self.assertNotIsInstance(self.harness.charm.unit.status, BlockedStatus) + + pod_spec, _ = self.harness.get_pod_spec() + + self.assertDictEqual(expected_result, pod_spec) + + def test_on_start_with_relations_no_ng_ro(self) -> NoReturn: + """Test deployment with old RO.""" + self.harness.update_config({"enable_ng_ro": False}) + + expected_result = { + "version": 3, + "containers": [ + { + "name": "ro", + "imageDetails": self.harness.charm.image.fetch(), + "imagePullPolicy": "Always", + "ports": [ + { + "name": "ro", + "containerPort": 9090, + "protocol": "TCP", + } + ], + "envConfig": { + "OSMRO_LOG_LEVEL": "INFO", + "RO_DB_HOST": "mysql", + "RO_DB_OVIM_HOST": "mysql", + "RO_DB_PORT": 3306, + "RO_DB_OVIM_PORT": 3306, + "RO_DB_USER": "mano", + "RO_DB_OVIM_USER": "mano", + "RO_DB_PASSWORD": "manopw", + "RO_DB_OVIM_PASSWORD": "manopw", + "RO_DB_ROOT_PASSWORD": "rootmanopw", + "RO_DB_OVIM_ROOT_PASSWORD": "rootmanopw", + "RO_DB_NAME": "mano_db", + "RO_DB_OVIM_NAME": "mano_vim_db", + "OPENMANO_TENANT": "osm", + }, + "kubernetes": { + "startupProbe": { + "exec": {"command": ["/usr/bin/pgrep", "python3"]}, + "initialDelaySeconds": 60, + "timeoutSeconds": 5, + }, + "readinessProbe": { + "httpGet": { + "path": "/openmano/tenants", + "port": 9090, + }, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + }, + "livenessProbe": { + "httpGet": { + "path": "/openmano/tenants", + "port": 9090, + }, + "initialDelaySeconds": 600, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + }, + }, + } + ], + "kubernetesResources": {"ingressResources": []}, + } + + self.harness.charm.on.start.emit() + + # Initializing the mysql relation + relation_id = self.harness.add_relation("mysql", "mysql") + self.harness.add_relation_unit(relation_id, "mysql/0") + self.harness.update_relation_data( + relation_id, + "mysql/0", + { + "host": "mysql", + "port": 3306, + "user": "mano", + "password": "manopw", + "root_password": "rootmanopw", + }, + ) + + # Verifying status + self.assertNotIsInstance(self.harness.charm.unit.status, BlockedStatus) + + pod_spec, _ = self.harness.get_pod_spec() + + self.assertDictEqual(expected_result, pod_spec) + + def test_on_kafka_unit_relation_changed(self) -> NoReturn: + """Test to see if kafka relation is updated.""" + self.harness.charm.on.start.emit() + + relation_id = self.harness.add_relation("kafka", "kafka") + self.harness.add_relation_unit(relation_id, "kafka/0") + self.harness.update_relation_data( + relation_id, + "kafka/0", + { + "host": "kafka", + "port": 9090, + }, + ) + + # Verifying status + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) + + # Verifying status message + self.assertGreater(len(self.harness.charm.unit.status.message), 0) + self.assertTrue( + self.harness.charm.unit.status.message.startswith("Waiting for ") + ) + self.assertIn("mongodb", self.harness.charm.unit.status.message) + self.assertTrue(self.harness.charm.unit.status.message.endswith(" relation")) + + def test_on_mongodb_unit_relation_changed(self) -> NoReturn: + """Test to see if mongodb relation is updated.""" + self.harness.charm.on.start.emit() + + relation_id = self.harness.add_relation("mongodb", "mongodb") + self.harness.add_relation_unit(relation_id, "mongodb/0") + self.harness.update_relation_data( + relation_id, + "mongodb/0", + { + "connection_string": "mongodb://mongo", + }, + ) + + # Verifying status + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) + + # Verifying status message + self.assertGreater(len(self.harness.charm.unit.status.message), 0) + self.assertTrue( + self.harness.charm.unit.status.message.startswith("Waiting for ") + ) + self.assertIn("kafka", self.harness.charm.unit.status.message) + self.assertTrue(self.harness.charm.unit.status.message.endswith(" relation")) + + def test_on_mysql_unit_relation_changed(self) -> NoReturn: + """Test to see if mysql relation is updated.""" + self.harness.charm.on.start.emit() + + relation_id = self.harness.add_relation("mysql", "mysql") + self.harness.add_relation_unit(relation_id, "mysql/0") + self.harness.update_relation_data( + relation_id, + "mysql/0", + { + "host": "mysql", + "port": 3306, + "user": "mano", + "password": "manopw", + "root_password": "rootmanopw", + }, + ) + + # Verifying status + self.assertIsInstance(self.harness.charm.unit.status, BlockedStatus) + + # Verifying status message + self.assertGreater(len(self.harness.charm.unit.status.message), 0) + self.assertTrue( + self.harness.charm.unit.status.message.startswith("Waiting for ") + ) + self.assertIn("kafka", self.harness.charm.unit.status.message) + self.assertIn("mongodb", self.harness.charm.unit.status.message) + self.assertTrue(self.harness.charm.unit.status.message.endswith(" relations")) + + def test_publish_ro_info(self) -> NoReturn: + """Test to see if ro relation is updated.""" + expected_result = { + "host": "ro", + "port": "9090", + } + + self.harness.charm.on.start.emit() + + relation_id = self.harness.add_relation("ro", "lcm") + self.harness.add_relation_unit(relation_id, "lcm/0") + relation_data = self.harness.get_relation_data(relation_id, "ro") + + self.assertDictEqual(expected_result, relation_data) + + +if __name__ == "__main__": + unittest.main() diff --git a/installers/charm/ro/tests/test_pod_spec.py b/installers/charm/ro/tests/test_pod_spec.py new file mode 100644 index 00000000..2dc11041 --- /dev/null +++ b/installers/charm/ro/tests/test_pod_spec.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +from typing import NoReturn +import unittest + +import pod_spec + + +class TestPodSpec(unittest.TestCase): + """Pod spec unit tests.""" + + def test_make_pod_ports(self) -> NoReturn: + """Testing make pod ports.""" + port = 9090 + + expected_result = [ + { + "name": "ro", + "containerPort": port, + "protocol": "TCP", + } + ] + + pod_ports = pod_spec._make_pod_ports(port) + + self.assertListEqual(expected_result, pod_ports) + + def test_make_pod_envconfig_ng_ro(self) -> NoReturn: + """Teting make pod envconfig.""" + config = { + "enable_ng_ro": True, + "database_commonkey": "osm", + "log_level": "INFO", + } + relation_state = { + "kafka_host": "kafka", + "kafka_port": 9090, + "mongodb_connection_string": "mongodb://mongo", + } + + expected_result = { + "OSMRO_LOG_LEVEL": config["log_level"], + "OSMRO_MESSAGE_DRIVER": "kafka", + "OSMRO_MESSAGE_HOST": relation_state["kafka_host"], + "OSMRO_MESSAGE_PORT": relation_state["kafka_port"], + "OSMRO_DATABASE_DRIVER": "mongo", + "OSMRO_DATABASE_URI": relation_state["mongodb_connection_string"], + "OSMRO_DATABASE_COMMONKEY": config["database_commonkey"], + } + + pod_envconfig = pod_spec._make_pod_envconfig(config, relation_state) + + self.assertDictEqual(expected_result, pod_envconfig) + + def test_make_pod_envconfig_no_ng_ro(self) -> NoReturn: + """Teting make pod envconfig.""" + config = { + "log_level": "INFO", + "enable_ng_ro": False, + "vim_database": "mano_vim_db", + "ro_database": "mano_db", + "openmano_tenant": "osm", + } + relation_state = { + "mysql_host": "mysql", + "mysql_port": 3306, + "mysql_user": "mano", + "mysql_password": "manopw", + "mysql_root_password": "rootmanopw", + } + + expected_result = { + "OSMRO_LOG_LEVEL": config["log_level"], + "RO_DB_HOST": relation_state["mysql_host"], + "RO_DB_OVIM_HOST": relation_state["mysql_host"], + "RO_DB_PORT": relation_state["mysql_port"], + "RO_DB_OVIM_PORT": relation_state["mysql_port"], + "RO_DB_USER": relation_state["mysql_user"], + "RO_DB_OVIM_USER": relation_state["mysql_user"], + "RO_DB_PASSWORD": relation_state["mysql_password"], + "RO_DB_OVIM_PASSWORD": relation_state["mysql_password"], + "RO_DB_ROOT_PASSWORD": relation_state["mysql_root_password"], + "RO_DB_OVIM_ROOT_PASSWORD": relation_state["mysql_root_password"], + "RO_DB_NAME": config["ro_database"], + "RO_DB_OVIM_NAME": config["vim_database"], + "OPENMANO_TENANT": config["openmano_tenant"], + } + + pod_envconfig = pod_spec._make_pod_envconfig(config, relation_state) + + self.assertDictEqual(expected_result, pod_envconfig) + + def test_make_startup_probe(self) -> NoReturn: + """Testing make startup probe.""" + expected_result = { + "exec": {"command": ["/usr/bin/pgrep", "python3"]}, + "initialDelaySeconds": 60, + "timeoutSeconds": 5, + } + + startup_probe = pod_spec._make_startup_probe() + + self.assertDictEqual(expected_result, startup_probe) + + def test_make_readiness_probe(self) -> NoReturn: + """Testing make readiness probe.""" + port = 9090 + + expected_result = { + "httpGet": { + "path": "/openmano/tenants", + "port": port, + }, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + } + + readiness_probe = pod_spec._make_readiness_probe(port) + + self.assertDictEqual(expected_result, readiness_probe) + + def test_make_liveness_probe(self) -> NoReturn: + """Testing make liveness probe.""" + port = 9090 + + expected_result = { + "httpGet": { + "path": "/openmano/tenants", + "port": port, + }, + "initialDelaySeconds": 600, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + } + + liveness_probe = pod_spec._make_liveness_probe(port) + + self.assertDictEqual(expected_result, liveness_probe) + + def test_make_pod_spec_ng_ro(self) -> NoReturn: + """Testing make pod spec.""" + image_info = {"upstream-source": "opensourcemano/ro:8"} + config = { + "database_commonkey": "osm", + "log_level": "INFO", + "enable_ng_ro": True, + } + relation_state = { + "kafka_host": "kafka", + "kafka_port": 9090, + "mongodb_connection_string": "mongodb://mongo", + } + app_name = "ro" + port = 9090 + + expected_result = { + "version": 3, + "containers": [ + { + "name": app_name, + "imageDetails": image_info, + "imagePullPolicy": "Always", + "ports": [ + { + "name": app_name, + "containerPort": port, + "protocol": "TCP", + } + ], + "envConfig": { + "OSMRO_LOG_LEVEL": config["log_level"], + "OSMRO_MESSAGE_DRIVER": "kafka", + "OSMRO_MESSAGE_HOST": relation_state["kafka_host"], + "OSMRO_MESSAGE_PORT": relation_state["kafka_port"], + "OSMRO_DATABASE_DRIVER": "mongo", + "OSMRO_DATABASE_URI": relation_state[ + "mongodb_connection_string" + ], + "OSMRO_DATABASE_COMMONKEY": config["database_commonkey"], + }, + "kubernetes": { + "startupProbe": { + "exec": {"command": ["/usr/bin/pgrep", "python3"]}, + "initialDelaySeconds": 60, + "timeoutSeconds": 5, + }, + "readinessProbe": { + "httpGet": { + "path": "/openmano/tenants", + "port": port, + }, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + }, + "livenessProbe": { + "httpGet": { + "path": "/openmano/tenants", + "port": port, + }, + "initialDelaySeconds": 600, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + }, + }, + } + ], + "kubernetesResources": {"ingressResources": []}, + } + + spec = pod_spec.make_pod_spec( + image_info, config, relation_state, app_name, port + ) + + self.assertDictEqual(expected_result, spec) + + def test_make_pod_spec_no_ng_ro(self) -> NoReturn: + """Testing make pod spec.""" + image_info = {"upstream-source": "opensourcemano/ro:8"} + config = { + "log_level": "INFO", + "enable_ng_ro": False, + "vim_database": "mano_vim_db", + "ro_database": "mano_db", + "openmano_tenant": "osm", + } + relation_state = { + "mysql_host": "mysql", + "mysql_port": 3306, + "mysql_user": "mano", + "mysql_password": "manopw", + "mysql_root_password": "rootmanopw", + } + app_name = "ro" + port = 9090 + + expected_result = { + "version": 3, + "containers": [ + { + "name": app_name, + "imageDetails": image_info, + "imagePullPolicy": "Always", + "ports": [ + { + "name": app_name, + "containerPort": port, + "protocol": "TCP", + } + ], + "envConfig": { + "OSMRO_LOG_LEVEL": config["log_level"], + "RO_DB_HOST": relation_state["mysql_host"], + "RO_DB_OVIM_HOST": relation_state["mysql_host"], + "RO_DB_PORT": relation_state["mysql_port"], + "RO_DB_OVIM_PORT": relation_state["mysql_port"], + "RO_DB_USER": relation_state["mysql_user"], + "RO_DB_OVIM_USER": relation_state["mysql_user"], + "RO_DB_PASSWORD": relation_state["mysql_password"], + "RO_DB_OVIM_PASSWORD": relation_state["mysql_password"], + "RO_DB_ROOT_PASSWORD": relation_state["mysql_root_password"], + "RO_DB_OVIM_ROOT_PASSWORD": relation_state[ + "mysql_root_password" + ], + "RO_DB_NAME": config["ro_database"], + "RO_DB_OVIM_NAME": config["vim_database"], + "OPENMANO_TENANT": config["openmano_tenant"], + }, + "kubernetes": { + "startupProbe": { + "exec": {"command": ["/usr/bin/pgrep", "python3"]}, + "initialDelaySeconds": 60, + "timeoutSeconds": 5, + }, + "readinessProbe": { + "httpGet": { + "path": "/openmano/tenants", + "port": port, + }, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + }, + "livenessProbe": { + "httpGet": { + "path": "/openmano/tenants", + "port": port, + }, + "initialDelaySeconds": 600, + "periodSeconds": 10, + "timeoutSeconds": 5, + "successThreshold": 1, + "failureThreshold": 3, + }, + }, + } + ], + "kubernetesResources": {"ingressResources": []}, + } + + spec = pod_spec.make_pod_spec( + image_info, config, relation_state, app_name, port + ) + + self.assertDictEqual(expected_result, spec) + + def test_make_pod_spec_without_image_info(self) -> NoReturn: + """Testing make pod spec without image_info.""" + image_info = None + config = { + "enable_ng_ro": True, + "database_commonkey": "osm", + "log_level": "INFO", + } + relation_state = { + "kafka_host": "kafka", + "kafka_port": 9090, + "mongodb_connection_string": "mongodb://mongo", + } + app_name = "ro" + port = 9090 + + spec = pod_spec.make_pod_spec( + image_info, config, relation_state, app_name, port + ) + + self.assertIsNone(spec) + + def test_make_pod_spec_without_config(self) -> NoReturn: + """Testing make pod spec without config.""" + image_info = {"upstream-source": "opensourcemano/ro:8"} + config = {} + relation_state = { + "kafka_host": "kafka", + "kafka_port": 9090, + "mongodb_connection_string": "mongodb://mongo", + } + app_name = "ro" + port = 9090 + + with self.assertRaises(ValueError): + pod_spec.make_pod_spec(image_info, config, relation_state, app_name, port) + + def test_make_pod_spec_without_relation_state(self) -> NoReturn: + """Testing make pod spec without relation_state.""" + image_info = {"upstream-source": "opensourcemano/ro:8"} + config = { + "enable_ng_ro": True, + "database_commonkey": "osm", + "log_level": "INFO", + } + relation_state = {} + app_name = "ro" + port = 9090 + + with self.assertRaises(ValueError): + pod_spec.make_pod_spec(image_info, config, relation_state, app_name, port) + + +if __name__ == "__main__": + unittest.main() diff --git a/installers/charm/ro/tox.ini b/installers/charm/ro/tox.ini new file mode 100644 index 00000000..44461453 --- /dev/null +++ b/installers/charm/ro/tox.ini @@ -0,0 +1,81 @@ +# Copyright 2020 Canonical Ltd. +# +# 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. +# +# For those usages not covered by the Apache License, Version 2.0 please +# contact: legal@canonical.com +# +# To get in touch with the maintainers, please contact: +# osm-charmers@lists.launchpad.net +## + +[tox] +skipsdist = True +envlist = unit, lint +sitepackages = False +skip_missing_interpreters = False + +[testenv] +basepython = python3 +setenv = + PYTHONHASHSEED=0 + PYTHONPATH = {toxinidir}/src + CHARM_NAME = ro + +[testenv:build] +passenv=HTTP_PROXY HTTPS_PROXY NO_PROXY +whitelist_externals = + charmcraft + rm + unzip +commands = + rm -rf release ro.charm + charmcraft build + unzip ro.charm -d release + +[testenv:unit] +commands = + coverage erase + stestr run --slowest --test-path=./tests --top-dir=./ + coverage combine + coverage html -d cover + coverage xml -o cover/coverage.xml + coverage report +deps = + coverage + stestr + mock + ops +setenv = + {[testenv]setenv} + PYTHON=coverage run + +[testenv:lint] +deps = + black + yamllint + flake8 +commands = + black --check --diff . --exclude "build/|.tox/|mod/|lib/" + yamllint . + flake8 . --max-line-length=100 --ignore="E501,W503,W504,F722" --exclude "build/ .tox/ mod/ lib/" + +[coverage:run] +branch = True +concurrency = multiprocessing +parallel = True +source = + . +omit = + .tox/* + tests/* -- 2.25.1