Misc fixes and additional testing

* Add functional tests. This is currently limited to checking the
  charm deploys and relates to mandatory relations.
* Add heat-engine container
* Add management of all 3 containers to charm. Previously only the
  heat-api container was managed and this was incorrectly done on
  the assumption is was a wsgi app
* Add management for auth_encryption_key
* Add ops.testing unit tests

Change-Id: I57b24a01ed473c96648f78095dc5e4e87d240e66
This commit is contained in:
Liam Young 2023-07-13 11:58:23 +00:00
parent 0c20244384
commit 6ddf3c01ff
21 changed files with 652 additions and 311 deletions

8
.gitignore vendored
View File

@ -1,9 +1,11 @@
venv/
build/
*.charm
.tox/
*.swp
.coverage
__pycache__/
*.py[cod]
.idea
.vscode/
.tox
.stestr/
tempest.log

5
.gitreview Normal file
View File

@ -0,0 +1,5 @@
[gerrit]
host=review.opendev.org
port=29418
project=openstack/charm-heat-k8s.git
defaultbranch=main

3
.stestr.conf Normal file
View File

@ -0,0 +1,3 @@
[DEFAULT]
test_path=./tests/unit
top_dir=./tests

11
.zuul.yaml Normal file
View File

@ -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

3
TODO.txt Normal file
View File

@ -0,0 +1,3 @@
* Register CFN endpoint with keystone traefik
* Tempest tests
* Switch to Antelope rocks

View File

@ -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:

10
osci.yaml Normal file
View File

@ -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

View File

@ -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"

13
rename.sh Executable file
View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }}

View File

@ -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

71
tests/bundles/smoke.yaml Normal file
View File

@ -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

1
tests/config.yaml Symbolic link
View File

@ -0,0 +1 @@
../config.yaml

View File

@ -1,35 +0,0 @@
#!/usr/bin/env python3
# Copyright 2023 Felipe Reyes <felipe.reyes@canonical.com>
# 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
),
)

35
tests/tests.yaml Normal file
View File

@ -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: '^.*$'

15
tests/unit/__init__.py Normal file
View File

@ -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."""

View File

@ -1,74 +0,0 @@
# Copyright 2023 Felipe Reyes <felipe.reyes@canonical.com>
# 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)

View File

@ -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)

217
tox.ini
View File

@ -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