From 4a473da7feff446f8d41bf9154b4e07799f0f1b6 Mon Sep 17 00:00:00 2001 From: Hemanth Nakkina Date: Fri, 3 Nov 2023 12:21:42 +0530 Subject: [PATCH] Deploy heat-api-cfn container as part of heat charm Currently the heat charm instance runs heat-api and heat-engine services or heat-api-cfn or heat-engine services. Change heat charm to deploy heat-api, heat-api-cfn, heat-engine containers. Change the ingress relation to use traefik-route interface instead of ingress interface so that the traefik configuration for heat-api and heat-api-cfn serive can be written by charm. Add heat-api-cfn pebble container handler and update service endpoints accordingly. Remove heat-config interface and corresponding handlers. Change-Id: I391f8d4ffefcebdb2423fcc1947590ca906d711a --- charms/heat-k8s/config.yaml | 13 +- charms/heat-k8s/fetch-libs.sh | 2 +- .../charms/heat_k8s/v0/heat_shared_config.py | 209 ------ .../traefik_route_k8s/v0/traefik_route.py | 359 ++++++++++ charms/heat-k8s/metadata.yaml | 17 +- charms/heat-k8s/src/charm.py | 664 ++++++++++-------- .../src/templates/api-paste-cfn.ini.j2 | 147 ++++ .../heat-k8s/src/templates/api-paste.ini.j2 | 24 +- .../src/templates/heat-api-cfn.conf.j2 | 34 + .../heat-k8s/src/templates/wsgi-heat-api.conf | 27 - .../src/templates/wsgi-template.conf.j2 | 27 - charms/heat-k8s/tests/bundles/smoke.yaml | 33 +- charms/heat-k8s/tests/tests.yaml | 3 - charms/heat-k8s/tests/unit/test_heat_charm.py | 26 +- 14 files changed, 943 insertions(+), 642 deletions(-) delete mode 100644 charms/heat-k8s/lib/charms/heat_k8s/v0/heat_shared_config.py create mode 100644 charms/heat-k8s/lib/charms/traefik_route_k8s/v0/traefik_route.py create mode 100644 charms/heat-k8s/src/templates/api-paste-cfn.ini.j2 create mode 100644 charms/heat-k8s/src/templates/heat-api-cfn.conf.j2 delete mode 100644 charms/heat-k8s/src/templates/wsgi-heat-api.conf delete mode 100644 charms/heat-k8s/src/templates/wsgi-template.conf.j2 diff --git a/charms/heat-k8s/config.yaml b/charms/heat-k8s/config.yaml index 001d217d..24b14b0e 100644 --- a/charms/heat-k8s/config.yaml +++ b/charms/heat-k8s/config.yaml @@ -4,19 +4,19 @@ options: description: Enable debug logging. type: boolean os-admin-hostname: - default: glance.juju + default: heat.juju description: | The hostname or address of the admin endpoints that should be advertised in the glance image provider. type: string os-internal-hostname: - default: glance.juju + default: heat.juju description: | The hostname or address of the internal endpoints that should be advertised in the glance image provider. type: string os-public-hostname: - default: glance.juju + default: heat.juju description: | The hostname or address of the internal endpoints that should be advertised in the glance image provider. @@ -25,10 +25,3 @@ 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 diff --git a/charms/heat-k8s/fetch-libs.sh b/charms/heat-k8s/fetch-libs.sh index 87e14f4f..600c0b69 100755 --- a/charms/heat-k8s/fetch-libs.sh +++ b/charms/heat-k8s/fetch-libs.sh @@ -5,4 +5,4 @@ charmcraft fetch-lib charms.data_platform_libs.v0.database_requires charmcraft fetch-lib charms.keystone_k8s.v0.identity_resource charmcraft fetch-lib charms.keystone_k8s.v1.identity_service charmcraft fetch-lib charms.rabbitmq_k8s.v0.rabbitmq -charmcraft fetch-lib charms.traefik_k8s.v2.ingress +charmcraft fetch-lib charms.traefik_route_k8s.v0.traefik_route diff --git a/charms/heat-k8s/lib/charms/heat_k8s/v0/heat_shared_config.py b/charms/heat-k8s/lib/charms/heat_k8s/v0/heat_shared_config.py deleted file mode 100644 index b6be649c..00000000 --- a/charms/heat-k8s/lib/charms/heat_k8s/v0/heat_shared_config.py +++ /dev/null @@ -1,209 +0,0 @@ -"""HeatSharedConfig Provides and Requires module. - -This library contains the Requires and Provides classes for handling -the heat-shared-config interface. - -Import `HeatSharedConfigRequires` in your charm, with the charm object and the -relation name: - - self - - "heat_config" - -Two events are also available to respond to: - - config_changed - - goneaway - -A basic example showing the usage of this relation follows: - -``` -from charms.heat_k8s.v0.heat_shared_config import ( - HeatSharedConfigRequires -) - -class HeatSharedConfigClientCharm(CharmBase): - def __init__(self, *args): - super().__init__(*args) - # HeatSharedConfig Requires - self.heat_config = HeatSharedConfigRequires( - self, "heat_config", - ) - self.framework.observe( - self.heat_config.on.config_changed, - self._on_heat_shared_config_changed - ) - self.framework.observe( - self.heat_config.on.goneaway, - self._on_heat_shared_config_goneaway - ) - - def _on_heat_shared_config_changed(self, event): - '''React to the Heat shared config changed event. - - This event happens when Heat shared config relation is added to the - model and relation data is changed. - ''' - # Do something with the configuration provided by relation. - pass - - def _on_heat_shared_config_goneaway(self, event): - '''React to the HeatSharedConfig goneaway event. - - This event happens when Heat shared config relation is removed. - ''' - # HeatSharedConfig Relation has goneaway. - pass -``` -""" - -import logging -from typing import ( - Optional, -) - -from ops.charm import ( - CharmBase, - RelationBrokenEvent, - RelationChangedEvent, - RelationEvent, -) -from ops.framework import ( - EventSource, - Object, - ObjectEvents, -) -from ops.model import ( - Relation, -) - -logger = logging.getLogger(__name__) - -# The unique Charmhub library identifier, never change it -LIBID = "88823d2312d34be08ba8940b3b30c3d4" - -# Increment this major API version when introducing breaking changes -LIBAPI = 0 - -# Increment this PATCH version before using `charmcraft publish-lib` or reset -# to 0 if you are raising the major API version -LIBPATCH = 1 - - -class HeatSharedConfigRequestEvent(RelationEvent): - """HeatConfigRequest Event.""" - - pass - - -class HeatSharedConfigProviderEvents(ObjectEvents): - """Events class for `on`.""" - - config_request = EventSource(HeatSharedConfigRequestEvent) - - -class HeatSharedConfigProvides(Object): - """HeatSharedConfigProvides class.""" - - on = HeatSharedConfigProviderEvents() - - def __init__(self, charm: CharmBase, relation_name: str): - super().__init__(charm, relation_name) - self.charm = charm - self.relation_name = relation_name - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_heat_shared_config_relation_changed, - ) - - def _on_heat_shared_config_relation_changed( - self, event: RelationChangedEvent - ): - """Handle HeatSharedConfig relation changed.""" - logging.debug("HeatSharedConfig relation changed") - self.on.config_request.emit(event.relation) - - def set_config( - self, relation: Relation, auth_encryption_key: str - ) -> None: - """Set heat configuration on the relation.""" - if not self.charm.unit.is_leader(): - logging.debug("Not a leader unit, skipping set config") - return - - logging.debug( - f"Sending config on relation {relation.app.name} " - f"{relation.name}/{relation.id}" - ) - relation.data[self.charm.app][ - "auth-encryption-key" - ] = auth_encryption_key - - -class HeatSharedConfigChangedEvent(RelationEvent): - """HeatSharedConfigChanged Event.""" - - pass - - -class HeatSharedConfigGoneAwayEvent(RelationEvent): - """HeatSharedConfigGoneAway Event.""" - - pass - - -class HeatSharedConfigRequirerEvents(ObjectEvents): - """Events class for `on`.""" - - config_changed = EventSource(HeatSharedConfigChangedEvent) - goneaway = EventSource(HeatSharedConfigGoneAwayEvent) - - -class HeatSharedConfigRequires(Object): - """HeatSharedConfigRequires class.""" - - on = HeatSharedConfigRequirerEvents() - - def __init__(self, charm: CharmBase, relation_name: str): - super().__init__(charm, relation_name) - self.charm = charm - self.relation_name = relation_name - self.framework.observe( - self.charm.on[relation_name].relation_changed, - self._on_heat_shared_config_relation_changed, - ) - self.framework.observe( - self.charm.on[relation_name].relation_broken, - self._on_heat_shared_config_relation_broken, - ) - - def _on_heat_shared_config_relation_changed( - self, event: RelationChangedEvent - ): - """Handle HeatSharedConfig relation changed.""" - logging.debug("HeatSharedConfig config data changed") - self.on.config_changed.emit(event.relation) - - def _on_heat_shared_config_relation_broken( - self, event: RelationBrokenEvent - ): - """Handle HeatSharedConfig relation changed.""" - logging.debug("HeatSharedConfig on_broken") - self.on.goneaway.emit(event.relation) - - @property - def _heat_shared_config_rel(self) -> Optional[Relation]: - """The heat shared config relation.""" - return self.framework.model.get_relation(self.relation_name) - - def get_remote_app_data(self, key: str) -> Optional[str]: - """Return the value for the given key from remote app data.""" - if self._heat_shared_config_rel: - data = self._heat_shared_config_rel.data[ - self._heat_shared_config_rel.app - ] - return data.get(key) - - return None - - @property - def auth_encryption_key(self) -> Optional[str]: - """Return the auth_encryption_key.""" - return self.get_remote_app_data("auth-encryption-key") diff --git a/charms/heat-k8s/lib/charms/traefik_route_k8s/v0/traefik_route.py b/charms/heat-k8s/lib/charms/traefik_route_k8s/v0/traefik_route.py new file mode 100644 index 00000000..88e78b7d --- /dev/null +++ b/charms/heat-k8s/lib/charms/traefik_route_k8s/v0/traefik_route.py @@ -0,0 +1,359 @@ +#!/usr/bin/env python3 +# Copyright 2022 Canonical Ltd. +# See LICENSE file for licensing details. + +r"""# Interface Library for traefik_route. + +This library wraps relation endpoints for traefik_route. The requirer of this +relation is the traefik-route-k8s charm, or any charm capable of providing +Traefik configuration files. The provider is the traefik-k8s charm, or another +charm willing to consume Traefik configuration files. + +## Getting Started + +To get started using the library, you just need to fetch the library using `charmcraft`. + +```shell +cd some-charm +charmcraft fetch-lib charms.traefik_route_k8s.v0.traefik_route +``` + +To use the library from the provider side (Traefik): + +```yaml +requires: + traefik_route: + interface: traefik_route + limit: 1 +``` + +```python +from charms.traefik_route_k8s.v0.traefik_route import TraefikRouteProvider + +class TraefikCharm(CharmBase): + def __init__(self, *args): + # ... + self.traefik_route = TraefikRouteProvider(self) + + self.framework.observe( + self.traefik_route.on.ready, self._handle_traefik_route_ready + ) + + def _handle_traefik_route_ready(self, event): + config: str = self.traefik_route.get_config(event.relation) # yaml + # use config to configure Traefik +``` + +To use the library from the requirer side (TraefikRoute): + +```yaml +requires: + traefik-route: + interface: traefik_route + limit: 1 + optional: false +``` + +```python +# ... +from charms.traefik_route_k8s.v0.traefik_route import TraefikRouteRequirer + +class TraefikRouteCharm(CharmBase): + def __init__(self, *args): + # ... + traefik_route = TraefikRouteRequirer( + self, self.model.relations.get("traefik-route"), + "traefik-route" + ) + if traefik_route.is_ready(): + traefik_route.submit_to_traefik( + config={'my': {'traefik': 'configuration'}} + ) + +``` +""" +import logging +from typing import Optional + +import yaml +from ops.charm import CharmBase, CharmEvents, RelationEvent +from ops.framework import EventSource, Object, StoredState +from ops.model import Relation + +# The unique Charmhub library identifier, never change it +LIBID = "fe2ac43a373949f2bf61383b9f35c83c" + +# Increment this major API version when introducing breaking changes +LIBAPI = 0 + +# Increment this PATCH version before using `charmcraft publish-lib` or reset +# to 0 if you are raising the major API version +LIBPATCH = 8 + +log = logging.getLogger(__name__) + + +class TraefikRouteException(RuntimeError): + """Base class for exceptions raised by TraefikRoute.""" + + +class UnauthorizedError(TraefikRouteException): + """Raised when the unit needs leadership to perform some action.""" + + +class TraefikRouteProviderReadyEvent(RelationEvent): + """Event emitted when Traefik is ready to provide ingress for a routed unit.""" + + +class TraefikRouteProviderDataRemovedEvent(RelationEvent): + """Event emitted when a routed ingress relation is removed.""" + + +class TraefikRouteRequirerReadyEvent(RelationEvent): + """Event emitted when a unit requesting ingress has provided all data Traefik needs.""" + + +class TraefikRouteRequirerEvents(CharmEvents): + """Container for TraefikRouteRequirer events.""" + + ready = EventSource(TraefikRouteRequirerReadyEvent) + + +class TraefikRouteProviderEvents(CharmEvents): + """Container for TraefikRouteProvider events.""" + + ready = EventSource(TraefikRouteProviderReadyEvent) # TODO rename to data_provided in v1 + data_removed = EventSource(TraefikRouteProviderDataRemovedEvent) + + +class TraefikRouteProvider(Object): + """Implementation of the provider of traefik_route. + + This will presumably be owned by a Traefik charm. + The main idea is that Traefik will observe the `ready` event and, upon + receiving it, will fetch the config from the TraefikRoute's application databag, + apply it, and update its own app databag to let Route know that the ingress + is there. + The TraefikRouteProvider provides api to do this easily. + """ + + on = TraefikRouteProviderEvents() + _stored = StoredState() + + def __init__( + self, + charm: CharmBase, + relation_name: str = "traefik-route", + external_host: str = "", + *, + scheme: str = "http", + ): + """Constructor for TraefikRouteProvider. + + Args: + charm: The charm that is instantiating the instance. + relation_name: The name of the relation relation_name to bind to + (defaults to "traefik-route"). + external_host: The external host. + scheme: The scheme. + """ + super().__init__(charm, relation_name) + self._stored.set_default(external_host=None, scheme=None) + + self._charm = charm + self._relation_name = relation_name + + if self._stored.external_host != external_host or self._stored.scheme != scheme: + # If traefik endpoint details changed, update + self.update_traefik_address(external_host=external_host, scheme=scheme) + + self.framework.observe( + self._charm.on[relation_name].relation_changed, self._on_relation_changed + ) + self.framework.observe( + self._charm.on[relation_name].relation_broken, self._on_relation_broken + ) + + @property + def external_host(self) -> str: + """Return the external host set by Traefik, if any.""" + self._update_stored() + return self._stored.external_host or "" # type: ignore + + @property + def scheme(self) -> str: + """Return the scheme set by Traefik, if any.""" + self._update_stored() + return self._stored.scheme or "" # type: ignore + + @property + def relations(self): + """The list of Relation instances associated with this endpoint.""" + return list(self._charm.model.relations[self._relation_name]) + + def _update_stored(self) -> None: + """Ensure that the stored data is up-to-date. + + This is split out into a separate method since, in the case of multi-unit deployments, + removal of a `TraefikRouteRequirer` will not cause a `RelationEvent`, but the guard on + app data ensures that only the previous leader will know what it is. Separating it + allows for re-use both when the property is called and if the relation changes, so a + leader change where the new leader checks the property will do the right thing. + """ + if not self._charm.unit.is_leader(): + return + + for relation in self._charm.model.relations[self._relation_name]: + if not relation.app: + self._stored.external_host = "" + self._stored.scheme = "" + return + external_host = relation.data[relation.app].get("external_host", "") + self._stored.external_host = external_host or self._stored.external_host + scheme = relation.data[relation.app].get("scheme", "") + self._stored.scheme = scheme or self._stored.scheme + + def _on_relation_changed(self, event: RelationEvent): + if self.is_ready(event.relation): + # todo check data is valid here? + self.update_traefik_address() + self.on.ready.emit(event.relation) + + def _on_relation_broken(self, event: RelationEvent): + self.on.data_removed.emit(event.relation) + + def update_traefik_address( + self, *, external_host: Optional[str] = None, scheme: Optional[str] = None + ): + """Ensure that requirers know the external host for Traefik.""" + if not self._charm.unit.is_leader(): + return + + for relation in self._charm.model.relations[self._relation_name]: + relation.data[self._charm.app]["external_host"] = external_host or self.external_host + relation.data[self._charm.app]["scheme"] = scheme or self.scheme + + # We first attempt to write relation data (which may raise) and only then update stored + # state. + self._stored.external_host = external_host + self._stored.scheme = scheme + + @staticmethod + def is_ready(relation: Relation) -> bool: + """Whether TraefikRoute is ready on this relation. + + Returns True when the remote app shared the config; False otherwise. + """ + assert relation.app is not None # not currently handled anyway + return "config" in relation.data[relation.app] + + @staticmethod + def get_config(relation: Relation) -> Optional[str]: + """Retrieve the config published by the remote application.""" + # TODO: validate this config + assert relation.app is not None # not currently handled anyway + return relation.data[relation.app].get("config") + + +class TraefikRouteRequirer(Object): + """Wrapper for the requirer side of traefik-route. + + The traefik_route requirer will publish to the application databag an object like: + { + 'config': + } + + NB: TraefikRouteRequirer does no validation; it assumes that the + traefik-route-k8s charm will provide valid yaml-encoded config. + The TraefikRouteRequirer provides api to store this config in the + application databag. + """ + + on = TraefikRouteRequirerEvents() + _stored = StoredState() + + def __init__(self, charm: CharmBase, relation: Relation, relation_name: str = "traefik-route"): + super(TraefikRouteRequirer, self).__init__(charm, relation_name) + self._stored.set_default(external_host=None, scheme=None) + + self._charm = charm + self._relation = relation + + self.framework.observe( + self._charm.on[relation_name].relation_changed, self._on_relation_changed + ) + self.framework.observe( + self._charm.on[relation_name].relation_broken, self._on_relation_broken + ) + + @property + def external_host(self) -> str: + """Return the external host set by Traefik, if any.""" + self._update_stored() + return self._stored.external_host or "" # type: ignore + + @property + def scheme(self) -> str: + """Return the scheme set by Traefik, if any.""" + self._update_stored() + return self._stored.scheme or "" # type: ignore + + def _update_stored(self) -> None: + """Ensure that the stored host is up-to-date. + + This is split out into a separate method since, in the case of multi-unit deployments, + removal of a `TraefikRouteRequirer` will not cause a `RelationEvent`, but the guard on + app data ensures that only the previous leader will know what it is. Separating it + allows for re-use both when the property is called and if the relation changes, so a + leader change where the new leader checks the property will do the right thing. + """ + if not self._charm.unit.is_leader(): + return + + if self._relation: + for relation in self._charm.model.relations[self._relation.name]: + if not relation.app: + self._stored.external_host = "" + self._stored.scheme = "" + return + external_host = relation.data[relation.app].get("external_host", "") + self._stored.external_host = external_host or self._stored.external_host + scheme = relation.data[relation.app].get("scheme", "") + self._stored.scheme = scheme or self._stored.scheme + + def _on_relation_changed(self, event: RelationEvent) -> None: + """Update StoredState with external_host and other information from Traefik.""" + self._update_stored() + if self._charm.unit.is_leader(): + self.on.ready.emit(event.relation) + + def _on_relation_broken(self, event: RelationEvent) -> None: + """On RelationBroken, clear the stored data if set and emit an event.""" + self._stored.external_host = "" + if self._charm.unit.is_leader(): + self.on.ready.emit(event.relation) + + def is_ready(self) -> bool: + """Is the TraefikRouteRequirer ready to submit data to Traefik?""" + if self._relation: + return True + + return False + # return self._relation is not None + + def submit_to_traefik(self, config): + """Relay an ingress configuration data structure to traefik. + + This will publish to TraefikRoute's traefik-route relation databag + the config traefik needs to route the units behind this charm. + """ + if not self._charm.unit.is_leader(): + raise UnauthorizedError() + + if not self._relation: + return + + app_databag = self._relation.data[self._charm.app] + + # Traefik thrives on yaml, feels pointless to talk json to Route + app_databag["config"] = yaml.safe_dump(config) diff --git a/charms/heat-k8s/metadata.yaml b/charms/heat-k8s/metadata.yaml index cb8a4960..c74d2867 100644 --- a/charms/heat-k8s/metadata.yaml +++ b/charms/heat-k8s/metadata.yaml @@ -20,6 +20,8 @@ issues: https://bugs.launchpad.net/charm-heat-k8s containers: heat-api: resource: heat-api-image + heat-api-cfn: + resource: heat-api-image heat-engine: resource: heat-engine-image @@ -41,24 +43,17 @@ requires: limit: 1 identity-service: interface: keystone - ingress-internal: - interface: ingress + traefik-route-internal: + interface: traefik_route optional: true limit: 1 - ingress-public: - interface: ingress + traefik-route-public: + interface: traefik_route limit: 1 amqp: interface: rabbitmq identity-ops: interface: keystone-resources - heat-config: - interface: heat-shared-config - optional: true - -provides: - heat-service: - interface: heat-shared-config peers: peers: diff --git a/charms/heat-k8s/src/charm.py b/charms/heat-k8s/src/charm.py index 89d08f22..73414df6 100755 --- a/charms/heat-k8s/src/charm.py +++ b/charms/heat-k8s/src/charm.py @@ -22,6 +22,7 @@ import hashlib import json import logging import secrets +import socket from typing import ( Callable, List, @@ -35,14 +36,7 @@ import ops_sunbeam.config_contexts as sunbeam_config_contexts import ops_sunbeam.container_handlers as sunbeam_chandlers import ops_sunbeam.core as sunbeam_core import ops_sunbeam.relation_handlers as sunbeam_rhandlers -from charms.heat_k8s.v0.heat_shared_config import ( - HeatSharedConfigChangedEvent, - HeatSharedConfigProvides, - HeatSharedConfigRequestEvent, - HeatSharedConfigRequires, -) from ops.charm import ( - CharmBase, RelationEvent, ) from ops.framework import ( @@ -53,130 +47,74 @@ from ops.main import ( ) from ops.model import ( ModelError, - Relation, - SecretNotFoundError, ) logger = logging.getLogger(__name__) -CREDENTIALS_SECRET_PREFIX = "credentials_" HEAT_API_CONTAINER = "heat-api" +HEAT_API_CFN_CONTAINER = "heat-api-cfn" HEAT_ENGINE_CONTAINER = "heat-engine" -HEAT_API_SERVICE_KEY = "api-service" -HEAT_API_SERVICE_NAME = "heat-api" -HEAT_CFN_SERVICE_NAME = "heat-api-cfn" +HEAT_API_INGRESS_NAME = "heat" +HEAT_API_CFN_INGRESS_NAME = "heat-cfn" +HEAT_API_PORT = 8004 +HEAT_API_CFN_PORT = 8000 -class HeatSharedConfigProvidesHandler(sunbeam_rhandlers.RelationHandler): - """Handler for heat shared config relation on provider side.""" +class TraefikRouteHandler(sunbeam_rhandlers.RelationHandler): + """Base class to handle traefik route relations.""" def __init__( self, - charm: CharmBase, - relation_name: str, - callback_f: Callable, - ): - """Create a new heat-shared-config handler. - - Create a new HeatSharedConfigProvidesHandler that updates heat config - auth-encryption-key on the related units. - - :param charm: the Charm class the handler is for - :type charm: ops.charm.CharmBase - :param relation_name: the relation the handler is bound to - :type relation_name: str - :param callback_f: the function to call when the nodes are connected - :type callback_f: Callable - """ - super().__init__(charm, relation_name, callback_f) - - def setup_event_handler(self): - """Configure event handlers for Heat shared config relation.""" - logger.debug("Setting up Heat shared config event handler") - svc = HeatSharedConfigProvides( - self.charm, - self.relation_name, - ) - self.framework.observe( - svc.on.config_request, - self._on_config_request, - ) - return svc - - def _on_config_request(self, event: HeatSharedConfigRequestEvent) -> None: - """Handle Config request event.""" - self.callback_f(event) - - @property - def ready(self) -> bool: - """Report if relation is ready.""" - return True - - -class HeatSharedConfigRequiresHandler(sunbeam_rhandlers.RelationHandler): - """Handle heat shared config relation on the requires side.""" - - def __init__( - self, - charm: CharmBase, + charm: ops.charm.CharmBase, relation_name: str, callback_f: Callable, mandatory: bool = False, - ): - """Create a new heat-shared-config handler. - - Create a new HeatSharedConfigRequiresHandler that handles initial - events from the relation and invokes the provided callbacks based on - the event raised. - - :param charm: the Charm class the handler is for - :type charm: ops.charm.CharmBase - :param relation_name: the relation the handler is bound to - :type relation_name: str - :param callback_f: the function to call when the nodes are connected - :type callback_f: Callable - :param mandatory: If the relation is mandatory to proceed with - configuring charm - :type mandatory: bool - """ + ) -> None: + """Run constructor.""" super().__init__(charm, relation_name, callback_f, mandatory) - def setup_event_handler(self) -> None: - """Configure event handlers for Heat shared config relation.""" - logger.debug("Setting up Heat shared config event handler") - svc = HeatSharedConfigRequires( + def setup_event_handler(self) -> ops.charm.Object: + """Configure event handlers for an Ingress relation.""" + logger.debug("Setting up ingress event handler") + from charms.traefik_route_k8s.v0.traefik_route import ( + TraefikRouteRequirer, + ) + + interface = TraefikRouteRequirer( self.charm, + self.model.get_relation(self.relation_name), self.relation_name, ) - self.framework.observe( - svc.on.config_changed, - self._on_config_changed, - ) - self.framework.observe( - svc.on.goneaway, - self._on_goneaway, - ) - return svc - def _on_config_changed(self, event: RelationEvent) -> None: - """Handle config_changed event.""" - logger.debug( - "Heat shared config provider config changed event received" + self.framework.observe(interface.on.ready, self._on_ingress_ready) + self.framework.observe( + self.charm.on[self.relation_name].relation_joined, + self._on_traefik_relation_joined, ) - self.callback_f(event) + return interface - def _on_goneaway(self, event: RelationEvent) -> None: - """Handle gone_away event.""" - logger.debug("Heat shared config relation is departed/broken") - self.callback_f(event) + def _on_traefik_relation_joined(self, event: RelationEvent) -> None: + """Handle traefik relation joined event.""" + # This is passed as None during the init method, so update the + # relation attribute in TraefikRouteRequirer + self.interface._relation = event.relation + + def _on_ingress_ready(self, event: RelationEvent) -> None: + """Handle ingress relation changed events. + + `event` is an instance of + `charms.traefik_k8s.v2.ingress.IngressPerAppReadyEvent`. + """ + if self.interface.is_ready(): + self.callback_f(event) @property def ready(self) -> bool: - """Whether handler is ready for use.""" - try: - return bool(self.interface.auth_encryption_key) - except (AttributeError, KeyError): - return False + """Whether the handler is ready for use.""" + if self.charm.unit.is_leader(): + return bool(self.interface.external_host) + else: + return self.interface.is_ready() class HeatAPIPebbleHandler(sunbeam_chandlers.ServicePebbleHandler): @@ -188,36 +126,20 @@ class HeatAPIPebbleHandler(sunbeam_chandlers.ServicePebbleHandler): :returns: pebble service layer configuration for heat api service :rtype: dict """ - if self.charm.service_name == HEAT_CFN_SERVICE_NAME: - 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", - } - }, - } + 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. @@ -230,7 +152,49 @@ class HeatAPIPebbleHandler(sunbeam_chandlers.ServicePebbleHandler): "override": "replace", "level": "ready", "http": { - "url": f"{self.charm.healthcheck_http_url}/healthcheck" + "url": f"http://localhost:{HEAT_API_PORT}/healthcheck" + }, + }, + } + } + + +class HeatCfnAPIPebbleHandler(sunbeam_chandlers.ServicePebbleHandler): + """Pebble handler for Heat CFN API container.""" + + def get_layer(self): + """Heat CFN API service. + + :returns: pebble service layer configuration for heat cfn api 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 CFN", + "command": "heat-api-cfn --config-file /etc/heat/heat-api-cfn.conf", + "startup": "enabled", + "user": "heat", + "group": "heat", + } + }, + } + + 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": f"http://localhost:{HEAT_API_CFN_PORT}/healthcheck" }, }, } @@ -285,6 +249,8 @@ class HeatConfigurationContext(sunbeam_config_contexts.ConfigContext): "stack_domain_admin_user": username, "stack_domain_admin_password": password, "auth_encryption_key": self.charm.get_heat_auth_encryption_key(), + "ingress_path": f"/{self.charm.model.name}-{HEAT_API_INGRESS_NAME}", + "cfn_ingress_path": f"/{self.charm.model.name}-{HEAT_API_CFN_INGRESS_NAME}", } @@ -302,13 +268,28 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): "database", "amqp", "identity-service", - "ingress-public", + "traefik-route-public", "identity-ops", } def __init__(self, framework): + self.traefik_route_public = None + self.traefik_route_internal = None super().__init__(framework) self._state.set_default(identity_ops_ready=False) + self.framework.observe( + self.on.peers_relation_created, self._on_peer_relation_created + ) + self.framework.observe( + self.on["peers"].relation_departed, self._on_peer_relation_departed + ) + + def _on_peer_relation_created(self, event: ops.EventBase) -> None: + logger.info("Setting peer unit data") + self.peers.set_unit_data({"host": socket.getfqdn()}) + + def _on_peer_relation_departed(self, event: ops.EventBase) -> None: + self.handle_traefik_ready(event) def hash_ops(self, ops: list) -> str: """Return the sha1 of the requested ops.""" @@ -333,26 +314,20 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): ) handlers.append(self.user_id_ops) - if self.service_name == HEAT_CFN_SERVICE_NAME: - # heat-config is not a mandatory relation. - # If instance of heat-k8s deployed with service heat-api-cfn and - # without any heat-api, heat-api-cfn workload should still come - # to active using internally generated auth-encryption-key - self.heat_config_receiver = HeatSharedConfigRequiresHandler( - self, - "heat-config", - self.handle_heat_config_events, - "heat-config" in self.mandatory_relations, - ) - handlers.append(self.heat_config_receiver) - else: - self.config_svc = HeatSharedConfigProvidesHandler( - self, - "heat-service", - self.set_config_from_event, - ) - handlers.append(self.config_svc) - + self.traefik_route_public = TraefikRouteHandler( + self, + "traefik-route-public", + self.handle_traefik_ready, + "traefik-route-public" in self.mandatory_relations, + ) + handlers.append(self.traefik_route_public) + self.traefik_route_internal = TraefikRouteHandler( + self, + "traefik-route-internal", + self.handle_traefik_ready, + "traefik-route-internal" in self.mandatory_relations, + ) + handlers.append(self.traefik_route_internal) return handlers def get_pebble_handlers( @@ -364,7 +339,15 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): self, HEAT_API_CONTAINER, "heat-api", - self.default_container_configs(), + self.heat_api_container_configs(), + self.template_dir, + self.configure_charm, + ), + HeatCfnAPIPebbleHandler( + self, + HEAT_API_CFN_CONTAINER, + "heat-api-cfn", + self.heat_api_cfn_container_configs(), self.template_dir, self.configure_charm, ), @@ -372,7 +355,7 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): self, HEAT_ENGINE_CONTAINER, "heat-engine", - self.default_container_configs(), + self.heat_api_container_configs(), self.template_dir, self.configure_charm, ), @@ -423,23 +406,96 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): else: logger.debug("Creating auth key") self.set_heat_auth_encryption_key() - if self.service_name == HEAT_API_SERVICE_NAME: - # Send Auth encryption key over heat-service relation - self.set_config_on_update() + + self.handle_traefik_ready(event) super().configure_charm(event) - def configure_app_leader(self, event): - """Configure app leader. + @property + def traefik_config(self) -> dict: + """Config to publish to traefik.""" + model = self.model.name + router_cfg = {} + # Add routers for both heat-api and heat-api-cfn + for app in HEAT_API_INGRESS_NAME, HEAT_API_CFN_INGRESS_NAME: + router_cfg.update( + { + f"juju-{model}-{app}-router": { + "rule": f"PathPrefix(`/{model}-{app}`)", + "service": f"juju-{model}-{app}-service", + "entryPoints": ["web"], + }, + f"juju-{model}-{app}-router-tls": { + "rule": f"PathPrefix(`/{model}-{app}`)", + "service": f"juju-{model}-{app}-service", + "entryPoints": ["websecure"], + }, + } + ) - Ensure setting service_name in peer relation application data if it - does not exist. - """ - super().configure_app_leader(event) + # Get host key value from all units + hosts = self.peers.get_all_unit_values( + key="host", include_local_unit=True + ) + api_lb_servers = [ + {"url": f"http://{host}:{HEAT_API_PORT}"} for host in hosts + ] + cfn_lb_servers = [ + {"url": f"http://{host}:{HEAT_API_CFN_PORT}"} for host in hosts + ] + # Add services for heat-api and heat-api-cfn + service_cfg = { + f"juju-{model}-{HEAT_API_INGRESS_NAME}-service": { + "loadBalancer": {"servers": api_lb_servers}, + }, + f"juju-{model}-{HEAT_API_CFN_INGRESS_NAME}-service": { + "loadBalancer": {"servers": cfn_lb_servers}, + }, + } - # Update service name in application data - if not self.leader_get(HEAT_API_SERVICE_KEY): - self.leader_set({HEAT_API_SERVICE_KEY: self.service_name}) + config = { + "http": { + "routers": router_cfg, + "services": service_cfg, + }, + } + return config + + def _update_service_endpoints(self): + try: + if self.id_svc.update_service_endpoints: + logger.info( + "Updating service endpoints after ingress relation changed" + ) + self.id_svc.update_service_endpoints(self.service_endpoints) + except (AttributeError, KeyError): + pass + + def handle_traefik_ready(self, event: ops.EventBase): + """Handle Traefik route ready callback.""" + if not self.unit.is_leader(): + logger.debug( + "Not a leader unit, not updating traefik route config" + ) + return + + if self.traefik_route_public: + logger.debug("Sending traefik config for public interface") + self.traefik_route_public.interface.submit_to_traefik( + config=self.traefik_config + ) + + if self.traefik_route_public.ready: + self._update_service_endpoints() + + if self.traefik_route_internal: + logger.debug("Sending traefik config for internal interface") + self.traefik_route_internal.interface.submit_to_traefik( + config=self.traefik_config + ) + + if self.traefik_route_internal.ready: + self._update_service_endpoints() @property def databases(self) -> Mapping[str, str]: @@ -453,28 +509,8 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): @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.leader_get(HEAT_API_SERVICE_KEY) - - if not service_name: - service_name = self.config.get("api_service") - if service_name not in [ - HEAT_API_SERVICE_NAME, - HEAT_CFN_SERVICE_NAME, - ]: - logger.warning( - "Config parameter api_service should be one of heat-api, heat-api-cfn, defaulting to heat-api." - ) - service_name = HEAT_API_SERVICE_NAME - - return service_name + """Service name.""" + return "heat" @property def service_conf(self) -> str: @@ -494,44 +530,118 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): @property def service_endpoints(self): """Return heat service endpoints.""" - if self.service_name == HEAT_CFN_SERVICE_NAME: - 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", - } - ] + return [ + { + "service_name": HEAT_API_CFN_INGRESS_NAME, + "type": "cloudformation", + "description": "OpenStack Heat CloudFormation API", + "internal_url": f"{self.heat_cfn_internal_url}/v1/$(tenant_id)s", + "public_url": f"{self.heat_cfn_public_url}/v1/$(tenant_id)s", + "admin_url": f"{self.heat_cfn_admin_url}/v1/$(tenant_id)s", + }, + { + "service_name": HEAT_API_INGRESS_NAME, + "type": "orchestration", + "description": "OpenStack Heat API", + "internal_url": f"{self.heat_internal_url}/v1/$(tenant_id)s", + "public_url": f"{self.heat_public_url}/v1/$(tenant_id)s", + "admin_url": f"{self.heat_admin_url}/v1/$(tenant_id)s", + }, + ] + + @property + def heat_public_url(self) -> str: + """Url for accessing the public endpoint for heat service.""" + if self.traefik_route_public and self.traefik_route_public.ready: + scheme = self.traefik_route_public.interface.scheme + external_host = self.traefik_route_public.interface.external_host + public_url = ( + f"{scheme}://{external_host}/{self.model.name}" + f"-{HEAT_API_INGRESS_NAME}" + ) + return self.add_explicit_port(public_url) 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", - }, - ] + return self.add_explicit_port( + self.service_url(self.public_ingress_address) + ) + + @property + def heat_cfn_public_url(self) -> str: + """Url for accessing the public endpoint for heat cfn service.""" + if ( + self.traefik_route_public + and self.traefik_route_public.interface.is_ready() + ): + scheme = self.traefik_route_public.interface.scheme + external_host = self.traefik_route_public.interface.external_host + public_url = ( + f"{scheme}://{external_host}/{self.model.name}" + f"-{HEAT_API_CFN_INGRESS_NAME}" + ) + return self.add_explicit_port(public_url) + else: + return self.add_explicit_port( + self.service_url(self.public_ingress_address) + ) + + @property + def heat_internal_url(self) -> str: + """Url for accessing the internal endpoint for heat service.""" + if self.traefik_route_internal and self.traefik_route_internal.ready: + scheme = self.traefik_route_internal.interface.scheme + external_host = self.traefik_route_internal.interface.external_host + internal_url = ( + f"{scheme}://{external_host}/{self.model.name}" + f"-{HEAT_API_INGRESS_NAME}" + ) + return self.add_explicit_port(internal_url) + else: + return self.heat_admin_url + + @property + def heat_cfn_internal_url(self) -> str: + """Url for accessing the internal endpoint for heat cfn service.""" + if ( + self.traefik_route_internal + and self.traefik_route_internal.interface.is_ready() + ): + scheme = self.traefik_route_internal.interface.scheme + external_host = self.traefik_route_internal.interface.external_host + internal_url = ( + f"{scheme}://{external_host}/{self.model.name}" + f"-{HEAT_API_CFN_INGRESS_NAME}" + ) + return self.add_explicit_port(internal_url) + else: + return self.heat_cfn_admin_url + + @property + def heat_admin_url(self) -> str: + """Url for accessing the admin endpoint for heat service.""" + hostname = self.model.get_binding( + "identity-service" + ).network.ingress_address + url = f"http://{hostname}:{HEAT_API_PORT}" + return self.add_explicit_port(url) + + @property + def heat_cfn_admin_url(self) -> str: + """Url for accessing the admin endpoint for heat service.""" + hostname = self.model.get_binding( + "identity-service" + ).network.ingress_address + url = f"http://{hostname}:{HEAT_API_CFN_PORT}" + return self.add_explicit_port(url) @property def default_public_ingress_port(self): """Port for Heat API service.""" - # Port 8000 if api service is heat-api-cfn - if self.service_name == HEAT_CFN_SERVICE_NAME: - return 8000 - - # Default heat-api port - return 8004 + return HEAT_API_PORT @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" + return HEAT_API_CONTAINER @property def stack_domain_name(self) -> str: @@ -541,10 +651,7 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): @property def stack_domain_admin_user(self) -> str: """User to manage users and projects in stack_domain_name.""" - if self.service_name == HEAT_CFN_SERVICE_NAME: - return "heat_domain_admin_cfn" - else: - return "heat_domain_admin" + return "heat_domain_admin" @property def stack_user_role(self) -> str: @@ -558,14 +665,37 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): _cadapters.extend([HeatConfigurationContext(self, "heat")]) return _cadapters - def default_container_configs(self): + def heat_api_container_configs(self): """Return base container configs.""" return [ sunbeam_core.ContainerConfigFile( - "/etc/heat/heat.conf", "root", "heat" + "/etc/heat/heat.conf", + self.service_user, + self.service_group, + 0o640, ), sunbeam_core.ContainerConfigFile( - "/etc/heat/api-paste.ini", "root", "heat" + "/etc/heat/api-paste.ini", + self.service_user, + self.service_group, + 0o640, + ), + ] + + def heat_api_cfn_container_configs(self): + """Return base container configs.""" + return [ + sunbeam_core.ContainerConfigFile( + "/etc/heat/heat-api-cfn.conf", + self.service_user, + self.service_group, + 0o640, + ), + sunbeam_core.ContainerConfigFile( + "/etc/heat/api-paste-cfn.ini", + self.service_user, + self.service_group, + 0o640, ), ] @@ -592,80 +722,6 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm): else: logger.warning("Heat stack user role creation failed.") - def _set_config(self, key: str, relation: Relation) -> None: - """Set config key over the relation.""" - logger.debug( - f"Setting config on relation {relation.app.name} {relation.name}/{relation.id}" - ) - try: - secret = self.model.get_secret(id=key) - logger.debug( - f"Granting access to secret {key} for relation " - f"{relation.app.name} {relation.name}/{relation.id}" - ) - secret.grant(relation) - self.config_svc.interface.set_config( - relation=relation, - auth_encryption_key=key, - ) - except (ModelError, SecretNotFoundError) as e: - logger.debug( - f"Error during granting access to secret {key} for " - f"relation {relation.app.name} {relation.name}/{relation.id}: " - f"{str(e)}" - ) - - def set_config_from_event(self, event: RelationEvent) -> None: - """Set config in relation data.""" - if not self.unit.is_leader(): - logger.debug("Not a leader unit, skipping set config") - return - - key = self.get_heat_auth_encryption_key_secret() - if not key: - logger.debug("Auth encryption key not yet set, not sending config") - return - - self._set_config(key, event.relation) - - def set_config_on_update(self) -> None: - """Set config on relation on update of local data.""" - logger.debug( - "Update config on all connected heat-shared-config relations" - ) - key = self.get_heat_auth_encryption_key_secret() - if not key: - logger.info("Auth encryption key not yet set, not sending config") - return - - # Send config on all joined heat-service relations - for relation in self.framework.model.relations["heat-service"]: - self._set_config(key, relation) - - def handle_heat_config_events(self, event: RelationEvent) -> None: - """Handle heat config events. - - This function is called only for heat-k8s instances with api_service - heat-api-cfn. Receives auth_encryption_key update from heat-api - service via interface heat-service. - Update the peer appdata and configure charm for the leader unit. - For non-leader units, peer changed event should get triggered which - calls configure_charm. - """ - logger.debug(f"Received event {event}") - if isinstance(event, HeatSharedConfigChangedEvent): - key = self.heat_config_receiver.interface.auth_encryption_key - # Update appdata with auth-encryption-key from heat-api - if self.unit.is_leader(): - logger.debug( - "Update Auth encryption key in appdata received from " - "heat-service relation event" - ) - self.leader_set({self.heat_auth_encryption_key: key}) - self.configure_charm(event) - else: - logger.debug("Not a leader unit, nothing to do") - if __name__ == "__main__": main(HeatOperatorCharm) diff --git a/charms/heat-k8s/src/templates/api-paste-cfn.ini.j2 b/charms/heat-k8s/src/templates/api-paste-cfn.ini.j2 new file mode 100644 index 00000000..0b672408 --- /dev/null +++ b/charms/heat-k8s/src/templates/api-paste-cfn.ini.j2 @@ -0,0 +1,147 @@ + +# heat-api composite +[composite:heat-api] +paste.composite_factory = heat.api:root_app_factory +/: api +/healthcheck: healthcheck +{% if heat.cfn_ingress_path -%} +{{ heat.cfn_ingress_path }}: api +{% endif -%} + +# 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). +# To enable, in heat.conf: +# [paste_deploy] +# flavor = standalone +# +[composite:heat-api-standalone] +paste.composite_factory = heat.api:root_app_factory +/: api +/healthcheck: healthcheck +{% if heat.cfn_ingress_path -%} +{{ heat.cfn_ingress_path }}: api +{% endif -%} + +# heat-api composite 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 +{% if heat.cfn_ingress_path -%} +{{ heat.cfn_ingress_path }}: api +{% endif -%} + +# To enable, in heat.conf: +# [paste_deploy] +# flavor = noauth +# +[composite:heat-api-noauth] +paste.composite_factory = heat.api:root_app_factory +/: api +/healthcheck: healthcheck +{% if heat.cfn_ingress_path -%} +{{ heat.cfn_ingress_path }}: api +{% endif -%} + +# heat-api-cfn composite +[composite:heat-api-cfn] +paste.composite_factory = heat.api:root_app_factory +/: api-cfn +/healthcheck: healthcheck +{% if heat.cfn_ingress_path -%} +{{ heat.cfn_ingress_path }}: api-cfn +{% endif -%} + +# heat-api-cfn composite 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 +{% if heat.cfn_ingress_path -%} +{{ heat.cfn_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 +heat.app_factory = heat.api.openstack.v1:API + +[app:apicfnv1app] +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 + +[filter:cors] +paste.filter_factory = oslo_middleware.cors:filter_factory +oslo_config_project = heat + +[filter:faultwrap] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = heat.api.openstack:faultwrap_filter + +[filter:cfnversionnegotiation] +paste.filter_factory = heat.common.wsgi:filter_factory +heat.filter_factory = heat.api.cfn:version_negotiation_filter + +[filter:cwversionnegotiation] +paste.filter_factory = heat.common.wsgi:filter_factory + +[filter:context] +paste.filter_factory = heat.common.context:ContextMiddleware_filter_factory + +[filter:ec2authtoken] +paste.filter_factory = heat.api.aws.ec2token:EC2Token_filter_factory + +[filter:http_proxy_to_wsgi] +paste.filter_factory = oslo_middleware:HTTPProxyToWSGI.factory + +# Middleware to set auth_url header appropriately +[filter:authurl] +paste.filter_factory = heat.common.auth_url:filter_factory + +# Auth middleware that validates token against keystone +[filter:authtoken] +paste.filter_factory = keystonemiddleware.auth_token:filter_factory + +# Auth middleware that validates username/password against keystone +[filter:authpassword] +paste.filter_factory = heat.common.auth_password:filter_factory + +# Auth middleware that validates against custom backend +[filter:custombackendauth] +paste.filter_factory = heat.common.custom_backend_auth:filter_factory + +# Auth middleware that accepts any auth +[filter:noauth] +paste.filter_factory = heat.common.noauth:filter_factory + +# Middleware to set x-openstack-request-id in http response header +[filter:request_id] +paste.filter_factory = oslo_middleware.request_id:RequestId.factory + +[filter:osprofiler] +paste.filter_factory = osprofiler.web:WsgiMiddleware.factory diff --git a/charms/heat-k8s/src/templates/api-paste.ini.j2 b/charms/heat-k8s/src/templates/api-paste.ini.j2 index 9b6e170b..08f43b4a 100644 --- a/charms/heat-k8s/src/templates/api-paste.ini.j2 +++ b/charms/heat-k8s/src/templates/api-paste.ini.j2 @@ -4,8 +4,8 @@ paste.composite_factory = heat.api:root_app_factory /: api /healthcheck: healthcheck -{% if ingress_public.ingress_path -%} -{{ ingress_public.ingress_path }}: api +{% if heat.ingress_path -%} +{{ heat.ingress_path }}: api {% endif -%} # heat-api composite for standalone heat @@ -20,8 +20,8 @@ paste.composite_factory = heat.api:root_app_factory paste.composite_factory = heat.api:root_app_factory /: api /healthcheck: healthcheck -{% if ingress_public.ingress_path -%} -{{ ingress_public.ingress_path }}: api +{% if heat.ingress_path -%} +{{ heat.ingress_path }}: api {% endif -%} # heat-api composite for custom cloud backends @@ -33,8 +33,8 @@ paste.composite_factory = heat.api:root_app_factory paste.composite_factory = heat.api:root_app_factory /: api /healthcheck: healthcheck -{% if ingress_public.ingress_path -%} -{{ ingress_public.ingress_path }}: api +{% if heat.ingress_path -%} +{{ heat.ingress_path }}: api {% endif -%} # To enable, in heat.conf: @@ -45,8 +45,8 @@ paste.composite_factory = heat.api:root_app_factory paste.composite_factory = heat.api:root_app_factory /: api /healthcheck: healthcheck -{% if ingress_public.ingress_path -%} -{{ ingress_public.ingress_path }}: api +{% if heat.ingress_path -%} +{{ heat.ingress_path }}: api {% endif -%} # heat-api-cfn composite @@ -54,8 +54,8 @@ paste.composite_factory = heat.api:root_app_factory paste.composite_factory = heat.api:root_app_factory /: api-cfn /healthcheck: healthcheck -{% if ingress_public.ingress_path -%} -{{ ingress_public.ingress_path }}: api-cfn +{% if heat.ingress_path -%} +{{ heat.ingress_path }}: api-cfn {% endif -%} # heat-api-cfn composite for standalone heat @@ -64,8 +64,8 @@ paste.composite_factory = heat.api:root_app_factory paste.composite_factory = heat.api:root_app_factory /: api-cfn /healthcheck: healthcheck -{% if ingress_public.ingress_path -%} -{{ ingress_public.ingress_path }}: api-cfn +{% if heat.ingress_path -%} +{{ heat.ingress_path }}: api-cfn {% endif -%} [composite:api] diff --git a/charms/heat-k8s/src/templates/heat-api-cfn.conf.j2 b/charms/heat-k8s/src/templates/heat-api-cfn.conf.j2 new file mode 100644 index 00000000..d961f9c0 --- /dev/null +++ b/charms/heat-k8s/src/templates/heat-api-cfn.conf.j2 @@ -0,0 +1,34 @@ +[DEFAULT] +debug = {{ options.debug }} + +instance_driver=heat.engine.nova +plugin_dirs = /usr/lib64/heat,/usr/lib/heat +environment_dir=/etc/heat/environment.d +host=heat +auth_encryption_key={{ heat.auth_encryption_key }} +stack_domain_admin={{ heat.stack_domain_admin_user }} +stack_domain_admin_password={{ heat.stack_domain_admin_password }} +stack_user_domain_name={{ heat.stack_domain_name }} + +transport_url = {{ amqp.transport_url }} + +num_engine_workers = 4 + +{% include "parts/section-database" %} + +{% include "parts/section-identity" %} + + +[paste_deploy] +api_paste_config=/etc/heat/api-paste-cfn.ini + +[heat_api] +bind_port = 8004 +workers = 4 + +[heat_api_cfn] +bind_port = 8000 +workers = 4 + +{% include "parts/section-oslo-messaging-rabbit" %} + diff --git a/charms/heat-k8s/src/templates/wsgi-heat-api.conf b/charms/heat-k8s/src/templates/wsgi-heat-api.conf deleted file mode 100644 index b34c076e..00000000 --- a/charms/heat-k8s/src/templates/wsgi-heat-api.conf +++ /dev/null @@ -1,27 +0,0 @@ -Listen {{ wsgi_config.public_port }} - - WSGIDaemonProcess {{ wsgi_config.group }} processes=3 threads=1 user={{ wsgi_config.user }} group={{ wsgi_config.group }} \ - display-name=%{GROUP} - WSGIProcessGroup {{ wsgi_config.group }} - {% if ingress_internal.ingress_path -%} - WSGIScriptAlias {{ ingress_internal.ingress_path }} {{ wsgi_config.wsgi_public_script }} - {% endif -%} - WSGIScriptAlias / {{ wsgi_config.wsgi_public_script }} - WSGIApplicationGroup %{GLOBAL} - WSGIPassAuthorization On - = 2.4> - ErrorLogFormat "%{cu}t %M" - - ErrorLog {{ wsgi_config.error_log }} - CustomLog {{ wsgi_config.custom_log }} combined - - - = 2.4> - Require all granted - - - Order allow,deny - Allow from all - - - diff --git a/charms/heat-k8s/src/templates/wsgi-template.conf.j2 b/charms/heat-k8s/src/templates/wsgi-template.conf.j2 deleted file mode 100644 index b34c076e..00000000 --- a/charms/heat-k8s/src/templates/wsgi-template.conf.j2 +++ /dev/null @@ -1,27 +0,0 @@ -Listen {{ wsgi_config.public_port }} - - WSGIDaemonProcess {{ wsgi_config.group }} processes=3 threads=1 user={{ wsgi_config.user }} group={{ wsgi_config.group }} \ - display-name=%{GROUP} - WSGIProcessGroup {{ wsgi_config.group }} - {% if ingress_internal.ingress_path -%} - WSGIScriptAlias {{ ingress_internal.ingress_path }} {{ wsgi_config.wsgi_public_script }} - {% endif -%} - WSGIScriptAlias / {{ wsgi_config.wsgi_public_script }} - WSGIApplicationGroup %{GLOBAL} - WSGIPassAuthorization On - = 2.4> - ErrorLogFormat "%{cu}t %M" - - ErrorLog {{ wsgi_config.error_log }} - CustomLog {{ wsgi_config.custom_log }} combined - - - = 2.4> - Require all granted - - - Order allow,deny - Allow from all - - - diff --git a/charms/heat-k8s/tests/bundles/smoke.yaml b/charms/heat-k8s/tests/bundles/smoke.yaml index bb5e16ae..b44ce6c3 100644 --- a/charms/heat-k8s/tests/bundles/smoke.yaml +++ b/charms/heat-k8s/tests/bundles/smoke.yaml @@ -50,16 +50,6 @@ applications: heat-api-image: ghcr.io/canonical/heat-consolidated:2023.2 heat-engine-image: ghcr.io/canonical/heat-consolidated:2023.2 - heat-cfn: - charm: ../../heat-k8s.charm - scale: 1 - trust: true - options: - api_service: heat-api-cfn - resources: - heat-api-image: ghcr.io/canonical/heat-consolidated:2023.2 - heat-engine-image: ghcr.io/canonical/heat-consolidated:2023.2 - relations: - - traefik:ingress - keystone:ingress-internal @@ -75,24 +65,9 @@ relations: - heat:identity-service - - keystone:identity-ops - heat:identity-ops -- - traefik:ingress - - heat:ingress-internal -- - traefik-public:ingress - - heat:ingress-public +- - traefik:traefik-route + - heat:traefik-route-internal +- - traefik-public:traefik-route + - heat:traefik-route-public - - rabbitmq:amqp - heat:amqp - -- - mysql:database - - heat-cfn:database -- - keystone:identity-service - - heat-cfn:identity-service -- - keystone:identity-ops - - heat-cfn:identity-ops -- - traefik:ingress - - heat-cfn:ingress-internal -- - traefik-public:ingress - - heat-cfn:ingress-public -- - rabbitmq:amqp - - heat-cfn:amqp -- - heat:heat-service - - heat-cfn:heat-config diff --git a/charms/heat-k8s/tests/tests.yaml b/charms/heat-k8s/tests/tests.yaml index c8c062fb..35500daf 100644 --- a/charms/heat-k8s/tests/tests.yaml +++ b/charms/heat-k8s/tests/tests.yaml @@ -36,6 +36,3 @@ target_deploy_status: heat: workload-status: active workload-status-message-regex: '^.*$' - heat-cfn: - workload-status: active - workload-status-message-regex: '^.*$' diff --git a/charms/heat-k8s/tests/unit/test_heat_charm.py b/charms/heat-k8s/tests/unit/test_heat_charm.py index 99b3e4e0..05652c02 100644 --- a/charms/heat-k8s/tests/unit/test_heat_charm.py +++ b/charms/heat-k8s/tests/unit/test_heat_charm.py @@ -104,14 +104,17 @@ class TestHeatOperatorCharm(test_utils.CharmTestCase): ) return rel_id - def add_complete_heat_shared_config_relation( - self, harness: Harness - ) -> None: - """Add complete Heat shared config relation.""" + def add_complete_ingress_relation(self, harness: Harness) -> None: + """Add complete traefik-route relations.""" harness.add_relation( - "heat-config", + "traefik-route-public", "heat", - app_data={"auth-encryption-key": "fake-secret"}, + app_data={"external_host": "dummy-ip", "scheme": "http"}, + ) + harness.add_relation( + "traefik-route-internal", + "heat", + app_data={"external_host": "dummy-ip", "scheme": "http"}, ) def test_pebble_ready_handler(self): @@ -129,7 +132,7 @@ class TestHeatOperatorCharm(test_utils.CharmTestCase): self.assertEqual(self.harness.charm.seen_events, []) test_utils.set_all_pebbles_ready(self.harness) - self.assertEqual(len(self.harness.charm.seen_events), 2) + self.assertEqual(len(self.harness.charm.seen_events), 3) def test_all_relations(self): """Test all integrations for operator.""" @@ -148,9 +151,8 @@ class TestHeatOperatorCharm(test_utils.CharmTestCase): 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) + self.add_complete_ingress_relation(self.harness) self.add_complete_identity_resource_relation(self.harness) - self.add_complete_heat_shared_config_relation(self.harness) setup_cmds = [["heat-manage", "db_sync"]] for cmd in setup_cmds: @@ -158,3 +160,9 @@ class TestHeatOperatorCharm(test_utils.CharmTestCase): config_files = ["/etc/heat/heat.conf", "/etc/heat/api-paste.ini"] for f in config_files: self.check_file("heat-api", f) + config_files = [ + "/etc/heat/heat-api-cfn.conf", + "/etc/heat/api-paste-cfn.ini", + ] + for f in config_files: + self.check_file("heat-api-cfn", f)