diff --git a/charms/heat-k8s/src/charm.py b/charms/heat-k8s/src/charm.py
index a725e5ad..877774f5 100755
--- a/charms/heat-k8s/src/charm.py
+++ b/charms/heat-k8s/src/charm.py
@@ -24,7 +24,6 @@ import logging
import secrets
import socket
from typing import (
- Callable,
List,
Mapping,
Optional,
@@ -36,9 +35,6 @@ 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 ops.charm import (
- RelationEvent,
-)
from ops.framework import (
StoredState,
)
@@ -60,63 +56,6 @@ HEAT_API_PORT = 8004
HEAT_API_CFN_PORT = 8000
-class TraefikRouteHandler(sunbeam_rhandlers.RelationHandler):
- """Base class to handle traefik route relations."""
-
- def __init__(
- self,
- charm: ops.charm.CharmBase,
- relation_name: str,
- callback_f: Callable,
- mandatory: bool = False,
- ) -> None:
- """Run constructor."""
- super().__init__(charm, relation_name, callback_f, mandatory)
-
- def setup_event_handler(self) -> ops.framework.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(interface.on.ready, self._on_ingress_ready)
- self.framework.observe(
- self.charm.on[self.relation_name].relation_joined,
- self._on_traefik_relation_joined,
- )
- return interface
-
- 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 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):
"""Pebble handler for Heat API container."""
@@ -311,14 +250,14 @@ class HeatOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
)
handlers.append(self.user_id_ops)
- self.traefik_route_public = TraefikRouteHandler(
+ self.traefik_route_public = sunbeam_rhandlers.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 = sunbeam_rhandlers.TraefikRouteHandler(
self,
"traefik-route-internal",
self.handle_traefik_ready,
diff --git a/charms/nova-k8s/metadata.yaml b/charms/nova-k8s/metadata.yaml
index 2352616e..a5806f46 100644
--- a/charms/nova-k8s/metadata.yaml
+++ b/charms/nova-k8s/metadata.yaml
@@ -32,6 +32,8 @@ containers:
resource: nova-scheduler-image
nova-conductor:
resource: nova-conductor-image
+ nova-spiceproxy:
+ resource: nova-spiceproxy-image
resources:
nova-api-image:
@@ -46,14 +48,18 @@ resources:
type: oci-image
description: OCI image for OpenStack Nova Conductor
upstream-source: ghcr.io/canonical/nova-consolidated:2024.1
+ nova-spiceproxy-image:
+ type: oci-image
+ description: OCI image for OpenStack Nova Spice proxy
+ upstream-source: ghcr.io/canonical/nova-consolidated:2024.1
requires:
- 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
database:
interface: mysql_client
@@ -85,7 +91,7 @@ requires:
optional: true
provides:
- cloud-controller:
+ nova-service:
interface: nova
peers:
diff --git a/charms/nova-k8s/src/charm.py b/charms/nova-k8s/src/charm.py
index 9537b819..82e3e5b5 100755
--- a/charms/nova-k8s/src/charm.py
+++ b/charms/nova-k8s/src/charm.py
@@ -19,6 +19,7 @@ This charm provide Nova services as part of an OpenStack deployment
"""
import logging
+import socket
import uuid
from typing import (
Callable,
@@ -33,6 +34,13 @@ import ops_sunbeam.config_contexts as sunbeam_ctxts
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.nova_k8s.v0.nova_service import (
+ NovaConfigRequestEvent,
+ NovaServiceProvides,
+)
+from ops.charm import (
+ CharmBase,
+)
from ops.main import (
main,
)
@@ -45,6 +53,10 @@ logger = logging.getLogger(__name__)
NOVA_WSGI_CONTAINER = "nova-api"
NOVA_SCHEDULER_CONTAINER = "nova-scheduler"
NOVA_CONDUCTOR_CONTAINER = "nova-conductor"
+NOVA_SPICEPROXY_CONTAINER = "nova-spiceproxy"
+NOVA_API_INGRESS_NAME = "nova"
+NOVA_SPICEPROXY_INGRESS_NAME = "nova-spiceproxy"
+NOVA_SPICEPROXY_INGRESS_PORT = 6182
class WSGINovaMetadataConfigContext(sunbeam_ctxts.ConfigContext):
@@ -164,6 +176,74 @@ class NovaConductorPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
]
+class NovaSpiceProxyPebbleHandler(sunbeam_chandlers.ServicePebbleHandler):
+ """Pebble handler for Nova spice proxy."""
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+ self.enable_service_check = True
+
+ def get_layer(self) -> dict:
+ """Nova Scheduler service layer.
+
+ :returns: pebble layer configuration for scheduler service
+ :rtype: dict
+ """
+ return {
+ "summary": "nova spice proxy layer",
+ "description": "pebble configuration for nova services",
+ "services": {
+ "nova-spiceproxy": {
+ "override": "replace",
+ "summary": "Nova Spice Proxy",
+ "command": "nova-spicehtml5proxy",
+ "user": "nova",
+ "group": "nova",
+ },
+ "apache forwarder": {
+ "override": "replace",
+ "summary": "apache",
+ "command": "/usr/sbin/apache2ctl -DFOREGROUND",
+ },
+ },
+ }
+
+ def default_container_configs(
+ self,
+ ) -> List[sunbeam_core.ContainerConfigFile]:
+ """Container configurations for handler."""
+ return [
+ sunbeam_core.ContainerConfigFile(
+ "/etc/nova/nova.conf",
+ "root",
+ "nova",
+ 0o640,
+ ),
+ sunbeam_core.ContainerConfigFile(
+ "/etc/apache2/sites-enabled/nova-spiceproxy-forwarding.conf",
+ self.charm.service_user,
+ self.charm.service_group,
+ 0o640,
+ ),
+ sunbeam_core.ContainerConfigFile(
+ "/usr/local/share/ca-certificates/ca-bundle.pem",
+ "root",
+ "nova",
+ 0o640,
+ ),
+ ]
+
+ @property
+ def service_ready(self) -> bool:
+ """Determine whether the service the container provides is running."""
+ if self.enable_service_check:
+ logging.debug("Service checks enabled for nova spice proxy")
+ return super().service_ready
+ else:
+ logging.debug("Service checks disabled for nova spice proxy")
+ return self.pebble_ready
+
+
class CloudComputeRequiresHandler(sunbeam_rhandlers.RelationHandler):
"""Handles the cloud-compute relation on the requires side."""
@@ -224,6 +304,40 @@ class CloudComputeRequiresHandler(sunbeam_rhandlers.RelationHandler):
return True
+class NovaServiceProvidesHandler(sunbeam_rhandlers.RelationHandler):
+ """Handler for nova service relation."""
+
+ def __init__(
+ self,
+ charm: CharmBase,
+ relation_name: str,
+ callback_f: Callable,
+ ):
+ super().__init__(charm, relation_name, callback_f)
+
+ def setup_event_handler(self):
+ """Configure event handlers for nova service relation."""
+ logger.debug("Setting up Nova service event handler")
+ svc = NovaServiceProvides(
+ self.charm,
+ self.relation_name,
+ )
+ self.framework.observe(
+ svc.on.config_request,
+ self._on_config_request,
+ )
+ return svc
+
+ def _on_config_request(self, event: NovaConfigRequestEvent) -> None:
+ """Handle Config request event."""
+ self.callback_f(event)
+
+ @property
+ def ready(self) -> bool:
+ """Report if relation is ready."""
+ return True
+
+
class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Charm the service."""
@@ -238,9 +352,31 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"cell-database",
"amqp",
"identity-service",
- "ingress-public",
+ "traefik-route-public",
}
+ def __init__(self, framework):
+ self.traefik_route_public = None
+ self.traefik_route_internal = None
+ super().__init__(framework)
+ 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.framework.EventBase
+ ) -> None:
+ logger.info("Setting peer unit data")
+ self.peers.set_unit_data({"host": socket.getfqdn()})
+
+ def _on_peer_relation_departed(
+ self, event: ops.framework.EventBase
+ ) -> None:
+ self.handle_traefik_ready(event)
+
@property
def db_sync_cmds(self) -> List[List[str]]:
"""DB sync commands for Nova operator."""
@@ -302,6 +438,50 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Default port for service ingress."""
return 8774
+ @property
+ def public_url(self) -> str:
+ """Url for accessing the public endpoint for nova 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"-{NOVA_API_INGRESS_NAME}"
+ )
+ return self.add_explicit_port(public_url)
+ else:
+ return self.add_explicit_port(
+ self.service_url(self.public_ingress_address)
+ )
+
+ @property
+ def internal_url(self) -> str:
+ """Url for accessing the internal endpoint for nova 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"-{NOVA_API_INGRESS_NAME}"
+ )
+ return self.add_explicit_port(internal_url)
+ else:
+ return self.admin_url
+
+ @property
+ def nova_spiceproxy_public_url(self) -> str | None:
+ """URL for accessing public endpoint for nova spiceproxy 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"-{NOVA_SPICEPROXY_INGRESS_NAME}"
+ )
+ return public_url
+
+ return None
+
@property
def databases(self) -> Mapping[str, str]:
"""Databases needed to support this charm.
@@ -345,6 +525,14 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
self.template_dir,
self.configure_charm,
),
+ NovaSpiceProxyPebbleHandler(
+ self,
+ NOVA_SPICEPROXY_CONTAINER,
+ "nova-spiceproxy",
+ [],
+ self.template_dir,
+ self.configure_charm,
+ ),
]
return pebble_handlers
@@ -361,6 +549,32 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
self.register_compute_nodes,
)
handlers.append(self.compute_nodes)
+
+ if self.can_add_handler("nova-service", handlers):
+ self.config_svc = NovaServiceProvidesHandler(
+ self,
+ "nova-service",
+ self.set_config_from_event,
+ )
+ handlers.append(self.config_svc)
+
+ self.traefik_route_public = sunbeam_rhandlers.TraefikRouteHandler(
+ self,
+ "traefik-route-public",
+ self.handle_traefik_ready,
+ "traefik-route-public" in self.mandatory_relations,
+ [NOVA_API_INGRESS_NAME, NOVA_SPICEPROXY_INGRESS_NAME],
+ )
+ handlers.append(self.traefik_route_public)
+ self.traefik_route_internal = sunbeam_rhandlers.TraefikRouteHandler(
+ self,
+ "traefik-route-internal",
+ self.handle_traefik_ready,
+ "traefik-route-internal" in self.mandatory_relations,
+ [NOVA_API_INGRESS_NAME, NOVA_SPICEPROXY_INGRESS_NAME],
+ )
+ handlers.append(self.traefik_route_internal)
+
return handlers
@property
@@ -399,6 +613,59 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
]
return _cconfigs
+ @property
+ def traefik_config(self) -> dict:
+ """Config to publish to traefik."""
+ model = self.model.name
+ router_cfg = {}
+ # Add routers for both nova-api and nova-spiceproxy
+ for app in NOVA_API_INGRESS_NAME, NOVA_SPICEPROXY_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"],
+ "tls": {},
+ },
+ }
+ )
+
+ # 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}:{self.default_public_ingress_port}"}
+ for host in hosts
+ ]
+ spice_lb_servers = [
+ {"url": f"http://{host}:{NOVA_SPICEPROXY_INGRESS_PORT}"}
+ for host in hosts
+ ]
+ # Add services for heat-api and heat-api-cfn
+ service_cfg = {
+ f"juju-{model}-{NOVA_API_INGRESS_NAME}-service": {
+ "loadBalancer": {"servers": api_lb_servers},
+ },
+ f"juju-{model}-{NOVA_SPICEPROXY_INGRESS_NAME}-service": {
+ "loadBalancer": {"servers": spice_lb_servers},
+ },
+ }
+
+ config = {
+ "http": {
+ "routers": router_cfg,
+ "services": service_cfg,
+ },
+ }
+ return config
+
def get_shared_metadatasecret(self):
"""Return the shared metadata secret."""
return self.leader_get(self.shared_metadata_secret_key)
@@ -453,6 +720,42 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
logger.exception("Failed to discover hosts for cell1")
raise
+ 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.framework.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()
+
def get_cell_uuid(self, cell, fatal=True):
"""Returns the cell UUID from the name.
@@ -506,6 +809,7 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"""Callback handler for nova operator configuration."""
if not self.peers.ready:
return
+
metadata_secret = self.get_shared_metadatasecret()
if metadata_secret:
logger.debug("Found metadata secret in leader DB")
@@ -513,6 +817,8 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
if self.unit.is_leader():
logger.debug("Creating metadata secret")
self.set_shared_metadatasecret()
+ self.handle_traefik_ready(event)
+ self.set_config_on_update()
else:
logger.debug("Metadata secret not ready")
return
@@ -522,6 +828,23 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
NOVA_SCHEDULER_CONTAINER
)
scheduler_handler.enable_service_check = False
+
+ # Enable apache proxy_http module for nova-spiceproxy apache forwarding
+ nova_spice_handler = self.get_named_pebble_handler(
+ NOVA_SPICEPROXY_CONTAINER
+ )
+ if nova_spice_handler.pebble_ready:
+ nova_spice_handler.execute(
+ ["a2enmod", "proxy_http"], exception_on_error=True
+ )
+ nova_spice_handler.execute(
+ ["apt-get", "update"], exception_on_error=True
+ )
+ nova_spice_handler.execute(
+ ["apt", "install", "spice-html5", "-y"],
+ exception_on_error=True,
+ )
+
super().configure_charm(event)
if scheduler_handler.pebble_ready:
logging.debug("Starting nova scheduler service, pebble ready")
@@ -534,6 +857,26 @@ class NovaOperatorCharm(sunbeam_charm.OSBaseOperatorAPICharm):
"Not starting nova scheduler service, pebble not ready"
)
+ def set_config_from_event(self, event: ops.framework.EventBase) -> None:
+ """Set config in relation data."""
+ if self.nova_spiceproxy_public_url:
+ self.config_svc.interface.set_config(
+ relation=event.relation,
+ nova_spiceproxy_url=self.nova_spiceproxy_public_url,
+ )
+ else:
+ logging.debug("Nova spiceproxy not yet set, not sending config")
+
+ def set_config_on_update(self) -> None:
+ """Set config on relation on update of local data."""
+ if self.nova_spiceproxy_public_url:
+ self.config_svc.interface.set_config(
+ relation=None,
+ nova_spiceproxy_url=self.nova_spiceproxy_public_url,
+ )
+ else:
+ logging.debug("Nova spiceproxy not yet set, not sending config")
+
if __name__ == "__main__":
main(NovaOperatorCharm)
diff --git a/charms/nova-k8s/src/templates/nova-spiceproxy-forwarding.conf.j2 b/charms/nova-k8s/src/templates/nova-spiceproxy-forwarding.conf.j2
new file mode 100644
index 00000000..8d747d71
--- /dev/null
+++ b/charms/nova-k8s/src/templates/nova-spiceproxy-forwarding.conf.j2
@@ -0,0 +1,15 @@
+Listen 6182
+
+
+ ProxyPreserveHost On
+ ProxyRequests Off
+ {% if traefik_route_public and traefik_route_public.nova_spiceproxy_ingress_path -%}
+ ProxyPass {{ traefik_route_public.nova_spiceproxy_ingress_path }} http://localhost:6082/
+ ProxyPassReverse {{ traefik_route_public.nova_spiceproxy_ingress_path }} http://localhost:6082/
+ {% endif -%}
+ ProxyPass / http://localhost:6082/
+ ProxyPassReverse / http://localhost:6082/
+ ErrorLog {{ wsgi_config.error_log }}
+ CustomLog {{ wsgi_config.custom_log }} combined
+
+
diff --git a/charms/nova-k8s/src/templates/wsgi-nova-api.conf.j2 b/charms/nova-k8s/src/templates/wsgi-nova-api.conf.j2
index 3d90ef2c..0353b500 100644
--- a/charms/nova-k8s/src/templates/wsgi-nova-api.conf.j2
+++ b/charms/nova-k8s/src/templates/wsgi-nova-api.conf.j2
@@ -4,8 +4,8 @@ Listen {{ wsgi_nova_metadata.public_port }}
WSGIDaemonProcess nova-api processes=4 threads=1 user={{ wsgi_config.user }} group={{ wsgi_config.group }} \
display-name=%{GROUP}
WSGIProcessGroup nova-api
- {% if ingress_public and ingress_public.ingress_path -%}
- WSGIScriptAlias {{ ingress_public.ingress_path }} {{ wsgi_config.wsgi_public_script }}
+ {% if traefik_route_public and traefik_route_public.nova_ingress_path -%}
+ WSGIScriptAlias {{ traefik_route_public.nova_ingress_path }} {{ wsgi_config.wsgi_public_script }}
{% endif -%}
WSGIScriptAlias / {{ wsgi_config.wsgi_public_script }}
WSGIApplicationGroup %{GLOBAL}
diff --git a/charms/nova-k8s/tests/unit/test_nova_charm.py b/charms/nova-k8s/tests/unit/test_nova_charm.py
index 85b9ea88..f2417201 100644
--- a/charms/nova-k8s/tests/unit/test_nova_charm.py
+++ b/charms/nova-k8s/tests/unit/test_nova_charm.py
@@ -18,6 +18,9 @@
import charm
import ops_sunbeam.test_utils as test_utils
+from ops.testing import (
+ Harness,
+)
class _NovaTestOperatorCharm(charm.NovaOperatorCharm):
@@ -77,11 +80,34 @@ class TestNovaOperatorCharm(test_utils.CharmTestCase):
self.addCleanup(self.harness.cleanup)
self.harness.begin()
+ def add_complete_ingress_relation(self, harness: Harness) -> None:
+ """Add complete traefik-route relations."""
+ harness.add_relation(
+ "traefik-route-public",
+ "nova",
+ app_data={"external_host": "dummy-ip", "scheme": "http"},
+ )
+ harness.add_relation(
+ "traefik-route-internal",
+ "nova",
+ app_data={"external_host": "dummy-ip", "scheme": "http"},
+ )
+
+ def add_db_relation(self, harness: Harness, name: str) -> str:
+ """Add db relation."""
+ rel_id = harness.add_relation(name, "mysql")
+ harness.add_relation_unit(rel_id, "mysql/0")
+ harness.add_relation_unit(rel_id, "mysql/0")
+ harness.update_relation_data(
+ rel_id, "mysql/0", {"ingress-address": "10.0.0.3"}
+ )
+ return rel_id
+
def test_pebble_ready_handler(self):
"""Test pebble ready handler."""
self.assertEqual(self.harness.charm.seen_events, [])
test_utils.set_all_pebbles_ready(self.harness)
- self.assertEqual(len(self.harness.charm.seen_events), 3)
+ self.assertEqual(len(self.harness.charm.seen_events), 4)
def test_all_relations(self):
"""Test all integrations for operator."""
@@ -89,12 +115,12 @@ class TestNovaOperatorCharm(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)
# but nova has some extra db relations, so add them manually here
- rel_id = add_db_relation(self.harness, "api-database")
+ rel_id = self.add_db_relation(self.harness, "api-database")
test_utils.add_db_relation_credentials(self.harness, rel_id)
- rel_id = add_db_relation(self.harness, "cell-database")
+ rel_id = self.add_db_relation(self.harness, "cell-database")
test_utils.add_db_relation_credentials(self.harness, rel_id)
setup_cmds = [
@@ -122,14 +148,3 @@ class TestNovaOperatorCharm(test_utils.CharmTestCase):
]
for f in config_files:
self.check_file("nova-api", f)
-
-
-def add_db_relation(harness, name) -> str:
- """Add db relation."""
- rel_id = harness.add_relation(name, "mysql")
- harness.add_relation_unit(rel_id, "mysql/0")
- harness.add_relation_unit(rel_id, "mysql/0")
- harness.update_relation_data(
- rel_id, "mysql/0", {"ingress-address": "10.0.0.3"}
- )
- return rel_id
diff --git a/charms/openstack-hypervisor/metadata.yaml b/charms/openstack-hypervisor/metadata.yaml
index e2d503b1..ec3e5e48 100644
--- a/charms/openstack-hypervisor/metadata.yaml
+++ b/charms/openstack-hypervisor/metadata.yaml
@@ -26,6 +26,8 @@ requires:
receive-ca-cert:
interface: certificate_transfer
optional: true
+ nova-service:
+ interface: nova
provides:
cos-agent:
diff --git a/charms/openstack-hypervisor/src/charm.py b/charms/openstack-hypervisor/src/charm.py
index 164ca1ad..4f9aceb4 100755
--- a/charms/openstack-hypervisor/src/charm.py
+++ b/charms/openstack-hypervisor/src/charm.py
@@ -46,6 +46,10 @@ from charms.ceilometer_k8s.v0.ceilometer_service import (
from charms.grafana_agent.v0.cos_agent import (
COSAgentProvider,
)
+from charms.nova_k8s.v0.nova_service import (
+ NovaConfigChangedEvent,
+ NovaServiceGoneAwayEvent,
+)
from cryptography import (
x509,
)
@@ -183,7 +187,12 @@ class HypervisorOperatorCharm(sunbeam_charm.OSBaseOperatorCharm):
METADATA_SECRET_KEY = "ovn-metadata-proxy-shared-secret"
DEFAULT_SECRET_LENGTH = 32
- mandatory_relations = {"amqp", "identity-credentials", "ovsdb-cms"}
+ mandatory_relations = {
+ "amqp",
+ "identity-credentials",
+ "ovsdb-cms",
+ "nova-service",
+ }
def __init__(self, framework: ops.framework.Framework) -> None:
"""Run constructor."""
@@ -256,6 +265,16 @@ class HypervisorOperatorCharm(sunbeam_charm.OSBaseOperatorCharm):
"ovsdb-cms" in self.mandatory_relations,
)
handlers.append(self.ovsdb_cms)
+ if self.can_add_handler("nova-service", handlers):
+ self.nova_controller = (
+ sunbeam_rhandlers.NovaServiceRequiresHandler(
+ self,
+ "nova-service",
+ self.handle_nova_controller_events,
+ "nova-service" in self.mandatory_relations,
+ )
+ )
+ handlers.append(self.nova_controller)
if self.can_add_handler("ceilometer-service", handlers):
self.ceilometer = (
sunbeam_rhandlers.CeilometerServiceRequiresHandler(
@@ -439,39 +458,60 @@ class HypervisorOperatorCharm(sunbeam_charm.OSBaseOperatorCharm):
"Data missing: {}".format(e.name)
)
# Handle optional config contexts
- try:
- if contexts.ceph_access.uuid:
- snap_data.update(
- {
- "compute.rbd-user": "nova",
- "compute.rbd-secret-uuid": contexts.ceph_access.uuid,
- "compute.rbd-key": contexts.ceph_access.key,
- }
- )
- except AttributeError:
- # If the relation has been removed it is probably less disruptive to leave the
- # rbd setting in the snap rather than unsetting them.
- logger.debug("ceph_access relation not integrated")
- try:
- if contexts.ceilometer_service.telemetry_secret:
- snap_data.update(
- {
- "telemetry.enable": self.enable_telemetry,
- "telemetry.publisher-secret": contexts.ceilometer_service.telemetry_secret,
- }
- )
- else:
- snap_data.update({"telemetry.enable": self.enable_telemetry})
- except AttributeError:
- logger.debug("ceilometer_service relation not integrated")
- snap_data.update({"telemetry.enable": self.enable_telemetry})
-
+ snap_data.update(self._handle_ceph_access(contexts))
+ snap_data.update(self._handle_ceilometer_service(contexts))
+ snap_data.update(self._handle_nova_service(contexts))
snap_data.update(self._handle_receive_ca_cert(contexts))
self.set_snap_data(snap_data)
self.ensure_services_running()
self._state.unit_bootstrapped = True
+ def _handle_ceph_access(
+ self, contexts: sunbeam_core.OPSCharmContexts
+ ) -> dict:
+ try:
+ if contexts.ceph_access.uuid:
+ return {
+ "compute.rbd-user": "nova",
+ "compute.rbd-secret-uuid": contexts.ceph_access.uuid,
+ "compute.rbd-key": contexts.ceph_access.key,
+ }
+ except AttributeError:
+ # If the relation has been removed it is probably less disruptive to leave the
+ # rbd setting in the snap rather than unsetting them.
+ logger.debug("ceph_access relation not integrated")
+
+ return {}
+
+ def _handle_ceilometer_service(
+ self, contexts: sunbeam_core.OPSCharmContexts
+ ) -> dict:
+ try:
+ if contexts.ceilometer_service.telemetry_secret:
+ return {
+ "telemetry.enable": self.enable_telemetry,
+ "telemetry.publisher-secret": contexts.ceilometer_service.telemetry_secret,
+ }
+ else:
+ return {"telemetry.enable": self.enable_telemetry}
+ except AttributeError:
+ logger.debug("ceilometer_service relation not integrated")
+ return {"telemetry.enable": self.enable_telemetry}
+
+ def _handle_nova_service(
+ self, contexts: sunbeam_core.OPSCharmContexts
+ ) -> dict:
+ try:
+ if contexts.nova_service.nova_spiceproxy_url:
+ return {
+ "compute.nova-spiceproxy-url": contexts.nova_service.nova_spiceproxy_url,
+ }
+ except AttributeError as e:
+ logger.debug(f"Nova service relation not integrated: {str(e)}")
+
+ return {}
+
def _handle_receive_ca_cert(
self, context: sunbeam_core.OPSCharmContexts
) -> dict:
@@ -493,6 +533,15 @@ class HypervisorOperatorCharm(sunbeam_charm.OSBaseOperatorCharm):
self.enable_telemetry = False
self.configure_charm(event)
+ def handle_nova_controller_events(
+ self, event: ops.framework.EventBase
+ ) -> None:
+ """Handle nova controller events."""
+ if isinstance(event, NovaConfigChangedEvent) or isinstance(
+ event, NovaServiceGoneAwayEvent
+ ):
+ self.configure_charm(event)
+
def stop_services(self, relation: Optional[Set[str]]) -> None:
"""Stop services based on relation goneaway event."""
snap_data = {}
diff --git a/charms/openstack-hypervisor/tests/unit/test_charm.py b/charms/openstack-hypervisor/tests/unit/test_charm.py
index 36b6ba38..1d1c81e6 100644
--- a/charms/openstack-hypervisor/tests/unit/test_charm.py
+++ b/charms/openstack-hypervisor/tests/unit/test_charm.py
@@ -107,8 +107,18 @@ class TestCharm(test_utils.CharmTestCase):
self.socket.getfqdn.return_value = "test.local"
self.initial_setup()
self.harness.set_leader()
+
test_utils.add_complete_amqp_relation(self.harness)
test_utils.add_complete_identity_credentials_relation(self.harness)
+ # Add nova-service relation
+ self.harness.add_relation(
+ "nova-service",
+ "nova",
+ app_data={
+ "nova-spiceproxy-url": "http://INGRESS_IP/nova-spiceproxy"
+ },
+ )
+
hypervisor_snap_mock.ensure.assert_any_call(
"latest", channel="essex/stable"
)
@@ -137,6 +147,7 @@ class TestCharm(test_utils.CharmTestCase):
"compute.rbd-user": "nova",
"compute.rbd-secret-uuid": "ddd",
"compute.rbd-key": "eee",
+ "compute.nova-spiceproxy-url": "http://INGRESS_IP/nova-spiceproxy",
"credentials.ovn-metadata-proxy-shared-secret": metadata,
"identity.admin-role": None,
"identity.auth-url": "http://10.153.2.45:80/openstack-keystone",
@@ -190,6 +201,15 @@ class TestCharm(test_utils.CharmTestCase):
app_data={"telemetry-secret": "FAKE_SECRET"},
)
+ # Add nova-service relation
+ self.harness.add_relation(
+ "nova-service",
+ "nova",
+ app_data={
+ "nova-spiceproxy-url": "http://INGRESS_IP/nova-spiceproxy"
+ },
+ )
+
self.get_local_ip_by_default_route.return_value = "10.0.0.10"
hypervisor_snap_mock = MagicMock()
hypervisor_snap_mock.present = False
@@ -230,6 +250,7 @@ class TestCharm(test_utils.CharmTestCase):
"compute.rbd-user": "nova",
"compute.rbd-secret-uuid": "ddd",
"compute.rbd-key": "eee",
+ "compute.nova-spiceproxy-url": "http://INGRESS_IP/nova-spiceproxy",
"credentials.ovn-metadata-proxy-shared-secret": metadata,
"identity.admin-role": None,
"identity.auth-url": "http://10.153.2.45:80/openstack-keystone",
diff --git a/common.sh b/common.sh
index e3e80aa5..f55e9f85 100644
--- a/common.sh
+++ b/common.sh
@@ -58,6 +58,7 @@ INTERNAL_NEUTRON_LIBS=(
INTERNAL_NOVA_LIBS=(
"keystone_k8s"
+ "nova_k8s"
"sunbeam_nova_compute_operator"
)
@@ -66,6 +67,7 @@ INTERNAL_OPENSTACK_HYPERVISOR_LIBS=(
"ovn_central_k8s"
"cinder_ceph_k8s"
"ceilometer_k8s"
+ "nova_k8s"
)
INTERNAL_OVN_CENTRAL_LIBS=(
@@ -343,7 +345,7 @@ declare -A EXTERNAL_LIBS=(
[keystone-ldap-k8s]=${NULL_ARRAY[@]}
[magnum-k8s]=${EXTERNAL_AODH_LIBS[@]}
[neutron-k8s]=${EXTERNAL_NEUTRON_LIBS[@]}
- [nova-k8s]=${EXTERNAL_AODH_LIBS[@]}
+ [nova-k8s]=${EXTERNAL_HEAT_LIBS[@]}
[octavia-k8s]=${EXTERNAL_OCTAVIA_LIBS[@]}
[openstack-exporter-k8s]=${EXTERNAL_OPENSTACK_EXPORTER_LIBS[@]}
[openstack-hypervisor]=${EXTERNAL_OPENSTACK_HYPERVISOR_LIBS[@]}
diff --git a/libs/internal/lib/charms/nova_k8s/v0/nova_service.py b/libs/internal/lib/charms/nova_k8s/v0/nova_service.py
new file mode 100644
index 00000000..d2a32d24
--- /dev/null
+++ b/libs/internal/lib/charms/nova_k8s/v0/nova_service.py
@@ -0,0 +1,210 @@
+"""NovaServiceProvides and Requires module.
+
+This library contains the Requires and Provides classes for handling
+the nova_service interface.
+
+Import `NovaServiceRequires` in your charm, with the charm object and the
+relation name:
+ - self
+ - "nova_service"
+
+Two events are also available to respond to:
+ - config_changed
+ - goneaway
+
+A basic example showing the usage of this relation follows:
+
+```
+from charms.nova_k8s.v0.nova_service import (
+ NovaServiceRequires
+)
+
+class NovaServiceClientCharm(CharmBase):
+ def __init__(self, *args):
+ super().__init__(*args)
+ # NovaService Requires
+ self.nova_service = NovaServiceRequires(
+ self, "nova_service",
+ )
+ self.framework.observe(
+ self.nova_service.on.config_changed,
+ self._on_nova_service_config_changed
+ )
+ self.framework.observe(
+ self.nova_service.on.goneaway,
+ self._on_nova_service_goneaway
+ )
+
+ def _on_nova_service_config_changed(self, event):
+ '''React to the Nova service config changed event.
+
+ This event happens when NovaService relation is added to the
+ model and relation data is changed.
+ '''
+ # Do something with the configuration provided by relation.
+ pass
+
+ def _on_nova_service_goneaway(self, event):
+ '''React to the NovaService goneaway event.
+
+ This event happens when NovaService relation is removed.
+ '''
+ # NovaService Relation has goneaway.
+ pass
+```
+"""
+
+import logging
+
+from ops.charm import (
+ CharmBase,
+ RelationBrokenEvent,
+ RelationChangedEvent,
+ RelationEvent,
+)
+from ops.framework import (
+ EventSource,
+ Object,
+ ObjectEvents,
+)
+from ops.model import (
+ Relation,
+)
+
+# The unique Charmhub library identifier, never change it
+LIBID = "050da1b56a094b52a08bb9b9ab7504f1"
+
+# 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 NovaConfigRequestEvent(RelationEvent):
+ """NovaConfigRequest Event."""
+
+ pass
+
+
+class NovaServiceProviderEvents(ObjectEvents):
+ """Events class for `on`."""
+
+ config_request = EventSource(NovaConfigRequestEvent)
+
+
+class NovaServiceProvides(Object):
+ """NovaServiceProvides class."""
+
+ on = NovaServiceProviderEvents()
+
+ 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_nova_service_relation_changed,
+ )
+
+ def _on_nova_service_relation_changed(self, event: RelationChangedEvent):
+ """Handle NovaService relation changed."""
+ logging.debug("NovaService relation changed")
+ self.on.config_request.emit(event.relation)
+
+ def set_config(
+ self, relation: Relation | None, nova_spiceproxy_url: str
+ ) -> None:
+ """Set nova configuration on the relation."""
+ if not self.charm.unit.is_leader():
+ logging.debug("Not a leader unit, skipping set config")
+ return
+
+ # If relation is not provided send config to all the related
+ # applications. This happens usually when config data is
+ # updated by provider and wants to send the data to all
+ # related applications
+ if relation is None:
+ logging.debug(
+ "Sending config to all related applications of relation"
+ f"{self.relation_name}"
+ )
+ for relation in self.framework.model.relations[self.relation_name]:
+ relation.data[self.charm.app][
+ "nova-spiceproxy-url"
+ ] = nova_spiceproxy_url
+ else:
+ logging.debug(
+ f"Sending config on relation {relation.app.name} "
+ f"{relation.name}/{relation.id}"
+ )
+ relation.data[self.charm.app][
+ "nova-spiceproxy-url"
+ ] = nova_spiceproxy_url
+
+
+class NovaConfigChangedEvent(RelationEvent):
+ """NovaConfigChanged Event."""
+
+ pass
+
+
+class NovaServiceGoneAwayEvent(RelationEvent):
+ """NovaServiceGoneAway Event."""
+
+ pass
+
+
+class NovaServiceRequirerEvents(ObjectEvents):
+ """Events class for `on`."""
+
+ config_changed = EventSource(NovaConfigChangedEvent)
+ goneaway = EventSource(NovaServiceGoneAwayEvent)
+
+
+class NovaServiceRequires(Object):
+ """NovaServiceRequires class."""
+
+ on = NovaServiceRequirerEvents()
+
+ 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_nova_service_relation_changed,
+ )
+ self.framework.observe(
+ self.charm.on[relation_name].relation_broken,
+ self._on_nova_service_relation_broken,
+ )
+
+ def _on_nova_service_relation_changed(self, event: RelationChangedEvent):
+ """Handle NovaService relation changed."""
+ logging.debug("NovaService config data changed")
+ self.on.config_changed.emit(event.relation)
+
+ def _on_nova_service_relation_broken(self, event: RelationBrokenEvent):
+ """Handle NovaService relation changed."""
+ logging.debug("NovaService on_broken")
+ self.on.goneaway.emit(event.relation)
+
+ @property
+ def _nova_service_rel(self) -> Relation | None:
+ """The nova service relation."""
+ return self.framework.model.get_relation(self.relation_name)
+
+ def get_remote_app_data(self, key: str) -> str | None:
+ """Return the value for the given key from remote app data."""
+ if self._nova_service_rel:
+ data = self._nova_service_rel.data[self._nova_service_rel.app]
+ return data.get(key)
+
+ return None
+
+ @property
+ def nova_spiceproxy_url(self) -> str | None:
+ """Return the nova_spiceproxy url."""
+ return self.get_remote_app_data("nova-spiceproxy-url")
diff --git a/ops-sunbeam/ops_sunbeam/relation_handlers.py b/ops-sunbeam/ops_sunbeam/relation_handlers.py
index 3577c4cb..8629b7d9 100644
--- a/ops-sunbeam/ops_sunbeam/relation_handlers.py
+++ b/ops-sunbeam/ops_sunbeam/relation_handlers.py
@@ -1865,3 +1865,144 @@ class CertificateTransferRequiresHandler(RelationHandler):
ca_bundle.append(chain_)
return {"ca_bundle": "\n".join(ca_bundle)}
+
+
+class TraefikRouteHandler(RelationHandler):
+ """Base class to handle traefik route relations."""
+
+ def __init__(
+ self,
+ charm: ops.charm.CharmBase,
+ relation_name: str,
+ callback_f: Callable,
+ mandatory: bool = False,
+ ingress_names: list | None = None,
+ ) -> None:
+ """Run constructor."""
+ super().__init__(charm, relation_name, callback_f, mandatory)
+ self.ingress_names = ingress_names or []
+
+ def setup_event_handler(self) -> ops.framework.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(interface.on.ready, self._on_ingress_ready)
+ self.framework.observe(
+ self.charm.on[self.relation_name].relation_joined,
+ self._on_traefik_relation_joined,
+ )
+ return interface
+
+ def _on_traefik_relation_joined(
+ self, event: ops.charm.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: ops.charm.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 the handler is ready for use."""
+ if self.charm.unit.is_leader():
+ return bool(self.interface.external_host)
+ else:
+ return self.interface.is_ready()
+
+ def context(self) -> dict:
+ """Context containing ingress data.
+
+ Returns dictionary of ingress_key: value
+ ingress_key will be _ingress_path (replace - with _ in name)
+ value will be /-
+ """
+ return {
+ f"{name.replace('-', '_')}_ingress_path": f"/{self.charm.model.name}-{name}"
+ for name in self.ingress_names
+ }
+
+
+class NovaServiceRequiresHandler(RelationHandler):
+ """Handle nova service relation on the requires side."""
+
+ def __init__(
+ self,
+ charm: ops.charm.CharmBase,
+ relation_name: str,
+ callback_f: Callable,
+ mandatory: bool = False,
+ ):
+ """Create a new nova-service handler.
+
+ Create a new NovaServiceRequiresHandler 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
+ """
+ super().__init__(charm, relation_name, callback_f, mandatory)
+
+ def setup_event_handler(self) -> None:
+ """Configure event handlers for Nova service relation."""
+ import charms.nova_k8s.v0.nova_service as nova_svc
+
+ logger.debug("Setting up Nova service event handler")
+ svc = nova_svc.NovaServiceRequires(
+ self.charm,
+ 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: ops.framework.EventBase) -> None:
+ """Handle config_changed event."""
+ logger.debug("Nova service provider config changed event received")
+ self.callback_f(event)
+
+ def _on_goneaway(self, event: ops.framework.EventBase) -> None:
+ """Handle gone_away event."""
+ logger.debug("Nova service relation is departed/broken")
+ self.callback_f(event)
+ if self.mandatory:
+ self.status.set(BlockedStatus("integration missing"))
+
+ @property
+ def ready(self) -> bool:
+ """Whether handler is ready for use."""
+ try:
+ return bool(self.interface.nova_spiceproxy_url)
+ except (AttributeError, KeyError):
+ return False
diff --git a/tests/core/smoke.yaml.j2 b/tests/core/smoke.yaml.j2
index 0488115d..e2c7ddc3 100644
--- a/tests/core/smoke.yaml.j2
+++ b/tests/core/smoke.yaml.j2
@@ -102,6 +102,7 @@ applications:
nova-api-image: ghcr.io/canonical/nova-consolidated:2024.1
nova-scheduler-image: ghcr.io/canonical/nova-consolidated:2024.1
nova-conductor-image: ghcr.io/canonical/nova-consolidated:2024.1
+ nova-spiceproxy-image: ghcr.io/canonical/nova-consolidated:2024.1
placement:
{% if placement_k8s is defined and placement_k8s is sameas true -%}
charm: ../../../placement-k8s.charm
@@ -164,8 +165,8 @@ relations:
- nova:amqp
- - keystone:identity-service
- nova:identity-service
-- - traefik:ingress
- - nova:ingress-public
+- - traefik:traefik-route
+ - nova:traefik-route-public
- - keystone:send-ca-cert
- nova:receive-ca-cert