Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 39 additions & 13 deletions openstack_hypervisor/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@
socket_path,
)
from openstack_hypervisor.log import setup_logging
from openstack_hypervisor.ovn_env import OVNEnvError, parse_ovn_env
from openstack_hypervisor.ovs import (
OVSCli,
OVSCommandError,
Expand Down Expand Up @@ -342,7 +343,6 @@ def _get_local_ip_by_default_route() -> str:
"network.ovs-lcore-mask": UNSET,
"network.ovs-dpdk-ports": UNSET,
"network.dpdk-driver": "vfio-pci",
"network.ovn-sb-connection": UNSET,
"network.ovn-cert": UNSET,
"network.ovn-key": UNSET,
"network.ovn-cacert": UNSET,
Expand Down Expand Up @@ -386,7 +386,14 @@ def _get_local_ip_by_default_route() -> str:
"credentials.ovn_metadata_proxy_shared_secret",
"network.nova_metadata_proxy_url",
],
"neutron-ovn-metadata-agent": ["credentials", "network", "node", "network.ovn_key"],
"neutron-ovn-agent": [
"credentials",
"network",
"node",
"network.ovn_key",
"network.ovn_nb_connection",
"network.ovn_sb_connection",
],
"ceilometer-compute-agent": [
"identity.password",
"identity.username",
Expand Down Expand Up @@ -518,11 +525,11 @@ def _split_dedicated_cores_by_profile(
},
Path("etc/neutron/neutron.conf"): {
"template": "neutron.conf.j2",
"services": ["neutron-ovn-metadata-agent"],
"services": ["neutron-ovn-agent"],
},
Path("etc/neutron/neutron_ovn_metadata_agent.ini"): {
"template": "neutron_ovn_metadata_agent.ini.j2",
"services": ["neutron-ovn-metadata-agent"],
Path("etc/neutron/neutron_ovn_agent.ini"): {
"template": "neutron_ovn_agent.ini.j2",
"services": ["neutron-ovn-agent"],
},
Path("etc/neutron/neutron_sriov_nic_agent.ini"): {
"template": "neutron_sriov_nic_agent.ini.j2",
Expand Down Expand Up @@ -583,6 +590,7 @@ def __init__(self, snap: Snap, files: dict, exclude_services: list = None):
self.files = files
self.file_hash = {}
self.exclude_services = exclude_services or []
self.restarted_services = set()

def __enter__(self):
"""Record all file hashes on entry."""
Expand All @@ -607,11 +615,13 @@ def __exit__(self, exc_type, exc_value, exc_traceback):
if new_hash != self.file_hash[file]:
restart_services.extend(self.files[file].get("services", []))

restart_services = set([s for s in restart_services if s not in self.exclude_services])
if not restart_services:
self.restarted_services = set(
[service for service in restart_services if service not in self.exclude_services]
)
if not self.restarted_services:
return

_restart_services(self.snap, restart_services)
_restart_services(self.snap, self.restarted_services)


def _service_subset(snap: Snap, names: typing.Iterable[str]) -> tuple[list[str], Dict[str, Any]]:
Expand Down Expand Up @@ -2198,7 +2208,6 @@ def _check_config_present(key: str, context: dict) -> bool:

def _services_not_ready(context: dict) -> List[str]:
"""Check if any services are missing keys they need to function."""
logging.warning(f"Context {context}")
not_ready = []
for svc in services():
for required in REQUIRED_CONFIG.get(svc, []):
Expand Down Expand Up @@ -2483,11 +2492,17 @@ def configure(snap: Snap) -> None:
ovs_deferred = not _microovn_ovs_ready(snap)
_ensure_services_stopped(snap, exclude_services)

with RestartOnChange(snap, {**TEMPLATES, **TLS_TEMPLATES}, exclude_services):
with RestartOnChange(
snap, {**TEMPLATES, **TLS_TEMPLATES}, exclude_services
) as restart_on_change:
_render_templates(snap, context)
_configure_tls(snap, configure_ovn_tls=not ovs_deferred)
_configure_webdav_apache(snap, context)

ovn_agent = "neutron-ovn-agent"
if ovn_agent not in exclude_services and ovn_agent not in restart_on_change.restarted_services:
_ensure_services_started(snap, [ovn_agent])

if ovs_deferred:
logging.info(
"MicroOVN OVSDB socket not present yet, deferring OVS/OVN configuration "
Expand Down Expand Up @@ -2568,8 +2583,6 @@ def _get_configure_context(snap: Snap) -> dict:
context["compute"]["allocated_cores"] = allocated_cores
context["compute"]["cpu_shared_set"] = cpu_shared_set

logging.info(context)

if not context.get("identity"):
context["identity"] = {}
if not context["identity"].get("keystone-region-name"):
Expand All @@ -2582,6 +2595,19 @@ def _get_configure_context(snap: Snap) -> dict:

# Add OVS socket path to network context for template rendering
context["network"]["ovs_socket_path"] = ovs_switch_socket(snap)
context["network"].pop("ovn_nb_connection", None)
context["network"].pop("ovn_sb_connection", None)
ovn_env_path = snap.paths.data / "microovn" / "ovn-env" / "env" / "ovn.env"
try:
ovn_connections = parse_ovn_env(ovn_env_path)
except OVNEnvError:
logging.warning(
"MicroOVN connection data is unavailable or invalid; "
"neutron-ovn-agent will remain stopped"
)
else:
context["network"]["ovn_nb_connection"] = ovn_connections["OVN_NB_CONNECT"]
context["network"]["ovn_sb_connection"] = ovn_connections["OVN_SB_CONNECT"]
_set_nova_metadata_proxy_context(context)

return context
Expand Down
102 changes: 102 additions & 0 deletions openstack_hypervisor/ovn_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# SPDX-FileCopyrightText: 2026 - Canonical Ltd
# SPDX-License-Identifier: Apache-2.0

import ipaddress
import re
from pathlib import Path

REQUIRED_CONNECTIONS = ("OVN_NB_CONNECT", "OVN_SB_CONNECT")
SUPPORTED_PROTOCOLS = frozenset(("ssl", "tcp"))

_ASSIGNMENT = re.compile(
r"(?P<name>[A-Za-z_][A-Za-z0-9_]*)=(?P<quote>['\"])(?P<value>.*)(?P=quote)"
)
_IPV4_ENDPOINT = re.compile(r"(?P<host>[^:]+):(?P<port>[0-9]+)")
_IPV6_ENDPOINT = re.compile(r"\[(?P<host>[^]]+)\]:(?P<port>[0-9]+)")


class OVNEnvError(ValueError):
"""Raised when MicroOVN's generated environment is unavailable or invalid."""


def _validate_endpoint(endpoint: str, connection_name: str) -> None:
"""Validate one MicroOVN-generated OVSDB endpoint."""
if not endpoint or any(character.isspace() for character in endpoint):
raise OVNEnvError(f"Invalid endpoint in {connection_name}")

try:
protocol, address = endpoint.split(":", 1)
except ValueError as exc:
raise OVNEnvError(f"Invalid endpoint in {connection_name}") from exc
if protocol not in SUPPORTED_PROTOCOLS:
raise OVNEnvError(f"Unsupported endpoint protocol in {connection_name}")

ipv6 = address.startswith("[")
match = (_IPV6_ENDPOINT if ipv6 else _IPV4_ENDPOINT).fullmatch(address)
if match is None:
raise OVNEnvError(f"Invalid endpoint address in {connection_name}")

try:
parsed_address = ipaddress.ip_address(match.group("host"))
except ValueError as exc:
raise OVNEnvError(f"Invalid endpoint address in {connection_name}") from exc
if ipv6 != (parsed_address.version == 6):
raise OVNEnvError(f"Invalid endpoint address in {connection_name}")

port = int(match.group("port"))
if not 1 <= port <= 65535:
raise OVNEnvError(f"Invalid endpoint port in {connection_name}")


def _validate_connection(connection: str, connection_name: str) -> None:
"""Validate every endpoint in a comma-separated connection string."""
endpoints = connection.split(",")
if not endpoints:
raise OVNEnvError(f"Empty required assignment: {connection_name}")
for endpoint in endpoints:
_validate_endpoint(endpoint, connection_name)


def _read_lines(path: Path) -> list[str]:
"""Read the environment as UTF-8 without interpreting its contents."""
try:
return path.read_text(encoding="utf-8").splitlines()
except (OSError, UnicodeError) as exc:
raise OVNEnvError("Unable to read OVN environment file") from exc


def _parse_required_assignments(lines: list[str]) -> dict[str, str]:
"""Extract required quoted assignments and reject malformed input."""
connections: dict[str, str] = {}
for line_number, line in enumerate(lines, start=1):
if not line.strip() or line.lstrip().startswith("#"):
continue

match = _ASSIGNMENT.fullmatch(line)
if match is None:
raise OVNEnvError(f"Malformed assignment on line {line_number}")

name = match.group("name")
if name not in REQUIRED_CONNECTIONS:
continue
if name in connections:
raise OVNEnvError(f"Duplicate required assignment: {name}")

value = match.group("value")
if not value:
raise OVNEnvError(f"Empty required assignment: {name}")
connections[name] = value

missing = [name for name in REQUIRED_CONNECTIONS if name not in connections]
if missing:
raise OVNEnvError(f"Missing required assignment: {', '.join(missing)}")
return connections


def parse_ovn_env(path: Path) -> dict[str, str]:
"""Strictly parse required OVN connections without executing the file."""
connections = _parse_required_assignments(_read_lines(path))

for name, connection in connections.items():
_validate_connection(connection, name)
return connections
47 changes: 25 additions & 22 deletions openstack_hypervisor/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -134,48 +134,53 @@ def run(self, snap: Snap) -> int:
nova_api_metadata = partial(entry_point, NovaAPIMetadataService)


class NeutronOVNMetadataAgentService(OpenStackService):
"""A python service object used to run the neutron-ovn-metadata-agent daemon."""
class NeutronOVNAgentService(OpenStackService):
"""A service object used to run the neutron-ovn-agent daemon."""

conf_files = [
Path("etc/neutron/neutron.conf"),
Path("etc/neutron/neutron_ovn_metadata_agent.ini"),
Path("etc/neutron/neutron_ovn_agent.ini"),
]
conf_dirs = [
Path("etc/neutron/neutron.conf.d"),
]

executable = Path("usr/bin/neutron-ovn-metadata-agent")
executable = Path("usr/bin/neutron-ovn-agent")

def run(self, snap: Snap) -> int:
"""Run neutron-ovn-metadata-agent once MicroOVN's OVSDB is ready."""
"""Run neutron-ovn-agent once required connections and local OVS are ready."""
setup_logging(snap.paths.common / f"{self.executable.name}-{snap.name}.log")
ovsdb_connection = self._ovsdb_connection(snap)
if not ovsdb_connection:
ovsdb_connections = self._ovsdb_connections(snap)
if ovsdb_connections is None:
return 1

ovsdb_connection = ovsdb_connections["ovsdb_connection"]
if not self._wait_for_ovsdb_schema(snap, ovsdb_connection):
return 1

return super().run(snap)

def _ovsdb_connection(self, snap: Snap) -> str | None:
"""Read the configured MicroOVN OVSDB connection string."""
config_path = snap.paths.common / "etc/neutron/neutron_ovn_metadata_agent.ini"
def _ovsdb_connections(self, snap: Snap) -> dict[str, str] | None:
"""Read all connections required by the OVN agent."""
config_path = snap.paths.common / "etc/neutron/neutron_ovn_agent.ini"
parser = configparser.ConfigParser()
try:
if not parser.read(config_path):
logging.error("Unable to read OVN metadata agent config: %s", config_path)
logging.error("Unable to read OVN agent config: %s", config_path)
return None
ovsdb_connection = parser.get("ovs", "ovsdb_connection", fallback="").strip()
except configparser.Error as exc:
logging.error("Unable to parse OVN metadata agent config %s: %s", config_path, exc)
connections = {
"ovsdb_connection": parser.get("ovs", "ovsdb_connection", fallback="").strip(),
"ovn_nb_connection": parser.get("ovn", "ovn_nb_connection", fallback="").strip(),
"ovn_sb_connection": parser.get("ovn", "ovn_sb_connection", fallback="").strip(),
}
except (configparser.Error, OSError, UnicodeError):
logging.error("Unable to parse OVN agent config: %s", config_path)
return None

if not ovsdb_connection:
logging.error("ovsdb_connection is not configured in %s", config_path)
if not all(connections.values()):
logging.error("Required OVSDB connections are not configured in %s", config_path)
return None
return ovsdb_connection
return connections

def _wait_for_ovsdb_schema(self, snap: Snap, ovsdb_connection: str) -> bool:
"""Wait until ovsdb-client can retrieve the Open_vSwitch schema."""
Expand All @@ -190,14 +195,12 @@ def _wait_for_ovsdb_schema(self, snap: Snap, ovsdb_connection: str) -> bool:

while True:
if socket_path and not socket_path.exists():
logging.info("Waiting for MicroOVN OVSDB socket: %s", socket_path)
logging.info("Waiting for the local MicroOVN OVSDB socket")
elif self._ovsdb_schema_available(command):
return True

if time.monotonic() >= deadline:
logging.error(
"Timed out waiting for Open_vSwitch schema from %s", ovsdb_connection
)
logging.error("Timed out waiting for the local Open_vSwitch schema")
return False
time.sleep(OVSDB_SCHEMA_CHECK_INTERVAL)

Expand All @@ -221,7 +224,7 @@ def _unix_socket_path(self, ovsdb_connection: str) -> Path | None:
return Path(ovsdb_connection.removeprefix("unix:"))


neutron_ovn_metadata_agent = partial(entry_point, NeutronOVNMetadataAgentService)
neutron_ovn_agent = partial(entry_point, NeutronOVNAgentService)


class NeutronSRIOVNicAgentService(OpenStackService):
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ tics= [
[project.scripts]
nova-compute-service = "openstack_hypervisor.services:nova_compute"
nova-api-metadata-service = "openstack_hypervisor.services:nova_api_metadata"
neutron-ovn-metadata-agent-service = "openstack_hypervisor.services:neutron_ovn_metadata_agent"
neutron-ovn-agent-service = "openstack_hypervisor.services:neutron_ovn_agent"
neutron-sriov-nic-agent-service = "openstack_hypervisor.services:neutron_sriov_nic_agent"
ceilometer-compute-agent-service = "openstack_hypervisor.services:ceilometer_compute_agent"
masakari-instancemonitor-service = "openstack_hypervisor.services:masakari_instancemonitor"
Expand Down
12 changes: 10 additions & 2 deletions snap/snapcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,8 @@ apps:
- mount-observe
- nvme-control

neutron-ovn-metadata-agent:
command: 'bin/neutron-ovn-metadata-agent-service'
neutron-ovn-agent:
command: 'bin/neutron-ovn-agent-service'
daemon: simple
restart-condition: on-failure
restart-delay: 7s
Expand All @@ -223,6 +223,7 @@ apps:
- ovn-chassis
- microstack-support
- firewall-control
- shared-memory

neutron-sriov-nic-agent:
command: 'bin/neutron-sriov-nic-agent-service'
Expand Down Expand Up @@ -253,6 +254,7 @@ apps:
- network-bind
- firewall-control
- microstack-support
- shared-memory

masakari-instancemonitor:
command: 'bin/masakari-instancemonitor-service'
Expand Down Expand Up @@ -859,6 +861,7 @@ hooks:
- epa-info
- etc-driverctl
- ovn-chassis
- ovn-env
- dm-multipath
connect-slot-hypervisor-config:
plugs:
Expand All @@ -879,3 +882,8 @@ plugs:
ovn-chassis:
interface: content
target: $SNAP_DATA/microovn/chassis
ovn-env:
interface: content
target: $SNAP_DATA/microovn/ovn-env
shared-memory:
private: true
Loading
Loading