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