Merge "[nova] add support for nova spice proxy" into main

This commit is contained in:
Zuul 2024-04-20 07:19:11 +00:00 committed by Gerrit Code Review
commit 28f6c50c74
13 changed files with 861 additions and 117 deletions

View File

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

View File

@ -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:

View File

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

View File

@ -0,0 +1,15 @@
Listen 6182
<VirtualHost *: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
</VirtualHost>

View File

@ -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}

View File

@ -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

View File

@ -26,6 +26,8 @@ requires:
receive-ca-cert:
interface: certificate_transfer
optional: true
nova-service:
interface: nova
provides:
cos-agent:

View File

@ -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 = {}

View File

@ -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",

View File

@ -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[@]}

View File

@ -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")

View File

@ -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 name>_ingress_path (replace - with _ in name)
value will be /<model name>-<ingress name>
"""
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

View File

@ -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