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
58 changes: 50 additions & 8 deletions openstack_hypervisor/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import xml.etree.ElementTree
from pathlib import Path
from typing import Any, Dict, List, Optional
from urllib import parse

from cryptography import x509
from cryptography.exceptions import InvalidSignature
Expand Down Expand Up @@ -68,7 +69,7 @@
# or None for IPvAnyNetwork.
IPVANYNETWORK_UNSET = "0.0.0.0/0"

SECRETS = ["credentials.ovn-metadata-proxy-shared-secret"]
SECRETS = []

DEFAULT_SECRET_LENGTH = 32
TRUE_STRINGS = ("1", "t", "true", "on", "y", "yes")
Expand Down Expand Up @@ -102,6 +103,7 @@
Path("etc/nova/nova.conf.d"),
Path("etc/neutron"),
Path("etc/neutron/neutron.conf.d"),
Path("etc/haproxy"),
Path("etc/ssl/certs"),
Path("etc/ssl/private"),
Path("etc/ceilometer"),
Expand Down Expand Up @@ -381,6 +383,7 @@ def _get_local_ip_by_default_route() -> str:
"network.ovn-cert": UNSET,
"network.ovn-key": UNSET,
"network.ovn-cacert": UNSET,
"network.nova-metadata-proxy-url": UNSET,
"network.enable-gateway": False,
"network.ip-address": _get_local_ip_by_default_route, # noqa: F821
# Deprecate external nic
Expand Down Expand Up @@ -418,11 +421,8 @@ def _get_local_ip_by_default_route() -> str:
"rabbitmq.url",
],
"nova-api-metadata": [
"identity.password",
"identity.username",
"identity",
"rabbitmq.url",
"network",
"credentials.ovn_metadata_proxy_shared_secret",
"network.nova_metadata_proxy_url",
],
"neutron-ovn-metadata-agent": ["credentials", "network", "node", "network.ovn_key"],
"ceilometer-compute-agent": [
Expand Down Expand Up @@ -472,7 +472,10 @@ def _get_template(snap: Snap, template: str) -> Template:
:rtype: Template
"""
template_dir = snap.paths.snap / "templates"
env = Environment(loader=FileSystemLoader(searchpath=str(template_dir)))
env = Environment(
loader=FileSystemLoader(searchpath=str(template_dir)),
keep_trailing_newline=True,
)
return env.get_template(template)


Expand Down Expand Up @@ -545,7 +548,11 @@ def _split_dedicated_cores_by_profile(
TEMPLATES = {
Path("etc/nova/nova.conf"): {
"template": "nova.conf.j2",
"services": ["nova-compute", "nova-api-metadata"],
"services": ["nova-compute"],
},
Path("etc/haproxy/nova_metadata.cfg"): {
"template": "nova_metadata_haproxy.cfg.j2",
"services": ["nova-api-metadata"],
},
Path("etc/neutron/neutron.conf"): {
"template": "neutron.conf.j2",
Expand Down Expand Up @@ -3125,6 +3132,7 @@ def _get_configure_context(snap: Snap) -> dict:
context.setdefault("compute", {})
context.setdefault("network", {})
context.setdefault("identity", {})
context.setdefault("credentials", {})
context["compute"]["multipath_enabled"] = (
context["compute"].get("multipath_forced", False) or _is_multipathd_available()
)
Expand Down Expand Up @@ -3172,10 +3180,44 @@ 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)
_set_nova_metadata_proxy_context(context)

return context


def _set_nova_metadata_proxy_context(context: dict) -> None:
"""Add parsed Nova metadata upstream fields for HAProxy rendering."""
network = context.setdefault("network", {})
proxy_url = network.get("nova_metadata_proxy_url")
if not proxy_url:
return

parsed = parse.urlsplit(proxy_url)
if parsed.scheme not in ("http", "https") or not parsed.hostname:
raise ValueError(f"Invalid Nova metadata proxy URL: {proxy_url}")
if parsed.username or parsed.password:
raise ValueError("Nova metadata proxy URL must not include userinfo")

host = parsed.hostname
server_host = f"[{host}]" if ":" in host and not host.startswith("[") else host
default_port = 443 if parsed.scheme == "https" else 80
port = parsed.port or default_port
host_header = (
f"{server_host}:{parsed.port}"
if parsed.port and parsed.port != default_port
else server_host
)
path = parsed.path.rstrip("/")

network["nova_metadata_proxy_scheme"] = parsed.scheme
network["nova_metadata_proxy_host"] = host
network["nova_metadata_proxy_server_host"] = server_host
network["nova_metadata_proxy_port"] = port
network["nova_metadata_proxy_host_header"] = host_header
network["nova_metadata_proxy_path"] = path
network["nova_metadata_proxy_ssl"] = parsed.scheme == "https"


# Services to exclude when using external OVS (ovn-chassis plug connected)
EXTERNAL_OVS_SERVICES = [
"ovsdb-server",
Expand Down
27 changes: 19 additions & 8 deletions openstack_hypervisor/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,16 +87,27 @@ class NovaComputeService(OpenStackService):


class NovaAPIMetadataService(OpenStackService):
"""A python service object used to run the nova-api-metadata daemon."""
"""A service object used to run the Nova metadata HAProxy bridge."""

conf_files = [
Path("etc/nova/nova.conf"),
]
conf_dirs = [
Path("etc/nova/nova.conf.d"),
]
def run(self, snap: Snap) -> int:
"""Runs the local Nova metadata reverse proxy."""
setup_logging(snap.paths.common / "nova-api-metadata-service.log")

executable = Path("usr/bin/nova-api-metadata")
upstream_url = snap.config.get("network.nova-metadata-proxy-url")
if not upstream_url or upstream_url == "UNSET":
logging.error("network.nova-metadata-proxy-url is not configured")
return 1

cmd = [
str(snap.paths.snap / "usr" / "sbin" / "haproxy"),
"-f",
str(snap.paths.common / "etc" / "haproxy" / "nova_metadata.cfg"),
"-db",
]
completed_process = subprocess.run(cmd)

logging.info("Exiting with code %s", completed_process.returncode)
return completed_process.returncode


nova_api_metadata = partial(entry_point, NovaAPIMetadataService)
Expand Down
8 changes: 7 additions & 1 deletion snap/snapcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -684,7 +684,13 @@ parts:
git config user.email "snapcraft@build"
git config user.name "snapcraft"
git am --abort 2>/dev/null || true
git am $CRAFT_PROJECT_DIR/snap/patches/libvirt/*.patch
for patch in $CRAFT_PROJECT_DIR/snap/patches/libvirt/*.patch; do
if git apply --check "$patch"; then
git am "$patch"
else
echo "Skipping inapplicable libvirt patch: $patch"
fi
done
popd || exit 1

craftctl default
Expand Down
5 changes: 4 additions & 1 deletion templates/neutron_ovn_metadata_agent.ini.j2
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@
# Use $SNAP_COMMON/etc/neutron/neutron.conf.d for deployment specific
# configuration
[DEFAULT]
nova_metadata_host = {{ node.ip_address }}
nova_metadata_host = 127.0.0.1
nova_metadata_port = 8775
{% if credentials.ovn_metadata_proxy_shared_secret is defined -%}
metadata_proxy_shared_secret = {{ credentials.ovn_metadata_proxy_shared_secret }}
{% endif -%}
debug = {{ logging.debug }}

[ovs]
Expand Down
2 changes: 2 additions & 0 deletions templates/nova.conf.j2
Original file line number Diff line number Diff line change
Expand Up @@ -192,8 +192,10 @@ password = {{ identity.password }}
{% if ca and ca.bundle -%}
cafile = {{ snap_common }}/etc/ssl/certs/receive-ca-bundle.pem
{% endif -%}
{% if credentials.ovn_metadata_proxy_shared_secret is defined -%}
service_metadata_proxy = True
metadata_proxy_shared_secret = {{ credentials.ovn_metadata_proxy_shared_secret }}
{% endif -%}

[placement]
auth_url = {{ identity.auth_url }}
Expand Down
32 changes: 32 additions & 0 deletions templates/nova_metadata_haproxy.cfg.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# THIS FILE IS MANAGED BY THE SNAP - CHANGES WILL BE OVERWRITTEN
# HAProxy configuration for the local Nova metadata bridge.
global
log stdout format raw local0
maxconn 4096

defaults
log global
mode http
option httplog
option dontlognull
option http-server-close
retries 3
timeout http-request 30s
timeout connect 5s
timeout client 32s
timeout server 32s
timeout http-keep-alive 30s

frontend nova_metadata
bind 127.0.0.1:8775
http-request set-header Host {{ network.nova_metadata_proxy_host_header }}
http-request set-header X-Forwarded-Proto http
{% if network.nova_metadata_proxy_path %}
http-request set-path {{ network.nova_metadata_proxy_path }}%[path]
{% endif %}
http-response del-header Transfer-Encoding
http-response set-header Connection close
default_backend nova_metadata_upstream

backend nova_metadata_upstream
server nova-metadata {{ network.nova_metadata_proxy_server_host }}:{{ network.nova_metadata_proxy_port }}{% if network.nova_metadata_proxy_ssl %} ssl verify required ca-file {% if ca and ca.bundle %}{{ snap_common }}/etc/ssl/certs/receive-ca-bundle.pem{% else %}{{ snap }}/etc/ssl/certs/ca-certificates.crt{% endif %}{% endif %}
52 changes: 51 additions & 1 deletion tests/unit/test_hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,48 @@ def test_owned_path_is_path_subclass(self):
class TestHooks:
"""Contains tests for openstack_hypervisor.hooks."""

def test_set_nova_metadata_proxy_context_parses_url(self):
"""Nova metadata ingress URL is expanded for HAProxy rendering."""
context = {"network": {"nova_metadata_proxy_url": "http://internal/nova-metadata"}}

hooks._set_nova_metadata_proxy_context(context)

assert context["network"]["nova_metadata_proxy_scheme"] == "http"
assert context["network"]["nova_metadata_proxy_host"] == "internal"
assert context["network"]["nova_metadata_proxy_server_host"] == "internal"
assert context["network"]["nova_metadata_proxy_port"] == 80
assert context["network"]["nova_metadata_proxy_host_header"] == "internal"
assert context["network"]["nova_metadata_proxy_path"] == "/nova-metadata"
assert context["network"]["nova_metadata_proxy_ssl"] is False

def test_set_nova_metadata_proxy_context_defaults_https_port(self):
"""HTTPS metadata ingress uses the default HTTPS upstream port."""
context = {"network": {"nova_metadata_proxy_url": "https://internal/nova-metadata/"}}

hooks._set_nova_metadata_proxy_context(context)

assert context["network"]["nova_metadata_proxy_port"] == 443
assert context["network"]["nova_metadata_proxy_path"] == "/nova-metadata"
assert context["network"]["nova_metadata_proxy_ssl"] is True

def test_set_nova_metadata_proxy_context_keeps_non_default_host_port(self):
"""Host header preserves an explicit non-default upstream port."""
context = {"network": {"nova_metadata_proxy_url": "https://internal:8443/nova-metadata/"}}

hooks._set_nova_metadata_proxy_context(context)

assert context["network"]["nova_metadata_proxy_host_header"] == "internal:8443"
assert context["network"]["nova_metadata_proxy_port"] == 8443

def test_set_nova_metadata_proxy_context_rejects_userinfo(self):
"""Nova metadata ingress URLs must not contain userinfo."""
context = {
"network": {"nova_metadata_proxy_url": "https://user:pass@internal/nova-metadata/"}
}

with pytest.raises(ValueError, match="must not include userinfo"):
hooks._set_nova_metadata_proxy_context(context)

def test_install_hook(self, mocker, snap, shutil_chown):
"""Tests the install hook."""
mocker.patch.object(hooks, "_secure_copy")
Expand Down Expand Up @@ -271,7 +313,15 @@ def test_services_not_ready(self, snap):
"ovn_key": "key",
"ovn_cacert": "cacert",
}
assert hooks._services_not_ready(config) == ["neutron-ovn-metadata-agent"]
assert hooks._services_not_ready(config) == [
"neutron-ovn-metadata-agent",
"nova-api-metadata",
]
config["network"]["nova_metadata_proxy_url"] = "http://internal/nova-metadata"
assert hooks._services_not_ready(config) == [
"neutron-ovn-metadata-agent",
"nova-api-metadata",
]
config["credentials"] = {"ovn_metadata_proxy_shared_secret": "secret"}
assert hooks._services_not_ready(config) == []

Expand Down
46 changes: 45 additions & 1 deletion tests/unit/test_services.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@

import pytest

from openstack_hypervisor.services import FileTransferService
from openstack_hypervisor.services import (
FileTransferService,
NovaAPIMetadataService,
)

_CERT = base64.b64encode(b"CERT").decode()
_KEY = base64.b64encode(b"KEY").decode()
Expand Down Expand Up @@ -132,3 +135,44 @@ def test_success_path(
assert cmd[sep + 1] == str(tls_config.paths.snap / "usr" / "sbin" / "apache2")
assert cmd[-1] == "-DFOREGROUND"
assert "/proc/self/fd/6" in cmd


class TestNovaAPIMetadataService:
"""Tests for NovaAPIMetadataService."""

@patch("openstack_hypervisor.services.subprocess.run")
def test_success_path(
self,
mock_run,
snap,
):
"""Service should start the local HAProxy metadata bridge."""
snap.config.get.return_value = "http://internal/nova-metadata/"
mock_run.return_value = MagicMock(returncode=0)

result = NovaAPIMetadataService().run(snap)

assert result == 0
snap.config.get.assert_called_once_with("network.nova-metadata-proxy-url")
mock_run.assert_called_once_with(
[
str(snap.paths.snap / "usr" / "sbin" / "haproxy"),
"-f",
str(snap.paths.common / "etc" / "haproxy" / "nova_metadata.cfg"),
"-db",
]
)

@patch("openstack_hypervisor.services.subprocess.run")
def test_returns_1_without_metadata_proxy_url(
self,
mock_run,
snap,
):
"""Service should fail fast when the metadata ingress URL is missing."""
snap.config.get.return_value = "UNSET"

result = NovaAPIMetadataService().run(snap)

assert result == 1
mock_run.assert_not_called()
Loading
Loading