Move to 2023.1

* Move base to 22.04
* Use images from ghrc with tag 2023.1
* Fix heat-api service endpoints
* Add healthchecks
* Change api-paste file to reflect latest from upstream
* Add ingress rules to api-paste.ini
* Add service token role configs
* Add tempest tests
* Update necessary external libs
* Make API service configurable using config parameter.
  With this change, heat-k8s charm can be used to deploy
  heat-api+heat-engine OR heat-api-cfn+heat-engine

Change-Id: I3b89feba7f9b9d98e02b01cdb4177f7708c9d675
This commit is contained in:
Hemanth Nakkina 2023-07-21 08:54:07 +05:30
parent 2ee5a5779a
commit 0eb0eac208
21 changed files with 363 additions and 528 deletions

View File

@ -10,10 +10,12 @@ Use links instead.
# heat-k8s
Charmhub package name: operator-template
Charmhub package name: heat-k8s
More information: https://charmhub.io/heat-k8s
Describe your charm in one or two sentences.
Operator for OpenStack Heat. The charm can be used to deploy
either heat-api service or heat-api-cnf service determined by
the configuration parameter api_service.
## Other resources

View File

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

View File

@ -2,10 +2,10 @@ type: "charm"
bases:
- build-on:
- name: "ubuntu"
channel: "20.04"
channel: "22.04"
run-on:
- name: "ubuntu"
channel: "20.04"
channel: "22.04"
parts:
update-certificates:
plugin: nil

View File

@ -25,3 +25,10 @@ options:
default: RegionOne
description: Space delimited list of OpenStack regions
type: string
api_service:
default: heat-api
description: |
Value should be one of heat-api or heat-api-cfn. The configuration parameter
is only applicable during the initial deploy of the charm and change in the
configuration does not have any effect once deployed.
type: string

7
charms/heat-k8s/fetch-libs.sh Executable file
View File

@ -0,0 +1,7 @@
#!/bin/bash
echo "INFO: Fetching libs from charmhub."
charmcraft fetch-lib charms.data_platform_libs.v0.database_requires
charmcraft fetch-lib charms.keystone_k8s.v1.identity_service
charmcraft fetch-lib charms.rabbitmq_k8s.v0.rabbitmq
charmcraft fetch-lib charms.traefik_k8s.v1.ingress

View File

@ -1,4 +1,4 @@
# Copyright 2022 Canonical Ltd.
# 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.
@ -12,7 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""[DEPRECATED] Relation 'requires' side abstraction for database relation.
r"""[DEPRECATED] Relation 'requires' side abstraction for database relation.
This library is a uniform interface to a selection of common database
metadata, with added custom events that add convenience to database management,
@ -160,7 +160,7 @@ LIBAPI = 0
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version.
LIBPATCH = 5
LIBPATCH = 6
logger = logging.getLogger(__name__)
@ -171,16 +171,25 @@ class DatabaseEvent(RelationEvent):
@property
def endpoints(self) -> Optional[str]:
"""Returns a comma separated list of read/write endpoints."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("endpoints")
@property
def password(self) -> Optional[str]:
"""Returns the password for the created user."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("password")
@property
def read_only_endpoints(self) -> Optional[str]:
"""Returns a comma separated list of read only endpoints."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("read-only-endpoints")
@property
@ -189,16 +198,25 @@ class DatabaseEvent(RelationEvent):
MongoDB only.
"""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("replset")
@property
def tls(self) -> Optional[str]:
"""Returns whether TLS is configured."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("tls")
@property
def tls_ca(self) -> Optional[str]:
"""Returns TLS CA."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("tls-ca")
@property
@ -207,11 +225,17 @@ class DatabaseEvent(RelationEvent):
MongoDB, Redis, OpenSearch and Kafka only.
"""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("uris")
@property
def username(self) -> Optional[str]:
"""Returns the created username."""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("username")
@property
@ -220,6 +244,9 @@ class DatabaseEvent(RelationEvent):
Version as informed by the database daemon.
"""
if not self.relation.app:
return None
return self.relation.data[self.relation.app].get("version")
@ -259,15 +286,15 @@ A tuple for storing the diff between two data mappings.
class DatabaseRequires(Object):
"""Requires-side of the database relation."""
on = DatabaseEvents()
on = DatabaseEvents() # pyright: ignore [reportGeneralTypeIssues]
def __init__(
self,
charm,
relation_name: str,
database_name: str,
extra_user_roles: str = None,
relations_aliases: List[str] = None,
extra_user_roles: Optional[str] = None,
relations_aliases: Optional[List[str]] = None,
):
"""Manager of database client relations."""
super().__init__(charm, relation_name)
@ -352,9 +379,11 @@ class DatabaseRequires(Object):
# Retrieve the old data from the data key in the local unit relation databag.
old_data = json.loads(event.relation.data[self.local_unit].get("data", "{}"))
# Retrieve the new data from the event relation databag.
new_data = {
key: value for key, value in event.relation.data[event.app].items() if key != "data"
}
new_data = (
{key: value for key, value in event.relation.data[event.app].items() if key != "data"}
if event.app
else {}
)
# These are the keys that were added to the databag and triggered this event.
added = new_data.keys() - old_data.keys()
@ -413,9 +442,11 @@ class DatabaseRequires(Object):
"""
data = {}
for relation in self.relations:
data[relation.id] = {
key: value for key, value in relation.data[relation.app].items() if key != "data"
}
data[relation.id] = (
{key: value for key, value in relation.data[relation.app].items() if key != "data"}
if relation.app
else {}
)
return data
def _update_relation_data(self, relation_id: int, data: dict) -> None:
@ -461,7 +492,9 @@ class DatabaseRequires(Object):
if "username" in diff.added and "password" in diff.added:
# Emit the default event (the one without an alias).
logger.info("database created at %s", datetime.now())
self.on.database_created.emit(event.relation, app=event.app, unit=event.unit)
getattr(self.on, "database_created").emit(
event.relation, app=event.app, unit=event.unit
)
# Emit the aliased event (if any).
self._emit_aliased_event(event, "database_created")
@ -475,7 +508,9 @@ class DatabaseRequires(Object):
if "endpoints" in diff.added or "endpoints" in diff.changed:
# Emit the default event (the one without an alias).
logger.info("endpoints changed on %s", datetime.now())
self.on.endpoints_changed.emit(event.relation, app=event.app, unit=event.unit)
getattr(self.on, "endpoints_changed").emit(
event.relation, app=event.app, unit=event.unit
)
# Emit the aliased event (if any).
self._emit_aliased_event(event, "endpoints_changed")
@ -489,7 +524,7 @@ class DatabaseRequires(Object):
if "read-only-endpoints" in diff.added or "read-only-endpoints" in diff.changed:
# Emit the default event (the one without an alias).
logger.info("read-only-endpoints changed on %s", datetime.now())
self.on.read_only_endpoints_changed.emit(
getattr(self.on, "read_only_endpoints_changed").emit(
event.relation, app=event.app, unit=event.unit
)

View File

@ -100,7 +100,7 @@ LIBAPI = 1
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 0
LIBPATCH = 1
logger = logging.getLogger(__name__)
@ -349,6 +349,11 @@ class IdentityServiceRequires(Object):
"""Return the public_auth_url."""
return self.get_remote_app_data('public-auth-url')
@property
def admin_role(self) -> str:
"""Return the admin_role."""
return self.get_remote_app_data('admin-role')
def register_services(self, service_endpoints: dict,
region: str) -> None:
"""Request access to the IdentityService server."""
@ -481,7 +486,8 @@ class IdentityServiceProvides(Object):
internal_auth_url: str,
admin_auth_url: str,
public_auth_url: str,
service_credentials: str):
service_credentials: str,
admin_role: str):
logging.debug("Setting identity_service connection information.")
_identity_service_rel = None
for relation in self.framework.model.relations[relation_name]:
@ -516,3 +522,4 @@ class IdentityServiceProvides(Object):
app_data["admin-auth-url"] = admin_auth_url
app_data["public-auth-url"] = public_auth_url
app_data["service-credentials"] = service_credentials
app_data["admin-role"] = admin_role

View File

@ -1,341 +0,0 @@
# Copyright 2021 Canonical Ltd.
# See LICENSE file for licensing details.
"""# KubernetesServicePatch Library.
This library is designed to enable developers to more simply patch the Kubernetes Service created
by Juju during the deployment of a sidecar charm. When sidecar charms are deployed, Juju creates a
service named after the application in the namespace (named after the Juju model). This service by
default contains a "placeholder" port, which is 65536/TCP.
When modifying the default set of resources managed by Juju, one must consider the lifecycle of the
charm. In this case, any modifications to the default service (created during deployment), will be
overwritten during a charm upgrade.
When initialised, this library binds a handler to the parent charm's `install` and `upgrade_charm`
events which applies the patch to the cluster. This should ensure that the service ports are
correct throughout the charm's life.
The constructor simply takes a reference to the parent charm, and a list of
[`lightkube`](https://github.com/gtsystem/lightkube) ServicePorts that each define a port for the
service. For information regarding the `lightkube` `ServicePort` model, please visit the
`lightkube` [docs](https://gtsystem.github.io/lightkube-models/1.23/models/core_v1/#serviceport).
Optionally, a name of the service (in case service name needs to be patched as well), labels,
selectors, and annotations can be provided as keyword arguments.
## Getting Started
To get started using the library, you just need to fetch the library using `charmcraft`. **Note
that you also need to add `lightkube` and `lightkube-models` to your charm's `requirements.txt`.**
```shell
cd some-charm
charmcraft fetch-lib charms.observability_libs.v1.kubernetes_service_patch
cat << EOF >> requirements.txt
lightkube
lightkube-models
EOF
```
Then, to initialise the library:
For `ClusterIP` services:
```python
# ...
from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
port = ServicePort(443, name=f"{self.app.name}")
self.service_patcher = KubernetesServicePatch(self, [port])
# ...
```
For `LoadBalancer`/`NodePort` services:
```python
# ...
from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
port = ServicePort(443, name=f"{self.app.name}", targetPort=443, nodePort=30666)
self.service_patcher = KubernetesServicePatch(
self, [port], "LoadBalancer"
)
# ...
```
Port protocols can also be specified. Valid protocols are `"TCP"`, `"UDP"`, and `"SCTP"`
```python
# ...
from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
tcp = ServicePort(443, name=f"{self.app.name}-tcp", protocol="TCP")
udp = ServicePort(443, name=f"{self.app.name}-udp", protocol="UDP")
sctp = ServicePort(443, name=f"{self.app.name}-sctp", protocol="SCTP")
self.service_patcher = KubernetesServicePatch(self, [tcp, udp, sctp])
# ...
```
Bound with custom events by providing `refresh_event` argument:
For example, you would like to have a configurable port in your charm and want to apply
service patch every time charm config is changed.
```python
from charms.observability_libs.v1.kubernetes_service_patch import KubernetesServicePatch
from lightkube.models.core_v1 import ServicePort
class SomeCharm(CharmBase):
def __init__(self, *args):
# ...
port = ServicePort(int(self.config["charm-config-port"]), name=f"{self.app.name}")
self.service_patcher = KubernetesServicePatch(
self,
[port],
refresh_event=self.on.config_changed
)
# ...
```
Additionally, you may wish to use mocks in your charm's unit testing to ensure that the library
does not try to make any API calls, or open any files during testing that are unlikely to be
present, and could break your tests. The easiest way to do this is during your test `setUp`:
```python
# ...
@patch("charm.KubernetesServicePatch", lambda x, y: None)
def setUp(self, *unused):
self.harness = Harness(SomeCharm)
# ...
```
"""
import logging
from types import MethodType
from typing import List, Literal, Optional, Union
from lightkube import ApiError, Client
from lightkube.core import exceptions
from lightkube.models.core_v1 import ServicePort, ServiceSpec
from lightkube.models.meta_v1 import ObjectMeta
from lightkube.resources.core_v1 import Service
from lightkube.types import PatchType
from ops.charm import CharmBase
from ops.framework import BoundEvent, Object
logger = logging.getLogger(__name__)
# The unique Charmhub library identifier, never change it
LIBID = "0042f86d0a874435adef581806cddbbb"
# Increment this major API version when introducing breaking changes
LIBAPI = 1
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 7
ServiceType = Literal["ClusterIP", "LoadBalancer"]
class KubernetesServicePatch(Object):
"""A utility for patching the Kubernetes service set up by Juju."""
def __init__(
self,
charm: CharmBase,
ports: List[ServicePort],
service_name: Optional[str] = None,
service_type: ServiceType = "ClusterIP",
additional_labels: Optional[dict] = None,
additional_selectors: Optional[dict] = None,
additional_annotations: Optional[dict] = None,
*,
refresh_event: Optional[Union[BoundEvent, List[BoundEvent]]] = None,
):
"""Constructor for KubernetesServicePatch.
Args:
charm: the charm that is instantiating the library.
ports: a list of ServicePorts
service_name: allows setting custom name to the patched service. If none given,
application name will be used.
service_type: desired type of K8s service. Default value is in line with ServiceSpec's
default value.
additional_labels: Labels to be added to the kubernetes service (by default only
"app.kubernetes.io/name" is set to the service name)
additional_selectors: Selectors to be added to the kubernetes service (by default only
"app.kubernetes.io/name" is set to the service name)
additional_annotations: Annotations to be added to the kubernetes service.
refresh_event: an optional bound event or list of bound events which
will be observed to re-apply the patch (e.g. on port change).
The `install` and `upgrade-charm` events would be observed regardless.
"""
super().__init__(charm, "kubernetes-service-patch")
self.charm = charm
self.service_name = service_name if service_name else self._app
self.service = self._service_object(
ports,
service_name,
service_type,
additional_labels,
additional_selectors,
additional_annotations,
)
# Make mypy type checking happy that self._patch is a method
assert isinstance(self._patch, MethodType)
# Ensure this patch is applied during the 'install' and 'upgrade-charm' events
self.framework.observe(charm.on.install, self._patch)
self.framework.observe(charm.on.upgrade_charm, self._patch)
self.framework.observe(charm.on.update_status, self._patch)
# apply user defined events
if refresh_event:
if not isinstance(refresh_event, list):
refresh_event = [refresh_event]
for evt in refresh_event:
self.framework.observe(evt, self._patch)
def _service_object(
self,
ports: List[ServicePort],
service_name: Optional[str] = None,
service_type: ServiceType = "ClusterIP",
additional_labels: Optional[dict] = None,
additional_selectors: Optional[dict] = None,
additional_annotations: Optional[dict] = None,
) -> Service:
"""Creates a valid Service representation.
Args:
ports: a list of ServicePorts
service_name: allows setting custom name to the patched service. If none given,
application name will be used.
service_type: desired type of K8s service. Default value is in line with ServiceSpec's
default value.
additional_labels: Labels to be added to the kubernetes service (by default only
"app.kubernetes.io/name" is set to the service name)
additional_selectors: Selectors to be added to the kubernetes service (by default only
"app.kubernetes.io/name" is set to the service name)
additional_annotations: Annotations to be added to the kubernetes service.
Returns:
Service: A valid representation of a Kubernetes Service with the correct ports.
"""
if not service_name:
service_name = self._app
labels = {"app.kubernetes.io/name": self._app}
if additional_labels:
labels.update(additional_labels)
selector = {"app.kubernetes.io/name": self._app}
if additional_selectors:
selector.update(additional_selectors)
return Service(
apiVersion="v1",
kind="Service",
metadata=ObjectMeta(
namespace=self._namespace,
name=service_name,
labels=labels,
annotations=additional_annotations, # type: ignore[arg-type]
),
spec=ServiceSpec(
selector=selector,
ports=ports,
type=service_type,
),
)
def _patch(self, _) -> None:
"""Patch the Kubernetes service created by Juju to map the correct port.
Raises:
PatchFailed: if patching fails due to lack of permissions, or otherwise.
"""
try:
client = Client()
except exceptions.ConfigError as e:
logger.warning("Error creating k8s client: %s", e)
return
try:
if self._is_patched(client):
return
if self.service_name != self._app:
self._delete_and_create_service(client)
client.patch(Service, self.service_name, self.service, patch_type=PatchType.MERGE)
except ApiError as e:
if e.status.code == 403:
logger.error("Kubernetes service patch failed: `juju trust` this application.")
else:
logger.error("Kubernetes service patch failed: %s", str(e))
else:
logger.info("Kubernetes service '%s' patched successfully", self._app)
def _delete_and_create_service(self, client: Client):
service = client.get(Service, self._app, namespace=self._namespace)
service.metadata.name = self.service_name # type: ignore[attr-defined]
service.metadata.resourceVersion = service.metadata.uid = None # type: ignore[attr-defined] # noqa: E501
client.delete(Service, self._app, namespace=self._namespace)
client.create(service)
def is_patched(self) -> bool:
"""Reports if the service patch has been applied.
Returns:
bool: A boolean indicating if the service patch has been applied.
"""
client = Client()
return self._is_patched(client)
def _is_patched(self, client: Client) -> bool:
# Get the relevant service from the cluster
try:
service = client.get(Service, name=self.service_name, namespace=self._namespace)
except ApiError as e:
if e.status.code == 404 and self.service_name != self._app:
return False
logger.error("Kubernetes service get failed: %s", str(e))
raise
# Construct a list of expected ports, should the patch be applied
expected_ports = [(p.port, p.targetPort) for p in self.service.spec.ports]
# Construct a list in the same manner, using the fetched service
fetched_ports = [
(p.port, p.targetPort) for p in service.spec.ports # type: ignore[attr-defined]
] # noqa: E501
return expected_ports == fetched_ports
@property
def _app(self) -> str:
"""Name of the current Juju application.
Returns:
str: A string containing the name of the current Juju application.
"""
return self.charm.app.name
@property
def _namespace(self) -> str:
"""The Kubernetes namespace we're running in.
Returns:
str: A string containing the name of the current Kubernetes namespace.
"""
with open("/var/run/secrets/kubernetes.io/serviceaccount/namespace", "r") as f:
return f.read().strip()

View File

@ -69,7 +69,7 @@ LIBAPI = 1
# Increment this PATCH version before using `charmcraft publish-lib` or reset
# to 0 if you are raising the major API version
LIBPATCH = 12
LIBPATCH = 15
DEFAULT_RELATION_NAME = "ingress"
RELATION_INTERFACE = "ingress"
@ -98,6 +98,7 @@ INGRESS_REQUIRES_APP_SCHEMA = {
"host": {"type": "string"},
"port": {"type": "string"},
"strip-prefix": {"type": "string"},
"redirect-https": {"type": "string"},
},
"required": ["model", "name", "host", "port"],
}
@ -113,12 +114,19 @@ INGRESS_PROVIDES_APP_SCHEMA = {
try:
from typing import TypedDict
except ImportError:
from typing_extensions import TypedDict # py35 compat
from typing_extensions import TypedDict # py35 compatibility
# Model of the data a unit implementing the requirer will need to provide.
RequirerData = TypedDict(
"RequirerData",
{"model": str, "name": str, "host": str, "port": int, "strip-prefix": bool},
{
"model": str,
"name": str,
"host": str,
"port": int,
"strip-prefix": bool,
"redirect-https": bool,
},
total=False,
)
# Provider ingress data model.
@ -148,7 +156,7 @@ class _IngressPerAppBase(Object):
"""Base class for IngressPerUnit interface classes."""
def __init__(self, charm: CharmBase, relation_name: str = DEFAULT_RELATION_NAME):
super().__init__(charm, relation_name)
super().__init__(charm, relation_name + "_V1")
self.charm: CharmBase = charm
self.relation_name = relation_name
@ -183,8 +191,8 @@ class _IngressPerAppBase(Object):
class _IPAEvent(RelationEvent):
__args__ = () # type: Tuple[str, ...]
__optional_kwargs__ = {} # type: Dict[str, Any]
__args__: Tuple[str, ...] = ()
__optional_kwargs__: Dict[str, Any] = {}
@classmethod
def __attrs__(cls):
@ -226,14 +234,15 @@ class _IPAEvent(RelationEvent):
class IngressPerAppDataProvidedEvent(_IPAEvent):
"""Event representing that ingress data has been provided for an app."""
__args__ = ("name", "model", "port", "host", "strip_prefix")
__args__ = ("name", "model", "port", "host", "strip_prefix", "redirect_https")
if typing.TYPE_CHECKING:
name = None # type: Optional[str]
model = None # type: Optional[str]
port = None # type: Optional[str]
host = None # type: Optional[str]
strip_prefix = False # type: bool
name: Optional[str] = None
model: Optional[str] = None
port: Optional[str] = None
host: Optional[str] = None
strip_prefix: bool = False
redirect_https: bool = False
class IngressPerAppDataRemovedEvent(RelationEvent):
@ -274,6 +283,7 @@ class IngressPerAppProvider(_IngressPerAppBase):
data["port"],
data["host"],
data.get("strip-prefix", False),
data.get("redirect-https", False),
)
def _handle_relation_broken(self, event):
@ -305,14 +315,15 @@ class IngressPerAppProvider(_IngressPerAppBase):
return {}
databag = relation.data[relation.app]
remote_data = {} # type: Dict[str, Union[int, str]]
for k in ("port", "host", "model", "name", "mode", "strip-prefix"):
remote_data: Dict[str, Union[int, str]] = {}
for k in ("port", "host", "model", "name", "mode", "strip-prefix", "redirect-https"):
v = databag.get(k)
if v is not None:
remote_data[k] = v
_validate_data(remote_data, INGRESS_REQUIRES_APP_SCHEMA)
remote_data["port"] = int(remote_data["port"])
remote_data["strip-prefix"] = bool(remote_data.get("strip-prefix", False))
remote_data["strip-prefix"] = bool(remote_data.get("strip-prefix", "false") == "true")
remote_data["redirect-https"] = bool(remote_data.get("redirect-https", "false") == "true")
return typing.cast(RequirerData, remote_data)
def get_data(self, relation: Relation) -> RequirerData: # type: ignore
@ -387,7 +398,7 @@ class IngressPerAppReadyEvent(_IPAEvent):
__args__ = ("url",)
if typing.TYPE_CHECKING:
url = None # type: Optional[str]
url: Optional[str] = None
class IngressPerAppRevokedEvent(RelationEvent):
@ -418,6 +429,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
host: Optional[str] = None,
port: Optional[int] = None,
strip_prefix: bool = False,
redirect_https: bool = False,
):
"""Constructor for IngressRequirer.
@ -433,6 +445,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
host: Hostname to be used by the ingress provider to address the requiring
application; if unspecified, the default Kubernetes service name will be used.
strip_prefix: configure Traefik to strip the path prefix.
redirect_https: redirect incoming requests to the HTTPS.
Request Args:
port: the port of the service
@ -441,6 +454,7 @@ class IngressPerAppRequirer(_IngressPerAppBase):
self.charm: CharmBase = charm
self.relation_name = relation_name
self._strip_prefix = strip_prefix
self._redirect_https = redirect_https
self._stored.set_default(current_url=None) # type: ignore
@ -517,6 +531,9 @@ class IngressPerAppRequirer(_IngressPerAppBase):
if self._strip_prefix:
data["strip-prefix"] = "true"
if self._redirect_https:
data["redirect-https"] = "true"
_validate_data(data, INGRESS_REQUIRES_APP_SCHEMA)
self.relation.data[self.app].update(data)

View File

@ -11,7 +11,7 @@ bases:
channel: 22.04/stable
assumes:
- k8s-api
- juju >= 3.1
- juju >= 3.2
tags:
- openstack
source: https://opendev.org/openstack/charm-heat-k8s
@ -20,8 +20,6 @@ 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
@ -29,17 +27,13 @@ resources:
heat-api-image:
type: oci-image
description: OCI image for OpenStack Heat
# docker.io/kolla/ubuntu-binary-heat-api:yoga
upstream-source: docker.io/kolla/ubuntu-binary-heat-api@sha256:ca80d57606525facb404d8b0374701c02609c2ade5cb7e28ba132e666dd85949
heat-api-cfn-image:
type: oci-image
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
# ghcr.io/openstack-snaps/heat-api:2023.1
upstream-source: ghcr.io/openstack-snaps/heat-api:2023.1
heat-engine-image:
type: oci-image
description: OCI image for OpenStack Heat Engine
upstream-source: docker.io/kolla/ubuntu-binary-heat-engine@sha256:a54491f7e09eedeaa42c046cedc478f8ba78fc455a6ba285a52a5d0f8ae1df84
# ghcr.io/openstack-snaps/heat-engine:2023.1
upstream-source: ghcr.io/openstack-snaps/heat-engine:2023.1
requires:
database:

View File

@ -23,6 +23,7 @@ import secrets
import string
from typing import (
List,
Mapping,
)
import ops_sunbeam.charm as sunbeam_charm
@ -38,8 +39,8 @@ from ops.main import (
logger = logging.getLogger(__name__)
HEAT_API_CONTAINER = "heat-api"
HEAT_API_CNF_CONTAINER = "heat-api-cfn"
HEAT_ENGINE_CONTAINER = "heat-engine"
HEAT_API_SERVICE_KEY = "api-service"
class HeatAPIPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
@ -51,44 +52,52 @@ class HeatAPIPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
: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",
}
},
}
if self.charm.service_name == "heat-api-cfn":
return {
"summary": "heat api cfn layer",
"description": "pebble configuration for heat api cfn service",
"services": {
"heat-api": {
"override": "replace",
"summary": "Heat API CFN",
"command": "heat-api-cfn",
"startup": "enabled",
"user": "heat",
"group": "heat",
}
},
}
else:
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",
}
},
}
def get_healthcheck_layer(self) -> dict:
"""Health check pebble layer.
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
:returns: pebble health check layer configuration for heat service
"""
return {
"summary": "heat api cfn layer",
"description": "pebble configuration for heat api cfn service",
"services": {
"heat-api-cfn": {
"checks": {
"online": {
"override": "replace",
"summary": "Heat API CNF",
"command": "heat-api-cfn",
"startup": "enabled",
"user": "heat",
"group": "heat",
}
},
"level": "ready",
"http": {
"url": f"{self.charm.healthcheck_http_url}/healthcheck"
},
},
}
}
@ -121,10 +130,9 @@ 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"
heat_auth_encryption_key = "auth_encryption_key"
heat_auth_encryption_key = "auth-encryption-key"
db_sync_cmds = [["heat-manage", "db_sync"]]
@ -148,14 +156,6 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
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,
@ -192,6 +192,50 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
self.set_heat_auth_encryption_key()
super().configure_charm(event)
def configure_app_leader(self, event):
"""Configure app leader.
Ensure setting service_name in peer relation application data if it
does not exist.
"""
super().configure_app_leader(event)
# Update service name in application data
if not self.peers.get_app_data(HEAT_API_SERVICE_KEY):
self.peers.set_app_data({HEAT_API_SERVICE_KEY: self.service_name})
@property
def databases(self) -> Mapping[str, str]:
"""Databases needed to support this charm.
Set database name as heat for both heat-api, heat-api-cfn.
"""
return {
"database": "heat",
}
@property
def service_name(self) -> str:
"""Update service_name to heat-api or heat-api-cfn.
service_name should be updated only once. Get service name from app data if
it exists and ignore the charm configuration parameter api-service.
If app data does not exist, return with the value from charm configuration.
"""
service_name = None
if hasattr(self, "peers"):
service_name = self.peers.get_app_data(HEAT_API_SERVICE_KEY)
if not service_name:
service_name = self.config.get("api_service")
if service_name not in ["heat-api", "heat-api-cfn"]:
logger.warning(
"Config parameter api_service should be one of heat-api, heat-api-cfn, defaulting to heat-api."
)
service_name = "heat-api"
return service_name
@property
def service_conf(self) -> str:
"""Service default configuration file."""
@ -210,31 +254,28 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
@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}",
}
]
def get_healthcheck_layer(self) -> dict:
"""Health check pebble layer.
:returns: pebble health check layer configuration for heat service
"""
return {
"checks": {
"online": {
"override": "replace",
"level": "ready",
"http": {"url": self.charm.healthcheck_http_url},
if self.service_name == "heat-api-cfn":
return [
{
"service_name": "heat-cfn",
"type": "cloudformation",
"description": "OpenStack Heat CloudFormation API",
"internal_url": f"{self.internal_url}/v1/$(tenant_id)s",
"public_url": f"{self.public_url}/v1/$(tenant_id)s",
"admin_url": f"{self.admin_url}/v1/$(tenant_id)s",
}
]
else:
return [
{
"service_name": "heat",
"type": "orchestration",
"description": "OpenStack Heat API",
"internal_url": f"{self.internal_url}/v1/$(tenant_id)s",
"public_url": f"{self.public_url}/v1/$(tenant_id)s",
"admin_url": f"{self.admin_url}/v1/$(tenant_id)s",
},
}
}
]
def default_container_configs(self):
"""Return base container configs."""
@ -249,9 +290,20 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
@property
def default_public_ingress_port(self):
"""Port for Heat AI service."""
"""Port for Heat API service."""
# Port 8000 if api service is heat-api-cfn
if self.service_name == "heat-api-cfn":
return 8000
# Default heat-api port
return 8004
@property
def wsgi_container_name(self) -> str:
"""Name of the WSGI application container."""
# Container name for both heat-api and heat-api-cfn service is heat-api
return "heat-api"
if __name__ == "__main__":
main(HeatOperatorCharm)

View File

@ -1,9 +1,14 @@
# heat-api pipeline
[pipeline:heat-api]
pipeline = healthcheck cors request_id faultwrap http_proxy_to_wsgi versionnegotiation authurl authtoken context osprofiler apiv1app
# heat-api composite
[composite:heat-api]
paste.composite_factory = heat.api:root_app_factory
/: api
/healthcheck: healthcheck
{% if ingress_public.ingress_path -%}
{{ ingress_public.ingress_path }}: api
{% endif -%}
# heat-api pipeline for standalone heat
# heat-api composite 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).
@ -11,32 +16,69 @@ pipeline = healthcheck cors request_id faultwrap http_proxy_to_wsgi versionnegot
# [paste_deploy]
# flavor = standalone
#
[pipeline:heat-api-standalone]
pipeline = healthcheck cors request_id faultwrap http_proxy_to_wsgi versionnegotiation authurl authpassword context apiv1app
[composite:heat-api-standalone]
paste.composite_factory = heat.api:root_app_factory
/: api
/healthcheck: healthcheck
{% if ingress_public.ingress_path -%}
{{ ingress_public.ingress_path }}: api
{% endif -%}
# heat-api pipeline for custom cloud backends
# heat-api composite for custom cloud backends
# i.e. in heat.conf:
# [paste_deploy]
# flavor = custombackend
#
[pipeline:heat-api-custombackend]
pipeline = healthcheck cors request_id context faultwrap versionnegotiation custombackendauth apiv1app
[composite:heat-api-custombackend]
paste.composite_factory = heat.api:root_app_factory
/: api
/healthcheck: healthcheck
{% if ingress_public.ingress_path -%}
{{ ingress_public.ingress_path }}: api
{% endif -%}
# To enable, in heat.conf:
# [paste_deploy]
# flavor = noauth
#
[pipeline:heat-api-noauth]
pipeline = healthcheck cors request_id faultwrap noauth context http_proxy_to_wsgi versionnegotiation apiv1app
[composite:heat-api-noauth]
paste.composite_factory = heat.api:root_app_factory
/: api
/healthcheck: healthcheck
{% if ingress_public.ingress_path -%}
{{ ingress_public.ingress_path }}: api
{% endif -%}
# 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
[composite:heat-api-cfn]
paste.composite_factory = heat.api:root_app_factory
/: api-cfn
/healthcheck: healthcheck
{% if ingress_public.ingress_path -%}
{{ ingress_public.ingress_path }}: api-cfn
{% endif -%}
# heat-api-cfn pipeline for standalone heat
# heat-api-cfn composite for standalone heat
# relies exclusively on authenticating with ec2 signed requests
[pipeline:heat-api-cfn-standalone]
pipeline = healthcheck cors request_id http_proxy_to_wsgi cfnversionnegotiation ec2authtoken context apicfnv1app
[composite:heat-api-cfn-standalone]
paste.composite_factory = heat.api:root_app_factory
/: api-cfn
/healthcheck: healthcheck
{% if ingress_public.ingress_path -%}
{{ ingress_public.ingress_path }}: api-cfn
{% endif -%}
[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
[app:apiv1app]
paste.app_factory = heat.common.wsgi:app_factory
@ -46,6 +88,9 @@ 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
@ -100,6 +145,3 @@ 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

@ -1,22 +0,0 @@
###############################################################################
# [ WARNING ]
# ceph configuration file maintained in aso
# local changes may be overwritten.
###############################################################################
[global]
{% if ceph.auth -%}
auth_supported = {{ ceph.auth }}
mon host = {{ ceph.mon_hosts }}
{% endif -%}
keyring = /etc/ceph/$cluster.$name.keyring
log to syslog = false
err to syslog = false
clog to syslog = false
{% if ceph.rbd_features %}
rbd default features = {{ ceph.rbd_features }}
{% endif %}
[client]
{% if ceph_config.rbd_default_data_pool -%}
rbd default data pool = {{ ceph_config.rbd_default_data_pool }}
{% endif %}

View File

@ -1,7 +1,6 @@
[DEFAULT]
debug = {{ options.debug }}
instance_user=ec2-user
instance_driver=heat.engine.nova
plugin_dirs = /usr/lib64/heat,/usr/lib/heat
environment_dir=/etc/heat/environment.d
@ -11,10 +10,10 @@ auth_encryption_key={{ peers.auth_encryption_key }}
transport_url = {{ amqp.transport_url }}
{% include "parts/section-database" %}
{% include "parts/section-identity" %}
[database]
{% include "parts/database-connection" %}
[paste_deploy]
api_paste_config=/etc/heat/api-paste.ini

View File

@ -1,3 +0,0 @@
{% if database.connection -%}
connection = {{ database.connection }}
{% endif -%}

View File

@ -1,10 +0,0 @@
{% if identity_service.internal_host -%}
www_authenticate_uri = {{ identity_service.internal_protocol }}://{{ identity_service.internal_host }}:{{ identity_service.internal_port }}
auth_url = {{ identity_service.internal_protocol }}://{{ identity_service.internal_host }}:{{ identity_service.internal_port }}
auth_type = password
project_domain_name = {{ identity_service.service_domain_name }}
user_domain_name = {{ identity_service.service_domain_name }}
project_name = {{ identity_service.service_project_name }}
username = {{ identity_service.service_user_name }}
password = {{ identity_service.service_password }}
{% endif -%}

View File

@ -1,3 +1,7 @@
[database]
{% include "parts/database-connection" %}
{% if database.connection -%}
connection = {{ database.connection }}
{% else -%}
connection = sqlite:////var/lib/cinder/cinder.db
{% endif -%}
connection_recycle_time = 200

View File

@ -1,2 +1,24 @@
[keystone_authtoken]
{% include "parts/identity-data" %}
{% if identity_service.admin_auth_url -%}
auth_url = {{ identity_service.admin_auth_url }}
interface = admin
{% elif identity_service.internal_auth_url -%}
auth_url = {{ identity_service.internal_auth_url }}
interface = internal
{% elif identity_service.internal_host -%}
auth_url = {{ identity_service.internal_protocol }}://{{ identity_service.internal_host }}:{{ identity_service.internal_port }}
interface = internal
{% endif -%}
{% if identity_service.public_auth_url -%}
www_authenticate_uri = {{ identity_service.public_auth_url }}
{% elif identity_service.internal_host -%}
www_authenticate_uri = {{ identity_service.internal_protocol }}://{{ identity_service.internal_host }}:{{ identity_service.internal_port }}
{% endif -%}
auth_type = password
project_domain_name = {{ identity_service.service_domain_name }}
user_domain_name = {{ identity_service.service_domain_name }}
project_name = {{ identity_service.service_project_name }}
username = {{ identity_service.service_user_name }}
password = {{ identity_service.service_password }}
service_token_roles = {{ identity_service.admin_role }}
service_token_roles_required = True

View File

@ -45,9 +45,18 @@ applications:
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
heat-api-image: ghcr.io/openstack-snaps/heat-api:2023.1
heat-engine-image: ghcr.io/openstack-snaps/heat-engine:2023.1
heat-cfn:
charm: ../../heat-k8s.charm
scale: 1
trust: true
options:
api_service: heat-api-cfn
resources:
heat-api-image: ghcr.io/openstack-snaps/heat-api:2023.1
heat-engine-image: ghcr.io/openstack-snaps/heat-engine:2023.1
relations:
- - traefik:ingress
@ -69,3 +78,14 @@ relations:
- - rabbitmq:amqp
- heat:amqp
- - mysql:database
- heat-cfn:database
- - keystone:identity-service
- heat-cfn:identity-service
- - traefik:ingress
- heat-cfn:ingress-internal
- - traefik-public:ingress
- heat-cfn:ingress-public
- - rabbitmq:amqp
- heat-cfn:amqp

View File

@ -4,15 +4,19 @@ smoke_bundles:
- smoke
# There is no storage provider at the moment so cannot run tests.
configure:
- zaza.charm_tests.noop.setup.basic_setup
- zaza.openstack.charm_tests.keystone.setup.add_tempest_roles
tests:
- zaza.charm_tests.noop.tests.NoopTest
- zaza.openstack.charm_tests.heat.tests.HeatTempestTestK8S
tests_options:
trust:
- smoke
ignore_hard_deploy_errors:
- smoke
tempest:
default:
smoke: True
target_deploy_status:
traefik:
workload-status: active
@ -32,4 +36,6 @@ target_deploy_status:
heat:
workload-status: active
workload-status-message-regex: '^.*$'
heat-cfn:
workload-status: active
workload-status-message-regex: '^.*$'

View File

@ -76,7 +76,7 @@ class TestHeatOperatorCharm(test_utils.CharmTestCase):
"""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)
self.assertEqual(len(self.harness.charm.seen_events), 2)
def test_all_relations(self):
"""Test all integrations for operator."""