diff --git a/charms/heat-k8s/.gitignore b/charms/heat-k8s/.gitignore index a26d707f..73f116c9 100644 --- a/charms/heat-k8s/.gitignore +++ b/charms/heat-k8s/.gitignore @@ -1,9 +1,11 @@ venv/ build/ *.charm -.tox/ +*.swp + .coverage __pycache__/ *.py[cod] -.idea -.vscode/ +.tox +.stestr/ +tempest.log diff --git a/charms/heat-k8s/.gitreview b/charms/heat-k8s/.gitreview new file mode 100644 index 00000000..5afeb9d1 --- /dev/null +++ b/charms/heat-k8s/.gitreview @@ -0,0 +1,5 @@ +[gerrit] +host=review.opendev.org +port=29418 +project=openstack/charm-heat-k8s.git +defaultbranch=main diff --git a/charms/heat-k8s/.stestr.conf b/charms/heat-k8s/.stestr.conf new file mode 100644 index 00000000..e4750de4 --- /dev/null +++ b/charms/heat-k8s/.stestr.conf @@ -0,0 +1,3 @@ +[DEFAULT] +test_path=./tests/unit +top_dir=./tests diff --git a/charms/heat-k8s/.zuul.yaml b/charms/heat-k8s/.zuul.yaml new file mode 100644 index 00000000..79bd87e3 --- /dev/null +++ b/charms/heat-k8s/.zuul.yaml @@ -0,0 +1,11 @@ +- project: + templates: + - openstack-python3-charm-yoga-jobs + - openstack-cover-jobs + - microk8s-func-test + vars: + charm_build_name: heat-k8s + juju_channel: 3.2/stable + juju_classic_mode: false + microk8s_channel: 1.26-strict/stable + microk8s_classic_mode: false diff --git a/charms/heat-k8s/TODO.txt b/charms/heat-k8s/TODO.txt new file mode 100644 index 00000000..2bd816ab --- /dev/null +++ b/charms/heat-k8s/TODO.txt @@ -0,0 +1,3 @@ +* Register CFN endpoint with keystone traefik +* Tempest tests +* Switch to Antelope rocks diff --git a/charms/heat-k8s/metadata.yaml b/charms/heat-k8s/metadata.yaml index 5ce06997..20cbaa86 100644 --- a/charms/heat-k8s/metadata.yaml +++ b/charms/heat-k8s/metadata.yaml @@ -8,15 +8,22 @@ description: | version: 3 bases: - name: ubuntu - channel: 20.04/stable + channel: 22.04/stable +assumes: + - k8s-api + - juju >= 3.1 tags: - openstack +source: https://opendev.org/openstack/charm-heat-k8s +issues: https://bugs.launchpad.net/charm-heat-k8s containers: heat-api: resource: heat-api-image heat-api-cfn: resource: heat-api-cfn-image + heat-engine: + resource: heat-engine-image resources: heat-api-image: @@ -29,6 +36,10 @@ resources: description: OCI image for OpenStack Heat CFN # docker.io/kolla/ubuntu-binary-heat-api-cfn:yoga upstream-source: docker.io/kolla/ubuntu-binary-heat-api-cfn@sha256:6eec5915066b55696414022c86c42360cdbd4b8b1250e06b470fee25af394b66 + heat-engine-image: + type: oci-image + description: OCI image for OpenStack Heat Engine + upstream-source: docker.io/kolla/ubuntu-binary-heat-engine@sha256:a54491f7e09eedeaa42c046cedc478f8ba78fc455a6ba285a52a5d0f8ae1df84 requires: database: diff --git a/charms/heat-k8s/osci.yaml b/charms/heat-k8s/osci.yaml new file mode 100644 index 00000000..ce82c269 --- /dev/null +++ b/charms/heat-k8s/osci.yaml @@ -0,0 +1,10 @@ +- project: + templates: + - charm-publish-jobs + vars: + needs_charm_build: true + charm_build_name: heat-k8s + build_type: charmcraft + publish_charm: true + charmcraft_channel: 2.0/stable + publish_channel: 2023.1/edge diff --git a/charms/heat-k8s/pyproject.toml b/charms/heat-k8s/pyproject.toml index 3f514427..2896bc05 100644 --- a/charms/heat-k8s/pyproject.toml +++ b/charms/heat-k8s/pyproject.toml @@ -1,3 +1,6 @@ +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + # Testing tools configuration [tool.coverage.run] branch = true @@ -11,29 +14,26 @@ log_cli_level = "INFO" # Formatting tools configuration [tool.black] -line-length = 99 -target-version = ["py38"] +line-length = 79 + +[tool.isort] +profile = "black" +multi_line_output = 3 +force_grid_wrap = true # Linting tools configuration -[tool.ruff] -line-length = 99 -select = ["E", "W", "F", "C", "N", "D", "I001"] -extend-ignore = [ - "D203", - "D204", - "D213", - "D215", - "D400", - "D404", - "D406", - "D407", - "D408", - "D409", - "D413", -] -ignore = ["E501", "D107"] -extend-exclude = ["__pycache__", "*.egg_info"] -per-file-ignores = {"tests/*" = ["D100","D101","D102","D103","D104"]} - -[tool.ruff.mccabe] +[tool.flake8] +max-line-length = 79 +max-doc-length = 99 max-complexity = 10 +exclude = [".git", "__pycache__", ".tox", "build", "dist", "*.egg_info", "venv"] +select = ["E", "W", "F", "C", "N", "R", "D", "H"] +# Ignore W503, E501 because using black creates errors with this +# Ignore D107 Missing docstring in __init__ +ignore = ["W503", "E501", "D107", "E402"] +per-file-ignores = [] +docstring-convention = "google" +# Check for properly formatted copyright header in each file +copyright-check = "True" +copyright-author = "Canonical Ltd." +copyright-regexp = "Copyright\\s\\d{4}([-,]\\d{4})*\\s+%(author)s" diff --git a/charms/heat-k8s/rename.sh b/charms/heat-k8s/rename.sh new file mode 100755 index 00000000..d0c35c97 --- /dev/null +++ b/charms/heat-k8s/rename.sh @@ -0,0 +1,13 @@ +#!/bin/bash +charm=$(grep "charm_build_name" osci.yaml | awk '{print $2}') +echo "renaming ${charm}_*.charm to ${charm}.charm" +echo -n "pwd: " +pwd +ls -al +echo "Removing bad downloaded charm maybe?" +if [[ -e "${charm}.charm" ]]; +then + rm "${charm}.charm" +fi +echo "Renaming charm here." +mv ${charm}_*.charm ${charm}.charm diff --git a/charms/heat-k8s/src/charm.py b/charms/heat-k8s/src/charm.py index 1dba9501..269f8168 100755 --- a/charms/heat-k8s/src/charm.py +++ b/charms/heat-k8s/src/charm.py @@ -1,56 +1,225 @@ #!/usr/bin/env python3 +# Copyright 2023 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. + """Heat Operator Charm. -This charm provide Heat services as part of an OpenStack deployment +This charm provide heat services as part of an OpenStack deployment """ import logging - -from ops.framework import StoredState -from ops.main import main +import secrets +import string +from typing import ( + List, +) import ops_sunbeam.charm as sunbeam_charm +import ops_sunbeam.container_handlers as sunbeam_chandlers +import ops_sunbeam.core as sunbeam_core +from ops.framework import ( + StoredState, +) +from ops.main import ( + main, +) logger = logging.getLogger(__name__) +HEAT_API_CONTAINER = "heat-api" +HEAT_API_CNF_CONTAINER = "heat-api-cfn" +HEAT_ENGINE_CONTAINER = "heat-engine" + + +class HeatAPIPebbleHandler(sunbeam_chandlers.ServicePebbleHandler): + """Pebble handler for Heat API container.""" + + def get_layer(self): + """Heat API service. + + :returns: pebble service layer configuration for heat api service + :rtype: dict + """ + return { + "summary": "heat api layer", + "description": "pebble configuration for heat api service", + "services": { + "heat-api": { + "override": "replace", + "summary": "Heat API", + "command": "heat-api", + "startup": "enabled", + "user": "heat", + "group": "heat", + } + }, + } + + +class HeatAPICFNPebbleHandler(sunbeam_chandlers.ServicePebbleHandler): + """Pebble handler for Heat API CNF container.""" + + def get_layer(self): + """Heat API CNF service. + + :returns: pebble service layer configuration for API CNF service + :rtype: dict + """ + return { + "summary": "heat api cfn layer", + "description": "pebble configuration for heat api cfn service", + "services": { + "heat-api-cfn": { + "override": "replace", + "summary": "Heat API CNF", + "command": "heat-api-cfn", + "startup": "enabled", + "user": "heat", + "group": "heat", + } + }, + } + + +class HeatEnginePebbleHandler(sunbeam_chandlers.ServicePebbleHandler): + """Pebble handler for Heat engine container.""" + + def get_layer(self): + """Heat Engine service. + + :returns: pebble service layer configuration for heat engine service + :rtype: dict + """ + return { + "summary": "heat engine layer", + "description": "pebble configuration for heat engine service", + "services": { + "heat-engine": { + "override": "replace", + "summary": "Heat Engine", + "command": "heat-engine", + "startup": "enabled", + "user": "heat", + "group": "heat", + } + }, + } + class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): """Charm the service.""" _state = StoredState() service_name = "heat-api" - wsgi_admin_script = '/usr/bin/heat-wsgi-api' - wsgi_public_script = '/usr/bin/heat-wsgi-api' + wsgi_admin_script = "/usr/bin/heat-wsgi-api" + wsgi_public_script = "/usr/bin/heat-wsgi-api" + heat_auth_encryption_key = "auth_encryption_key" - db_sync_cmds = [ - ['heat-manage', 'db_sync'] - ] + db_sync_cmds = [["heat-manage", "db_sync"]] + + mandatory_relations = { + "database", + "amqp", + "identity-service", + "ingress-public", + } + + def get_pebble_handlers( + self, + ) -> List[sunbeam_chandlers.ServicePebbleHandler]: + """Pebble handlers for operator.""" + pebble_handlers = [ + HeatAPIPebbleHandler( + self, + HEAT_API_CONTAINER, + "heat-api", + self.default_container_configs(), + self.template_dir, + self.configure_charm, + ), + HeatAPICFNPebbleHandler( + self, + HEAT_API_CNF_CONTAINER, + "heat-api-cfn", + self.default_container_configs(), + self.template_dir, + self.configure_charm, + ), + HeatEnginePebbleHandler( + self, + HEAT_ENGINE_CONTAINER, + "heat-engine", + self.default_container_configs(), + self.template_dir, + self.configure_charm, + ), + ] + return pebble_handlers + + def get_heat_auth_encryption_key(self): + """Return the shared metadata secret.""" + return self.leader_get(self.heat_auth_encryption_key) + + def set_heat_auth_encryption_key(self): + """Store the shared metadata secret.""" + alphabet = string.ascii_letters + string.digits + key = "".join(secrets.choice(alphabet) for i in range(32)) + self.leader_set({self.heat_auth_encryption_key: key}) + + def configure_charm(self, event): + """Configure charm. + + Ensure setting the auth key is first as services in container need it + to start. + """ + if self.unit.is_leader(): + auth_key = self.get_heat_auth_encryption_key() + if auth_key: + logger.debug("Found auth key in leader DB") + else: + logger.debug("Creating auth key") + self.set_heat_auth_encryption_key() + super().configure_charm(event) @property def service_conf(self) -> str: """Service default configuration file.""" - return f"/etc/heat/heat.conf" + return "/etc/heat/heat.conf" @property def service_user(self) -> str: """Service user file and directory ownership.""" - return 'heat' + return "heat" @property def service_group(self) -> str: """Service group file and directory ownership.""" - return 'heat' + return "heat" @property def service_endpoints(self): + """Return heat service endpoints.""" return [ { - 'service_name': 'heat', - 'type': 'heat', - 'description': "OpenStack Heat API", - 'internal_url': f'{self.internal_url}', - 'public_url': f'{self.public_url}', - 'admin_url': f'{self.admin_url}'}] + "service_name": "heat", + "type": "heat", + "description": "OpenStack Heat API", + "internal_url": f"{self.internal_url}", + "public_url": f"{self.public_url}", + "admin_url": f"{self.admin_url}", + } + ] def get_healthcheck_layer(self) -> dict: """Health check pebble layer. @@ -68,18 +237,19 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): } def default_container_configs(self): - """Base container configs.""" + """Return base container configs.""" return [ sunbeam_core.ContainerConfigFile( - "/etc/heat/heat-api.conf", "heat", "heat" + "/etc/heat/heat.conf", "root", "heat" ), sunbeam_core.ContainerConfigFile( - "/etc/heat/api-paste.ini", "heat", "heat" + "/etc/heat/api-paste.ini", "root", "heat" ), ] @property def default_public_ingress_port(self): + """Port for Heat AI service.""" return 8004 diff --git a/charms/heat-k8s/src/templates/api-paste.ini.j2 b/charms/heat-k8s/src/templates/api-paste.ini.j2 index 220f68d9..09b4d82f 100644 --- a/charms/heat-k8s/src/templates/api-paste.ini.j2 +++ b/charms/heat-k8s/src/templates/api-paste.ini.j2 @@ -1,10 +1,9 @@ -# heat-api composite -[composite:heat-api] -paste.composite_factory = heat.api:root_app_factory -/: api -/healthcheck: healthcheck -# heat-api composite for standalone heat +# heat-api pipeline +[pipeline:heat-api] +pipeline = healthcheck cors request_id faultwrap http_proxy_to_wsgi versionnegotiation authurl authtoken context osprofiler apiv1app + +# heat-api pipeline for standalone heat # ie. uses alternative auth backend that authenticates users against keystone # using username and password instead of validating token (which requires # an admin/service token). @@ -12,54 +11,32 @@ paste.composite_factory = heat.api:root_app_factory # [paste_deploy] # flavor = standalone # -[composite:heat-api-standalone] -paste.composite_factory = heat.api:root_app_factory -/: api -/healthcheck: healthcheck +[pipeline:heat-api-standalone] +pipeline = healthcheck cors request_id faultwrap http_proxy_to_wsgi versionnegotiation authurl authpassword context apiv1app -# heat-api composite for custom cloud backends +# heat-api pipeline for custom cloud backends # i.e. in heat.conf: # [paste_deploy] # flavor = custombackend # -[composite:heat-api-custombackend] -paste.composite_factory = heat.api:root_app_factory -/: api -/healthcheck: healthcheck +[pipeline:heat-api-custombackend] +pipeline = healthcheck cors request_id context faultwrap versionnegotiation custombackendauth apiv1app # To enable, in heat.conf: # [paste_deploy] # flavor = noauth # -[composite:heat-api-noauth] -paste.composite_factory = heat.api:root_app_factory -/: api -/healthcheck: healthcheck +[pipeline:heat-api-noauth] +pipeline = healthcheck cors request_id faultwrap noauth context http_proxy_to_wsgi versionnegotiation apiv1app -# heat-api-cfn composite -[composite:heat-api-cfn] -paste.composite_factory = heat.api:root_app_factory -/: api-cfn -/healthcheck: healthcheck +# heat-api-cfn pipeline +[pipeline:heat-api-cfn] +pipeline = healthcheck cors request_id http_proxy_to_wsgi cfnversionnegotiation ec2authtoken authtoken context osprofiler apicfnv1app -# heat-api-cfn composite for standalone heat +# heat-api-cfn pipeline for standalone heat # relies exclusively on authenticating with ec2 signed requests -[composite:heat-api-cfn-standalone] -paste.composite_factory = heat.api:root_app_factory -/: api-cfn -/healthcheck: healthcheck - -[composite:api] -paste.composite_factory = heat.api:pipeline_factory -default = cors request_id faultwrap http_proxy_to_wsgi versionnegotiation authurl authtoken context osprofiler apiv1app -standalone = cors request_id faultwrap http_proxy_to_wsgi versionnegotiation authurl authpassword context apiv1app -custombackend = cors request_id context faultwrap versionnegotiation custombackendauth apiv1app -noauth = cors request_id faultwrap noauth context http_proxy_to_wsgi versionnegotiation apiv1app - -[composite:api-cfn] -paste.composite_factory = heat.api:pipeline_factory -default = cors request_id http_proxy_to_wsgi cfnversionnegotiation ec2authtoken authtoken context osprofiler apicfnv1app -standalone = cors request_id http_proxy_to_wsgi cfnversionnegotiation ec2authtoken context apicfnv1app +[pipeline:heat-api-cfn-standalone] +pipeline = healthcheck cors request_id http_proxy_to_wsgi cfnversionnegotiation ec2authtoken context apicfnv1app [app:apiv1app] paste.app_factory = heat.common.wsgi:app_factory @@ -69,9 +46,6 @@ heat.app_factory = heat.api.openstack.v1:API paste.app_factory = heat.common.wsgi:app_factory heat.app_factory = heat.api.cfn.v1:API -[app:healthcheck] -paste.app_factory = oslo_middleware:Healthcheck.app_factory - [filter:versionnegotiation] paste.filter_factory = heat.common.wsgi:filter_factory heat.filter_factory = heat.api.openstack:version_negotiation_filter @@ -126,3 +100,6 @@ paste.filter_factory = oslo_middleware.request_id:RequestId.factory [filter:osprofiler] paste.filter_factory = osprofiler.web:WsgiMiddleware.factory + +[filter:healthcheck] +paste.filter_factory = oslo_middleware:Healthcheck.factory diff --git a/charms/heat-k8s/src/templates/heat.conf.j2 b/charms/heat-k8s/src/templates/heat.conf.j2 index bcdc4eef..a53d370c 100644 --- a/charms/heat-k8s/src/templates/heat.conf.j2 +++ b/charms/heat-k8s/src/templates/heat.conf.j2 @@ -7,7 +7,7 @@ plugin_dirs = /usr/lib64/heat,/usr/lib/heat environment_dir=/etc/heat/environment.d deferred_auth_method=password host=heat -auth_encryption_key={{ encryption_key }} +auth_encryption_key={{ peers.auth_encryption_key }} transport_url = {{ amqp.transport_url }} diff --git a/charms/heat-k8s/test-requirements.txt b/charms/heat-k8s/test-requirements.txt index 8057d2c6..f4b4552c 100644 --- a/charms/heat-k8s/test-requirements.txt +++ b/charms/heat-k8s/test-requirements.txt @@ -1,17 +1,15 @@ -# 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 +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. See the 'global' dir contents for available +# choices of *requirements.txt files for OpenStack Charms: +# https://github.com/openstack-charmers/release-tools +# + +pwgen +coverage +mock +flake8 +stestr +git+https://github.com/openstack-charmers/zaza.git@libjuju-3.1#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?) +git+https://opendev.org/openstack/tempest.git#egg=tempest +ops diff --git a/charms/heat-k8s/tests/bundles/smoke.yaml b/charms/heat-k8s/tests/bundles/smoke.yaml new file mode 100644 index 00000000..1b57e6bf --- /dev/null +++ b/charms/heat-k8s/tests/bundles/smoke.yaml @@ -0,0 +1,71 @@ +bundle: kubernetes +applications: + + mysql: + charm: ch:mysql-k8s + channel: 8.0/stable + scale: 1 + trust: false + + # Currently traefik is required for networking things. + # If this isn't present, the units will hang at "installing agent". + traefik: + charm: ch:traefik-k8s + channel: 1.0/stable + scale: 1 + trust: true + + traefik-public: + charm: ch:traefik-k8s + channel: 1.0/stable + scale: 1 + trust: true + options: + kubernetes-service-annotations: metallb.universe.tf/address-pool=public + + rabbitmq: + charm: ch:rabbitmq-k8s + channel: 3.9/edge + scale: 1 + trust: true + + keystone: + charm: ch:keystone-k8s + channel: 2023.1/edge + scale: 1 + trust: true + options: + admin-role: admin + storage: + fernet-keys: 5M + credential-keys: 5M + + heat: + charm: ../../heat-k8s.charm + scale: 1 + trust: true + resources: + heat-api-image: docker.io/kolla/ubuntu-binary-heat-api@sha256:ca80d57606525facb404d8b0374701c02609c2ade5cb7e28ba132e666dd85949 + heat-api-cfn-image: docker.io/kolla/ubuntu-binary-heat-api-cfn@sha256:6eec5915066b55696414022c86c42360cdbd4b8b1250e06b470fee25af394b66 + heat-engine-image: docker.io/kolla/ubuntu-binary-heat-engine@sha256:a54491f7e09eedeaa42c046cedc478f8ba78fc455a6ba285a52a5d0f8ae1df84 + +relations: +- - traefik:ingress + - keystone:ingress-internal +- - traefik-public:ingress + - keystone:ingress-public + +- - mysql:database + - keystone:database + +- - mysql:database + - heat:database +- - keystone:identity-service + - heat:identity-service +- - traefik:ingress + - heat:ingress-internal +- - traefik-public:ingress + - heat:ingress-public +- - rabbitmq:amqp + - heat:amqp + diff --git a/charms/heat-k8s/tests/config.yaml b/charms/heat-k8s/tests/config.yaml new file mode 120000 index 00000000..e84e89a8 --- /dev/null +++ b/charms/heat-k8s/tests/config.yaml @@ -0,0 +1 @@ +../config.yaml \ No newline at end of file diff --git a/charms/heat-k8s/tests/integration/test_charm.py b/charms/heat-k8s/tests/integration/test_charm.py deleted file mode 100644 index 7a762d6a..00000000 --- a/charms/heat-k8s/tests/integration/test_charm.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python3 -# Copyright 2023 Felipe Reyes -# See LICENSE file for licensing details. - -import asyncio -import logging -from pathlib import Path - -import pytest -import yaml -from pytest_operator.plugin import OpsTest - -logger = logging.getLogger(__name__) - -METADATA = yaml.safe_load(Path("./metadata.yaml").read_text()) -APP_NAME = METADATA["name"] - - -@pytest.mark.abort_on_fail -async def test_build_and_deploy(ops_test: OpsTest): - """Build the charm-under-test and deploy it together with related charms. - - Assert on the unit status before any relations/configurations take place. - """ - # Build and deploy charm from local source folder - charm = await ops_test.build_charm(".") - resources = {"httpbin-image": METADATA["resources"]["httpbin-image"]["upstream-source"]} - - # Deploy the charm and wait for active/idle status - await asyncio.gather( - ops_test.model.deploy(charm, resources=resources, application_name=APP_NAME), - ops_test.model.wait_for_idle( - apps=[APP_NAME], status="active", raise_on_blocked=True, timeout=1000 - ), - ) diff --git a/charms/heat-k8s/tests/tests.yaml b/charms/heat-k8s/tests/tests.yaml new file mode 100644 index 00000000..b9d7d730 --- /dev/null +++ b/charms/heat-k8s/tests/tests.yaml @@ -0,0 +1,35 @@ +gate_bundles: + - smoke +smoke_bundles: + - smoke +# There is no storage provider at the moment so cannot run tests. +configure: + - zaza.charm_tests.noop.setup.basic_setup +tests: + - zaza.charm_tests.noop.tests.NoopTest +tests_options: + trust: + - smoke + ignore_hard_deploy_errors: + - smoke + +target_deploy_status: + traefik: + workload-status: active + workload-status-message-regex: '^$' + traefik-public: + workload-status: active + workload-status-message-regex: '^$' + rabbitmq: + workload-status: active + workload-status-message-regex: '^$' + keystone: + workload-status: active + workload-status-message-regex: '^$' + mysql: + workload-status: active + workload-status-message-regex: '^.*$' + heat: + workload-status: active + workload-status-message-regex: '^.*$' + diff --git a/charms/heat-k8s/tests/unit/__init__.py b/charms/heat-k8s/tests/unit/__init__.py new file mode 100644 index 00000000..e9b176f7 --- /dev/null +++ b/charms/heat-k8s/tests/unit/__init__.py @@ -0,0 +1,15 @@ +# Copyright 2022 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. + +"""Unit tests for Heat operator.""" diff --git a/charms/heat-k8s/tests/unit/test_charm.py b/charms/heat-k8s/tests/unit/test_charm.py deleted file mode 100644 index fe9ceb09..00000000 --- a/charms/heat-k8s/tests/unit/test_charm.py +++ /dev/null @@ -1,74 +0,0 @@ -# Copyright 2023 Felipe Reyes -# See LICENSE file for licensing details. -# -# Learn more about testing at: https://juju.is/docs/sdk/testing - -import unittest - -import ops.testing -from charm import HeatK8SCharm -from ops.model import ActiveStatus, BlockedStatus, WaitingStatus -from ops.testing import Harness - - -class TestCharm(unittest.TestCase): - def setUp(self): - # Enable more accurate simulation of container networking. - # For more information, see https://juju.is/docs/sdk/testing#heading--simulate-can-connect - ops.testing.SIMULATE_CAN_CONNECT = True - self.addCleanup(setattr, ops.testing, "SIMULATE_CAN_CONNECT", False) - - self.harness = Harness(HeatK8SCharm) - self.addCleanup(self.harness.cleanup) - self.harness.begin() - - def test_httpbin_pebble_ready(self): - # 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": {"GUNICORN_CMD_ARGS": "--log-level info"}, - } - }, - } - # Simulate the container coming up and emission of pebble-ready event - self.harness.container_pebble_ready("httpbin") - # 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()) - - def test_config_changed_valid_can_connect(self): - # Ensure the simulated Pebble API is reachable - self.harness.set_can_connect("httpbin", True) - # Trigger a config-changed event with an updated value - self.harness.update_config({"log-level": "debug"}) - # Get the plan now we've run PebbleReady - updated_plan = self.harness.get_container_pebble_plan("httpbin").to_dict() - updated_env = updated_plan["services"]["httpbin"]["environment"] - # Check the config change was effective - self.assertEqual(updated_env, {"GUNICORN_CMD_ARGS": "--log-level debug"}) - self.assertEqual(self.harness.model.unit.status, ActiveStatus()) - - def test_config_changed_valid_cannot_connect(self): - # Trigger a config-changed event with an updated value - self.harness.update_config({"log-level": "debug"}) - # Check the charm is in WaitingStatus - self.assertIsInstance(self.harness.model.unit.status, WaitingStatus) - - def test_config_changed_invalid(self): - # Ensure the simulated Pebble API is reachable - self.harness.set_can_connect("httpbin", True) - # Trigger a config-changed event with an updated value - self.harness.update_config({"log-level": "foobar"}) - # Check the charm is in BlockedStatus - self.assertIsInstance(self.harness.model.unit.status, BlockedStatus) diff --git a/charms/heat-k8s/tests/unit/test_heat_charm.py b/charms/heat-k8s/tests/unit/test_heat_charm.py new file mode 100644 index 00000000..48ca1904 --- /dev/null +++ b/charms/heat-k8s/tests/unit/test_heat_charm.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 + +# Copyright 2021 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. + +"""Unit tests for Heat operator.""" + +import ops_sunbeam.test_utils as test_utils + +import charm + + +class _HeatTestOperatorCharm(charm.HeatOperatorCharm): + """Test Operator Charm for Heat Operator.""" + + def __init__(self, framework): + self.seen_events = [] + super().__init__(framework) + + def _log_event(self, event): + self.seen_events.append(type(event).__name__) + + def configure_charm(self, event): + super().configure_charm(event) + self._log_event(event) + + @property + def public_ingress_address(self): + return "heat.juju" + + +class TestHeatOperatorCharm(test_utils.CharmTestCase): + """Unit tests for Heat Operator.""" + + PATCHES = [] + + def setUp(self): + """Run setup for unit tests.""" + super().setUp(charm, self.PATCHES) + self.harness = test_utils.get_harness( + _HeatTestOperatorCharm, container_calls=self.container_calls + ) + + # clean up events that were dynamically defined, + # otherwise we get issues because they'll be redefined, + # which is not allowed. + from charms.data_platform_libs.v0.database_requires import ( + DatabaseEvents, + ) + + for attr in ( + "database_database_created", + "database_endpoints_changed", + "database_read_only_endpoints_changed", + ): + try: + delattr(DatabaseEvents, attr) + except AttributeError: + pass + + self.addCleanup(self.harness.cleanup) + self.harness.begin() + + def test_pebble_ready_handler(self): + """Test pebble ready handler.""" + self.assertEqual(self.harness.charm.seen_events, []) + test_utils.set_all_pebbles_ready(self.harness) + self.assertEqual(len(self.harness.charm.seen_events), 3) + + def test_all_relations(self): + """Test all integrations for operator.""" + self.harness.set_leader() + test_utils.set_all_pebbles_ready(self.harness) + # this adds all the default/common relations + test_utils.add_all_relations(self.harness) + test_utils.add_complete_ingress_relation(self.harness) + + setup_cmds = [["heat-manage", "db_sync"]] + for cmd in setup_cmds: + self.assertIn(cmd, self.container_calls.execute["heat-api"]) + config_files = ["/etc/heat/heat.conf", "/etc/heat/api-paste.ini"] + for f in config_files: + self.check_file("heat-api", f) diff --git a/charms/heat-k8s/tox.ini b/charms/heat-k8s/tox.ini index 31301b80..ba31f62b 100644 --- a/charms/heat-k8s/tox.ini +++ b/charms/heat-k8s/tox.ini @@ -1,77 +1,84 @@ -# Operator charm (with zaza): tox.ini +# Source charm: ./tox.ini +# This file is managed centrally by release-tools and should not be modified +# within individual charm repos. See the 'global' dir contents for available +# choices of tox.ini for OpenStack Charms: +# https://github.com/openstack-charmers/release-tools [tox] -envlist = pep8,py3 skipsdist = True -# NOTE: Avoid build/test env pollution by not enabling sitepackages. +envlist = pep8,py3 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 +minversion = 3.18.0 + +[vars] +src_path = {toxinidir}/src/ +tst_path = {toxinidir}/tests/ +lib_path = {toxinidir}/lib/ +pyproject_toml = {toxinidir}/pyproject.toml +all_path = {[vars]src_path} {[vars]tst_path} [testenv] -setenv = VIRTUAL_ENV={envdir} - PYTHONHASHSEED=0 - CHARM_DIR={envdir} +basepython = python3 +setenv = + PYTHONPATH = {toxinidir}:{[vars]lib_path}:{[vars]src_path} +passenv = + HOME + PYTHONPATH 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 +allowlist_externals = + git + charmcraft + {toxinidir}/fetch-libs.sh + {toxinidir}/rename.sh +deps = + -r{toxinidir}/test-requirements.txt -[testenv:py35] -basepython = python3.5 -# python3.5 is irrelevant on a focal+ charm. -commands = /bin/true +[testenv:fmt] +description = Apply coding style standards to code +deps = + black + isort +commands = + isort {[vars]all_path} --skip-glob {[vars]lib_path} --skip {toxinidir}/.tox + black --config {[vars]pyproject_toml} {[vars]all_path} --exclude {[vars]lib_path} -[testenv:py36] -basepython = python3.6 -deps = -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt +[testenv:build] +basepython = python3 +deps = +commands = + charmcraft -v pack + {toxinidir}/rename.sh -[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:fetch] +basepython = python3 +deps = +commands = + {toxinidir}/fetch-libs.sh [testenv:py3] basepython = python3 -deps = -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt +deps = + {[testenv]deps} + -r{toxinidir}/requirements.txt -[testenv:pep8] -basepython = python3 -deps = -r{toxinidir}/requirements.txt - -r{toxinidir}/test-requirements.txt -commands = flake8 {posargs} src unit_tests tests +[testenv:py38] +basepython = python3.8 +deps = {[testenv:py3]deps} + +[testenv:py39] +basepython = python3.9 +deps = {[testenv:py3]deps} + +[testenv:py310] +basepython = python3.10 +deps = {[testenv:py3]deps} [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 +deps = {[testenv:py3]deps} setenv = {[testenv]setenv} PYTHON=coverage run @@ -83,6 +90,66 @@ commands = coverage xml -o cover/coverage.xml coverage report +[testenv:pep8] +description = Alias for lint +deps = {[testenv:lint]deps} +commands = {[testenv:lint]commands} + +[testenv:lint] +description = Check code against coding style standards +deps = + black + flake8<6 # Pin version until https://github.com/savoirfairelinux/flake8-copyright/issues/19 is merged + flake8-docstrings + flake8-copyright + flake8-builtins + pyproject-flake8 + pep8-naming + isort + codespell +commands = + codespell {[vars]all_path} + # pflake8 wrapper supports config from pyproject.toml + pflake8 --exclude {[vars]lib_path} --config {toxinidir}/pyproject.toml {[vars]all_path} + isort --check-only --diff {[vars]all_path} --skip-glob {[vars]lib_path} + black --config {[vars]pyproject_toml} --check --diff {[vars]all_path} --exclude {[vars]lib_path} + +[testenv:func-noop] +basepython = python3 +deps = + git+https://github.com/openstack-charmers/zaza.git@libjuju-3.1#egg=zaza + git+https://github.com/openstack-charmers/zaza-openstack-tests.git#egg=zaza.openstack + git+https://opendev.org/openstack/tempest.git#egg=tempest +commands = + functest-run-suite --help + +[testenv:func] +basepython = python3 +deps = {[testenv:func-noop]deps} +commands = + functest-run-suite --keep-model + +[testenv:func-smoke] +basepython = python3 +deps = {[testenv:func-noop]deps} +setenv = + TEST_MODEL_SETTINGS = automatically-retry-hooks=true + TEST_MAX_RESOLVE_COUNT = 5 +commands = + functest-run-suite --keep-model --smoke + +[testenv:func-dev] +basepython = python3 +deps = {[testenv:func-noop]deps} +commands = + functest-run-suite --keep-model --dev + +[testenv:func-target] +basepython = python3 +deps = {[testenv:func-noop]deps} +commands = + functest-run-suite --keep-model --bundle {posargs} + [coverage:run] branch = True concurrency = multiprocessing @@ -91,44 +158,8 @@ 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} + tests/* + src/templates/* [flake8] -# Ignore E902 because the unit_tests directory is missing in the built charm. -ignore = E402,E226,E902 +ignore=E226,W504