From 2ee5a5779a711fe83deba82538762f44a5fc4657 Mon Sep 17 00:00:00 2001
From: Liam Young <liam.young@canonical.com>
Date: Thu, 13 Jul 2023 11:58:23 +0000
Subject: [PATCH] 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
---
 charms/heat-k8s/.gitignore                    |   8 +-
 charms/heat-k8s/.gitreview                    |   5 +
 charms/heat-k8s/.stestr.conf                  |   3 +
 charms/heat-k8s/.zuul.yaml                    |  11 +
 charms/heat-k8s/TODO.txt                      |   3 +
 charms/heat-k8s/metadata.yaml                 |  13 +-
 charms/heat-k8s/osci.yaml                     |  10 +
 charms/heat-k8s/pyproject.toml                |  46 ++--
 charms/heat-k8s/rename.sh                     |  13 ++
 charms/heat-k8s/src/charm.py                  | 212 +++++++++++++++--
 .../heat-k8s/src/templates/api-paste.ini.j2   |  65 ++----
 charms/heat-k8s/src/templates/heat.conf.j2    |   2 +-
 charms/heat-k8s/test-requirements.txt         |  30 ++-
 charms/heat-k8s/tests/bundles/smoke.yaml      |  71 ++++++
 charms/heat-k8s/tests/config.yaml             |   1 +
 .../heat-k8s/tests/integration/test_charm.py  |  35 ---
 charms/heat-k8s/tests/tests.yaml              |  35 +++
 charms/heat-k8s/tests/unit/__init__.py        |  15 ++
 charms/heat-k8s/tests/unit/test_charm.py      |  74 ------
 charms/heat-k8s/tests/unit/test_heat_charm.py |  94 ++++++++
 charms/heat-k8s/tox.ini                       | 217 ++++++++++--------
 21 files changed, 652 insertions(+), 311 deletions(-)
 create mode 100644 charms/heat-k8s/.gitreview
 create mode 100644 charms/heat-k8s/.stestr.conf
 create mode 100644 charms/heat-k8s/.zuul.yaml
 create mode 100644 charms/heat-k8s/TODO.txt
 create mode 100644 charms/heat-k8s/osci.yaml
 create mode 100755 charms/heat-k8s/rename.sh
 create mode 100644 charms/heat-k8s/tests/bundles/smoke.yaml
 create mode 120000 charms/heat-k8s/tests/config.yaml
 delete mode 100644 charms/heat-k8s/tests/integration/test_charm.py
 create mode 100644 charms/heat-k8s/tests/tests.yaml
 create mode 100644 charms/heat-k8s/tests/unit/__init__.py
 delete mode 100644 charms/heat-k8s/tests/unit/test_charm.py
 create mode 100644 charms/heat-k8s/tests/unit/test_heat_charm.py

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