From 118f5943f3131cdac05c99b31093ec7047535575 Mon Sep 17 00:00:00 2001 From: haseeb Date: Fri, 14 Aug 2026 21:08:40 +0530 Subject: [PATCH 01/13] CRUD implementation of the router_flavors plugin --- ...ck.rackspace.net_neutronrouterflavors.yaml | 4 +- python/openstack-sync/README.md | 6 +- .../openstack_sync/hooks/common.py | 250 +++++++++++++ .../openstack_sync/hooks/router_flavors.py | 246 +++++++++---- .../openstack_sync/plugins/__init__.py | 1 + .../openstack_sync/plugins/common.py | 339 +++++++++++++++++ .../plugins/neutron/__init__.py | 1 + .../neutron/router_flavors/__init__.py | 1 + .../plugins/neutron/router_flavors/create.py | 158 ++++++++ .../plugins/neutron/router_flavors/delete.py | 238 ++++++++++++ .../router_flavors/router_flavors_common.py | 223 +++++++++++ .../plugins/neutron/router_flavors/update.py | 83 +++++ python/openstack-sync/tests/conftest.py | 42 +++ .../openstack-sync/tests/test_hook_common.py | 346 ++++++++++++++++++ .../openstack-sync/tests/test_placeholder.py | 124 ++++++- .../tests/test_router_flavors.py | 301 ++++++--------- .../tests/test_router_flavors_create.py | 186 ++++++++++ .../tests/test_router_flavors_hook.py | 239 ++++++++++++ .../tests/test_router_flavors_prune.py | 203 ++++++++++ 19 files changed, 2729 insertions(+), 262 deletions(-) create mode 100644 python/openstack-sync/openstack_sync/hooks/common.py create mode 100644 python/openstack-sync/openstack_sync/plugins/__init__.py create mode 100644 python/openstack-sync/openstack_sync/plugins/common.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/__init__.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/__init__.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py create mode 100644 python/openstack-sync/tests/conftest.py create mode 100644 python/openstack-sync/tests/test_hook_common.py create mode 100644 python/openstack-sync/tests/test_router_flavors_create.py create mode 100644 python/openstack-sync/tests/test_router_flavors_hook.py create mode 100644 python/openstack-sync/tests/test_router_flavors_prune.py diff --git a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml index b41887e83..97eca1cb9 100644 --- a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml +++ b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml @@ -85,7 +85,9 @@ spec: maxLength: 255 pattern: ^[A-Za-z0-9._-]+$ service_type: - description: Neutron service type for the flavor. + description: >- + Neutron service type for the flavor. For router flavors this + is always L3_ROUTER_NAT (plugin_constants.L3 in neutron-lib). type: string enum: - L3_ROUTER_NAT diff --git a/python/openstack-sync/README.md b/python/openstack-sync/README.md index 1c87b91e3..a32ab3a90 100644 --- a/python/openstack-sync/README.md +++ b/python/openstack-sync/README.md @@ -2,5 +2,7 @@ Shell-operator package for OpenStack reconciliation hooks. -The base image ships with a no-op placeholder hook. Resource-specific sync hooks -are added as plugins. +The operator image ships with a no-op placeholder hook and resource-specific +sync hooks under `openstack_sync/hooks/`. The Neutron router flavor hook is +implemented under `openstack_sync/plugins/neutron/router_flavors/` and exposed +to shell-operator as `/hooks/router_flavors.py`. diff --git a/python/openstack-sync/openstack_sync/hooks/common.py b/python/openstack-sync/openstack_sync/hooks/common.py new file mode 100644 index 000000000..f13e6150d --- /dev/null +++ b/python/openstack-sync/openstack_sync/hooks/common.py @@ -0,0 +1,250 @@ +"""Generic shell-operator hook utilities shared across all hooks. + +Provides binding context I/O, status patching via kubectl, and the +Synchronization/Event/Schedule dispatch loop that every hook needs. +""" + +from __future__ import annotations + +import datetime as dt +import json +import os +import subprocess +import sys +from collections.abc import Callable +from typing import Any + +# --------------------------------------------------------------------------- +# Type coercions +# --------------------------------------------------------------------------- + + +def string_or_none(value: Any) -> str | None: + return None if value is None else str(value) + + +def int_or_none(value: Any) -> int | None: + if value is None: + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +# --------------------------------------------------------------------------- +# Binding context I/O +# --------------------------------------------------------------------------- + + +def read_binding_context() -> list[dict[str, Any]]: + """Read and parse the shell-operator binding context from BINDING_CONTEXT_PATH.""" + path = os.environ.get("BINDING_CONTEXT_PATH") + if not path: + return [] + with open(path, encoding="utf-8") as f: + contexts = json.load(f) + if not isinstance(contexts, list): + raise ValueError("Shell-operator binding context must be a list") + return contexts + + +def snapshot_items( + contexts: list[dict[str, Any]], + binding_name: str, +) -> list[Any] | None: + """Return snapshot items for *binding_name* from *contexts*, or None.""" + for context in contexts: + snapshots = context.get("snapshots") + if not isinstance(snapshots, dict): + continue + items = snapshots.get(binding_name) + if items is not None: + if not isinstance(items, list): + raise ValueError(f"Snapshot {binding_name} must be a list") + return items + return None + + +def synchronization_items( + contexts: list[dict[str, Any]], + binding_name: str, +) -> list[Any] | None: + """Return Synchronization objects for *binding_name* from *contexts*, or None.""" + for context in contexts: + if ( + context.get("binding") == binding_name + and context.get("type") == "Synchronization" + ): + items = context.get("objects", []) + if not isinstance(items, list): + raise ValueError( + f"Synchronization {binding_name} objects must be a list" + ) + return items + return None + + +# --------------------------------------------------------------------------- +# Status patching +# --------------------------------------------------------------------------- + + +def utc_timestamp() -> str: + """Return the current UTC time as an ISO-8601 string with Z suffix.""" + timestamp = dt.datetime.now(dt.UTC).replace(microsecond=0) + return timestamp.isoformat().replace("+00:00", "Z") + + +def truncate_message(message: Any, max_length: int = 2048) -> str: + """Truncate *message* to *max_length* characters, appending '...' if cut.""" + text = str(message) + if len(text) <= max_length: + return text + return f"{text[: max_length - 3]}..." + + +def patch_resource_status( + *, + name: str, + namespace: str | None, + generation: int | None, + sync_status: str, + message: str, + crd_resource: str, + crd_kind: str, + status_enabled: bool, + log_fn: Callable[[str], None] = lambda msg: print(msg, file=sys.stderr), +) -> None: + """Patch the status subresource of a CR via kubectl. + + Args: + name: CR metadata.name. + namespace: CR metadata.namespace (optional). + generation: CR metadata.generation for observedGeneration (optional). + sync_status: One of ``"Synced"`` or ``"Failed"``. + message: Human-readable detail for the status message. + crd_resource: Fully-qualified CRD resource name for kubectl (e.g. + ``neutronrouterflavors.neutron.understack.rackspace.net``). + crd_kind: CRD kind used in log messages (e.g. ``NeutronRouterFlavor``). + status_enabled: When False the function returns immediately. + log_fn: Callable used to emit warning messages. + """ + if not status_enabled: + return + + timestamp = utc_timestamp() + condition_status = "True" if sync_status == "Synced" else "False" + reason = "ReconcileSucceeded" if sync_status == "Synced" else "ReconcileFailed" + status: dict[str, Any] = { + "syncStatus": sync_status, + "lastSyncTime": timestamp, + "message": truncate_message(message), + "conditions": [ + { + "type": "Synced", + "status": condition_status, + "reason": reason, + "message": truncate_message(message), + "lastTransitionTime": timestamp, + } + ], + } + if generation is not None: + status["observedGeneration"] = generation + + command = [ + "kubectl", + "patch", + crd_resource, + name, + "--type", + "merge", + "--subresource", + "status", + "-p", + json.dumps({"status": status}, sort_keys=True), + ] + if namespace: + command.extend(["-n", namespace]) + + try: + result = subprocess.run( # noqa: S603,S607 + command, + capture_output=True, + check=False, + text=True, + ) + except FileNotFoundError: + log_fn(f"WARNING: kubectl not found; unable to patch {crd_kind} status") + return + + if result.returncode != 0: + error = (result.stderr or result.stdout or "unknown error").strip() + log_fn(f"WARNING: failed to patch {crd_kind} status for {name}: {error}") + + +# --------------------------------------------------------------------------- +# Binding context dispatch loop +# --------------------------------------------------------------------------- + + +def dispatch_binding_contexts( + binding_contexts: list[dict[str, Any]], + binding_name: str, + reconcile_fn: Callable[[dict[str, Any]], None], + log_fn: Callable[[str], None] = lambda msg: print(msg, file=sys.stderr), +) -> int: + """Dispatch each object in *binding_contexts* to *reconcile_fn*. + + Handles the three shell-operator context types: + - ``Synchronization``: full object list on startup + - ``Event``: single Added/Modified/Deleted event (Deleted is skipped) + - Schedule / other: objects from the snapshots map + + Args: + binding_contexts: Parsed list from the shell-operator binding context. + binding_name: The binding name to filter on. + reconcile_fn: Called with each individual event dict ``{"object": ...}``. + log_fn: Callable used to emit error messages. + + Returns: + 0 on success, 1 if any reconciliation raises. + """ + for context in binding_contexts: + binding = context.get("binding", "") + if binding != binding_name: + continue + + context_type = context.get("type", "") + + if context_type == "Synchronization": + for item in context.get("objects", []): + try: + reconcile_fn(item) + except Exception as exc: # noqa: BLE001 + log_fn(f"reconcile failed: {exc}") + return 1 + + elif context_type == "Event": + if context.get("watchEvent") == "Deleted": + continue + obj = context.get("object") + if obj: + try: + reconcile_fn({"object": obj}) + except Exception as exc: # noqa: BLE001 + log_fn(f"reconcile failed: {exc}") + return 1 + + else: + # Schedule or other: objects live in the snapshots map + snapshots = context.get("snapshots", {}) + for item in snapshots.get(binding_name, []): + try: + reconcile_fn(item) + except Exception as exc: # noqa: BLE001 + log_fn(f"reconcile failed: {exc}") + return 1 + + return 0 diff --git a/python/openstack-sync/openstack_sync/hooks/router_flavors.py b/python/openstack-sync/openstack_sync/hooks/router_flavors.py index b8ce1c0bc..147a3fbd2 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -6,56 +6,60 @@ import json import os import sys +from dataclasses import dataclass from typing import Any +from openstack_sync.hooks.common import dispatch_binding_contexts +from openstack_sync.hooks.common import int_or_none +from openstack_sync.hooks.common import patch_resource_status +from openstack_sync.hooks.common import read_binding_context +from openstack_sync.hooks.common import snapshot_items +from openstack_sync.hooks.common import string_or_none +from openstack_sync.hooks.common import synchronization_items +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + CRD_API_VERSION, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + CRD_BINDING_NAME, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import CRD_KIND +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + CRD_NAMESPACE, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + CRD_RESOURCE, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + DEFAULT_CLOUD, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + DEFAULT_SECRET, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + STATUS_ENABLED, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + ConfigError, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import log +from openstack_sync.plugins.neutron.router_flavors.update import sync_flavor from openstack_sync.utils import get_openstack_connection -from openstack_sync.utils import pod_namespace # noqa: F401 — re-exported for tests - -TRUTHY_VALUES = {"1", "true", "yes", "on"} - - -def env_is_truthy(name: str, default: str = "false") -> bool: - return os.environ.get(name, default).lower() in TRUTHY_VALUES - - -def router_flavor_namespace() -> str | None: - return ( - os.environ.get("NEUTRON_ROUTER_FLAVOR_NAMESPACE") - or os.environ.get("POD_NAMESPACE") - or None - ) - # --------------------------------------------------------------------------- -# Reconciliation +# Resource dataclass # --------------------------------------------------------------------------- -def reconcile_router_flavor(event: dict[str, Any]) -> None: - """Reconcile a single NeutronRouterFlavor resource against OpenStack. - - Reads ``spec.cloudCredentialsRef`` from the event to determine which - Kubernetes Secret and which cloud entry to use. No operator-level - cloud configuration is required — each resource is self-describing. - """ - obj = event["object"] - spec = obj.get("spec", {}) - - creds_ref = spec.get("cloudCredentialsRef", {}) - secret_name = creds_ref.get("secretName") - cloud_name = creds_ref.get("cloudName") - - if not secret_name or not cloud_name: - raise ValueError( - f"NeutronRouterFlavor {obj.get('metadata', {}).get('name')!r} " - "is missing spec.cloudCredentialsRef.secretName or .cloudName" - ) - - conn = get_openstack_connection(secret_name, cloud_name) # noqa: F841 +@dataclass(frozen=True) +class RouterFlavorResource: + """A single NeutronRouterFlavor CR with its resolved credentials.""" - # Full reconciliation logic (create/update/delete router flavor) will be - # wired in here once the connection-per-resource pattern is established. - # The connection object is available as `conn` for subsequent API calls. + flavor: dict[str, Any] + name: str | None + namespace: str | None + generation: int | None + secret_name: str + cloud_name: str # --------------------------------------------------------------------------- @@ -63,8 +67,8 @@ def reconcile_router_flavor(event: dict[str, Any]) -> None: # --------------------------------------------------------------------------- -def build_hook_config() -> dict[str, object]: - hook_config: dict[str, object] = { +def build_hook_config() -> dict[str, Any]: + hook_config: dict[str, Any] = { "configVersion": "v1", "settings": { "executionMinInterval": "30s", @@ -72,35 +76,34 @@ def build_hook_config() -> dict[str, object]: }, } - if not env_is_truthy("NEUTRON_ROUTER_FLAVOR_ENABLED"): + is_sync_enabled = bool( + os.environ.get("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "").strip() + ) + if not is_sync_enabled: # Shell-operator requires at least one binding. hook_config["onStartup"] = 10 return hook_config - kubernetes_binding: dict[str, object] = { - "name": "neutron-router-flavors", - "apiVersion": "neutron.understack.rackspace.net/v1alpha1", - "kind": "NeutronRouterFlavor", + namespace = os.environ.get("POD_NAMESPACE") + kubernetes_binding: dict[str, Any] = { + "name": CRD_BINDING_NAME, + "apiVersion": CRD_API_VERSION, + "kind": CRD_KIND, "executeHookOnEvent": ["Added", "Modified", "Deleted"], "jqFilter": ".", - "includeSnapshotsFrom": ["neutron-router-flavors"], + "includeSnapshotsFrom": [CRD_BINDING_NAME], } - namespace = router_flavor_namespace() if namespace: kubernetes_binding["namespace"] = { - "nameSelector": { - "matchNames": [namespace], - }, + "nameSelector": {"matchNames": [namespace]}, } hook_config["kubernetes"] = [kubernetes_binding] hook_config["schedule"] = [ { "name": "hourly sync", - "crontab": os.environ.get( - "NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *" - ), - "includeSnapshotsFrom": ["neutron-router-flavors"], + "crontab": os.environ["NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB"], + "includeSnapshotsFrom": [CRD_BINDING_NAME], } ] return hook_config @@ -110,7 +113,118 @@ def build_hook_config() -> dict[str, object]: # --------------------------------------------------------------------------- -# Entry point +# Binding context parsing +# --------------------------------------------------------------------------- + + +def _resource_from_object(obj: Any, source: str) -> RouterFlavorResource: + if not isinstance(obj, dict): + raise ConfigError(f"{source} object must be a Kubernetes object") + + spec = obj.get("spec") + if not isinstance(spec, dict): + raise ConfigError(f"{source} spec must be an object") + + flavor = dict(spec) + metadata = obj.get("metadata", {}) + resource_name = None + resource_namespace = None + generation = None + if isinstance(metadata, dict): + resource_name = string_or_none(metadata.get("name")) + resource_namespace = string_or_none(metadata.get("namespace")) + generation = int_or_none(metadata.get("generation")) + + if "name" not in flavor and resource_name: + flavor["name"] = resource_name + + creds_ref = flavor.pop("cloudCredentialsRef", {}) or {} + secret_name = creds_ref.get("secretName") or DEFAULT_SECRET + cloud_name = creds_ref.get("cloudName") or DEFAULT_CLOUD + + return RouterFlavorResource( + flavor=flavor, + name=resource_name, + namespace=resource_namespace, + generation=generation, + secret_name=secret_name, + cloud_name=cloud_name, + ) + + +def _resources_from_items(items: list[Any], source: str) -> list[RouterFlavorResource]: + resources: list[RouterFlavorResource] = [] + for index, item in enumerate(items): + item_source = f"{source}[{index}]" + if not isinstance(item, dict): + raise ConfigError(f"{item_source} must be an object") + obj = item.get("object", item) + resources.append(_resource_from_object(obj, item_source)) + + return sorted(resources, key=lambda r: str(r.flavor.get("name", ""))) + + +def load_router_flavor_resources() -> list[RouterFlavorResource]: + contexts = read_binding_context() + if not contexts: + raise ConfigError( + f"Shell-operator binding context is required to load {CRD_KIND} objects" + ) + + items = snapshot_items(contexts, CRD_BINDING_NAME) + if items is not None: + return _resources_from_items(items, f"Snapshot {CRD_BINDING_NAME}") + + items = synchronization_items(contexts, CRD_BINDING_NAME) + if items is not None: + return _resources_from_items(items, f"Synchronization {CRD_BINDING_NAME}") + + raise ConfigError( + f"Shell-operator binding context does not contain " + f"{CRD_BINDING_NAME} snapshot or synchronization objects" + ) + + +# --------------------------------------------------------------------------- +# Status patching +# --------------------------------------------------------------------------- + + +def patch_flavor_status( + resource: RouterFlavorResource, + sync_status: str, + message: str, +) -> None: + if not resource.name: + log(f"Unable to patch {CRD_KIND} status; Kubernetes metadata.name is missing") + return + patch_resource_status( + name=resource.name, + namespace=resource.namespace or CRD_NAMESPACE, + generation=resource.generation, + sync_status=sync_status, + message=message, + crd_resource=CRD_RESOURCE, + crd_kind=CRD_KIND, + status_enabled=STATUS_ENABLED, + log_fn=log, + ) + + +# --------------------------------------------------------------------------- +# Reconciliation +# --------------------------------------------------------------------------- + + +def reconcile_router_flavor(event: dict[str, Any]) -> None: + """Reconcile a single NeutronRouterFlavor resource against OpenStack.""" + resource = _resource_from_object(event["object"], "event.object") + conn = get_openstack_connection(resource.secret_name, resource.cloud_name) + sync_flavor(conn, resource.flavor) + + +# --------------------------------------------------------------------------- +# Run loop # --------------------------------------------------------------------------- @@ -122,7 +236,8 @@ def main() -> int: context_path = os.environ.get("BINDING_CONTEXT_PATH") if not context_path: return 0 - with open(context_path) as f: + + with open(context_path, encoding="utf-8") as f: raw = f.read() if not raw.strip(): return 0 @@ -133,13 +248,12 @@ def main() -> int: print(f"failed to parse binding context: {exc}", file=sys.stderr) return 1 - for context in binding_contexts: - binding = context.get("binding", "") - if binding == "neutron-router-flavors": - for item in context.get("objects", []): - reconcile_router_flavor(item) - - return 0 + return dispatch_binding_contexts( + binding_contexts, + CRD_BINDING_NAME, + reconcile_router_flavor, + log_fn=log, + ) if __name__ == "__main__": diff --git a/python/openstack-sync/openstack_sync/plugins/__init__.py b/python/openstack-sync/openstack_sync/plugins/__init__.py new file mode 100644 index 000000000..57add78ab --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/__init__.py @@ -0,0 +1 @@ +"""OpenStack sync plugin implementations.""" diff --git a/python/openstack-sync/openstack_sync/plugins/common.py b/python/openstack-sync/openstack_sync/plugins/common.py new file mode 100644 index 000000000..b77f903ea --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/common.py @@ -0,0 +1,339 @@ +"""Generic utilities shared across all openstack-sync plugins. + +Provides environment helpers, duck-typed OpenStack resource accessors, +meta_info normalisation, exception classifiers, and common API helpers +that are reusable by any plugin regardless of which OpenStack service it +targets. +""" + +from __future__ import annotations + +import ast +import json +import os +import time +from typing import Any + +from openstack_sync.utils import get_openstack_connection + +# --------------------------------------------------------------------------- +# Environment helpers +# --------------------------------------------------------------------------- + + +def env_bool(name: str, default: bool) -> bool: + """Return a boolean from an environment variable. + + Accepts ``1 / true / yes / on`` (case-insensitive) as truthy values. + Returns *default* when the variable is unset. + """ + value = os.environ.get(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def env_tuple(name: str, default: str) -> tuple[str, ...]: + """Return a tuple of strings parsed from a comma-separated env variable.""" + return tuple( + item.strip() + for item in os.environ.get(name, default).split(",") + if item.strip() + ) + + +# --------------------------------------------------------------------------- +# Error type +# --------------------------------------------------------------------------- + + +class ConfigError(Exception): + """Raised when a plugin receives an invalid or incomplete configuration.""" + + +# --------------------------------------------------------------------------- +# Duck-typed OpenStack resource accessors +# --------------------------------------------------------------------------- + +_MISSING = object() + + +def _resource_value(resource: Any, name: str) -> Any: + """Read *name* from *resource* regardless of type. + + Handles dicts, SDK objects with ``.get()``, plain attributes, and objects + with a ``.to_dict()`` method. Returns the ``_MISSING`` sentinel when the + name cannot be found. + """ + if isinstance(resource, dict): + return resource[name] if name in resource else _MISSING + + getter = getattr(resource, "get", None) + if callable(getter): + try: + value = getter(name, _MISSING) + except TypeError: + try: + value = getter(name) + except Exception: + value = _MISSING + except Exception: + value = _MISSING + + if value is not _MISSING: + return value + + value = getattr(resource, name, _MISSING) + if value is not _MISSING: + return value + + try: + data = resource.to_dict(computed=False) + except Exception: + data = {} + + return data[name] if name in data else _MISSING + + +def get_value(resource: Any, *names: str, default: Any = None) -> Any: + """Return the first non-None value found under any of *names* in *resource*. + + Tries each name in turn using :func:`_resource_value`, supporting dicts, + OpenStack SDK objects (which use inconsistent casing like ``id`` vs + ``ID``), and objects with a ``.to_dict()`` method. + """ + for name in names: + value = _resource_value(resource, name) + if value is not _MISSING and value is not None: + return value + return default + + +def resource_id(resource: Any) -> str: + """Return the string ID of an OpenStack resource. + + Tries ``id``, ``ID``, and ``Id`` in that order. + + Raises: + RuntimeError: When no ID field can be found. + """ + value = get_value(resource, "id", "ID", "Id") + if not value: + raise RuntimeError(f"Unable to read ID from resource {resource!r}") + return str(value) + + +# --------------------------------------------------------------------------- +# meta_info helpers +# --------------------------------------------------------------------------- + + +def normalize_meta_info(value: Any) -> Any: + """Normalise a meta_info value into a Python dict (or passthrough). + + Neutron stores ``service_profile.meta_info`` as a JSON string in some SDK + versions and as a dict in others. This function handles both, as well as + Python literal strings produced by older tooling. + """ + if value is None or value == "": + return {} + + if isinstance(value, str): + text = value.strip() + if not text: + return {} + try: + return json.loads(text) + except json.JSONDecodeError: + try: + return ast.literal_eval(text) + except (SyntaxError, ValueError): + return text + + return value + + +def meta_info_payload(value: Any) -> str: + """Return a canonical compact JSON string representation of *value*.""" + normalized = normalize_meta_info(value) + return json.dumps(normalized, sort_keys=True, separators=(",", ":")) + + +def comparable_meta_info(value: Any) -> Any: + """Strip operator-managed keys from *value* before comparison. + + Operator marker keys (e.g. ``_understack_router_flavor_operator``) are + injected at creation time and must not trigger spurious updates when + comparing desired vs current state. The caller is responsible for + passing the set of keys to strip via the module-level constant in the + plugin's ``common`` module. + """ + normalized = normalize_meta_info(value) + if isinstance(normalized, dict): + return {k: v for k, v in normalized.items()} + return normalized + + +def comparable_meta_info_without(value: Any, exclude_keys: frozenset[str]) -> Any: + """Strip *exclude_keys* from *value* before comparison.""" + normalized = normalize_meta_info(value) + if isinstance(normalized, dict): + return {k: v for k, v in normalized.items() if k not in exclude_keys} + return normalized + + +def meta_info_matches_without( + current: Any, desired: Any, exclude_keys: frozenset[str] +) -> bool: + """Return True when *current* and *desired* are logically equal. + + Keys in *exclude_keys* are stripped before comparison. + """ + return meta_info_payload( + comparable_meta_info_without(current, exclude_keys) + ) == meta_info_payload(comparable_meta_info_without(desired, exclude_keys)) + + +def managed_meta_info(value: Any, markers: dict[str, str]) -> Any: + """Merge *markers* into *value*, returning the combined meta_info dict.""" + normalized = normalize_meta_info(value) + if not isinstance(normalized, dict): + return normalized + managed = dict(normalized) + managed.update(markers) + return managed + + +# --------------------------------------------------------------------------- +# Exception classifiers +# --------------------------------------------------------------------------- + + +def is_not_found(exc: Exception) -> bool: + """Return True for 404 / ResourceNotFound exceptions.""" + return getattr(exc, "status_code", None) == 404 or exc.__class__.__name__ in { + "NotFoundException", + "ResourceNotFound", + } + + +def is_conflict(exc: Exception) -> bool: + """Return True for 409 / ConflictException / 'already exists' exceptions.""" + return ( + getattr(exc, "status_code", None) == 409 + or exc.__class__.__name__ in {"ConflictException", "ResourceConflict"} + or "already" in str(exc).lower() + ) + + +# --------------------------------------------------------------------------- +# Config validation +# --------------------------------------------------------------------------- + + +def validate_config(items: Any, source: str) -> list[dict[str, Any]]: + """Validate that *items* is a list of dicts. + + Args: + items: The value to validate. + source: Human-readable label used in error messages. + + Returns: + A shallow copy of the validated list. + + Raises: + ConfigError: When *items* is not a list or contains a non-dict element. + """ + if not isinstance(items, list): + raise ConfigError(f"{source} must be a list") + validated = [] + for index, item in enumerate(items): + if not isinstance(item, dict): + raise ConfigError(f"{source}[{index}] must be an object") + validated.append(dict(item)) + return validated + + +# --------------------------------------------------------------------------- +# OpenStack connection +# --------------------------------------------------------------------------- + + +def connect_openstack(secret_name: str, cloud_name: str) -> Any: + """Return an authenticated OpenStack connection loaded from a K8s Secret. + + Delegates to :func:`openstack_sync.utils.get_openstack_connection` so + credentials are read from Kubernetes rather than a file on disk. + """ + return get_openstack_connection(secret_name, cloud_name) + + +# --------------------------------------------------------------------------- +# Neutron network readiness probe +# --------------------------------------------------------------------------- + + +def wait_for_openstack_network( + conn: Any, + retries: int = 30, + delay: float = 10.0, + log_fn: Any = None, +) -> None: + """Poll until the Neutron network API is reachable. + + Args: + conn: An authenticated OpenStack connection. + retries: Maximum number of attempts before raising. + delay: Seconds to wait between attempts. + log_fn: Optional callable used to emit progress messages. + + Raises: + RuntimeError: When the API does not become ready within *retries*. + """ + for attempt in range(1, retries + 1): + try: + next(iter(conn.network.flavors()), None) + return + except Exception as exc: + if attempt >= retries: + raise RuntimeError( + f"Neutron API did not become ready after {retries} attempt(s)" + ) from exc + if log_fn: + log_fn(f"Waiting for Neutron API ({attempt}/{retries}): {exc}") + time.sleep(delay) + + +# --------------------------------------------------------------------------- +# Service profile helpers +# --------------------------------------------------------------------------- + + +def get_service_profile(conn: Any, profile_id: str) -> Any | None: + """Fetch a service profile by ID, returning None if not found.""" + try: + return conn.network.get_service_profile(profile_id) + except Exception as exc: + if is_not_found(exc): + return None + raise + + +def service_profile_ids(flavor: Any) -> list[str]: + """Return the list of service profile IDs attached to *flavor*. + + Handles the SDK's inconsistent field names (``service_profile_ids``, + ``service_profiles``, ``profiles``) and CSV string representations. + """ + profiles = get_value( + flavor, + "service_profile_ids", + "service_profiles", + "profiles", + default=[], + ) + if profiles is None: + return [] + if isinstance(profiles, str): + return [item.strip() for item in profiles.split(",") if item.strip()] + return [str(profile) for profile in profiles] diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/__init__.py b/python/openstack-sync/openstack_sync/plugins/neutron/__init__.py new file mode 100644 index 000000000..0c5b0ff67 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/__init__.py @@ -0,0 +1 @@ +"""Neutron sync plugin implementations.""" diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/__init__.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/__init__.py new file mode 100644 index 000000000..cd8db3a64 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/__init__.py @@ -0,0 +1 @@ +"""Neutron router flavor sync package.""" diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py new file mode 100644 index 000000000..4f6ee583d --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py @@ -0,0 +1,158 @@ +"""Create helpers for Neutron router flavors and service profiles.""" + +from __future__ import annotations + +from typing import Any + +from openstack_sync.plugins.common import get_service_profile +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import is_conflict +from openstack_sync.plugins.common import meta_info_payload +from openstack_sync.plugins.common import resource_id +from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + comparable_meta_info, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + is_managed_service_profile, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import log +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + managed_flavor_description, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + managed_meta_info, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + meta_info_matches, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + service_profile_meta_info, +) + + +def find_matching_profile(conn: Any, driver: str, meta_info: Any) -> Any | None: + matching_profiles = [] + for profile in conn.network.service_profiles(): + if get_value(profile, "driver", "Driver", default="") != driver: + continue + if meta_info_matches(service_profile_meta_info(profile), meta_info): + matching_profiles.append(profile) + + for profile in matching_profiles: + if is_managed_service_profile(profile): + return profile + + return matching_profiles[0] if matching_profiles else None + + +def _profile_drifted(profile: Any, driver: str, meta_info: Any) -> list[str]: + """Return drift descriptions between *profile* and the desired spec. + + Neutron rejects ``update_service_profile`` with a 409 once the profile is + attached to service instances, so we cannot reconcile drift — but we must + surface it rather than reporting success while spec and reality diverge. + """ + drift = [] + current_driver = get_value(profile, "driver", "Driver", default="") + if current_driver != driver: + drift.append(f"driver: have={current_driver!r} want={driver!r}") + + if not meta_info_matches(service_profile_meta_info(profile), meta_info): + current_meta = meta_info_payload( + comparable_meta_info(service_profile_meta_info(profile)) + ) + desired_meta = meta_info_payload(comparable_meta_info(meta_info)) + drift.append(f"meta_info: have={current_meta} want={desired_meta}") + + return drift + + +def ensure_profile( + conn: Any, + name: str, + driver: str, + description: str, + meta_info: Any, + configured_profile_id: str, +) -> Any: + if configured_profile_id: + profile = get_service_profile(conn, configured_profile_id) + if profile: + profile_id = resource_id(profile) + drift = _profile_drifted(profile, driver, meta_info) + if drift: + log( + f"WARNING: service profile {profile_id} for {name} cannot be " + f"updated (Neutron rejects updates to in-use profiles). " + f"Spec has drifted: {'; '.join(drift)}. " + "To apply changes, detach all routers from this flavor, " + "remove profile_id from the CR, and re-sync." + ) + else: + log(f"Using configured service profile {profile_id} for {name}") + return profile + + log( + f"Configured service profile {configured_profile_id} " + f"for {name} was not found" + ) + + profile = find_matching_profile(conn, driver, meta_info) + if profile: + profile_id = resource_id(profile) + log(f"Reusing service profile {profile_id} for {name}") + return profile + + service_profile_meta = ( + meta_info if configured_profile_id else managed_meta_info(meta_info) + ) + + log(f"Creating service profile for {name} driver={driver}") + return conn.network.create_service_profile( + description=description, + driver=driver, + meta_info=meta_info_payload(service_profile_meta), + is_enabled=True, + ) + + +def find_flavor(conn: Any, name: str) -> Any | None: + # The SDK passes name= as a server-side query parameter (?name=), + # which Neutron filters in SQL — at most one record is returned. The + # equality check guards against a future change to substring/LIKE semantics. + for flavor in conn.network.flavors(name=name): + if get_value(flavor, "name", "Name") == name: + return flavor + return None + + +def create_flavor(conn: Any, name: str, service_type: str, description: str) -> Any: + log(f"Creating router flavor {name} service_type={service_type}") + return conn.network.create_flavor( + name=name, + service_type=service_type, + is_enabled=True, + description=managed_flavor_description(description), + ) + + +def ensure_profile_attached(conn: Any, flavor: Any, profile: Any) -> Any: + flavor = conn.network.get_flavor(flavor) + flavor_id = resource_id(flavor) + profile_id = resource_id(profile) + + if profile_id in service_profile_ids(flavor): + flavor_name = get_value(flavor, "name", "Name", default=flavor_id) + log(f"Router flavor {flavor_name} already has service profile {profile_id}") + return flavor + + log(f"Binding service profile {profile_id} to router flavor {flavor_id}") + try: + conn.network.associate_flavor_with_service_profile(flavor, profile) + except Exception as exc: + if not is_conflict(exc): + raise + log(f"Router flavor {flavor_id} already has service profile {profile_id}") + + return conn.network.get_flavor(flavor) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py new file mode 100644 index 000000000..cbf05292d --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py @@ -0,0 +1,238 @@ +"""Delete/prune logic for removed Neutron router flavors.""" + +from __future__ import annotations + +from typing import Any + +from openstack_sync.plugins.common import get_service_profile +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import is_conflict +from openstack_sync.plugins.common import is_not_found +from openstack_sync.plugins.common import resource_id +from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + DEFAULT_SERVICE_TYPE, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + DELETE_UNUSED_SERVICE_PROFILES, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + PRUNE_DRIVER_PREFIXES, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + PRUNE_REMOVED_FLAVORS, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + is_managed_flavor, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + is_managed_service_profile, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import log + + +def configured_service_profile_ids(flavors: list[dict[str, Any]]) -> set[str]: + return { + str(flavor_config["profile_id"]) + for flavor_config in flavors + if flavor_config.get("profile_id") + } + + +def configured_flavor_names(flavors: list[dict[str, Any]]) -> set[str]: + return { + str(flavor_config["name"]) + for flavor_config in flavors + if flavor_config.get("name") + } + + +def service_profile_driver(profile: Any) -> str: + return str(get_value(profile, "driver", "Driver", default="")) + + +def get_cached_service_profile( + conn: Any, + profile_id: str, + profile_cache: dict[str, Any | None], +) -> Any | None: + if profile_id not in profile_cache: + profile_cache[profile_id] = get_service_profile(conn, profile_id) + return profile_cache[profile_id] + + +def is_prunable_service_profile(profile: Any) -> bool: + driver = service_profile_driver(profile) + return bool(PRUNE_DRIVER_PREFIXES) and any( + driver.startswith(prefix) for prefix in PRUNE_DRIVER_PREFIXES + ) + + +def is_prunable_flavor(conn: Any, flavor: Any) -> bool: + if get_value(flavor, "service_type", "Service Type") != DEFAULT_SERVICE_TYPE: + return False + return is_managed_flavor(flavor) + + +def flavor_has_routers(conn: Any, flavor: Any) -> bool: + flavor_id = resource_id(flavor) + flavor_name = get_value(flavor, "name", "Name", default=flavor_id) + + try: + routers = list(conn.network.routers(flavor_id=flavor_id)) + except Exception as exc: + log( + f"Unable to check routers for removed router flavor {flavor_name}; " + f"skipping deletion: {exc}" + ) + return True + + if routers: + log( + f"Router flavor {flavor_name} is still used by {len(routers)} " + "router(s); skipping deletion" + ) + return True + + return False + + +def service_profile_attached_to_any_flavor(conn: Any, profile_id: str) -> bool: + for flavor in conn.network.flavors(service_type=DEFAULT_SERVICE_TYPE): + if profile_id in service_profile_ids(flavor): + return True + return False + + +def maybe_delete_service_profile( + conn: Any, + profile_id: str, + protected_profile_ids: set[str], + profile_cache: dict[str, Any | None], +) -> None: + if not DELETE_UNUSED_SERVICE_PROFILES: + log(f"Keeping service profile {profile_id}; profile pruning is disabled") + return + + if profile_id in protected_profile_ids: + log( + f"Keeping service profile {profile_id}; it is configured by " + "current router flavor config" + ) + return + + profile = get_cached_service_profile(conn, profile_id, profile_cache) + if not profile: + return + + if not is_prunable_service_profile(profile): + log( + f"Keeping service profile {profile_id}; driver " + f"{service_profile_driver(profile)} is outside prune scope" + ) + return + + if not is_managed_service_profile(profile): + log(f"Keeping service profile {profile_id}; it is not operator-managed") + return + + if service_profile_attached_to_any_flavor(conn, profile_id): + log(f"Keeping service profile {profile_id}; it is still attached") + return + + log(f"Deleting unused service profile {profile_id}") + try: + conn.network.delete_service_profile(profile, ignore_missing=True) + profile_cache[profile_id] = None + except Exception as exc: + if is_not_found(exc): + profile_cache[profile_id] = None + return + if is_conflict(exc): + log(f"Service profile {profile_id} is still in use; skipping delete") + return + raise + + +def delete_removed_flavor( + conn: Any, + flavor: Any, + protected_profile_ids: set[str], + profile_cache: dict[str, Any | None], +) -> None: + flavor_id = resource_id(flavor) + flavor_name = get_value(flavor, "name", "Name", default=flavor_id) + profile_ids = service_profile_ids(flavor) + + if flavor_has_routers(conn, flavor): + return + + log(f"Deleting removed router flavor {flavor_name} ({flavor_id})") + try: + conn.network.delete_flavor(flavor, ignore_missing=True) + except Exception as exc: + if is_not_found(exc): + return + if is_conflict(exc): + log(f"Router flavor {flavor_name} is still in use; skipping delete") + return + raise + + for profile_id in profile_ids: + maybe_delete_service_profile( + conn, profile_id, protected_profile_ids, profile_cache + ) + + +def prune_orphaned_service_profiles( + conn: Any, + protected_profile_ids: set[str], + profile_cache: dict[str, Any | None], +) -> None: + """Delete orphaned operator-managed service profiles. + + Runs after the flavor prune loop to catch profiles left behind when + delete_flavor succeeded but maybe_delete_service_profile threw on the same + run. Safe to run every cycle — only touches operator-owned, unattached + profiles. + """ + log("Scanning for orphaned operator-managed service profiles") + for profile in list(conn.network.service_profiles()): + profile_id = resource_id(profile) + if not is_prunable_service_profile(profile): + continue + if not is_managed_service_profile(profile): + continue + maybe_delete_service_profile( + conn, profile_id, protected_profile_ids, profile_cache + ) + + +def prune_removed_flavors(conn: Any, flavors: list[dict[str, Any]]) -> None: + if not PRUNE_REMOVED_FLAVORS: + log("Router flavor pruning is disabled") + return + + if not flavors: + log( + "No desired router flavors found; skipping prune to avoid deleting " + "all managed router flavors" + ) + return + + desired_names = configured_flavor_names(flavors) + protected_profile_ids = configured_service_profile_ids(flavors) + profile_cache: dict[str, Any | None] = {} + + log("Pruning removed router flavors") + for flavor in list(conn.network.flavors(service_type=DEFAULT_SERVICE_TYPE)): + flavor_name = get_value(flavor, "name", "Name") + if not flavor_name or flavor_name in desired_names: + continue + if not is_prunable_flavor(conn, flavor): + continue + delete_removed_flavor(conn, flavor, protected_profile_ids, profile_cache) + + # Second pass: catch profiles orphaned by a partial failure on a previous + # run (delete_flavor succeeded but maybe_delete_service_profile threw). + prune_orphaned_service_profiles(conn, protected_profile_ids, profile_cache) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py new file mode 100644 index 000000000..763acfa69 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py @@ -0,0 +1,223 @@ +"""Router-flavor-specific constants and helpers. + +Generic utilities (env helpers, resource accessors, meta_info, exception +classifiers, etc.) live in :mod:`openstack_sync.plugins.common`. +""" + +from __future__ import annotations + +import os +import sys +import time +from typing import Any + +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import env_bool +from openstack_sync.plugins.common import env_tuple +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import meta_info_payload +from openstack_sync.plugins.common import normalize_meta_info + +# --------------------------------------------------------------------------- +# Router-flavor CRD identity +# --------------------------------------------------------------------------- +# These four are always injected by the Helm chart from the CRD file via +CRD_API_VERSION = os.environ["NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION"] +CRD_KIND = os.environ["NEUTRON_ROUTER_FLAVOR_CRD_KIND"] +CRD_RESOURCE = os.environ["NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE"] +STATUS_ENABLED = env_bool("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", False) +# Internal shell-operator binding label -- not injected externally. +CRD_BINDING_NAME = os.environ.get( + "NEUTRON_ROUTER_FLAVOR_CRD_BINDING_NAME", + "neutron-router-flavors", +) +CRD_NAMESPACE = os.environ.get("POD_NAMESPACE") +DEFAULT_SERVICE_TYPE = os.environ.get( + "NEUTRON_ROUTER_FLAVOR_SERVICE_TYPE", + "L3_ROUTER_NAT", +) + +# --------------------------------------------------------------------------- +# Prune / lifecycle config +# --------------------------------------------------------------------------- + +PRUNE_REMOVED_FLAVORS = env_bool("NEUTRON_ROUTER_FLAVOR_PRUNE", False) +DELETE_UNUSED_SERVICE_PROFILES = env_bool( + "NEUTRON_ROUTER_FLAVOR_DELETE_UNUSED_PROFILES", + True, +) +PRUNE_DRIVER_PREFIXES = env_tuple( + "NEUTRON_ROUTER_FLAVOR_PRUNE_DRIVER_PREFIXES", + "neutron_understack.l3_router.", +) + +# --------------------------------------------------------------------------- +# Operator ownership markers +# --------------------------------------------------------------------------- + +MANAGED_META_INFO_KEY = os.environ.get( + "NEUTRON_ROUTER_FLAVOR_MANAGED_META_INFO_KEY", + "_understack_router_flavor_operator", +) +MANAGED_META_INFO_VALUE = "managed" +FLAVOR_DESCRIPTION_MARKER = os.environ.get( + "NEUTRON_ROUTER_FLAVOR_DESCRIPTION_MARKER", + "[understack-router-flavor-operator]", +) +MARKER_VERSION_META_INFO_KEY = "_understack_router_flavor_marker_version" +MARKER_VERSION_META_INFO_VALUE = "v1" +MARKER_SOURCE_META_INFO_KEY = "_understack_router_flavor_source" +MARKER_SOURCE_META_INFO_VALUE = os.environ.get( + "NEUTRON_ROUTER_FLAVOR_SOURCE", + CRD_KIND, +) +OPERATOR_META_INFO_MARKERS: dict[str, str] = { + MANAGED_META_INFO_KEY: MANAGED_META_INFO_VALUE, + MARKER_VERSION_META_INFO_KEY: MARKER_VERSION_META_INFO_VALUE, + MARKER_SOURCE_META_INFO_KEY: MARKER_SOURCE_META_INFO_VALUE, +} +OPERATOR_META_INFO_KEYS = frozenset(OPERATOR_META_INFO_MARKERS) + +# --------------------------------------------------------------------------- +# Retry / credential defaults +# --------------------------------------------------------------------------- + +READY_RETRIES = int(os.environ.get("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "30")) +READY_DELAY = float(os.environ.get("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "10")) + +DEFAULT_SECRET = os.environ["NEUTRON_ROUTER_FLAVOR_DEFAULT_SECRET"] +DEFAULT_CLOUD = os.environ["NEUTRON_ROUTER_FLAVOR_DEFAULT_CLOUD"] + + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + + +def log(message: str) -> None: + """Write a prefixed message to stderr.""" + print(f"[router_flavors] {message}", file=sys.stderr) + + +# --------------------------------------------------------------------------- +# meta_info — plugin-specific wrappers that close over OPERATOR_META_INFO_KEYS +# --------------------------------------------------------------------------- + + +def comparable_meta_info(value: Any) -> Any: + """Strip operator marker keys from *value* before comparison.""" + normalized = normalize_meta_info(value) + if isinstance(normalized, dict): + return {k: v for k, v in normalized.items() if k not in OPERATOR_META_INFO_KEYS} + return normalized + + +def meta_info_matches(current: Any, desired: Any) -> bool: + """Return True when *current* and *desired* are logically equal. + + Operator-managed marker keys are ignored during comparison. + """ + return meta_info_payload(comparable_meta_info(current)) == meta_info_payload( + comparable_meta_info(desired) + ) + + +def managed_meta_info(value: Any) -> Any: + """Merge operator ownership markers into *value*.""" + normalized = normalize_meta_info(value) + if not isinstance(normalized, dict): + return normalized + merged = dict(normalized) + merged.update(OPERATOR_META_INFO_MARKERS) + return merged + + +# --------------------------------------------------------------------------- +# Flavor description marker helpers +# --------------------------------------------------------------------------- + + +def clean_flavor_description(value: Any) -> str: + """Return *value* with the operator description marker stripped.""" + description = "" if value is None else str(value) + return description.replace(FLAVOR_DESCRIPTION_MARKER, "").strip() + + +def managed_flavor_description(value: Any) -> str: + """Return *value* with the operator description marker appended.""" + description = clean_flavor_description(value) + if not description: + return FLAVOR_DESCRIPTION_MARKER + return f"{description} {FLAVOR_DESCRIPTION_MARKER}" + + +def flavor_description_has_marker(value: Any) -> bool: + """Return True when *value* contains the operator description marker.""" + return FLAVOR_DESCRIPTION_MARKER in str(value or "") + + +def is_managed_flavor(flavor: Any) -> bool: + """Return True when the flavor's description contains the operator marker.""" + return flavor_description_has_marker( + get_value(flavor, "description", "Description", default="") + ) + + +# --------------------------------------------------------------------------- +# Service profile ownership helpers +# --------------------------------------------------------------------------- + + +def service_profile_meta_info(profile: Any) -> Any: + """Return the meta_info field of *profile*.""" + return get_value(profile, "meta_info", "metainfo", default={}) + + +def is_managed_service_profile(profile: Any) -> bool: + """Return True when the service profile carries the operator ownership marker.""" + meta_info = normalize_meta_info(service_profile_meta_info(profile)) + return ( + isinstance(meta_info, dict) + and meta_info.get(MANAGED_META_INFO_KEY) == MANAGED_META_INFO_VALUE + ) + + +# --------------------------------------------------------------------------- +# Config validation +# --------------------------------------------------------------------------- + + +def config_meta_info(flavor_config: dict[str, Any]) -> Any: + """Extract and validate meta_info from a flavor config dict. + + Raises: + ConfigError: When the deprecated ``metainfo`` key is used instead of + ``meta_info``. + """ + if "metainfo" in flavor_config: + name = flavor_config.get("name", "") + raise ConfigError(f"Router flavor {name} uses metainfo; use meta_info instead") + return flavor_config.get("meta_info", {}) + + +# --------------------------------------------------------------------------- +# Neutron readiness probe — thin wrapper that uses module-level retry config +# --------------------------------------------------------------------------- + + +def wait_for_openstack_network(conn: Any) -> None: + """Poll until the Neutron network API is reachable. + + Uses ``READY_RETRIES`` and ``READY_DELAY`` from this module's env config. + """ + for attempt in range(1, READY_RETRIES + 1): + try: + next(iter(conn.network.flavors()), None) + return + except Exception as exc: + if attempt >= READY_RETRIES: + raise RuntimeError( + f"Neutron API did not become ready after {READY_RETRIES} attempt(s)" + ) from exc + log(f"Waiting for Neutron API ({attempt}/{READY_RETRIES}): {exc}") + time.sleep(READY_DELAY) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py new file mode 100644 index 000000000..44d26c7cf --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py @@ -0,0 +1,83 @@ +"""Update and sync logic for configured Neutron router flavors.""" + +from __future__ import annotations + +import json +from typing import Any + +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors import create +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + DEFAULT_SERVICE_TYPE, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + ConfigError, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + clean_flavor_description, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + config_meta_info, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + flavor_description_has_marker, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import log +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + managed_flavor_description, +) + + +def ensure_flavor(conn: Any, name: str, service_type: str, description: str) -> Any: + flavor = create.find_flavor(conn, name) + managed_description = managed_flavor_description(description) + if flavor: + log(f"Router flavor {name} already exists") + current_description = get_value( + flavor, "description", "Description", default="" + ) + description_changed = clean_flavor_description( + current_description + ) != clean_flavor_description(description) + marker_missing = not flavor_description_has_marker(current_description) + if description_changed or marker_missing: + return conn.network.update_flavor(flavor, description=managed_description) + return flavor + + return create.create_flavor(conn, name, service_type, description) + + +def render_flavor(flavor: Any) -> dict[str, Any]: + return { + "id": get_value(flavor, "id", "ID"), + "name": get_value(flavor, "name", "Name"), + "service_type": get_value(flavor, "service_type", "Service Type"), + "description": get_value(flavor, "description", "Description"), + "service_profile_ids": service_profile_ids(flavor), + } + + +def sync_flavor(conn: Any, flavor_config: dict[str, Any]) -> None: + name = flavor_config.get("name") + driver = flavor_config.get("driver") + if not name or not driver: + raise ConfigError( + f"Each router flavor entry must define name and driver: {flavor_config}" + ) + + description = flavor_config.get("description", "") + profile_description = flavor_config.get("profile_description", description) + service_type = flavor_config.get("service_type", DEFAULT_SERVICE_TYPE) + profile_id = flavor_config.get("profile_id", "") + meta_info = config_meta_info(flavor_config) + + log(f"Reconciling router flavor {name}") + profile = create.ensure_profile( + conn, name, driver, profile_description, meta_info, profile_id + ) + flavor = ensure_flavor(conn, name, service_type, description) + flavor = create.ensure_profile_attached(conn, flavor, profile) + log( + f"Reconciled router flavor: {json.dumps(render_flavor(flavor), sort_keys=True)}" + ) diff --git a/python/openstack-sync/tests/conftest.py b/python/openstack-sync/tests/conftest.py new file mode 100644 index 000000000..34f8421cc --- /dev/null +++ b/python/openstack-sync/tests/conftest.py @@ -0,0 +1,42 @@ +"""Pytest configuration and shared fixtures for openstack-sync tests. + +Sets environment variables that router_flavors_common.py reads at import time +(os.environ[...] fail-fast vars). These must be present before the module is +first imported, so they are set at collection time via a session-scoped +autouse fixture. +""" + +from __future__ import annotations + +import os + +import pytest + +# --------------------------------------------------------------------------- +# Required env vars for router_flavors_common — set before any import +# --------------------------------------------------------------------------- + +_ROUTER_FLAVOR_REQUIRED_ENV = { + "NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION": ( + "neutron.understack.rackspace.net/v1alpha1" + ), + "NEUTRON_ROUTER_FLAVOR_CRD_KIND": "NeutronRouterFlavor", + "NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE": ( + "neutronrouterflavors.neutron.understack.rackspace.net" + ), + "NEUTRON_ROUTER_FLAVOR_DEFAULT_SECRET": "infrasetup", + "NEUTRON_ROUTER_FLAVOR_DEFAULT_CLOUD": "understack", +} + +for _key, _value in _ROUTER_FLAVOR_REQUIRED_ENV.items(): + os.environ.setdefault(_key, _value) + + +@pytest.fixture(autouse=True) +def _router_flavor_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Ensure required router flavor env vars are set for every test. + + Individual tests may override these via their own monkeypatch calls. + """ + for key, value in _ROUTER_FLAVOR_REQUIRED_ENV.items(): + monkeypatch.setenv(key, value) diff --git a/python/openstack-sync/tests/test_hook_common.py b/python/openstack-sync/tests/test_hook_common.py new file mode 100644 index 000000000..35f7977d6 --- /dev/null +++ b/python/openstack-sync/tests/test_hook_common.py @@ -0,0 +1,346 @@ +"""Tests for openstack_sync.hooks.common — generic shell-operator utilities.""" + +from __future__ import annotations + +import json +from unittest import mock + +import pytest + +from openstack_sync.hooks import common as hc + +# --------------------------------------------------------------------------- +# Type coercions +# --------------------------------------------------------------------------- + + +def test_string_or_none_returns_none_for_none(): + assert hc.string_or_none(None) is None + + +def test_string_or_none_converts_value(): + assert hc.string_or_none(42) == "42" + assert hc.string_or_none("hello") == "hello" + + +def test_int_or_none_returns_none_for_none(): + assert hc.int_or_none(None) is None + + +def test_int_or_none_converts_int_string(): + assert hc.int_or_none("7") == 7 + assert hc.int_or_none(3) == 3 + + +def test_int_or_none_returns_none_for_invalid(): + assert hc.int_or_none("not-a-number") is None + assert hc.int_or_none([]) is None + + +# --------------------------------------------------------------------------- +# read_binding_context +# --------------------------------------------------------------------------- + + +def test_read_binding_context_returns_empty_when_no_env(monkeypatch): + monkeypatch.delenv("BINDING_CONTEXT_PATH", raising=False) + assert hc.read_binding_context() == [] + + +def test_read_binding_context_parses_json(monkeypatch, tmp_path): + ctx = [{"binding": "test", "type": "Event"}] + ctx_file = tmp_path / "ctx.json" + ctx_file.write_text(json.dumps(ctx), encoding="utf-8") + monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) + + assert hc.read_binding_context() == ctx + + +def test_read_binding_context_raises_on_non_list(monkeypatch, tmp_path): + ctx_file = tmp_path / "ctx.json" + ctx_file.write_text(json.dumps({"not": "a list"}), encoding="utf-8") + monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) + + with pytest.raises(ValueError, match="must be a list"): + hc.read_binding_context() + + +# --------------------------------------------------------------------------- +# snapshot_items +# --------------------------------------------------------------------------- + + +def test_snapshot_items_returns_items(): + contexts = [ + { + "binding": "schedule", + "snapshots": {"my-binding": [{"object": {"id": "1"}}]}, + } + ] + items = hc.snapshot_items(contexts, "my-binding") + assert items == [{"object": {"id": "1"}}] + + +def test_snapshot_items_returns_none_when_absent(): + contexts = [{"binding": "schedule", "snapshots": {"other": []}}] + assert hc.snapshot_items(contexts, "my-binding") is None + + +def test_snapshot_items_raises_on_non_list(): + contexts = [{"snapshots": {"my-binding": "not-a-list"}}] + with pytest.raises(ValueError, match="must be a list"): + hc.snapshot_items(contexts, "my-binding") + + +# --------------------------------------------------------------------------- +# synchronization_items +# --------------------------------------------------------------------------- + + +def test_synchronization_items_returns_objects(): + contexts = [ + { + "binding": "my-binding", + "type": "Synchronization", + "objects": [{"object": {"id": "1"}}], + } + ] + items = hc.synchronization_items(contexts, "my-binding") + assert items == [{"object": {"id": "1"}}] + + +def test_synchronization_items_returns_none_when_absent(): + contexts = [{"binding": "other", "type": "Synchronization", "objects": []}] + assert hc.synchronization_items(contexts, "my-binding") is None + + +def test_synchronization_items_raises_on_non_list(): + contexts = [{"binding": "my-binding", "type": "Synchronization", "objects": "bad"}] + with pytest.raises(ValueError, match="must be a list"): + hc.synchronization_items(contexts, "my-binding") + + +# --------------------------------------------------------------------------- +# utc_timestamp / truncate_message +# --------------------------------------------------------------------------- + + +def test_utc_timestamp_format(): + ts = hc.utc_timestamp() + assert ts.endswith("Z") + assert "T" in ts + + +def test_truncate_message_short(): + assert hc.truncate_message("hello") == "hello" + + +def test_truncate_message_exact_limit(): + msg = "x" * 2048 + assert hc.truncate_message(msg) == msg + + +def test_truncate_message_truncates(): + msg = "x" * 3000 + result = hc.truncate_message(msg) + assert len(result) == 2048 + assert result.endswith("...") + + +def test_truncate_message_custom_limit(): + result = hc.truncate_message("abcdefgh", max_length=5) + assert result == "ab..." + + +# --------------------------------------------------------------------------- +# patch_resource_status +# --------------------------------------------------------------------------- + + +def test_patch_resource_status_skips_when_disabled(): + logs = [] + hc.patch_resource_status( + name="test-flavor", + namespace="openstack", + generation=1, + sync_status="Synced", + message="ok", + crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", + crd_kind="NeutronRouterFlavor", + status_enabled=False, + log_fn=logs.append, + ) + assert logs == [] + + +def test_patch_resource_status_calls_kubectl(): + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock(returncode=0) + hc.patch_resource_status( + name="test-flavor", + namespace="openstack", + generation=2, + sync_status="Synced", + message="all good", + crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", + crd_kind="NeutronRouterFlavor", + status_enabled=True, + ) + + mock_run.assert_called_once() + cmd = mock_run.call_args[0][0] + assert "kubectl" in cmd + assert "test-flavor" in cmd + assert "-n" in cmd + assert "openstack" in cmd + + +def test_patch_resource_status_no_namespace(): + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock(returncode=0) + hc.patch_resource_status( + name="test-flavor", + namespace=None, + generation=None, + sync_status="Failed", + message="error", + crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", + crd_kind="NeutronRouterFlavor", + status_enabled=True, + ) + + cmd = mock_run.call_args[0][0] + assert "-n" not in cmd + + +def test_patch_resource_status_logs_on_kubectl_not_found(): + logs = [] + with mock.patch("subprocess.run", side_effect=FileNotFoundError): + hc.patch_resource_status( + name="test-flavor", + namespace=None, + generation=None, + sync_status="Synced", + message="ok", + crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", + crd_kind="NeutronRouterFlavor", + status_enabled=True, + log_fn=logs.append, + ) + assert any("kubectl not found" in msg for msg in logs) + + +def test_patch_resource_status_logs_on_kubectl_failure(): + logs = [] + with mock.patch("subprocess.run") as mock_run: + mock_run.return_value = mock.MagicMock( + returncode=1, stderr="not found", stdout="" + ) + hc.patch_resource_status( + name="test-flavor", + namespace="openstack", + generation=None, + sync_status="Synced", + message="ok", + crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", + crd_kind="NeutronRouterFlavor", + status_enabled=True, + log_fn=logs.append, + ) + assert any("failed to patch" in msg for msg in logs) + + +# --------------------------------------------------------------------------- +# dispatch_binding_contexts +# --------------------------------------------------------------------------- + + +def _make_event(binding: str, event_type: str, watch_event: str = "Added") -> dict: + return { + "binding": binding, + "type": event_type, + "watchEvent": watch_event, + "object": {"metadata": {"name": "obj-1"}, "spec": {}}, + } + + +def test_dispatch_synchronization(): + called = [] + contexts = [ + { + "binding": "my-binding", + "type": "Synchronization", + "objects": [ + {"object": {"metadata": {"name": "a"}, "spec": {}}}, + {"object": {"metadata": {"name": "b"}, "spec": {}}}, + ], + } + ] + result = hc.dispatch_binding_contexts( + contexts, "my-binding", lambda item: called.append(item) + ) + assert result == 0 + assert len(called) == 2 + + +def test_dispatch_event_added(): + called = [] + contexts = [_make_event("my-binding", "Event", "Added")] + result = hc.dispatch_binding_contexts( + contexts, "my-binding", lambda item: called.append(item) + ) + assert result == 0 + assert len(called) == 1 + + +def test_dispatch_event_deleted_is_skipped(): + called = [] + contexts = [_make_event("my-binding", "Event", "Deleted")] + result = hc.dispatch_binding_contexts( + contexts, "my-binding", lambda item: called.append(item) + ) + assert result == 0 + assert called == [] + + +def test_dispatch_schedule_snapshot(): + called = [] + contexts = [ + { + "binding": "my-binding", + "type": "Schedule", + "snapshots": { + "my-binding": [ + {"object": {"metadata": {"name": "x"}, "spec": {}}}, + ] + }, + } + ] + result = hc.dispatch_binding_contexts( + contexts, "my-binding", lambda item: called.append(item) + ) + assert result == 0 + assert len(called) == 1 + + +def test_dispatch_ignores_other_bindings(): + called = [] + contexts = [_make_event("other-binding", "Event", "Added")] + result = hc.dispatch_binding_contexts( + contexts, "my-binding", lambda item: called.append(item) + ) + assert result == 0 + assert called == [] + + +def test_dispatch_returns_1_on_reconcile_error(): + logs = [] + contexts = [_make_event("my-binding", "Event", "Added")] + result = hc.dispatch_binding_contexts( + contexts, + "my-binding", + lambda item: (_ for _ in ()).throw(RuntimeError("boom")), + log_fn=logs.append, + ) + assert result == 1 + assert any("reconcile failed" in msg for msg in logs) diff --git a/python/openstack-sync/tests/test_placeholder.py b/python/openstack-sync/tests/test_placeholder.py index 4df7cc7fe..242068046 100644 --- a/python/openstack-sync/tests/test_placeholder.py +++ b/python/openstack-sync/tests/test_placeholder.py @@ -1,12 +1,31 @@ -"""Tests for the openstack-sync placeholder hook.""" +"""Tests for the openstack-sync placeholder hook and shared utils.""" from __future__ import annotations import json from unittest import mock +import pytest + +import openstack_sync.utils as utils from openstack_sync.hooks import placeholder +FAKE_CLOUDS_YAML = """ +clouds: + understack: + auth: + auth_url: https://keystone.example.com/v3 + username: infrasetup + password: secret + project_name: baremetal + region_name: iad3 +""" + + +# --------------------------------------------------------------------------- +# placeholder hook config +# --------------------------------------------------------------------------- + def test_placeholder_hook_config(capsys): with mock.patch.object(placeholder.sys, "argv", ["placeholder.py", "--config"]): @@ -20,3 +39,106 @@ def test_placeholder_hook_config(capsys): def test_placeholder_hook_run_is_noop(): with mock.patch.object(placeholder.sys, "argv", ["placeholder.py"]): assert placeholder.main() == 0 + + +# --------------------------------------------------------------------------- +# utils.read_secret_key +# --------------------------------------------------------------------------- + + +def test_read_secret_key_raises_on_missing_key(): + """read_secret_key propagates KeyError when the key is absent.""" + with mock.patch.object( + utils, "read_secret_key", side_effect=KeyError("clouds.yaml") + ): + with pytest.raises(KeyError): + utils.read_secret_key("infrasetup", "clouds.yaml", "openstack") + + +# --------------------------------------------------------------------------- +# utils.get_openstack_connection +# --------------------------------------------------------------------------- + + +def test_get_openstack_connection_reads_secret(monkeypatch): + """Connection is built from the named K8s secret via read_secret_key.""" + monkeypatch.setattr(utils, "_connection_cache", {}) + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + fake_conn = mock.MagicMock(name="fake_conn") + + with mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=fake_conn, + ): + with mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML): + conn = utils.get_openstack_connection("infrasetup", "understack") + + assert conn is fake_conn + + +def test_get_openstack_connection_memoized(monkeypatch): + """Same (secret_name, cloud_name) returns cached connection.""" + monkeypatch.setattr(utils, "_connection_cache", {}) + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + fake_conn = mock.MagicMock(name="fake_conn") + + with mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=fake_conn, + ) as mock_conn: + with mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML): + conn1 = utils.get_openstack_connection("infrasetup", "understack") + conn2 = utils.get_openstack_connection("infrasetup", "understack") + + assert conn1 is conn2 + mock_conn.assert_called_once() + + +def test_get_openstack_connection_separate_per_secret(monkeypatch): + """Different secrets produce independent connections.""" + monkeypatch.setattr(utils, "_connection_cache", {}) + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + conn_a = mock.MagicMock(name="conn_a") + conn_b = mock.MagicMock(name="conn_b") + bm_yaml = FAKE_CLOUDS_YAML.replace("infrasetup", "baremetal-manage") + + def fake_read(secret_name, secret_key, namespace): + return FAKE_CLOUDS_YAML if secret_name == "infrasetup" else bm_yaml # noqa: S105 + + with mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + side_effect=[conn_a, conn_b], + ): + with mock.patch.object(utils, "read_secret_key", side_effect=fake_read): + result_a = utils.get_openstack_connection("infrasetup", "understack") + result_b = utils.get_openstack_connection("baremetal-manage", "understack") + + assert result_a is conn_a + assert result_b is conn_b + + +# --------------------------------------------------------------------------- +# cloudCredentialsRef resolution (shared behaviour used by all hooks) +# --------------------------------------------------------------------------- + + +def test_get_openstack_connection_uses_per_resource_credentials(monkeypatch): + """Per-resource secretName/cloudName passed through to get_openstack_connection.""" + monkeypatch.setattr(utils, "_connection_cache", {}) + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + fake_conn = mock.MagicMock(name="fake_conn") + + with mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=fake_conn, + ): + with mock.patch.object( + utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML + ) as mock_read: + utils.get_openstack_connection("baremetal-manage", "understack") + + mock_read.assert_called_once_with("baremetal-manage", "clouds.yaml", "openstack") diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py index c500c90ab..179327140 100644 --- a/python/openstack-sync/tests/test_router_flavors.py +++ b/python/openstack-sync/tests/test_router_flavors.py @@ -5,23 +5,9 @@ import json from unittest import mock -import pytest - -import openstack_sync.utils as k8s_module +import openstack_sync.utils as utils from openstack_sync.hooks import router_flavors -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def clear_router_flavor_env(monkeypatch): - monkeypatch.delenv("NEUTRON_ROUTER_FLAVOR_ENABLED", raising=False) - monkeypatch.delenv("NEUTRON_ROUTER_FLAVOR_NAMESPACE", raising=False) - monkeypatch.delenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", raising=False) - monkeypatch.delenv("POD_NAMESPACE", raising=False) - - FAKE_CLOUDS_YAML = """ clouds: understack: @@ -34,13 +20,17 @@ def clear_router_flavor_env(monkeypatch): """ +def _fake_conn(): + return mock.MagicMock(name="fake_conn") + + # --------------------------------------------------------------------------- -# build_hook_config +# build_hook_config — reads env at call time so monkeypatch works directly # --------------------------------------------------------------------------- def test_router_flavor_hook_config_disabled(monkeypatch): - clear_router_flavor_env(monkeypatch) + monkeypatch.delenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", raising=False) config = router_flavors.build_hook_config() @@ -50,160 +40,63 @@ def test_router_flavor_hook_config_disabled(monkeypatch): def test_router_flavor_hook_config_uses_pod_namespace(monkeypatch): - clear_router_flavor_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") monkeypatch.setenv("POD_NAMESPACE", "openstack") config = router_flavors.build_hook_config() - kubernetes_binding = config["kubernetes"][0] - assert kubernetes_binding["namespace"] == { - "nameSelector": { - "matchNames": ["openstack"], - }, + assert config["kubernetes"][0]["namespace"] == { + "nameSelector": {"matchNames": ["openstack"]} } assert config["schedule"][0]["crontab"] == "0 * * * *" assert "onStartup" not in config -def test_router_flavor_hook_config_namespace_override(monkeypatch): - clear_router_flavor_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_NAMESPACE", "custom") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - config = router_flavors.build_hook_config() - - kubernetes_binding = config["kubernetes"][0] - assert kubernetes_binding["namespace"]["nameSelector"]["matchNames"] == ["custom"] - - -def test_router_flavor_hook_config_output_uses_runtime_environment(monkeypatch, capsys): - clear_router_flavor_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") +def test_router_flavor_hook_config_custom_crontab(monkeypatch): monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") + monkeypatch.delenv("POD_NAMESPACE", raising=False) - with mock.patch.object( - router_flavors.sys, "argv", ["router_flavors.py", "--config"] - ): - assert router_flavors.main() == 0 + config = router_flavors.build_hook_config() - config = json.loads(capsys.readouterr().out) - assert config["kubernetes"][0]["namespace"]["nameSelector"]["matchNames"] == [ - "openstack" - ] assert config["schedule"][0]["crontab"] == "*/15 * * * *" def test_router_flavor_hook_config_uses_full_object_filter(monkeypatch): - """JqFilter must be '.' so cloudCredentialsRef is available in the event.""" - clear_router_flavor_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + """JqFilter must be '.' so cloudCredentialsRef is available at reconcile time.""" + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") + monkeypatch.delenv("POD_NAMESPACE", raising=False) config = router_flavors.build_hook_config() assert config["kubernetes"][0]["jqFilter"] == "." -# --------------------------------------------------------------------------- -# k8s.read_secret_key (common module) -# --------------------------------------------------------------------------- - - -def test_read_secret_key_raises_on_missing_key(): - """read_secret_key propagates KeyError when the key is absent.""" - with mock.patch.object( - k8s_module, "read_secret_key", side_effect=KeyError("clouds.yaml") - ): - with pytest.raises(KeyError): - k8s_module.read_secret_key("infrasetup", "clouds.yaml", "openstack") - - -# --------------------------------------------------------------------------- -# k8s.get_openstack_connection (common module, used by all hooks) -# --------------------------------------------------------------------------- - - -def test_get_openstack_connection_reads_secret(monkeypatch): - """Connection is built from the named K8s secret via read_secret_key.""" - monkeypatch.setattr(k8s_module, "_connection_cache", {}) - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - fake_conn = mock.MagicMock(name="fake_conn") - - with mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=fake_conn, - ): - with mock.patch.object( - k8s_module, "read_secret_key", return_value=FAKE_CLOUDS_YAML - ): - conn = k8s_module.get_openstack_connection("infrasetup", "understack") - - assert conn is fake_conn - - -def test_get_openstack_connection_memoized(monkeypatch): - """Same (secret_name, cloud_name) returns cached connection.""" - monkeypatch.setattr(k8s_module, "_connection_cache", {}) - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - fake_conn = mock.MagicMock(name="fake_conn") - - with mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=fake_conn, - ) as mock_conn: - with mock.patch.object( - k8s_module, "read_secret_key", return_value=FAKE_CLOUDS_YAML - ): - conn1 = k8s_module.get_openstack_connection("infrasetup", "understack") - conn2 = k8s_module.get_openstack_connection("infrasetup", "understack") - - assert conn1 is conn2 - mock_conn.assert_called_once() - - -def test_get_openstack_connection_separate_per_secret(monkeypatch): - """Different secrets produce independent connections.""" - monkeypatch.setattr(k8s_module, "_connection_cache", {}) +def test_router_flavor_hook_config_printed_on_config_flag(monkeypatch, capsys): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") monkeypatch.setenv("POD_NAMESPACE", "openstack") - conn_a = mock.MagicMock(name="conn_a") - conn_b = mock.MagicMock(name="conn_b") - - bm_yaml = FAKE_CLOUDS_YAML.replace("infrasetup", "baremetal-manage") - - def fake_read(secret_name, secret_key, namespace): - return FAKE_CLOUDS_YAML if secret_name == "infrasetup" else bm_yaml # noqa: S105 - - with mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - side_effect=[conn_a, conn_b], + with mock.patch.object( + router_flavors.sys, "argv", ["router_flavors.py", "--config"] ): - with mock.patch.object(k8s_module, "read_secret_key", side_effect=fake_read): - result_a = k8s_module.get_openstack_connection("infrasetup", "understack") - result_b = k8s_module.get_openstack_connection( - "baremetal-manage", "understack" - ) + assert router_flavors.main() == 0 - assert result_a is conn_a - assert result_b is conn_b + config = json.loads(capsys.readouterr().out) + assert config["kubernetes"][0]["namespace"]["nameSelector"]["matchNames"] == [ + "openstack" + ] + assert config["schedule"][0]["crontab"] == "*/15 * * * *" # --------------------------------------------------------------------------- -# reconcile_router_flavor +# reconcile_router_flavor — credential resolution + sync delegation # --------------------------------------------------------------------------- -def test_reconcile_router_flavor_reads_credentials_ref(monkeypatch): - """Hook reads secretName + cloudName from spec.cloudCredentialsRef.""" - monkeypatch.setattr(k8s_module, "_connection_cache", {}) +def test_reconcile_uses_cloudcredentialsref(monkeypatch): + """Per-resource cloudCredentialsRef is used to connect to OpenStack.""" + monkeypatch.setattr(utils, "_connection_cache", {}) monkeypatch.setenv("POD_NAMESPACE", "openstack") - fake_conn = mock.MagicMock() - event = { "object": { "metadata": {"name": "test-flavor"}, @@ -220,59 +113,73 @@ def test_reconcile_router_flavor_reads_credentials_ref(monkeypatch): with mock.patch( "openstack_sync.utils.openstack.connection.Connection", - return_value=fake_conn, + return_value=_fake_conn(), ): with mock.patch.object( - k8s_module, "read_secret_key", return_value=FAKE_CLOUDS_YAML + utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML ) as mock_read: - router_flavors.reconcile_router_flavor(event) + with mock.patch("openstack_sync.hooks.router_flavors.sync_flavor"): + router_flavors.reconcile_router_flavor(event) mock_read.assert_called_once_with("baremetal-manage", "clouds.yaml", "openstack") -def test_reconcile_router_flavor_raises_when_creds_ref_missing(): - """Missing cloudCredentialsRef raises ValueError.""" +def test_reconcile_falls_back_to_default_credentials(monkeypatch): + """When cloudCredentialsRef is absent, operator DEFAULT_SECRET/CLOUD are used.""" + monkeypatch.setattr(utils, "_connection_cache", {}) + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DEFAULT_SECRET", "infrasetup") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DEFAULT_CLOUD", "understack") + event = { "object": { - "metadata": {"name": "bad-flavor"}, - "spec": {"name": "bad-flavor", "driver": "some.Driver"}, + "metadata": {"name": "no-ref-flavor"}, + "spec": {"name": "no-ref-flavor", "driver": "some.Driver"}, } } - with pytest.raises(ValueError, match="cloudCredentialsRef"): - router_flavors.reconcile_router_flavor(event) - + with mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=_fake_conn(), + ): + with mock.patch.object( + utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML + ) as mock_read: + with mock.patch("openstack_sync.hooks.router_flavors.sync_flavor"): + router_flavors.reconcile_router_flavor(event) -def test_reconcile_router_flavor_raises_when_secret_name_missing(): - event = { - "object": { - "metadata": {"name": "bad-flavor"}, - "spec": { - "name": "bad-flavor", - "driver": "some.Driver", - "cloudCredentialsRef": {"cloudName": "understack"}, - }, - } - } + mock_read.assert_called_once_with("infrasetup", "clouds.yaml", "openstack") - with pytest.raises(ValueError, match="cloudCredentialsRef"): - router_flavors.reconcile_router_flavor(event) +def test_reconcile_partial_ref_falls_back_per_field(monkeypatch): + """A cloudCredentialsRef with only secretName still uses DEFAULT_CLOUD.""" + monkeypatch.setattr(utils, "_connection_cache", {}) + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DEFAULT_SECRET", "infrasetup") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DEFAULT_CLOUD", "understack") -def test_reconcile_router_flavor_raises_when_cloud_name_missing(): event = { "object": { - "metadata": {"name": "bad-flavor"}, + "metadata": {"name": "partial-flavor"}, "spec": { - "name": "bad-flavor", + "name": "partial-flavor", "driver": "some.Driver", - "cloudCredentialsRef": {"secretName": "baremetal-manage"}, + "cloudCredentialsRef": {"secretName": "custom-secret"}, }, } } - with pytest.raises(ValueError, match="cloudCredentialsRef"): - router_flavors.reconcile_router_flavor(event) + with mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=_fake_conn(), + ): + with mock.patch.object( + utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML + ) as mock_read: + with mock.patch("openstack_sync.hooks.router_flavors.sync_flavor"): + router_flavors.reconcile_router_flavor(event) + + mock_read.assert_called_once_with("custom-secret", "clouds.yaml", "openstack") # --------------------------------------------------------------------------- @@ -280,31 +187,28 @@ def test_reconcile_router_flavor_raises_when_cloud_name_missing(): # --------------------------------------------------------------------------- -def test_main_dispatches_binding_context(monkeypatch, capsys, tmp_path): - monkeypatch.setattr(k8s_module, "_connection_cache", {}) +def test_main_dispatches_to_reconcile(monkeypatch, tmp_path): + """main() reads BINDING_CONTEXT_PATH and dispatches each object.""" + monkeypatch.setattr(utils, "_connection_cache", {}) monkeypatch.setenv("POD_NAMESPACE", "openstack") - fake_conn = mock.MagicMock() - binding_context = json.dumps( [ { "binding": "neutron-router-flavors", - "objects": [ - { - "object": { - "metadata": {"name": "flavor-a"}, - "spec": { - "name": "flavor-a", - "driver": "some.Driver", - "cloudCredentialsRef": { - "secretName": "infrasetup", - "cloudName": "understack", - }, - }, - } - } - ], + "type": "Event", + "watchEvent": "Added", + "object": { + "metadata": {"name": "flavor-a"}, + "spec": { + "name": "flavor-a", + "driver": "some.Driver", + "cloudCredentialsRef": { + "secretName": "infrasetup", + "cloudName": "understack", + }, + }, + }, } ] ) @@ -315,15 +219,19 @@ def test_main_dispatches_binding_context(monkeypatch, capsys, tmp_path): with mock.patch( "openstack_sync.utils.openstack.connection.Connection", - return_value=fake_conn, + return_value=_fake_conn(), ): - with mock.patch.object( - k8s_module, "read_secret_key", return_value=FAKE_CLOUDS_YAML - ): - with mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]): - result = router_flavors.main() + with mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML): + with mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor" + ) as mock_sync: + with mock.patch.object( + router_flavors.sys, "argv", ["router_flavors.py"] + ): + result = router_flavors.main() assert result == 0 + mock_sync.assert_called_once() def test_main_returns_error_on_invalid_json(monkeypatch, capsys, tmp_path): @@ -338,7 +246,7 @@ def test_main_returns_error_on_invalid_json(monkeypatch, capsys, tmp_path): assert "failed to parse binding context" in capsys.readouterr().err -def test_main_returns_zero_on_empty_stdin(monkeypatch, tmp_path): +def test_main_returns_zero_on_empty_context(monkeypatch, tmp_path): ctx_file = tmp_path / "binding_context.json" ctx_file.write_text("") monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) @@ -347,3 +255,12 @@ def test_main_returns_zero_on_empty_stdin(monkeypatch, tmp_path): result = router_flavors.main() assert result == 0 + + +def test_main_returns_zero_when_no_context_path(monkeypatch): + monkeypatch.delenv("BINDING_CONTEXT_PATH", raising=False) + + with mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]): + result = router_flavors.main() + + assert result == 0 diff --git a/python/openstack-sync/tests/test_router_flavors_create.py b/python/openstack-sync/tests/test_router_flavors_create.py new file mode 100644 index 000000000..d7fbbd8e4 --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_create.py @@ -0,0 +1,186 @@ +"""Tests for ensure_profile drift detection in create.py.""" + +from __future__ import annotations + +import types +from typing import Any +from unittest import mock + +from openstack_sync.plugins.neutron.router_flavors import create +from openstack_sync.plugins.neutron.router_flavors import ( + router_flavors_common as common, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_profile( + profile_id: str, + driver: str = "neutron_understack.l3_router.vrf.Vrf", + meta_info: Any = None, + managed: bool = True, +) -> Any: + raw_meta = dict(meta_info or {}) + if managed: + raw_meta.update(common.OPERATOR_META_INFO_MARKERS) + return types.SimpleNamespace( + id=profile_id, + driver=driver, + meta_info=common.meta_info_payload(raw_meta), + ) + + +def _conn_with_profile(profile: Any) -> Any: + network = mock.MagicMock() + network.get_service_profile.return_value = profile + return types.SimpleNamespace(network=network) + + +# --------------------------------------------------------------------------- +# _profile_drifted +# --------------------------------------------------------------------------- + + +def test_no_drift_when_driver_and_meta_info_match(): + profile = _make_profile("p1", driver="some.Driver", meta_info={"vni_alloc": "auto"}) + assert create._profile_drifted(profile, "some.Driver", {"vni_alloc": "auto"}) == [] + + +def test_drift_detected_on_driver_change(): + profile = _make_profile("p1", driver="old.Driver") + drift = create._profile_drifted(profile, "new.Driver", {}) + assert len(drift) == 1 + assert "driver" in drift[0] + assert "old.Driver" in drift[0] + assert "new.Driver" in drift[0] + + +def test_drift_detected_on_meta_info_change(): + profile = _make_profile("p1", meta_info={"vni_alloc": "auto"}) + drift = create._profile_drifted(profile, profile.driver, {"vni_alloc": "on"}) + assert len(drift) == 1 + assert "meta_info" in drift[0] + + +def test_drift_detected_on_both_fields(): + profile = _make_profile("p1", driver="old.Driver", meta_info={"vni_alloc": "auto"}) + drift = create._profile_drifted(profile, "new.Driver", {"vni_alloc": "on"}) + assert len(drift) == 2 + + +def test_drift_ignores_operator_marker_keys(): + """Operator-injected marker keys must not appear as drift. + + The profile in Neutron has OPERATOR_META_INFO_MARKERS merged in at creation + time. The CR spec only carries user-supplied keys. The comparison must + strip marker keys before diffing so a freshly created profile does not + immediately report drift against its own CR. + """ + desired_meta = {"vni_alloc": "auto"} + profile = _make_profile("p1", meta_info=desired_meta, managed=True) + # The profile's stored meta_info includes marker keys; desired_meta does not. + drift = create._profile_drifted(profile, profile.driver, desired_meta) + assert drift == [] + + +# --------------------------------------------------------------------------- +# ensure_profile — configured_profile_id path drift warning +# --------------------------------------------------------------------------- + + +def test_ensure_profile_logs_warning_on_driver_drift(capfd): + """A pinned profile whose driver diverged from the CR emits a WARNING.""" + profile = _make_profile("pinned-id", driver="old.Driver") + conn = _conn_with_profile(profile) + + import io + + buf = io.StringIO() + with mock.patch("sys.stderr", buf): + result = create.ensure_profile( + conn, + name="test-flavor", + driver="new.Driver", + description="desc", + meta_info={}, + configured_profile_id="pinned-id", + ) + + assert result is profile + output = buf.getvalue() + assert "WARNING" in output + assert "driver" in output + assert "old.Driver" in output + assert "new.Driver" in output + + +def test_ensure_profile_logs_warning_on_meta_info_drift(capfd): + """A pinned profile whose meta_info diverged from the CR emits a WARNING.""" + profile = _make_profile("pinned-id", meta_info={"vni_alloc": "auto"}) + conn = _conn_with_profile(profile) + + import io + + buf = io.StringIO() + with mock.patch("sys.stderr", buf): + result = create.ensure_profile( + conn, + name="test-flavor", + driver=profile.driver, + description="desc", + meta_info={"vni_alloc": "on"}, + configured_profile_id="pinned-id", + ) + + assert result is profile + assert "WARNING" in buf.getvalue() + assert "meta_info" in buf.getvalue() + + +def test_ensure_profile_no_warning_when_pinned_profile_matches(): + """A pinned profile that matches the spec emits no WARNING.""" + desired_meta = {"vni_alloc": "auto"} + profile = _make_profile("pinned-id", meta_info=desired_meta, managed=True) + conn = _conn_with_profile(profile) + + import io + + buf = io.StringIO() + with mock.patch("sys.stderr", buf): + create.ensure_profile( + conn, + name="test-flavor", + driver=profile.driver, + description="desc", + meta_info=desired_meta, + configured_profile_id="pinned-id", + ) + + assert "WARNING" not in buf.getvalue() + + +def test_ensure_profile_returns_profile_despite_drift(): + """Even when drift is detected the profile is still returned. + + We cannot fix the drift (Neutron rejects updates on in-use profiles), but + we must not break the reconcile — the flavor should still get bound to the + existing profile so the operator can continue to function. + """ + profile = _make_profile("pinned-id", driver="old.Driver") + conn = _conn_with_profile(profile) + + import io + + with mock.patch("sys.stderr", io.StringIO()): + result = create.ensure_profile( + conn, + name="test-flavor", + driver="new.Driver", + description="desc", + meta_info={}, + configured_profile_id="pinned-id", + ) + + assert result is profile diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py new file mode 100644 index 000000000..e42eeb3e8 --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -0,0 +1,239 @@ +"""Integration-style tests for the Neutron router flavor hook run loop.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest import mock + +import pytest + +import openstack_sync.utils as utils +from openstack_sync.hooks import router_flavors as hook +from openstack_sync.plugins.neutron.router_flavors import ( + router_flavors_common as common, +) + +ROUTER_ENV_NAMES = ( + "BINDING_CONTEXT_PATH", + "NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", + "NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION", + "NEUTRON_ROUTER_FLAVOR_CRD_KIND", + "NEUTRON_ROUTER_FLAVOR_CRD_BINDING_NAME", + "NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE", + "POD_NAMESPACE", +) + +FAKE_CLOUDS_YAML = """ +clouds: + understack: + auth: + auth_url: https://keystone.example.com/v3 + username: infrasetup + password: secret + project_name: baremetal + region_name: iad3 +""" + + +def clear_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ROUTER_ENV_NAMES: + monkeypatch.delenv(name, raising=False) + + +def write_binding_context(path: Path, contexts: list[dict]) -> str: + context_path = path / "binding-context.json" + context_path.write_text(json.dumps(contexts), encoding="utf-8") + return str(context_path) + + +def router_flavor_object(name: str, spec: dict | None = None) -> dict: + flavor_spec = { + "name": name, + "service_type": "L3_ROUTER_NAT", + "description": f"{name} description", + "driver": "neutron_understack.l3_router.vrf.Vrf", + "profile_description": f"{name} profile", + "meta_info": {"vni_alloc": "auto"}, + "cloudCredentialsRef": { + "secretName": "infrasetup", + "cloudName": "understack", + }, + } + flavor_spec.update(spec or {}) + return { + "apiVersion": "neutron.understack.rackspace.net/v1alpha1", + "kind": "NeutronRouterFlavor", + "metadata": { + "name": name, + "namespace": "openstack", + "generation": 3, + }, + "spec": flavor_spec, + } + + +# --------------------------------------------------------------------------- +# HOOK_CONFIG shape +# --------------------------------------------------------------------------- + + +def test_disabled_hook_config_is_valid_noop(monkeypatch, capsys): + clear_env(monkeypatch) + + config = hook.build_hook_config() + + assert config["onStartup"] == 10 + assert "kubernetes" not in config + assert "schedule" not in config + + with mock.patch.object(hook.sys, "argv", ["router_flavors.py", "--config"]): + assert hook.main() == 0 + + assert json.loads(capsys.readouterr().out) == config + + +def test_enabled_hook_config_watches_router_flavors(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + config = hook.build_hook_config() + + binding = config["kubernetes"][0] + assert "onStartup" not in config + assert binding["name"] == common.CRD_BINDING_NAME + assert binding["apiVersion"] == common.CRD_API_VERSION + assert binding["kind"] == common.CRD_KIND + assert binding["executeHookOnEvent"] == ["Added", "Modified", "Deleted"] + assert binding["jqFilter"] == "." + assert binding["includeSnapshotsFrom"] == [common.CRD_BINDING_NAME] + assert binding["namespace"]["nameSelector"]["matchNames"] == ["openstack"] + assert config["schedule"] == [ + { + "name": "hourly sync", + "crontab": "*/15 * * * *", + "includeSnapshotsFrom": [common.CRD_BINDING_NAME], + } + ] + + +# --------------------------------------------------------------------------- +# load_router_flavor_resources — binding context parsing +# --------------------------------------------------------------------------- + + +def test_load_router_flavors_from_snapshot(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": "hourly sync", + "type": "Schedule", + "snapshots": { + common.CRD_BINDING_NAME: [ + { + "object": router_flavor_object( + "dynamic-vrf", + {"name": "dynamic_vrf"}, + ), + }, + ], + }, + }, + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + resources = hook.load_router_flavor_resources() + + assert len(resources) == 1 + assert resources[0].name == "dynamic-vrf" + assert resources[0].namespace == "openstack" + assert resources[0].generation == 3 + assert resources[0].flavor["name"] == "dynamic_vrf" + assert resources[0].flavor["driver"] == "neutron_understack.l3_router.vrf.Vrf" + # cloudCredentialsRef is popped into secret_name / cloud_name + assert resources[0].secret_name == "infrasetup" # noqa: S105 + assert resources[0].cloud_name == "understack" + assert "cloudCredentialsRef" not in resources[0].flavor + + +# --------------------------------------------------------------------------- +# main() dispatches per-object reconciliation +# --------------------------------------------------------------------------- + + +def test_main_reconciles_binding_context_objects(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setattr(utils, "_connection_cache", {}) + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Added", + "object": router_flavor_object("pa1410"), + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + synced = [] + + with mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=mock.MagicMock(), + ): + with mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML): + with mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", + side_effect=lambda conn, flavor: synced.append(flavor["name"]), + ): + with mock.patch.object(hook.sys, "argv", ["router_flavors.py"]): + result = hook.main() + + assert result == 0 + assert synced == ["pa1410"] + + +def test_main_returns_error_when_reconcile_fails(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setattr(utils, "_connection_cache", {}) + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Added", + "object": router_flavor_object("bad-flavor"), + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=mock.MagicMock(), + ): + with mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML): + with mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", + side_effect=RuntimeError("bad flavor config"), + ): + with mock.patch.object(hook.sys, "argv", ["router_flavors.py"]): + result = hook.main() + + assert result == 1 diff --git a/python/openstack-sync/tests/test_router_flavors_prune.py b/python/openstack-sync/tests/test_router_flavors_prune.py new file mode 100644 index 000000000..852cac862 --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_prune.py @@ -0,0 +1,203 @@ +"""Tests for Neutron router flavor prune behavior.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from openstack_sync.plugins.neutron.router_flavors import delete +from openstack_sync.plugins.neutron.router_flavors import ( + router_flavors_common as common, +) + + +class FakeNetwork: + def __init__(self, flavors: list[dict[str, Any]], profiles: dict[str, Any]): + self._flavors = flavors + self._profiles = profiles + self.deleted_flavors: list[str] = [] + + def flavors(self, service_type: str | None = None) -> list[dict[str, Any]]: + return [ + flavor + for flavor in self._flavors + if service_type is None or flavor["service_type"] == service_type + ] + + def routers(self, flavor_id: str) -> list[dict[str, Any]]: + return [] + + def service_profiles(self) -> list[Any]: + return [p for p in self._profiles.values() if p is not None] + + def get_service_profile(self, profile_id: str) -> Any: + return self._profiles.get(profile_id) + + def delete_flavor( + self, flavor: dict[str, Any], ignore_missing: bool = True + ) -> None: + self.deleted_flavors.append(flavor["id"]) + + +def test_prune_keeps_manual_flavor_with_managed_service_profile(monkeypatch): + monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + flavor = { + "id": "manual-flavor-id", + "name": "manual-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": "created outside the operator", + "service_profile_ids": ["managed-profile-id"], + } + profile = SimpleNamespace( + id="managed-profile-id", + driver="neutron_understack.l3_router.vrf.Vrf", + meta_info=common.managed_meta_info({"vni_alloc": "auto"}), + ) + conn = SimpleNamespace(network=FakeNetwork([flavor], {profile.id: profile})) + + delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == [] + + +def test_prune_keeps_managed_flavors_when_desired_list_is_empty(monkeypatch): + monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + flavor = { + "id": "managed-flavor-id", + "name": "removed-managed-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [], + } + conn = SimpleNamespace(network=FakeNetwork([flavor], {})) + + delete.prune_removed_flavors(conn, []) + + assert conn.network.deleted_flavors == [] + + +def test_prune_deletes_removed_managed_flavor(monkeypatch): + monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + flavor = { + "id": "managed-flavor-id", + "name": "removed-managed-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [], + } + conn = SimpleNamespace(network=FakeNetwork([flavor], {})) + + delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == ["managed-flavor-id"] + + +# --------------------------------------------------------------------------- +# prune_orphaned_service_profiles — second-pass GC for partial-failure orphans +# --------------------------------------------------------------------------- + + +class FakeNetworkWithProfiles(FakeNetwork): + """FakeNetwork extended to track service profile deletes.""" + + def __init__( + self, + flavors: list[dict[str, Any]], + profiles: dict[str, Any], + ): + super().__init__(flavors, profiles) + self.deleted_profiles: list[str] = [] + + def service_profiles(self) -> list[Any]: + return [p for p in self._profiles.values() if p is not None] + + def delete_service_profile(self, profile: Any, ignore_missing: bool = True) -> None: + profile_id = profile.id if hasattr(profile, "id") else profile["id"] + self.deleted_profiles.append(profile_id) + self._profiles[profile_id] = None + + def get_service_profile(self, profile_id: str) -> Any: + profile = self._profiles.get(profile_id) + if profile is None: + raise Exception(f"Profile {profile_id} not found") + return profile + + +def _make_orphan_profile( + profile_id: str, driver: str = "neutron_understack.l3_router.vrf.Vrf" +): + """Return a SimpleNamespace service profile with operator ownership markers.""" + import types + + return types.SimpleNamespace( + id=profile_id, + driver=driver, + meta_info=common.managed_meta_info({"vni_alloc": "auto"}), + ) + + +def test_prune_orphaned_profiles_deletes_unattached_managed_profile(monkeypatch): + """A managed profile with no parent flavor is deleted by the second pass.""" + monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) + + orphan = _make_orphan_profile("orphan-profile-id") + # No flavors in Neutron — the orphan's parent was already deleted. + network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) + conn = SimpleNamespace(network=network) + + delete.prune_orphaned_service_profiles(conn, set(), {}) + + assert "orphan-profile-id" in network.deleted_profiles + + +def test_prune_orphaned_profiles_keeps_protected_profile(monkeypatch): + """A profile listed in protected_profile_ids is never deleted.""" + monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) + + orphan = _make_orphan_profile("protected-profile-id") + network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) + conn = SimpleNamespace(network=network) + + delete.prune_orphaned_service_profiles(conn, {"protected-profile-id"}, {}) + + assert network.deleted_profiles == [] + + +def test_prune_orphaned_profiles_keeps_non_managed_profile(monkeypatch): + """A profile without the operator ownership marker is not touched.""" + monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) + import types + + unmanaged = types.SimpleNamespace( + id="unmanaged-profile-id", + driver="neutron_understack.l3_router.vrf.Vrf", + meta_info={"vni_alloc": "auto"}, # no MANAGED_META_INFO_KEY + ) + network = FakeNetworkWithProfiles(flavors=[], profiles={unmanaged.id: unmanaged}) + conn = SimpleNamespace(network=network) + + delete.prune_orphaned_service_profiles(conn, set(), {}) + + assert network.deleted_profiles == [] + + +def test_prune_removed_flavors_cleans_up_orphaned_profile_on_next_run(monkeypatch): + """Simulate a partial failure: flavor deleted, profile cleanup threw last run. + + On the next prune_removed_flavors call the flavor no longer exists in + Neutron, so the flavor loop skips it. The second-pass GC should find and + delete the orphaned profile. + """ + monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) + + # Neutron state after the partial failure: flavor is gone, profile remains. + orphan = _make_orphan_profile("orphan-after-partial-failure") + network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) + conn = SimpleNamespace(network=network) + + # desired list is non-empty so the empty-list guard does not fire. + delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert "orphan-after-partial-failure" in network.deleted_profiles From 174fd3ea70c2dad52ca4b8976b3648a9a158ea8b Mon Sep 17 00:00:00 2001 From: haseeb Date: Wed, 19 Aug 2026 07:16:31 +0530 Subject: [PATCH 02/13] implementing review comments --- ...ck.rackspace.net_neutronrouterflavors.yaml | 10 +- .../openstack-sync-operator/values.yaml | 2 - .../openstack_sync/hooks/common.py | 47 ++-- .../openstack_sync/hooks/placeholder.py | 31 ++- .../openstack_sync/hooks/router_flavors.py | 233 ++++++++++++++---- .../openstack_sync/plugins/common.py | 144 ++++------- .../plugins/neutron/router_flavors/create.py | 67 +++-- .../plugins/neutron/router_flavors/delete.py | 70 +++--- .../router_flavors/router_flavors_common.py | 78 ++---- .../plugins/neutron/router_flavors/update.py | 29 ++- python/openstack-sync/tests/conftest.py | 4 +- .../openstack-sync/tests/test_hook_common.py | 135 ++++++---- .../tests/test_router_flavors.py | 180 ++++++++------ .../tests/test_router_flavors_create.py | 101 +++++--- .../tests/test_router_flavors_hook.py | 210 +++++++++++++--- .../tests/test_router_flavors_prune.py | 4 +- 16 files changed, 843 insertions(+), 502 deletions(-) diff --git a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml index 97eca1cb9..69494f36c 100644 --- a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml +++ b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml @@ -37,6 +37,7 @@ spec: openAPIV3Schema: description: NeutronRouterFlavor defines one Neutron router flavor and its service profile. type: object + additionalProperties: false required: - spec properties: @@ -48,6 +49,7 @@ spec: type: object spec: type: object + additionalProperties: false required: - name - driver @@ -57,8 +59,9 @@ spec: description: >- cloudCredentialsRef points to a Kubernetes Secret containing an OpenStack clouds.yaml file. The operator reads this secret - directly at reconcile time — no volume mount is required. + directly at reconcile time; no volume mount is required. type: object + additionalProperties: false required: - secretName - cloudName @@ -119,8 +122,9 @@ spec: type: string format: uuid meta_info: - description: Service profile metainfo payload. + description: Service profile metadata payload. type: object + additionalProperties: false properties: resource_class: description: Resource class consumed by a physical router provider. @@ -138,6 +142,7 @@ spec: status: description: NeutronRouterFlavorStatus defines the observed sync state. type: object + additionalProperties: false properties: syncStatus: description: SyncStatus indicates the synchronization state with Neutron. @@ -163,6 +168,7 @@ spec: type: array items: type: object + additionalProperties: false required: - type - status diff --git a/components/openstack-sync-operator/values.yaml b/components/openstack-sync-operator/values.yaml index ab89eed9b..37bc0e00f 100644 --- a/components/openstack-sync-operator/values.yaml +++ b/components/openstack-sync-operator/values.yaml @@ -49,8 +49,6 @@ pluginData: env: SYNC_CRONTAB: "0 * * * *" PRUNE: "false" - DEFAULT_SECRET: infrasetup - DEFAULT_CLOUD: understack hooks: {} diff --git a/python/openstack-sync/openstack_sync/hooks/common.py b/python/openstack-sync/openstack_sync/hooks/common.py index f13e6150d..d698f1daf 100644 --- a/python/openstack-sync/openstack_sync/hooks/common.py +++ b/python/openstack-sync/openstack_sync/hooks/common.py @@ -8,12 +8,25 @@ import datetime as dt import json +import logging import os import subprocess import sys from collections.abc import Callable from typing import Any +LOG = logging.getLogger(__name__) + + +def configure_logging() -> None: + """Configure runtime hook logging without affecting --config output.""" + logging.basicConfig( + level=os.environ.get("OPENSTACK_SYNC_LOG_LEVEL", "INFO").upper(), + format="%(levelname)s:%(name)s:%(message)s", + stream=sys.stderr, + ) + + # --------------------------------------------------------------------------- # Type coercions # --------------------------------------------------------------------------- @@ -114,7 +127,6 @@ def patch_resource_status( crd_resource: str, crd_kind: str, status_enabled: bool, - log_fn: Callable[[str], None] = lambda msg: print(msg, file=sys.stderr), ) -> None: """Patch the status subresource of a CR via kubectl. @@ -128,7 +140,6 @@ def patch_resource_status( ``neutronrouterflavors.neutron.understack.rackspace.net``). crd_kind: CRD kind used in log messages (e.g. ``NeutronRouterFlavor``). status_enabled: When False the function returns immediately. - log_fn: Callable used to emit warning messages. """ if not status_enabled: return @@ -176,12 +187,12 @@ def patch_resource_status( text=True, ) except FileNotFoundError: - log_fn(f"WARNING: kubectl not found; unable to patch {crd_kind} status") + LOG.warning("kubectl not found; unable to patch %s status", crd_kind) return if result.returncode != 0: error = (result.stderr or result.stdout or "unknown error").strip() - log_fn(f"WARNING: failed to patch {crd_kind} status for {name}: {error}") + LOG.warning("failed to patch %s status for %s: %s", crd_kind, name, error) # --------------------------------------------------------------------------- @@ -193,7 +204,6 @@ def dispatch_binding_contexts( binding_contexts: list[dict[str, Any]], binding_name: str, reconcile_fn: Callable[[dict[str, Any]], None], - log_fn: Callable[[str], None] = lambda msg: print(msg, file=sys.stderr), ) -> int: """Dispatch each object in *binding_contexts* to *reconcile_fn*. @@ -206,27 +216,29 @@ def dispatch_binding_contexts( binding_contexts: Parsed list from the shell-operator binding context. binding_name: The binding name to filter on. reconcile_fn: Called with each individual event dict ``{"object": ...}``. - log_fn: Callable used to emit error messages. Returns: 0 on success, 1 if any reconciliation raises. """ + failed = False + for context in binding_contexts: binding = context.get("binding", "") - if binding != binding_name: - continue - context_type = context.get("type", "") if context_type == "Synchronization": + if binding != binding_name: + continue for item in context.get("objects", []): try: reconcile_fn(item) except Exception as exc: # noqa: BLE001 - log_fn(f"reconcile failed: {exc}") - return 1 + LOG.error("reconcile failed: %s", exc) + failed = True elif context_type == "Event": + if binding != binding_name: + continue if context.get("watchEvent") == "Deleted": continue obj = context.get("object") @@ -234,17 +246,18 @@ def dispatch_binding_contexts( try: reconcile_fn({"object": obj}) except Exception as exc: # noqa: BLE001 - log_fn(f"reconcile failed: {exc}") - return 1 + LOG.error("reconcile failed: %s", exc) + failed = True else: - # Schedule or other: objects live in the snapshots map + # Schedule bindings use the schedule's name, not the Kubernetes + # binding name. The desired objects live in the snapshots map. snapshots = context.get("snapshots", {}) for item in snapshots.get(binding_name, []): try: reconcile_fn(item) except Exception as exc: # noqa: BLE001 - log_fn(f"reconcile failed: {exc}") - return 1 + LOG.error("reconcile failed: %s", exc) + failed = True - return 0 + return 1 if failed else 0 diff --git a/python/openstack-sync/openstack_sync/hooks/placeholder.py b/python/openstack-sync/openstack_sync/hooks/placeholder.py index 04923e3fb..c940583ab 100644 --- a/python/openstack-sync/openstack_sync/hooks/placeholder.py +++ b/python/openstack-sync/openstack_sync/hooks/placeholder.py @@ -11,12 +11,15 @@ from __future__ import annotations import json +import logging import os import sys from typing import Any +from openstack_sync.hooks.common import configure_logging from openstack_sync.utils import get_openstack_connection +LOG = logging.getLogger(__name__) TRUTHY_VALUES = {"1", "true", "yes", "on"} @@ -52,19 +55,16 @@ def check_openstack_connectivity() -> None: secret_name = os.environ.get("OPENSTACK_PLACEHOLDER_DEFAULT_SECRET") cloud_name = os.environ.get("OPENSTACK_PLACEHOLDER_DEFAULT_CLOUD") - print( - f"connectivity check: authenticating against cloud={cloud_name!r} " - f"secret={secret_name!r}", - flush=True, + LOG.info( + "connectivity check: authenticating against cloud=%r secret=%r", + cloud_name, + secret_name, ) conn = get_openstack_connection(secret_name, cloud_name) # Lightweight probe: check_token(str) -> bool confirms the token is valid # and Keystone is reachable without any side effects. conn.identity.check_token(conn.auth_token) - print( - f"connectivity check: OK cloud={cloud_name!r} secret={secret_name!r}", - flush=True, - ) + LOG.info("connectivity check: OK cloud=%r secret=%r", cloud_name, secret_name) def main() -> int: @@ -72,6 +72,8 @@ def main() -> int: print(json.dumps(build_hook_config(), indent=2)) return 0 + configure_logging() + context_path = os.environ.get("BINDING_CONTEXT_PATH") if not context_path: return 0 @@ -83,27 +85,22 @@ def main() -> int: try: binding_contexts = json.loads(raw) except json.JSONDecodeError as exc: - print(f"failed to parse binding context: {exc}", file=sys.stderr) + LOG.error("failed to parse binding context: %s", exc) return 1 for context in binding_contexts: # Shell-operator passes [{"binding": "onStartup"}] for startup runs. if context.get("binding") == "onStartup": if not env_is_truthy("OPENSTACK_PLACEHOLDER_ENABLED"): - print( + LOG.info( "connectivity check: skipped" - " (OPENSTACK_PLACEHOLDER_ENABLED is not set)", - flush=True, + " (OPENSTACK_PLACEHOLDER_ENABLED is not set)" ) continue try: check_openstack_connectivity() except Exception as exc: # noqa: BLE001 - print( - f"connectivity check FAILED: {exc}", - file=sys.stderr, - flush=True, - ) + LOG.error("connectivity check FAILED: %s", exc) return 1 return 0 diff --git a/python/openstack-sync/openstack_sync/hooks/router_flavors.py b/python/openstack-sync/openstack_sync/hooks/router_flavors.py index 147a3fbd2..c02330044 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -4,18 +4,23 @@ from __future__ import annotations import json +import logging import os import sys from dataclasses import dataclass from typing import Any -from openstack_sync.hooks.common import dispatch_binding_contexts +from openstack_sync.hooks.common import configure_logging from openstack_sync.hooks.common import int_or_none from openstack_sync.hooks.common import patch_resource_status from openstack_sync.hooks.common import read_binding_context from openstack_sync.hooks.common import snapshot_items from openstack_sync.hooks.common import string_or_none from openstack_sync.hooks.common import synchronization_items +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import env_bool +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.neutron.router_flavors.delete import prune_removed_flavors from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( CRD_API_VERSION, ) @@ -29,22 +34,17 @@ from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( CRD_RESOURCE, ) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - DEFAULT_CLOUD, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - DEFAULT_SECRET, -) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( STATUS_ENABLED, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - ConfigError, + wait_for_openstack_network, ) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import log from openstack_sync.plugins.neutron.router_flavors.update import sync_flavor from openstack_sync.utils import get_openstack_connection +LOG = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Resource dataclass # --------------------------------------------------------------------------- @@ -76,14 +76,12 @@ def build_hook_config() -> dict[str, Any]: }, } - is_sync_enabled = bool( - os.environ.get("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "").strip() - ) - if not is_sync_enabled: + if not env_bool("NEUTRON_ROUTER_FLAVOR_ENABLED", False): # Shell-operator requires at least one binding. hook_config["onStartup"] = 10 return hook_config + sync_crontab = os.environ.get("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "").strip() namespace = os.environ.get("POD_NAMESPACE") kubernetes_binding: dict[str, Any] = { "name": CRD_BINDING_NAME, @@ -99,13 +97,14 @@ def build_hook_config() -> dict[str, Any]: } hook_config["kubernetes"] = [kubernetes_binding] - hook_config["schedule"] = [ - { - "name": "hourly sync", - "crontab": os.environ["NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB"], - "includeSnapshotsFrom": [CRD_BINDING_NAME], - } - ] + if sync_crontab: + hook_config["schedule"] = [ + { + "name": "hourly sync", + "crontab": sync_crontab, + "includeSnapshotsFrom": [CRD_BINDING_NAME], + } + ] return hook_config @@ -117,6 +116,19 @@ def build_hook_config() -> dict[str, Any]: # --------------------------------------------------------------------------- +def _required_cloud_credential( + creds_ref: dict[str, Any], + field: str, + source: str, +) -> str: + value = creds_ref.get(field) + if not isinstance(value, str) or not value.strip(): + raise ConfigError( + f"{source} spec.cloudCredentialsRef.{field} must be a non-empty string" + ) + return value.strip() + + def _resource_from_object(obj: Any, source: str) -> RouterFlavorResource: if not isinstance(obj, dict): raise ConfigError(f"{source} object must be a Kubernetes object") @@ -135,12 +147,14 @@ def _resource_from_object(obj: Any, source: str) -> RouterFlavorResource: resource_namespace = string_or_none(metadata.get("namespace")) generation = int_or_none(metadata.get("generation")) - if "name" not in flavor and resource_name: - flavor["name"] = resource_name - - creds_ref = flavor.pop("cloudCredentialsRef", {}) or {} - secret_name = creds_ref.get("secretName") or DEFAULT_SECRET - cloud_name = creds_ref.get("cloudName") or DEFAULT_CLOUD + try: + creds_ref = flavor.pop("cloudCredentialsRef") + except KeyError as exc: + raise ConfigError(f"{source} spec.cloudCredentialsRef is required") from exc + if not isinstance(creds_ref, dict): + raise ConfigError(f"{source} spec.cloudCredentialsRef must be an object") + secret_name = _required_cloud_credential(creds_ref, "secretName", source) + cloud_name = _required_cloud_credential(creds_ref, "cloudName", source) return RouterFlavorResource( flavor=flavor, @@ -164,13 +178,9 @@ def _resources_from_items(items: list[Any], source: str) -> list[RouterFlavorRes return sorted(resources, key=lambda r: str(r.flavor.get("name", ""))) -def load_router_flavor_resources() -> list[RouterFlavorResource]: - contexts = read_binding_context() - if not contexts: - raise ConfigError( - f"Shell-operator binding context is required to load {CRD_KIND} objects" - ) - +def router_flavor_resources_from_binding_context( + contexts: list[dict[str, Any]], +) -> list[RouterFlavorResource] | None: items = snapshot_items(contexts, CRD_BINDING_NAME) if items is not None: return _resources_from_items(items, f"Snapshot {CRD_BINDING_NAME}") @@ -179,6 +189,23 @@ def load_router_flavor_resources() -> list[RouterFlavorResource]: if items is not None: return _resources_from_items(items, f"Synchronization {CRD_BINDING_NAME}") + return None + + +def load_router_flavor_resources( + contexts: list[dict[str, Any]] | None = None, +) -> list[RouterFlavorResource]: + if contexts is None: + contexts = read_binding_context() + if not contexts: + raise ConfigError( + f"Shell-operator binding context is required to load {CRD_KIND} objects" + ) + + resources = router_flavor_resources_from_binding_context(contexts) + if resources is not None: + return resources + raise ConfigError( f"Shell-operator binding context does not contain " f"{CRD_BINDING_NAME} snapshot or synchronization objects" @@ -196,7 +223,10 @@ def patch_flavor_status( message: str, ) -> None: if not resource.name: - log(f"Unable to patch {CRD_KIND} status; Kubernetes metadata.name is missing") + LOG.warning( + "Unable to patch %s status; Kubernetes metadata.name is missing", + CRD_KIND, + ) return patch_resource_status( name=resource.name, @@ -207,7 +237,6 @@ def patch_flavor_status( crd_resource=CRD_RESOURCE, crd_kind=CRD_KIND, status_enabled=STATUS_ENABLED, - log_fn=log, ) @@ -216,11 +245,121 @@ def patch_flavor_status( # --------------------------------------------------------------------------- +def _resource_display_name(resource: RouterFlavorResource) -> str: + return str(get_value(resource.flavor, "name", default=resource.name or "")) + + +def _resources_by_credentials( + resources: list[RouterFlavorResource], +) -> dict[tuple[str, str], list[RouterFlavorResource]]: + grouped: dict[tuple[str, str], list[RouterFlavorResource]] = {} + for resource in resources: + key = (resource.secret_name, resource.cloud_name) + grouped.setdefault(key, []).append(resource) + return grouped + + +def _mark_resources_failed( + resources: list[RouterFlavorResource], + message: str, +) -> None: + for resource in resources: + patch_flavor_status(resource, "Failed", message) + + +def reconcile_router_flavor_resource(conn: Any, resource: RouterFlavorResource) -> None: + sync_flavor(conn, resource.flavor) + + def reconcile_router_flavor(event: dict[str, Any]) -> None: """Reconcile a single NeutronRouterFlavor resource against OpenStack.""" resource = _resource_from_object(event["object"], "event.object") conn = get_openstack_connection(resource.secret_name, resource.cloud_name) - sync_flavor(conn, resource.flavor) + try: + wait_for_openstack_network(conn) + reconcile_router_flavor_resource(conn, resource) + except Exception as exc: + patch_flavor_status(resource, "Failed", str(exc)) + raise + patch_flavor_status(resource, "Synced", "Successfully reconciled router flavor") + + +def reconcile_router_flavor_resources(resources: list[RouterFlavorResource]) -> int: + flavors = [resource.flavor for resource in resources] + LOG.info("Found %s router flavor(s) to reconcile", len(flavors)) + + grouped_resources = _resources_by_credentials(resources) + connections: dict[tuple[str, str], Any] = {} + failed_resources: list[RouterFlavorResource] = [] + + for credentials, credential_resources in grouped_resources.items(): + secret_name, cloud_name = credentials + try: + conn = get_openstack_connection(secret_name, cloud_name) + except Exception as exc: # noqa: BLE001 + failed_resources.extend(credential_resources) + message = f"OpenStack connection failed: {exc}" + _mark_resources_failed(credential_resources, message) + LOG.error( + "Failed to connect to OpenStack cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + + connections[credentials] = conn + try: + wait_for_openstack_network(conn) + except Exception as exc: # noqa: BLE001 + failed_resources.extend(credential_resources) + _mark_resources_failed( + credential_resources, + f"Neutron API unavailable: {exc}", + ) + LOG.error( + "Neutron API unavailable for cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + + for resource in credential_resources: + try: + reconcile_router_flavor_resource(conn, resource) + except Exception as exc: # noqa: BLE001 + failed_resources.append(resource) + patch_flavor_status(resource, "Failed", str(exc)) + LOG.error( + "Failed to reconcile router flavor %s: %s", + _resource_display_name(resource), + exc, + ) + continue + + patch_flavor_status( + resource, + "Synced", + "Successfully reconciled router flavor", + ) + + if failed_resources: + LOG.error( + "Skipping router flavor prune because %s flavor(s) failed to reconcile", + len(failed_resources), + ) + return 1 + + for credentials, credential_resources in grouped_resources.items(): + conn = connections[credentials] + prune_removed_flavors( + conn, + [resource.flavor for resource in credential_resources], + ) + + LOG.info("Finished reconciling router flavors") + return 0 # --------------------------------------------------------------------------- @@ -233,6 +372,12 @@ def main() -> int: print(json.dumps(build_hook_config(), indent=2)) return 0 + configure_logging() + + if not env_bool("NEUTRON_ROUTER_FLAVOR_ENABLED", False): + LOG.info("Router flavor sync is disabled") + return 0 + context_path = os.environ.get("BINDING_CONTEXT_PATH") if not context_path: return 0 @@ -245,15 +390,17 @@ def main() -> int: try: binding_contexts = json.loads(raw) except json.JSONDecodeError as exc: - print(f"failed to parse binding context: {exc}", file=sys.stderr) + LOG.error("failed to parse binding context: %s", exc) return 1 - return dispatch_binding_contexts( - binding_contexts, - CRD_BINDING_NAME, - reconcile_router_flavor, - log_fn=log, - ) + try: + if not isinstance(binding_contexts, list): + raise ConfigError("Shell-operator binding context must be a list") + resources = load_router_flavor_resources(binding_contexts) + return reconcile_router_flavor_resources(resources) + except Exception as exc: # noqa: BLE001 + LOG.error("%s", exc) + return 1 if __name__ == "__main__": diff --git a/python/openstack-sync/openstack_sync/plugins/common.py b/python/openstack-sync/openstack_sync/plugins/common.py index b77f903ea..acf50ecab 100644 --- a/python/openstack-sync/openstack_sync/plugins/common.py +++ b/python/openstack-sync/openstack_sync/plugins/common.py @@ -1,6 +1,6 @@ """Generic utilities shared across all openstack-sync plugins. -Provides environment helpers, duck-typed OpenStack resource accessors, +Provides environment helpers, OpenStack SDK resource accessors, meta_info normalisation, exception classifiers, and common API helpers that are reusable by any plugin regardless of which OpenStack service it targets. @@ -8,14 +8,18 @@ from __future__ import annotations -import ast import json +import logging import os import time from typing import Any +from openstack import exceptions as openstack_exceptions + from openstack_sync.utils import get_openstack_connection +LOG = logging.getLogger(__name__) + # --------------------------------------------------------------------------- # Environment helpers # --------------------------------------------------------------------------- @@ -52,72 +56,61 @@ class ConfigError(Exception): # --------------------------------------------------------------------------- -# Duck-typed OpenStack resource accessors +# OpenStack SDK resource accessors # --------------------------------------------------------------------------- _MISSING = object() +def _mapping_value(mapping: dict[str, Any], name: str) -> Any: + """Read *name* from a mapping without invoking default values.""" + try: + return mapping[name] + except KeyError: + return _MISSING + + +def _attribute_value(resource: Any, name: str) -> Any: + """Read *name* through attribute access.""" + try: + return getattr(resource, name) + except AttributeError: + return _MISSING + + def _resource_value(resource: Any, name: str) -> Any: """Read *name* from *resource* regardless of type. - Handles dicts, SDK objects with ``.get()``, plain attributes, and objects - with a ``.to_dict()`` method. Returns the ``_MISSING`` sentinel when the - name cannot be found. + Plain dicts are the operator contract and are read by exact key. + OpenStack resources are read through their openstacksdk attribute names, + for example ``meta_info`` and ``service_profile_ids``. Neutron wire names + are mapped by openstacksdk before this layer reads them. """ - if isinstance(resource, dict): - return resource[name] if name in resource else _MISSING + if type(resource) is dict: + return _mapping_value(resource, name) - getter = getattr(resource, "get", None) - if callable(getter): - try: - value = getter(name, _MISSING) - except TypeError: - try: - value = getter(name) - except Exception: - value = _MISSING - except Exception: - value = _MISSING - - if value is not _MISSING: - return value - - value = getattr(resource, name, _MISSING) + value = _attribute_value(resource, name) if value is not _MISSING: return value - try: - data = resource.to_dict(computed=False) - except Exception: - data = {} - - return data[name] if name in data else _MISSING - + return _MISSING -def get_value(resource: Any, *names: str, default: Any = None) -> Any: - """Return the first non-None value found under any of *names* in *resource*. - Tries each name in turn using :func:`_resource_value`, supporting dicts, - OpenStack SDK objects (which use inconsistent casing like ``id`` vs - ``ID``), and objects with a ``.to_dict()`` method. - """ - for name in names: - value = _resource_value(resource, name) - if value is not _MISSING and value is not None: - return value +def get_value(resource: Any, name: str, default: Any = None) -> Any: + """Return a non-None value from *resource* by canonical field name.""" + value = _resource_value(resource, name) + if value is not _MISSING and value is not None: + return value return default def resource_id(resource: Any) -> str: """Return the string ID of an OpenStack resource. - Tries ``id``, ``ID``, and ``Id`` in that order. - Raises: RuntimeError: When no ID field can be found. """ - value = get_value(resource, "id", "ID", "Id") + value = get_value(resource, "id") if not value: raise RuntimeError(f"Unable to read ID from resource {resource!r}") return str(value) @@ -131,9 +124,10 @@ def resource_id(resource: Any) -> str: def normalize_meta_info(value: Any) -> Any: """Normalise a meta_info value into a Python dict (or passthrough). - Neutron stores ``service_profile.meta_info`` as a JSON string in some SDK - versions and as a dict in others. This function handles both, as well as - Python literal strings produced by older tooling. + The operator uses the openstacksdk field name ``meta_info``. Neutron + stores that value as JSON text, so existing service profiles may return a + string while desired specs provide a dict. Non-JSON strings pass through + unchanged so drift reports can show the raw value. """ if value is None or value == "": return {} @@ -145,10 +139,7 @@ def normalize_meta_info(value: Any) -> Any: try: return json.loads(text) except json.JSONDecodeError: - try: - return ast.literal_eval(text) - except (SyntaxError, ValueError): - return text + return text return value @@ -159,21 +150,6 @@ def meta_info_payload(value: Any) -> str: return json.dumps(normalized, sort_keys=True, separators=(",", ":")) -def comparable_meta_info(value: Any) -> Any: - """Strip operator-managed keys from *value* before comparison. - - Operator marker keys (e.g. ``_understack_router_flavor_operator``) are - injected at creation time and must not trigger spurious updates when - comparing desired vs current state. The caller is responsible for - passing the set of keys to strip via the module-level constant in the - plugin's ``common`` module. - """ - normalized = normalize_meta_info(value) - if isinstance(normalized, dict): - return {k: v for k, v in normalized.items()} - return normalized - - def comparable_meta_info_without(value: Any, exclude_keys: frozenset[str]) -> Any: """Strip *exclude_keys* from *value* before comparison.""" normalized = normalize_meta_info(value) @@ -210,20 +186,13 @@ def managed_meta_info(value: Any, markers: dict[str, str]) -> Any: def is_not_found(exc: Exception) -> bool: - """Return True for 404 / ResourceNotFound exceptions.""" - return getattr(exc, "status_code", None) == 404 or exc.__class__.__name__ in { - "NotFoundException", - "ResourceNotFound", - } + """Return True for openstacksdk 404 exceptions.""" + return isinstance(exc, openstack_exceptions.NotFoundException) def is_conflict(exc: Exception) -> bool: - """Return True for 409 / ConflictException / 'already exists' exceptions.""" - return ( - getattr(exc, "status_code", None) == 409 - or exc.__class__.__name__ in {"ConflictException", "ResourceConflict"} - or "already" in str(exc).lower() - ) + """Return True for openstacksdk 409 exceptions.""" + return isinstance(exc, openstack_exceptions.ConflictException) # --------------------------------------------------------------------------- @@ -277,7 +246,6 @@ def wait_for_openstack_network( conn: Any, retries: int = 30, delay: float = 10.0, - log_fn: Any = None, ) -> None: """Poll until the Neutron network API is reachable. @@ -285,7 +253,6 @@ def wait_for_openstack_network( conn: An authenticated OpenStack connection. retries: Maximum number of attempts before raising. delay: Seconds to wait between attempts. - log_fn: Optional callable used to emit progress messages. Raises: RuntimeError: When the API does not become ready within *retries*. @@ -299,8 +266,7 @@ def wait_for_openstack_network( raise RuntimeError( f"Neutron API did not become ready after {retries} attempt(s)" ) from exc - if log_fn: - log_fn(f"Waiting for Neutron API ({attempt}/{retries}): {exc}") + LOG.info("Waiting for Neutron API (%s/%s): %s", attempt, retries, exc) time.sleep(delay) @@ -322,18 +288,12 @@ def get_service_profile(conn: Any, profile_id: str) -> Any | None: def service_profile_ids(flavor: Any) -> list[str]: """Return the list of service profile IDs attached to *flavor*. - Handles the SDK's inconsistent field names (``service_profile_ids``, - ``service_profiles``, ``profiles``) and CSV string representations. + The openstacksdk ``Flavor.service_profile_ids`` attribute maps Neutron's + ``service_profiles`` wire field. """ - profiles = get_value( - flavor, - "service_profile_ids", - "service_profiles", - "profiles", - default=[], - ) + profiles = get_value(flavor, "service_profile_ids", default=[]) if profiles is None: return [] - if isinstance(profiles, str): - return [item.strip() for item in profiles.split(",") if item.strip()] + if not isinstance(profiles, list): + raise TypeError("flavor.service_profile_ids must be a list") return [str(profile) for profile in profiles] diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py index 4f6ee583d..1a2332918 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py @@ -2,8 +2,10 @@ from __future__ import annotations +import logging from typing import Any +from openstack_sync.plugins.common import ConfigError from openstack_sync.plugins.common import get_service_profile from openstack_sync.plugins.common import get_value from openstack_sync.plugins.common import is_conflict @@ -16,7 +18,6 @@ from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( is_managed_service_profile, ) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import log from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( managed_flavor_description, ) @@ -30,11 +31,13 @@ service_profile_meta_info, ) +LOG = logging.getLogger(__name__) + def find_matching_profile(conn: Any, driver: str, meta_info: Any) -> Any | None: matching_profiles = [] for profile in conn.network.service_profiles(): - if get_value(profile, "driver", "Driver", default="") != driver: + if get_value(profile, "driver", default="") != driver: continue if meta_info_matches(service_profile_meta_info(profile), meta_info): matching_profiles.append(profile) @@ -50,11 +53,11 @@ def _profile_drifted(profile: Any, driver: str, meta_info: Any) -> list[str]: """Return drift descriptions between *profile* and the desired spec. Neutron rejects ``update_service_profile`` with a 409 once the profile is - attached to service instances, so we cannot reconcile drift — but we must + attached to service instances, so we cannot reconcile drift. We still surface it rather than reporting success while spec and reality diverge. """ drift = [] - current_driver = get_value(profile, "driver", "Driver", default="") + current_driver = get_value(profile, "driver", default="") if current_driver != driver: drift.append(f"driver: have={current_driver!r} want={driver!r}") @@ -82,18 +85,26 @@ def ensure_profile( profile_id = resource_id(profile) drift = _profile_drifted(profile, driver, meta_info) if drift: - log( - f"WARNING: service profile {profile_id} for {name} cannot be " - f"updated (Neutron rejects updates to in-use profiles). " - f"Spec has drifted: {'; '.join(drift)}. " - "To apply changes, detach all routers from this flavor, " - "remove profile_id from the CR, and re-sync." + LOG.warning( + "service profile %s for %s cannot be updated " + "(Neutron rejects updates to in-use profiles). " + "Spec has drifted: %s. To apply changes, detach all " + "routers from this flavor, remove profile_id from the CR, " + "and re-sync.", + profile_id, + name, + "; ".join(drift), ) else: - log(f"Using configured service profile {profile_id} for {name}") + LOG.info("Using configured service profile %s for %s", profile_id, name) return profile - log( + LOG.error( + "Configured service profile %s for %s was not found", + configured_profile_id, + name, + ) + raise ConfigError( f"Configured service profile {configured_profile_id} " f"for {name} was not found" ) @@ -101,34 +112,30 @@ def ensure_profile( profile = find_matching_profile(conn, driver, meta_info) if profile: profile_id = resource_id(profile) - log(f"Reusing service profile {profile_id} for {name}") + LOG.info("Reusing service profile %s for %s", profile_id, name) return profile - service_profile_meta = ( - meta_info if configured_profile_id else managed_meta_info(meta_info) - ) - - log(f"Creating service profile for {name} driver={driver}") + LOG.info("Creating service profile for %s driver=%s", name, driver) return conn.network.create_service_profile( description=description, driver=driver, - meta_info=meta_info_payload(service_profile_meta), + meta_info=meta_info_payload(managed_meta_info(meta_info)), is_enabled=True, ) def find_flavor(conn: Any, name: str) -> Any | None: # The SDK passes name= as a server-side query parameter (?name=), - # which Neutron filters in SQL — at most one record is returned. The + # which Neutron filters in SQL, so at most one record is returned. The # equality check guards against a future change to substring/LIKE semantics. for flavor in conn.network.flavors(name=name): - if get_value(flavor, "name", "Name") == name: + if get_value(flavor, "name") == name: return flavor return None def create_flavor(conn: Any, name: str, service_type: str, description: str) -> Any: - log(f"Creating router flavor {name} service_type={service_type}") + LOG.info("Creating router flavor %s service_type=%s", name, service_type) return conn.network.create_flavor( name=name, service_type=service_type, @@ -143,16 +150,24 @@ def ensure_profile_attached(conn: Any, flavor: Any, profile: Any) -> Any: profile_id = resource_id(profile) if profile_id in service_profile_ids(flavor): - flavor_name = get_value(flavor, "name", "Name", default=flavor_id) - log(f"Router flavor {flavor_name} already has service profile {profile_id}") + flavor_name = get_value(flavor, "name", default=flavor_id) + LOG.info( + "Router flavor %s already has service profile %s", + flavor_name, + profile_id, + ) return flavor - log(f"Binding service profile {profile_id} to router flavor {flavor_id}") + LOG.info("Binding service profile %s to router flavor %s", profile_id, flavor_id) try: conn.network.associate_flavor_with_service_profile(flavor, profile) except Exception as exc: if not is_conflict(exc): raise - log(f"Router flavor {flavor_id} already has service profile {profile_id}") + LOG.info( + "Router flavor %s already has service profile %s", + flavor_id, + profile_id, + ) return conn.network.get_flavor(flavor) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py index cbf05292d..999dd4bf5 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging from typing import Any from openstack_sync.plugins.common import get_service_profile @@ -28,7 +29,8 @@ from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( is_managed_service_profile, ) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import log + +LOG = logging.getLogger(__name__) def configured_service_profile_ids(flavors: list[dict[str, Any]]) -> set[str]: @@ -48,7 +50,7 @@ def configured_flavor_names(flavors: list[dict[str, Any]]) -> set[str]: def service_profile_driver(profile: Any) -> str: - return str(get_value(profile, "driver", "Driver", default="")) + return str(get_value(profile, "driver", default="")) def get_cached_service_profile( @@ -69,28 +71,31 @@ def is_prunable_service_profile(profile: Any) -> bool: def is_prunable_flavor(conn: Any, flavor: Any) -> bool: - if get_value(flavor, "service_type", "Service Type") != DEFAULT_SERVICE_TYPE: + if get_value(flavor, "service_type") != DEFAULT_SERVICE_TYPE: return False return is_managed_flavor(flavor) def flavor_has_routers(conn: Any, flavor: Any) -> bool: flavor_id = resource_id(flavor) - flavor_name = get_value(flavor, "name", "Name", default=flavor_id) + flavor_name = get_value(flavor, "name", default=flavor_id) try: routers = list(conn.network.routers(flavor_id=flavor_id)) except Exception as exc: - log( - f"Unable to check routers for removed router flavor {flavor_name}; " - f"skipping deletion: {exc}" + LOG.warning( + "Unable to check routers for removed router flavor %s; " + "skipping deletion: %s", + flavor_name, + exc, ) return True if routers: - log( - f"Router flavor {flavor_name} is still used by {len(routers)} " - "router(s); skipping deletion" + LOG.info( + "Router flavor %s is still used by %s router(s); skipping deletion", + flavor_name, + len(routers), ) return True @@ -111,13 +116,14 @@ def maybe_delete_service_profile( profile_cache: dict[str, Any | None], ) -> None: if not DELETE_UNUSED_SERVICE_PROFILES: - log(f"Keeping service profile {profile_id}; profile pruning is disabled") + LOG.info("Keeping service profile %s; profile pruning is disabled", profile_id) return if profile_id in protected_profile_ids: - log( - f"Keeping service profile {profile_id}; it is configured by " - "current router flavor config" + LOG.info( + "Keeping service profile %s; it is configured by current router flavor " + "config", + profile_id, ) return @@ -126,21 +132,22 @@ def maybe_delete_service_profile( return if not is_prunable_service_profile(profile): - log( - f"Keeping service profile {profile_id}; driver " - f"{service_profile_driver(profile)} is outside prune scope" + LOG.info( + "Keeping service profile %s; driver %s is outside prune scope", + profile_id, + service_profile_driver(profile), ) return if not is_managed_service_profile(profile): - log(f"Keeping service profile {profile_id}; it is not operator-managed") + LOG.info("Keeping service profile %s; it is not operator-managed", profile_id) return if service_profile_attached_to_any_flavor(conn, profile_id): - log(f"Keeping service profile {profile_id}; it is still attached") + LOG.info("Keeping service profile %s; it is still attached", profile_id) return - log(f"Deleting unused service profile {profile_id}") + LOG.info("Deleting unused service profile %s", profile_id) try: conn.network.delete_service_profile(profile, ignore_missing=True) profile_cache[profile_id] = None @@ -149,7 +156,7 @@ def maybe_delete_service_profile( profile_cache[profile_id] = None return if is_conflict(exc): - log(f"Service profile {profile_id} is still in use; skipping delete") + LOG.info("Service profile %s is still in use; skipping delete", profile_id) return raise @@ -161,20 +168,23 @@ def delete_removed_flavor( profile_cache: dict[str, Any | None], ) -> None: flavor_id = resource_id(flavor) - flavor_name = get_value(flavor, "name", "Name", default=flavor_id) + flavor_name = get_value(flavor, "name", default=flavor_id) profile_ids = service_profile_ids(flavor) if flavor_has_routers(conn, flavor): return - log(f"Deleting removed router flavor {flavor_name} ({flavor_id})") + LOG.info("Deleting removed router flavor %s (%s)", flavor_name, flavor_id) try: conn.network.delete_flavor(flavor, ignore_missing=True) except Exception as exc: if is_not_found(exc): return if is_conflict(exc): - log(f"Router flavor {flavor_name} is still in use; skipping delete") + LOG.info( + "Router flavor %s is still in use; skipping delete", + flavor_name, + ) return raise @@ -193,10 +203,10 @@ def prune_orphaned_service_profiles( Runs after the flavor prune loop to catch profiles left behind when delete_flavor succeeded but maybe_delete_service_profile threw on the same - run. Safe to run every cycle — only touches operator-owned, unattached + run. Safe to run every cycle because it only touches operator-owned, unattached profiles. """ - log("Scanning for orphaned operator-managed service profiles") + LOG.info("Scanning for orphaned operator-managed service profiles") for profile in list(conn.network.service_profiles()): profile_id = resource_id(profile) if not is_prunable_service_profile(profile): @@ -210,11 +220,11 @@ def prune_orphaned_service_profiles( def prune_removed_flavors(conn: Any, flavors: list[dict[str, Any]]) -> None: if not PRUNE_REMOVED_FLAVORS: - log("Router flavor pruning is disabled") + LOG.info("Router flavor pruning is disabled") return if not flavors: - log( + LOG.warning( "No desired router flavors found; skipping prune to avoid deleting " "all managed router flavors" ) @@ -224,9 +234,9 @@ def prune_removed_flavors(conn: Any, flavors: list[dict[str, Any]]) -> None: protected_profile_ids = configured_service_profile_ids(flavors) profile_cache: dict[str, Any | None] = {} - log("Pruning removed router flavors") + LOG.info("Pruning removed router flavors") for flavor in list(conn.network.flavors(service_type=DEFAULT_SERVICE_TYPE)): - flavor_name = get_value(flavor, "name", "Name") + flavor_name = get_value(flavor, "name") if not flavor_name or flavor_name in desired_names: continue if not is_prunable_flavor(conn, flavor): diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py index 763acfa69..c9f931129 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py @@ -7,21 +7,21 @@ from __future__ import annotations import os -import sys -import time from typing import Any -from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import comparable_meta_info_without from openstack_sync.plugins.common import env_bool from openstack_sync.plugins.common import env_tuple from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.common import meta_info_payload +from openstack_sync.plugins.common import managed_meta_info as managed_meta_info_with +from openstack_sync.plugins.common import meta_info_matches_without from openstack_sync.plugins.common import normalize_meta_info +from openstack_sync.plugins.common import wait_for_openstack_network as wait_for_network # --------------------------------------------------------------------------- # Router-flavor CRD identity # --------------------------------------------------------------------------- -# These four are always injected by the Helm chart from the CRD file via +# The chart injects these from the rendered CRD when the hook has an envPrefix. CRD_API_VERSION = os.environ["NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION"] CRD_KIND = os.environ["NEUTRON_ROUTER_FLAVOR_CRD_KIND"] CRD_RESOURCE = os.environ["NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE"] @@ -32,10 +32,7 @@ "neutron-router-flavors", ) CRD_NAMESPACE = os.environ.get("POD_NAMESPACE") -DEFAULT_SERVICE_TYPE = os.environ.get( - "NEUTRON_ROUTER_FLAVOR_SERVICE_TYPE", - "L3_ROUTER_NAT", -) +DEFAULT_SERVICE_TYPE = "L3_ROUTER_NAT" # --------------------------------------------------------------------------- # Prune / lifecycle config @@ -79,37 +76,21 @@ OPERATOR_META_INFO_KEYS = frozenset(OPERATOR_META_INFO_MARKERS) # --------------------------------------------------------------------------- -# Retry / credential defaults +# Retry config # --------------------------------------------------------------------------- READY_RETRIES = int(os.environ.get("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "30")) READY_DELAY = float(os.environ.get("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "10")) -DEFAULT_SECRET = os.environ["NEUTRON_ROUTER_FLAVOR_DEFAULT_SECRET"] -DEFAULT_CLOUD = os.environ["NEUTRON_ROUTER_FLAVOR_DEFAULT_CLOUD"] - - -# --------------------------------------------------------------------------- -# Logging -# --------------------------------------------------------------------------- - - -def log(message: str) -> None: - """Write a prefixed message to stderr.""" - print(f"[router_flavors] {message}", file=sys.stderr) - # --------------------------------------------------------------------------- -# meta_info — plugin-specific wrappers that close over OPERATOR_META_INFO_KEYS +# meta_info helpers bound to this plugin's operator marker keys # --------------------------------------------------------------------------- def comparable_meta_info(value: Any) -> Any: """Strip operator marker keys from *value* before comparison.""" - normalized = normalize_meta_info(value) - if isinstance(normalized, dict): - return {k: v for k, v in normalized.items() if k not in OPERATOR_META_INFO_KEYS} - return normalized + return comparable_meta_info_without(value, OPERATOR_META_INFO_KEYS) def meta_info_matches(current: Any, desired: Any) -> bool: @@ -117,19 +98,12 @@ def meta_info_matches(current: Any, desired: Any) -> bool: Operator-managed marker keys are ignored during comparison. """ - return meta_info_payload(comparable_meta_info(current)) == meta_info_payload( - comparable_meta_info(desired) - ) + return meta_info_matches_without(current, desired, OPERATOR_META_INFO_KEYS) def managed_meta_info(value: Any) -> Any: """Merge operator ownership markers into *value*.""" - normalized = normalize_meta_info(value) - if not isinstance(normalized, dict): - return normalized - merged = dict(normalized) - merged.update(OPERATOR_META_INFO_MARKERS) - return merged + return managed_meta_info_with(value, OPERATOR_META_INFO_MARKERS) # --------------------------------------------------------------------------- @@ -158,9 +132,7 @@ def flavor_description_has_marker(value: Any) -> bool: def is_managed_flavor(flavor: Any) -> bool: """Return True when the flavor's description contains the operator marker.""" - return flavor_description_has_marker( - get_value(flavor, "description", "Description", default="") - ) + return flavor_description_has_marker(get_value(flavor, "description", default="")) # --------------------------------------------------------------------------- @@ -170,7 +142,7 @@ def is_managed_flavor(flavor: Any) -> bool: def service_profile_meta_info(profile: Any) -> Any: """Return the meta_info field of *profile*.""" - return get_value(profile, "meta_info", "metainfo", default={}) + return get_value(profile, "meta_info", default={}) def is_managed_service_profile(profile: Any) -> bool: @@ -188,20 +160,12 @@ def is_managed_service_profile(profile: Any) -> bool: def config_meta_info(flavor_config: dict[str, Any]) -> Any: - """Extract and validate meta_info from a flavor config dict. - - Raises: - ConfigError: When the deprecated ``metainfo`` key is used instead of - ``meta_info``. - """ - if "metainfo" in flavor_config: - name = flavor_config.get("name", "") - raise ConfigError(f"Router flavor {name} uses metainfo; use meta_info instead") + """Return the canonical meta_info payload from a router flavor spec.""" return flavor_config.get("meta_info", {}) # --------------------------------------------------------------------------- -# Neutron readiness probe — thin wrapper that uses module-level retry config +# Neutron readiness probe # --------------------------------------------------------------------------- @@ -210,14 +174,4 @@ def wait_for_openstack_network(conn: Any) -> None: Uses ``READY_RETRIES`` and ``READY_DELAY`` from this module's env config. """ - for attempt in range(1, READY_RETRIES + 1): - try: - next(iter(conn.network.flavors()), None) - return - except Exception as exc: - if attempt >= READY_RETRIES: - raise RuntimeError( - f"Neutron API did not become ready after {READY_RETRIES} attempt(s)" - ) from exc - log(f"Waiting for Neutron API ({attempt}/{READY_RETRIES}): {exc}") - time.sleep(READY_DELAY) + wait_for_network(conn, retries=READY_RETRIES, delay=READY_DELAY) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py index 44d26c7cf..bb0d2756e 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py @@ -3,17 +3,16 @@ from __future__ import annotations import json +import logging from typing import Any +from openstack_sync.plugins.common import ConfigError from openstack_sync.plugins.common import get_value from openstack_sync.plugins.common import service_profile_ids from openstack_sync.plugins.neutron.router_flavors import create from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( DEFAULT_SERVICE_TYPE, ) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - ConfigError, -) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( clean_flavor_description, ) @@ -23,20 +22,19 @@ from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( flavor_description_has_marker, ) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import log from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( managed_flavor_description, ) +LOG = logging.getLogger(__name__) + def ensure_flavor(conn: Any, name: str, service_type: str, description: str) -> Any: flavor = create.find_flavor(conn, name) managed_description = managed_flavor_description(description) if flavor: - log(f"Router flavor {name} already exists") - current_description = get_value( - flavor, "description", "Description", default="" - ) + LOG.info("Router flavor %s already exists", name) + current_description = get_value(flavor, "description", default="") description_changed = clean_flavor_description( current_description ) != clean_flavor_description(description) @@ -50,10 +48,10 @@ def ensure_flavor(conn: Any, name: str, service_type: str, description: str) -> def render_flavor(flavor: Any) -> dict[str, Any]: return { - "id": get_value(flavor, "id", "ID"), - "name": get_value(flavor, "name", "Name"), - "service_type": get_value(flavor, "service_type", "Service Type"), - "description": get_value(flavor, "description", "Description"), + "id": get_value(flavor, "id"), + "name": get_value(flavor, "name"), + "service_type": get_value(flavor, "service_type"), + "description": get_value(flavor, "description"), "service_profile_ids": service_profile_ids(flavor), } @@ -72,12 +70,13 @@ def sync_flavor(conn: Any, flavor_config: dict[str, Any]) -> None: profile_id = flavor_config.get("profile_id", "") meta_info = config_meta_info(flavor_config) - log(f"Reconciling router flavor {name}") + LOG.info("Reconciling router flavor %s", name) profile = create.ensure_profile( conn, name, driver, profile_description, meta_info, profile_id ) flavor = ensure_flavor(conn, name, service_type, description) flavor = create.ensure_profile_attached(conn, flavor, profile) - log( - f"Reconciled router flavor: {json.dumps(render_flavor(flavor), sort_keys=True)}" + LOG.info( + "Reconciled router flavor: %s", + json.dumps(render_flavor(flavor), sort_keys=True), ) diff --git a/python/openstack-sync/tests/conftest.py b/python/openstack-sync/tests/conftest.py index 34f8421cc..3b8237cb4 100644 --- a/python/openstack-sync/tests/conftest.py +++ b/python/openstack-sync/tests/conftest.py @@ -13,7 +13,7 @@ import pytest # --------------------------------------------------------------------------- -# Required env vars for router_flavors_common — set before any import +# Required env vars for router_flavors_common - set before any import # --------------------------------------------------------------------------- _ROUTER_FLAVOR_REQUIRED_ENV = { @@ -24,8 +24,6 @@ "NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE": ( "neutronrouterflavors.neutron.understack.rackspace.net" ), - "NEUTRON_ROUTER_FLAVOR_DEFAULT_SECRET": "infrasetup", - "NEUTRON_ROUTER_FLAVOR_DEFAULT_CLOUD": "understack", } for _key, _value in _ROUTER_FLAVOR_REQUIRED_ENV.items(): diff --git a/python/openstack-sync/tests/test_hook_common.py b/python/openstack-sync/tests/test_hook_common.py index 35f7977d6..59550e7d2 100644 --- a/python/openstack-sync/tests/test_hook_common.py +++ b/python/openstack-sync/tests/test_hook_common.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import logging from unittest import mock import pytest @@ -158,19 +159,19 @@ def test_truncate_message_custom_limit(): def test_patch_resource_status_skips_when_disabled(): - logs = [] - hc.patch_resource_status( - name="test-flavor", - namespace="openstack", - generation=1, - sync_status="Synced", - message="ok", - crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", - crd_kind="NeutronRouterFlavor", - status_enabled=False, - log_fn=logs.append, - ) - assert logs == [] + with mock.patch("subprocess.run") as mock_run: + hc.patch_resource_status( + name="test-flavor", + namespace="openstack", + generation=1, + sync_status="Synced", + message="ok", + crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", + crd_kind="NeutronRouterFlavor", + status_enabled=False, + ) + + mock_run.assert_not_called() def test_patch_resource_status_calls_kubectl(): @@ -213,41 +214,39 @@ def test_patch_resource_status_no_namespace(): assert "-n" not in cmd -def test_patch_resource_status_logs_on_kubectl_not_found(): - logs = [] +def test_patch_resource_status_logs_on_kubectl_not_found(caplog): with mock.patch("subprocess.run", side_effect=FileNotFoundError): - hc.patch_resource_status( - name="test-flavor", - namespace=None, - generation=None, - sync_status="Synced", - message="ok", - crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", - crd_kind="NeutronRouterFlavor", - status_enabled=True, - log_fn=logs.append, - ) - assert any("kubectl not found" in msg for msg in logs) - - -def test_patch_resource_status_logs_on_kubectl_failure(): - logs = [] + with caplog.at_level(logging.WARNING, logger="openstack_sync.hooks.common"): + hc.patch_resource_status( + name="test-flavor", + namespace=None, + generation=None, + sync_status="Synced", + message="ok", + crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", + crd_kind="NeutronRouterFlavor", + status_enabled=True, + ) + assert "kubectl not found" in caplog.text + + +def test_patch_resource_status_logs_on_kubectl_failure(caplog): with mock.patch("subprocess.run") as mock_run: mock_run.return_value = mock.MagicMock( returncode=1, stderr="not found", stdout="" ) - hc.patch_resource_status( - name="test-flavor", - namespace="openstack", - generation=None, - sync_status="Synced", - message="ok", - crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", - crd_kind="NeutronRouterFlavor", - status_enabled=True, - log_fn=logs.append, - ) - assert any("failed to patch" in msg for msg in logs) + with caplog.at_level(logging.WARNING, logger="openstack_sync.hooks.common"): + hc.patch_resource_status( + name="test-flavor", + namespace="openstack", + generation=None, + sync_status="Synced", + message="ok", + crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", + crd_kind="NeutronRouterFlavor", + status_enabled=True, + ) + assert "failed to patch" in caplog.text # --------------------------------------------------------------------------- @@ -307,7 +306,7 @@ def test_dispatch_schedule_snapshot(): called = [] contexts = [ { - "binding": "my-binding", + "binding": "hourly sync", "type": "Schedule", "snapshots": { "my-binding": [ @@ -333,14 +332,44 @@ def test_dispatch_ignores_other_bindings(): assert called == [] -def test_dispatch_returns_1_on_reconcile_error(): - logs = [] +def test_dispatch_returns_1_on_reconcile_error(caplog): contexts = [_make_event("my-binding", "Event", "Added")] - result = hc.dispatch_binding_contexts( - contexts, - "my-binding", - lambda item: (_ for _ in ()).throw(RuntimeError("boom")), - log_fn=logs.append, - ) + with caplog.at_level(logging.ERROR, logger="openstack_sync.hooks.common"): + result = hc.dispatch_binding_contexts( + contexts, + "my-binding", + lambda item: (_ for _ in ()).throw(RuntimeError("boom")), + ) + assert result == 1 + assert "reconcile failed" in caplog.text + + +def test_dispatch_continues_after_reconcile_error(caplog): + called = [] + contexts = [ + { + "binding": "my-binding", + "type": "Synchronization", + "objects": [ + {"object": {"metadata": {"name": "bad"}, "spec": {}}}, + {"object": {"metadata": {"name": "good"}, "spec": {}}}, + ], + } + ] + + def reconcile(item: dict) -> None: + name = item["object"]["metadata"]["name"] + called.append(name) + if name == "bad": + raise RuntimeError("boom") + + with caplog.at_level(logging.ERROR, logger="openstack_sync.hooks.common"): + result = hc.dispatch_binding_contexts( + contexts, + "my-binding", + reconcile, + ) + assert result == 1 - assert any("reconcile failed" in msg for msg in logs) + assert called == ["bad", "good"] + assert "reconcile failed" in caplog.text diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py index 179327140..321783772 100644 --- a/python/openstack-sync/tests/test_router_flavors.py +++ b/python/openstack-sync/tests/test_router_flavors.py @@ -3,8 +3,11 @@ from __future__ import annotations import json +import logging from unittest import mock +import pytest + import openstack_sync.utils as utils from openstack_sync.hooks import router_flavors @@ -24,13 +27,46 @@ def _fake_conn(): return mock.MagicMock(name="fake_conn") +def _router_flavor_object(name: str, spec: dict | None = None) -> dict: + flavor_spec = { + "name": name, + "driver": "some.Driver", + "cloudCredentialsRef": { + "secretName": "infrasetup", + "cloudName": "understack", + }, + } + flavor_spec.update(spec or {}) + return { + "metadata": { + "name": name, + "namespace": "openstack", + "generation": 1, + }, + "spec": flavor_spec, + } + + +def _snapshot_context(*objects: dict) -> list[dict]: + return [ + { + "binding": "hourly sync", + "type": "Schedule", + "snapshots": { + router_flavors.CRD_BINDING_NAME: [{"object": obj} for obj in objects], + }, + } + ] + + # --------------------------------------------------------------------------- -# build_hook_config — reads env at call time so monkeypatch works directly +# build_hook_config: reads env at call time so monkeypatch works directly # --------------------------------------------------------------------------- def test_router_flavor_hook_config_disabled(monkeypatch): - monkeypatch.delenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", raising=False) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "false") config = router_flavors.build_hook_config() @@ -39,7 +75,19 @@ def test_router_flavor_hook_config_disabled(monkeypatch): assert "schedule" not in config +def test_router_flavor_hook_config_omits_schedule_without_crontab(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.delenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", raising=False) + monkeypatch.delenv("POD_NAMESPACE", raising=False) + + config = router_flavors.build_hook_config() + + assert config["kubernetes"][0]["name"] == router_flavors.CRD_BINDING_NAME + assert "schedule" not in config + + def test_router_flavor_hook_config_uses_pod_namespace(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") monkeypatch.setenv("POD_NAMESPACE", "openstack") @@ -53,6 +101,7 @@ def test_router_flavor_hook_config_uses_pod_namespace(monkeypatch): def test_router_flavor_hook_config_custom_crontab(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") monkeypatch.delenv("POD_NAMESPACE", raising=False) @@ -63,6 +112,7 @@ def test_router_flavor_hook_config_custom_crontab(monkeypatch): def test_router_flavor_hook_config_uses_full_object_filter(monkeypatch): """JqFilter must be '.' so cloudCredentialsRef is available at reconcile time.""" + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") monkeypatch.delenv("POD_NAMESPACE", raising=False) @@ -72,6 +122,7 @@ def test_router_flavor_hook_config_uses_full_object_filter(monkeypatch): def test_router_flavor_hook_config_printed_on_config_flag(monkeypatch, capsys): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") monkeypatch.setenv("POD_NAMESPACE", "openstack") @@ -88,7 +139,7 @@ def test_router_flavor_hook_config_printed_on_config_flag(monkeypatch, capsys): # --------------------------------------------------------------------------- -# reconcile_router_flavor — credential resolution + sync delegation +# reconcile_router_flavor: credential resolution and sync delegation # --------------------------------------------------------------------------- @@ -111,26 +162,24 @@ def test_reconcile_uses_cloudcredentialsref(monkeypatch): } } - with mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=_fake_conn(), - ): - with mock.patch.object( + with ( + mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=_fake_conn(), + ), + mock.patch.object( utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML - ) as mock_read: - with mock.patch("openstack_sync.hooks.router_flavors.sync_flavor"): - router_flavors.reconcile_router_flavor(event) + ) as mock_read, + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor"), + ): + router_flavors.reconcile_router_flavor(event) mock_read.assert_called_once_with("baremetal-manage", "clouds.yaml", "openstack") -def test_reconcile_falls_back_to_default_credentials(monkeypatch): - """When cloudCredentialsRef is absent, operator DEFAULT_SECRET/CLOUD are used.""" - monkeypatch.setattr(utils, "_connection_cache", {}) - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DEFAULT_SECRET", "infrasetup") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DEFAULT_CLOUD", "understack") - +def test_reconcile_requires_cloudcredentialsref(): event = { "object": { "metadata": {"name": "no-ref-flavor"}, @@ -138,26 +187,14 @@ def test_reconcile_falls_back_to_default_credentials(monkeypatch): } } - with mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=_fake_conn(), + with pytest.raises( + router_flavors.ConfigError, + match="cloudCredentialsRef is required", ): - with mock.patch.object( - utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML - ) as mock_read: - with mock.patch("openstack_sync.hooks.router_flavors.sync_flavor"): - router_flavors.reconcile_router_flavor(event) - - mock_read.assert_called_once_with("infrasetup", "clouds.yaml", "openstack") + router_flavors.reconcile_router_flavor(event) -def test_reconcile_partial_ref_falls_back_per_field(monkeypatch): - """A cloudCredentialsRef with only secretName still uses DEFAULT_CLOUD.""" - monkeypatch.setattr(utils, "_connection_cache", {}) - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DEFAULT_SECRET", "infrasetup") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DEFAULT_CLOUD", "understack") - +def test_reconcile_requires_complete_cloudcredentialsref(): event = { "object": { "metadata": {"name": "partial-flavor"}, @@ -169,86 +206,68 @@ def test_reconcile_partial_ref_falls_back_per_field(monkeypatch): } } - with mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=_fake_conn(), + with pytest.raises( + router_flavors.ConfigError, + match=r"cloudCredentialsRef\.cloudName", ): - with mock.patch.object( - utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML - ) as mock_read: - with mock.patch("openstack_sync.hooks.router_flavors.sync_flavor"): - router_flavors.reconcile_router_flavor(event) - - mock_read.assert_called_once_with("custom-secret", "clouds.yaml", "openstack") + router_flavors.reconcile_router_flavor(event) # --------------------------------------------------------------------------- -# main() — binding context dispatch +# main(): binding context dispatch # --------------------------------------------------------------------------- def test_main_dispatches_to_reconcile(monkeypatch, tmp_path): """main() reads BINDING_CONTEXT_PATH and dispatches each object.""" monkeypatch.setattr(utils, "_connection_cache", {}) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("POD_NAMESPACE", "openstack") - binding_context = json.dumps( - [ - { - "binding": "neutron-router-flavors", - "type": "Event", - "watchEvent": "Added", - "object": { - "metadata": {"name": "flavor-a"}, - "spec": { - "name": "flavor-a", - "driver": "some.Driver", - "cloudCredentialsRef": { - "secretName": "infrasetup", - "cloudName": "understack", - }, - }, - }, - } - ] - ) + binding_context = json.dumps(_snapshot_context(_router_flavor_object("flavor-a"))) ctx_file = tmp_path / "binding_context.json" ctx_file.write_text(binding_context) monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) - with mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=_fake_conn(), + with ( + mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=_fake_conn(), + ), + mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]), ): - with mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML): - with mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor" - ) as mock_sync: - with mock.patch.object( - router_flavors.sys, "argv", ["router_flavors.py"] - ): - result = router_flavors.main() + result = router_flavors.main() assert result == 0 mock_sync.assert_called_once() -def test_main_returns_error_on_invalid_json(monkeypatch, capsys, tmp_path): +def test_main_returns_error_on_invalid_json(monkeypatch, caplog, tmp_path): ctx_file = tmp_path / "binding_context.json" ctx_file.write_text("not-json") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) - with mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]): + with ( + caplog.at_level(logging.ERROR, logger="openstack_sync.hooks.router_flavors"), + mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]), + ): result = router_flavors.main() assert result == 1 - assert "failed to parse binding context" in capsys.readouterr().err + assert "failed to parse binding context" in caplog.text def test_main_returns_zero_on_empty_context(monkeypatch, tmp_path): ctx_file = tmp_path / "binding_context.json" ctx_file.write_text("") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) with mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]): @@ -258,6 +277,7 @@ def test_main_returns_zero_on_empty_context(monkeypatch, tmp_path): def test_main_returns_zero_when_no_context_path(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.delenv("BINDING_CONTEXT_PATH", raising=False) with mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]): diff --git a/python/openstack-sync/tests/test_router_flavors_create.py b/python/openstack-sync/tests/test_router_flavors_create.py index d7fbbd8e4..95b2c14bd 100644 --- a/python/openstack-sync/tests/test_router_flavors_create.py +++ b/python/openstack-sync/tests/test_router_flavors_create.py @@ -2,10 +2,14 @@ from __future__ import annotations +import logging import types from typing import Any from unittest import mock +import pytest + +from openstack_sync.plugins import common as plugin_common from openstack_sync.plugins.neutron.router_flavors import create from openstack_sync.plugins.neutron.router_flavors import ( router_flavors_common as common, @@ -28,7 +32,7 @@ def _make_profile( return types.SimpleNamespace( id=profile_id, driver=driver, - meta_info=common.meta_info_payload(raw_meta), + meta_info=plugin_common.meta_info_payload(raw_meta), ) @@ -38,6 +42,14 @@ def _conn_with_profile(profile: Any) -> Any: return types.SimpleNamespace(network=network) +def _conn_without_profiles() -> Any: + network = mock.MagicMock() + network.get_service_profile.return_value = None + network.service_profiles.return_value = [] + network.create_service_profile.return_value = _make_profile("new-profile") + return types.SimpleNamespace(network=network) + + # --------------------------------------------------------------------------- # _profile_drifted # --------------------------------------------------------------------------- @@ -86,19 +98,19 @@ def test_drift_ignores_operator_marker_keys(): # --------------------------------------------------------------------------- -# ensure_profile — configured_profile_id path drift warning +# ensure_profile: configured_profile_id path drift warning # --------------------------------------------------------------------------- -def test_ensure_profile_logs_warning_on_driver_drift(capfd): +def test_ensure_profile_logs_warning_on_driver_drift(caplog): """A pinned profile whose driver diverged from the CR emits a WARNING.""" profile = _make_profile("pinned-id", driver="old.Driver") conn = _conn_with_profile(profile) - import io - - buf = io.StringIO() - with mock.patch("sys.stderr", buf): + with caplog.at_level( + logging.WARNING, + logger="openstack_sync.plugins.neutron.router_flavors.create", + ): result = create.ensure_profile( conn, name="test-flavor", @@ -109,22 +121,21 @@ def test_ensure_profile_logs_warning_on_driver_drift(capfd): ) assert result is profile - output = buf.getvalue() - assert "WARNING" in output + output = caplog.text assert "driver" in output assert "old.Driver" in output assert "new.Driver" in output -def test_ensure_profile_logs_warning_on_meta_info_drift(capfd): +def test_ensure_profile_logs_warning_on_meta_info_drift(caplog): """A pinned profile whose meta_info diverged from the CR emits a WARNING.""" profile = _make_profile("pinned-id", meta_info={"vni_alloc": "auto"}) conn = _conn_with_profile(profile) - import io - - buf = io.StringIO() - with mock.patch("sys.stderr", buf): + with caplog.at_level( + logging.WARNING, + logger="openstack_sync.plugins.neutron.router_flavors.create", + ): result = create.ensure_profile( conn, name="test-flavor", @@ -135,20 +146,19 @@ def test_ensure_profile_logs_warning_on_meta_info_drift(capfd): ) assert result is profile - assert "WARNING" in buf.getvalue() - assert "meta_info" in buf.getvalue() + assert "meta_info" in caplog.text -def test_ensure_profile_no_warning_when_pinned_profile_matches(): +def test_ensure_profile_no_warning_when_pinned_profile_matches(caplog): """A pinned profile that matches the spec emits no WARNING.""" desired_meta = {"vni_alloc": "auto"} profile = _make_profile("pinned-id", meta_info=desired_meta, managed=True) conn = _conn_with_profile(profile) - import io - - buf = io.StringIO() - with mock.patch("sys.stderr", buf): + with caplog.at_level( + logging.WARNING, + logger="openstack_sync.plugins.neutron.router_flavors.create", + ): create.ensure_profile( conn, name="test-flavor", @@ -158,29 +168,62 @@ def test_ensure_profile_no_warning_when_pinned_profile_matches(): configured_profile_id="pinned-id", ) - assert "WARNING" not in buf.getvalue() + assert not caplog.records def test_ensure_profile_returns_profile_despite_drift(): """Even when drift is detected the profile is still returned. We cannot fix the drift (Neutron rejects updates on in-use profiles), but - we must not break the reconcile — the flavor should still get bound to the + we must not break the reconcile. The flavor should still get bound to the existing profile so the operator can continue to function. """ profile = _make_profile("pinned-id", driver="old.Driver") conn = _conn_with_profile(profile) - import io + result = create.ensure_profile( + conn, + name="test-flavor", + driver="new.Driver", + description="desc", + meta_info={}, + configured_profile_id="pinned-id", + ) + + assert result is profile - with mock.patch("sys.stderr", io.StringIO()): - result = create.ensure_profile( + +def test_ensure_profile_raises_when_configured_profile_id_is_missing(): + conn = _conn_without_profiles() + + with pytest.raises(plugin_common.ConfigError, match="missing-profile"): + create.ensure_profile( conn, name="test-flavor", - driver="new.Driver", + driver="some.Driver", description="desc", meta_info={}, - configured_profile_id="pinned-id", + configured_profile_id="missing-profile", ) - assert result is profile + conn.network.service_profiles.assert_not_called() + conn.network.create_service_profile.assert_not_called() + + +def test_ensure_profile_creates_service_profile_with_management_markers(): + conn = _conn_without_profiles() + + create.ensure_profile( + conn, + name="test-flavor", + driver="some.Driver", + description="desc", + meta_info={"vni_alloc": "auto"}, + configured_profile_id="", + ) + + kwargs = conn.network.create_service_profile.call_args.kwargs + meta_info = plugin_common.normalize_meta_info(kwargs["meta_info"]) + assert meta_info["vni_alloc"] == "auto" + for key, value in common.OPERATOR_META_INFO_MARKERS.items(): + assert meta_info[key] == value diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py index e42eeb3e8..611e7000d 100644 --- a/python/openstack-sync/tests/test_router_flavors_hook.py +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -16,6 +16,7 @@ ROUTER_ENV_NAMES = ( "BINDING_CONTEXT_PATH", + "NEUTRON_ROUTER_FLAVOR_ENABLED", "NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION", "NEUTRON_ROUTER_FLAVOR_CRD_KIND", @@ -93,8 +94,31 @@ def test_disabled_hook_config_is_valid_noop(monkeypatch, capsys): assert json.loads(capsys.readouterr().out) == config +def test_crontab_does_not_enable_disabled_hook(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "false") + + config = hook.build_hook_config() + + assert config["onStartup"] == 10 + assert "kubernetes" not in config + assert "schedule" not in config + + +def test_enabled_hook_config_omits_schedule_without_crontab(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + + config = hook.build_hook_config() + + assert config["kubernetes"][0]["name"] == common.CRD_BINDING_NAME + assert "schedule" not in config + + def test_enabled_hook_config_watches_router_flavors(monkeypatch): clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") monkeypatch.setenv("POD_NAMESPACE", "openstack") @@ -119,12 +143,13 @@ def test_enabled_hook_config_watches_router_flavors(monkeypatch): # --------------------------------------------------------------------------- -# load_router_flavor_resources — binding context parsing +# load_router_flavor_resources: binding context parsing # --------------------------------------------------------------------------- def test_load_router_flavors_from_snapshot(monkeypatch, tmp_path): clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") monkeypatch.setenv("POD_NAMESPACE", "openstack") @@ -170,6 +195,7 @@ def test_load_router_flavors_from_snapshot(monkeypatch, tmp_path): def test_main_reconciles_binding_context_objects(monkeypatch, tmp_path): clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") monkeypatch.setenv("POD_NAMESPACE", "openstack") monkeypatch.setattr(utils, "_connection_cache", {}) @@ -178,10 +204,13 @@ def test_main_reconciles_binding_context_objects(monkeypatch, tmp_path): tmp_path, [ { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Added", - "object": router_flavor_object("pa1410"), + "binding": "hourly sync", + "type": "Schedule", + "snapshots": { + common.CRD_BINDING_NAME: [ + {"object": router_flavor_object("pa1410")}, + ] + }, } ], ) @@ -189,17 +218,22 @@ def test_main_reconciles_binding_context_objects(monkeypatch, tmp_path): synced = [] - with mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=mock.MagicMock(), + with ( + mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=mock.MagicMock(), + ), + mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", + side_effect=lambda conn, flavor: synced.append(flavor["name"]), + ), + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), ): - with mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML): - with mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", - side_effect=lambda conn, flavor: synced.append(flavor["name"]), - ): - with mock.patch.object(hook.sys, "argv", ["router_flavors.py"]): - result = hook.main() + result = hook.main() assert result == 0 assert synced == ["pa1410"] @@ -207,6 +241,7 @@ def test_main_reconciles_binding_context_objects(monkeypatch, tmp_path): def test_main_returns_error_when_reconcile_fails(monkeypatch, tmp_path): clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") monkeypatch.setenv("POD_NAMESPACE", "openstack") monkeypatch.setattr(utils, "_connection_cache", {}) @@ -215,25 +250,142 @@ def test_main_returns_error_when_reconcile_fails(monkeypatch, tmp_path): tmp_path, [ { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Added", - "object": router_flavor_object("bad-flavor"), + "binding": "hourly sync", + "type": "Schedule", + "snapshots": { + common.CRD_BINDING_NAME: [ + {"object": router_flavor_object("bad-flavor")}, + ] + }, } ], ) monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - with mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=mock.MagicMock(), + with ( + mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=mock.MagicMock(), + ), + mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", + side_effect=RuntimeError("bad flavor config"), + ), + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), ): - with mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML): - with mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", - side_effect=RuntimeError("bad flavor config"), - ): - with mock.patch.object(hook.sys, "argv", ["router_flavors.py"]): - result = hook.main() + result = hook.main() assert result == 1 + + +def test_main_prunes_after_successful_full_set_reconcile(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + conn = mock.MagicMock() + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": "hourly sync", + "type": "Schedule", + "snapshots": { + common.CRD_BINDING_NAME: [ + {"object": router_flavor_object("pa1410")}, + {"object": router_flavor_object("dynamic-vrf")}, + ] + }, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + assert [call.args[1]["name"] for call in mock_sync.call_args_list] == [ + "dynamic-vrf", + "pa1410", + ] + mock_prune.assert_called_once() + assert mock_prune.call_args.args[0] is conn + assert [flavor["name"] for flavor in mock_prune.call_args.args[1]] == [ + "dynamic-vrf", + "pa1410", + ] + + +def test_main_continues_after_failure_and_skips_prune(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + conn = mock.MagicMock() + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": "hourly sync", + "type": "Schedule", + "snapshots": { + common.CRD_BINDING_NAME: [ + {"object": router_flavor_object("bad-flavor")}, + {"object": router_flavor_object("good-flavor")}, + ] + }, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + seen = [] + + def sync_flavor(conn, flavor): + seen.append(flavor["name"]) + if flavor["name"] == "bad-flavor": + raise RuntimeError("bad flavor config") + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch( + "openstack_sync.hooks.router_flavors.patch_flavor_status" + ) as mock_status, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", + side_effect=sync_flavor, + ), + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 1 + assert seen == ["bad-flavor", "good-flavor"] + assert [call.args[1] for call in mock_status.call_args_list] == [ + "Failed", + "Synced", + ] + mock_prune.assert_not_called() diff --git a/python/openstack-sync/tests/test_router_flavors_prune.py b/python/openstack-sync/tests/test_router_flavors_prune.py index 852cac862..5b4847f58 100644 --- a/python/openstack-sync/tests/test_router_flavors_prune.py +++ b/python/openstack-sync/tests/test_router_flavors_prune.py @@ -93,7 +93,7 @@ def test_prune_deletes_removed_managed_flavor(monkeypatch): # --------------------------------------------------------------------------- -# prune_orphaned_service_profiles — second-pass GC for partial-failure orphans +# prune_orphaned_service_profiles: second-pass GC for partial-failure orphans # --------------------------------------------------------------------------- @@ -142,7 +142,7 @@ def test_prune_orphaned_profiles_deletes_unattached_managed_profile(monkeypatch) monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) orphan = _make_orphan_profile("orphan-profile-id") - # No flavors in Neutron — the orphan's parent was already deleted. + # No flavors in Neutron; the orphan's parent was already deleted. network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) conn = SimpleNamespace(network=network) From 4b1d62e23c1cba224522fb444e6944cbe3d45531 Mon Sep 17 00:00:00 2001 From: haseeb Date: Wed, 19 Aug 2026 09:05:57 +0530 Subject: [PATCH 03/13] tighten openstack-sync hook configuration contract --- ...ck.rackspace.net_neutronrouterflavors.yaml | 6 - .../examples/extra-rbac-rules-values.yaml | 39 ++++++ .../templates/_crd.tpl | 16 +-- .../templates/_helpers.tpl | 5 +- .../templates/deployment.yaml.tpl | 2 +- .../values.schema.json | 61 +++++----- .../openstack-sync-operator/values.yaml | 18 +-- .../openstack_sync/hooks/placeholder.py | 8 +- .../openstack_sync/plugins/common.py | 10 +- .../tests/test_plugins_common.py | 111 ++++++++++++++++++ 10 files changed, 204 insertions(+), 72 deletions(-) create mode 100644 components/openstack-sync-operator/examples/extra-rbac-rules-values.yaml create mode 100644 python/openstack-sync/tests/test_plugins_common.py diff --git a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml index 69494f36c..e247180b8 100644 --- a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml +++ b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml @@ -37,7 +37,6 @@ spec: openAPIV3Schema: description: NeutronRouterFlavor defines one Neutron router flavor and its service profile. type: object - additionalProperties: false required: - spec properties: @@ -49,7 +48,6 @@ spec: type: object spec: type: object - additionalProperties: false required: - name - driver @@ -61,7 +59,6 @@ spec: an OpenStack clouds.yaml file. The operator reads this secret directly at reconcile time; no volume mount is required. type: object - additionalProperties: false required: - secretName - cloudName @@ -124,7 +121,6 @@ spec: meta_info: description: Service profile metadata payload. type: object - additionalProperties: false properties: resource_class: description: Resource class consumed by a physical router provider. @@ -142,7 +138,6 @@ spec: status: description: NeutronRouterFlavorStatus defines the observed sync state. type: object - additionalProperties: false properties: syncStatus: description: SyncStatus indicates the synchronization state with Neutron. @@ -168,7 +163,6 @@ spec: type: array items: type: object - additionalProperties: false required: - type - status diff --git a/components/openstack-sync-operator/examples/extra-rbac-rules-values.yaml b/components/openstack-sync-operator/examples/extra-rbac-rules-values.yaml new file mode 100644 index 000000000..4af0387ea --- /dev/null +++ b/components/openstack-sync-operator/examples/extra-rbac-rules-values.yaml @@ -0,0 +1,39 @@ +# Example values override for a future plugin that needs Kubernetes resources +# outside the chart-generated defaults. +# +# Use with: +# helm template openstack-sync-operator ../ -f extra-rbac-rules-values.yaml +# +# Default RBAC +# ------------ +# Without rbac.rules, the chart generates the permissions it can infer: +# +# 1. Secret read access, always: +# apiGroups: [""] +# resources: ["secrets"] +# verbs: ["get"] +# +# 2. For each enabled plugin, CRD read/watch access from pluginData..hook.crd: +# verbs: ["get", "list", "watch"] +# +# 3. For each enabled plugin whose CRD defines a status subresource: +# resources: ["/status"] +# verbs: ["get", "patch", "update"] +# +# For example, enabling plugins.neutronRouterFlavors adds access to: +# - neutronrouterflavors +# - neutronrouterflavors/status +# +# Extra RBAC +# ---------- +# rbac.rules is only for resources the chart cannot infer from plugin CRDs. +# Each item is appended verbatim to the generated Role or ClusterRole. +# +# When rbac.clusterWide is false, these rules go into a namespaced Role. +# When rbac.clusterWide is true, these rules go into a ClusterRole. + +rbac: + rules: + - apiGroups: [""] + resources: ["configmaps"] + verbs: ["get", "list", "watch"] diff --git a/components/openstack-sync-operator/templates/_crd.tpl b/components/openstack-sync-operator/templates/_crd.tpl index 41c313d2f..3524b221f 100644 --- a/components/openstack-sync-operator/templates/_crd.tpl +++ b/components/openstack-sync-operator/templates/_crd.tpl @@ -7,19 +7,19 @@ Read hook CRD metadata used by RBAC and shell-operator environment wiring. {{- $hook := index . 2 -}} {{- $crdPath := get $hook "crd" -}} {{- if not $crdPath -}} -{{- fail (printf "hooks.%s.crd is required for CRD metadata" $hookName) -}} +{{- fail (printf "pluginData.%s.hook.crd is required for CRD metadata" $hookName) -}} {{- end -}} -{{- $crdYaml := required (printf "hooks.%s.crd file %s is empty or missing" $hookName $crdPath) ($root.Files.Get $crdPath) -}} +{{- $crdYaml := required (printf "pluginData.%s.hook.crd file %s is empty or missing" $hookName $crdPath) ($root.Files.Get $crdPath) -}} {{- $crd := fromYaml $crdYaml -}} {{- if ne $crd.kind "CustomResourceDefinition" -}} -{{- fail (printf "hooks.%s.crd must point to a CustomResourceDefinition" $hookName) -}} +{{- fail (printf "pluginData.%s.hook.crd must point to a CustomResourceDefinition" $hookName) -}} {{- end -}} -{{- $group := required (printf "hooks.%s.crd spec.group is required" $hookName) $crd.spec.group -}} -{{- $kind := required (printf "hooks.%s.crd spec.names.kind is required" $hookName) $crd.spec.names.kind -}} -{{- $plural := required (printf "hooks.%s.crd spec.names.plural is required" $hookName) $crd.spec.names.plural -}} +{{- $group := required (printf "pluginData.%s.hook.crd spec.group is required" $hookName) $crd.spec.group -}} +{{- $kind := required (printf "pluginData.%s.hook.crd spec.names.kind is required" $hookName) $crd.spec.names.kind -}} +{{- $plural := required (printf "pluginData.%s.hook.crd spec.names.plural is required" $hookName) $crd.spec.names.plural -}} {{- $storageVersion := "" -}} {{- $hasStatus := false -}} -{{- range $version := required (printf "hooks.%s.crd spec.versions is required" $hookName) $crd.spec.versions }} +{{- range $version := required (printf "pluginData.%s.hook.crd spec.versions is required" $hookName) $crd.spec.versions }} {{- if $version.storage -}} {{- $storageVersion = $version.name -}} {{- end -}} @@ -28,7 +28,7 @@ Read hook CRD metadata used by RBAC and shell-operator environment wiring. {{- end -}} {{- end -}} {{- if not $storageVersion -}} -{{- fail (printf "hooks.%s.crd must define a storage version" $hookName) -}} +{{- fail (printf "pluginData.%s.hook.crd must define a storage version" $hookName) -}} {{- end -}} {{- dict "apiVersion" (printf "%s/%s" $group $storageVersion) diff --git a/components/openstack-sync-operator/templates/_helpers.tpl b/components/openstack-sync-operator/templates/_helpers.tpl index e5c2ad443..f3ffe0933 100644 --- a/components/openstack-sync-operator/templates/_helpers.tpl +++ b/components/openstack-sync-operator/templates/_helpers.tpl @@ -69,7 +69,7 @@ required because shell-operator reads hook watches only when the pod starts. {{- end }} {{/* -Normalize built-in plugin hooks and direct hook definitions. +Normalize built-in plugin hooks. */}} {{- define "openstack-sync-operator.configuredHooks" -}} {{- $hooks := dict -}} @@ -91,8 +91,5 @@ Normalize built-in plugin hooks and direct hook definitions. {{- $_2 := set $hooks $pluginName $hookValues -}} {{- end -}} {{- end -}} -{{- range $hookName, $hook := default dict .Values.hooks -}} -{{- $_ := set $hooks $hookName $hook -}} -{{- end -}} {{- $hooks | toYaml -}} {{- end }} diff --git a/components/openstack-sync-operator/templates/deployment.yaml.tpl b/components/openstack-sync-operator/templates/deployment.yaml.tpl index e2d017ec8..1abc6a222 100644 --- a/components/openstack-sync-operator/templates/deployment.yaml.tpl +++ b/components/openstack-sync-operator/templates/deployment.yaml.tpl @@ -65,7 +65,7 @@ spec: - | missing=0 {{- range $hookName, $hook := $enabledHooks }} - {{- $hookPath := required (printf "hooks.%s.path is required when hook is enabled" $hookName) $hook.path }} + {{- $hookPath := required (printf "pluginData.%s.hook.path is required when hook is enabled" $hookName) $hook.path }} if [ ! -x {{ $hookPath | quote }} ]; then echo {{ printf "enabled hook %s missing or not executable: %s" $hookName $hookPath | quote }} >&2 missing=1 diff --git a/components/openstack-sync-operator/values.schema.json b/components/openstack-sync-operator/values.schema.json index 3b31e71ad..bdbbaadb9 100644 --- a/components/openstack-sync-operator/values.schema.json +++ b/components/openstack-sync-operator/values.schema.json @@ -3,13 +3,6 @@ "type": "object", "additionalProperties": true, "properties": { - "hooks": { - "type": "object", - "description": "Additional hook definitions keyed by hook name.", - "additionalProperties": { - "$ref": "#/definitions/hook" - } - }, "plugins": { "type": "object", "description": "Built-in plugin enablement keyed by plugin name.", @@ -21,24 +14,24 @@ "type": "object", "description": "Built-in plugin hook data keyed by plugin name.", "additionalProperties": { - "type": "object", - "additionalProperties": true, - "properties": { - "hook": { - "$ref": "#/definitions/hook" - } - } + "$ref": "#/definitions/pluginData" } } }, "definitions": { - "hook": { + "pluginData": { + "type": "object", + "additionalProperties": false, + "properties": { + "hook": { + "$ref": "#/definitions/pluginHook" + } + } + }, + "pluginHook": { "type": "object", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean" - }, "path": { "type": "string", "minLength": 1 @@ -53,17 +46,31 @@ "pattern": "^[A-Z][A-Z0-9_]*$" }, "env": { - "type": "object", - "additionalProperties": { - "type": [ - "string", - "number", - "integer", - "boolean" - ] - } + "$ref": "#/definitions/hookEnv" } } + }, + "hookEnv": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "additionalProperties": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string", + "not": { + "pattern": "^\\s*([Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|1|0|[Yy][Ee][Ss]|[Nn][Oo]|[Oo][Nn]|[Oo][Ff][Ff])\\s*$" + } + } + ] + } } } } diff --git a/components/openstack-sync-operator/values.yaml b/components/openstack-sync-operator/values.yaml index 37bc0e00f..5289c0b78 100644 --- a/components/openstack-sync-operator/values.yaml +++ b/components/openstack-sync-operator/values.yaml @@ -13,12 +13,7 @@ serviceAccount: rbac: create: true clusterWide: false - # The base placeholder hook has no Kubernetes bindings. Add hook permissions - # here with the hook that needs them. - rules: [] -# Built-in plugin enablement. Site values normally only override these booleans -# and selected pluginData..hook.env values. # Built-in hook configuration. Site values normally override only: # - plugins.: enable or disable a hook # - pluginData..hook.env: override selected hook env values @@ -48,15 +43,4 @@ pluginData: envPrefix: NEUTRON_ROUTER_FLAVOR env: SYNC_CRONTAB: "0 * * * *" - PRUNE: "false" - -hooks: {} - -podAnnotations: {} -podLabels: {} - -resources: {} - -nodeSelector: {} -tolerations: [] -affinity: {} + PRUNE: false diff --git a/python/openstack-sync/openstack_sync/hooks/placeholder.py b/python/openstack-sync/openstack_sync/hooks/placeholder.py index c940583ab..a4c43e995 100644 --- a/python/openstack-sync/openstack_sync/hooks/placeholder.py +++ b/python/openstack-sync/openstack_sync/hooks/placeholder.py @@ -17,14 +17,10 @@ from typing import Any from openstack_sync.hooks.common import configure_logging +from openstack_sync.plugins.common import env_bool from openstack_sync.utils import get_openstack_connection LOG = logging.getLogger(__name__) -TRUTHY_VALUES = {"1", "true", "yes", "on"} - - -def env_is_truthy(name: str, default: str = "false") -> bool: - return os.environ.get(name, default).lower() in TRUTHY_VALUES def build_hook_config() -> dict[str, Any]: @@ -91,7 +87,7 @@ def main() -> int: for context in binding_contexts: # Shell-operator passes [{"binding": "onStartup"}] for startup runs. if context.get("binding") == "onStartup": - if not env_is_truthy("OPENSTACK_PLACEHOLDER_ENABLED"): + if not env_bool("OPENSTACK_PLACEHOLDER_ENABLED", False): LOG.info( "connectivity check: skipped" " (OPENSTACK_PLACEHOLDER_ENABLED is not set)" diff --git a/python/openstack-sync/openstack_sync/plugins/common.py b/python/openstack-sync/openstack_sync/plugins/common.py index acf50ecab..08b5a090c 100644 --- a/python/openstack-sync/openstack_sync/plugins/common.py +++ b/python/openstack-sync/openstack_sync/plugins/common.py @@ -28,13 +28,17 @@ def env_bool(name: str, default: bool) -> bool: """Return a boolean from an environment variable. - Accepts ``1 / true / yes / on`` (case-insensitive) as truthy values. - Returns *default* when the variable is unset. + Accepts only the exact values ``true`` and ``false``. Returns *default* + when the variable is unset. """ value = os.environ.get(name) if value is None: return default - return value.strip().lower() in {"1", "true", "yes", "on"} + if value == "true": + return True + if value == "false": + return False + raise ConfigError(f"{name} must be true or false") def env_tuple(name: str, default: str) -> tuple[str, ...]: diff --git a/python/openstack-sync/tests/test_plugins_common.py b/python/openstack-sync/tests/test_plugins_common.py new file mode 100644 index 000000000..40f069d92 --- /dev/null +++ b/python/openstack-sync/tests/test_plugins_common.py @@ -0,0 +1,111 @@ +"""Tests for shared openstack-sync plugin utilities.""" + +from __future__ import annotations + +import pytest +from openstack import exceptions as sdk_exceptions +from openstack.network.v2 import flavor as sdk_flavor +from openstack.network.v2 import service_profile as sdk_service_profile + +from openstack_sync.plugins import common + + +def test_env_bool_accepts_only_lowercase_true_false(monkeypatch): + assert common.env_bool("OPENSTACK_SYNC_TEST_MISSING_TRUE", True) is True + assert common.env_bool("OPENSTACK_SYNC_TEST_MISSING_FALSE", False) is False + + monkeypatch.setenv("OPENSTACK_SYNC_TEST_BOOL", "true") + assert common.env_bool("OPENSTACK_SYNC_TEST_BOOL", False) is True + + monkeypatch.setenv("OPENSTACK_SYNC_TEST_BOOL", "false") + assert common.env_bool("OPENSTACK_SYNC_TEST_BOOL", True) is False + + +@pytest.mark.parametrize( + "value", + ["1", "0", "yes", "no", "on", "off", "TRUE", "FALSE", " true "], +) +def test_env_bool_rejects_boolean_aliases(monkeypatch, value): + monkeypatch.setenv("OPENSTACK_SYNC_TEST_BOOL", value) + + with pytest.raises(common.ConfigError, match="must be true or false"): + common.env_bool("OPENSTACK_SYNC_TEST_BOOL", False) + + +def test_get_value_reads_openstacksdk_attribute_names(): + profile = sdk_service_profile.ServiceProfile( + id="profile-id", + driver="neutron_understack.l3_router.vrf.Vrf", + metainfo={"vni_alloc": "auto"}, + ) + + assert common.resource_id(profile) == "profile-id" + assert common.get_value(profile, "driver") == "neutron_understack.l3_router.vrf.Vrf" + assert common.get_value(profile, "meta_info") == {"vni_alloc": "auto"} + + +def test_get_value_reads_exact_dict_keys_only(): + assert common.get_value( + {"meta_info": {"vni_alloc": "auto"}}, + "meta_info", + ) == {"vni_alloc": "auto"} + assert ( + common.get_value( + {"metainfo": {"vni_alloc": "auto"}}, + "meta_info", + default="missing", + ) + == "missing" + ) + + +def test_openstacksdk_maps_wire_names_to_attribute_names(): + profile = sdk_service_profile.ServiceProfile( + id="profile-id", + metainfo={"vni_alloc": "auto"}, + ) + flavor = sdk_flavor.Flavor( + id="flavor-id", + service_profiles=["profile-id"], + ) + + assert common.get_value(profile, "meta_info") == {"vni_alloc": "auto"} + assert common.service_profile_ids(flavor) == ["profile-id"] + + +def test_service_profile_ids_reads_openstacksdk_flavor(): + flavor = sdk_flavor.Flavor( + id="flavor-id", + service_profiles=["profile-1", "profile-2"], + ) + + assert common.service_profile_ids(flavor) == ["profile-1", "profile-2"] + + +def test_get_value_returns_default_for_missing_or_none_values(): + assert common.get_value({"name": None}, "name", default="fallback") == "fallback" + assert ( + common.get_value({"name": "router-flavor"}, "missing", default="fallback") + == "fallback" + ) + + +def test_service_profile_ids_requires_list(): + with pytest.raises(TypeError, match="service_profile_ids"): + common.service_profile_ids({"service_profile_ids": "profile-id"}) + + +def test_sdk_exception_classifiers_match_openstacksdk_classes(): + assert common.is_not_found(sdk_exceptions.NotFoundException("missing")) + assert not common.is_not_found(sdk_exceptions.ConflictException("conflict")) + + assert common.is_conflict(sdk_exceptions.ConflictException("conflict")) + assert not common.is_conflict(sdk_exceptions.NotFoundException("missing")) + + +def test_meta_info_payload_canonicalizes_json_strings(): + assert common.meta_info_payload('{"b": 2, "a": 1}') == '{"a":1,"b":2}' + + +def test_normalize_meta_info_leaves_non_json_strings_unchanged(): + assert common.normalize_meta_info("{'b': 2, 'a': 1}") == "{'b': 2, 'a': 1}" From 5871160e0b80b248346a01cdd187b72b406e2217 Mon Sep 17 00:00:00 2001 From: haseeb Date: Wed, 19 Aug 2026 12:20:40 +0530 Subject: [PATCH 04/13] Fix router flavor status patch feedback loop --- .../openstack-sync-operator/values.yaml | 2 + .../openstack_sync/hooks/common.py | 155 +++++++------ .../openstack_sync/hooks/placeholder.py | 3 - .../openstack_sync/hooks/router_flavors.py | 87 ++++++-- .../openstack_sync/plugins/common.py | 44 ---- .../plugins/neutron/router_flavors/delete.py | 9 +- .../openstack-sync/tests/test_hook_common.py | 204 +++++++----------- .../openstack-sync/tests/test_placeholder.py | 2 +- .../tests/test_router_flavors.py | 142 ++++++++---- .../tests/test_router_flavors_hook.py | 188 +++++++++++++++- .../tests/test_router_flavors_prune.py | 16 ++ 11 files changed, 528 insertions(+), 324 deletions(-) diff --git a/components/openstack-sync-operator/values.yaml b/components/openstack-sync-operator/values.yaml index 5289c0b78..f951e7c62 100644 --- a/components/openstack-sync-operator/values.yaml +++ b/components/openstack-sync-operator/values.yaml @@ -43,4 +43,6 @@ pluginData: envPrefix: NEUTRON_ROUTER_FLAVOR env: SYNC_CRONTAB: "0 * * * *" + # When true, removing a NeutronRouterFlavor CR also deletes its unused + # operator-managed OpenStack flavor. Enable this before removing the CR. PRUNE: false diff --git a/python/openstack-sync/openstack_sync/hooks/common.py b/python/openstack-sync/openstack_sync/hooks/common.py index d698f1daf..c7f17effa 100644 --- a/python/openstack-sync/openstack_sync/hooks/common.py +++ b/python/openstack-sync/openstack_sync/hooks/common.py @@ -1,7 +1,6 @@ """Generic shell-operator hook utilities shared across all hooks. -Provides binding context I/O, status patching via kubectl, and the -Synchronization/Event/Schedule dispatch loop that every hook needs. +Provides binding context I/O and status patching via kubectl. """ from __future__ import annotations @@ -12,7 +11,6 @@ import os import subprocess import sys -from collections.abc import Callable from typing import Any LOG = logging.getLogger(__name__) @@ -117,6 +115,64 @@ def truncate_message(message: Any, max_length: int = 2048) -> str: return f"{text[: max_length - 3]}..." +def _condition_status(sync_status: str) -> str: + return "True" if sync_status == "Synced" else "False" + + +def _condition_reason(sync_status: str) -> str: + return "ReconcileSucceeded" if sync_status == "Synced" else "ReconcileFailed" + + +def _desired_condition(sync_status: str, message: str) -> dict[str, str]: + return { + "type": "Synced", + "status": _condition_status(sync_status), + "reason": _condition_reason(sync_status), + "message": truncate_message(message), + } + + +def _synced_condition(current: dict[str, Any]) -> dict[str, Any] | None: + conditions = current.get("conditions") + if not isinstance(conditions, list): + return None + for condition in conditions: + if isinstance(condition, dict) and condition.get("type") == "Synced": + return condition + return None + + +def _status_is_current( + current: dict[str, Any] | None, + sync_status: str, + message: str, + generation: int | None, +) -> bool: + """Return True when the existing CR status already matches desired state. + + Timestamp fields are intentionally ignored. Rewriting them on every no-op + reconcile creates a Kubernetes Modified event and can requeue the hook. + """ + if not current: + return False + + truncated_message = truncate_message(message) + if current.get("syncStatus") != sync_status: + return False + if current.get("message") != truncated_message: + return False + if generation is not None and current.get("observedGeneration") != generation: + return False + + current_condition = _synced_condition(current) + if current_condition is None: + return False + for key, value in _desired_condition(sync_status, truncated_message).items(): + if current_condition.get(key) != value: + return False + return True + + def patch_resource_status( *, name: str, @@ -127,6 +183,7 @@ def patch_resource_status( crd_resource: str, crd_kind: str, status_enabled: bool, + current_status: dict[str, Any] | None = None, ) -> None: """Patch the status subresource of a CR via kubectl. @@ -140,26 +197,28 @@ def patch_resource_status( ``neutronrouterflavors.neutron.understack.rackspace.net``). crd_kind: CRD kind used in log messages (e.g. ``NeutronRouterFlavor``). status_enabled: When False the function returns immediately. + current_status: Current CR status from the binding context. When it + already matches the desired stable fields, the patch is skipped. """ if not status_enabled: return + if _status_is_current(current_status, sync_status, message, generation): + LOG.debug( + "skipping %s status patch for %s; status is already current", + crd_kind, + name, + ) + return + timestamp = utc_timestamp() - condition_status = "True" if sync_status == "Synced" else "False" - reason = "ReconcileSucceeded" if sync_status == "Synced" else "ReconcileFailed" + condition = _desired_condition(sync_status, message) + condition["lastTransitionTime"] = timestamp status: dict[str, Any] = { "syncStatus": sync_status, "lastSyncTime": timestamp, "message": truncate_message(message), - "conditions": [ - { - "type": "Synced", - "status": condition_status, - "reason": reason, - "message": truncate_message(message), - "lastTransitionTime": timestamp, - } - ], + "conditions": [condition], } if generation is not None: status["observedGeneration"] = generation @@ -193,71 +252,3 @@ def patch_resource_status( if result.returncode != 0: error = (result.stderr or result.stdout or "unknown error").strip() LOG.warning("failed to patch %s status for %s: %s", crd_kind, name, error) - - -# --------------------------------------------------------------------------- -# Binding context dispatch loop -# --------------------------------------------------------------------------- - - -def dispatch_binding_contexts( - binding_contexts: list[dict[str, Any]], - binding_name: str, - reconcile_fn: Callable[[dict[str, Any]], None], -) -> int: - """Dispatch each object in *binding_contexts* to *reconcile_fn*. - - Handles the three shell-operator context types: - - ``Synchronization``: full object list on startup - - ``Event``: single Added/Modified/Deleted event (Deleted is skipped) - - Schedule / other: objects from the snapshots map - - Args: - binding_contexts: Parsed list from the shell-operator binding context. - binding_name: The binding name to filter on. - reconcile_fn: Called with each individual event dict ``{"object": ...}``. - - Returns: - 0 on success, 1 if any reconciliation raises. - """ - failed = False - - for context in binding_contexts: - binding = context.get("binding", "") - context_type = context.get("type", "") - - if context_type == "Synchronization": - if binding != binding_name: - continue - for item in context.get("objects", []): - try: - reconcile_fn(item) - except Exception as exc: # noqa: BLE001 - LOG.error("reconcile failed: %s", exc) - failed = True - - elif context_type == "Event": - if binding != binding_name: - continue - if context.get("watchEvent") == "Deleted": - continue - obj = context.get("object") - if obj: - try: - reconcile_fn({"object": obj}) - except Exception as exc: # noqa: BLE001 - LOG.error("reconcile failed: %s", exc) - failed = True - - else: - # Schedule bindings use the schedule's name, not the Kubernetes - # binding name. The desired objects live in the snapshots map. - snapshots = context.get("snapshots", {}) - for item in snapshots.get(binding_name, []): - try: - reconcile_fn(item) - except Exception as exc: # noqa: BLE001 - LOG.error("reconcile failed: %s", exc) - failed = True - - return 1 if failed else 0 diff --git a/python/openstack-sync/openstack_sync/hooks/placeholder.py b/python/openstack-sync/openstack_sync/hooks/placeholder.py index a4c43e995..407291e24 100644 --- a/python/openstack-sync/openstack_sync/hooks/placeholder.py +++ b/python/openstack-sync/openstack_sync/hooks/placeholder.py @@ -35,9 +35,6 @@ def build_hook_config() -> dict[str, Any]: return hook_config -HOOK_CONFIG = build_hook_config() - - def check_openstack_connectivity() -> None: """Attempt to authenticate against OpenStack and log the result. diff --git a/python/openstack-sync/openstack_sync/hooks/router_flavors.py b/python/openstack-sync/openstack_sync/hooks/router_flavors.py index c02330044..cd65ce332 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -34,6 +34,9 @@ from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( CRD_RESOURCE, ) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + PRUNE_REMOVED_FLAVORS, +) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( STATUS_ENABLED, ) @@ -44,6 +47,7 @@ from openstack_sync.utils import get_openstack_connection LOG = logging.getLogger(__name__) +CredentialKey = tuple[str, str] # --------------------------------------------------------------------------- # Resource dataclass @@ -60,6 +64,7 @@ class RouterFlavorResource: generation: int | None secret_name: str cloud_name: str + current_status: dict[str, Any] | None = None # --------------------------------------------------------------------------- @@ -108,9 +113,6 @@ def build_hook_config() -> dict[str, Any]: return hook_config -HOOK_CONFIG = build_hook_config() - - # --------------------------------------------------------------------------- # Binding context parsing # --------------------------------------------------------------------------- @@ -146,6 +148,8 @@ def _resource_from_object(obj: Any, source: str) -> RouterFlavorResource: resource_name = string_or_none(metadata.get("name")) resource_namespace = string_or_none(metadata.get("namespace")) generation = int_or_none(metadata.get("generation")) + raw_status = obj.get("status") + current_status = raw_status if isinstance(raw_status, dict) else None try: creds_ref = flavor.pop("cloudCredentialsRef") @@ -163,6 +167,7 @@ def _resource_from_object(obj: Any, source: str) -> RouterFlavorResource: generation=generation, secret_name=secret_name, cloud_name=cloud_name, + current_status=current_status, ) @@ -178,6 +183,34 @@ def _resources_from_items(items: list[Any], source: str) -> list[RouterFlavorRes return sorted(resources, key=lambda r: str(r.flavor.get("name", ""))) +def deleted_router_flavor_resources_from_binding_context( + contexts: list[dict[str, Any]], +) -> list[RouterFlavorResource]: + resources: list[RouterFlavorResource] = [] + for index, context in enumerate(contexts): + if ( + context.get("binding") != CRD_BINDING_NAME + or context.get("type") != "Event" + or context.get("watchEvent") != "Deleted" + ): + continue + obj = context.get("object") + if not obj: + LOG.warning( + "Deleted %s event has no object; cannot use it for prune credentials", + CRD_KIND, + ) + continue + resources.append( + _resource_from_object( + obj, + f"Deleted event {CRD_BINDING_NAME}[{index}]", + ) + ) + + return resources + + def router_flavor_resources_from_binding_context( contexts: list[dict[str, Any]], ) -> list[RouterFlavorResource] | None: @@ -237,6 +270,7 @@ def patch_flavor_status( crd_resource=CRD_RESOURCE, crd_kind=CRD_KIND, status_enabled=STATUS_ENABLED, + current_status=resource.current_status, ) @@ -251,8 +285,8 @@ def _resource_display_name(resource: RouterFlavorResource) -> str: def _resources_by_credentials( resources: list[RouterFlavorResource], -) -> dict[tuple[str, str], list[RouterFlavorResource]]: - grouped: dict[tuple[str, str], list[RouterFlavorResource]] = {} +) -> dict[CredentialKey, list[RouterFlavorResource]]: + grouped: dict[CredentialKey, list[RouterFlavorResource]] = {} for resource in resources: key = (resource.secret_name, resource.cloud_name) grouped.setdefault(key, []).append(resource) @@ -271,25 +305,17 @@ def reconcile_router_flavor_resource(conn: Any, resource: RouterFlavorResource) sync_flavor(conn, resource.flavor) -def reconcile_router_flavor(event: dict[str, Any]) -> None: - """Reconcile a single NeutronRouterFlavor resource against OpenStack.""" - resource = _resource_from_object(event["object"], "event.object") - conn = get_openstack_connection(resource.secret_name, resource.cloud_name) - try: - wait_for_openstack_network(conn) - reconcile_router_flavor_resource(conn, resource) - except Exception as exc: - patch_flavor_status(resource, "Failed", str(exc)) - raise - patch_flavor_status(resource, "Synced", "Successfully reconciled router flavor") - - -def reconcile_router_flavor_resources(resources: list[RouterFlavorResource]) -> int: +def reconcile_router_flavor_resources( + resources: list[RouterFlavorResource], + deleted_resources: list[RouterFlavorResource] | None = None, +) -> int: + deleted_resources = deleted_resources or [] flavors = [resource.flavor for resource in resources] LOG.info("Found %s router flavor(s) to reconcile", len(flavors)) grouped_resources = _resources_by_credentials(resources) - connections: dict[tuple[str, str], Any] = {} + deleted_resources_by_credentials = _resources_by_credentials(deleted_resources) + connections: dict[CredentialKey, Any] = {} failed_resources: list[RouterFlavorResource] = [] for credentials, credential_resources in grouped_resources.items(): @@ -358,6 +384,22 @@ def reconcile_router_flavor_resources(resources: list[RouterFlavorResource]) -> [resource.flavor for resource in credential_resources], ) + if PRUNE_REMOVED_FLAVORS: + deleted_only_credentials = set(deleted_resources_by_credentials) - set( + grouped_resources + ) + for credentials in sorted(deleted_only_credentials): + secret_name, cloud_name = credentials + conn = get_openstack_connection(secret_name, cloud_name) + wait_for_openstack_network(conn) + prune_removed_flavors(conn, [], authoritative_empty_desired=True) + + if not grouped_resources and not deleted_resources_by_credentials: + LOG.info( + "Skipping router flavor prune; no router flavor credentials " + "are available" + ) + LOG.info("Finished reconciling router flavors") return 0 @@ -397,7 +439,10 @@ def main() -> int: if not isinstance(binding_contexts, list): raise ConfigError("Shell-operator binding context must be a list") resources = load_router_flavor_resources(binding_contexts) - return reconcile_router_flavor_resources(resources) + deleted_resources = deleted_router_flavor_resources_from_binding_context( + binding_contexts + ) + return reconcile_router_flavor_resources(resources, deleted_resources) except Exception as exc: # noqa: BLE001 LOG.error("%s", exc) return 1 diff --git a/python/openstack-sync/openstack_sync/plugins/common.py b/python/openstack-sync/openstack_sync/plugins/common.py index 08b5a090c..4d66c316e 100644 --- a/python/openstack-sync/openstack_sync/plugins/common.py +++ b/python/openstack-sync/openstack_sync/plugins/common.py @@ -16,8 +16,6 @@ from openstack import exceptions as openstack_exceptions -from openstack_sync.utils import get_openstack_connection - LOG = logging.getLogger(__name__) # --------------------------------------------------------------------------- @@ -199,48 +197,6 @@ def is_conflict(exc: Exception) -> bool: return isinstance(exc, openstack_exceptions.ConflictException) -# --------------------------------------------------------------------------- -# Config validation -# --------------------------------------------------------------------------- - - -def validate_config(items: Any, source: str) -> list[dict[str, Any]]: - """Validate that *items* is a list of dicts. - - Args: - items: The value to validate. - source: Human-readable label used in error messages. - - Returns: - A shallow copy of the validated list. - - Raises: - ConfigError: When *items* is not a list or contains a non-dict element. - """ - if not isinstance(items, list): - raise ConfigError(f"{source} must be a list") - validated = [] - for index, item in enumerate(items): - if not isinstance(item, dict): - raise ConfigError(f"{source}[{index}] must be an object") - validated.append(dict(item)) - return validated - - -# --------------------------------------------------------------------------- -# OpenStack connection -# --------------------------------------------------------------------------- - - -def connect_openstack(secret_name: str, cloud_name: str) -> Any: - """Return an authenticated OpenStack connection loaded from a K8s Secret. - - Delegates to :func:`openstack_sync.utils.get_openstack_connection` so - credentials are read from Kubernetes rather than a file on disk. - """ - return get_openstack_connection(secret_name, cloud_name) - - # --------------------------------------------------------------------------- # Neutron network readiness probe # --------------------------------------------------------------------------- diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py index 999dd4bf5..deb95c0c7 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py @@ -218,12 +218,17 @@ def prune_orphaned_service_profiles( ) -def prune_removed_flavors(conn: Any, flavors: list[dict[str, Any]]) -> None: +def prune_removed_flavors( + conn: Any, + flavors: list[dict[str, Any]], + *, + authoritative_empty_desired: bool = False, +) -> None: if not PRUNE_REMOVED_FLAVORS: LOG.info("Router flavor pruning is disabled") return - if not flavors: + if not flavors and not authoritative_empty_desired: LOG.warning( "No desired router flavors found; skipping prune to avoid deleting " "all managed router flavors" diff --git a/python/openstack-sync/tests/test_hook_common.py b/python/openstack-sync/tests/test_hook_common.py index 59550e7d2..cb91832ca 100644 --- a/python/openstack-sync/tests/test_hook_common.py +++ b/python/openstack-sync/tests/test_hook_common.py @@ -153,6 +153,67 @@ def test_truncate_message_custom_limit(): assert result == "ab..." +def _matching_status( + *, + sync_status: str = "Synced", + message: str = "ok", + generation: int | None = 1, +) -> dict: + condition_status = "True" if sync_status == "Synced" else "False" + reason = "ReconcileSucceeded" if sync_status == "Synced" else "ReconcileFailed" + status = { + "syncStatus": sync_status, + "lastSyncTime": "2026-08-19T06:20:21Z", + "message": message, + "conditions": [ + { + "type": "Synced", + "status": condition_status, + "reason": reason, + "message": message, + "lastTransitionTime": "2026-08-19T06:20:21Z", + } + ], + } + if generation is not None: + status["observedGeneration"] = generation + return status + + +def test_status_is_current_ignores_timestamps(): + current = _matching_status( + message="Successfully reconciled router flavor", + generation=3, + ) + + assert hc._status_is_current( + current, + "Synced", + "Successfully reconciled router flavor", + 3, + ) + + +@pytest.mark.parametrize( + ("current", "sync_status", "message", "generation"), + [ + (None, "Synced", "ok", 1), + ({}, "Synced", "ok", 1), + (_matching_status(sync_status="Failed"), "Synced", "ok", 1), + (_matching_status(message="old"), "Synced", "new", 1), + (_matching_status(generation=1), "Synced", "ok", 2), + ({**_matching_status(), "conditions": []}, "Synced", "ok", 1), + ], +) +def test_status_is_current_detects_real_status_differences( + current, + sync_status, + message, + generation, +): + assert not hc._status_is_current(current, sync_status, message, generation) + + # --------------------------------------------------------------------------- # patch_resource_status # --------------------------------------------------------------------------- @@ -196,6 +257,23 @@ def test_patch_resource_status_calls_kubectl(): assert "openstack" in cmd +def test_patch_resource_status_skips_when_current_status_matches(): + with mock.patch("subprocess.run") as mock_run: + hc.patch_resource_status( + name="test-flavor", + namespace="openstack", + generation=1, + sync_status="Synced", + message="ok", + crd_resource="neutronrouterflavors.neutron.understack.rackspace.net", + crd_kind="NeutronRouterFlavor", + status_enabled=True, + current_status=_matching_status(), + ) + + mock_run.assert_not_called() + + def test_patch_resource_status_no_namespace(): with mock.patch("subprocess.run") as mock_run: mock_run.return_value = mock.MagicMock(returncode=0) @@ -247,129 +325,3 @@ def test_patch_resource_status_logs_on_kubectl_failure(caplog): status_enabled=True, ) assert "failed to patch" in caplog.text - - -# --------------------------------------------------------------------------- -# dispatch_binding_contexts -# --------------------------------------------------------------------------- - - -def _make_event(binding: str, event_type: str, watch_event: str = "Added") -> dict: - return { - "binding": binding, - "type": event_type, - "watchEvent": watch_event, - "object": {"metadata": {"name": "obj-1"}, "spec": {}}, - } - - -def test_dispatch_synchronization(): - called = [] - contexts = [ - { - "binding": "my-binding", - "type": "Synchronization", - "objects": [ - {"object": {"metadata": {"name": "a"}, "spec": {}}}, - {"object": {"metadata": {"name": "b"}, "spec": {}}}, - ], - } - ] - result = hc.dispatch_binding_contexts( - contexts, "my-binding", lambda item: called.append(item) - ) - assert result == 0 - assert len(called) == 2 - - -def test_dispatch_event_added(): - called = [] - contexts = [_make_event("my-binding", "Event", "Added")] - result = hc.dispatch_binding_contexts( - contexts, "my-binding", lambda item: called.append(item) - ) - assert result == 0 - assert len(called) == 1 - - -def test_dispatch_event_deleted_is_skipped(): - called = [] - contexts = [_make_event("my-binding", "Event", "Deleted")] - result = hc.dispatch_binding_contexts( - contexts, "my-binding", lambda item: called.append(item) - ) - assert result == 0 - assert called == [] - - -def test_dispatch_schedule_snapshot(): - called = [] - contexts = [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - "my-binding": [ - {"object": {"metadata": {"name": "x"}, "spec": {}}}, - ] - }, - } - ] - result = hc.dispatch_binding_contexts( - contexts, "my-binding", lambda item: called.append(item) - ) - assert result == 0 - assert len(called) == 1 - - -def test_dispatch_ignores_other_bindings(): - called = [] - contexts = [_make_event("other-binding", "Event", "Added")] - result = hc.dispatch_binding_contexts( - contexts, "my-binding", lambda item: called.append(item) - ) - assert result == 0 - assert called == [] - - -def test_dispatch_returns_1_on_reconcile_error(caplog): - contexts = [_make_event("my-binding", "Event", "Added")] - with caplog.at_level(logging.ERROR, logger="openstack_sync.hooks.common"): - result = hc.dispatch_binding_contexts( - contexts, - "my-binding", - lambda item: (_ for _ in ()).throw(RuntimeError("boom")), - ) - assert result == 1 - assert "reconcile failed" in caplog.text - - -def test_dispatch_continues_after_reconcile_error(caplog): - called = [] - contexts = [ - { - "binding": "my-binding", - "type": "Synchronization", - "objects": [ - {"object": {"metadata": {"name": "bad"}, "spec": {}}}, - {"object": {"metadata": {"name": "good"}, "spec": {}}}, - ], - } - ] - - def reconcile(item: dict) -> None: - name = item["object"]["metadata"]["name"] - called.append(name) - if name == "bad": - raise RuntimeError("boom") - - with caplog.at_level(logging.ERROR, logger="openstack_sync.hooks.common"): - result = hc.dispatch_binding_contexts( - contexts, - "my-binding", - reconcile, - ) - - assert result == 1 - assert called == ["bad", "good"] - assert "reconcile failed" in caplog.text diff --git a/python/openstack-sync/tests/test_placeholder.py b/python/openstack-sync/tests/test_placeholder.py index 242068046..a4105ecb6 100644 --- a/python/openstack-sync/tests/test_placeholder.py +++ b/python/openstack-sync/tests/test_placeholder.py @@ -32,7 +32,7 @@ def test_placeholder_hook_config(capsys): assert placeholder.main() == 0 config = json.loads(capsys.readouterr().out) - assert config == placeholder.HOOK_CONFIG + assert config == placeholder.build_hook_config() assert config["onStartup"] == 10 diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py index 321783772..d76af3e40 100644 --- a/python/openstack-sync/tests/test_router_flavors.py +++ b/python/openstack-sync/tests/test_router_flavors.py @@ -27,7 +27,11 @@ def _fake_conn(): return mock.MagicMock(name="fake_conn") -def _router_flavor_object(name: str, spec: dict | None = None) -> dict: +def _router_flavor_object( + name: str, + spec: dict | None = None, + status: dict | None = None, +) -> dict: flavor_spec = { "name": name, "driver": "some.Driver", @@ -37,7 +41,7 @@ def _router_flavor_object(name: str, spec: dict | None = None) -> dict: }, } flavor_spec.update(spec or {}) - return { + obj = { "metadata": { "name": name, "namespace": "openstack", @@ -45,6 +49,9 @@ def _router_flavor_object(name: str, spec: dict | None = None) -> dict: }, "spec": flavor_spec, } + if status is not None: + obj["status"] = status + return obj def _snapshot_context(*objects: dict) -> list[dict]: @@ -86,6 +93,17 @@ def test_router_flavor_hook_config_omits_schedule_without_crontab(monkeypatch): assert "schedule" not in config +def test_router_flavor_hook_config_omits_schedule_with_empty_crontab(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "") + monkeypatch.delenv("POD_NAMESPACE", raising=False) + + config = router_flavors.build_hook_config() + + assert config["kubernetes"][0]["name"] == router_flavors.CRD_BINDING_NAME + assert "schedule" not in config + + def test_router_flavor_hook_config_uses_pod_namespace(monkeypatch): monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") @@ -139,78 +157,114 @@ def test_router_flavor_hook_config_printed_on_config_flag(monkeypatch, capsys): # --------------------------------------------------------------------------- -# reconcile_router_flavor: credential resolution and sync delegation +# binding context parsing # --------------------------------------------------------------------------- -def test_reconcile_uses_cloudcredentialsref(monkeypatch): - """Per-resource cloudCredentialsRef is used to connect to OpenStack.""" - monkeypatch.setattr(utils, "_connection_cache", {}) - monkeypatch.setenv("POD_NAMESPACE", "openstack") +def test_load_router_flavor_resources_keeps_current_status(): + status = { + "syncStatus": "Synced", + "message": "Successfully reconciled router flavor", + "observedGeneration": 1, + } + contexts = _snapshot_context(_router_flavor_object("flavor-a", status=status)) + + resources = router_flavors.load_router_flavor_resources(contexts) + + assert resources[0].current_status == status + + +def test_patch_flavor_status_passes_current_status(): + status = {"syncStatus": "Synced", "message": "ok", "observedGeneration": 1} + secret_name = "infrasetup" # noqa: S105 + resource = router_flavors.RouterFlavorResource( + flavor={"name": "flavor-a", "driver": "some.Driver"}, + name="flavor-a", + namespace="openstack", + generation=1, + secret_name=secret_name, + cloud_name="understack", + current_status=status, + ) + + with mock.patch( + "openstack_sync.hooks.router_flavors.patch_resource_status" + ) as mock_patch: + router_flavors.patch_flavor_status(resource, "Synced", "ok") + + assert mock_patch.call_args.kwargs["current_status"] == status - event = { - "object": { - "metadata": {"name": "test-flavor"}, - "spec": { - "name": "test-flavor", - "driver": "some.Driver", - "cloudCredentialsRef": { - "secretName": "baremetal-manage", - "cloudName": "understack", + +# --------------------------------------------------------------------------- +# reconcile_router_flavor_resources: credential resolution and sync delegation +# --------------------------------------------------------------------------- + + +def test_reconcile_uses_cloudcredentialsref(): + """Per-resource cloudCredentialsRef is used to connect to OpenStack.""" + resource = router_flavors.load_router_flavor_resources( + _snapshot_context( + _router_flavor_object( + "test-flavor", + { + "cloudCredentialsRef": { + "secretName": "baremetal-manage", + "cloudName": "understack", + }, }, - }, - } - } + ) + ) + )[0] + conn = _fake_conn() with ( mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=_fake_conn(), - ), - mock.patch.object( - utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML - ) as mock_read, + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ) as mock_connect, mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, ): - router_flavors.reconcile_router_flavor(event) + result = router_flavors.reconcile_router_flavor_resources([resource]) - mock_read.assert_called_once_with("baremetal-manage", "clouds.yaml", "openstack") + assert result == 0 + mock_connect.assert_called_once_with("baremetal-manage", "understack") + mock_sync.assert_called_once_with(conn, resource.flavor) + mock_prune.assert_called_once_with(conn, [resource.flavor]) def test_reconcile_requires_cloudcredentialsref(): - event = { - "object": { - "metadata": {"name": "no-ref-flavor"}, - "spec": {"name": "no-ref-flavor", "driver": "some.Driver"}, - } + obj = { + "metadata": {"name": "no-ref-flavor"}, + "spec": {"name": "no-ref-flavor", "driver": "some.Driver"}, } with pytest.raises( router_flavors.ConfigError, match="cloudCredentialsRef is required", ): - router_flavors.reconcile_router_flavor(event) + router_flavors.load_router_flavor_resources(_snapshot_context(obj)) def test_reconcile_requires_complete_cloudcredentialsref(): - event = { - "object": { - "metadata": {"name": "partial-flavor"}, - "spec": { - "name": "partial-flavor", - "driver": "some.Driver", - "cloudCredentialsRef": {"secretName": "custom-secret"}, - }, - } + obj = { + "metadata": {"name": "partial-flavor"}, + "spec": { + "name": "partial-flavor", + "driver": "some.Driver", + "cloudCredentialsRef": {"secretName": "custom-secret"}, + }, } with pytest.raises( router_flavors.ConfigError, match=r"cloudCredentialsRef\.cloudName", ): - router_flavors.reconcile_router_flavor(event) + router_flavors.load_router_flavor_resources(_snapshot_context(obj)) # --------------------------------------------------------------------------- diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py index 611e7000d..16ae91a2e 100644 --- a/python/openstack-sync/tests/test_router_flavors_hook.py +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -75,7 +75,7 @@ def router_flavor_object(name: str, spec: dict | None = None) -> dict: # --------------------------------------------------------------------------- -# HOOK_CONFIG shape +# hook config shape # --------------------------------------------------------------------------- @@ -333,6 +333,192 @@ def test_main_prunes_after_successful_full_set_reconcile(monkeypatch, tmp_path): ] +def test_main_prunes_deleted_only_credentials(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setattr(hook, "PRUNE_REMOVED_FLAVORS", True) + conn = mock.MagicMock() + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": router_flavor_object("pa1410"), + "snapshots": {common.CRD_BINDING_NAME: []}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ) as mock_connect, + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + mock_connect.assert_called_once_with("infrasetup", "understack") + mock_sync.assert_not_called() + mock_prune.assert_called_once_with(conn, [], authoritative_empty_desired=True) + + +def test_main_ignores_deleted_only_credentials_when_prune_is_disabled( + monkeypatch, tmp_path +): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setattr(hook, "PRUNE_REMOVED_FLAVORS", False) + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": router_flavor_object("pa1410"), + "snapshots": {common.CRD_BINDING_NAME: []}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection" + ) as mock_connect, + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + mock_connect.assert_not_called() + mock_sync.assert_not_called() + mock_prune.assert_not_called() + + +def test_main_prunes_active_and_deleted_only_credentials(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setattr(hook, "PRUNE_REMOVED_FLAVORS", True) + active_conn = mock.MagicMock(name="active_conn") + deleted_conn = mock.MagicMock(name="deleted_conn") + + active_object = router_flavor_object("pa1410") + deleted_object = router_flavor_object( + "other-cloud-flavor", + { + "cloudCredentialsRef": { + "secretName": "other-secret", + "cloudName": "other-cloud", + } + }, + ) + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": deleted_object, + "snapshots": { + common.CRD_BINDING_NAME: [{"object": active_object}], + }, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + def connect(secret_name, cloud_name): + if (secret_name, cloud_name) == ("infrasetup", "understack"): + return active_conn + if (secret_name, cloud_name) == ("other-secret", "other-cloud"): + return deleted_conn + raise AssertionError((secret_name, cloud_name)) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + side_effect=connect, + ), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor"), + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + assert mock_prune.call_args_list == [ + mock.call(active_conn, [mock.ANY]), + mock.call(deleted_conn, [], authoritative_empty_desired=True), + ] + assert mock_prune.call_args_list[0].args[1][0]["name"] == "pa1410" + + +def test_main_skips_empty_snapshot_prune_without_credentials(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setattr(hook, "PRUNE_REMOVED_FLAVORS", True) + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": "hourly sync", + "type": "Schedule", + "snapshots": {common.CRD_BINDING_NAME: []}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection" + ) as mock_connect, + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + mock_connect.assert_not_called() + mock_sync.assert_not_called() + mock_prune.assert_not_called() + + def test_main_continues_after_failure_and_skips_prune(monkeypatch, tmp_path): clear_env(monkeypatch) monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") diff --git a/python/openstack-sync/tests/test_router_flavors_prune.py b/python/openstack-sync/tests/test_router_flavors_prune.py index 5b4847f58..760b64096 100644 --- a/python/openstack-sync/tests/test_router_flavors_prune.py +++ b/python/openstack-sync/tests/test_router_flavors_prune.py @@ -76,6 +76,22 @@ def test_prune_keeps_managed_flavors_when_desired_list_is_empty(monkeypatch): assert conn.network.deleted_flavors == [] +def test_prune_deletes_managed_flavors_when_empty_desired_is_explicit(monkeypatch): + monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + flavor = { + "id": "managed-flavor-id", + "name": "removed-managed-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [], + } + conn = SimpleNamespace(network=FakeNetwork([flavor], {})) + + delete.prune_removed_flavors(conn, [], authoritative_empty_desired=True) + + assert conn.network.deleted_flavors == ["managed-flavor-id"] + + def test_prune_deletes_removed_managed_flavor(monkeypatch): monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) flavor = { From 57b3fcae2c5aad7f292f1dfa791edc8dfc17489a Mon Sep 17 00:00:00 2001 From: haseeb Date: Wed, 19 Aug 2026 22:54:40 +0530 Subject: [PATCH 05/13] accept operator level environment variables --- .../templates/deployment.yaml.tpl | 11 ++++ .../values.schema.json | 55 ++++++++++++++----- .../openstack-sync-operator/values.yaml | 5 ++ .../openstack_sync/hooks/common.py | 2 +- .../openstack-sync/tests/test_hook_common.py | 23 ++++++++ 5 files changed, 81 insertions(+), 15 deletions(-) diff --git a/components/openstack-sync-operator/templates/deployment.yaml.tpl b/components/openstack-sync-operator/templates/deployment.yaml.tpl index 1abc6a222..b61c738dc 100644 --- a/components/openstack-sync-operator/templates/deployment.yaml.tpl +++ b/components/openstack-sync-operator/templates/deployment.yaml.tpl @@ -27,6 +27,13 @@ {{- end }} {{- end }} {{- end -}} +{{- $operatorEnv := dict "LOG_LEVEL" "info" -}} +{{- range $envName, $envValue := default dict .Values.env }} +{{- if hasKey $hookEnv $envName }} +{{- fail (printf "duplicate operator environment variable %s" $envName) }} +{{- end }} +{{- $_ = set $operatorEnv $envName $envValue -}} +{{- end }} apiVersion: apps/v1 kind: Deployment metadata: @@ -104,6 +111,10 @@ spec: - name: {{ $envName }} value: {{ get $hookEnv $envName | quote }} {{- end }} + {{- range $envName := keys $operatorEnv | sortAlpha }} + - name: {{ $envName }} + value: {{ get $operatorEnv $envName | quote }} + {{- end }} {{- with .Values.resources }} resources: {{- toYaml . | nindent 12 }} diff --git a/components/openstack-sync-operator/values.schema.json b/components/openstack-sync-operator/values.schema.json index bdbbaadb9..4d8d47a35 100644 --- a/components/openstack-sync-operator/values.schema.json +++ b/components/openstack-sync-operator/values.schema.json @@ -3,6 +3,10 @@ "type": "object", "additionalProperties": true, "properties": { + "env": { + "description": "Operator-level environment variables injected directly into the container.", + "$ref": "#/definitions/operatorEnv" + }, "plugins": { "type": "object", "description": "Built-in plugin enablement keyed by plugin name.", @@ -19,6 +23,26 @@ } }, "definitions": { + "operatorEnv": { + "type": "object", + "propertyNames": { + "pattern": "^[A-Z][A-Z0-9_]*$" + }, + "properties": { + "LOG_LEVEL": { + "type": "string", + "default": "info", + "enum": [ + "debug", + "info", + "error" + ] + } + }, + "additionalProperties": { + "$ref": "#/definitions/envValue" + } + }, "pluginData": { "type": "object", "additionalProperties": false, @@ -56,21 +80,24 @@ "pattern": "^[A-Z][A-Z0-9_]*$" }, "additionalProperties": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "number" - }, - { - "type": "string", - "not": { - "pattern": "^\\s*([Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|1|0|[Yy][Ee][Ss]|[Nn][Oo]|[Oo][Nn]|[Oo][Ff][Ff])\\s*$" - } - } - ] + "$ref": "#/definitions/envValue" } + }, + "envValue": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "number" + }, + { + "type": "string", + "not": { + "pattern": "^\\s*([Tt][Rr][Uu][Ee]|[Ff][Aa][Ll][Ss][Ee]|1|0|[Yy][Ee][Ss]|[Nn][Oo]|[Oo][Nn]|[Oo][Ff][Ff])\\s*$" + } + } + ] } } } diff --git a/components/openstack-sync-operator/values.yaml b/components/openstack-sync-operator/values.yaml index f951e7c62..374e55437 100644 --- a/components/openstack-sync-operator/values.yaml +++ b/components/openstack-sync-operator/values.yaml @@ -1,5 +1,10 @@ replicaCount: 1 +# Operator-level environment variables injected directly into the container. +# LOG_LEVEL controls both shell-operator and Python hook logging. +env: + LOG_LEVEL: info + image: repository: ghcr.io/rackerlabs/understack/openstack-sync-operator pullPolicy: IfNotPresent diff --git a/python/openstack-sync/openstack_sync/hooks/common.py b/python/openstack-sync/openstack_sync/hooks/common.py index c7f17effa..51f987efc 100644 --- a/python/openstack-sync/openstack_sync/hooks/common.py +++ b/python/openstack-sync/openstack_sync/hooks/common.py @@ -19,7 +19,7 @@ def configure_logging() -> None: """Configure runtime hook logging without affecting --config output.""" logging.basicConfig( - level=os.environ.get("OPENSTACK_SYNC_LOG_LEVEL", "INFO").upper(), + level=os.environ.get("LOG_LEVEL", "info").upper(), format="%(levelname)s:%(name)s:%(message)s", stream=sys.stderr, ) diff --git a/python/openstack-sync/tests/test_hook_common.py b/python/openstack-sync/tests/test_hook_common.py index cb91832ca..2a8d1bf21 100644 --- a/python/openstack-sync/tests/test_hook_common.py +++ b/python/openstack-sync/tests/test_hook_common.py @@ -10,6 +10,29 @@ from openstack_sync.hooks import common as hc +# --------------------------------------------------------------------------- +# configure_logging +# --------------------------------------------------------------------------- + + +def test_configure_logging_defaults_to_info(monkeypatch): + monkeypatch.delenv("LOG_LEVEL", raising=False) + + with mock.patch.object(logging, "basicConfig") as basic_config: + hc.configure_logging() + + assert basic_config.call_args.kwargs["level"] == "INFO" + + +def test_configure_logging_reads_log_level(monkeypatch): + monkeypatch.setenv("LOG_LEVEL", "debug") + + with mock.patch.object(logging, "basicConfig") as basic_config: + hc.configure_logging() + + assert basic_config.call_args.kwargs["level"] == "DEBUG" + + # --------------------------------------------------------------------------- # Type coercions # --------------------------------------------------------------------------- From a81b04c370b3fb19fcf840b661d1454c4c2f442b Mon Sep 17 00:00:00 2001 From: haseeb Date: Thu, 20 Aug 2026 14:43:57 +0530 Subject: [PATCH 06/13] dedicated queue for each hook --- python/openstack-sync/openstack_sync/hooks/router_flavors.py | 5 +++++ python/openstack-sync/tests/test_router_flavors.py | 2 ++ python/openstack-sync/tests/test_router_flavors_hook.py | 2 ++ 3 files changed, 9 insertions(+) diff --git a/python/openstack-sync/openstack_sync/hooks/router_flavors.py b/python/openstack-sync/openstack_sync/hooks/router_flavors.py index cd65ce332..8b29dae42 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -95,6 +95,10 @@ def build_hook_config() -> dict[str, Any]: "executeHookOnEvent": ["Added", "Modified", "Deleted"], "jqFilter": ".", "includeSnapshotsFrom": [CRD_BINDING_NAME], + # Dedicated queue so a slow Neutron readiness wait or reconciliation + # only delays this hook's own tasks, not other hooks sharing the + # default "main" queue. + "queue": CRD_BINDING_NAME, } if namespace: kubernetes_binding["namespace"] = { @@ -108,6 +112,7 @@ def build_hook_config() -> dict[str, Any]: "name": "hourly sync", "crontab": sync_crontab, "includeSnapshotsFrom": [CRD_BINDING_NAME], + "queue": CRD_BINDING_NAME, } ] return hook_config diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py index d76af3e40..b86ee9148 100644 --- a/python/openstack-sync/tests/test_router_flavors.py +++ b/python/openstack-sync/tests/test_router_flavors.py @@ -114,7 +114,9 @@ def test_router_flavor_hook_config_uses_pod_namespace(monkeypatch): assert config["kubernetes"][0]["namespace"] == { "nameSelector": {"matchNames": ["openstack"]} } + assert config["kubernetes"][0]["queue"] == router_flavors.CRD_BINDING_NAME assert config["schedule"][0]["crontab"] == "0 * * * *" + assert config["schedule"][0]["queue"] == router_flavors.CRD_BINDING_NAME assert "onStartup" not in config diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py index 16ae91a2e..fdc503a62 100644 --- a/python/openstack-sync/tests/test_router_flavors_hook.py +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -133,11 +133,13 @@ def test_enabled_hook_config_watches_router_flavors(monkeypatch): assert binding["jqFilter"] == "." assert binding["includeSnapshotsFrom"] == [common.CRD_BINDING_NAME] assert binding["namespace"]["nameSelector"]["matchNames"] == ["openstack"] + assert binding["queue"] == common.CRD_BINDING_NAME assert config["schedule"] == [ { "name": "hourly sync", "crontab": "*/15 * * * *", "includeSnapshotsFrom": [common.CRD_BINDING_NAME], + "queue": common.CRD_BINDING_NAME, } ] From 43b085ec626d457a02a45819f48852b0ce7b6f2e Mon Sep 17 00:00:00 2001 From: haseeb Date: Thu, 20 Aug 2026 16:22:21 +0530 Subject: [PATCH 07/13] operational/performance improvements --- .../openstack-sync-operator/values.yaml | 4 + .../openstack_sync/hooks/router_flavors.py | 14 +- .../plugins/neutron/router_flavors/create.py | 32 +++- .../plugins/neutron/router_flavors/update.py | 8 +- .../tests/test_router_flavors.py | 2 +- .../tests/test_router_flavors_create.py | 153 ++++++++++++++++++ .../tests/test_router_flavors_hook.py | 4 +- .../tests/test_router_flavors_prune.py | 23 +++ 8 files changed, 226 insertions(+), 14 deletions(-) diff --git a/components/openstack-sync-operator/values.yaml b/components/openstack-sync-operator/values.yaml index 374e55437..b985e59ff 100644 --- a/components/openstack-sync-operator/values.yaml +++ b/components/openstack-sync-operator/values.yaml @@ -48,6 +48,10 @@ pluginData: envPrefix: NEUTRON_ROUTER_FLAVOR env: SYNC_CRONTAB: "0 * * * *" + # Neutron readiness wait before a router flavor reconcile fails. + # Total wait is READY_RETRIES * READY_DELAY seconds. + READY_RETRIES: 30 + READY_DELAY: 10 # When true, removing a NeutronRouterFlavor CR also deletes its unused # operator-managed OpenStack flavor. Enable this before removing the CR. PRUNE: false diff --git a/python/openstack-sync/openstack_sync/hooks/router_flavors.py b/python/openstack-sync/openstack_sync/hooks/router_flavors.py index 8b29dae42..95fb275c5 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -20,6 +20,7 @@ from openstack_sync.plugins.common import ConfigError from openstack_sync.plugins.common import env_bool from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.neutron.router_flavors.create import ServiceProfileCache from openstack_sync.plugins.neutron.router_flavors.delete import prune_removed_flavors from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( CRD_API_VERSION, @@ -306,8 +307,10 @@ def _mark_resources_failed( patch_flavor_status(resource, "Failed", message) -def reconcile_router_flavor_resource(conn: Any, resource: RouterFlavorResource) -> None: - sync_flavor(conn, resource.flavor) +def reconcile_router_flavor_resource( + conn: Any, resource: RouterFlavorResource, profile_cache: ServiceProfileCache +) -> None: + sync_flavor(conn, resource.flavor, profile_cache) def reconcile_router_flavor_resources( @@ -356,9 +359,14 @@ def reconcile_router_flavor_resources( ) continue + # Fetched lazily by driver once per credential group. ensure_profile() + # appends newly created profiles into the same driver cache entry so a + # later flavor with an identical meta_info spec reuses it. + profile_cache: ServiceProfileCache = {} + for resource in credential_resources: try: - reconcile_router_flavor_resource(conn, resource) + reconcile_router_flavor_resource(conn, resource, profile_cache) except Exception as exc: # noqa: BLE001 failed_resources.append(resource) patch_flavor_status(resource, "Failed", str(exc)) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py index 1a2332918..90aea7d63 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py @@ -32,13 +32,26 @@ ) LOG = logging.getLogger(__name__) +ServiceProfileCache = dict[str, list[Any]] -def find_matching_profile(conn: Any, driver: str, meta_info: Any) -> Any | None: +def list_service_profiles(conn: Any, driver: str) -> list[Any]: + """Fetch service profiles for a single driver from Neutron.""" + return list(conn.network.service_profiles(driver=driver)) + + +def service_profiles_for_driver( + conn: Any, driver: str, profile_cache: ServiceProfileCache +) -> list[Any]: + """Return a credential-group cache entry for service profiles by driver.""" + if driver not in profile_cache: + profile_cache[driver] = list_service_profiles(conn, driver) + return profile_cache[driver] + + +def find_matching_profile(profiles: list[Any], meta_info: Any) -> Any | None: matching_profiles = [] - for profile in conn.network.service_profiles(): - if get_value(profile, "driver", default="") != driver: - continue + for profile in profiles: if meta_info_matches(service_profile_meta_info(profile), meta_info): matching_profiles.append(profile) @@ -78,6 +91,7 @@ def ensure_profile( description: str, meta_info: Any, configured_profile_id: str, + profile_cache: ServiceProfileCache, ) -> Any: if configured_profile_id: profile = get_service_profile(conn, configured_profile_id) @@ -109,19 +123,25 @@ def ensure_profile( f"for {name} was not found" ) - profile = find_matching_profile(conn, driver, meta_info) + profiles = service_profiles_for_driver(conn, driver, profile_cache) + profile = find_matching_profile(profiles, meta_info) if profile: profile_id = resource_id(profile) LOG.info("Reusing service profile %s for %s", profile_id, name) return profile LOG.info("Creating service profile for %s driver=%s", name, driver) - return conn.network.create_service_profile( + new_profile = conn.network.create_service_profile( description=description, driver=driver, meta_info=meta_info_payload(managed_meta_info(meta_info)), is_enabled=True, ) + # Make the new profile visible to any later flavor in this same run that + # has an identical (driver, meta_info) spec, so it gets reused instead of + # creating a duplicate profile. + profiles.append(new_profile) + return new_profile def find_flavor(conn: Any, name: str) -> Any | None: diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py index bb0d2756e..aa573fd06 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py @@ -56,7 +56,11 @@ def render_flavor(flavor: Any) -> dict[str, Any]: } -def sync_flavor(conn: Any, flavor_config: dict[str, Any]) -> None: +def sync_flavor( + conn: Any, + flavor_config: dict[str, Any], + profile_cache: create.ServiceProfileCache, +) -> None: name = flavor_config.get("name") driver = flavor_config.get("driver") if not name or not driver: @@ -72,7 +76,7 @@ def sync_flavor(conn: Any, flavor_config: dict[str, Any]) -> None: LOG.info("Reconciling router flavor %s", name) profile = create.ensure_profile( - conn, name, driver, profile_description, meta_info, profile_id + conn, name, driver, profile_description, meta_info, profile_id, profile_cache ) flavor = ensure_flavor(conn, name, service_type, description) flavor = create.ensure_profile_attached(conn, flavor, profile) diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py index b86ee9148..938aa444b 100644 --- a/python/openstack-sync/tests/test_router_flavors.py +++ b/python/openstack-sync/tests/test_router_flavors.py @@ -235,7 +235,7 @@ def test_reconcile_uses_cloudcredentialsref(): assert result == 0 mock_connect.assert_called_once_with("baremetal-manage", "understack") - mock_sync.assert_called_once_with(conn, resource.flavor) + mock_sync.assert_called_once_with(conn, resource.flavor, {}) mock_prune.assert_called_once_with(conn, [resource.flavor]) diff --git a/python/openstack-sync/tests/test_router_flavors_create.py b/python/openstack-sync/tests/test_router_flavors_create.py index 95b2c14bd..0817a57bf 100644 --- a/python/openstack-sync/tests/test_router_flavors_create.py +++ b/python/openstack-sync/tests/test_router_flavors_create.py @@ -50,6 +50,49 @@ def _conn_without_profiles() -> Any: return types.SimpleNamespace(network=network) +# --------------------------------------------------------------------------- +# service profile query cache +# --------------------------------------------------------------------------- + + +def test_list_service_profiles_queries_by_driver(): + network = mock.MagicMock() + network.service_profiles.return_value = [_make_profile("profile-id")] + conn = types.SimpleNamespace(network=network) + + result = create.list_service_profiles(conn, "some.Driver") + + assert result == list(network.service_profiles.return_value) + network.service_profiles.assert_called_once_with(driver="some.Driver") + + +def test_service_profiles_for_driver_caches_per_driver(): + first_driver = "first.Driver" + second_driver = "second.Driver" + first_profile = _make_profile("first-profile", driver=first_driver) + second_profile = _make_profile("second-profile", driver=second_driver) + network = mock.MagicMock() + network.service_profiles.side_effect = [[first_profile], [second_profile]] + conn = types.SimpleNamespace(network=network) + profile_cache: create.ServiceProfileCache = {} + + first_result = create.service_profiles_for_driver(conn, first_driver, profile_cache) + cached_result = create.service_profiles_for_driver( + conn, first_driver, profile_cache + ) + second_result = create.service_profiles_for_driver( + conn, second_driver, profile_cache + ) + + assert first_result == [first_profile] + assert cached_result is first_result + assert second_result == [second_profile] + assert network.service_profiles.call_args_list == [ + mock.call(driver=first_driver), + mock.call(driver=second_driver), + ] + + # --------------------------------------------------------------------------- # _profile_drifted # --------------------------------------------------------------------------- @@ -118,6 +161,7 @@ def test_ensure_profile_logs_warning_on_driver_drift(caplog): description="desc", meta_info={}, configured_profile_id="pinned-id", + profile_cache={}, ) assert result is profile @@ -143,6 +187,7 @@ def test_ensure_profile_logs_warning_on_meta_info_drift(caplog): description="desc", meta_info={"vni_alloc": "on"}, configured_profile_id="pinned-id", + profile_cache={}, ) assert result is profile @@ -166,6 +211,7 @@ def test_ensure_profile_no_warning_when_pinned_profile_matches(caplog): description="desc", meta_info=desired_meta, configured_profile_id="pinned-id", + profile_cache={}, ) assert not caplog.records @@ -188,6 +234,7 @@ def test_ensure_profile_returns_profile_despite_drift(): description="desc", meta_info={}, configured_profile_id="pinned-id", + profile_cache={}, ) assert result is profile @@ -204,6 +251,7 @@ def test_ensure_profile_raises_when_configured_profile_id_is_missing(): description="desc", meta_info={}, configured_profile_id="missing-profile", + profile_cache={}, ) conn.network.service_profiles.assert_not_called() @@ -220,6 +268,7 @@ def test_ensure_profile_creates_service_profile_with_management_markers(): description="desc", meta_info={"vni_alloc": "auto"}, configured_profile_id="", + profile_cache={}, ) kwargs = conn.network.create_service_profile.call_args.kwargs @@ -227,3 +276,107 @@ def test_ensure_profile_creates_service_profile_with_management_markers(): assert meta_info["vni_alloc"] == "auto" for key, value in common.OPERATOR_META_INFO_MARKERS.items(): assert meta_info[key] == value + + +def test_ensure_profile_appends_newly_created_profile_to_driver_cache(): + """A profile created for one flavor must be visible to the next flavor. + + profile_cache is caller-owned and shared across all flavors in the same + credential group during one reconcile pass. If ensure_profile does not + append newly created profiles into the driver's cache entry, two flavors + with an identical (driver, meta_info) spec would each create their own + duplicate profile instead of the second one reusing the first's. + """ + driver = "some.Driver" + meta_info = {"vni_alloc": "auto"} + + # The mock must return a profile whose driver/meta_info actually match + # what was requested, otherwise find_matching_profile would not find it + # on the second call regardless of whether the append happened. + created_profile = _make_profile("new-profile", driver=driver, meta_info=meta_info) + network = mock.MagicMock() + network.get_service_profile.return_value = None + network.service_profiles.return_value = [] + network.create_service_profile.return_value = created_profile + conn = types.SimpleNamespace(network=network) + + profile_cache: create.ServiceProfileCache = {} + + created = create.ensure_profile( + conn, + name="flavor-a", + driver=driver, + description="desc", + meta_info=meta_info, + configured_profile_id="", + profile_cache=profile_cache, + ) + + assert created is created_profile + assert profile_cache == {driver: [created]} + + # A second flavor with the same driver/meta_info, using the now-updated + # shared driver cache, must reuse the profile instead of creating another + # one. + reused = create.ensure_profile( + conn, + name="flavor-b", + driver=driver, + description="desc", + meta_info=meta_info, + configured_profile_id="", + profile_cache=profile_cache, + ) + + assert reused is created + conn.network.create_service_profile.assert_called_once() + conn.network.service_profiles.assert_called_once_with(driver=driver) + + +def test_ensure_profile_does_not_reuse_profiles_across_drivers(): + meta_info = {"vni_alloc": "auto"} + first_driver = "first.Driver" + second_driver = "second.Driver" + first_profile = _make_profile( + "first-profile", driver=first_driver, meta_info=meta_info + ) + second_profile = _make_profile( + "second-profile", driver=second_driver, meta_info=meta_info + ) + network = mock.MagicMock() + network.get_service_profile.return_value = None + network.service_profiles.side_effect = [[], []] + network.create_service_profile.side_effect = [first_profile, second_profile] + conn = types.SimpleNamespace(network=network) + profile_cache: create.ServiceProfileCache = {} + + first_result = create.ensure_profile( + conn, + name="flavor-a", + driver=first_driver, + description="desc", + meta_info=meta_info, + configured_profile_id="", + profile_cache=profile_cache, + ) + second_result = create.ensure_profile( + conn, + name="flavor-b", + driver=second_driver, + description="desc", + meta_info=meta_info, + configured_profile_id="", + profile_cache=profile_cache, + ) + + assert first_result is first_profile + assert second_result is second_profile + assert profile_cache == { + first_driver: [first_profile], + second_driver: [second_profile], + } + assert network.service_profiles.call_args_list == [ + mock.call(driver=first_driver), + mock.call(driver=second_driver), + ] + assert network.create_service_profile.call_count == 2 diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py index fdc503a62..518b107c9 100644 --- a/python/openstack-sync/tests/test_router_flavors_hook.py +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -231,7 +231,7 @@ def test_main_reconciles_binding_context_objects(monkeypatch, tmp_path): mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), mock.patch( "openstack_sync.hooks.router_flavors.sync_flavor", - side_effect=lambda conn, flavor: synced.append(flavor["name"]), + side_effect=lambda conn, flavor, profiles: synced.append(flavor["name"]), ), mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), ): @@ -545,7 +545,7 @@ def test_main_continues_after_failure_and_skips_prune(monkeypatch, tmp_path): monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) seen = [] - def sync_flavor(conn, flavor): + def sync_flavor(conn, flavor, profiles): seen.append(flavor["name"]) if flavor["name"] == "bad-flavor": raise RuntimeError("bad flavor config") diff --git a/python/openstack-sync/tests/test_router_flavors_prune.py b/python/openstack-sync/tests/test_router_flavors_prune.py index 760b64096..15701184b 100644 --- a/python/openstack-sync/tests/test_router_flavors_prune.py +++ b/python/openstack-sync/tests/test_router_flavors_prune.py @@ -37,6 +37,9 @@ def delete_flavor( self, flavor: dict[str, Any], ignore_missing: bool = True ) -> None: self.deleted_flavors.append(flavor["id"]) + self._flavors = [ + current for current in self._flavors if current["id"] != flavor["id"] + ] def test_prune_keeps_manual_flavor_with_managed_service_profile(monkeypatch): @@ -108,6 +111,26 @@ def test_prune_deletes_removed_managed_flavor(monkeypatch): assert conn.network.deleted_flavors == ["managed-flavor-id"] +def test_prune_deletes_removed_managed_flavor_and_unused_profile(monkeypatch): + monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) + profile = _make_orphan_profile("managed-profile-id") + flavor = { + "id": "managed-flavor-id", + "name": "removed-managed-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [profile.id], + } + network = FakeNetworkWithProfiles([flavor], {profile.id: profile}) + conn = SimpleNamespace(network=network) + + delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert network.deleted_flavors == ["managed-flavor-id"] + assert network.deleted_profiles == ["managed-profile-id"] + + # --------------------------------------------------------------------------- # prune_orphaned_service_profiles: second-pass GC for partial-failure orphans # --------------------------------------------------------------------------- From 74fdb039772bc29a1769757b3e45e3788b23f287 Mon Sep 17 00:00:00 2001 From: haseeb Date: Fri, 21 Aug 2026 07:50:55 +0530 Subject: [PATCH 08/13] =?UTF-8?q?making=20hook=20import-safe=20before=20it?= =?UTF-8?q?=20can=20emit=20config=20and=20safe=20updates=20and=20deletes,?= =?UTF-8?q?=20avoid=20O(profiles=20=C3=97=20flavors)=20API=20call=20patter?= =?UTF-8?q?n=20in=20prune?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../openstack_sync/hooks/router_flavors.py | 103 +++++++---- .../openstack_sync/plugins/common.py | 38 ++++ .../plugins/neutron/router_flavors/delete.py | 96 +++++++--- .../router_flavors/router_flavors_common.py | 143 ++++++++++---- .../plugins/neutron/router_flavors/update.py | 24 ++- python/openstack-sync/tests/conftest.py | 17 +- .../tests/test_router_flavors.py | 13 +- .../tests/test_router_flavors_create.py | 4 +- .../tests/test_router_flavors_hook.py | 132 ++++++++++++- .../tests/test_router_flavors_prune.py | 93 ++++++++-- .../tests/test_router_flavors_update.py | 174 ++++++++++++++++++ 11 files changed, 696 insertions(+), 141 deletions(-) create mode 100644 python/openstack-sync/tests/test_router_flavors_update.py diff --git a/python/openstack-sync/openstack_sync/hooks/router_flavors.py b/python/openstack-sync/openstack_sync/hooks/router_flavors.py index 95fb275c5..432c974ea 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -23,23 +23,23 @@ from openstack_sync.plugins.neutron.router_flavors.create import ServiceProfileCache from openstack_sync.plugins.neutron.router_flavors.delete import prune_removed_flavors from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - CRD_API_VERSION, + crd_api_version, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - CRD_BINDING_NAME, + crd_binding_name, ) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import CRD_KIND +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import crd_kind from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - CRD_NAMESPACE, + crd_namespace, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - CRD_RESOURCE, + crd_resource, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - PRUNE_REMOVED_FLAVORS, + prune_removed_flavors_enabled, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - STATUS_ENABLED, + status_enabled, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( wait_for_openstack_network, @@ -89,17 +89,18 @@ def build_hook_config() -> dict[str, Any]: sync_crontab = os.environ.get("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "").strip() namespace = os.environ.get("POD_NAMESPACE") + binding_name = crd_binding_name() kubernetes_binding: dict[str, Any] = { - "name": CRD_BINDING_NAME, - "apiVersion": CRD_API_VERSION, - "kind": CRD_KIND, + "name": binding_name, + "apiVersion": crd_api_version(), + "kind": crd_kind(), "executeHookOnEvent": ["Added", "Modified", "Deleted"], "jqFilter": ".", - "includeSnapshotsFrom": [CRD_BINDING_NAME], + "includeSnapshotsFrom": [binding_name], # Dedicated queue so a slow Neutron readiness wait or reconciliation # only delays this hook's own tasks, not other hooks sharing the # default "main" queue. - "queue": CRD_BINDING_NAME, + "queue": binding_name, } if namespace: kubernetes_binding["namespace"] = { @@ -112,8 +113,8 @@ def build_hook_config() -> dict[str, Any]: { "name": "hourly sync", "crontab": sync_crontab, - "includeSnapshotsFrom": [CRD_BINDING_NAME], - "queue": CRD_BINDING_NAME, + "includeSnapshotsFrom": [binding_name], + "queue": binding_name, } ] return hook_config @@ -192,10 +193,11 @@ def _resources_from_items(items: list[Any], source: str) -> list[RouterFlavorRes def deleted_router_flavor_resources_from_binding_context( contexts: list[dict[str, Any]], ) -> list[RouterFlavorResource]: + binding_name = crd_binding_name() resources: list[RouterFlavorResource] = [] for index, context in enumerate(contexts): if ( - context.get("binding") != CRD_BINDING_NAME + context.get("binding") != binding_name or context.get("type") != "Event" or context.get("watchEvent") != "Deleted" ): @@ -204,13 +206,13 @@ def deleted_router_flavor_resources_from_binding_context( if not obj: LOG.warning( "Deleted %s event has no object; cannot use it for prune credentials", - CRD_KIND, + crd_kind(), ) continue resources.append( _resource_from_object( obj, - f"Deleted event {CRD_BINDING_NAME}[{index}]", + f"Deleted event {binding_name}[{index}]", ) ) @@ -220,13 +222,14 @@ def deleted_router_flavor_resources_from_binding_context( def router_flavor_resources_from_binding_context( contexts: list[dict[str, Any]], ) -> list[RouterFlavorResource] | None: - items = snapshot_items(contexts, CRD_BINDING_NAME) + binding_name = crd_binding_name() + items = snapshot_items(contexts, binding_name) if items is not None: - return _resources_from_items(items, f"Snapshot {CRD_BINDING_NAME}") + return _resources_from_items(items, f"Snapshot {binding_name}") - items = synchronization_items(contexts, CRD_BINDING_NAME) + items = synchronization_items(contexts, binding_name) if items is not None: - return _resources_from_items(items, f"Synchronization {CRD_BINDING_NAME}") + return _resources_from_items(items, f"Synchronization {binding_name}") return None @@ -238,7 +241,7 @@ def load_router_flavor_resources( contexts = read_binding_context() if not contexts: raise ConfigError( - f"Shell-operator binding context is required to load {CRD_KIND} objects" + f"Shell-operator binding context is required to load {crd_kind()} objects" ) resources = router_flavor_resources_from_binding_context(contexts) @@ -247,7 +250,7 @@ def load_router_flavor_resources( raise ConfigError( f"Shell-operator binding context does not contain " - f"{CRD_BINDING_NAME} snapshot or synchronization objects" + f"{crd_binding_name()} snapshot or synchronization objects" ) @@ -261,21 +264,22 @@ def patch_flavor_status( sync_status: str, message: str, ) -> None: + kind = crd_kind() if not resource.name: LOG.warning( "Unable to patch %s status; Kubernetes metadata.name is missing", - CRD_KIND, + kind, ) return patch_resource_status( name=resource.name, - namespace=resource.namespace or CRD_NAMESPACE, + namespace=resource.namespace or crd_namespace(), generation=resource.generation, sync_status=sync_status, message=message, - crd_resource=CRD_RESOURCE, - crd_kind=CRD_KIND, - status_enabled=STATUS_ENABLED, + crd_resource=crd_resource(), + crd_kind=kind, + status_enabled=status_enabled(), current_status=resource.current_status, ) @@ -397,15 +401,50 @@ def reconcile_router_flavor_resources( [resource.flavor for resource in credential_resources], ) - if PRUNE_REMOVED_FLAVORS: + if prune_removed_flavors_enabled(): deleted_only_credentials = set(deleted_resources_by_credentials) - set( grouped_resources ) + deleted_only_prune_failed = False for credentials in sorted(deleted_only_credentials): secret_name, cloud_name = credentials - conn = get_openstack_connection(secret_name, cloud_name) - wait_for_openstack_network(conn) - prune_removed_flavors(conn, [], authoritative_empty_desired=True) + try: + conn = get_openstack_connection(secret_name, cloud_name) + except Exception as exc: # noqa: BLE001 + deleted_only_prune_failed = True + LOG.error( + "Failed to connect to OpenStack for deleted-only prune " + "cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + try: + wait_for_openstack_network(conn) + except Exception as exc: # noqa: BLE001 + deleted_only_prune_failed = True + LOG.error( + "Neutron API unavailable for deleted-only prune " + "cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + try: + prune_removed_flavors(conn, [], authoritative_empty_desired=True) + except Exception as exc: # noqa: BLE001 + deleted_only_prune_failed = True + LOG.error( + "Failed to prune deleted-only flavors cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + + if deleted_only_prune_failed: + return 1 if not grouped_resources and not deleted_resources_by_credentials: LOG.info( diff --git a/python/openstack-sync/openstack_sync/plugins/common.py b/python/openstack-sync/openstack_sync/plugins/common.py index 4d66c316e..39f02c98b 100644 --- a/python/openstack-sync/openstack_sync/plugins/common.py +++ b/python/openstack-sync/openstack_sync/plugins/common.py @@ -39,6 +39,44 @@ def env_bool(name: str, default: bool) -> bool: raise ConfigError(f"{name} must be true or false") +def env_int(name: str, default: int) -> int: + """Return an integer from an environment variable.""" + value = os.environ.get(name) + if value is None: + return default + try: + return int(value) + except ValueError as exc: + raise ConfigError(f"{name} must be an integer") from exc + + +def env_float(name: str, default: float) -> float: + """Return a float from an environment variable.""" + value = os.environ.get(name) + if value is None: + return default + try: + return float(value) + except ValueError as exc: + raise ConfigError(f"{name} must be a number") from exc + + +def env_required(name: str) -> str: + """Return the value of a required environment variable. + + Raises :exc:`ConfigError` when the variable is absent or empty. Use + this for values that must be present at runtime but must not be read at + import time (e.g. CRD identity vars injected by the Helm chart). + """ + value = os.environ.get(name) + if not value: + raise ConfigError( + f"{name} is required but not set; " + "ensure the Helm chart has injected it before the hook runs" + ) + return value + + def env_tuple(name: str, default: str) -> tuple[str, ...]: """Return a tuple of strings parsed from a comma-separated env variable.""" return tuple( diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py index deb95c0c7..0c526c553 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py @@ -3,6 +3,7 @@ from __future__ import annotations import logging +from collections import Counter from typing import Any from openstack_sync.plugins.common import get_service_profile @@ -15,19 +16,19 @@ DEFAULT_SERVICE_TYPE, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - DELETE_UNUSED_SERVICE_PROFILES, + delete_unused_service_profiles_enabled, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - PRUNE_DRIVER_PREFIXES, + is_managed_flavor, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - PRUNE_REMOVED_FLAVORS, + is_managed_service_profile, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - is_managed_flavor, + prune_driver_prefixes, ) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - is_managed_service_profile, + prune_removed_flavors_enabled, ) LOG = logging.getLogger(__name__) @@ -65,17 +66,33 @@ def get_cached_service_profile( def is_prunable_service_profile(profile: Any) -> bool: driver = service_profile_driver(profile) - return bool(PRUNE_DRIVER_PREFIXES) and any( - driver.startswith(prefix) for prefix in PRUNE_DRIVER_PREFIXES - ) + prefixes = prune_driver_prefixes() + return bool(prefixes) and any(driver.startswith(prefix) for prefix in prefixes) -def is_prunable_flavor(conn: Any, flavor: Any) -> bool: +def is_prunable_flavor(flavor: Any) -> bool: if get_value(flavor, "service_type") != DEFAULT_SERVICE_TYPE: return False return is_managed_flavor(flavor) +def service_profile_attachment_counts(flavors: list[Any]) -> Counter[str]: + counts: Counter[str] = Counter() + for flavor in flavors: + counts.update(set(service_profile_ids(flavor))) + return counts + + +def detach_service_profile_ids( + profile_attachment_counts: Counter[str], + profile_ids: list[str], +) -> None: + for profile_id in profile_ids: + profile_attachment_counts[profile_id] -= 1 + if profile_attachment_counts[profile_id] <= 0: + del profile_attachment_counts[profile_id] + + def flavor_has_routers(conn: Any, flavor: Any) -> bool: flavor_id = resource_id(flavor) flavor_name = get_value(flavor, "name", default=flavor_id) @@ -102,11 +119,11 @@ def flavor_has_routers(conn: Any, flavor: Any) -> bool: return False -def service_profile_attached_to_any_flavor(conn: Any, profile_id: str) -> bool: - for flavor in conn.network.flavors(service_type=DEFAULT_SERVICE_TYPE): - if profile_id in service_profile_ids(flavor): - return True - return False +def service_profile_attached_to_any_flavor( + profile_attachment_counts: Counter[str], + profile_id: str, +) -> bool: + return profile_attachment_counts[profile_id] > 0 def maybe_delete_service_profile( @@ -114,8 +131,9 @@ def maybe_delete_service_profile( profile_id: str, protected_profile_ids: set[str], profile_cache: dict[str, Any | None], + profile_attachment_counts: Counter[str], ) -> None: - if not DELETE_UNUSED_SERVICE_PROFILES: + if not delete_unused_service_profiles_enabled(): LOG.info("Keeping service profile %s; profile pruning is disabled", profile_id) return @@ -143,7 +161,7 @@ def maybe_delete_service_profile( LOG.info("Keeping service profile %s; it is not operator-managed", profile_id) return - if service_profile_attached_to_any_flavor(conn, profile_id): + if service_profile_attached_to_any_flavor(profile_attachment_counts, profile_id): LOG.info("Keeping service profile %s; it is still attached", profile_id) return @@ -166,6 +184,7 @@ def delete_removed_flavor( flavor: Any, protected_profile_ids: set[str], profile_cache: dict[str, Any | None], + profile_attachment_counts: Counter[str], ) -> None: flavor_id = resource_id(flavor) flavor_name = get_value(flavor, "name", default=flavor_id) @@ -179,18 +198,25 @@ def delete_removed_flavor( conn.network.delete_flavor(flavor, ignore_missing=True) except Exception as exc: if is_not_found(exc): - return - if is_conflict(exc): + LOG.info("Router flavor %s (%s) is already absent", flavor_name, flavor_id) + elif is_conflict(exc): LOG.info( "Router flavor %s is still in use; skipping delete", flavor_name, ) return - raise + else: + raise + + detach_service_profile_ids(profile_attachment_counts, profile_ids) for profile_id in profile_ids: maybe_delete_service_profile( - conn, profile_id, protected_profile_ids, profile_cache + conn, + profile_id, + protected_profile_ids, + profile_cache, + profile_attachment_counts, ) @@ -198,6 +224,7 @@ def prune_orphaned_service_profiles( conn: Any, protected_profile_ids: set[str], profile_cache: dict[str, Any | None], + profile_attachment_counts: Counter[str], ) -> None: """Delete orphaned operator-managed service profiles. @@ -214,7 +241,11 @@ def prune_orphaned_service_profiles( if not is_managed_service_profile(profile): continue maybe_delete_service_profile( - conn, profile_id, protected_profile_ids, profile_cache + conn, + profile_id, + protected_profile_ids, + profile_cache, + profile_attachment_counts, ) @@ -224,7 +255,7 @@ def prune_removed_flavors( *, authoritative_empty_desired: bool = False, ) -> None: - if not PRUNE_REMOVED_FLAVORS: + if not prune_removed_flavors_enabled(): LOG.info("Router flavor pruning is disabled") return @@ -240,14 +271,27 @@ def prune_removed_flavors( profile_cache: dict[str, Any | None] = {} LOG.info("Pruning removed router flavors") - for flavor in list(conn.network.flavors(service_type=DEFAULT_SERVICE_TYPE)): + current_flavors = list(conn.network.flavors(service_type=DEFAULT_SERVICE_TYPE)) + profile_attachment_counts = service_profile_attachment_counts(current_flavors) + for flavor in current_flavors: flavor_name = get_value(flavor, "name") if not flavor_name or flavor_name in desired_names: continue - if not is_prunable_flavor(conn, flavor): + if not is_prunable_flavor(flavor): continue - delete_removed_flavor(conn, flavor, protected_profile_ids, profile_cache) + delete_removed_flavor( + conn, + flavor, + protected_profile_ids, + profile_cache, + profile_attachment_counts, + ) # Second pass: catch profiles orphaned by a partial failure on a previous # run (delete_flavor succeeded but maybe_delete_service_profile threw). - prune_orphaned_service_profiles(conn, protected_profile_ids, profile_cache) + prune_orphaned_service_profiles( + conn, + protected_profile_ids, + profile_cache, + profile_attachment_counts, + ) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py index c9f931129..8efa152bf 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py @@ -11,6 +11,9 @@ from openstack_sync.plugins.common import comparable_meta_info_without from openstack_sync.plugins.common import env_bool +from openstack_sync.plugins.common import env_float +from openstack_sync.plugins.common import env_int +from openstack_sync.plugins.common import env_required from openstack_sync.plugins.common import env_tuple from openstack_sync.plugins.common import get_value from openstack_sync.plugins.common import managed_meta_info as managed_meta_info_with @@ -21,32 +24,74 @@ # --------------------------------------------------------------------------- # Router-flavor CRD identity # --------------------------------------------------------------------------- -# The chart injects these from the rendered CRD when the hook has an envPrefix. -CRD_API_VERSION = os.environ["NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION"] -CRD_KIND = os.environ["NEUTRON_ROUTER_FLAVOR_CRD_KIND"] -CRD_RESOURCE = os.environ["NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE"] -STATUS_ENABLED = env_bool("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", False) -# Internal shell-operator binding label -- not injected externally. -CRD_BINDING_NAME = os.environ.get( - "NEUTRON_ROUTER_FLAVOR_CRD_BINDING_NAME", - "neutron-router-flavors", -) -CRD_NAMESPACE = os.environ.get("POD_NAMESPACE") +# CRD_API_VERSION, CRD_KIND, and CRD_RESOURCE are injected by the Helm chart +# at runtime and must NOT be read at module import time. Importing this module +# happens before shell-operator invokes the hook with --config, and these vars +# are not guaranteed to be present at that point (e.g. broken chart rendering, +# unit tests that only exercise the --config path). +# +# Use the accessor functions below — crd_api_version(), crd_kind(), +# crd_resource() — everywhere these values are needed. They call +# env_required() which raises ConfigError with a clear message if a var is +# absent, rather than crashing at import with a raw KeyError. +# +# Internal shell-operator binding label default. +CRD_BINDING_NAME = "neutron-router-flavors" DEFAULT_SERVICE_TYPE = "L3_ROUTER_NAT" + +def crd_api_version() -> str: + """Return the CRD API version injected by the Helm chart.""" + return env_required("NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION") + + +def crd_kind() -> str: + """Return the CRD kind injected by the Helm chart.""" + return env_required("NEUTRON_ROUTER_FLAVOR_CRD_KIND") + + +def crd_resource() -> str: + """Return the fully-qualified CRD resource name injected by the Helm chart.""" + return env_required("NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE") + + +def crd_binding_name() -> str: + """Return the shell-operator binding label for the CRD watch.""" + return os.environ.get("NEUTRON_ROUTER_FLAVOR_CRD_BINDING_NAME", CRD_BINDING_NAME) + + +def crd_namespace() -> str | None: + """Return the namespace used for CRD status patches.""" + return os.environ.get("POD_NAMESPACE") + + +def status_enabled() -> bool: + """Return whether CRD status patching is enabled.""" + return env_bool("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", False) + + # --------------------------------------------------------------------------- # Prune / lifecycle config # --------------------------------------------------------------------------- -PRUNE_REMOVED_FLAVORS = env_bool("NEUTRON_ROUTER_FLAVOR_PRUNE", False) -DELETE_UNUSED_SERVICE_PROFILES = env_bool( - "NEUTRON_ROUTER_FLAVOR_DELETE_UNUSED_PROFILES", - True, -) -PRUNE_DRIVER_PREFIXES = env_tuple( - "NEUTRON_ROUTER_FLAVOR_PRUNE_DRIVER_PREFIXES", - "neutron_understack.l3_router.", -) + +def prune_removed_flavors_enabled() -> bool: + """Return whether removed router flavor pruning is enabled.""" + return env_bool("NEUTRON_ROUTER_FLAVOR_PRUNE", False) + + +def delete_unused_service_profiles_enabled() -> bool: + """Return whether unused service profile deletion is enabled.""" + return env_bool("NEUTRON_ROUTER_FLAVOR_DELETE_UNUSED_PROFILES", True) + + +def prune_driver_prefixes() -> tuple[str, ...]: + """Return service profile driver prefixes eligible for pruning.""" + return env_tuple( + "NEUTRON_ROUTER_FLAVOR_PRUNE_DRIVER_PREFIXES", + "neutron_understack.l3_router.", + ) + # --------------------------------------------------------------------------- # Operator ownership markers @@ -64,23 +109,46 @@ MARKER_VERSION_META_INFO_KEY = "_understack_router_flavor_marker_version" MARKER_VERSION_META_INFO_VALUE = "v1" MARKER_SOURCE_META_INFO_KEY = "_understack_router_flavor_source" -MARKER_SOURCE_META_INFO_VALUE = os.environ.get( - "NEUTRON_ROUTER_FLAVOR_SOURCE", - CRD_KIND, -) -OPERATOR_META_INFO_MARKERS: dict[str, str] = { - MANAGED_META_INFO_KEY: MANAGED_META_INFO_VALUE, - MARKER_VERSION_META_INFO_KEY: MARKER_VERSION_META_INFO_VALUE, - MARKER_SOURCE_META_INFO_KEY: MARKER_SOURCE_META_INFO_VALUE, -} -OPERATOR_META_INFO_KEYS = frozenset(OPERATOR_META_INFO_MARKERS) # --------------------------------------------------------------------------- # Retry config # --------------------------------------------------------------------------- -READY_RETRIES = int(os.environ.get("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "30")) -READY_DELAY = float(os.environ.get("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "10")) + +def ready_retries() -> int: + """Return the Neutron readiness retry count.""" + return env_int("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", 30) + + +def ready_delay() -> float: + """Return the Neutron readiness delay in seconds.""" + return env_float("NEUTRON_ROUTER_FLAVOR_READY_DELAY", 10) + + +# --------------------------------------------------------------------------- +# Runtime-resolved marker helpers +# --------------------------------------------------------------------------- +# MARKER_SOURCE defaults to the CRD kind, which is only available at runtime. +# Use marker_source() rather than a module-level constant. + + +def marker_source() -> str: + """Return the marker source value, defaulting to the CRD kind.""" + return os.environ.get("NEUTRON_ROUTER_FLAVOR_SOURCE") or crd_kind() + + +def operator_meta_info_markers() -> dict[str, str]: + """Return the operator ownership marker dict.""" + return { + MANAGED_META_INFO_KEY: MANAGED_META_INFO_VALUE, + MARKER_VERSION_META_INFO_KEY: MARKER_VERSION_META_INFO_VALUE, + MARKER_SOURCE_META_INFO_KEY: marker_source(), + } + + +def operator_meta_info_keys() -> frozenset[str]: + """Return the frozenset of operator marker keys.""" + return frozenset(operator_meta_info_markers()) # --------------------------------------------------------------------------- @@ -90,7 +158,7 @@ def comparable_meta_info(value: Any) -> Any: """Strip operator marker keys from *value* before comparison.""" - return comparable_meta_info_without(value, OPERATOR_META_INFO_KEYS) + return comparable_meta_info_without(value, operator_meta_info_keys()) def meta_info_matches(current: Any, desired: Any) -> bool: @@ -98,12 +166,12 @@ def meta_info_matches(current: Any, desired: Any) -> bool: Operator-managed marker keys are ignored during comparison. """ - return meta_info_matches_without(current, desired, OPERATOR_META_INFO_KEYS) + return meta_info_matches_without(current, desired, operator_meta_info_keys()) def managed_meta_info(value: Any) -> Any: """Merge operator ownership markers into *value*.""" - return managed_meta_info_with(value, OPERATOR_META_INFO_MARKERS) + return managed_meta_info_with(value, operator_meta_info_markers()) # --------------------------------------------------------------------------- @@ -172,6 +240,7 @@ def config_meta_info(flavor_config: dict[str, Any]) -> Any: def wait_for_openstack_network(conn: Any) -> None: """Poll until the Neutron network API is reachable. - Uses ``READY_RETRIES`` and ``READY_DELAY`` from this module's env config. + Reads retry config at call time so malformed values do not break hook + import or shell-operator --config registration. """ - wait_for_network(conn, retries=READY_RETRIES, delay=READY_DELAY) + wait_for_network(conn, retries=ready_retries(), delay=ready_delay()) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py index aa573fd06..708dde60c 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py @@ -34,13 +34,33 @@ def ensure_flavor(conn: Any, name: str, service_type: str, description: str) -> managed_description = managed_flavor_description(description) if flavor: LOG.info("Router flavor %s already exists", name) + + current_service_type = get_value(flavor, "service_type", default="") + if current_service_type != service_type: + raise ConfigError( + f"Router flavor {name!r} already exists in Neutron with " + f"service_type={current_service_type!r}; " + f"expected {service_type!r}. Neutron does not allow updating " + f"service_type on an existing flavor. Rename the CR or remove " + f"the existing Neutron flavor to let the operator recreate it." + ) + current_description = get_value(flavor, "description", default="") description_changed = clean_flavor_description( current_description ) != clean_flavor_description(description) marker_missing = not flavor_description_has_marker(current_description) - if description_changed or marker_missing: - return conn.network.update_flavor(flavor, description=managed_description) + is_disabled = not get_value(flavor, "is_enabled", default=True) + + if is_disabled: + LOG.info("Router flavor %s is disabled in Neutron; re-enabling it", name) + + if description_changed or marker_missing or is_disabled: + return conn.network.update_flavor( + flavor, + description=managed_description, + is_enabled=True, + ) return flavor return create.create_flavor(conn, name, service_type, description) diff --git a/python/openstack-sync/tests/conftest.py b/python/openstack-sync/tests/conftest.py index 3b8237cb4..70eb5a7e1 100644 --- a/python/openstack-sync/tests/conftest.py +++ b/python/openstack-sync/tests/conftest.py @@ -1,21 +1,15 @@ """Pytest configuration and shared fixtures for openstack-sync tests. -Sets environment variables that router_flavors_common.py reads at import time -(os.environ[...] fail-fast vars). These must be present before the module is -first imported, so they are set at collection time via a session-scoped -autouse fixture. +Sets environment variables that router_flavors_common.py reads at runtime +via env_required(). These must be present when any function that calls +crd_kind() / crd_api_version() / crd_resource() runs, so they are set +via a session-scoped autouse fixture that runs before every test. """ from __future__ import annotations -import os - import pytest -# --------------------------------------------------------------------------- -# Required env vars for router_flavors_common - set before any import -# --------------------------------------------------------------------------- - _ROUTER_FLAVOR_REQUIRED_ENV = { "NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION": ( "neutron.understack.rackspace.net/v1alpha1" @@ -26,9 +20,6 @@ ), } -for _key, _value in _ROUTER_FLAVOR_REQUIRED_ENV.items(): - os.environ.setdefault(_key, _value) - @pytest.fixture(autouse=True) def _router_flavor_env(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py index 938aa444b..b3f638b91 100644 --- a/python/openstack-sync/tests/test_router_flavors.py +++ b/python/openstack-sync/tests/test_router_flavors.py @@ -10,6 +10,9 @@ import openstack_sync.utils as utils from openstack_sync.hooks import router_flavors +from openstack_sync.plugins.neutron.router_flavors import ( + router_flavors_common as common, +) FAKE_CLOUDS_YAML = """ clouds: @@ -60,7 +63,7 @@ def _snapshot_context(*objects: dict) -> list[dict]: "binding": "hourly sync", "type": "Schedule", "snapshots": { - router_flavors.CRD_BINDING_NAME: [{"object": obj} for obj in objects], + common.CRD_BINDING_NAME: [{"object": obj} for obj in objects], }, } ] @@ -89,7 +92,7 @@ def test_router_flavor_hook_config_omits_schedule_without_crontab(monkeypatch): config = router_flavors.build_hook_config() - assert config["kubernetes"][0]["name"] == router_flavors.CRD_BINDING_NAME + assert config["kubernetes"][0]["name"] == common.CRD_BINDING_NAME assert "schedule" not in config @@ -100,7 +103,7 @@ def test_router_flavor_hook_config_omits_schedule_with_empty_crontab(monkeypatch config = router_flavors.build_hook_config() - assert config["kubernetes"][0]["name"] == router_flavors.CRD_BINDING_NAME + assert config["kubernetes"][0]["name"] == common.CRD_BINDING_NAME assert "schedule" not in config @@ -114,9 +117,9 @@ def test_router_flavor_hook_config_uses_pod_namespace(monkeypatch): assert config["kubernetes"][0]["namespace"] == { "nameSelector": {"matchNames": ["openstack"]} } - assert config["kubernetes"][0]["queue"] == router_flavors.CRD_BINDING_NAME + assert config["kubernetes"][0]["queue"] == common.CRD_BINDING_NAME assert config["schedule"][0]["crontab"] == "0 * * * *" - assert config["schedule"][0]["queue"] == router_flavors.CRD_BINDING_NAME + assert config["schedule"][0]["queue"] == common.CRD_BINDING_NAME assert "onStartup" not in config diff --git a/python/openstack-sync/tests/test_router_flavors_create.py b/python/openstack-sync/tests/test_router_flavors_create.py index 0817a57bf..ae1685144 100644 --- a/python/openstack-sync/tests/test_router_flavors_create.py +++ b/python/openstack-sync/tests/test_router_flavors_create.py @@ -28,7 +28,7 @@ def _make_profile( ) -> Any: raw_meta = dict(meta_info or {}) if managed: - raw_meta.update(common.OPERATOR_META_INFO_MARKERS) + raw_meta.update(common.operator_meta_info_markers()) return types.SimpleNamespace( id=profile_id, driver=driver, @@ -274,7 +274,7 @@ def test_ensure_profile_creates_service_profile_with_management_markers(): kwargs = conn.network.create_service_profile.call_args.kwargs meta_info = plugin_common.normalize_meta_info(kwargs["meta_info"]) assert meta_info["vni_alloc"] == "auto" - for key, value in common.OPERATOR_META_INFO_MARKERS.items(): + for key, value in common.operator_meta_info_markers().items(): assert meta_info[key] == value diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py index 518b107c9..b7f161fb0 100644 --- a/python/openstack-sync/tests/test_router_flavors_hook.py +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -2,6 +2,7 @@ from __future__ import annotations +import importlib import json from pathlib import Path from unittest import mock @@ -18,10 +19,11 @@ "BINDING_CONTEXT_PATH", "NEUTRON_ROUTER_FLAVOR_ENABLED", "NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", - "NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION", - "NEUTRON_ROUTER_FLAVOR_CRD_KIND", "NEUTRON_ROUTER_FLAVOR_CRD_BINDING_NAME", - "NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE", + "NEUTRON_ROUTER_FLAVOR_PRUNE", + "NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", + "NEUTRON_ROUTER_FLAVOR_READY_RETRIES", + "NEUTRON_ROUTER_FLAVOR_READY_DELAY", "POD_NAMESPACE", ) @@ -94,6 +96,28 @@ def test_disabled_hook_config_is_valid_noop(monkeypatch, capsys): assert json.loads(capsys.readouterr().out) == config +def test_common_import_is_safe_with_bad_runtime_env(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "maybe") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", "maybe") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "soon") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "later") + + importlib.reload(common) + + +def test_disabled_hook_config_does_not_parse_runtime_env(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "maybe") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", "maybe") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "soon") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "later") + + config = hook.build_hook_config() + + assert config["onStartup"] == 10 + assert "kubernetes" not in config + + def test_crontab_does_not_enable_disabled_hook(monkeypatch): clear_env(monkeypatch) monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") @@ -127,8 +151,8 @@ def test_enabled_hook_config_watches_router_flavors(monkeypatch): binding = config["kubernetes"][0] assert "onStartup" not in config assert binding["name"] == common.CRD_BINDING_NAME - assert binding["apiVersion"] == common.CRD_API_VERSION - assert binding["kind"] == common.CRD_KIND + assert binding["apiVersion"] == common.crd_api_version() + assert binding["kind"] == common.crd_kind() assert binding["executeHookOnEvent"] == ["Added", "Modified", "Deleted"] assert binding["jqFilter"] == "." assert binding["includeSnapshotsFrom"] == [common.CRD_BINDING_NAME] @@ -339,7 +363,7 @@ def test_main_prunes_deleted_only_credentials(monkeypatch, tmp_path): clear_env(monkeypatch) monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setattr(hook, "PRUNE_REMOVED_FLAVORS", True) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") conn = mock.MagicMock() context_path = write_binding_context( @@ -377,13 +401,103 @@ def test_main_prunes_deleted_only_credentials(monkeypatch, tmp_path): mock_prune.assert_called_once_with(conn, [], authoritative_empty_desired=True) +def test_main_returns_error_when_deleted_only_connection_fails(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": router_flavor_object("pa1410"), + "snapshots": {common.CRD_BINDING_NAME: []}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + side_effect=RuntimeError("secret missing"), + ) as mock_connect, + mock.patch( + "openstack_sync.hooks.router_flavors.wait_for_openstack_network" + ) as mock_wait, + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 1 + mock_connect.assert_called_once_with("infrasetup", "understack") + mock_wait.assert_not_called() + mock_sync.assert_not_called() + mock_prune.assert_not_called() + + +def test_main_returns_error_when_deleted_only_prune_fails(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + conn = mock.MagicMock() + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": router_flavor_object("pa1410"), + "snapshots": {common.CRD_BINDING_NAME: []}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ) as mock_connect, + mock.patch( + "openstack_sync.hooks.router_flavors.wait_for_openstack_network" + ) as mock_wait, + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors", + side_effect=RuntimeError("delete failed"), + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 1 + mock_connect.assert_called_once_with("infrasetup", "understack") + mock_wait.assert_called_once_with(conn) + mock_sync.assert_not_called() + mock_prune.assert_called_once_with(conn, [], authoritative_empty_desired=True) + + def test_main_ignores_deleted_only_credentials_when_prune_is_disabled( monkeypatch, tmp_path ): clear_env(monkeypatch) monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setattr(hook, "PRUNE_REMOVED_FLAVORS", False) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "false") context_path = write_binding_context( tmp_path, @@ -423,7 +537,7 @@ def test_main_prunes_active_and_deleted_only_credentials(monkeypatch, tmp_path): clear_env(monkeypatch) monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setattr(hook, "PRUNE_REMOVED_FLAVORS", True) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") active_conn = mock.MagicMock(name="active_conn") deleted_conn = mock.MagicMock(name="deleted_conn") @@ -487,7 +601,7 @@ def test_main_skips_empty_snapshot_prune_without_credentials(monkeypatch, tmp_pa clear_env(monkeypatch) monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setattr(hook, "PRUNE_REMOVED_FLAVORS", True) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") context_path = write_binding_context( tmp_path, diff --git a/python/openstack-sync/tests/test_router_flavors_prune.py b/python/openstack-sync/tests/test_router_flavors_prune.py index 15701184b..65da9b660 100644 --- a/python/openstack-sync/tests/test_router_flavors_prune.py +++ b/python/openstack-sync/tests/test_router_flavors_prune.py @@ -11,13 +11,23 @@ ) +def enable_prune(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + + +def enable_profile_delete(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DELETE_UNUSED_PROFILES", "true") + + class FakeNetwork: def __init__(self, flavors: list[dict[str, Any]], profiles: dict[str, Any]): self._flavors = flavors self._profiles = profiles self.deleted_flavors: list[str] = [] + self.flavor_list_calls = 0 def flavors(self, service_type: str | None = None) -> list[dict[str, Any]]: + self.flavor_list_calls += 1 return [ flavor for flavor in self._flavors @@ -43,7 +53,7 @@ def delete_flavor( def test_prune_keeps_manual_flavor_with_managed_service_profile(monkeypatch): - monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + enable_prune(monkeypatch) flavor = { "id": "manual-flavor-id", "name": "manual-flavor", @@ -64,7 +74,7 @@ def test_prune_keeps_manual_flavor_with_managed_service_profile(monkeypatch): def test_prune_keeps_managed_flavors_when_desired_list_is_empty(monkeypatch): - monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + enable_prune(monkeypatch) flavor = { "id": "managed-flavor-id", "name": "removed-managed-flavor", @@ -80,7 +90,7 @@ def test_prune_keeps_managed_flavors_when_desired_list_is_empty(monkeypatch): def test_prune_deletes_managed_flavors_when_empty_desired_is_explicit(monkeypatch): - monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + enable_prune(monkeypatch) flavor = { "id": "managed-flavor-id", "name": "removed-managed-flavor", @@ -96,7 +106,7 @@ def test_prune_deletes_managed_flavors_when_empty_desired_is_explicit(monkeypatc def test_prune_deletes_removed_managed_flavor(monkeypatch): - monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) + enable_prune(monkeypatch) flavor = { "id": "managed-flavor-id", "name": "removed-managed-flavor", @@ -112,8 +122,8 @@ def test_prune_deletes_removed_managed_flavor(monkeypatch): def test_prune_deletes_removed_managed_flavor_and_unused_profile(monkeypatch): - monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) - monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) + enable_prune(monkeypatch) + enable_profile_delete(monkeypatch) profile = _make_orphan_profile("managed-profile-id") flavor = { "id": "managed-flavor-id", @@ -177,35 +187,45 @@ def _make_orphan_profile( def test_prune_orphaned_profiles_deletes_unattached_managed_profile(monkeypatch): """A managed profile with no parent flavor is deleted by the second pass.""" - monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) - monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) + enable_prune(monkeypatch) + enable_profile_delete(monkeypatch) orphan = _make_orphan_profile("orphan-profile-id") # No flavors in Neutron; the orphan's parent was already deleted. network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) conn = SimpleNamespace(network=network) - delete.prune_orphaned_service_profiles(conn, set(), {}) + delete.prune_orphaned_service_profiles( + conn, + set(), + {}, + delete.service_profile_attachment_counts([]), + ) assert "orphan-profile-id" in network.deleted_profiles def test_prune_orphaned_profiles_keeps_protected_profile(monkeypatch): """A profile listed in protected_profile_ids is never deleted.""" - monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) + enable_profile_delete(monkeypatch) orphan = _make_orphan_profile("protected-profile-id") network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) conn = SimpleNamespace(network=network) - delete.prune_orphaned_service_profiles(conn, {"protected-profile-id"}, {}) + delete.prune_orphaned_service_profiles( + conn, + {"protected-profile-id"}, + {}, + delete.service_profile_attachment_counts([]), + ) assert network.deleted_profiles == [] def test_prune_orphaned_profiles_keeps_non_managed_profile(monkeypatch): """A profile without the operator ownership marker is not touched.""" - monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) + enable_profile_delete(monkeypatch) import types unmanaged = types.SimpleNamespace( @@ -216,7 +236,12 @@ def test_prune_orphaned_profiles_keeps_non_managed_profile(monkeypatch): network = FakeNetworkWithProfiles(flavors=[], profiles={unmanaged.id: unmanaged}) conn = SimpleNamespace(network=network) - delete.prune_orphaned_service_profiles(conn, set(), {}) + delete.prune_orphaned_service_profiles( + conn, + set(), + {}, + delete.service_profile_attachment_counts([]), + ) assert network.deleted_profiles == [] @@ -228,8 +253,8 @@ def test_prune_removed_flavors_cleans_up_orphaned_profile_on_next_run(monkeypatc Neutron, so the flavor loop skips it. The second-pass GC should find and delete the orphaned profile. """ - monkeypatch.setattr(delete, "PRUNE_REMOVED_FLAVORS", True) - monkeypatch.setattr(delete, "DELETE_UNUSED_SERVICE_PROFILES", True) + enable_prune(monkeypatch) + enable_profile_delete(monkeypatch) # Neutron state after the partial failure: flavor is gone, profile remains. orphan = _make_orphan_profile("orphan-after-partial-failure") @@ -240,3 +265,41 @@ def test_prune_removed_flavors_cleans_up_orphaned_profile_on_next_run(monkeypatc delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) assert "orphan-after-partial-failure" in network.deleted_profiles + + +def test_prune_removed_flavors_lists_l3_flavors_once_for_profile_checks(monkeypatch): + enable_prune(monkeypatch) + enable_profile_delete(monkeypatch) + + removed_profile = _make_orphan_profile("removed-profile-id") + orphan_profile = _make_orphan_profile("orphan-profile-id") + attached_profile = _make_orphan_profile("attached-profile-id") + removed_flavor = { + "id": "removed-flavor-id", + "name": "removed-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [removed_profile.id], + } + kept_flavor = { + "id": "kept-flavor-id", + "name": "kept-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [attached_profile.id], + } + network = FakeNetworkWithProfiles( + [removed_flavor, kept_flavor], + { + removed_profile.id: removed_profile, + orphan_profile.id: orphan_profile, + attached_profile.id: attached_profile, + }, + ) + conn = SimpleNamespace(network=network) + + delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert network.flavor_list_calls == 1 + assert network.deleted_flavors == ["removed-flavor-id"] + assert network.deleted_profiles == ["removed-profile-id", "orphan-profile-id"] diff --git a/python/openstack-sync/tests/test_router_flavors_update.py b/python/openstack-sync/tests/test_router_flavors_update.py new file mode 100644 index 000000000..3f09cc529 --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_update.py @@ -0,0 +1,174 @@ +"""Tests for update.ensure_flavor — service_type guard and is_enabled reconcile.""" + +from __future__ import annotations + +import types +from typing import Any +from unittest import mock + +import pytest + +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.neutron.router_flavors import update +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + FLAVOR_DESCRIPTION_MARKER, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_NAME = "test-flavor" +_SERVICE_TYPE = "L3_ROUTER_NAT" +_DESCRIPTION = "my flavor" + + +def _make_flavor( + *, + name: str = _NAME, + service_type: str = _SERVICE_TYPE, + description: str = f"{_DESCRIPTION} {FLAVOR_DESCRIPTION_MARKER}", + is_enabled: bool = True, +) -> Any: + return types.SimpleNamespace( + name=name, + service_type=service_type, + description=description, + is_enabled=is_enabled, + ) + + +# --------------------------------------------------------------------------- +# service_type mismatch — must raise ConfigError +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_raises_on_service_type_mismatch(): + flavor = _make_flavor(service_type="DIFFERENT_TYPE") + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + with pytest.raises(ConfigError, match="service_type"): + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + + +def test_ensure_flavor_error_message_contains_both_service_types(): + flavor = _make_flavor(service_type="WRONG") + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + with pytest.raises(ConfigError) as exc_info: + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + msg = str(exc_info.value) + assert "WRONG" in msg + assert _SERVICE_TYPE in msg + assert _NAME in msg + + +# --------------------------------------------------------------------------- +# is_enabled reconcile +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_reenables_disabled_flavor(caplog): + flavor = _make_flavor(is_enabled=False) + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + conn.network.update_flavor.return_value = _make_flavor(is_enabled=True) + with caplog.at_level("INFO", logger="openstack_sync"): + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + + conn.network.update_flavor.assert_called_once() + _, kwargs = conn.network.update_flavor.call_args + assert kwargs["is_enabled"] is True + assert "re-enabling" in caplog.text + + +def test_ensure_flavor_reenables_disabled_flavor_even_when_description_matches(): + """is_enabled=False must trigger an update even if description is current.""" + flavor = _make_flavor(is_enabled=False) + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + conn.network.update_flavor.return_value = _make_flavor(is_enabled=True) + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + + conn.network.update_flavor.assert_called_once() + + +def test_ensure_flavor_no_update_when_already_correct(): + """No Neutron call when description and is_enabled are already correct.""" + flavor = _make_flavor(is_enabled=True) + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + result = update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + + conn.network.update_flavor.assert_not_called() + assert result is flavor + + +# --------------------------------------------------------------------------- +# description drift still triggers update +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_updates_changed_description(): + flavor = _make_flavor(description="old description") + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + conn.network.update_flavor.return_value = _make_flavor() + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, "new description") + + conn.network.update_flavor.assert_called_once() + + +def test_ensure_flavor_adds_missing_marker(): + flavor = _make_flavor(description="no marker here") + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + conn.network.update_flavor.return_value = _make_flavor() + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + + conn.network.update_flavor.assert_called_once() + _, kwargs = conn.network.update_flavor.call_args + assert FLAVOR_DESCRIPTION_MARKER in kwargs["description"] + + +# --------------------------------------------------------------------------- +# flavor not found — creates it +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_creates_when_not_found(): + with ( + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=None, + ), + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.create_flavor", + return_value=_make_flavor(), + ) as mock_create, + ): + conn = mock.MagicMock() + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + + mock_create.assert_called_once_with(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) From aa3a7493d0ca92bb3f0e6f44fe04ff9cd81f7efa Mon Sep 17 00:00:00 2001 From: haseeb Date: Fri, 21 Aug 2026 12:28:38 +0530 Subject: [PATCH 09/13] avoid full-state reconciliation perform event-aware reconcile by reconciling only the changed object. --- .../openstack_sync/hooks/router_flavors.py | 243 +++++++++++++--- .../tests/test_router_flavors.py | 14 +- .../tests/test_router_flavors_hook.py | 261 +++++++++++++++++- 3 files changed, 466 insertions(+), 52 deletions(-) diff --git a/python/openstack-sync/openstack_sync/hooks/router_flavors.py b/python/openstack-sync/openstack_sync/hooks/router_flavors.py index 432c974ea..15422579a 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -68,6 +68,16 @@ class RouterFlavorResource: current_status: dict[str, Any] | None = None +@dataclass(frozen=True) +class RouterFlavorHookInputs: + """Parsed shell-operator context split by reconciliation purpose.""" + + resources_to_reconcile: list[RouterFlavorResource] + desired_resources_for_prune: list[RouterFlavorResource] + deleted_resources: list[RouterFlavorResource] + prune_credentials: frozenset[CredentialKey] + + # --------------------------------------------------------------------------- # Hook configuration # --------------------------------------------------------------------------- @@ -190,6 +200,84 @@ def _resources_from_items(items: list[Any], source: str) -> list[RouterFlavorRes return sorted(resources, key=lambda r: str(r.flavor.get("name", ""))) +def _credentials_for_resources( + resources: list[RouterFlavorResource], +) -> frozenset[CredentialKey]: + return frozenset( + (resource.secret_name, resource.cloud_name) for resource in resources + ) + + +def _router_flavor_event_watch_events( + contexts: list[dict[str, Any]], +) -> frozenset[str] | None: + binding_name = crd_binding_name() + watch_events: set[str] = set() + for context in contexts: + if context.get("binding") != binding_name or context.get("type") != "Event": + continue + watch_event = context.get("watchEvent") + if not isinstance(watch_event, str) or not watch_event: + raise ConfigError( + f"{binding_name} event watchEvent must be a non-empty string" + ) + watch_events.add(watch_event) + return frozenset(watch_events) if watch_events else None + + +def _modified_event_status_is_current(resource: RouterFlavorResource) -> bool: + status = resource.current_status + return ( + resource.generation is not None + and status is not None + and status.get("syncStatus") == "Synced" + and status.get("observedGeneration") == resource.generation + ) + + +def changed_router_flavor_resources_from_binding_context( + contexts: list[dict[str, Any]], +) -> list[RouterFlavorResource] | None: + binding_name = crd_binding_name() + resources: list[RouterFlavorResource] = [] + saw_event = False + for index, context in enumerate(contexts): + if context.get("binding") != binding_name or context.get("type") != "Event": + continue + + saw_event = True + watch_event = context.get("watchEvent") + if watch_event == "Deleted": + continue + if watch_event not in {"Added", "Modified"}: + raise ConfigError( + f"{binding_name} event watchEvent must be Added, Modified, or Deleted" + ) + + obj = context.get("object") + if not obj: + raise ConfigError( + f"{watch_event} event {binding_name}[{index}] object is required" + ) + resource = _resource_from_object( + obj, + f"{watch_event} event {binding_name}[{index}]", + ) + if watch_event == "Modified" and _modified_event_status_is_current(resource): + LOG.info( + "Skipping router flavor %s Modified event; generation %s is already " + "Synced", + _resource_display_name(resource), + resource.generation, + ) + continue + resources.append(resource) + + if not saw_event: + return None + return sorted(resources, key=lambda r: str(r.flavor.get("name", ""))) + + def deleted_router_flavor_resources_from_binding_context( contexts: list[dict[str, Any]], ) -> list[RouterFlavorResource]: @@ -234,9 +322,50 @@ def router_flavor_resources_from_binding_context( return None -def load_router_flavor_resources( +def router_flavor_hook_inputs_from_binding_context( + contexts: list[dict[str, Any]], +) -> RouterFlavorHookInputs | None: + binding_name = crd_binding_name() + event_watch_events = _router_flavor_event_watch_events(contexts) + changed_resources = changed_router_flavor_resources_from_binding_context(contexts) + deleted_resources = deleted_router_flavor_resources_from_binding_context(contexts) + + if event_watch_events is not None: + items = snapshot_items(contexts, binding_name) + if items is None: + raise ConfigError( + f"Shell-operator {binding_name} event context does not contain " + f"{binding_name} snapshot objects" + ) + desired_resources = _resources_from_items(items, f"Snapshot {binding_name}") + if changed_resources or deleted_resources or "Deleted" in event_watch_events: + prune_credentials = _credentials_for_resources( + desired_resources + ) | _credentials_for_resources(deleted_resources) + else: + prune_credentials = frozenset() + return RouterFlavorHookInputs( + resources_to_reconcile=changed_resources or [], + desired_resources_for_prune=desired_resources, + deleted_resources=deleted_resources, + prune_credentials=prune_credentials, + ) + + resources = router_flavor_resources_from_binding_context(contexts) + if resources is not None: + return RouterFlavorHookInputs( + resources_to_reconcile=resources, + desired_resources_for_prune=resources, + deleted_resources=[], + prune_credentials=_credentials_for_resources(resources), + ) + + return None + + +def load_router_flavor_hook_inputs( contexts: list[dict[str, Any]] | None = None, -) -> list[RouterFlavorResource]: +) -> RouterFlavorHookInputs: if contexts is None: contexts = read_binding_context() if not contexts: @@ -244,13 +373,13 @@ def load_router_flavor_resources( f"Shell-operator binding context is required to load {crd_kind()} objects" ) - resources = router_flavor_resources_from_binding_context(contexts) - if resources is not None: - return resources + hook_inputs = router_flavor_hook_inputs_from_binding_context(contexts) + if hook_inputs is not None: + return hook_inputs raise ConfigError( f"Shell-operator binding context does not contain " - f"{crd_binding_name()} snapshot or synchronization objects" + f"{crd_binding_name()} event, snapshot, or synchronization objects" ) @@ -320,17 +449,24 @@ def reconcile_router_flavor_resource( def reconcile_router_flavor_resources( resources: list[RouterFlavorResource], deleted_resources: list[RouterFlavorResource] | None = None, + prune_resources: list[RouterFlavorResource] | None = None, + prune_credentials: frozenset[CredentialKey] | None = None, ) -> int: deleted_resources = deleted_resources or [] + prune_resources = resources if prune_resources is None else prune_resources flavors = [resource.flavor for resource in resources] LOG.info("Found %s router flavor(s) to reconcile", len(flavors)) grouped_resources = _resources_by_credentials(resources) + grouped_prune_resources = _resources_by_credentials(prune_resources) deleted_resources_by_credentials = _resources_by_credentials(deleted_resources) + if prune_credentials is None: + prune_credentials = frozenset(grouped_resources) connections: dict[CredentialKey, Any] = {} failed_resources: list[RouterFlavorResource] = [] - for credentials, credential_resources in grouped_resources.items(): + for credentials in sorted(grouped_resources): + credential_resources = grouped_resources[credentials] secret_name, cloud_name = credentials try: conn = get_openstack_connection(secret_name, cloud_name) @@ -394,26 +530,32 @@ def reconcile_router_flavor_resources( ) return 1 - for credentials, credential_resources in grouped_resources.items(): - conn = connections[credentials] - prune_removed_flavors( - conn, - [resource.flavor for resource in credential_resources], + prune_failed = False + for credentials in sorted(prune_credentials): + secret_name, cloud_name = credentials + desired_resources = grouped_prune_resources.get(credentials, []) + authoritative_empty_desired = ( + credentials in deleted_resources_by_credentials and not desired_resources ) + if not desired_resources and not authoritative_empty_desired: + LOG.info( + "Skipping router flavor prune for cloud=%r secret=%r; no desired " + "router flavors are available", + cloud_name, + secret_name, + ) + continue - if prune_removed_flavors_enabled(): - deleted_only_credentials = set(deleted_resources_by_credentials) - set( - grouped_resources - ) - deleted_only_prune_failed = False - for credentials in sorted(deleted_only_credentials): - secret_name, cloud_name = credentials + conn = connections.get(credentials) + if conn is None: + if not prune_removed_flavors_enabled(): + continue try: conn = get_openstack_connection(secret_name, cloud_name) except Exception as exc: # noqa: BLE001 - deleted_only_prune_failed = True + prune_failed = True LOG.error( - "Failed to connect to OpenStack for deleted-only prune " + "Failed to connect to OpenStack for router flavor prune " "cloud=%r secret=%r: %s", cloud_name, secret_name, @@ -423,34 +565,47 @@ def reconcile_router_flavor_resources( try: wait_for_openstack_network(conn) except Exception as exc: # noqa: BLE001 - deleted_only_prune_failed = True + prune_failed = True LOG.error( - "Neutron API unavailable for deleted-only prune " + "Neutron API unavailable for router flavor prune " "cloud=%r secret=%r: %s", cloud_name, secret_name, exc, ) continue - try: - prune_removed_flavors(conn, [], authoritative_empty_desired=True) - except Exception as exc: # noqa: BLE001 - deleted_only_prune_failed = True - LOG.error( - "Failed to prune deleted-only flavors cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, + connections[credentials] = conn + + try: + desired_flavors = [resource.flavor for resource in desired_resources] + if authoritative_empty_desired: + prune_removed_flavors( + conn, + desired_flavors, + authoritative_empty_desired=True, ) + else: + prune_removed_flavors(conn, desired_flavors) + except Exception as exc: # noqa: BLE001 + prune_failed = True + LOG.error( + "Failed to prune router flavors cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) - if deleted_only_prune_failed: - return 1 + if prune_failed: + return 1 - if not grouped_resources and not deleted_resources_by_credentials: - LOG.info( - "Skipping router flavor prune; no router flavor credentials " - "are available" - ) + if ( + not prune_credentials + and not grouped_resources + and not deleted_resources_by_credentials + ): + LOG.info( + "Skipping router flavor prune; no router flavor credentials are available" + ) LOG.info("Finished reconciling router flavors") return 0 @@ -490,11 +645,13 @@ def main() -> int: try: if not isinstance(binding_contexts, list): raise ConfigError("Shell-operator binding context must be a list") - resources = load_router_flavor_resources(binding_contexts) - deleted_resources = deleted_router_flavor_resources_from_binding_context( - binding_contexts + hook_inputs = load_router_flavor_hook_inputs(binding_contexts) + return reconcile_router_flavor_resources( + hook_inputs.resources_to_reconcile, + hook_inputs.deleted_resources, + hook_inputs.desired_resources_for_prune, + hook_inputs.prune_credentials, ) - return reconcile_router_flavor_resources(resources, deleted_resources) except Exception as exc: # noqa: BLE001 LOG.error("%s", exc) return 1 diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py index b3f638b91..2e3a727bd 100644 --- a/python/openstack-sync/tests/test_router_flavors.py +++ b/python/openstack-sync/tests/test_router_flavors.py @@ -166,7 +166,7 @@ def test_router_flavor_hook_config_printed_on_config_flag(monkeypatch, capsys): # --------------------------------------------------------------------------- -def test_load_router_flavor_resources_keeps_current_status(): +def test_load_router_flavor_hook_inputs_keeps_current_status(): status = { "syncStatus": "Synced", "message": "Successfully reconciled router flavor", @@ -174,9 +174,9 @@ def test_load_router_flavor_resources_keeps_current_status(): } contexts = _snapshot_context(_router_flavor_object("flavor-a", status=status)) - resources = router_flavors.load_router_flavor_resources(contexts) + hook_inputs = router_flavors.load_router_flavor_hook_inputs(contexts) - assert resources[0].current_status == status + assert hook_inputs.resources_to_reconcile[0].current_status == status def test_patch_flavor_status_passes_current_status(): @@ -207,7 +207,7 @@ def test_patch_flavor_status_passes_current_status(): def test_reconcile_uses_cloudcredentialsref(): """Per-resource cloudCredentialsRef is used to connect to OpenStack.""" - resource = router_flavors.load_router_flavor_resources( + resource = router_flavors.load_router_flavor_hook_inputs( _snapshot_context( _router_flavor_object( "test-flavor", @@ -219,7 +219,7 @@ def test_reconcile_uses_cloudcredentialsref(): }, ) ) - )[0] + ).resources_to_reconcile[0] conn = _fake_conn() with ( @@ -252,7 +252,7 @@ def test_reconcile_requires_cloudcredentialsref(): router_flavors.ConfigError, match="cloudCredentialsRef is required", ): - router_flavors.load_router_flavor_resources(_snapshot_context(obj)) + router_flavors.load_router_flavor_hook_inputs(_snapshot_context(obj)) def test_reconcile_requires_complete_cloudcredentialsref(): @@ -269,7 +269,7 @@ def test_reconcile_requires_complete_cloudcredentialsref(): router_flavors.ConfigError, match=r"cloudCredentialsRef\.cloudName", ): - router_flavors.load_router_flavor_resources(_snapshot_context(obj)) + router_flavors.load_router_flavor_hook_inputs(_snapshot_context(obj)) # --------------------------------------------------------------------------- diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py index b7f161fb0..c7e15c63c 100644 --- a/python/openstack-sync/tests/test_router_flavors_hook.py +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -169,7 +169,7 @@ def test_enabled_hook_config_watches_router_flavors(monkeypatch): # --------------------------------------------------------------------------- -# load_router_flavor_resources: binding context parsing +# load_router_flavor_hook_inputs: binding context parsing # --------------------------------------------------------------------------- @@ -200,7 +200,8 @@ def test_load_router_flavors_from_snapshot(monkeypatch, tmp_path): ) monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - resources = hook.load_router_flavor_resources() + hook_inputs = hook.load_router_flavor_hook_inputs() + resources = hook_inputs.resources_to_reconcile assert len(resources) == 1 assert resources[0].name == "dynamic-vrf" @@ -212,6 +213,10 @@ def test_load_router_flavors_from_snapshot(monkeypatch, tmp_path): assert resources[0].secret_name == "infrasetup" # noqa: S105 assert resources[0].cloud_name == "understack" assert "cloudCredentialsRef" not in resources[0].flavor + # Schedule contexts fall through to snapshot parsing, so desired equals + # resources_to_reconcile. + assert hook_inputs.desired_resources_for_prune == resources + assert hook_inputs.deleted_resources == [] # --------------------------------------------------------------------------- @@ -691,3 +696,255 @@ def sync_flavor(conn, flavor, profiles): "Synced", ] mock_prune.assert_not_called() + + +# --------------------------------------------------------------------------- +# Event-driven scenarios: reconcile only the changed CR while prune uses +# the full snapshot delivered by shell-operator. +# --------------------------------------------------------------------------- + + +def router_flavor_object_with_status( + name: str, + *, + generation: int = 3, + status: dict | None = None, + spec: dict | None = None, +) -> dict: + """Build a NeutronRouterFlavor object with optional status/generation. + + Mirrors :func:`router_flavor_object` but allows tests to control the + metadata.generation and status subresource used by the Modified-event + status-current guard. + """ + obj = router_flavor_object(name, spec) + obj["metadata"]["generation"] = generation + if status is not None: + obj["status"] = status + return obj + + +def test_added_event_reconciles_only_added_resource(monkeypatch, tmp_path): + """An Added event reconciles only the new CR; prune sees the full snapshot. + + Regression guard for the noise-on-create scenario: creating a new CR must + not reconcile the four unrelated CRs already present in Neutron. + """ + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + conn = mock.MagicMock() + + added = router_flavor_object("crud_svi") + other_names = ["dynamic_vrf", "pa1410", "static_vrf", "svi"] + snapshot_objects = [router_flavor_object(name) for name in other_names] + [added] + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Added", + "object": added, + "snapshots": { + common.CRD_BINDING_NAME: [ + {"object": obj} for obj in snapshot_objects + ], + }, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + assert [call.args[1]["name"] for call in mock_sync.call_args_list] == ["crud_svi"] + mock_prune.assert_called_once() + prune_flavors = mock_prune.call_args.args[1] + assert sorted(flavor["name"] for flavor in prune_flavors) == sorted( + other_names + ["crud_svi"] + ) + assert "authoritative_empty_desired" not in mock_prune.call_args.kwargs + + +def test_deleted_event_reconciles_none_and_prunes_with_remaining_snapshot( + monkeypatch, tmp_path +): + """Delete of one CR while others remain in the same credential group. + + Regression guard for the exact log scenario: deleting crud_svi while + four remain must not reconcile any of the remaining flavors. Prune + receives the snapshot of the remaining four and does NOT set + authoritative_empty_desired, so it only removes the flavor that is + absent from the snapshot. + """ + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + conn = mock.MagicMock() + + deleted = router_flavor_object("crud_svi") + remaining_names = ["dynamic_vrf", "pa1410", "static_vrf", "svi"] + remaining = [router_flavor_object(name) for name in remaining_names] + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": deleted, + "snapshots": { + common.CRD_BINDING_NAME: [{"object": obj} for obj in remaining], + }, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + mock_sync.assert_not_called() + mock_prune.assert_called_once() + prune_flavors = mock_prune.call_args.args[1] + assert sorted(flavor["name"] for flavor in prune_flavors) == sorted(remaining_names) + # authoritative_empty_desired must NOT be set: snapshot still has items. + assert mock_prune.call_args.kwargs.get("authoritative_empty_desired") is not True + + +def test_modified_event_skipped_when_status_already_current(monkeypatch, tmp_path): + """Status-only Modified events must not trigger OpenStack work. + + The hook's own status patch surfaces as a Modified event with the same + metadata.generation. If status already reflects that generation as Synced, + the hook must skip both reconcile and prune to break the feedback loop. + """ + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + obj = router_flavor_object_with_status( + "crud_svi", + generation=7, + status={ + "syncStatus": "Synced", + "observedGeneration": 7, + "message": "Successfully reconciled router flavor", + }, + ) + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Modified", + "object": obj, + "snapshots": {common.CRD_BINDING_NAME: [{"object": obj}]}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection" + ) as mock_connect, + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + mock_connect.assert_not_called() + mock_sync.assert_not_called() + mock_prune.assert_not_called() + + +def test_modified_event_reconciles_when_generation_bumped(monkeypatch, tmp_path): + """A real spec change bumps metadata.generation past observedGeneration. + + The status-current guard must not skip these events: the spec is drifted + from what the operator last reconciled, so reconcile must run. + """ + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + conn = mock.MagicMock() + + obj = router_flavor_object_with_status( + "crud_svi", + generation=8, + status={ + "syncStatus": "Synced", + "observedGeneration": 7, + "message": "Successfully reconciled router flavor", + }, + ) + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Modified", + "object": obj, + "snapshots": {common.CRD_BINDING_NAME: [{"object": obj}]}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + assert [call.args[1]["name"] for call in mock_sync.call_args_list] == ["crud_svi"] From 7f5910b1c4fe29634902d2c01883694b1c03614c Mon Sep 17 00:00:00 2001 From: haseeb Date: Fri, 21 Aug 2026 15:51:50 +0530 Subject: [PATCH 10/13] support 1-to-many service profiles per flavor --- ...ck.rackspace.net_neutronrouterflavors.yaml | 101 +++-- .../neutron-router-flavors/dynamic-vrf.yaml | 9 +- .../neutron-router-flavors/pa1410.yaml | 9 +- .../neutron-router-flavors/static-vrf.yaml | 9 +- .../neutron-router-flavors/svi.yaml | 6 +- .../plugins/neutron/router_flavors/create.py | 209 +++++---- .../plugins/neutron/router_flavors/delete.py | 24 - .../plugins/neutron/router_flavors/update.py | 72 +-- .../tests/test_router_flavors_create.py | 411 ++++++++---------- .../tests/test_router_flavors_prune.py | 20 - .../tests/test_router_flavors_update.py | 178 +++++++- .../neutron-router-flavor.schema.json | 59 ++- 12 files changed, 663 insertions(+), 444 deletions(-) diff --git a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml index e247180b8..52112ed38 100644 --- a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml +++ b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml @@ -24,9 +24,9 @@ spec: - name: ServiceType type: string jsonPath: .spec.service_type - - name: Driver - type: string - jsonPath: .spec.driver + - name: Enabled + type: boolean + jsonPath: .spec.is_enabled - name: SyncStatus type: string jsonPath: .status.syncStatus @@ -35,7 +35,13 @@ spec: jsonPath: .metadata.creationTimestamp schema: openAPIV3Schema: - description: NeutronRouterFlavor defines one Neutron router flavor and its service profile. + description: >- + NeutronRouterFlavor defines one Neutron router flavor and the + service profiles bound to it. Neutron supports many-to-one from + flavor to service_profile, so ``spec.service_profiles`` is a + list of profile specs. The operator find-or-creates each profile + by ``(driver, meta_info)`` and reconciles the flavor's set of + bound profiles to match. type: object required: - spec @@ -50,7 +56,7 @@ spec: type: object required: - name - - driver + - service_profiles - cloudCredentialsRef properties: cloudCredentialsRef: @@ -94,6 +100,13 @@ spec: minLength: 1 maxLength: 255 default: L3_ROUTER_NAT + is_enabled: + description: >- + Whether the Neutron router flavor is enabled. The operator + reconciles drift toward this value, so setting it to false + disables an operator-managed flavor without deleting it. + type: boolean + default: true service_provider: description: Optional Neutron service provider name used when generating Neutron configuration. type: string @@ -104,37 +117,53 @@ spec: description: Description stored on the Neutron router flavor. type: string maxLength: 1024 - driver: - description: Service profile driver class. - type: string - minLength: 1 - maxLength: 1024 - pattern: ^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$ - profile_description: - description: Description stored on the Neutron service profile. - type: string - maxLength: 1024 - profile_id: - description: Existing Neutron service profile ID to attach instead of creating or discovering one. - type: string - format: uuid - meta_info: - description: Service profile metadata payload. - type: object - properties: - resource_class: - description: Resource class consumed by a physical router provider. - type: string - minLength: 1 - maxLength: 255 - pattern: ^[A-Za-z0-9._:-]+$ - vni_alloc: - description: VNI allocation mode for VRF router providers. - type: string - enum: - - "off" - - "on" - - auto + service_profiles: + description: >- + Service profiles to associate with this flavor. Neutron + supports multiple profiles per flavor and the operator + reconciles the full set: profiles listed here are + find-or-created and associated, and any operator-managed + profile currently attached to the flavor but absent from + this list is disassociated. Unmanaged profiles attached + out-of-band are left untouched. + type: array + minItems: 1 + items: + type: object + required: + - driver + properties: + driver: + description: Service profile driver class. + type: string + minLength: 1 + maxLength: 1024 + pattern: ^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$ + description: + description: Description stored on the Neutron service profile. + type: string + maxLength: 1024 + is_enabled: + description: Whether the service profile is enabled in Neutron. + type: boolean + default: true + meta_info: + description: Service profile metadata payload. + type: object + properties: + resource_class: + description: Resource class consumed by a physical router provider. + type: string + minLength: 1 + maxLength: 255 + pattern: ^[A-Za-z0-9._:-]+$ + vni_alloc: + description: VNI allocation mode for VRF router providers. + type: string + enum: + - "off" + - "on" + - auto status: description: NeutronRouterFlavorStatus defines the observed sync state. type: object diff --git a/components/openstack-sync-plugins/neutron-router-flavors/dynamic-vrf.yaml b/components/openstack-sync-plugins/neutron-router-flavors/dynamic-vrf.yaml index ac5bff691..ff14c3dc1 100644 --- a/components/openstack-sync-plugins/neutron-router-flavors/dynamic-vrf.yaml +++ b/components/openstack-sync-plugins/neutron-router-flavors/dynamic-vrf.yaml @@ -15,7 +15,8 @@ spec: name: dynamic_vrf service_type: L3_ROUTER_NAT description: Dynamic Fabric VRF (auto VNI) - driver: neutron_understack.l3_router.vrf.Vrf - profile_description: Dynamic Fabric VRF (auto VNI) - meta_info: - vni_alloc: auto + service_profiles: + - driver: neutron_understack.l3_router.vrf.Vrf + description: Dynamic Fabric VRF (auto VNI) + meta_info: + vni_alloc: auto diff --git a/components/openstack-sync-plugins/neutron-router-flavors/pa1410.yaml b/components/openstack-sync-plugins/neutron-router-flavors/pa1410.yaml index e71c9391c..62b36ab41 100644 --- a/components/openstack-sync-plugins/neutron-router-flavors/pa1410.yaml +++ b/components/openstack-sync-plugins/neutron-router-flavors/pa1410.yaml @@ -15,7 +15,8 @@ spec: name: pa1410 service_type: L3_ROUTER_NAT description: Physical PA 1410 - driver: neutron_understack.l3_router.palo_alto.PaloAlto - profile_description: Physical PA 1410 - meta_info: - resource_class: pa1410 + service_profiles: + - driver: neutron_understack.l3_router.palo_alto.PaloAlto + description: Physical PA 1410 + meta_info: + resource_class: pa1410 diff --git a/components/openstack-sync-plugins/neutron-router-flavors/static-vrf.yaml b/components/openstack-sync-plugins/neutron-router-flavors/static-vrf.yaml index 16ea464db..af1f5a563 100644 --- a/components/openstack-sync-plugins/neutron-router-flavors/static-vrf.yaml +++ b/components/openstack-sync-plugins/neutron-router-flavors/static-vrf.yaml @@ -15,7 +15,8 @@ spec: name: static_vrf service_type: L3_ROUTER_NAT description: Static Fabric VRF (admin supplied VNI) - driver: neutron_understack.l3_router.vrf.Vrf - profile_description: Static Fabric VRF (admin supplied VNI) - meta_info: - vni_alloc: "on" + service_profiles: + - driver: neutron_understack.l3_router.vrf.Vrf + description: Static Fabric VRF (admin supplied VNI) + meta_info: + vni_alloc: "on" diff --git a/components/openstack-sync-plugins/neutron-router-flavors/svi.yaml b/components/openstack-sync-plugins/neutron-router-flavors/svi.yaml index 6451b4ba2..dff04bcb3 100644 --- a/components/openstack-sync-plugins/neutron-router-flavors/svi.yaml +++ b/components/openstack-sync-plugins/neutron-router-flavors/svi.yaml @@ -15,6 +15,6 @@ spec: name: svi service_type: L3_ROUTER_NAT description: On-Fabric SVI Gateways - driver: neutron_understack.l3_router.svi.Svi - profile_description: On-Fabric SVI Gateways - meta_info: {} + service_profiles: + - driver: neutron_understack.l3_router.svi.Svi + description: On-Fabric SVI Gateways diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py index 90aea7d63..8926b6810 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py @@ -5,16 +5,13 @@ import logging from typing import Any -from openstack_sync.plugins.common import ConfigError from openstack_sync.plugins.common import get_service_profile from openstack_sync.plugins.common import get_value from openstack_sync.plugins.common import is_conflict +from openstack_sync.plugins.common import is_not_found from openstack_sync.plugins.common import meta_info_payload from openstack_sync.plugins.common import resource_id from openstack_sync.plugins.common import service_profile_ids -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - comparable_meta_info, -) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( is_managed_service_profile, ) @@ -50,6 +47,7 @@ def service_profiles_for_driver( def find_matching_profile(profiles: list[Any], meta_info: Any) -> Any | None: + """Return an existing profile matching *meta_info*, preferring managed ones.""" matching_profiles = [] for profile in profiles: if meta_info_matches(service_profile_meta_info(profile), meta_info): @@ -62,80 +60,46 @@ def find_matching_profile(profiles: list[Any], meta_info: Any) -> Any | None: return matching_profiles[0] if matching_profiles else None -def _profile_drifted(profile: Any, driver: str, meta_info: Any) -> list[str]: - """Return drift descriptions between *profile* and the desired spec. - - Neutron rejects ``update_service_profile`` with a 409 once the profile is - attached to service instances, so we cannot reconcile drift. We still - surface it rather than reporting success while spec and reality diverge. - """ - drift = [] - current_driver = get_value(profile, "driver", default="") - if current_driver != driver: - drift.append(f"driver: have={current_driver!r} want={driver!r}") - - if not meta_info_matches(service_profile_meta_info(profile), meta_info): - current_meta = meta_info_payload( - comparable_meta_info(service_profile_meta_info(profile)) - ) - desired_meta = meta_info_payload(comparable_meta_info(meta_info)) - drift.append(f"meta_info: have={current_meta} want={desired_meta}") - - return drift - - def ensure_profile( conn: Any, - name: str, - driver: str, - description: str, - meta_info: Any, - configured_profile_id: str, + flavor_name: str, + profile_spec: dict[str, Any], profile_cache: ServiceProfileCache, ) -> Any: - if configured_profile_id: - profile = get_service_profile(conn, configured_profile_id) - if profile: - profile_id = resource_id(profile) - drift = _profile_drifted(profile, driver, meta_info) - if drift: - LOG.warning( - "service profile %s for %s cannot be updated " - "(Neutron rejects updates to in-use profiles). " - "Spec has drifted: %s. To apply changes, detach all " - "routers from this flavor, remove profile_id from the CR, " - "and re-sync.", - profile_id, - name, - "; ".join(drift), - ) - else: - LOG.info("Using configured service profile %s for %s", profile_id, name) - return profile + """Find or create a service profile matching *profile_spec*. - LOG.error( - "Configured service profile %s for %s was not found", - configured_profile_id, - name, - ) - raise ConfigError( - f"Configured service profile {configured_profile_id} " - f"for {name} was not found" - ) + The CR schema guarantees ``driver`` is present and ``is_enabled`` carries + the CRD default (true). ``description`` and ``meta_info`` are optional in + the schema; missing values fall back to empty. + """ + driver = profile_spec["driver"] + description = profile_spec.get("description", "") + meta_info = profile_spec.get("meta_info", {}) + is_enabled = profile_spec["is_enabled"] profiles = service_profiles_for_driver(conn, driver, profile_cache) profile = find_matching_profile(profiles, meta_info) if profile: profile_id = resource_id(profile) - LOG.info("Reusing service profile %s for %s", profile_id, name) + LOG.info( + "Reusing service profile %s for %s driver=%s", + profile_id, + flavor_name, + driver, + ) return profile - LOG.info("Creating service profile for %s driver=%s", name, driver) + LOG.info( + "Creating service profile for %s driver=%s is_enabled=%s", + flavor_name, + driver, + is_enabled, + ) new_profile = conn.network.create_service_profile( description=description, driver=driver, meta_info=meta_info_payload(managed_meta_info(meta_info)), - is_enabled=True, + is_enabled=is_enabled, ) # Make the new profile visible to any later flavor in this same run that # has an identical (driver, meta_info) spec, so it gets reused instead of @@ -154,34 +118,36 @@ def find_flavor(conn: Any, name: str) -> Any | None: return None -def create_flavor(conn: Any, name: str, service_type: str, description: str) -> Any: - LOG.info("Creating router flavor %s service_type=%s", name, service_type) +def create_flavor( + conn: Any, + name: str, + service_type: str, + description: str, + *, + is_enabled: bool, +) -> Any: + LOG.info( + "Creating router flavor %s service_type=%s is_enabled=%s", + name, + service_type, + is_enabled, + ) return conn.network.create_flavor( name=name, service_type=service_type, - is_enabled=True, + is_enabled=is_enabled, description=managed_flavor_description(description), ) -def ensure_profile_attached(conn: Any, flavor: Any, profile: Any) -> Any: - flavor = conn.network.get_flavor(flavor) +def _associate_profile(conn: Any, flavor: Any, profile: Any) -> None: + """Associate *profile* with *flavor*, treating a 409 as already-associated.""" flavor_id = resource_id(flavor) profile_id = resource_id(profile) - - if profile_id in service_profile_ids(flavor): - flavor_name = get_value(flavor, "name", default=flavor_id) - LOG.info( - "Router flavor %s already has service profile %s", - flavor_name, - profile_id, - ) - return flavor - LOG.info("Binding service profile %s to router flavor %s", profile_id, flavor_id) try: conn.network.associate_flavor_with_service_profile(flavor, profile) - except Exception as exc: + except Exception as exc: # noqa: BLE001 if not is_conflict(exc): raise LOG.info( @@ -190,4 +156,91 @@ def ensure_profile_attached(conn: Any, flavor: Any, profile: Any) -> Any: profile_id, ) + +def _disassociate_profile(conn: Any, flavor: Any, profile: Any) -> None: + """Disassociate *profile* from *flavor*, tolerating not-found/conflict.""" + flavor_id = resource_id(flavor) + profile_id = resource_id(profile) + LOG.info( + "Unbinding operator-managed service profile %s from router flavor %s", + profile_id, + flavor_id, + ) + try: + conn.network.disassociate_flavor_from_service_profile(flavor, profile) + except Exception as exc: # noqa: BLE001 + if is_not_found(exc): + LOG.info( + "Service profile %s already absent from router flavor %s", + profile_id, + flavor_id, + ) + return + if is_conflict(exc): + LOG.warning( + "Cannot unbind service profile %s from router flavor %s " + "(Neutron reports conflict, likely in use); leaving attached", + profile_id, + flavor_id, + ) + return + raise + + +def reconcile_flavor_profiles( + conn: Any, + flavor: Any, + desired_profiles: list[Any], +) -> Any: + """Reconcile the set of service profiles bound to *flavor*. + + ``desired_profiles`` is the list resolved from the CR spec (post + ``ensure_profile``). Profiles missing from the flavor are associated; + operator-managed profiles present on the flavor but absent from the + desired set are disassociated. Unmanaged profiles attached out-of-band + are left untouched so an operator's ad-hoc attachments survive reconcile. + + Returns the flavor re-fetched from Neutron so callers see the current + ``service_profile_ids``. + """ + flavor = conn.network.get_flavor(flavor) + flavor_id = resource_id(flavor) + flavor_name = get_value(flavor, "name", default=flavor_id) + + desired_by_id: dict[str, Any] = {resource_id(p): p for p in desired_profiles} + current_ids = set(service_profile_ids(flavor)) + desired_ids = set(desired_by_id) + + to_associate = desired_ids - current_ids + to_disassociate_candidates = current_ids - desired_ids + + if not to_associate and not to_disassociate_candidates: + LOG.info( + "Router flavor %s already has the desired service profiles %s", + flavor_name, + sorted(current_ids), + ) + return flavor + + for profile_id in sorted(to_associate): + _associate_profile(conn, flavor, desired_by_id[profile_id]) + + for profile_id in sorted(to_disassociate_candidates): + profile = get_service_profile(conn, profile_id) + if profile is None: + LOG.info( + "Service profile %s already absent from Neutron; nothing to unbind", + profile_id, + ) + continue + if not is_managed_service_profile(profile): + LOG.info( + "Keeping unmanaged service profile %s on router flavor %s; " + "operator only unbinds profiles it owns", + profile_id, + flavor_name, + ) + continue + _disassociate_profile(conn, flavor, profile) + return conn.network.get_flavor(flavor) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py index 0c526c553..e35502e39 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py @@ -34,14 +34,6 @@ LOG = logging.getLogger(__name__) -def configured_service_profile_ids(flavors: list[dict[str, Any]]) -> set[str]: - return { - str(flavor_config["profile_id"]) - for flavor_config in flavors - if flavor_config.get("profile_id") - } - - def configured_flavor_names(flavors: list[dict[str, Any]]) -> set[str]: return { str(flavor_config["name"]) @@ -129,7 +121,6 @@ def service_profile_attached_to_any_flavor( def maybe_delete_service_profile( conn: Any, profile_id: str, - protected_profile_ids: set[str], profile_cache: dict[str, Any | None], profile_attachment_counts: Counter[str], ) -> None: @@ -137,14 +128,6 @@ def maybe_delete_service_profile( LOG.info("Keeping service profile %s; profile pruning is disabled", profile_id) return - if profile_id in protected_profile_ids: - LOG.info( - "Keeping service profile %s; it is configured by current router flavor " - "config", - profile_id, - ) - return - profile = get_cached_service_profile(conn, profile_id, profile_cache) if not profile: return @@ -182,7 +165,6 @@ def maybe_delete_service_profile( def delete_removed_flavor( conn: Any, flavor: Any, - protected_profile_ids: set[str], profile_cache: dict[str, Any | None], profile_attachment_counts: Counter[str], ) -> None: @@ -214,7 +196,6 @@ def delete_removed_flavor( maybe_delete_service_profile( conn, profile_id, - protected_profile_ids, profile_cache, profile_attachment_counts, ) @@ -222,7 +203,6 @@ def delete_removed_flavor( def prune_orphaned_service_profiles( conn: Any, - protected_profile_ids: set[str], profile_cache: dict[str, Any | None], profile_attachment_counts: Counter[str], ) -> None: @@ -243,7 +223,6 @@ def prune_orphaned_service_profiles( maybe_delete_service_profile( conn, profile_id, - protected_profile_ids, profile_cache, profile_attachment_counts, ) @@ -267,7 +246,6 @@ def prune_removed_flavors( return desired_names = configured_flavor_names(flavors) - protected_profile_ids = configured_service_profile_ids(flavors) profile_cache: dict[str, Any | None] = {} LOG.info("Pruning removed router flavors") @@ -282,7 +260,6 @@ def prune_removed_flavors( delete_removed_flavor( conn, flavor, - protected_profile_ids, profile_cache, profile_attachment_counts, ) @@ -291,7 +268,6 @@ def prune_removed_flavors( # run (delete_flavor succeeded but maybe_delete_service_profile threw). prune_orphaned_service_profiles( conn, - protected_profile_ids, profile_cache, profile_attachment_counts, ) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py index 708dde60c..416943253 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py @@ -16,9 +16,6 @@ from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( clean_flavor_description, ) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - config_meta_info, -) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( flavor_description_has_marker, ) @@ -29,7 +26,14 @@ LOG = logging.getLogger(__name__) -def ensure_flavor(conn: Any, name: str, service_type: str, description: str) -> Any: +def ensure_flavor( + conn: Any, + name: str, + service_type: str, + description: str, + *, + is_enabled: bool, +) -> Any: flavor = create.find_flavor(conn, name) managed_description = managed_flavor_description(description) if flavor: @@ -50,20 +54,28 @@ def ensure_flavor(conn: Any, name: str, service_type: str, description: str) -> current_description ) != clean_flavor_description(description) marker_missing = not flavor_description_has_marker(current_description) - is_disabled = not get_value(flavor, "is_enabled", default=True) - - if is_disabled: - LOG.info("Router flavor %s is disabled in Neutron; re-enabling it", name) + current_is_enabled = bool(get_value(flavor, "is_enabled", default=True)) + is_enabled_drifted = current_is_enabled != is_enabled + + if is_enabled_drifted: + LOG.info( + "Router flavor %s is_enabled drift: have=%s want=%s; reconciling", + name, + current_is_enabled, + is_enabled, + ) - if description_changed or marker_missing or is_disabled: + if description_changed or marker_missing or is_enabled_drifted: return conn.network.update_flavor( flavor, description=managed_description, - is_enabled=True, + is_enabled=is_enabled, ) return flavor - return create.create_flavor(conn, name, service_type, description) + return create.create_flavor( + conn, name, service_type, description, is_enabled=is_enabled + ) def render_flavor(flavor: Any) -> dict[str, Any]: @@ -72,6 +84,7 @@ def render_flavor(flavor: Any) -> dict[str, Any]: "name": get_value(flavor, "name"), "service_type": get_value(flavor, "service_type"), "description": get_value(flavor, "description"), + "is_enabled": get_value(flavor, "is_enabled"), "service_profile_ids": service_profile_ids(flavor), } @@ -81,25 +94,30 @@ def sync_flavor( flavor_config: dict[str, Any], profile_cache: create.ServiceProfileCache, ) -> None: - name = flavor_config.get("name") - driver = flavor_config.get("driver") - if not name or not driver: - raise ConfigError( - f"Each router flavor entry must define name and driver: {flavor_config}" - ) - - description = flavor_config.get("description", "") - profile_description = flavor_config.get("profile_description", description) + """Reconcile one router flavor CR to the desired Neutron state. + + ``flavor_config`` is the CR spec after cloudCredentialsRef has been + stripped. Schema-required keys are read via subscript so a missing key + fails loudly rather than being silently defaulted; schema-optional keys + (description, meta_info) fall back to their type's empty value. + """ + name = flavor_config["name"] service_type = flavor_config.get("service_type", DEFAULT_SERVICE_TYPE) - profile_id = flavor_config.get("profile_id", "") - meta_info = config_meta_info(flavor_config) + description = flavor_config.get("description", "") + is_enabled = flavor_config["is_enabled"] + profile_specs = flavor_config["service_profiles"] - LOG.info("Reconciling router flavor %s", name) - profile = create.ensure_profile( - conn, name, driver, profile_description, meta_info, profile_id, profile_cache + LOG.info( + "Reconciling router flavor %s with %s service profile(s)", + name, + len(profile_specs), ) - flavor = ensure_flavor(conn, name, service_type, description) - flavor = create.ensure_profile_attached(conn, flavor, profile) + desired_profiles = [ + create.ensure_profile(conn, name, profile_spec, profile_cache) + for profile_spec in profile_specs + ] + flavor = ensure_flavor(conn, name, service_type, description, is_enabled=is_enabled) + flavor = create.reconcile_flavor_profiles(conn, flavor, desired_profiles) LOG.info( "Reconciled router flavor: %s", json.dumps(render_flavor(flavor), sort_keys=True), diff --git a/python/openstack-sync/tests/test_router_flavors_create.py b/python/openstack-sync/tests/test_router_flavors_create.py index ae1685144..7d979b47e 100644 --- a/python/openstack-sync/tests/test_router_flavors_create.py +++ b/python/openstack-sync/tests/test_router_flavors_create.py @@ -1,14 +1,11 @@ -"""Tests for ensure_profile drift detection in create.py.""" +"""Tests for create.py helpers: ensure_profile and reconcile_flavor_profiles.""" from __future__ import annotations -import logging import types from typing import Any from unittest import mock -import pytest - from openstack_sync.plugins import common as plugin_common from openstack_sync.plugins.neutron.router_flavors import create from openstack_sync.plugins.neutron.router_flavors import ( @@ -25,6 +22,7 @@ def _make_profile( driver: str = "neutron_understack.l3_router.vrf.Vrf", meta_info: Any = None, managed: bool = True, + is_enabled: bool = True, ) -> Any: raw_meta = dict(meta_info or {}) if managed: @@ -32,22 +30,35 @@ def _make_profile( return types.SimpleNamespace( id=profile_id, driver=driver, + is_enabled=is_enabled, meta_info=plugin_common.meta_info_payload(raw_meta), ) -def _conn_with_profile(profile: Any) -> Any: - network = mock.MagicMock() - network.get_service_profile.return_value = profile - return types.SimpleNamespace(network=network) +def _make_flavor( + flavor_id: str = "flavor-id", + name: str = "test-flavor", + service_profile_ids: list[str] | None = None, +) -> Any: + return types.SimpleNamespace( + id=flavor_id, + name=name, + service_profile_ids=list(service_profile_ids or []), + ) -def _conn_without_profiles() -> Any: - network = mock.MagicMock() - network.get_service_profile.return_value = None - network.service_profiles.return_value = [] - network.create_service_profile.return_value = _make_profile("new-profile") - return types.SimpleNamespace(network=network) +def _profile_spec( + driver: str = "neutron_understack.l3_router.vrf.Vrf", + description: str = "desc", + meta_info: dict[str, Any] | None = None, + is_enabled: bool = True, +) -> dict[str, Any]: + return { + "driver": driver, + "description": description, + "meta_info": meta_info if meta_info is not None else {}, + "is_enabled": is_enabled, + } # --------------------------------------------------------------------------- @@ -94,240 +105,100 @@ def test_service_profiles_for_driver_caches_per_driver(): # --------------------------------------------------------------------------- -# _profile_drifted -# --------------------------------------------------------------------------- - - -def test_no_drift_when_driver_and_meta_info_match(): - profile = _make_profile("p1", driver="some.Driver", meta_info={"vni_alloc": "auto"}) - assert create._profile_drifted(profile, "some.Driver", {"vni_alloc": "auto"}) == [] - - -def test_drift_detected_on_driver_change(): - profile = _make_profile("p1", driver="old.Driver") - drift = create._profile_drifted(profile, "new.Driver", {}) - assert len(drift) == 1 - assert "driver" in drift[0] - assert "old.Driver" in drift[0] - assert "new.Driver" in drift[0] - - -def test_drift_detected_on_meta_info_change(): - profile = _make_profile("p1", meta_info={"vni_alloc": "auto"}) - drift = create._profile_drifted(profile, profile.driver, {"vni_alloc": "on"}) - assert len(drift) == 1 - assert "meta_info" in drift[0] - - -def test_drift_detected_on_both_fields(): - profile = _make_profile("p1", driver="old.Driver", meta_info={"vni_alloc": "auto"}) - drift = create._profile_drifted(profile, "new.Driver", {"vni_alloc": "on"}) - assert len(drift) == 2 - - -def test_drift_ignores_operator_marker_keys(): - """Operator-injected marker keys must not appear as drift. - - The profile in Neutron has OPERATOR_META_INFO_MARKERS merged in at creation - time. The CR spec only carries user-supplied keys. The comparison must - strip marker keys before diffing so a freshly created profile does not - immediately report drift against its own CR. - """ - desired_meta = {"vni_alloc": "auto"} - profile = _make_profile("p1", meta_info=desired_meta, managed=True) - # The profile's stored meta_info includes marker keys; desired_meta does not. - drift = create._profile_drifted(profile, profile.driver, desired_meta) - assert drift == [] - - -# --------------------------------------------------------------------------- -# ensure_profile: configured_profile_id path drift warning +# ensure_profile: create-or-reuse by (driver, meta_info) # --------------------------------------------------------------------------- -def test_ensure_profile_logs_warning_on_driver_drift(caplog): - """A pinned profile whose driver diverged from the CR emits a WARNING.""" - profile = _make_profile("pinned-id", driver="old.Driver") - conn = _conn_with_profile(profile) - - with caplog.at_level( - logging.WARNING, - logger="openstack_sync.plugins.neutron.router_flavors.create", - ): - result = create.ensure_profile( - conn, - name="test-flavor", - driver="new.Driver", - description="desc", - meta_info={}, - configured_profile_id="pinned-id", - profile_cache={}, - ) - - assert result is profile - output = caplog.text - assert "driver" in output - assert "old.Driver" in output - assert "new.Driver" in output - - -def test_ensure_profile_logs_warning_on_meta_info_drift(caplog): - """A pinned profile whose meta_info diverged from the CR emits a WARNING.""" - profile = _make_profile("pinned-id", meta_info={"vni_alloc": "auto"}) - conn = _conn_with_profile(profile) - - with caplog.at_level( - logging.WARNING, - logger="openstack_sync.plugins.neutron.router_flavors.create", - ): - result = create.ensure_profile( - conn, - name="test-flavor", - driver=profile.driver, - description="desc", - meta_info={"vni_alloc": "on"}, - configured_profile_id="pinned-id", - profile_cache={}, - ) - - assert result is profile - assert "meta_info" in caplog.text - - -def test_ensure_profile_no_warning_when_pinned_profile_matches(caplog): - """A pinned profile that matches the spec emits no WARNING.""" - desired_meta = {"vni_alloc": "auto"} - profile = _make_profile("pinned-id", meta_info=desired_meta, managed=True) - conn = _conn_with_profile(profile) - - with caplog.at_level( - logging.WARNING, - logger="openstack_sync.plugins.neutron.router_flavors.create", - ): - create.ensure_profile( - conn, - name="test-flavor", - driver=profile.driver, - description="desc", - meta_info=desired_meta, - configured_profile_id="pinned-id", - profile_cache={}, - ) - - assert not caplog.records - - -def test_ensure_profile_returns_profile_despite_drift(): - """Even when drift is detected the profile is still returned. - - We cannot fix the drift (Neutron rejects updates on in-use profiles), but - we must not break the reconcile. The flavor should still get bound to the - existing profile so the operator can continue to function. - """ - profile = _make_profile("pinned-id", driver="old.Driver") - conn = _conn_with_profile(profile) +def test_ensure_profile_creates_service_profile_with_management_markers(): + network = mock.MagicMock() + network.service_profiles.return_value = [] + network.create_service_profile.return_value = _make_profile("new-profile") + conn = types.SimpleNamespace(network=network) - result = create.ensure_profile( + create.ensure_profile( conn, - name="test-flavor", - driver="new.Driver", - description="desc", - meta_info={}, - configured_profile_id="pinned-id", + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info={"vni_alloc": "auto"}), profile_cache={}, ) - assert result is profile + kwargs = conn.network.create_service_profile.call_args.kwargs + assert kwargs["driver"] == "neutron_understack.l3_router.vrf.Vrf" + assert kwargs["is_enabled"] is True + meta_info = plugin_common.normalize_meta_info(kwargs["meta_info"]) + assert meta_info["vni_alloc"] == "auto" + for key, value in common.operator_meta_info_markers().items(): + assert meta_info[key] == value -def test_ensure_profile_raises_when_configured_profile_id_is_missing(): - conn = _conn_without_profiles() +def test_ensure_profile_creates_disabled_profile_when_spec_disables(): + network = mock.MagicMock() + network.service_profiles.return_value = [] + network.create_service_profile.return_value = _make_profile( + "new-profile", is_enabled=False + ) + conn = types.SimpleNamespace(network=network) - with pytest.raises(plugin_common.ConfigError, match="missing-profile"): - create.ensure_profile( - conn, - name="test-flavor", - driver="some.Driver", - description="desc", - meta_info={}, - configured_profile_id="missing-profile", - profile_cache={}, - ) + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(is_enabled=False), + profile_cache={}, + ) - conn.network.service_profiles.assert_not_called() - conn.network.create_service_profile.assert_not_called() + assert conn.network.create_service_profile.call_args.kwargs["is_enabled"] is False -def test_ensure_profile_creates_service_profile_with_management_markers(): - conn = _conn_without_profiles() +def test_ensure_profile_reuses_existing_matching_profile(): + """When Neutron already has a managed profile matching (driver, meta_info).""" + meta_info = {"vni_alloc": "auto"} + existing = _make_profile("existing-profile", meta_info=meta_info, managed=True) + network = mock.MagicMock() + network.service_profiles.return_value = [existing] + conn = types.SimpleNamespace(network=network) - create.ensure_profile( + result = create.ensure_profile( conn, - name="test-flavor", - driver="some.Driver", - description="desc", - meta_info={"vni_alloc": "auto"}, - configured_profile_id="", + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info=meta_info), profile_cache={}, ) - kwargs = conn.network.create_service_profile.call_args.kwargs - meta_info = plugin_common.normalize_meta_info(kwargs["meta_info"]) - assert meta_info["vni_alloc"] == "auto" - for key, value in common.operator_meta_info_markers().items(): - assert meta_info[key] == value + assert result is existing + conn.network.create_service_profile.assert_not_called() def test_ensure_profile_appends_newly_created_profile_to_driver_cache(): """A profile created for one flavor must be visible to the next flavor. profile_cache is caller-owned and shared across all flavors in the same - credential group during one reconcile pass. If ensure_profile does not - append newly created profiles into the driver's cache entry, two flavors - with an identical (driver, meta_info) spec would each create their own - duplicate profile instead of the second one reusing the first's. + credential group during one reconcile pass. Two flavors with an identical + ``(driver, meta_info)`` spec must share one profile rather than each + creating a duplicate. """ driver = "some.Driver" meta_info = {"vni_alloc": "auto"} - - # The mock must return a profile whose driver/meta_info actually match - # what was requested, otherwise find_matching_profile would not find it - # on the second call regardless of whether the append happened. created_profile = _make_profile("new-profile", driver=driver, meta_info=meta_info) network = mock.MagicMock() - network.get_service_profile.return_value = None network.service_profiles.return_value = [] network.create_service_profile.return_value = created_profile conn = types.SimpleNamespace(network=network) - profile_cache: create.ServiceProfileCache = {} created = create.ensure_profile( conn, - name="flavor-a", - driver=driver, - description="desc", - meta_info=meta_info, - configured_profile_id="", + flavor_name="flavor-a", + profile_spec=_profile_spec(driver=driver, meta_info=meta_info), profile_cache=profile_cache, ) - - assert created is created_profile - assert profile_cache == {driver: [created]} - - # A second flavor with the same driver/meta_info, using the now-updated - # shared driver cache, must reuse the profile instead of creating another - # one. reused = create.ensure_profile( conn, - name="flavor-b", - driver=driver, - description="desc", - meta_info=meta_info, - configured_profile_id="", + flavor_name="flavor-b", + profile_spec=_profile_spec(driver=driver, meta_info=meta_info), profile_cache=profile_cache, ) + assert created is created_profile assert reused is created conn.network.create_service_profile.assert_called_once() conn.network.service_profiles.assert_called_once_with(driver=driver) @@ -344,7 +215,6 @@ def test_ensure_profile_does_not_reuse_profiles_across_drivers(): "second-profile", driver=second_driver, meta_info=meta_info ) network = mock.MagicMock() - network.get_service_profile.return_value = None network.service_profiles.side_effect = [[], []] network.create_service_profile.side_effect = [first_profile, second_profile] conn = types.SimpleNamespace(network=network) @@ -352,31 +222,126 @@ def test_ensure_profile_does_not_reuse_profiles_across_drivers(): first_result = create.ensure_profile( conn, - name="flavor-a", - driver=first_driver, - description="desc", - meta_info=meta_info, - configured_profile_id="", + flavor_name="flavor-a", + profile_spec=_profile_spec(driver=first_driver, meta_info=meta_info), profile_cache=profile_cache, ) second_result = create.ensure_profile( conn, - name="flavor-b", - driver=second_driver, - description="desc", - meta_info=meta_info, - configured_profile_id="", + flavor_name="flavor-b", + profile_spec=_profile_spec(driver=second_driver, meta_info=meta_info), profile_cache=profile_cache, ) assert first_result is first_profile assert second_result is second_profile - assert profile_cache == { - first_driver: [first_profile], - second_driver: [second_profile], - } - assert network.service_profiles.call_args_list == [ - mock.call(driver=first_driver), - mock.call(driver=second_driver), - ] assert network.create_service_profile.call_count == 2 + + +# --------------------------------------------------------------------------- +# reconcile_flavor_profiles: set-based associate + disassociate-if-managed +# --------------------------------------------------------------------------- + + +def _reconcile_conn( + flavor: Any, disassociate_profile_lookup: dict[str, Any] | None = None +) -> Any: + """Build a connection mock whose network exposes these behaviors. + + * ``get_flavor`` returns *flavor* on every call + * ``associate_flavor_with_service_profile`` succeeds silently + * ``disassociate_flavor_from_service_profile`` succeeds silently + * ``get_service_profile`` returns matching profile from + *disassociate_profile_lookup* so ``is_managed_service_profile`` can be + evaluated on candidates for removal. + """ + lookup = disassociate_profile_lookup or {} + network = mock.MagicMock() + network.get_flavor.return_value = flavor + network.get_service_profile.side_effect = lambda pid: lookup.get(pid) + return types.SimpleNamespace(network=network) + + +def test_reconcile_flavor_profiles_no_op_when_matches(): + """Current == desired → no associate/disassociate calls.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-b"]) + desired = [ + _make_profile("prof-a"), + _make_profile("prof-b"), + ] + conn = _reconcile_conn(flavor) + + result = create.reconcile_flavor_profiles(conn, flavor, desired) + + conn.network.associate_flavor_with_service_profile.assert_not_called() + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + assert result is flavor + + +def test_reconcile_flavor_profiles_associates_missing(): + flavor = _make_flavor(service_profile_ids=[]) + desired = [_make_profile("prof-a"), _make_profile("prof-b")] + conn = _reconcile_conn(flavor) + + create.reconcile_flavor_profiles(conn, flavor, desired) + + associate_calls = conn.network.associate_flavor_with_service_profile.call_args_list + associated_ids = sorted(call.args[1].id for call in associate_calls) + assert associated_ids == ["prof-a", "prof-b"] + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + + +def test_reconcile_flavor_profiles_disassociates_managed_extra(): + """A managed profile currently on the flavor but not desired must be unbound.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-extra"]) + desired = [_make_profile("prof-a")] + extra_profile = _make_profile("prof-extra", managed=True) + conn = _reconcile_conn(flavor, {"prof-extra": extra_profile}) + + create.reconcile_flavor_profiles(conn, flavor, desired) + + conn.network.associate_flavor_with_service_profile.assert_not_called() + conn.network.disassociate_flavor_from_service_profile.assert_called_once() + call = conn.network.disassociate_flavor_from_service_profile.call_args + assert call.args[1] is extra_profile + + +def test_reconcile_flavor_profiles_keeps_unmanaged_extra(): + """An unmanaged profile attached out-of-band must not be disassociated.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-adhoc"]) + desired = [_make_profile("prof-a")] + unmanaged = _make_profile("prof-adhoc", managed=False) + conn = _reconcile_conn(flavor, {"prof-adhoc": unmanaged}) + + create.reconcile_flavor_profiles(conn, flavor, desired) + + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + + +def test_reconcile_flavor_profiles_handles_add_and_remove_together(): + """Simultaneous associate + disassociate in one reconcile pass.""" + flavor = _make_flavor(service_profile_ids=["prof-old"]) + desired = [_make_profile("prof-new")] + old_profile = _make_profile("prof-old", managed=True) + conn = _reconcile_conn(flavor, {"prof-old": old_profile}) + + create.reconcile_flavor_profiles(conn, flavor, desired) + + conn.network.associate_flavor_with_service_profile.assert_called_once() + associate_call = conn.network.associate_flavor_with_service_profile.call_args + assert associate_call.args[1].id == "prof-new" + conn.network.disassociate_flavor_from_service_profile.assert_called_once() + disassociate_call = conn.network.disassociate_flavor_from_service_profile.call_args + assert disassociate_call.args[1] is old_profile + + +def test_reconcile_flavor_profiles_skips_deleted_extra_profile(): + """A candidate for disassociation that no longer exists is a silent no-op.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-gone"]) + desired = [_make_profile("prof-a")] + # Neutron says prof-gone doesn't exist anymore. + conn = _reconcile_conn(flavor, {"prof-gone": None}) + + create.reconcile_flavor_profiles(conn, flavor, desired) + + conn.network.disassociate_flavor_from_service_profile.assert_not_called() diff --git a/python/openstack-sync/tests/test_router_flavors_prune.py b/python/openstack-sync/tests/test_router_flavors_prune.py index 65da9b660..fa38a3557 100644 --- a/python/openstack-sync/tests/test_router_flavors_prune.py +++ b/python/openstack-sync/tests/test_router_flavors_prune.py @@ -197,7 +197,6 @@ def test_prune_orphaned_profiles_deletes_unattached_managed_profile(monkeypatch) delete.prune_orphaned_service_profiles( conn, - set(), {}, delete.service_profile_attachment_counts([]), ) @@ -205,24 +204,6 @@ def test_prune_orphaned_profiles_deletes_unattached_managed_profile(monkeypatch) assert "orphan-profile-id" in network.deleted_profiles -def test_prune_orphaned_profiles_keeps_protected_profile(monkeypatch): - """A profile listed in protected_profile_ids is never deleted.""" - enable_profile_delete(monkeypatch) - - orphan = _make_orphan_profile("protected-profile-id") - network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) - conn = SimpleNamespace(network=network) - - delete.prune_orphaned_service_profiles( - conn, - {"protected-profile-id"}, - {}, - delete.service_profile_attachment_counts([]), - ) - - assert network.deleted_profiles == [] - - def test_prune_orphaned_profiles_keeps_non_managed_profile(monkeypatch): """A profile without the operator ownership marker is not touched.""" enable_profile_delete(monkeypatch) @@ -238,7 +219,6 @@ def test_prune_orphaned_profiles_keeps_non_managed_profile(monkeypatch): delete.prune_orphaned_service_profiles( conn, - set(), {}, delete.service_profile_attachment_counts([]), ) diff --git a/python/openstack-sync/tests/test_router_flavors_update.py b/python/openstack-sync/tests/test_router_flavors_update.py index 3f09cc529..dfb3eb8d8 100644 --- a/python/openstack-sync/tests/test_router_flavors_update.py +++ b/python/openstack-sync/tests/test_router_flavors_update.py @@ -1,4 +1,8 @@ -"""Tests for update.ensure_flavor — service_type guard and is_enabled reconcile.""" +"""Tests for update.ensure_flavor and update.sync_flavor. + +Covers the service_type guard, is_enabled drift reconcile (both directions), +create-with-is_enabled-from-spec, and the sync_flavor spec pass-through. +""" from __future__ import annotations @@ -51,7 +55,9 @@ def test_ensure_flavor_raises_on_service_type_mismatch(): ): conn = mock.MagicMock() with pytest.raises(ConfigError, match="service_type"): - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True + ) def test_ensure_flavor_error_message_contains_both_service_types(): @@ -62,7 +68,9 @@ def test_ensure_flavor_error_message_contains_both_service_types(): ): conn = mock.MagicMock() with pytest.raises(ConfigError) as exc_info: - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True + ) msg = str(exc_info.value) assert "WRONG" in msg assert _SERVICE_TYPE in msg @@ -75,6 +83,7 @@ def test_ensure_flavor_error_message_contains_both_service_types(): def test_ensure_flavor_reenables_disabled_flavor(caplog): + """Neutron has is_enabled=False but spec says True → update to True.""" flavor = _make_flavor(is_enabled=False) with mock.patch( "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", @@ -83,12 +92,54 @@ def test_ensure_flavor_reenables_disabled_flavor(caplog): conn = mock.MagicMock() conn.network.update_flavor.return_value = _make_flavor(is_enabled=True) with caplog.at_level("INFO", logger="openstack_sync"): - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True + ) conn.network.update_flavor.assert_called_once() _, kwargs = conn.network.update_flavor.call_args assert kwargs["is_enabled"] is True - assert "re-enabling" in caplog.text + assert "is_enabled drift" in caplog.text + assert "have=False" in caplog.text + assert "want=True" in caplog.text + + +def test_ensure_flavor_disables_enabled_flavor_when_spec_disables(caplog): + """Neutron has is_enabled=True but spec says False → update to False.""" + flavor = _make_flavor(is_enabled=True) + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + conn.network.update_flavor.return_value = _make_flavor(is_enabled=False) + with caplog.at_level("INFO", logger="openstack_sync"): + update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False + ) + + conn.network.update_flavor.assert_called_once() + _, kwargs = conn.network.update_flavor.call_args + assert kwargs["is_enabled"] is False + assert "is_enabled drift" in caplog.text + assert "have=True" in caplog.text + assert "want=False" in caplog.text + + +def test_ensure_flavor_no_update_when_both_disabled(): + """Neutron has is_enabled=False and spec says False → no Neutron call.""" + flavor = _make_flavor(is_enabled=False) + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + result = update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False + ) + + conn.network.update_flavor.assert_not_called() + assert result is flavor def test_ensure_flavor_reenables_disabled_flavor_even_when_description_matches(): @@ -100,7 +151,7 @@ def test_ensure_flavor_reenables_disabled_flavor_even_when_description_matches() ): conn = mock.MagicMock() conn.network.update_flavor.return_value = _make_flavor(is_enabled=True) - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) conn.network.update_flavor.assert_called_once() @@ -113,7 +164,9 @@ def test_ensure_flavor_no_update_when_already_correct(): return_value=flavor, ): conn = mock.MagicMock() - result = update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + result = update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True + ) conn.network.update_flavor.assert_not_called() assert result is flavor @@ -132,7 +185,9 @@ def test_ensure_flavor_updates_changed_description(): ): conn = mock.MagicMock() conn.network.update_flavor.return_value = _make_flavor() - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, "new description") + update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, "new description", is_enabled=True + ) conn.network.update_flavor.assert_called_once() @@ -145,7 +200,7 @@ def test_ensure_flavor_adds_missing_marker(): ): conn = mock.MagicMock() conn.network.update_flavor.return_value = _make_flavor() - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) conn.network.update_flavor.assert_called_once() _, kwargs = conn.network.update_flavor.call_args @@ -169,6 +224,107 @@ def test_ensure_flavor_creates_when_not_found(): ) as mock_create, ): conn = mock.MagicMock() - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) + + mock_create.assert_called_once_with( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True + ) + + +def test_ensure_flavor_creates_with_is_enabled_from_spec(): + """A CR that opts out of enabled must create the Neutron flavor disabled.""" + with ( + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=None, + ), + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.create_flavor", + return_value=_make_flavor(is_enabled=False), + ) as mock_create, + ): + conn = mock.MagicMock() + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False) + + mock_create.assert_called_once_with( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False + ) + + +# --------------------------------------------------------------------------- +# sync_flavor: reads is_enabled from the CR spec +# --------------------------------------------------------------------------- + + +def _sync_flavor_config(*, is_enabled: bool) -> dict[str, Any]: + """Build a CR-shaped flavor_config. + + ``is_enabled`` mirrors the CRD default (true) that the k8s API server + materialises on admission; every real spec reaching the hook carries it. + """ + return { + "name": _NAME, + "description": _DESCRIPTION, + "service_type": _SERVICE_TYPE, + "is_enabled": is_enabled, + "service_profiles": [ + { + "driver": "neutron_understack.l3_router.vrf.Vrf", + "description": "profile description", + "meta_info": {}, + "is_enabled": True, + } + ], + } + + +def _sync_flavor_mocks(flavor: Any): + """Yield the mock stack used by sync_flavor pass-through tests. + + Uses a real openstacksdk-shaped flavor (SimpleNamespace with + ``service_profile_ids``) so ``render_flavor`` succeeds when + ``sync_flavor`` logs the reconciled result. + """ + rendered = types.SimpleNamespace( + id="flavor-id", + name=_NAME, + service_type=_SERVICE_TYPE, + description=flavor.description, + is_enabled=flavor.is_enabled, + service_profile_ids=["profile-id"], + ) + return ( + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.update.ensure_flavor", + return_value=rendered, + ), + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.ensure_profile" + ), + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create." + "reconcile_flavor_profiles", + return_value=rendered, + ), + ) + + +def test_sync_flavor_passes_is_enabled_true_from_spec(): + """The value the k8s API server put on the CR reaches ensure_flavor.""" + conn = mock.MagicMock() + flavor = _make_flavor(is_enabled=True) + ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) + with ensure_patch as mock_ensure, profile_patch, attached_patch: + update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) + + assert mock_ensure.call_args.kwargs["is_enabled"] is True + + +def test_sync_flavor_passes_is_enabled_false_from_spec(): + conn = mock.MagicMock() + flavor = _make_flavor(is_enabled=False) + ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) + with ensure_patch as mock_ensure, profile_patch, attached_patch: + update.sync_flavor(conn, _sync_flavor_config(is_enabled=False), {}) - mock_create.assert_called_once_with(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION) + assert mock_ensure.call_args.kwargs["is_enabled"] is False diff --git a/schema/openstack-sync/neutron-router-flavor.schema.json b/schema/openstack-sync/neutron-router-flavor.schema.json index 6d3cbebdc..c24c90f58 100644 --- a/schema/openstack-sync/neutron-router-flavor.schema.json +++ b/schema/openstack-sync/neutron-router-flavor.schema.json @@ -26,6 +26,24 @@ "type": "object", "additionalProperties": false, "properties": { + "cloudCredentialsRef": { + "description": "Reference to a Kubernetes Secret containing the OpenStack clouds.yaml.", + "type": "object", + "additionalProperties": false, + "required": ["secretName", "cloudName"], + "properties": { + "secretName": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "cloudName": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, "name": { "description": "Neutron router flavor name.", "type": "string", @@ -52,6 +70,31 @@ "type": "string", "maxLength": 1024 }, + "is_enabled": { + "description": "Whether the Neutron router flavor is enabled.", + "type": "boolean", + "default": true + }, + "service_profiles": { + "description": "Service profiles to associate with this flavor. The operator find-or-creates each profile by (driver, meta_info) and reconciles the flavor's set of bound profiles to match.", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/serviceProfileSpec" + } + } + }, + "required": [ + "name", + "service_profiles" + ] + }, + "serviceProfileSpec": { + "description": "A single service profile entry.", + "type": "object", + "additionalProperties": false, + "required": ["driver"], + "properties": { "driver": { "description": "Service profile driver class.", "type": "string", @@ -59,24 +102,20 @@ "maxLength": 1024, "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)+$" }, - "profile_description": { + "description": { "description": "Description stored on the Neutron service profile.", "type": "string", "maxLength": 1024 }, - "profile_id": { - "description": "Existing Neutron service profile ID to attach instead of creating or discovering one.", - "type": "string", - "format": "uuid" + "is_enabled": { + "description": "Whether the service profile is enabled in Neutron.", + "type": "boolean", + "default": true }, "meta_info": { "$ref": "#/definitions/metaInfo" } - }, - "required": [ - "name", - "driver" - ] + } }, "metaInfo": { "description": "Service profile metainfo payload.", From 69fb223acdee870c49e4c32fc00b36a8ea6da1de Mon Sep 17 00:00:00 2001 From: haseeb Date: Thu, 20 Aug 2026 16:45:57 +0530 Subject: [PATCH 11/13] testing non default params --- components/openstack-sync-operator/values.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/components/openstack-sync-operator/values.yaml b/components/openstack-sync-operator/values.yaml index b985e59ff..a81bc9d7c 100644 --- a/components/openstack-sync-operator/values.yaml +++ b/components/openstack-sync-operator/values.yaml @@ -28,7 +28,7 @@ rbac: # That restart is required because shell-operator reads hook watches only when # the pod starts. plugins: - openstackPlaceholder: false + openstackPlaceholder: true neutronRouterFlavors: false pluginData: @@ -54,4 +54,4 @@ pluginData: READY_DELAY: 10 # When true, removing a NeutronRouterFlavor CR also deletes its unused # operator-managed OpenStack flavor. Enable this before removing the CR. - PRUNE: false + PRUNE: true From 5d5d9c3664bf9289cfdf3cb918d412dd83d303ae Mon Sep 17 00:00:00 2001 From: haseeb Date: Fri, 21 Aug 2026 20:08:18 +0530 Subject: [PATCH 12/13] Service profile is_enabled never verified after reuse or external changes --- .../openstack_sync/hooks/router_flavors.py | 31 +- .../plugins/neutron/router_flavors/create.py | 118 ++++++- .../router_flavors/router_flavors_common.py | 37 +++ .../plugins/neutron/router_flavors/update.py | 23 +- .../tests/test_router_flavors.py | 8 +- .../tests/test_router_flavors_create.py | 295 ++++++++++++++++++ .../tests/test_router_flavors_hook.py | 137 +++++++- .../tests/test_router_flavors_update.py | 47 +++ 8 files changed, 670 insertions(+), 26 deletions(-) diff --git a/python/openstack-sync/openstack_sync/hooks/router_flavors.py b/python/openstack-sync/openstack_sync/hooks/router_flavors.py index 15422579a..ccf2e670a 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -22,6 +22,9 @@ from openstack_sync.plugins.common import get_value from openstack_sync.plugins.neutron.router_flavors.create import ServiceProfileCache from openstack_sync.plugins.neutron.router_flavors.delete import prune_removed_flavors +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + ProfileDrift, +) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( crd_api_version, ) @@ -35,6 +38,9 @@ from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( crd_resource, ) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + describe_profile_drift, +) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( prune_removed_flavors_enabled, ) @@ -442,8 +448,25 @@ def _mark_resources_failed( def reconcile_router_flavor_resource( conn: Any, resource: RouterFlavorResource, profile_cache: ServiceProfileCache -) -> None: - sync_flavor(conn, resource.flavor, profile_cache) +) -> list[ProfileDrift]: + return sync_flavor(conn, resource.flavor, profile_cache) + + +def _synced_status_message(drift: list[ProfileDrift]) -> str: + """Return the Synced status message, qualified by any unfixable drift. + + The flavor really is converged, so the status stays Synced; but reporting a + bare success while a reused service profile diverges from the spec is how a + disabled profile stays invisible until every router create against the + flavor fails. + """ + message = "Successfully reconciled router flavor" + if not drift: + return message + return ( + f"{message}; service profile drift requires manual action: " + f"{describe_profile_drift(drift)}" + ) def reconcile_router_flavor_resources( @@ -506,7 +529,7 @@ def reconcile_router_flavor_resources( for resource in credential_resources: try: - reconcile_router_flavor_resource(conn, resource, profile_cache) + drift = reconcile_router_flavor_resource(conn, resource, profile_cache) except Exception as exc: # noqa: BLE001 failed_resources.append(resource) patch_flavor_status(resource, "Failed", str(exc)) @@ -520,7 +543,7 @@ def reconcile_router_flavor_resources( patch_flavor_status( resource, "Synced", - "Successfully reconciled router flavor", + _synced_status_message(drift), ) if failed_resources: diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py index 8926b6810..3affa683e 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py @@ -12,6 +12,9 @@ from openstack_sync.plugins.common import meta_info_payload from openstack_sync.plugins.common import resource_id from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + ProfileDrift, +) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( is_managed_service_profile, ) @@ -47,17 +50,100 @@ def service_profiles_for_driver( def find_matching_profile(profiles: list[Any], meta_info: Any) -> Any | None: - """Return an existing profile matching *meta_info*, preferring managed ones.""" - matching_profiles = [] + """Return the operator-managed profile matching *meta_info*, if any. + + Only operator-owned profiles are reuse candidates. Reusing a profile the + operator does not own would bind it to the flavor, and + ``reconcile_flavor_profiles`` unbinds only profiles carrying the ownership + marker -- so the operator would have created a binding it can never remove. + That binding outlives the spec that created it, and Neutron's + ``get_flavor_next_provider`` selects an arbitrary binding (``objs[0]``), so a + stale one can end up serving routers with the wrong ``meta_info``. + + An unowned profile that happens to match is therefore left completely + untouched -- not adopted by stamping the ownership marker onto it, which + would enrol somebody else's profile into ``prune_orphaned_service_profiles`` + for eventual deletion -- and ``ensure_profile`` creates a dedicated managed + profile alongside it. + """ + unowned_matches: list[str] = [] for profile in profiles: - if meta_info_matches(service_profile_meta_info(profile), meta_info): - matching_profiles.append(profile) - - for profile in matching_profiles: + if not meta_info_matches(service_profile_meta_info(profile), meta_info): + continue if is_managed_service_profile(profile): return profile + unowned_matches.append(str(get_value(profile, "id", default=""))) - return matching_profiles[0] if matching_profiles else None + if unowned_matches: + LOG.info( + "Not reusing service profile(s) %s: they match the desired meta_info " + "but are not operator-owned, and the operator only binds profiles it " + "can unbind again; creating a dedicated managed profile instead", + sorted(unowned_matches), + ) + + return None + + +def _collect_profile_drift( + profile: Any, + profile_id: str, + driver: str, + flavor_name: str, + *, + description: str, + is_enabled: bool, +) -> list[ProfileDrift]: + """Return the spec fields on a reused *profile* that Neutron disagrees on. + + ``meta_info`` is excluded by construction -- the profile was selected by + matching it -- and ``driver`` is excluded because profiles are queried per + driver. That leaves ``is_enabled`` and ``description``. + + ``is_enabled`` is the consequential one: Neutron's + ``get_flavor_next_provider`` raises ``ServiceProfileDisabled`` (HTTP 503) + when the selected profile is disabled, so every router create against the + flavor fails while the flavor itself still looks healthy. + """ + drifted: list[ProfileDrift] = [] + + current_is_enabled = bool(get_value(profile, "is_enabled", default=True)) + if current_is_enabled != bool(is_enabled): + drifted.append( + ProfileDrift( + profile_id=profile_id, + driver=driver, + field="is_enabled", + have=current_is_enabled, + want=bool(is_enabled), + ) + ) + + current_description = str(get_value(profile, "description", default="")) + if current_description != str(description): + drifted.append( + ProfileDrift( + profile_id=profile_id, + driver=driver, + field="description", + have=current_description, + want=str(description), + ) + ) + + for item in drifted: + LOG.warning( + "Service profile %s reused by router flavor %s has drifted from the " + "CR spec (%s). Neutron rejects updates to a profile bound to any " + "flavor, so the operator cannot correct this; unbind the profile " + "from every flavor to update it, or delete it and let the operator " + "recreate it", + profile_id, + flavor_name, + item.describe(), + ) + + return drifted def ensure_profile( @@ -65,12 +151,20 @@ def ensure_profile( flavor_name: str, profile_spec: dict[str, Any], profile_cache: ServiceProfileCache, + drift: list[ProfileDrift] | None = None, ) -> Any: """Find or create a service profile matching *profile_spec*. The CR schema guarantees ``driver`` is present and ``is_enabled`` carries the CRD default (true). ``description`` and ``meta_info`` are optional in the schema; missing values fall back to empty. + + Only operator-owned profiles are reused (see ``find_matching_profile``). + When a reused profile has drifted from the spec, each drifted field is + logged and appended to *drift* if a list was supplied, so the caller can + surface it on the CR status rather than reporting an unqualified success. + Drift is only ever detected here, because this is the only place that holds + the desired value from the CR spec. """ driver = profile_spec["driver"] description = profile_spec.get("description", "") @@ -87,6 +181,16 @@ def ensure_profile( flavor_name, driver, ) + drifted = _collect_profile_drift( + profile, + profile_id, + driver, + flavor_name, + description=description, + is_enabled=is_enabled, + ) + if drift is not None: + drift.extend(drifted) return profile LOG.info( diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py index 8efa152bf..f44b39db5 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py @@ -7,6 +7,7 @@ from __future__ import annotations import os +from dataclasses import dataclass from typing import Any from openstack_sync.plugins.common import comparable_meta_info_without @@ -222,6 +223,42 @@ def is_managed_service_profile(profile: Any) -> bool: ) +# --------------------------------------------------------------------------- +# Service profile drift reporting +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ProfileDrift: + """One field of a reused service profile that diverged from the CR spec. + + Profile drift is reported, never auto-corrected. Neutron's + ``update_service_profile`` calls ``_ensure_service_profile_not_in_use`` and + raises ``ServiceProfileInUse`` (HTTP 409) while *any* flavor binding exists + -- not merely while a router is using it -- and this operator binds every + profile it manages. An update attempt would therefore fail every cycle. + Correcting drift requires unbinding the profile from every flavor first, + which is an operator decision, not something to do behind their back. + """ + + profile_id: str + driver: str + field: str + have: Any + want: Any + + def describe(self) -> str: + """Return a short ``field: have=... want=...`` description.""" + return f"{self.field}: have={self.have!r} want={self.want!r}" + + +def describe_profile_drift(drift: list[ProfileDrift]) -> str: + """Return a single-line summary of *drift* for logs and CR status.""" + return "; ".join( + f"service profile {item.profile_id} {item.describe()}" for item in drift + ) + + # --------------------------------------------------------------------------- # Config validation # --------------------------------------------------------------------------- diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py index 416943253..77a3e1ac7 100644 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py @@ -13,9 +13,15 @@ from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( DEFAULT_SERVICE_TYPE, ) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + ProfileDrift, +) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( clean_flavor_description, ) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + describe_profile_drift, +) from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( flavor_description_has_marker, ) @@ -93,13 +99,18 @@ def sync_flavor( conn: Any, flavor_config: dict[str, Any], profile_cache: create.ServiceProfileCache, -) -> None: +) -> list[ProfileDrift]: """Reconcile one router flavor CR to the desired Neutron state. ``flavor_config`` is the CR spec after cloudCredentialsRef has been stripped. Schema-required keys are read via subscript so a missing key fails loudly rather than being silently defaulted; schema-optional keys (description, meta_info) fall back to their type's empty value. + + Returns the service profile drift detected while reconciling. An empty list + means spec and Neutron agree. Drift is not a reconcile failure -- the flavor + itself is still converged -- but it needs an operator to act, so the caller + is expected to qualify the status it reports rather than dropping it. """ name = flavor_config["name"] service_type = flavor_config.get("service_type", DEFAULT_SERVICE_TYPE) @@ -112,8 +123,9 @@ def sync_flavor( name, len(profile_specs), ) + drift: list[ProfileDrift] = [] desired_profiles = [ - create.ensure_profile(conn, name, profile_spec, profile_cache) + create.ensure_profile(conn, name, profile_spec, profile_cache, drift) for profile_spec in profile_specs ] flavor = ensure_flavor(conn, name, service_type, description, is_enabled=is_enabled) @@ -122,3 +134,10 @@ def sync_flavor( "Reconciled router flavor: %s", json.dumps(render_flavor(flavor), sort_keys=True), ) + if drift: + LOG.warning( + "Router flavor %s converged but carries service profile drift: %s", + name, + describe_profile_drift(drift), + ) + return drift diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py index 2e3a727bd..761c9d5a7 100644 --- a/python/openstack-sync/tests/test_router_flavors.py +++ b/python/openstack-sync/tests/test_router_flavors.py @@ -229,7 +229,9 @@ def test_reconcile_uses_cloudcredentialsref(): ) as mock_connect, mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors" ) as mock_prune, @@ -298,7 +300,9 @@ def test_main_dispatches_to_reconcile(monkeypatch, tmp_path): mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]), ): result = router_flavors.main() diff --git a/python/openstack-sync/tests/test_router_flavors_create.py b/python/openstack-sync/tests/test_router_flavors_create.py index 7d979b47e..d9a747493 100644 --- a/python/openstack-sync/tests/test_router_flavors_create.py +++ b/python/openstack-sync/tests/test_router_flavors_create.py @@ -2,6 +2,7 @@ from __future__ import annotations +import logging import types from typing import Any from unittest import mock @@ -23,7 +24,14 @@ def _make_profile( meta_info: Any = None, managed: bool = True, is_enabled: bool = True, + description: str = "desc", ) -> Any: + """Build an openstacksdk-shaped service profile. + + ``description`` defaults to the ``_profile_spec`` default so that a profile + and a spec built with defaults are drift-free; drift tests opt in by passing + a mismatching value. + """ raw_meta = dict(meta_info or {}) if managed: raw_meta.update(common.operator_meta_info_markers()) @@ -31,6 +39,7 @@ def _make_profile( id=profile_id, driver=driver, is_enabled=is_enabled, + description=description, meta_info=plugin_common.meta_info_payload(raw_meta), ) @@ -238,6 +247,292 @@ def test_ensure_profile_does_not_reuse_profiles_across_drivers(): assert network.create_service_profile.call_count == 2 +# --------------------------------------------------------------------------- +# find_matching_profile / ensure_profile: only operator-owned profiles are reused +# --------------------------------------------------------------------------- + + +def _reuse_conn(profile: Any) -> Any: + """Build a connection whose only existing service profile is *profile*.""" + network = mock.MagicMock() + network.service_profiles.return_value = [profile] + return types.SimpleNamespace(network=network) + + +def test_find_matching_profile_ignores_unowned_match(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + + assert create.find_matching_profile([unowned], meta_info) is None + + +def test_find_matching_profile_prefers_owned_over_unowned(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + owned = _make_profile("owned-profile", meta_info=meta_info, managed=True) + + assert create.find_matching_profile([unowned, owned], meta_info) is owned + + +def test_ensure_profile_creates_owned_profile_instead_of_reusing_unowned(): + """An unowned profile must never be bound, because it can never be unbound. + + ``reconcile_flavor_profiles`` only unbinds profiles carrying the ownership + marker, so reusing somebody else's profile would create a binding that + outlives the spec that created it and that nothing can ever remove. + """ + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + created = _make_profile("new-profile", meta_info=meta_info, managed=True) + network = mock.MagicMock() + network.service_profiles.return_value = [unowned] + network.create_service_profile.return_value = created + conn = types.SimpleNamespace(network=network) + + result = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info=meta_info), + profile_cache={}, + ) + + assert result is created + network.create_service_profile.assert_called_once() + new_meta = plugin_common.normalize_meta_info( + network.create_service_profile.call_args.kwargs["meta_info"] + ) + for key, value in common.operator_meta_info_markers().items(): + assert new_meta[key] == value + + +def test_ensure_profile_never_adopts_an_unowned_profile(): + """The unowned profile is left alone, not stamped with the ownership marker. + + Adopting it would enrol somebody else's profile into + ``prune_orphaned_service_profiles``, which deletes owned, unattached + profiles -- an irreversible side effect on a resource the operator did not + create. + """ + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + network = mock.MagicMock() + network.service_profiles.return_value = [unowned] + network.create_service_profile.return_value = _make_profile("new-profile") + conn = types.SimpleNamespace(network=network) + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info=meta_info), + profile_cache={}, + ) + + network.update_service_profile.assert_not_called() + network.delete_service_profile.assert_not_called() + + +def test_ensure_profile_reuses_owned_profile_when_unowned_match_also_exists(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + owned = _make_profile("owned-profile", meta_info=meta_info, managed=True) + network = mock.MagicMock() + network.service_profiles.return_value = [unowned, owned] + conn = types.SimpleNamespace(network=network) + + result = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info=meta_info), + profile_cache={}, + ) + + assert result is owned + network.create_service_profile.assert_not_called() + + +def test_profile_created_beside_unowned_match_can_later_be_unbound(): + """End-to-end guard for why unowned profiles are not reused. + + Resolve a profile for spec A while an unowned match exists, then reconcile + the flavor against spec B. The profile bound for spec A must be unbindable, + which holds only because the operator created and owns it. + """ + meta_a = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_a, managed=False) + created_for_a = _make_profile("prof-a", meta_info=meta_a, managed=True) + network = mock.MagicMock() + network.service_profiles.return_value = [unowned] + network.create_service_profile.return_value = created_for_a + conn = types.SimpleNamespace(network=network) + + profile_for_a = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info=meta_a), + profile_cache={}, + ) + + # The spec moves on to a different profile; the flavor still carries prof-a. + flavor = _make_flavor(service_profile_ids=["prof-a"]) + reconcile_conn = _reconcile_conn(flavor, {"prof-a": profile_for_a}) + + create.reconcile_flavor_profiles( + reconcile_conn, flavor, [_make_profile("prof-b", managed=True)] + ) + + disassociate = reconcile_conn.network.disassociate_flavor_from_service_profile + disassociate.assert_called_once() + assert disassociate.call_args.args[1] is profile_for_a + + +# --------------------------------------------------------------------------- +# ensure_profile: drift reporting for reused profiles +# --------------------------------------------------------------------------- + + +def test_ensure_profile_reports_is_enabled_drift_on_reuse(caplog): + """A profile disabled out-of-band is reported instead of silently accepted. + + Neutron's ``get_flavor_next_provider`` raises ``ServiceProfileDisabled`` + when the profile it selects is disabled, so every router create against the + flavor fails while the flavor itself still looks converged. + """ + existing = _make_profile("owned-profile", managed=True, is_enabled=False) + conn = _reuse_conn(existing) + drift: list[common.ProfileDrift] = [] + + with caplog.at_level(logging.WARNING): + result = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(is_enabled=True), + profile_cache={}, + drift=drift, + ) + + assert result is existing + assert [(item.field, item.have, item.want) for item in drift] == [ + ("is_enabled", False, True) + ] + assert drift[0].profile_id == "owned-profile" + assert "is_enabled" in caplog.text + # Neutron rejects updates to a profile bound to any flavor: never try one. + conn.network.update_service_profile.assert_not_called() + + +def test_ensure_profile_reports_description_drift_on_reuse(): + existing = _make_profile("owned-profile", managed=True, description="stale") + conn = _reuse_conn(existing) + drift: list[common.ProfileDrift] = [] + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(description="wanted"), + profile_cache={}, + drift=drift, + ) + + assert [(item.field, item.have, item.want) for item in drift] == [ + ("description", "stale", "wanted") + ] + + +def test_ensure_profile_reports_every_drifted_field(): + existing = _make_profile( + "owned-profile", managed=True, is_enabled=False, description="stale" + ) + conn = _reuse_conn(existing) + drift: list[common.ProfileDrift] = [] + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(description="wanted", is_enabled=True), + profile_cache={}, + drift=drift, + ) + + assert sorted(item.field for item in drift) == ["description", "is_enabled"] + + +def test_ensure_profile_appends_to_existing_drift_collection(): + """sync_flavor passes one list across every profile in the spec.""" + existing = _make_profile("owned-profile", managed=True, is_enabled=False) + conn = _reuse_conn(existing) + already_found = common.ProfileDrift( + profile_id="other-profile", + driver="other.Driver", + field="is_enabled", + have=False, + want=True, + ) + drift = [already_found] + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(is_enabled=True), + profile_cache={}, + drift=drift, + ) + + assert len(drift) == 2 + assert drift[0] is already_found + + +def test_ensure_profile_reports_no_drift_when_profile_matches_spec(): + existing = _make_profile("owned-profile", managed=True) + conn = _reuse_conn(existing) + drift: list[common.ProfileDrift] = [] + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(), + profile_cache={}, + drift=drift, + ) + + assert drift == [] + + +def test_ensure_profile_reports_no_drift_for_freshly_created_profile(): + """A profile the operator just created from the spec cannot have drifted.""" + network = mock.MagicMock() + network.service_profiles.return_value = [] + network.create_service_profile.return_value = _make_profile( + "new-profile", is_enabled=False + ) + conn = types.SimpleNamespace(network=network) + drift: list[common.ProfileDrift] = [] + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(is_enabled=False), + profile_cache={}, + drift=drift, + ) + + assert drift == [] + + +def test_ensure_profile_drift_collection_is_optional(): + """Callers that do not track drift keep working unchanged.""" + existing = _make_profile("owned-profile", managed=True, is_enabled=False) + conn = _reuse_conn(existing) + + result = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(is_enabled=True), + profile_cache={}, + ) + + assert result is existing + + # --------------------------------------------------------------------------- # reconcile_flavor_profiles: set-based associate + disassociate-if-managed # --------------------------------------------------------------------------- diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py index c7e15c63c..d17125ce0 100644 --- a/python/openstack-sync/tests/test_router_flavors_hook.py +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -270,6 +270,101 @@ def test_main_reconciles_binding_context_objects(monkeypatch, tmp_path): assert synced == ["pa1410"] +def _drift_context(monkeypatch, tmp_path) -> None: + """Set up a single-flavor schedule binding context for status assertions.""" + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setattr(utils, "_connection_cache", {}) + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": "hourly sync", + "type": "Schedule", + "snapshots": { + common.CRD_BINDING_NAME: [ + {"object": router_flavor_object("pa1410")}, + ] + }, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + +def test_main_reports_plain_success_when_no_profile_drift(monkeypatch, tmp_path): + """The drift-free status message must stay exactly as it was.""" + _drift_context(monkeypatch, tmp_path) + + with ( + mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=mock.MagicMock(), + ), + mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch( + "openstack_sync.hooks.router_flavors.patch_flavor_status" + ) as mock_status, + mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor", return_value=[]), + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + assert mock_status.call_args.args[1] == "Synced" + assert mock_status.call_args.args[2] == "Successfully reconciled router flavor" + + +def test_main_reports_service_profile_drift_in_synced_status(monkeypatch, tmp_path): + """Drift must reach the CR status. + + The flavor is converged, so the status stays Synced -- but reporting a bare + success is how a disabled service profile stays invisible until every router + create against the flavor fails. + """ + _drift_context(monkeypatch, tmp_path) + drift = [ + common.ProfileDrift( + profile_id="prof-a", + driver="neutron_understack.l3_router.vrf.Vrf", + field="is_enabled", + have=False, + want=True, + ) + ] + + with ( + mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=mock.MagicMock(), + ), + mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch( + "openstack_sync.hooks.router_flavors.patch_flavor_status" + ) as mock_status, + mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=drift + ), + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + # Drift is not a reconcile failure: the flavor still converged. + assert result == 0 + assert mock_status.call_args.args[1] == "Synced" + message = mock_status.call_args.args[2] + assert message.startswith("Successfully reconciled router flavor") + assert "prof-a" in message + assert "is_enabled" in message + + def test_main_returns_error_when_reconcile_fails(monkeypatch, tmp_path): clear_env(monkeypatch) monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") @@ -343,7 +438,9 @@ def test_main_prunes_after_successful_full_set_reconcile(monkeypatch, tmp_path): ), mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors" ) as mock_prune, @@ -392,7 +489,9 @@ def test_main_prunes_deleted_only_credentials(monkeypatch, tmp_path): ) as mock_connect, mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors" ) as mock_prune, @@ -435,7 +534,9 @@ def test_main_returns_error_when_deleted_only_connection_fails(monkeypatch, tmp_ "openstack_sync.hooks.router_flavors.wait_for_openstack_network" ) as mock_wait, mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors" ) as mock_prune, @@ -480,7 +581,9 @@ def test_main_returns_error_when_deleted_only_prune_fails(monkeypatch, tmp_path) "openstack_sync.hooks.router_flavors.wait_for_openstack_network" ) as mock_wait, mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors", side_effect=RuntimeError("delete failed"), @@ -524,7 +627,9 @@ def test_main_ignores_deleted_only_credentials_when_prune_is_disabled( ) as mock_connect, mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors" ) as mock_prune, @@ -586,7 +691,7 @@ def connect(secret_name, cloud_name): ), mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor", return_value=[]), mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors" ) as mock_prune, @@ -626,7 +731,9 @@ def test_main_skips_empty_snapshot_prune_without_credentials(monkeypatch, tmp_pa ) as mock_connect, mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors" ) as mock_prune, @@ -764,7 +871,9 @@ def test_added_event_reconciles_only_added_resource(monkeypatch, tmp_path): ), mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors" ) as mock_prune, @@ -826,7 +935,9 @@ def test_deleted_event_reconciles_none_and_prunes_with_remaining_snapshot( ), mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors" ) as mock_prune, @@ -884,7 +995,9 @@ def test_modified_event_skipped_when_status_already_current(monkeypatch, tmp_pat ) as mock_connect, mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch( "openstack_sync.hooks.router_flavors.prune_removed_flavors" ) as mock_prune, @@ -940,7 +1053,9 @@ def test_modified_event_reconciles_when_generation_bumped(monkeypatch, tmp_path) ), mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), ): diff --git a/python/openstack-sync/tests/test_router_flavors_update.py b/python/openstack-sync/tests/test_router_flavors_update.py index dfb3eb8d8..d6978f15e 100644 --- a/python/openstack-sync/tests/test_router_flavors_update.py +++ b/python/openstack-sync/tests/test_router_flavors_update.py @@ -17,6 +17,9 @@ from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( FLAVOR_DESCRIPTION_MARKER, ) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + ProfileDrift, +) # --------------------------------------------------------------------------- # Helpers @@ -328,3 +331,47 @@ def test_sync_flavor_passes_is_enabled_false_from_spec(): update.sync_flavor(conn, _sync_flavor_config(is_enabled=False), {}) assert mock_ensure.call_args.kwargs["is_enabled"] is False + + +# --------------------------------------------------------------------------- +# sync_flavor: service profile drift reaches the caller +# --------------------------------------------------------------------------- + + +def test_sync_flavor_returns_empty_drift_when_nothing_drifted(): + conn = mock.MagicMock() + flavor = _make_flavor(is_enabled=True) + ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) + with ensure_patch, profile_patch, attached_patch: + result = update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) + + assert result == [] + + +def test_sync_flavor_propagates_profile_drift(): + """Drift collected while resolving profiles is returned to the caller. + + The flavor itself is converged, so this is not a reconcile failure -- but + the caller must be able to qualify the status it reports. + """ + conn = mock.MagicMock() + flavor = _make_flavor(is_enabled=True) + ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) + drifted = ProfileDrift( + profile_id="prof-a", + driver="neutron_understack.l3_router.vrf.Vrf", + field="is_enabled", + have=False, + want=True, + ) + + def ensure_profile(conn, name, profile_spec, profile_cache, drift=None): + if drift is not None: + drift.append(drifted) + return types.SimpleNamespace(id="prof-a") + + with ensure_patch, profile_patch as mock_profile, attached_patch: + mock_profile.side_effect = ensure_profile + result = update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) + + assert result == [drifted] From 350d6359eca0de7faa04b0e17b020c575f5f6354 Mon Sep 17 00:00:00 2001 From: haseeb Date: Fri, 21 Aug 2026 23:49:53 +0530 Subject: [PATCH 13/13] restructure openstack_sync project structure --- ...ck.rackspace.net_neutronrouterflavors.yaml | 6 - python/openstack-sync/README.md | 122 +- .../openstack_sync/hooks/common.py | 12 +- .../openstack_sync/hooks/framework.py | 619 +++++++++ .../openstack_sync/hooks/placeholder.py | 84 +- .../openstack_sync/hooks/router_flavors.py | 717 +--------- .../openstack_sync/plugins/common.py | 135 +- .../plugins/neutron/router_flavors/config.py | 19 + .../plugins/neutron/router_flavors/create.py | 350 ----- .../plugins/neutron/router_flavors/delete.py | 273 ---- .../plugins/neutron/router_flavors/markers.py | 106 ++ .../plugins/neutron/router_flavors/prune.py | 169 +++ .../neutron/router_flavors/reconcile.py | 420 ++++++ .../router_flavors/router_flavors_common.py | 283 ---- .../plugins/neutron/router_flavors/update.py | 143 -- python/openstack-sync/pyproject.toml | 8 +- python/openstack-sync/tests/conftest.py | 62 +- python/openstack-sync/tests/test_framework.py | 767 +++++++++++ .../tests/test_plugins_common.py | 24 +- python/openstack-sync/tests/test_prune.py | 228 ++++ python/openstack-sync/tests/test_reconcile.py | 758 +++++++++++ .../tests/test_router_flavors.py | 349 ----- .../tests/test_router_flavors_create.py | 642 --------- .../tests/test_router_flavors_hook.py | 1150 ++++------------- .../tests/test_router_flavors_prune.py | 285 ---- .../tests/test_router_flavors_update.py | 377 ------ 26 files changed, 3615 insertions(+), 4493 deletions(-) create mode 100644 python/openstack-sync/openstack_sync/hooks/framework.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/config.py delete mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py delete mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/markers.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/prune.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/reconcile.py delete mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py delete mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py create mode 100644 python/openstack-sync/tests/test_framework.py create mode 100644 python/openstack-sync/tests/test_prune.py create mode 100644 python/openstack-sync/tests/test_reconcile.py delete mode 100644 python/openstack-sync/tests/test_router_flavors.py delete mode 100644 python/openstack-sync/tests/test_router_flavors_create.py delete mode 100644 python/openstack-sync/tests/test_router_flavors_prune.py delete mode 100644 python/openstack-sync/tests/test_router_flavors_update.py diff --git a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml index 52112ed38..8778b4d5b 100644 --- a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml +++ b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml @@ -107,12 +107,6 @@ spec: disables an operator-managed flavor without deleting it. type: boolean default: true - service_provider: - description: Optional Neutron service provider name used when generating Neutron configuration. - type: string - minLength: 1 - maxLength: 255 - pattern: ^[A-Za-z0-9._-]+$ description: description: Description stored on the Neutron router flavor. type: string diff --git a/python/openstack-sync/README.md b/python/openstack-sync/README.md index a32ab3a90..d7e6c528b 100644 --- a/python/openstack-sync/README.md +++ b/python/openstack-sync/README.md @@ -2,7 +2,121 @@ Shell-operator package for OpenStack reconciliation hooks. -The operator image ships with a no-op placeholder hook and resource-specific -sync hooks under `openstack_sync/hooks/`. The Neutron router flavor hook is -implemented under `openstack_sync/plugins/neutron/router_flavors/` and exposed -to shell-operator as `/hooks/router_flavors.py`. +Each hook reconciles one Kubernetes CRD into one kind of OpenStack resource. The +generic machinery lives in `openstack_sync/hooks/framework.py`; a plugin supplies +only the parts that are specific to its resource. + +## Layout + +``` +openstack_sync/ + utils.py Kubernetes Secret access + memoised connections + hooks/ + common.py binding-context I/O, CR status patching + framework.py HookConfig, SyncPlugin, run_sync(), run_hook() + placeholder.py connectivity probe (no CRs) + router_flavors.py NeutronRouterFlavor hook + plugins/ + common.py OpenStack helpers shared by all plugins + neutron/router_flavors/ + config.py plugin constants + markers.py ownership markers + reconcile.py converge one CR + prune.py delete resources whose CR was removed +``` + +## What the framework does for you + +`run_sync` groups CRs by the credentials in `spec.cloudCredentialsRef`, opens one +connection per credential group, waits for the OpenStack service, reconciles each +CR, patches `Synced`/`Failed` onto the CR status, and then prunes. If any +reconcile fails it **skips the prune entirely** — a failed reconcile means the +desired state is unknown, so deleting anything would be unsafe. + +`run_hook` handles the shell-operator calling convention: `--config`, logging, +reading the binding context, and the exit code. + +## Adding a plugin + +1. **Write the CRD** in `components/openstack-sync-operator/crds/`. Include a + `status` subresource and a required `spec.cloudCredentialsRef` with + `secretName` and `cloudName` — the framework relies on both. Put validation + (`required`, `enum`, `minLength`, `default`) in the schema so the API server + rejects bad CRs and the Python side does not have to re-check them. + +2. **Register it** in `components/openstack-sync-operator/values.yaml`: + + ```yaml + plugins: + myResource: false # opt in per site + pluginData: + myResource: + hook: + path: /hooks/my_resource.py + crd: crds/_.yaml + envPrefix: MY_RESOURCE + env: + SYNC_CRONTAB: "0 * * * *" + ``` + + The chart derives `MY_RESOURCE_ENABLED`, `_CRD_API_VERSION`, `_CRD_KIND`, + `_CRD_RESOURCE` and `_STATUS_ENABLED` from the CRD file, and turns each `env` + key into `MY_RESOURCE_`. `HookConfig.from_env` reads exactly that set, so + the chart and Python cannot drift apart. + +3. **Write the plugin package** under `plugins///` with the + same four modules as `router_flavors`: `config.py` (constants), `markers.py` + (how you record that the operator owns a resource), `reconcile.py`, `prune.py`. + +4. **Write the hook** — subclass `SyncPlugin` and wire it up: + + ```python + class MyResourcePlugin(SyncPlugin): + noun = "my resource" + + def wait_for_api(self, conn) -> None: ... + + def reconcile(self, conn, spec, cache) -> list[str]: + return reconcile_module.sync(conn, spec, cache) + + def prune(self, conn, desired_specs, *, authoritative_empty) -> None: + if self.config.prune: + prune_module.prune(conn, desired_specs, + authoritative_empty=authoritative_empty) + + def main() -> int: + def run(contexts): + if not hook_enabled(ENV_PREFIX): + return 0 + config = HookConfig.from_env(ENV_PREFIX, binding_name=BINDING_NAME) + return run_sync(MyResourcePlugin(config), hook_inputs(contexts, config)) + + return run_hook(lambda: build_crd_hook_config(ENV_PREFIX, BINDING_NAME), run) + ``` + + `wait_for_api` and `reconcile` are required; `new_cache` and `prune` have + working defaults. + +## Two rules worth knowing + +**Only touch what you own.** Every plugin records ownership on the resources it +creates, and only ever updates or deletes resources carrying that marker. This is +what makes the operator safe to run against a cloud that also has hand-made +resources. Never adopt an existing resource by stamping the marker onto it — +that enrols somebody else's resource for eventual deletion. + +**Report what you cannot fix.** `reconcile` returns a list of notes. Use it for +state that diverges from the spec but that OpenStack will not let the operator +correct — for example Neutron rejects `update_service_profile` with a 409 while +the profile is bound to any flavor. The resource is still `Synced`, but the notes +appear on the CR status and in the logs so an operator can act. Raise an +exception only for an actual failure. + +## Tests + +```sh +.venv/bin/python -m pytest tests/ -q +``` + +`tests/test_framework.py` exercises the driver with a stub plugin and no +OpenStack at all — read it first to understand the contract a plugin gets. diff --git a/python/openstack-sync/openstack_sync/hooks/common.py b/python/openstack-sync/openstack_sync/hooks/common.py index 51f987efc..3191845f7 100644 --- a/python/openstack-sync/openstack_sync/hooks/common.py +++ b/python/openstack-sync/openstack_sync/hooks/common.py @@ -49,12 +49,20 @@ def int_or_none(value: Any) -> int | None: def read_binding_context() -> list[dict[str, Any]]: - """Read and parse the shell-operator binding context from BINDING_CONTEXT_PATH.""" + """Read and parse the shell-operator binding context. + + An absent ``BINDING_CONTEXT_PATH`` or an empty file yields no contexts; + shell-operator does invoke hooks with nothing to do. Malformed JSON raises + :exc:`json.JSONDecodeError`, a :exc:`ValueError`. + """ path = os.environ.get("BINDING_CONTEXT_PATH") if not path: return [] with open(path, encoding="utf-8") as f: - contexts = json.load(f) + raw = f.read() + if not raw.strip(): + return [] + contexts = json.loads(raw) if not isinstance(contexts, list): raise ValueError("Shell-operator binding context must be a list") return contexts diff --git a/python/openstack-sync/openstack_sync/hooks/framework.py b/python/openstack-sync/openstack_sync/hooks/framework.py new file mode 100644 index 000000000..6f394c82a --- /dev/null +++ b/python/openstack-sync/openstack_sync/hooks/framework.py @@ -0,0 +1,619 @@ +"""Framework for CR-driven OpenStack resource sync plugins. + +A plugin supplies four things: how to wait for its OpenStack service, how to +converge one CR spec, an optional per-credential-group cache, and an optional +prune. This module supplies everything else -- shell-operator hook config, +credential grouping, connection setup, per-resource status patching, the +reconcile-then-prune ordering, and the exit code contract. + +See ``README.md`` for the steps to add a plugin. +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +from abc import ABC +from abc import abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from openstack_sync.hooks.common import configure_logging +from openstack_sync.hooks.common import patch_resource_status +from openstack_sync.hooks.common import read_binding_context +from openstack_sync.hooks.common import snapshot_items +from openstack_sync.hooks.common import synchronization_items +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import env_bool +from openstack_sync.plugins.common import env_float +from openstack_sync.plugins.common import env_int +from openstack_sync.plugins.common import env_required +from openstack_sync.utils import get_openstack_connection + +LOG = logging.getLogger(__name__) + +#: A plugin's OpenStack credentials: ``(secret_name, cloud_name)``. +CredentialKey = tuple[str, str] + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class HookConfig: + """Runtime configuration for one hook, built from its chart env prefix. + + The Helm chart injects ``_ENABLED``, ``_CRD_API_VERSION``, + ``_CRD_KIND``, ``_CRD_RESOURCE`` and + ``_STATUS_ENABLED`` for every plugin that declares a CRD, plus one + variable per ``pluginData..hook.env`` key. This dataclass is the + Python half of that contract. + + Nothing here is read at import time. Shell-operator invokes ``--config`` + before the full environment is guaranteed to be present, so ``from_env`` is + called from ``main`` and only once the hook is known to be enabled. + """ + + prefix: str + crd_api_version: str + crd_kind: str + crd_resource: str + binding_name: str + namespace: str | None + status_enabled: bool + prune: bool + sync_crontab: str + ready_retries: int + ready_delay: float + + @classmethod + def from_env(cls, prefix: str, *, binding_name: str) -> HookConfig: + """Build config from the environment the Helm chart injected.""" + return cls( + prefix=prefix, + crd_api_version=env_required(f"{prefix}_CRD_API_VERSION"), + crd_kind=env_required(f"{prefix}_CRD_KIND"), + crd_resource=env_required(f"{prefix}_CRD_RESOURCE"), + binding_name=binding_name, + namespace=os.environ.get("POD_NAMESPACE"), + status_enabled=env_bool(f"{prefix}_STATUS_ENABLED", False), + prune=env_bool(f"{prefix}_PRUNE", False), + sync_crontab=os.environ.get(f"{prefix}_SYNC_CRONTAB", "").strip(), + ready_retries=env_int(f"{prefix}_READY_RETRIES", 30), + ready_delay=env_float(f"{prefix}_READY_DELAY", 10), + ) + + +def hook_enabled(prefix: str) -> bool: + """Return whether the chart enabled the plugin behind *prefix*.""" + return env_bool(f"{prefix}_ENABLED", False) + + +def build_crd_hook_config(prefix: str, binding_name: str) -> dict[str, Any]: + """Return the shell-operator hook config for a CRD-watching plugin. + + When the plugin is disabled the config carries only an ``onStartup`` + binding, because shell-operator requires every hook to declare at least + one binding but the hook must not register Kubernetes watches it will not + service. + """ + hook_config: dict[str, Any] = { + "configVersion": "v1", + "settings": {"executionMinInterval": "30s", "executionBurst": 1}, + } + + if not hook_enabled(prefix): + hook_config["onStartup"] = 10 + return hook_config + + config = HookConfig.from_env(prefix, binding_name=binding_name) + binding: dict[str, Any] = { + "name": config.binding_name, + "apiVersion": config.crd_api_version, + "kind": config.crd_kind, + "executeHookOnEvent": ["Added", "Modified", "Deleted"], + "jqFilter": ".", + "includeSnapshotsFrom": [config.binding_name], + # Dedicated queue so a slow readiness wait or reconcile only delays + # this hook's own tasks, not other hooks sharing the default queue. + "queue": config.binding_name, + } + if config.namespace: + binding["namespace"] = {"nameSelector": {"matchNames": [config.namespace]}} + + hook_config["kubernetes"] = [binding] + if config.sync_crontab: + hook_config["schedule"] = [ + { + "name": "periodic sync", + "crontab": config.sync_crontab, + "includeSnapshotsFrom": [config.binding_name], + "queue": config.binding_name, + } + ] + return hook_config + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SyncResource: + """One CR with its resolved OpenStack credentials. + + ``spec`` is the CR spec with ``cloudCredentialsRef`` removed, so a plugin + sees only its own fields. + """ + + spec: dict[str, Any] + name: str | None + namespace: str | None + generation: int | None + secret_name: str + cloud_name: str + current_status: dict[str, Any] | None = None + + @property + def credentials(self) -> CredentialKey: + return (self.secret_name, self.cloud_name) + + @property + def display_name(self) -> str: + """Return the OpenStack resource name, falling back to the CR name.""" + return str(self.spec.get("name") or self.name or "") + + +@dataclass(frozen=True) +class HookInputs: + """Binding context split by reconciliation purpose. + + The four-way split matters: an event-driven run reconciles only the changed + CRs, but must prune against the *full* desired set from the snapshot, and + must know which credentials a deleted CR used in order to prune at all. + """ + + resources_to_reconcile: list[SyncResource] + desired_resources_for_prune: list[SyncResource] + deleted_resources: list[SyncResource] + prune_credentials: frozenset[CredentialKey] + + +def group_by_credentials( + resources: list[SyncResource], +) -> dict[CredentialKey, list[SyncResource]]: + """Group *resources* by the credentials they authenticate with.""" + grouped: dict[CredentialKey, list[SyncResource]] = {} + for resource in resources: + grouped.setdefault(resource.credentials, []).append(resource) + return grouped + + +def _credentials(resources: list[SyncResource]) -> frozenset[CredentialKey]: + return frozenset(resource.credentials for resource in resources) + + +# --------------------------------------------------------------------------- +# Binding context -> resources +# --------------------------------------------------------------------------- + + +def _resource_from_object(obj: dict[str, Any]) -> SyncResource: + """Build a :class:`SyncResource` from a Kubernetes object. + + The CRD marks ``cloudCredentialsRef.secretName`` and ``.cloudName`` + required with ``minLength: 1``, so the API server rejects a CR missing them + long before a hook sees it. They are read directly rather than re-validated. + """ + spec = dict(obj["spec"]) + creds = spec.pop("cloudCredentialsRef") + metadata = obj.get("metadata", {}) + + return SyncResource( + spec=spec, + name=metadata.get("name"), + namespace=metadata.get("namespace"), + generation=metadata.get("generation"), + secret_name=creds["secretName"], + cloud_name=creds["cloudName"], + current_status=obj.get("status"), + ) + + +def _resources_from_items(items: list[Any]) -> list[SyncResource]: + """Build resources from snapshot or Synchronization items. + + Snapshot items wrap the object as ``{"object": {...}}``; Synchronization + items are the object itself. + """ + resources = [_resource_from_object(item.get("object", item)) for item in items] + return sorted(resources, key=lambda r: str(r.spec.get("name", ""))) + + +def _status_is_current(resource: SyncResource) -> bool: + """Return True when the CR status already records this generation as Synced. + + The hook's own status patch surfaces as a Modified event carrying the same + ``metadata.generation``. Without this check the hook would reconcile itself + in a loop. + """ + status = resource.current_status + return ( + resource.generation is not None + and status is not None + and status.get("syncStatus") == "Synced" + and status.get("observedGeneration") == resource.generation + ) + + +def _split_events( + contexts: list[dict[str, Any]], config: HookConfig +) -> tuple[list[SyncResource], list[SyncResource], frozenset[str]]: + """Split this binding's Event contexts into changed and deleted resources.""" + changed: list[SyncResource] = [] + deleted: list[SyncResource] = [] + watch_events: set[str] = set() + + for context in contexts: + if context.get("binding") != config.binding_name: + continue + if context.get("type") != "Event": + continue + + watch_event = context["watchEvent"] + watch_events.add(watch_event) + + obj = context.get("object") + if not obj: + LOG.warning( + "%s %s event carries no object; ignoring it", + config.crd_kind, + watch_event, + ) + continue + + resource = _resource_from_object(obj) + if watch_event == "Deleted": + deleted.append(resource) + elif watch_event == "Modified" and _status_is_current(resource): + LOG.info( + "Skipping %s Modified event; generation %s is already Synced", + resource.display_name, + resource.generation, + ) + else: + changed.append(resource) + + changed.sort(key=lambda r: str(r.spec.get("name", ""))) + return changed, deleted, frozenset(watch_events) + + +def hook_inputs(contexts: list[dict[str, Any]], config: HookConfig) -> HookInputs: + """Split a shell-operator binding context by reconciliation purpose. + + Event-driven runs reconcile only the changed CRs but prune against the full + desired set from the accompanying snapshot. Schedule and Synchronization + runs reconcile everything they are given. + """ + changed, deleted, watch_events = _split_events(contexts, config) + items = snapshot_items(contexts, config.binding_name) + + if watch_events: + if items is None: + raise ConfigError( + f"Shell-operator {config.binding_name} event context does not " + f"contain {config.binding_name} snapshot objects" + ) + desired = _resources_from_items(items) + # Only prune when something actually changed. A bare Added/Modified for + # an unrelated CR must not trigger a prune sweep. + if changed or deleted or "Deleted" in watch_events: + prune_credentials = _credentials(desired) | _credentials(deleted) + else: + prune_credentials = frozenset() + return HookInputs(changed, desired, deleted, prune_credentials) + + if items is None: + items = synchronization_items(contexts, config.binding_name) + if items is None: + raise ConfigError( + f"Shell-operator binding context does not contain " + f"{config.binding_name} event, snapshot, or synchronization objects" + ) + + resources = _resources_from_items(items) + return HookInputs(resources, resources, [], _credentials(resources)) + + +# --------------------------------------------------------------------------- +# Plugin contract +# --------------------------------------------------------------------------- + + +class SyncPlugin(ABC): + """One CR-driven OpenStack resource sync. + + Subclasses implement ``wait_for_api`` and ``reconcile``; ``new_cache`` and + ``prune`` have usable defaults. ``run_sync`` drives the rest. + """ + + #: Human-readable singular noun used in logs and CR status messages. + noun: str = "resource" + + def __init__(self, config: HookConfig) -> None: + self.config = config + + @abstractmethod + def wait_for_api(self, conn: Any) -> None: + """Block until the OpenStack service this plugin targets is reachable.""" + + @abstractmethod + def reconcile(self, conn: Any, spec: dict[str, Any], cache: Any) -> list[str]: + """Converge one CR spec onto OpenStack. + + Returns human-readable notes about state that diverges from the spec but + that the operator cannot correct on its own -- usually empty. Notes do + not make the reconcile a failure; they qualify the success reported on + the CR status. Raise to signal an actual failure. + """ + + def new_cache(self) -> Any: + """Return a scratch cache shared by every CR in one credential group.""" + return {} + + def prune( + self, + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool, + ) -> None: + """Delete resources whose CR was removed. + + Optional: the default does nothing, which is correct for a plugin whose + resources outlive their CR or that has nothing safe to delete. + """ + LOG.debug("%s defines no prune step", type(self).__name__) + + +# --------------------------------------------------------------------------- +# Status +# --------------------------------------------------------------------------- + + +def _patch_status( + plugin: SyncPlugin, resource: SyncResource, sync_status: str, message: str +) -> None: + config = plugin.config + if not resource.name: + LOG.warning( + "Unable to patch %s status; Kubernetes metadata.name is missing", + config.crd_kind, + ) + return + patch_resource_status( + name=resource.name, + namespace=resource.namespace or config.namespace, + generation=resource.generation, + sync_status=sync_status, + message=message, + crd_resource=config.crd_resource, + crd_kind=config.crd_kind, + status_enabled=config.status_enabled, + current_status=resource.current_status, + ) + + +def synced_message(noun: str, notes: list[str]) -> str: + """Return the Synced message, qualified by anything needing manual action. + + The resource really is converged, so the status stays Synced. Reporting a + bare success while state diverges from the spec is how a broken resource + stays invisible until it is used. + """ + message = f"Successfully reconciled {noun}" + if not notes: + return message + return f"{message}; needs manual action: {'; '.join(notes)}" + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + + +def run_sync(plugin: SyncPlugin, inputs: HookInputs) -> int: + """Reconcile every CR, then prune. Returns a process exit code.""" + noun = plugin.noun + resources = inputs.resources_to_reconcile + LOG.info("Found %s %s(s) to reconcile", len(resources), noun) + + grouped = group_by_credentials(resources) + grouped_desired = group_by_credentials(inputs.desired_resources_for_prune) + grouped_deleted = group_by_credentials(inputs.deleted_resources) + connections: dict[CredentialKey, Any] = {} + failed = 0 + + for credentials in sorted(grouped): + secret_name, cloud_name = credentials + group = grouped[credentials] + + try: + conn = get_openstack_connection(secret_name, cloud_name) + except Exception as exc: # noqa: BLE001 + failed += len(group) + _fail_group(plugin, group, f"OpenStack connection failed: {exc}") + LOG.error( + "Failed to connect to OpenStack cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + + connections[credentials] = conn + try: + plugin.wait_for_api(conn) + except Exception as exc: # noqa: BLE001 + failed += len(group) + _fail_group(plugin, group, f"OpenStack API unavailable: {exc}") + LOG.error( + "OpenStack API unavailable for cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + + # Shared across every CR in this credential group so lookups made for + # one CR are reused by the next. + cache = plugin.new_cache() + + for resource in group: + try: + notes = plugin.reconcile(conn, resource.spec, cache) + except Exception as exc: # noqa: BLE001 + failed += 1 + _patch_status(plugin, resource, "Failed", str(exc)) + LOG.error( + "Failed to reconcile %s %s: %s", noun, resource.display_name, exc + ) + continue + + if notes: + LOG.warning( + "%s %s converged but needs manual action: %s", + noun.capitalize(), + resource.display_name, + "; ".join(notes), + ) + _patch_status(plugin, resource, "Synced", synced_message(noun, notes)) + + if failed: + # Pruning deletes resources absent from the desired set. A failed + # reconcile means the desired set could not be established, so deleting + # anything now risks removing a resource that should exist. + LOG.error( + "Skipping %s prune because %s resource(s) failed to reconcile", + noun, + failed, + ) + return 1 + + return _run_prune(plugin, inputs, grouped_desired, grouped_deleted, connections) + + +def _fail_group(plugin: SyncPlugin, group: list[SyncResource], message: str) -> None: + for resource in group: + _patch_status(plugin, resource, "Failed", message) + + +def _run_prune( + plugin: SyncPlugin, + inputs: HookInputs, + grouped_desired: dict[CredentialKey, list[SyncResource]], + grouped_deleted: dict[CredentialKey, list[SyncResource]], + connections: dict[CredentialKey, Any], +) -> int: + noun = plugin.noun + prune_failed = False + + for credentials in sorted(inputs.prune_credentials): + secret_name, cloud_name = credentials + desired = grouped_desired.get(credentials, []) + # An empty desired set is only authoritative when we know a CR was + # deleted; otherwise it may just be a snapshot we could not read, and + # pruning against it would delete everything. + authoritative_empty = credentials in grouped_deleted and not desired + if not desired and not authoritative_empty: + LOG.info( + "Skipping %s prune for cloud=%r secret=%r; no desired resources", + noun, + cloud_name, + secret_name, + ) + continue + + conn = connections.get(credentials) + if conn is None: + if not plugin.config.prune: + continue + try: + conn = get_openstack_connection(secret_name, cloud_name) + plugin.wait_for_api(conn) + except Exception as exc: # noqa: BLE001 + prune_failed = True + LOG.error( + "Cannot reach OpenStack for %s prune cloud=%r secret=%r: %s", + noun, + cloud_name, + secret_name, + exc, + ) + continue + connections[credentials] = conn + + try: + plugin.prune( + conn, + [resource.spec for resource in desired], + authoritative_empty=authoritative_empty, + ) + except Exception as exc: # noqa: BLE001 + prune_failed = True + LOG.error( + "Failed to prune %s cloud=%r secret=%r: %s", + noun, + cloud_name, + secret_name, + exc, + ) + + if prune_failed: + return 1 + + LOG.info("Finished reconciling %s(s)", noun) + return 0 + + +# --------------------------------------------------------------------------- +# Entrypoint +# --------------------------------------------------------------------------- + + +def run_hook( + build_config: Callable[[], dict[str, Any]], + run: Callable[[list[dict[str, Any]]], int], +) -> int: + """Handle the shell-operator calling convention shared by every hook. + + ``--config`` prints the hook config and exits; otherwise the binding + context is read and handed to *run*. An empty or absent binding context is + not an error -- shell-operator invokes hooks with no work to do. + """ + if len(sys.argv) > 1 and sys.argv[1] == "--config": + print(json.dumps(build_config(), indent=2)) + return 0 + + configure_logging() + + try: + contexts = read_binding_context() + except ValueError as exc: + LOG.error("failed to parse binding context: %s", exc) + return 1 + + if not contexts: + return 0 + + try: + return run(contexts) + except Exception as exc: # noqa: BLE001 + LOG.error("%s", exc) + return 1 diff --git a/python/openstack-sync/openstack_sync/hooks/placeholder.py b/python/openstack-sync/openstack_sync/hooks/placeholder.py index 407291e24..664226e9a 100644 --- a/python/openstack-sync/openstack_sync/hooks/placeholder.py +++ b/python/openstack-sync/openstack_sync/hooks/placeholder.py @@ -1,52 +1,50 @@ #!/usr/bin/env python3 """Shell-operator hook for OpenStack connectivity verification. -When ``OPENSTACK_PLACEHOLDER_ENABLED`` is ``true`` this hook runs on startup -to verify that the operator can authenticate against OpenStack. When the flag -is ``false`` (the default) the hook registers only an ``onStartup`` binding so -the base image satisfies shell-operator's requirement for at least one binding -without needing any Kubernetes watches or extra RBAC. +When ``OPENSTACK_PLACEHOLDER_ENABLED`` is ``true`` this hook runs on startup to +verify that the operator can authenticate against OpenStack. When it is ``false`` +(the default) the hook still registers an ``onStartup`` binding, because +shell-operator requires every hook to declare at least one binding -- but it +does no work, so the base image needs no Kubernetes watches or extra RBAC. + +This is a connectivity probe rather than a CR reconciler, so it uses only +``run_hook`` and not the :class:`~openstack_sync.hooks.framework.SyncPlugin` +machinery. """ from __future__ import annotations -import json import logging import os import sys from typing import Any -from openstack_sync.hooks.common import configure_logging -from openstack_sync.plugins.common import env_bool +from openstack_sync.hooks.framework import hook_enabled +from openstack_sync.hooks.framework import run_hook from openstack_sync.utils import get_openstack_connection LOG = logging.getLogger(__name__) +ENV_PREFIX = "OPENSTACK_PLACEHOLDER" + def build_hook_config() -> dict[str, Any]: - hook_config: dict[str, Any] = { + return { "configVersion": "v1", - "settings": { - "executionMinInterval": "30s", - "executionBurst": 1, - }, + "settings": {"executionMinInterval": "30s", "executionBurst": 1}, "onStartup": 10, } - return hook_config def check_openstack_connectivity() -> None: - """Attempt to authenticate against OpenStack and log the result. - - Reads credentials from the Kubernetes Secret named by - ``OPENSTACK_PLACEHOLDER_DEFAULT_SECRET`` using - the cloud entry ``OPENSTACK_PLACEHOLDER_DEFAULT_CLOUD``. + """Authenticate against OpenStack and log the result. - Raises: - Exception: Re-raises any connection failure after logging it. + Credentials come from the Secret named by + ``OPENSTACK_PLACEHOLDER_DEFAULT_SECRET`` using the cloud entry + ``OPENSTACK_PLACEHOLDER_DEFAULT_CLOUD``. """ - secret_name = os.environ.get("OPENSTACK_PLACEHOLDER_DEFAULT_SECRET") - cloud_name = os.environ.get("OPENSTACK_PLACEHOLDER_DEFAULT_CLOUD") + secret_name = os.environ.get(f"{ENV_PREFIX}_DEFAULT_SECRET") + cloud_name = os.environ.get(f"{ENV_PREFIX}_DEFAULT_CLOUD") LOG.info( "connectivity check: authenticating against cloud=%r secret=%r", @@ -54,40 +52,21 @@ def check_openstack_connectivity() -> None: secret_name, ) conn = get_openstack_connection(secret_name, cloud_name) - # Lightweight probe: check_token(str) -> bool confirms the token is valid - # and Keystone is reachable without any side effects. + # check_token(str) -> bool confirms the token is valid and Keystone is + # reachable, with no side effects. conn.identity.check_token(conn.auth_token) LOG.info("connectivity check: OK cloud=%r secret=%r", cloud_name, secret_name) def main() -> int: - if len(sys.argv) > 1 and sys.argv[1] == "--config": - print(json.dumps(build_hook_config(), indent=2)) - return 0 - - configure_logging() - - context_path = os.environ.get("BINDING_CONTEXT_PATH") - if not context_path: - return 0 - with open(context_path) as f: - raw = f.read() - if not raw.strip(): - return 0 - - try: - binding_contexts = json.loads(raw) - except json.JSONDecodeError as exc: - LOG.error("failed to parse binding context: %s", exc) - return 1 - - for context in binding_contexts: - # Shell-operator passes [{"binding": "onStartup"}] for startup runs. - if context.get("binding") == "onStartup": - if not env_bool("OPENSTACK_PLACEHOLDER_ENABLED", False): + def run(contexts: list[dict[str, Any]]) -> int: + for context in contexts: + # Shell-operator passes [{"binding": "onStartup"}] for startup runs. + if context.get("binding") != "onStartup": + continue + if not hook_enabled(ENV_PREFIX): LOG.info( - "connectivity check: skipped" - " (OPENSTACK_PLACEHOLDER_ENABLED is not set)" + "connectivity check: skipped (%s_ENABLED is not set)", ENV_PREFIX ) continue try: @@ -95,8 +74,9 @@ def main() -> int: except Exception as exc: # noqa: BLE001 LOG.error("connectivity check FAILED: %s", exc) return 1 + return 0 - return 0 + return run_hook(build_hook_config, run) if __name__ == "__main__": diff --git a/python/openstack-sync/openstack_sync/hooks/router_flavors.py b/python/openstack-sync/openstack_sync/hooks/router_flavors.py index ccf2e670a..1de539ec5 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -3,681 +3,68 @@ from __future__ import annotations -import json -import logging -import os import sys -from dataclasses import dataclass from typing import Any -from openstack_sync.hooks.common import configure_logging -from openstack_sync.hooks.common import int_or_none -from openstack_sync.hooks.common import patch_resource_status -from openstack_sync.hooks.common import read_binding_context -from openstack_sync.hooks.common import snapshot_items -from openstack_sync.hooks.common import string_or_none -from openstack_sync.hooks.common import synchronization_items -from openstack_sync.plugins.common import ConfigError -from openstack_sync.plugins.common import env_bool -from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.neutron.router_flavors.create import ServiceProfileCache -from openstack_sync.plugins.neutron.router_flavors.delete import prune_removed_flavors -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - ProfileDrift, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - crd_api_version, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - crd_binding_name, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import crd_kind -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - crd_namespace, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - crd_resource, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - describe_profile_drift, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - prune_removed_flavors_enabled, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - status_enabled, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - wait_for_openstack_network, -) -from openstack_sync.plugins.neutron.router_flavors.update import sync_flavor -from openstack_sync.utils import get_openstack_connection - -LOG = logging.getLogger(__name__) -CredentialKey = tuple[str, str] - -# --------------------------------------------------------------------------- -# Resource dataclass -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class RouterFlavorResource: - """A single NeutronRouterFlavor CR with its resolved credentials.""" - - flavor: dict[str, Any] - name: str | None - namespace: str | None - generation: int | None - secret_name: str - cloud_name: str - current_status: dict[str, Any] | None = None - - -@dataclass(frozen=True) -class RouterFlavorHookInputs: - """Parsed shell-operator context split by reconciliation purpose.""" - - resources_to_reconcile: list[RouterFlavorResource] - desired_resources_for_prune: list[RouterFlavorResource] - deleted_resources: list[RouterFlavorResource] - prune_credentials: frozenset[CredentialKey] - - -# --------------------------------------------------------------------------- -# Hook configuration -# --------------------------------------------------------------------------- - - -def build_hook_config() -> dict[str, Any]: - hook_config: dict[str, Any] = { - "configVersion": "v1", - "settings": { - "executionMinInterval": "30s", - "executionBurst": 1, - }, - } - - if not env_bool("NEUTRON_ROUTER_FLAVOR_ENABLED", False): - # Shell-operator requires at least one binding. - hook_config["onStartup"] = 10 - return hook_config - - sync_crontab = os.environ.get("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "").strip() - namespace = os.environ.get("POD_NAMESPACE") - binding_name = crd_binding_name() - kubernetes_binding: dict[str, Any] = { - "name": binding_name, - "apiVersion": crd_api_version(), - "kind": crd_kind(), - "executeHookOnEvent": ["Added", "Modified", "Deleted"], - "jqFilter": ".", - "includeSnapshotsFrom": [binding_name], - # Dedicated queue so a slow Neutron readiness wait or reconciliation - # only delays this hook's own tasks, not other hooks sharing the - # default "main" queue. - "queue": binding_name, - } - if namespace: - kubernetes_binding["namespace"] = { - "nameSelector": {"matchNames": [namespace]}, - } - - hook_config["kubernetes"] = [kubernetes_binding] - if sync_crontab: - hook_config["schedule"] = [ - { - "name": "hourly sync", - "crontab": sync_crontab, - "includeSnapshotsFrom": [binding_name], - "queue": binding_name, - } - ] - return hook_config - - -# --------------------------------------------------------------------------- -# Binding context parsing -# --------------------------------------------------------------------------- - - -def _required_cloud_credential( - creds_ref: dict[str, Any], - field: str, - source: str, -) -> str: - value = creds_ref.get(field) - if not isinstance(value, str) or not value.strip(): - raise ConfigError( - f"{source} spec.cloudCredentialsRef.{field} must be a non-empty string" - ) - return value.strip() - - -def _resource_from_object(obj: Any, source: str) -> RouterFlavorResource: - if not isinstance(obj, dict): - raise ConfigError(f"{source} object must be a Kubernetes object") - - spec = obj.get("spec") - if not isinstance(spec, dict): - raise ConfigError(f"{source} spec must be an object") - - flavor = dict(spec) - metadata = obj.get("metadata", {}) - resource_name = None - resource_namespace = None - generation = None - if isinstance(metadata, dict): - resource_name = string_or_none(metadata.get("name")) - resource_namespace = string_or_none(metadata.get("namespace")) - generation = int_or_none(metadata.get("generation")) - raw_status = obj.get("status") - current_status = raw_status if isinstance(raw_status, dict) else None - - try: - creds_ref = flavor.pop("cloudCredentialsRef") - except KeyError as exc: - raise ConfigError(f"{source} spec.cloudCredentialsRef is required") from exc - if not isinstance(creds_ref, dict): - raise ConfigError(f"{source} spec.cloudCredentialsRef must be an object") - secret_name = _required_cloud_credential(creds_ref, "secretName", source) - cloud_name = _required_cloud_credential(creds_ref, "cloudName", source) - - return RouterFlavorResource( - flavor=flavor, - name=resource_name, - namespace=resource_namespace, - generation=generation, - secret_name=secret_name, - cloud_name=cloud_name, - current_status=current_status, - ) - - -def _resources_from_items(items: list[Any], source: str) -> list[RouterFlavorResource]: - resources: list[RouterFlavorResource] = [] - for index, item in enumerate(items): - item_source = f"{source}[{index}]" - if not isinstance(item, dict): - raise ConfigError(f"{item_source} must be an object") - obj = item.get("object", item) - resources.append(_resource_from_object(obj, item_source)) - - return sorted(resources, key=lambda r: str(r.flavor.get("name", ""))) - - -def _credentials_for_resources( - resources: list[RouterFlavorResource], -) -> frozenset[CredentialKey]: - return frozenset( - (resource.secret_name, resource.cloud_name) for resource in resources - ) - - -def _router_flavor_event_watch_events( - contexts: list[dict[str, Any]], -) -> frozenset[str] | None: - binding_name = crd_binding_name() - watch_events: set[str] = set() - for context in contexts: - if context.get("binding") != binding_name or context.get("type") != "Event": - continue - watch_event = context.get("watchEvent") - if not isinstance(watch_event, str) or not watch_event: - raise ConfigError( - f"{binding_name} event watchEvent must be a non-empty string" - ) - watch_events.add(watch_event) - return frozenset(watch_events) if watch_events else None - - -def _modified_event_status_is_current(resource: RouterFlavorResource) -> bool: - status = resource.current_status - return ( - resource.generation is not None - and status is not None - and status.get("syncStatus") == "Synced" - and status.get("observedGeneration") == resource.generation - ) - - -def changed_router_flavor_resources_from_binding_context( - contexts: list[dict[str, Any]], -) -> list[RouterFlavorResource] | None: - binding_name = crd_binding_name() - resources: list[RouterFlavorResource] = [] - saw_event = False - for index, context in enumerate(contexts): - if context.get("binding") != binding_name or context.get("type") != "Event": - continue - - saw_event = True - watch_event = context.get("watchEvent") - if watch_event == "Deleted": - continue - if watch_event not in {"Added", "Modified"}: - raise ConfigError( - f"{binding_name} event watchEvent must be Added, Modified, or Deleted" - ) - - obj = context.get("object") - if not obj: - raise ConfigError( - f"{watch_event} event {binding_name}[{index}] object is required" - ) - resource = _resource_from_object( - obj, - f"{watch_event} event {binding_name}[{index}]", - ) - if watch_event == "Modified" and _modified_event_status_is_current(resource): - LOG.info( - "Skipping router flavor %s Modified event; generation %s is already " - "Synced", - _resource_display_name(resource), - resource.generation, - ) - continue - resources.append(resource) - - if not saw_event: - return None - return sorted(resources, key=lambda r: str(r.flavor.get("name", ""))) - - -def deleted_router_flavor_resources_from_binding_context( - contexts: list[dict[str, Any]], -) -> list[RouterFlavorResource]: - binding_name = crd_binding_name() - resources: list[RouterFlavorResource] = [] - for index, context in enumerate(contexts): - if ( - context.get("binding") != binding_name - or context.get("type") != "Event" - or context.get("watchEvent") != "Deleted" - ): - continue - obj = context.get("object") - if not obj: - LOG.warning( - "Deleted %s event has no object; cannot use it for prune credentials", - crd_kind(), - ) - continue - resources.append( - _resource_from_object( - obj, - f"Deleted event {binding_name}[{index}]", - ) - ) - - return resources - - -def router_flavor_resources_from_binding_context( - contexts: list[dict[str, Any]], -) -> list[RouterFlavorResource] | None: - binding_name = crd_binding_name() - items = snapshot_items(contexts, binding_name) - if items is not None: - return _resources_from_items(items, f"Snapshot {binding_name}") - - items = synchronization_items(contexts, binding_name) - if items is not None: - return _resources_from_items(items, f"Synchronization {binding_name}") - - return None - - -def router_flavor_hook_inputs_from_binding_context( - contexts: list[dict[str, Any]], -) -> RouterFlavorHookInputs | None: - binding_name = crd_binding_name() - event_watch_events = _router_flavor_event_watch_events(contexts) - changed_resources = changed_router_flavor_resources_from_binding_context(contexts) - deleted_resources = deleted_router_flavor_resources_from_binding_context(contexts) - - if event_watch_events is not None: - items = snapshot_items(contexts, binding_name) - if items is None: - raise ConfigError( - f"Shell-operator {binding_name} event context does not contain " - f"{binding_name} snapshot objects" - ) - desired_resources = _resources_from_items(items, f"Snapshot {binding_name}") - if changed_resources or deleted_resources or "Deleted" in event_watch_events: - prune_credentials = _credentials_for_resources( - desired_resources - ) | _credentials_for_resources(deleted_resources) - else: - prune_credentials = frozenset() - return RouterFlavorHookInputs( - resources_to_reconcile=changed_resources or [], - desired_resources_for_prune=desired_resources, - deleted_resources=deleted_resources, - prune_credentials=prune_credentials, +from openstack_sync.hooks.framework import HookConfig +from openstack_sync.hooks.framework import SyncPlugin +from openstack_sync.hooks.framework import build_crd_hook_config +from openstack_sync.hooks.framework import hook_enabled +from openstack_sync.hooks.framework import hook_inputs +from openstack_sync.hooks.framework import run_hook +from openstack_sync.hooks.framework import run_sync +from openstack_sync.plugins.common import wait_for_openstack_network +from openstack_sync.plugins.neutron.router_flavors import prune as prune_module +from openstack_sync.plugins.neutron.router_flavors import reconcile as reconcile_module +from openstack_sync.plugins.neutron.router_flavors.config import BINDING_NAME +from openstack_sync.plugins.neutron.router_flavors.config import ENV_PREFIX + + +class RouterFlavorPlugin(SyncPlugin): + """Sync NeutronRouterFlavor CRs into Neutron flavors and service profiles.""" + + noun = "router flavor" + + def wait_for_api(self, conn: Any) -> None: + wait_for_openstack_network( + conn, + retries=self.config.ready_retries, + delay=self.config.ready_delay, ) - resources = router_flavor_resources_from_binding_context(contexts) - if resources is not None: - return RouterFlavorHookInputs( - resources_to_reconcile=resources, - desired_resources_for_prune=resources, - deleted_resources=[], - prune_credentials=_credentials_for_resources(resources), + def new_cache(self) -> reconcile_module.ProfileCache: + # Keyed by driver and shared across every flavor in one credential + # group, so two flavors wanting the same profile share one lookup and + # end up sharing one profile. + return {} + + def reconcile( + self, conn: Any, spec: dict[str, Any], cache: reconcile_module.ProfileCache + ) -> list[str]: + return reconcile_module.sync_flavor(conn, spec, cache) + + def prune( + self, + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool, + ) -> None: + if not self.config.prune: + return + prune_module.prune_removed_flavors( + conn, desired_specs, authoritative_empty=authoritative_empty ) - return None - - -def load_router_flavor_hook_inputs( - contexts: list[dict[str, Any]] | None = None, -) -> RouterFlavorHookInputs: - if contexts is None: - contexts = read_binding_context() - if not contexts: - raise ConfigError( - f"Shell-operator binding context is required to load {crd_kind()} objects" - ) - - hook_inputs = router_flavor_hook_inputs_from_binding_context(contexts) - if hook_inputs is not None: - return hook_inputs - - raise ConfigError( - f"Shell-operator binding context does not contain " - f"{crd_binding_name()} event, snapshot, or synchronization objects" - ) - - -# --------------------------------------------------------------------------- -# Status patching -# --------------------------------------------------------------------------- - - -def patch_flavor_status( - resource: RouterFlavorResource, - sync_status: str, - message: str, -) -> None: - kind = crd_kind() - if not resource.name: - LOG.warning( - "Unable to patch %s status; Kubernetes metadata.name is missing", - kind, - ) - return - patch_resource_status( - name=resource.name, - namespace=resource.namespace or crd_namespace(), - generation=resource.generation, - sync_status=sync_status, - message=message, - crd_resource=crd_resource(), - crd_kind=kind, - status_enabled=status_enabled(), - current_status=resource.current_status, - ) - - -# --------------------------------------------------------------------------- -# Reconciliation -# --------------------------------------------------------------------------- - - -def _resource_display_name(resource: RouterFlavorResource) -> str: - return str(get_value(resource.flavor, "name", default=resource.name or "")) - - -def _resources_by_credentials( - resources: list[RouterFlavorResource], -) -> dict[CredentialKey, list[RouterFlavorResource]]: - grouped: dict[CredentialKey, list[RouterFlavorResource]] = {} - for resource in resources: - key = (resource.secret_name, resource.cloud_name) - grouped.setdefault(key, []).append(resource) - return grouped - - -def _mark_resources_failed( - resources: list[RouterFlavorResource], - message: str, -) -> None: - for resource in resources: - patch_flavor_status(resource, "Failed", message) - - -def reconcile_router_flavor_resource( - conn: Any, resource: RouterFlavorResource, profile_cache: ServiceProfileCache -) -> list[ProfileDrift]: - return sync_flavor(conn, resource.flavor, profile_cache) - - -def _synced_status_message(drift: list[ProfileDrift]) -> str: - """Return the Synced status message, qualified by any unfixable drift. - - The flavor really is converged, so the status stays Synced; but reporting a - bare success while a reused service profile diverges from the spec is how a - disabled profile stays invisible until every router create against the - flavor fails. - """ - message = "Successfully reconciled router flavor" - if not drift: - return message - return ( - f"{message}; service profile drift requires manual action: " - f"{describe_profile_drift(drift)}" - ) - - -def reconcile_router_flavor_resources( - resources: list[RouterFlavorResource], - deleted_resources: list[RouterFlavorResource] | None = None, - prune_resources: list[RouterFlavorResource] | None = None, - prune_credentials: frozenset[CredentialKey] | None = None, -) -> int: - deleted_resources = deleted_resources or [] - prune_resources = resources if prune_resources is None else prune_resources - flavors = [resource.flavor for resource in resources] - LOG.info("Found %s router flavor(s) to reconcile", len(flavors)) - - grouped_resources = _resources_by_credentials(resources) - grouped_prune_resources = _resources_by_credentials(prune_resources) - deleted_resources_by_credentials = _resources_by_credentials(deleted_resources) - if prune_credentials is None: - prune_credentials = frozenset(grouped_resources) - connections: dict[CredentialKey, Any] = {} - failed_resources: list[RouterFlavorResource] = [] - - for credentials in sorted(grouped_resources): - credential_resources = grouped_resources[credentials] - secret_name, cloud_name = credentials - try: - conn = get_openstack_connection(secret_name, cloud_name) - except Exception as exc: # noqa: BLE001 - failed_resources.extend(credential_resources) - message = f"OpenStack connection failed: {exc}" - _mark_resources_failed(credential_resources, message) - LOG.error( - "Failed to connect to OpenStack cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, - ) - continue - - connections[credentials] = conn - try: - wait_for_openstack_network(conn) - except Exception as exc: # noqa: BLE001 - failed_resources.extend(credential_resources) - _mark_resources_failed( - credential_resources, - f"Neutron API unavailable: {exc}", - ) - LOG.error( - "Neutron API unavailable for cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, - ) - continue - - # Fetched lazily by driver once per credential group. ensure_profile() - # appends newly created profiles into the same driver cache entry so a - # later flavor with an identical meta_info spec reuses it. - profile_cache: ServiceProfileCache = {} - - for resource in credential_resources: - try: - drift = reconcile_router_flavor_resource(conn, resource, profile_cache) - except Exception as exc: # noqa: BLE001 - failed_resources.append(resource) - patch_flavor_status(resource, "Failed", str(exc)) - LOG.error( - "Failed to reconcile router flavor %s: %s", - _resource_display_name(resource), - exc, - ) - continue - - patch_flavor_status( - resource, - "Synced", - _synced_status_message(drift), - ) - - if failed_resources: - LOG.error( - "Skipping router flavor prune because %s flavor(s) failed to reconcile", - len(failed_resources), - ) - return 1 - - prune_failed = False - for credentials in sorted(prune_credentials): - secret_name, cloud_name = credentials - desired_resources = grouped_prune_resources.get(credentials, []) - authoritative_empty_desired = ( - credentials in deleted_resources_by_credentials and not desired_resources - ) - if not desired_resources and not authoritative_empty_desired: - LOG.info( - "Skipping router flavor prune for cloud=%r secret=%r; no desired " - "router flavors are available", - cloud_name, - secret_name, - ) - continue - - conn = connections.get(credentials) - if conn is None: - if not prune_removed_flavors_enabled(): - continue - try: - conn = get_openstack_connection(secret_name, cloud_name) - except Exception as exc: # noqa: BLE001 - prune_failed = True - LOG.error( - "Failed to connect to OpenStack for router flavor prune " - "cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, - ) - continue - try: - wait_for_openstack_network(conn) - except Exception as exc: # noqa: BLE001 - prune_failed = True - LOG.error( - "Neutron API unavailable for router flavor prune " - "cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, - ) - continue - connections[credentials] = conn - - try: - desired_flavors = [resource.flavor for resource in desired_resources] - if authoritative_empty_desired: - prune_removed_flavors( - conn, - desired_flavors, - authoritative_empty_desired=True, - ) - else: - prune_removed_flavors(conn, desired_flavors) - except Exception as exc: # noqa: BLE001 - prune_failed = True - LOG.error( - "Failed to prune router flavors cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, - ) - - if prune_failed: - return 1 - - if ( - not prune_credentials - and not grouped_resources - and not deleted_resources_by_credentials - ): - LOG.info( - "Skipping router flavor prune; no router flavor credentials are available" - ) - - LOG.info("Finished reconciling router flavors") - return 0 - - -# --------------------------------------------------------------------------- -# Run loop -# --------------------------------------------------------------------------- - def main() -> int: - if len(sys.argv) > 1 and sys.argv[1] == "--config": - print(json.dumps(build_hook_config(), indent=2)) - return 0 + def run(contexts: list[dict[str, Any]]) -> int: + if not hook_enabled(ENV_PREFIX): + return 0 + config = HookConfig.from_env(ENV_PREFIX, binding_name=BINDING_NAME) + return run_sync(RouterFlavorPlugin(config), hook_inputs(contexts, config)) - configure_logging() - - if not env_bool("NEUTRON_ROUTER_FLAVOR_ENABLED", False): - LOG.info("Router flavor sync is disabled") - return 0 - - context_path = os.environ.get("BINDING_CONTEXT_PATH") - if not context_path: - return 0 - - with open(context_path, encoding="utf-8") as f: - raw = f.read() - if not raw.strip(): - return 0 - - try: - binding_contexts = json.loads(raw) - except json.JSONDecodeError as exc: - LOG.error("failed to parse binding context: %s", exc) - return 1 - - try: - if not isinstance(binding_contexts, list): - raise ConfigError("Shell-operator binding context must be a list") - hook_inputs = load_router_flavor_hook_inputs(binding_contexts) - return reconcile_router_flavor_resources( - hook_inputs.resources_to_reconcile, - hook_inputs.deleted_resources, - hook_inputs.desired_resources_for_prune, - hook_inputs.prune_credentials, - ) - except Exception as exc: # noqa: BLE001 - LOG.error("%s", exc) - return 1 + return run_hook(lambda: build_crd_hook_config(ENV_PREFIX, BINDING_NAME), run) if __name__ == "__main__": diff --git a/python/openstack-sync/openstack_sync/plugins/common.py b/python/openstack-sync/openstack_sync/plugins/common.py index 39f02c98b..887486798 100644 --- a/python/openstack-sync/openstack_sync/plugins/common.py +++ b/python/openstack-sync/openstack_sync/plugins/common.py @@ -77,15 +77,6 @@ def env_required(name: str) -> str: return value -def env_tuple(name: str, default: str) -> tuple[str, ...]: - """Return a tuple of strings parsed from a comma-separated env variable.""" - return tuple( - item.strip() - for item in os.environ.get(name, default).split(",") - if item.strip() - ) - - # --------------------------------------------------------------------------- # Error type # --------------------------------------------------------------------------- @@ -99,61 +90,25 @@ class ConfigError(Exception): # OpenStack SDK resource accessors # --------------------------------------------------------------------------- -_MISSING = object() - - -def _mapping_value(mapping: dict[str, Any], name: str) -> Any: - """Read *name* from a mapping without invoking default values.""" - try: - return mapping[name] - except KeyError: - return _MISSING - - -def _attribute_value(resource: Any, name: str) -> Any: - """Read *name* through attribute access.""" - try: - return getattr(resource, name) - except AttributeError: - return _MISSING - -def _resource_value(resource: Any, name: str) -> Any: - """Read *name* from *resource* regardless of type. +def get_value(resource: Any, name: str, default: Any = None) -> Any: + """Return a field from a CR spec dict or an openstacksdk resource. - Plain dicts are the operator contract and are read by exact key. - OpenStack resources are read through their openstacksdk attribute names, - for example ``meta_info`` and ``service_profile_ids``. Neutron wire names - are mapped by openstacksdk before this layer reads them. + Specs are plain dicts read by exact key; OpenStack resources are read by + their openstacksdk attribute name (``meta_info``, ``service_profile_ids``), + which the SDK has already mapped from the Neutron wire name. """ - if type(resource) is dict: - return _mapping_value(resource, name) - - value = _attribute_value(resource, name) - if value is not _MISSING: - return value - - return _MISSING - - -def get_value(resource: Any, name: str, default: Any = None) -> Any: - """Return a non-None value from *resource* by canonical field name.""" - value = _resource_value(resource, name) - if value is not _MISSING and value is not None: - return value - return default + value = ( + resource.get(name) + if isinstance(resource, dict) + else getattr(resource, name, None) + ) + return default if value is None else value def resource_id(resource: Any) -> str: - """Return the string ID of an OpenStack resource. - - Raises: - RuntimeError: When no ID field can be found. - """ - value = get_value(resource, "id") - if not value: - raise RuntimeError(f"Unable to read ID from resource {resource!r}") - return str(value) + """Return the string ID of an OpenStack resource.""" + return str(get_value(resource, "id")) # --------------------------------------------------------------------------- @@ -190,51 +145,6 @@ def meta_info_payload(value: Any) -> str: return json.dumps(normalized, sort_keys=True, separators=(",", ":")) -def comparable_meta_info_without(value: Any, exclude_keys: frozenset[str]) -> Any: - """Strip *exclude_keys* from *value* before comparison.""" - normalized = normalize_meta_info(value) - if isinstance(normalized, dict): - return {k: v for k, v in normalized.items() if k not in exclude_keys} - return normalized - - -def meta_info_matches_without( - current: Any, desired: Any, exclude_keys: frozenset[str] -) -> bool: - """Return True when *current* and *desired* are logically equal. - - Keys in *exclude_keys* are stripped before comparison. - """ - return meta_info_payload( - comparable_meta_info_without(current, exclude_keys) - ) == meta_info_payload(comparable_meta_info_without(desired, exclude_keys)) - - -def managed_meta_info(value: Any, markers: dict[str, str]) -> Any: - """Merge *markers* into *value*, returning the combined meta_info dict.""" - normalized = normalize_meta_info(value) - if not isinstance(normalized, dict): - return normalized - managed = dict(normalized) - managed.update(markers) - return managed - - -# --------------------------------------------------------------------------- -# Exception classifiers -# --------------------------------------------------------------------------- - - -def is_not_found(exc: Exception) -> bool: - """Return True for openstacksdk 404 exceptions.""" - return isinstance(exc, openstack_exceptions.NotFoundException) - - -def is_conflict(exc: Exception) -> bool: - """Return True for openstacksdk 409 exceptions.""" - return isinstance(exc, openstack_exceptions.ConflictException) - - # --------------------------------------------------------------------------- # Neutron network readiness probe # --------------------------------------------------------------------------- @@ -274,24 +184,19 @@ def wait_for_openstack_network( def get_service_profile(conn: Any, profile_id: str) -> Any | None: - """Fetch a service profile by ID, returning None if not found.""" + """Fetch a service profile by ID, returning None if it no longer exists.""" try: return conn.network.get_service_profile(profile_id) - except Exception as exc: - if is_not_found(exc): - return None - raise + except openstack_exceptions.NotFoundException: + return None def service_profile_ids(flavor: Any) -> list[str]: - """Return the list of service profile IDs attached to *flavor*. + """Return the service profile IDs attached to *flavor*. The openstacksdk ``Flavor.service_profile_ids`` attribute maps Neutron's ``service_profiles`` wire field. """ - profiles = get_value(flavor, "service_profile_ids", default=[]) - if profiles is None: - return [] - if not isinstance(profiles, list): - raise TypeError("flavor.service_profile_ids must be a list") - return [str(profile) for profile in profiles] + return [ + str(profile) for profile in get_value(flavor, "service_profile_ids", default=[]) + ] diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/config.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/config.py new file mode 100644 index 000000000..0944f9530 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/config.py @@ -0,0 +1,19 @@ +"""Router-flavor plugin constants. + +Runtime configuration comes from :class:`openstack_sync.hooks.framework.HookConfig`, +built from the ``NEUTRON_ROUTER_FLAVOR`` env prefix the Helm chart injects. The +values here are not configurable at runtime: the chart never set them, so +carrying env plumbing for them only obscured what they are. +""" + +from __future__ import annotations + +#: Env prefix the Helm chart uses for this plugin's variables. +ENV_PREFIX = "NEUTRON_ROUTER_FLAVOR" + +#: shell-operator binding label for the CRD watch. +BINDING_NAME = "neutron-router-flavors" + +#: The only service type Neutron accepts for router flavors +#: (``plugin_constants.L3`` in neutron-lib). The CRD pins it with an enum. +SERVICE_TYPE = "L3_ROUTER_NAT" diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py deleted file mode 100644 index 3affa683e..000000000 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py +++ /dev/null @@ -1,350 +0,0 @@ -"""Create helpers for Neutron router flavors and service profiles.""" - -from __future__ import annotations - -import logging -from typing import Any - -from openstack_sync.plugins.common import get_service_profile -from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.common import is_conflict -from openstack_sync.plugins.common import is_not_found -from openstack_sync.plugins.common import meta_info_payload -from openstack_sync.plugins.common import resource_id -from openstack_sync.plugins.common import service_profile_ids -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - ProfileDrift, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - is_managed_service_profile, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - managed_flavor_description, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - managed_meta_info, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - meta_info_matches, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - service_profile_meta_info, -) - -LOG = logging.getLogger(__name__) -ServiceProfileCache = dict[str, list[Any]] - - -def list_service_profiles(conn: Any, driver: str) -> list[Any]: - """Fetch service profiles for a single driver from Neutron.""" - return list(conn.network.service_profiles(driver=driver)) - - -def service_profiles_for_driver( - conn: Any, driver: str, profile_cache: ServiceProfileCache -) -> list[Any]: - """Return a credential-group cache entry for service profiles by driver.""" - if driver not in profile_cache: - profile_cache[driver] = list_service_profiles(conn, driver) - return profile_cache[driver] - - -def find_matching_profile(profiles: list[Any], meta_info: Any) -> Any | None: - """Return the operator-managed profile matching *meta_info*, if any. - - Only operator-owned profiles are reuse candidates. Reusing a profile the - operator does not own would bind it to the flavor, and - ``reconcile_flavor_profiles`` unbinds only profiles carrying the ownership - marker -- so the operator would have created a binding it can never remove. - That binding outlives the spec that created it, and Neutron's - ``get_flavor_next_provider`` selects an arbitrary binding (``objs[0]``), so a - stale one can end up serving routers with the wrong ``meta_info``. - - An unowned profile that happens to match is therefore left completely - untouched -- not adopted by stamping the ownership marker onto it, which - would enrol somebody else's profile into ``prune_orphaned_service_profiles`` - for eventual deletion -- and ``ensure_profile`` creates a dedicated managed - profile alongside it. - """ - unowned_matches: list[str] = [] - for profile in profiles: - if not meta_info_matches(service_profile_meta_info(profile), meta_info): - continue - if is_managed_service_profile(profile): - return profile - unowned_matches.append(str(get_value(profile, "id", default=""))) - - if unowned_matches: - LOG.info( - "Not reusing service profile(s) %s: they match the desired meta_info " - "but are not operator-owned, and the operator only binds profiles it " - "can unbind again; creating a dedicated managed profile instead", - sorted(unowned_matches), - ) - - return None - - -def _collect_profile_drift( - profile: Any, - profile_id: str, - driver: str, - flavor_name: str, - *, - description: str, - is_enabled: bool, -) -> list[ProfileDrift]: - """Return the spec fields on a reused *profile* that Neutron disagrees on. - - ``meta_info`` is excluded by construction -- the profile was selected by - matching it -- and ``driver`` is excluded because profiles are queried per - driver. That leaves ``is_enabled`` and ``description``. - - ``is_enabled`` is the consequential one: Neutron's - ``get_flavor_next_provider`` raises ``ServiceProfileDisabled`` (HTTP 503) - when the selected profile is disabled, so every router create against the - flavor fails while the flavor itself still looks healthy. - """ - drifted: list[ProfileDrift] = [] - - current_is_enabled = bool(get_value(profile, "is_enabled", default=True)) - if current_is_enabled != bool(is_enabled): - drifted.append( - ProfileDrift( - profile_id=profile_id, - driver=driver, - field="is_enabled", - have=current_is_enabled, - want=bool(is_enabled), - ) - ) - - current_description = str(get_value(profile, "description", default="")) - if current_description != str(description): - drifted.append( - ProfileDrift( - profile_id=profile_id, - driver=driver, - field="description", - have=current_description, - want=str(description), - ) - ) - - for item in drifted: - LOG.warning( - "Service profile %s reused by router flavor %s has drifted from the " - "CR spec (%s). Neutron rejects updates to a profile bound to any " - "flavor, so the operator cannot correct this; unbind the profile " - "from every flavor to update it, or delete it and let the operator " - "recreate it", - profile_id, - flavor_name, - item.describe(), - ) - - return drifted - - -def ensure_profile( - conn: Any, - flavor_name: str, - profile_spec: dict[str, Any], - profile_cache: ServiceProfileCache, - drift: list[ProfileDrift] | None = None, -) -> Any: - """Find or create a service profile matching *profile_spec*. - - The CR schema guarantees ``driver`` is present and ``is_enabled`` carries - the CRD default (true). ``description`` and ``meta_info`` are optional in - the schema; missing values fall back to empty. - - Only operator-owned profiles are reused (see ``find_matching_profile``). - When a reused profile has drifted from the spec, each drifted field is - logged and appended to *drift* if a list was supplied, so the caller can - surface it on the CR status rather than reporting an unqualified success. - Drift is only ever detected here, because this is the only place that holds - the desired value from the CR spec. - """ - driver = profile_spec["driver"] - description = profile_spec.get("description", "") - meta_info = profile_spec.get("meta_info", {}) - is_enabled = profile_spec["is_enabled"] - - profiles = service_profiles_for_driver(conn, driver, profile_cache) - profile = find_matching_profile(profiles, meta_info) - if profile: - profile_id = resource_id(profile) - LOG.info( - "Reusing service profile %s for %s driver=%s", - profile_id, - flavor_name, - driver, - ) - drifted = _collect_profile_drift( - profile, - profile_id, - driver, - flavor_name, - description=description, - is_enabled=is_enabled, - ) - if drift is not None: - drift.extend(drifted) - return profile - - LOG.info( - "Creating service profile for %s driver=%s is_enabled=%s", - flavor_name, - driver, - is_enabled, - ) - new_profile = conn.network.create_service_profile( - description=description, - driver=driver, - meta_info=meta_info_payload(managed_meta_info(meta_info)), - is_enabled=is_enabled, - ) - # Make the new profile visible to any later flavor in this same run that - # has an identical (driver, meta_info) spec, so it gets reused instead of - # creating a duplicate profile. - profiles.append(new_profile) - return new_profile - - -def find_flavor(conn: Any, name: str) -> Any | None: - # The SDK passes name= as a server-side query parameter (?name=), - # which Neutron filters in SQL, so at most one record is returned. The - # equality check guards against a future change to substring/LIKE semantics. - for flavor in conn.network.flavors(name=name): - if get_value(flavor, "name") == name: - return flavor - return None - - -def create_flavor( - conn: Any, - name: str, - service_type: str, - description: str, - *, - is_enabled: bool, -) -> Any: - LOG.info( - "Creating router flavor %s service_type=%s is_enabled=%s", - name, - service_type, - is_enabled, - ) - return conn.network.create_flavor( - name=name, - service_type=service_type, - is_enabled=is_enabled, - description=managed_flavor_description(description), - ) - - -def _associate_profile(conn: Any, flavor: Any, profile: Any) -> None: - """Associate *profile* with *flavor*, treating a 409 as already-associated.""" - flavor_id = resource_id(flavor) - profile_id = resource_id(profile) - LOG.info("Binding service profile %s to router flavor %s", profile_id, flavor_id) - try: - conn.network.associate_flavor_with_service_profile(flavor, profile) - except Exception as exc: # noqa: BLE001 - if not is_conflict(exc): - raise - LOG.info( - "Router flavor %s already has service profile %s", - flavor_id, - profile_id, - ) - - -def _disassociate_profile(conn: Any, flavor: Any, profile: Any) -> None: - """Disassociate *profile* from *flavor*, tolerating not-found/conflict.""" - flavor_id = resource_id(flavor) - profile_id = resource_id(profile) - LOG.info( - "Unbinding operator-managed service profile %s from router flavor %s", - profile_id, - flavor_id, - ) - try: - conn.network.disassociate_flavor_from_service_profile(flavor, profile) - except Exception as exc: # noqa: BLE001 - if is_not_found(exc): - LOG.info( - "Service profile %s already absent from router flavor %s", - profile_id, - flavor_id, - ) - return - if is_conflict(exc): - LOG.warning( - "Cannot unbind service profile %s from router flavor %s " - "(Neutron reports conflict, likely in use); leaving attached", - profile_id, - flavor_id, - ) - return - raise - - -def reconcile_flavor_profiles( - conn: Any, - flavor: Any, - desired_profiles: list[Any], -) -> Any: - """Reconcile the set of service profiles bound to *flavor*. - - ``desired_profiles`` is the list resolved from the CR spec (post - ``ensure_profile``). Profiles missing from the flavor are associated; - operator-managed profiles present on the flavor but absent from the - desired set are disassociated. Unmanaged profiles attached out-of-band - are left untouched so an operator's ad-hoc attachments survive reconcile. - - Returns the flavor re-fetched from Neutron so callers see the current - ``service_profile_ids``. - """ - flavor = conn.network.get_flavor(flavor) - flavor_id = resource_id(flavor) - flavor_name = get_value(flavor, "name", default=flavor_id) - - desired_by_id: dict[str, Any] = {resource_id(p): p for p in desired_profiles} - current_ids = set(service_profile_ids(flavor)) - desired_ids = set(desired_by_id) - - to_associate = desired_ids - current_ids - to_disassociate_candidates = current_ids - desired_ids - - if not to_associate and not to_disassociate_candidates: - LOG.info( - "Router flavor %s already has the desired service profiles %s", - flavor_name, - sorted(current_ids), - ) - return flavor - - for profile_id in sorted(to_associate): - _associate_profile(conn, flavor, desired_by_id[profile_id]) - - for profile_id in sorted(to_disassociate_candidates): - profile = get_service_profile(conn, profile_id) - if profile is None: - LOG.info( - "Service profile %s already absent from Neutron; nothing to unbind", - profile_id, - ) - continue - if not is_managed_service_profile(profile): - LOG.info( - "Keeping unmanaged service profile %s on router flavor %s; " - "operator only unbinds profiles it owns", - profile_id, - flavor_name, - ) - continue - _disassociate_profile(conn, flavor, profile) - - return conn.network.get_flavor(flavor) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py deleted file mode 100644 index e35502e39..000000000 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Delete/prune logic for removed Neutron router flavors.""" - -from __future__ import annotations - -import logging -from collections import Counter -from typing import Any - -from openstack_sync.plugins.common import get_service_profile -from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.common import is_conflict -from openstack_sync.plugins.common import is_not_found -from openstack_sync.plugins.common import resource_id -from openstack_sync.plugins.common import service_profile_ids -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - DEFAULT_SERVICE_TYPE, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - delete_unused_service_profiles_enabled, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - is_managed_flavor, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - is_managed_service_profile, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - prune_driver_prefixes, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - prune_removed_flavors_enabled, -) - -LOG = logging.getLogger(__name__) - - -def configured_flavor_names(flavors: list[dict[str, Any]]) -> set[str]: - return { - str(flavor_config["name"]) - for flavor_config in flavors - if flavor_config.get("name") - } - - -def service_profile_driver(profile: Any) -> str: - return str(get_value(profile, "driver", default="")) - - -def get_cached_service_profile( - conn: Any, - profile_id: str, - profile_cache: dict[str, Any | None], -) -> Any | None: - if profile_id not in profile_cache: - profile_cache[profile_id] = get_service_profile(conn, profile_id) - return profile_cache[profile_id] - - -def is_prunable_service_profile(profile: Any) -> bool: - driver = service_profile_driver(profile) - prefixes = prune_driver_prefixes() - return bool(prefixes) and any(driver.startswith(prefix) for prefix in prefixes) - - -def is_prunable_flavor(flavor: Any) -> bool: - if get_value(flavor, "service_type") != DEFAULT_SERVICE_TYPE: - return False - return is_managed_flavor(flavor) - - -def service_profile_attachment_counts(flavors: list[Any]) -> Counter[str]: - counts: Counter[str] = Counter() - for flavor in flavors: - counts.update(set(service_profile_ids(flavor))) - return counts - - -def detach_service_profile_ids( - profile_attachment_counts: Counter[str], - profile_ids: list[str], -) -> None: - for profile_id in profile_ids: - profile_attachment_counts[profile_id] -= 1 - if profile_attachment_counts[profile_id] <= 0: - del profile_attachment_counts[profile_id] - - -def flavor_has_routers(conn: Any, flavor: Any) -> bool: - flavor_id = resource_id(flavor) - flavor_name = get_value(flavor, "name", default=flavor_id) - - try: - routers = list(conn.network.routers(flavor_id=flavor_id)) - except Exception as exc: - LOG.warning( - "Unable to check routers for removed router flavor %s; " - "skipping deletion: %s", - flavor_name, - exc, - ) - return True - - if routers: - LOG.info( - "Router flavor %s is still used by %s router(s); skipping deletion", - flavor_name, - len(routers), - ) - return True - - return False - - -def service_profile_attached_to_any_flavor( - profile_attachment_counts: Counter[str], - profile_id: str, -) -> bool: - return profile_attachment_counts[profile_id] > 0 - - -def maybe_delete_service_profile( - conn: Any, - profile_id: str, - profile_cache: dict[str, Any | None], - profile_attachment_counts: Counter[str], -) -> None: - if not delete_unused_service_profiles_enabled(): - LOG.info("Keeping service profile %s; profile pruning is disabled", profile_id) - return - - profile = get_cached_service_profile(conn, profile_id, profile_cache) - if not profile: - return - - if not is_prunable_service_profile(profile): - LOG.info( - "Keeping service profile %s; driver %s is outside prune scope", - profile_id, - service_profile_driver(profile), - ) - return - - if not is_managed_service_profile(profile): - LOG.info("Keeping service profile %s; it is not operator-managed", profile_id) - return - - if service_profile_attached_to_any_flavor(profile_attachment_counts, profile_id): - LOG.info("Keeping service profile %s; it is still attached", profile_id) - return - - LOG.info("Deleting unused service profile %s", profile_id) - try: - conn.network.delete_service_profile(profile, ignore_missing=True) - profile_cache[profile_id] = None - except Exception as exc: - if is_not_found(exc): - profile_cache[profile_id] = None - return - if is_conflict(exc): - LOG.info("Service profile %s is still in use; skipping delete", profile_id) - return - raise - - -def delete_removed_flavor( - conn: Any, - flavor: Any, - profile_cache: dict[str, Any | None], - profile_attachment_counts: Counter[str], -) -> None: - flavor_id = resource_id(flavor) - flavor_name = get_value(flavor, "name", default=flavor_id) - profile_ids = service_profile_ids(flavor) - - if flavor_has_routers(conn, flavor): - return - - LOG.info("Deleting removed router flavor %s (%s)", flavor_name, flavor_id) - try: - conn.network.delete_flavor(flavor, ignore_missing=True) - except Exception as exc: - if is_not_found(exc): - LOG.info("Router flavor %s (%s) is already absent", flavor_name, flavor_id) - elif is_conflict(exc): - LOG.info( - "Router flavor %s is still in use; skipping delete", - flavor_name, - ) - return - else: - raise - - detach_service_profile_ids(profile_attachment_counts, profile_ids) - - for profile_id in profile_ids: - maybe_delete_service_profile( - conn, - profile_id, - profile_cache, - profile_attachment_counts, - ) - - -def prune_orphaned_service_profiles( - conn: Any, - profile_cache: dict[str, Any | None], - profile_attachment_counts: Counter[str], -) -> None: - """Delete orphaned operator-managed service profiles. - - Runs after the flavor prune loop to catch profiles left behind when - delete_flavor succeeded but maybe_delete_service_profile threw on the same - run. Safe to run every cycle because it only touches operator-owned, unattached - profiles. - """ - LOG.info("Scanning for orphaned operator-managed service profiles") - for profile in list(conn.network.service_profiles()): - profile_id = resource_id(profile) - if not is_prunable_service_profile(profile): - continue - if not is_managed_service_profile(profile): - continue - maybe_delete_service_profile( - conn, - profile_id, - profile_cache, - profile_attachment_counts, - ) - - -def prune_removed_flavors( - conn: Any, - flavors: list[dict[str, Any]], - *, - authoritative_empty_desired: bool = False, -) -> None: - if not prune_removed_flavors_enabled(): - LOG.info("Router flavor pruning is disabled") - return - - if not flavors and not authoritative_empty_desired: - LOG.warning( - "No desired router flavors found; skipping prune to avoid deleting " - "all managed router flavors" - ) - return - - desired_names = configured_flavor_names(flavors) - profile_cache: dict[str, Any | None] = {} - - LOG.info("Pruning removed router flavors") - current_flavors = list(conn.network.flavors(service_type=DEFAULT_SERVICE_TYPE)) - profile_attachment_counts = service_profile_attachment_counts(current_flavors) - for flavor in current_flavors: - flavor_name = get_value(flavor, "name") - if not flavor_name or flavor_name in desired_names: - continue - if not is_prunable_flavor(flavor): - continue - delete_removed_flavor( - conn, - flavor, - profile_cache, - profile_attachment_counts, - ) - - # Second pass: catch profiles orphaned by a partial failure on a previous - # run (delete_flavor succeeded but maybe_delete_service_profile threw). - prune_orphaned_service_profiles( - conn, - profile_cache, - profile_attachment_counts, - ) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/markers.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/markers.py new file mode 100644 index 000000000..3f372b2b5 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/markers.py @@ -0,0 +1,106 @@ +"""Ownership markers for operator-managed router flavors and service profiles. + +The operator only ever creates, updates or deletes resources carrying one of +these markers. That is what makes it safe to run alongside flavors and profiles +a human created by hand. + +Two mechanisms, because Neutron gives the two resources different places to +write to: service profiles carry marker keys inside ``meta_info``, flavors carry +a marker string appended to ``description``. +""" + +from __future__ import annotations + +from typing import Any + +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import meta_info_payload +from openstack_sync.plugins.common import normalize_meta_info + +MANAGED_META_INFO_KEY = "_understack_router_flavor_operator" +MANAGED_META_INFO_VALUE = "managed" +MARKER_VERSION_META_INFO_KEY = "_understack_router_flavor_marker_version" +MARKER_VERSION_META_INFO_VALUE = "v1" +MARKER_SOURCE_META_INFO_KEY = "_understack_router_flavor_source" +MARKER_SOURCE_META_INFO_VALUE = "NeutronRouterFlavor" + +FLAVOR_DESCRIPTION_MARKER = "[understack-router-flavor-operator]" + +#: Marker keys stamped into a managed service profile's ``meta_info``. +OPERATOR_META_INFO_MARKERS = { + MANAGED_META_INFO_KEY: MANAGED_META_INFO_VALUE, + MARKER_VERSION_META_INFO_KEY: MARKER_VERSION_META_INFO_VALUE, + MARKER_SOURCE_META_INFO_KEY: MARKER_SOURCE_META_INFO_VALUE, +} + +_MARKER_KEYS = frozenset(OPERATOR_META_INFO_MARKERS) + + +# --------------------------------------------------------------------------- +# Service profiles: markers live in meta_info +# --------------------------------------------------------------------------- + + +def service_profile_meta_info(profile: Any) -> Any: + """Return the ``meta_info`` of *profile*.""" + return get_value(profile, "meta_info", default={}) + + +def _comparable(value: Any) -> Any: + """Strip operator marker keys so specs and Neutron state compare equal.""" + normalized = normalize_meta_info(value) + if isinstance(normalized, dict): + return {k: v for k, v in normalized.items() if k not in _MARKER_KEYS} + return normalized + + +def meta_info_matches(current: Any, desired: Any) -> bool: + """Return True when *current* and *desired* meta_info are logically equal.""" + return meta_info_payload(_comparable(current)) == meta_info_payload( + _comparable(desired) + ) + + +def managed_meta_info(value: Any) -> Any: + """Return *value* with the operator ownership markers merged in.""" + normalized = normalize_meta_info(value) + if not isinstance(normalized, dict): + return normalized + return {**normalized, **OPERATOR_META_INFO_MARKERS} + + +def is_managed_service_profile(profile: Any) -> bool: + """Return True when *profile* carries the operator ownership marker.""" + meta_info = normalize_meta_info(service_profile_meta_info(profile)) + return ( + isinstance(meta_info, dict) + and meta_info.get(MANAGED_META_INFO_KEY) == MANAGED_META_INFO_VALUE + ) + + +# --------------------------------------------------------------------------- +# Flavors: the marker lives in description +# --------------------------------------------------------------------------- + + +def clean_flavor_description(value: Any) -> str: + """Return *value* with the operator description marker stripped.""" + return str(value or "").replace(FLAVOR_DESCRIPTION_MARKER, "").strip() + + +def managed_flavor_description(value: Any) -> str: + """Return *value* with the operator description marker appended.""" + description = clean_flavor_description(value) + if not description: + return FLAVOR_DESCRIPTION_MARKER + return f"{description} {FLAVOR_DESCRIPTION_MARKER}" + + +def flavor_description_has_marker(value: Any) -> bool: + """Return True when *value* contains the operator description marker.""" + return FLAVOR_DESCRIPTION_MARKER in str(value or "") + + +def is_managed_flavor(flavor: Any) -> bool: + """Return True when the flavor's description carries the operator marker.""" + return flavor_description_has_marker(get_value(flavor, "description", default="")) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/prune.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/prune.py new file mode 100644 index 000000000..49810ef51 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/prune.py @@ -0,0 +1,169 @@ +"""Delete router flavors and service profiles whose CR was removed. + +Everything here is gated on the operator's ownership markers, so a flavor or +profile created by hand is never touched. Ownership is the only gate needed: a +resource carrying the marker was created by this operator, which makes any +further filtering redundant. +""" + +from __future__ import annotations + +import logging +from collections import Counter +from typing import Any + +from openstack import exceptions as openstack_exceptions + +from openstack_sync.plugins.common import get_service_profile +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import resource_id +from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors.config import SERVICE_TYPE +from openstack_sync.plugins.neutron.router_flavors.markers import is_managed_flavor +from openstack_sync.plugins.neutron.router_flavors.markers import ( + is_managed_service_profile, +) + +LOG = logging.getLogger(__name__) + +#: Service profiles fetched during a prune, keyed by ID. None means "gone". +ProfileCache = dict[str, Any | None] + + +def _cached_profile(conn: Any, profile_id: str, cache: ProfileCache) -> Any | None: + if profile_id not in cache: + cache[profile_id] = get_service_profile(conn, profile_id) + return cache[profile_id] + + +def _attachment_counts(flavors: list[Any]) -> Counter[str]: + """Count how many flavors each service profile is bound to.""" + counts: Counter[str] = Counter() + for flavor in flavors: + counts.update(set(service_profile_ids(flavor))) + return counts + + +def _flavor_has_routers(conn: Any, flavor: Any, flavor_name: str) -> bool: + """Return True when routers still use *flavor*, or when we cannot tell.""" + try: + routers = list(conn.network.routers(flavor_id=resource_id(flavor))) + except Exception as exc: # noqa: BLE001 + LOG.warning( + "Unable to check routers for router flavor %s; skipping deletion: %s", + flavor_name, + exc, + ) + return True + + if routers: + LOG.info( + "Router flavor %s is still used by %s router(s); skipping deletion", + flavor_name, + len(routers), + ) + return True + return False + + +def maybe_delete_profile( + conn: Any, profile_id: str, cache: ProfileCache, counts: Counter[str] +) -> None: + """Delete *profile_id* when the operator owns it and nothing is bound to it.""" + profile = _cached_profile(conn, profile_id, cache) + if not profile: + return + if not is_managed_service_profile(profile): + LOG.info("Keeping service profile %s; it is not operator-owned", profile_id) + return + if counts[profile_id] > 0: + LOG.info("Keeping service profile %s; it is still attached", profile_id) + return + + LOG.info("Deleting unused service profile %s", profile_id) + try: + conn.network.delete_service_profile(profile, ignore_missing=True) + cache[profile_id] = None + except openstack_exceptions.NotFoundException: + cache[profile_id] = None + except openstack_exceptions.ConflictException: + LOG.info("Service profile %s is still in use; skipping delete", profile_id) + + +def _delete_flavor( + conn: Any, flavor: Any, cache: ProfileCache, counts: Counter[str] +) -> None: + flavor_id = resource_id(flavor) + flavor_name = get_value(flavor, "name", default=flavor_id) + profile_ids = service_profile_ids(flavor) + + if _flavor_has_routers(conn, flavor, flavor_name): + return + + LOG.info("Deleting removed router flavor %s (%s)", flavor_name, flavor_id) + try: + conn.network.delete_flavor(flavor, ignore_missing=True) + except openstack_exceptions.NotFoundException: + LOG.info("Router flavor %s (%s) is already absent", flavor_name, flavor_id) + except openstack_exceptions.ConflictException: + LOG.info("Router flavor %s is still in use; skipping delete", flavor_name) + return + + # The flavor is gone, so its profiles lost one attachment each. + for profile_id in profile_ids: + counts[profile_id] -= 1 + if counts[profile_id] <= 0: + del counts[profile_id] + + for profile_id in profile_ids: + maybe_delete_profile(conn, profile_id, cache, counts) + + +def _prune_orphaned_profiles( + conn: Any, cache: ProfileCache, counts: Counter[str] +) -> None: + """Delete owned, unattached profiles left behind by an earlier partial failure. + + Safe to run every cycle: it only ever touches operator-owned profiles that + no flavor is bound to. + """ + LOG.info("Scanning for orphaned operator-owned service profiles") + for profile in list(conn.network.service_profiles()): + if is_managed_service_profile(profile): + maybe_delete_profile(conn, resource_id(profile), cache, counts) + + +def prune_removed_flavors( + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool = False, +) -> None: + """Delete operator-owned router flavors absent from *desired_specs*. + + An empty *desired_specs* is only acted on when *authoritative_empty* says a + CR really was deleted; otherwise it may be a snapshot we could not read, and + pruning against it would delete every managed flavor. + """ + if not desired_specs and not authoritative_empty: + LOG.warning( + "No desired router flavors found; skipping prune to avoid deleting " + "all managed router flavors" + ) + return + + desired_names = {str(spec["name"]) for spec in desired_specs if spec.get("name")} + cache: ProfileCache = {} + + LOG.info("Pruning removed router flavors") + current = list(conn.network.flavors(service_type=SERVICE_TYPE)) + counts = _attachment_counts(current) + for flavor in current: + name = get_value(flavor, "name") + if not name or name in desired_names: + continue + if not is_managed_flavor(flavor): + continue + _delete_flavor(conn, flavor, cache, counts) + + _prune_orphaned_profiles(conn, cache, counts) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/reconcile.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/reconcile.py new file mode 100644 index 000000000..9ebe599fd --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/reconcile.py @@ -0,0 +1,420 @@ +"""Reconcile a NeutronRouterFlavor CR onto Neutron. + +Ordered as the reconcile reads: resolve the service profiles the spec asks for, +converge the flavor itself, then converge the set of profiles bound to it. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Any + +from openstack import exceptions as openstack_exceptions + +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import get_service_profile +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import meta_info_payload +from openstack_sync.plugins.common import resource_id +from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors.markers import ( + clean_flavor_description, +) +from openstack_sync.plugins.neutron.router_flavors.markers import ( + flavor_description_has_marker, +) +from openstack_sync.plugins.neutron.router_flavors.markers import ( + is_managed_service_profile, +) +from openstack_sync.plugins.neutron.router_flavors.markers import ( + managed_flavor_description, +) +from openstack_sync.plugins.neutron.router_flavors.markers import managed_meta_info +from openstack_sync.plugins.neutron.router_flavors.markers import meta_info_matches +from openstack_sync.plugins.neutron.router_flavors.markers import ( + service_profile_meta_info, +) + +LOG = logging.getLogger(__name__) + +#: Service profiles already fetched this run, keyed by driver. +ProfileCache = dict[str, list[Any]] + + +@dataclass(frozen=True) +class ProfileDrift: + """One field of a reused service profile that diverged from the CR spec. + + Profile drift is reported, never auto-corrected. Neutron's + ``update_service_profile`` calls ``_ensure_service_profile_not_in_use`` and + raises ``ServiceProfileInUse`` (HTTP 409) while *any* flavor binding exists + -- not merely while a router is using it -- and this operator binds every + profile it manages. An update attempt would fail every cycle. Correcting + drift means unbinding the profile from every flavor first, which is an + operator decision. + """ + + profile_id: str + field: str + have: Any + want: Any + + def describe(self) -> str: + return ( + f"service profile {self.profile_id} {self.field}: " + f"have={self.have!r} want={self.want!r}" + ) + + +# --------------------------------------------------------------------------- +# Service profiles +# --------------------------------------------------------------------------- + + +def profiles_for_driver(conn: Any, driver: str, cache: ProfileCache) -> list[Any]: + """Return every service profile for *driver*, fetched once per run.""" + if driver not in cache: + cache[driver] = list(conn.network.service_profiles(driver=driver)) + return cache[driver] + + +def find_matching_profile(profiles: list[Any], meta_info: Any) -> Any | None: + """Return the operator-owned profile matching *meta_info*, if any. + + Only operator-owned profiles are reuse candidates. Reusing a profile the + operator does not own would bind it to the flavor, and + ``reconcile_flavor_profiles`` unbinds only profiles carrying the ownership + marker -- so the operator would have created a binding it can never remove. + That binding outlives the spec that created it, and Neutron's + ``get_flavor_next_provider`` picks an arbitrary binding (``objs[0]``), so a + stale one can end up serving routers with the wrong ``meta_info``. + + An unowned profile that happens to match is left completely alone -- not + adopted by stamping the marker onto it, which would enrol somebody else's + profile into ``prune`` for eventual deletion -- and ``ensure_profile`` + creates a dedicated managed profile alongside it. + """ + unowned: list[str] = [] + for profile in profiles: + if not meta_info_matches(service_profile_meta_info(profile), meta_info): + continue + if is_managed_service_profile(profile): + return profile + unowned.append(str(get_value(profile, "id", default=""))) + + if unowned: + LOG.info( + "Not reusing service profile(s) %s: they match the desired meta_info " + "but are not operator-owned, and the operator only binds profiles it " + "can unbind again; creating a dedicated managed profile instead", + sorted(unowned), + ) + return None + + +def _profile_drift( + profile: Any, profile_id: str, flavor_name: str, spec: dict[str, Any] +) -> list[ProfileDrift]: + """Return the spec fields a reused *profile* disagrees with. + + ``meta_info`` is excluded by construction -- the profile was selected by + matching it -- and ``driver`` is excluded because profiles are queried per + driver. That leaves ``is_enabled`` and ``description``. + + ``is_enabled`` is the consequential one: Neutron's + ``get_flavor_next_provider`` raises ``ServiceProfileDisabled`` (HTTP 503) + when the profile it selects is disabled, so every router create against the + flavor fails while the flavor still looks healthy. + """ + checks = ( + ( + "is_enabled", + bool(get_value(profile, "is_enabled", default=True)), + bool(spec["is_enabled"]), + ), + ( + "description", + str(get_value(profile, "description", default="")), + str(spec.get("description", "")), + ), + ) + drift = [ + ProfileDrift(profile_id=profile_id, field=field, have=have, want=want) + for field, have, want in checks + if have != want + ] + + for item in drift: + LOG.warning( + "Service profile %s reused by router flavor %s has drifted from the " + "CR spec (%s: have=%r want=%r). Neutron rejects updates to a profile " + "bound to any flavor, so the operator cannot correct this; unbind it " + "from every flavor to update it, or delete it and let the operator " + "recreate it", + profile_id, + flavor_name, + item.field, + item.have, + item.want, + ) + return drift + + +def ensure_profile( + conn: Any, + flavor_name: str, + spec: dict[str, Any], + cache: ProfileCache, + drift: list[ProfileDrift], +) -> Any: + """Find or create the service profile *spec* describes. + + The CRD guarantees ``driver`` and ``is_enabled`` are present; ``description`` + and ``meta_info`` are optional and fall back to empty. Drift on a reused + profile is appended to *drift* -- this is the only place holding both the + desired spec value and the Neutron state, so it is the only place drift can + be detected. + """ + driver = spec["driver"] + meta_info = spec.get("meta_info", {}) + + profiles = profiles_for_driver(conn, driver, cache) + profile = find_matching_profile(profiles, meta_info) + if profile: + profile_id = resource_id(profile) + LOG.info( + "Reusing service profile %s for %s driver=%s", + profile_id, + flavor_name, + driver, + ) + drift.extend(_profile_drift(profile, profile_id, flavor_name, spec)) + return profile + + LOG.info( + "Creating service profile for %s driver=%s is_enabled=%s", + flavor_name, + driver, + spec["is_enabled"], + ) + created = conn.network.create_service_profile( + description=spec.get("description", ""), + driver=driver, + meta_info=meta_info_payload(managed_meta_info(meta_info)), + is_enabled=spec["is_enabled"], + ) + # Visible to any later flavor this run with an identical (driver, meta_info) + # spec, so it reuses this profile instead of creating a duplicate. + profiles.append(created) + return created + + +# --------------------------------------------------------------------------- +# The flavor +# --------------------------------------------------------------------------- + + +def find_flavor(conn: Any, name: str) -> Any | None: + """Return the flavor named *name*, or None. + + The SDK passes ``name=`` as a server-side query parameter which Neutron + filters in SQL, so at most one record comes back; the equality check guards + against a future change to substring semantics. + """ + for flavor in conn.network.flavors(name=name): + if get_value(flavor, "name") == name: + return flavor + return None + + +def ensure_flavor(conn: Any, spec: dict[str, Any]) -> Any: + """Find or create the router flavor *spec* describes, reconciling drift.""" + name = spec["name"] + service_type = spec["service_type"] + description = spec.get("description", "") + is_enabled = spec["is_enabled"] + + flavor = find_flavor(conn, name) + if not flavor: + LOG.info( + "Creating router flavor %s service_type=%s is_enabled=%s", + name, + service_type, + is_enabled, + ) + return conn.network.create_flavor( + name=name, + service_type=service_type, + is_enabled=is_enabled, + description=managed_flavor_description(description), + ) + + LOG.info("Router flavor %s already exists", name) + current_service_type = get_value(flavor, "service_type", default="") + if current_service_type != service_type: + raise ConfigError( + f"Router flavor {name!r} already exists in Neutron with " + f"service_type={current_service_type!r}; expected {service_type!r}. " + f"Neutron does not allow updating service_type on an existing " + f"flavor. Rename the CR or remove the existing Neutron flavor to " + f"let the operator recreate it." + ) + + current_description = get_value(flavor, "description", default="") + current_is_enabled = bool(get_value(flavor, "is_enabled", default=True)) + description_changed = clean_flavor_description( + current_description + ) != clean_flavor_description(description) + marker_missing = not flavor_description_has_marker(current_description) + is_enabled_changed = current_is_enabled != is_enabled + + if is_enabled_changed: + LOG.info( + "Router flavor %s is_enabled drift: have=%s want=%s; reconciling", + name, + current_is_enabled, + is_enabled, + ) + + if description_changed or marker_missing or is_enabled_changed: + return conn.network.update_flavor( + flavor, + description=managed_flavor_description(description), + is_enabled=is_enabled, + ) + return flavor + + +# --------------------------------------------------------------------------- +# Flavor <-> profile bindings +# --------------------------------------------------------------------------- + + +def _associate(conn: Any, flavor: Any, profile: Any) -> None: + flavor_id = resource_id(flavor) + profile_id = resource_id(profile) + LOG.info("Binding service profile %s to router flavor %s", profile_id, flavor_id) + try: + conn.network.associate_flavor_with_service_profile(flavor, profile) + except openstack_exceptions.ConflictException: + # Another reconcile bound it first. + LOG.info( + "Router flavor %s already has service profile %s", flavor_id, profile_id + ) + + +def _disassociate(conn: Any, flavor: Any, profile: Any) -> None: + flavor_id = resource_id(flavor) + profile_id = resource_id(profile) + LOG.info( + "Unbinding operator-managed service profile %s from router flavor %s", + profile_id, + flavor_id, + ) + try: + conn.network.disassociate_flavor_from_service_profile(flavor, profile) + except openstack_exceptions.NotFoundException: + LOG.info( + "Service profile %s already absent from router flavor %s", + profile_id, + flavor_id, + ) + except openstack_exceptions.ConflictException: + LOG.warning( + "Cannot unbind service profile %s from router flavor %s (Neutron " + "reports conflict, likely in use); leaving it attached", + profile_id, + flavor_id, + ) + + +def reconcile_flavor_profiles( + conn: Any, flavor: Any, desired_profiles: list[Any] +) -> Any: + """Converge the set of service profiles bound to *flavor*. + + Profiles missing from the flavor are bound; operator-owned profiles bound to + it but absent from the desired set are unbound. Profiles attached + out-of-band are left alone -- the operator only unbinds what it owns. + """ + flavor = conn.network.get_flavor(flavor) + flavor_name = get_value(flavor, "name", default=resource_id(flavor)) + + desired_by_id = {resource_id(p): p for p in desired_profiles} + current_ids = set(service_profile_ids(flavor)) + to_bind = set(desired_by_id) - current_ids + to_unbind = current_ids - set(desired_by_id) + + if not to_bind and not to_unbind: + LOG.info( + "Router flavor %s already has the desired service profiles %s", + flavor_name, + sorted(current_ids), + ) + return flavor + + for profile_id in sorted(to_bind): + _associate(conn, flavor, desired_by_id[profile_id]) + + for profile_id in sorted(to_unbind): + profile = get_service_profile(conn, profile_id) + if profile is None: + LOG.info( + "Service profile %s already absent from Neutron; nothing to unbind", + profile_id, + ) + continue + if not is_managed_service_profile(profile): + LOG.info( + "Keeping unowned service profile %s on router flavor %s; the " + "operator only unbinds profiles it owns", + profile_id, + flavor_name, + ) + continue + _disassociate(conn, flavor, profile) + + return conn.network.get_flavor(flavor) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def render_flavor(flavor: Any) -> dict[str, Any]: + """Return the reconciled flavor as a loggable dict.""" + return { + "id": get_value(flavor, "id"), + "name": get_value(flavor, "name"), + "service_type": get_value(flavor, "service_type"), + "description": get_value(flavor, "description"), + "is_enabled": get_value(flavor, "is_enabled"), + "service_profile_ids": service_profile_ids(flavor), + } + + +def sync_flavor(conn: Any, spec: dict[str, Any], cache: ProfileCache) -> list[str]: + """Converge one NeutronRouterFlavor spec, returning drift notes.""" + name = spec["name"] + profile_specs = spec["service_profiles"] + + LOG.info( + "Reconciling router flavor %s with %s service profile(s)", + name, + len(profile_specs), + ) + drift: list[ProfileDrift] = [] + desired_profiles = [ + ensure_profile(conn, name, profile_spec, cache, drift) + for profile_spec in profile_specs + ] + flavor = ensure_flavor(conn, spec) + flavor = reconcile_flavor_profiles(conn, flavor, desired_profiles) + LOG.info( + "Reconciled router flavor: %s", + json.dumps(render_flavor(flavor), sort_keys=True), + ) + return [item.describe() for item in drift] diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py deleted file mode 100644 index f44b39db5..000000000 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Router-flavor-specific constants and helpers. - -Generic utilities (env helpers, resource accessors, meta_info, exception -classifiers, etc.) live in :mod:`openstack_sync.plugins.common`. -""" - -from __future__ import annotations - -import os -from dataclasses import dataclass -from typing import Any - -from openstack_sync.plugins.common import comparable_meta_info_without -from openstack_sync.plugins.common import env_bool -from openstack_sync.plugins.common import env_float -from openstack_sync.plugins.common import env_int -from openstack_sync.plugins.common import env_required -from openstack_sync.plugins.common import env_tuple -from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.common import managed_meta_info as managed_meta_info_with -from openstack_sync.plugins.common import meta_info_matches_without -from openstack_sync.plugins.common import normalize_meta_info -from openstack_sync.plugins.common import wait_for_openstack_network as wait_for_network - -# --------------------------------------------------------------------------- -# Router-flavor CRD identity -# --------------------------------------------------------------------------- -# CRD_API_VERSION, CRD_KIND, and CRD_RESOURCE are injected by the Helm chart -# at runtime and must NOT be read at module import time. Importing this module -# happens before shell-operator invokes the hook with --config, and these vars -# are not guaranteed to be present at that point (e.g. broken chart rendering, -# unit tests that only exercise the --config path). -# -# Use the accessor functions below — crd_api_version(), crd_kind(), -# crd_resource() — everywhere these values are needed. They call -# env_required() which raises ConfigError with a clear message if a var is -# absent, rather than crashing at import with a raw KeyError. -# -# Internal shell-operator binding label default. -CRD_BINDING_NAME = "neutron-router-flavors" -DEFAULT_SERVICE_TYPE = "L3_ROUTER_NAT" - - -def crd_api_version() -> str: - """Return the CRD API version injected by the Helm chart.""" - return env_required("NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION") - - -def crd_kind() -> str: - """Return the CRD kind injected by the Helm chart.""" - return env_required("NEUTRON_ROUTER_FLAVOR_CRD_KIND") - - -def crd_resource() -> str: - """Return the fully-qualified CRD resource name injected by the Helm chart.""" - return env_required("NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE") - - -def crd_binding_name() -> str: - """Return the shell-operator binding label for the CRD watch.""" - return os.environ.get("NEUTRON_ROUTER_FLAVOR_CRD_BINDING_NAME", CRD_BINDING_NAME) - - -def crd_namespace() -> str | None: - """Return the namespace used for CRD status patches.""" - return os.environ.get("POD_NAMESPACE") - - -def status_enabled() -> bool: - """Return whether CRD status patching is enabled.""" - return env_bool("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", False) - - -# --------------------------------------------------------------------------- -# Prune / lifecycle config -# --------------------------------------------------------------------------- - - -def prune_removed_flavors_enabled() -> bool: - """Return whether removed router flavor pruning is enabled.""" - return env_bool("NEUTRON_ROUTER_FLAVOR_PRUNE", False) - - -def delete_unused_service_profiles_enabled() -> bool: - """Return whether unused service profile deletion is enabled.""" - return env_bool("NEUTRON_ROUTER_FLAVOR_DELETE_UNUSED_PROFILES", True) - - -def prune_driver_prefixes() -> tuple[str, ...]: - """Return service profile driver prefixes eligible for pruning.""" - return env_tuple( - "NEUTRON_ROUTER_FLAVOR_PRUNE_DRIVER_PREFIXES", - "neutron_understack.l3_router.", - ) - - -# --------------------------------------------------------------------------- -# Operator ownership markers -# --------------------------------------------------------------------------- - -MANAGED_META_INFO_KEY = os.environ.get( - "NEUTRON_ROUTER_FLAVOR_MANAGED_META_INFO_KEY", - "_understack_router_flavor_operator", -) -MANAGED_META_INFO_VALUE = "managed" -FLAVOR_DESCRIPTION_MARKER = os.environ.get( - "NEUTRON_ROUTER_FLAVOR_DESCRIPTION_MARKER", - "[understack-router-flavor-operator]", -) -MARKER_VERSION_META_INFO_KEY = "_understack_router_flavor_marker_version" -MARKER_VERSION_META_INFO_VALUE = "v1" -MARKER_SOURCE_META_INFO_KEY = "_understack_router_flavor_source" - -# --------------------------------------------------------------------------- -# Retry config -# --------------------------------------------------------------------------- - - -def ready_retries() -> int: - """Return the Neutron readiness retry count.""" - return env_int("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", 30) - - -def ready_delay() -> float: - """Return the Neutron readiness delay in seconds.""" - return env_float("NEUTRON_ROUTER_FLAVOR_READY_DELAY", 10) - - -# --------------------------------------------------------------------------- -# Runtime-resolved marker helpers -# --------------------------------------------------------------------------- -# MARKER_SOURCE defaults to the CRD kind, which is only available at runtime. -# Use marker_source() rather than a module-level constant. - - -def marker_source() -> str: - """Return the marker source value, defaulting to the CRD kind.""" - return os.environ.get("NEUTRON_ROUTER_FLAVOR_SOURCE") or crd_kind() - - -def operator_meta_info_markers() -> dict[str, str]: - """Return the operator ownership marker dict.""" - return { - MANAGED_META_INFO_KEY: MANAGED_META_INFO_VALUE, - MARKER_VERSION_META_INFO_KEY: MARKER_VERSION_META_INFO_VALUE, - MARKER_SOURCE_META_INFO_KEY: marker_source(), - } - - -def operator_meta_info_keys() -> frozenset[str]: - """Return the frozenset of operator marker keys.""" - return frozenset(operator_meta_info_markers()) - - -# --------------------------------------------------------------------------- -# meta_info helpers bound to this plugin's operator marker keys -# --------------------------------------------------------------------------- - - -def comparable_meta_info(value: Any) -> Any: - """Strip operator marker keys from *value* before comparison.""" - return comparable_meta_info_without(value, operator_meta_info_keys()) - - -def meta_info_matches(current: Any, desired: Any) -> bool: - """Return True when *current* and *desired* are logically equal. - - Operator-managed marker keys are ignored during comparison. - """ - return meta_info_matches_without(current, desired, operator_meta_info_keys()) - - -def managed_meta_info(value: Any) -> Any: - """Merge operator ownership markers into *value*.""" - return managed_meta_info_with(value, operator_meta_info_markers()) - - -# --------------------------------------------------------------------------- -# Flavor description marker helpers -# --------------------------------------------------------------------------- - - -def clean_flavor_description(value: Any) -> str: - """Return *value* with the operator description marker stripped.""" - description = "" if value is None else str(value) - return description.replace(FLAVOR_DESCRIPTION_MARKER, "").strip() - - -def managed_flavor_description(value: Any) -> str: - """Return *value* with the operator description marker appended.""" - description = clean_flavor_description(value) - if not description: - return FLAVOR_DESCRIPTION_MARKER - return f"{description} {FLAVOR_DESCRIPTION_MARKER}" - - -def flavor_description_has_marker(value: Any) -> bool: - """Return True when *value* contains the operator description marker.""" - return FLAVOR_DESCRIPTION_MARKER in str(value or "") - - -def is_managed_flavor(flavor: Any) -> bool: - """Return True when the flavor's description contains the operator marker.""" - return flavor_description_has_marker(get_value(flavor, "description", default="")) - - -# --------------------------------------------------------------------------- -# Service profile ownership helpers -# --------------------------------------------------------------------------- - - -def service_profile_meta_info(profile: Any) -> Any: - """Return the meta_info field of *profile*.""" - return get_value(profile, "meta_info", default={}) - - -def is_managed_service_profile(profile: Any) -> bool: - """Return True when the service profile carries the operator ownership marker.""" - meta_info = normalize_meta_info(service_profile_meta_info(profile)) - return ( - isinstance(meta_info, dict) - and meta_info.get(MANAGED_META_INFO_KEY) == MANAGED_META_INFO_VALUE - ) - - -# --------------------------------------------------------------------------- -# Service profile drift reporting -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class ProfileDrift: - """One field of a reused service profile that diverged from the CR spec. - - Profile drift is reported, never auto-corrected. Neutron's - ``update_service_profile`` calls ``_ensure_service_profile_not_in_use`` and - raises ``ServiceProfileInUse`` (HTTP 409) while *any* flavor binding exists - -- not merely while a router is using it -- and this operator binds every - profile it manages. An update attempt would therefore fail every cycle. - Correcting drift requires unbinding the profile from every flavor first, - which is an operator decision, not something to do behind their back. - """ - - profile_id: str - driver: str - field: str - have: Any - want: Any - - def describe(self) -> str: - """Return a short ``field: have=... want=...`` description.""" - return f"{self.field}: have={self.have!r} want={self.want!r}" - - -def describe_profile_drift(drift: list[ProfileDrift]) -> str: - """Return a single-line summary of *drift* for logs and CR status.""" - return "; ".join( - f"service profile {item.profile_id} {item.describe()}" for item in drift - ) - - -# --------------------------------------------------------------------------- -# Config validation -# --------------------------------------------------------------------------- - - -def config_meta_info(flavor_config: dict[str, Any]) -> Any: - """Return the canonical meta_info payload from a router flavor spec.""" - return flavor_config.get("meta_info", {}) - - -# --------------------------------------------------------------------------- -# Neutron readiness probe -# --------------------------------------------------------------------------- - - -def wait_for_openstack_network(conn: Any) -> None: - """Poll until the Neutron network API is reachable. - - Reads retry config at call time so malformed values do not break hook - import or shell-operator --config registration. - """ - wait_for_network(conn, retries=ready_retries(), delay=ready_delay()) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py deleted file mode 100644 index 77a3e1ac7..000000000 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Update and sync logic for configured Neutron router flavors.""" - -from __future__ import annotations - -import json -import logging -from typing import Any - -from openstack_sync.plugins.common import ConfigError -from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.common import service_profile_ids -from openstack_sync.plugins.neutron.router_flavors import create -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - DEFAULT_SERVICE_TYPE, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - ProfileDrift, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - clean_flavor_description, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - describe_profile_drift, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - flavor_description_has_marker, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - managed_flavor_description, -) - -LOG = logging.getLogger(__name__) - - -def ensure_flavor( - conn: Any, - name: str, - service_type: str, - description: str, - *, - is_enabled: bool, -) -> Any: - flavor = create.find_flavor(conn, name) - managed_description = managed_flavor_description(description) - if flavor: - LOG.info("Router flavor %s already exists", name) - - current_service_type = get_value(flavor, "service_type", default="") - if current_service_type != service_type: - raise ConfigError( - f"Router flavor {name!r} already exists in Neutron with " - f"service_type={current_service_type!r}; " - f"expected {service_type!r}. Neutron does not allow updating " - f"service_type on an existing flavor. Rename the CR or remove " - f"the existing Neutron flavor to let the operator recreate it." - ) - - current_description = get_value(flavor, "description", default="") - description_changed = clean_flavor_description( - current_description - ) != clean_flavor_description(description) - marker_missing = not flavor_description_has_marker(current_description) - current_is_enabled = bool(get_value(flavor, "is_enabled", default=True)) - is_enabled_drifted = current_is_enabled != is_enabled - - if is_enabled_drifted: - LOG.info( - "Router flavor %s is_enabled drift: have=%s want=%s; reconciling", - name, - current_is_enabled, - is_enabled, - ) - - if description_changed or marker_missing or is_enabled_drifted: - return conn.network.update_flavor( - flavor, - description=managed_description, - is_enabled=is_enabled, - ) - return flavor - - return create.create_flavor( - conn, name, service_type, description, is_enabled=is_enabled - ) - - -def render_flavor(flavor: Any) -> dict[str, Any]: - return { - "id": get_value(flavor, "id"), - "name": get_value(flavor, "name"), - "service_type": get_value(flavor, "service_type"), - "description": get_value(flavor, "description"), - "is_enabled": get_value(flavor, "is_enabled"), - "service_profile_ids": service_profile_ids(flavor), - } - - -def sync_flavor( - conn: Any, - flavor_config: dict[str, Any], - profile_cache: create.ServiceProfileCache, -) -> list[ProfileDrift]: - """Reconcile one router flavor CR to the desired Neutron state. - - ``flavor_config`` is the CR spec after cloudCredentialsRef has been - stripped. Schema-required keys are read via subscript so a missing key - fails loudly rather than being silently defaulted; schema-optional keys - (description, meta_info) fall back to their type's empty value. - - Returns the service profile drift detected while reconciling. An empty list - means spec and Neutron agree. Drift is not a reconcile failure -- the flavor - itself is still converged -- but it needs an operator to act, so the caller - is expected to qualify the status it reports rather than dropping it. - """ - name = flavor_config["name"] - service_type = flavor_config.get("service_type", DEFAULT_SERVICE_TYPE) - description = flavor_config.get("description", "") - is_enabled = flavor_config["is_enabled"] - profile_specs = flavor_config["service_profiles"] - - LOG.info( - "Reconciling router flavor %s with %s service profile(s)", - name, - len(profile_specs), - ) - drift: list[ProfileDrift] = [] - desired_profiles = [ - create.ensure_profile(conn, name, profile_spec, profile_cache, drift) - for profile_spec in profile_specs - ] - flavor = ensure_flavor(conn, name, service_type, description, is_enabled=is_enabled) - flavor = create.reconcile_flavor_profiles(conn, flavor, desired_profiles) - LOG.info( - "Reconciled router flavor: %s", - json.dumps(render_flavor(flavor), sort_keys=True), - ) - if drift: - LOG.warning( - "Router flavor %s converged but carries service profile drift: %s", - name, - describe_profile_drift(drift), - ) - return drift diff --git a/python/openstack-sync/pyproject.toml b/python/openstack-sync/pyproject.toml index ef728ddd2..c43f68b26 100644 --- a/python/openstack-sync/pyproject.toml +++ b/python/openstack-sync/pyproject.toml @@ -81,4 +81,10 @@ force-single-line = true convention = "google" [tool.ruff.lint.per-file-ignores] -"tests/*" = ["S101"] # assert is the point in tests +"tests/*" = [ + "S101", # assert is the point in tests + # Fixtures name Kubernetes Secrets, which flake8-bandit reads as passwords. + "S105", + "S106", + "S107", +] diff --git a/python/openstack-sync/tests/conftest.py b/python/openstack-sync/tests/conftest.py index 70eb5a7e1..ca92df680 100644 --- a/python/openstack-sync/tests/conftest.py +++ b/python/openstack-sync/tests/conftest.py @@ -1,31 +1,61 @@ """Pytest configuration and shared fixtures for openstack-sync tests. -Sets environment variables that router_flavors_common.py reads at runtime -via env_required(). These must be present when any function that calls -crd_kind() / crd_api_version() / crd_resource() runs, so they are set -via a session-scoped autouse fixture that runs before every test. +Two levels of configuration, matching where the code reads it: + +* Hook-level tests drive ``main()``, which reads the environment the Helm chart + injects. The autouse fixture below provides the CRD identity variables the + chart always sets, so those tests exercise the real boundary. +* Everything below the hook takes a :class:`HookConfig` argument, so unit tests + use the ``hook_config`` fixture and never touch the environment. """ from __future__ import annotations import pytest -_ROUTER_FLAVOR_REQUIRED_ENV = { - "NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION": ( - "neutron.understack.rackspace.net/v1alpha1" - ), - "NEUTRON_ROUTER_FLAVOR_CRD_KIND": "NeutronRouterFlavor", - "NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE": ( - "neutronrouterflavors.neutron.understack.rackspace.net" - ), +from openstack_sync.hooks.framework import HookConfig +from openstack_sync.plugins.neutron.router_flavors.config import BINDING_NAME +from openstack_sync.plugins.neutron.router_flavors.config import ENV_PREFIX + +CRD_API_VERSION = "neutron.understack.rackspace.net/v1alpha1" +CRD_KIND = "NeutronRouterFlavor" +CRD_RESOURCE = "neutronrouterflavors.neutron.understack.rackspace.net" + +_CRD_IDENTITY_ENV = { + f"{ENV_PREFIX}_CRD_API_VERSION": CRD_API_VERSION, + f"{ENV_PREFIX}_CRD_KIND": CRD_KIND, + f"{ENV_PREFIX}_CRD_RESOURCE": CRD_RESOURCE, } @pytest.fixture(autouse=True) -def _router_flavor_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Ensure required router flavor env vars are set for every test. +def _crd_identity_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Provide the CRD identity variables the Helm chart always injects. - Individual tests may override these via their own monkeypatch calls. + Individual tests may override these with their own monkeypatch calls. """ - for key, value in _ROUTER_FLAVOR_REQUIRED_ENV.items(): + for key, value in _CRD_IDENTITY_ENV.items(): monkeypatch.setenv(key, value) + + +def make_hook_config(**overrides) -> HookConfig: + """Build a HookConfig without touching the environment.""" + defaults = { + "prefix": ENV_PREFIX, + "crd_api_version": CRD_API_VERSION, + "crd_kind": CRD_KIND, + "crd_resource": CRD_RESOURCE, + "binding_name": BINDING_NAME, + "namespace": "openstack", + "status_enabled": False, + "prune": False, + "sync_crontab": "", + "ready_retries": 30, + "ready_delay": 10.0, + } + return HookConfig(**{**defaults, **overrides}) + + +@pytest.fixture +def hook_config() -> HookConfig: + return make_hook_config() diff --git a/python/openstack-sync/tests/test_framework.py b/python/openstack-sync/tests/test_framework.py new file mode 100644 index 000000000..71b5595d8 --- /dev/null +++ b/python/openstack-sync/tests/test_framework.py @@ -0,0 +1,767 @@ +"""Tests for the generic sync framework. + +Deliberately free of Neutron: the driver is exercised through a stub plugin, so +these tests describe the contract any future plugin can rely on. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest import mock + +import pytest + +from openstack_sync.hooks import framework +from openstack_sync.hooks.framework import HookConfig +from openstack_sync.hooks.framework import HookInputs +from openstack_sync.hooks.framework import SyncPlugin +from openstack_sync.hooks.framework import SyncResource +from openstack_sync.hooks.framework import build_crd_hook_config +from openstack_sync.hooks.framework import hook_inputs +from openstack_sync.hooks.framework import run_hook +from openstack_sync.hooks.framework import run_sync +from openstack_sync.hooks.framework import synced_message +from openstack_sync.plugins.common import ConfigError +from tests.conftest import CRD_API_VERSION +from tests.conftest import CRD_KIND +from tests.conftest import CRD_RESOURCE +from tests.conftest import make_hook_config + +PREFIX = "NEUTRON_ROUTER_FLAVOR" +BINDING = "neutron-router-flavors" + +ENV_NAMES = ( + "BINDING_CONTEXT_PATH", + f"{PREFIX}_ENABLED", + f"{PREFIX}_SYNC_CRONTAB", + f"{PREFIX}_PRUNE", + f"{PREFIX}_STATUS_ENABLED", + f"{PREFIX}_READY_RETRIES", + f"{PREFIX}_READY_DELAY", + "POD_NAMESPACE", +) + + +def clear_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ENV_NAMES: + monkeypatch.delenv(name, raising=False) + + +# --------------------------------------------------------------------------- +# Stub plugin +# --------------------------------------------------------------------------- + + +class StubPlugin(SyncPlugin): + """Records what the driver asked it to do.""" + + noun = "widget" + + def __init__( + self, + config: HookConfig, + *, + fail_for: tuple[str, ...] = (), + notes_for: dict[str, list[str]] | None = None, + prune_raises: bool = False, + ) -> None: + super().__init__(config) + self.fail_for = set(fail_for) + self.notes_for = notes_for or {} + self.prune_raises = prune_raises + self.reconciled: list[str] = [] + self.pruned: list[tuple[list[str], bool]] = [] + self.waits = 0 + self.caches: list[Any] = [] + + def wait_for_api(self, conn: Any) -> None: + self.waits += 1 + + def new_cache(self) -> Any: + cache: dict[str, Any] = {} + self.caches.append(cache) + return cache + + def reconcile(self, conn: Any, spec: dict[str, Any], cache: Any) -> list[str]: + name = spec["name"] + self.reconciled.append(name) + if name in self.fail_for: + raise RuntimeError(f"reconcile failed for {name}") + return list(self.notes_for.get(name, [])) + + def prune( + self, + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool, + ) -> None: + if self.prune_raises: + raise RuntimeError("prune exploded") + self.pruned.append( + ([spec["name"] for spec in desired_specs], authoritative_empty) + ) + + +def _resource( + name: str, secret: str = "infrasetup", cloud: str = "understack" +) -> SyncResource: + return SyncResource( + spec={"name": name}, + name=name, + namespace="openstack", + generation=1, + secret_name=secret, + cloud_name=cloud, + ) + + +def _inputs( + reconcile: list[SyncResource], + desired: list[SyncResource] | None = None, + deleted: list[SyncResource] | None = None, + prune_credentials: frozenset[tuple[str, str]] | None = None, +) -> HookInputs: + desired = reconcile if desired is None else desired + deleted = deleted or [] + if prune_credentials is None: + prune_credentials = frozenset(r.credentials for r in desired + deleted) + return HookInputs(reconcile, desired, deleted, prune_credentials) + + +def _drive(plugin: StubPlugin, inputs: HookInputs): + """Run the driver with connections and status patching stubbed out.""" + with ( + mock.patch.object(framework, "get_openstack_connection") as connect, + mock.patch.object(framework, "patch_resource_status") as patch_status, + ): + code = run_sync(plugin, inputs) + return code, patch_status, connect + + +# --------------------------------------------------------------------------- +# HookConfig: the chart contract +# --------------------------------------------------------------------------- + + +def test_hook_config_reads_the_chart_contract(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_STATUS_ENABLED", "true") + monkeypatch.setenv(f"{PREFIX}_PRUNE", "true") + monkeypatch.setenv(f"{PREFIX}_SYNC_CRONTAB", "0 * * * *") + monkeypatch.setenv(f"{PREFIX}_READY_RETRIES", "7") + monkeypatch.setenv(f"{PREFIX}_READY_DELAY", "2.5") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + config = HookConfig.from_env(PREFIX, binding_name=BINDING) + + assert config.crd_api_version == CRD_API_VERSION + assert config.crd_kind == CRD_KIND + assert config.crd_resource == CRD_RESOURCE + assert config.binding_name == BINDING + assert config.namespace == "openstack" + assert config.status_enabled is True + assert config.prune is True + assert config.sync_crontab == "0 * * * *" + assert config.ready_retries == 7 + assert config.ready_delay == 2.5 + + +def test_hook_config_defaults_are_off(monkeypatch): + clear_env(monkeypatch) + + config = HookConfig.from_env(PREFIX, binding_name=BINDING) + + assert config.status_enabled is False + assert config.prune is False + assert config.sync_crontab == "" + assert config.ready_retries == 30 + assert config.ready_delay == 10 + + +def test_hook_config_requires_crd_identity(monkeypatch): + clear_env(monkeypatch) + monkeypatch.delenv(f"{PREFIX}_CRD_KIND", raising=False) + + with pytest.raises(ConfigError, match=f"{PREFIX}_CRD_KIND"): + HookConfig.from_env(PREFIX, binding_name=BINDING) + + +# --------------------------------------------------------------------------- +# Hook config JSON +# --------------------------------------------------------------------------- + + +def test_disabled_hook_config_is_a_valid_noop(monkeypatch): + clear_env(monkeypatch) + + config = build_crd_hook_config(PREFIX, BINDING) + + # shell-operator requires at least one binding, but a disabled hook must not + # register Kubernetes watches it will never service. + assert config["onStartup"] == 10 + assert "kubernetes" not in config + assert "schedule" not in config + + +def test_disabled_hook_config_does_not_read_runtime_env(monkeypatch): + """--config runs before the environment is guaranteed to be complete.""" + clear_env(monkeypatch) + for name in ( + f"{PREFIX}_CRD_API_VERSION", + f"{PREFIX}_CRD_KIND", + f"{PREFIX}_CRD_RESOURCE", + ): + monkeypatch.delenv(name, raising=False) + + config = build_crd_hook_config(PREFIX, BINDING) + + assert config["onStartup"] == 10 + + +def test_crontab_does_not_enable_a_disabled_hook(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_SYNC_CRONTAB", "0 * * * *") + + config = build_crd_hook_config(PREFIX, BINDING) + + assert "schedule" not in config + assert config["onStartup"] == 10 + + +def test_enabled_hook_config_watches_the_crd(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + config = build_crd_hook_config(PREFIX, BINDING) + + (binding,) = config["kubernetes"] + assert binding["name"] == BINDING + assert binding["apiVersion"] == CRD_API_VERSION + assert binding["kind"] == CRD_KIND + assert binding["executeHookOnEvent"] == ["Added", "Modified", "Deleted"] + # The full object is needed: the reconcile reads spec and status. + assert binding["jqFilter"] == "." + assert binding["includeSnapshotsFrom"] == [BINDING] + # A dedicated queue keeps a slow reconcile from blocking other hooks. + assert binding["queue"] == BINDING + assert binding["namespace"] == {"nameSelector": {"matchNames": ["openstack"]}} + + +def test_enabled_hook_config_omits_schedule_without_crontab(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_ENABLED", "true") + + assert "schedule" not in build_crd_hook_config(PREFIX, BINDING) + + +def test_enabled_hook_config_adds_schedule_with_crontab(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_ENABLED", "true") + monkeypatch.setenv(f"{PREFIX}_SYNC_CRONTAB", "*/5 * * * *") + + (schedule,) = build_crd_hook_config(PREFIX, BINDING)["schedule"] + + assert schedule["crontab"] == "*/5 * * * *" + assert schedule["includeSnapshotsFrom"] == [BINDING] + assert schedule["queue"] == BINDING + + +def test_enabled_hook_config_omits_namespace_without_pod_namespace(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_ENABLED", "true") + + (binding,) = build_crd_hook_config(PREFIX, BINDING)["kubernetes"] + + assert "namespace" not in binding + + +# --------------------------------------------------------------------------- +# Binding context -> HookInputs +# --------------------------------------------------------------------------- + + +def _cr(name: str, generation: int = 3, status: dict | None = None) -> dict: + obj = { + "apiVersion": CRD_API_VERSION, + "kind": CRD_KIND, + "metadata": {"name": name, "namespace": "openstack", "generation": generation}, + "spec": { + "name": name, + "cloudCredentialsRef": { + "secretName": "infrasetup", + "cloudName": "understack", + }, + }, + } + if status is not None: + obj["status"] = status + return obj + + +def test_hook_inputs_from_snapshot_reconciles_everything(): + config = make_hook_config() + contexts = [ + { + "binding": BINDING, + "type": "Schedule", + "snapshots": {BINDING: [{"object": _cr("b")}, {"object": _cr("a")}]}, + } + ] + + inputs = hook_inputs(contexts, config) + + # Sorted so a run is deterministic. + assert [r.spec["name"] for r in inputs.resources_to_reconcile] == ["a", "b"] + assert inputs.desired_resources_for_prune == inputs.resources_to_reconcile + assert inputs.deleted_resources == [] + assert inputs.prune_credentials == frozenset({("infrasetup", "understack")}) + + +def test_hook_inputs_from_synchronization(): + config = make_hook_config() + contexts = [ + { + "binding": BINDING, + "type": "Synchronization", + "objects": [{"object": _cr("a")}], + } + ] + + inputs = hook_inputs(contexts, config) + + assert [r.spec["name"] for r in inputs.resources_to_reconcile] == ["a"] + + +def test_hook_inputs_strips_cloud_credentials_from_spec(): + """A plugin must see only its own fields.""" + config = make_hook_config() + contexts = [ + { + "binding": BINDING, + "type": "Schedule", + "snapshots": {BINDING: [{"object": _cr("a")}]}, + } + ] + + (resource,) = hook_inputs(contexts, config).resources_to_reconcile + + assert "cloudCredentialsRef" not in resource.spec + assert resource.secret_name == "infrasetup" + assert resource.cloud_name == "understack" + + +def test_hook_inputs_keeps_current_status(): + """The status is needed to break the status-patch feedback loop.""" + config = make_hook_config() + status = {"syncStatus": "Synced", "observedGeneration": 3} + contexts = [ + { + "binding": BINDING, + "type": "Schedule", + "snapshots": {BINDING: [{"object": _cr("a", status=status)}]}, + } + ] + + (resource,) = hook_inputs(contexts, config).resources_to_reconcile + + assert resource.current_status == status + + +def test_added_event_reconciles_only_the_changed_resource(): + config = make_hook_config() + contexts = [ + { + "binding": BINDING, + "type": "Event", + "watchEvent": "Added", + "object": _cr("new"), + "snapshots": {BINDING: [{"object": _cr("new")}, {"object": _cr("old")}]}, + } + ] + + inputs = hook_inputs(contexts, config) + + assert [r.spec["name"] for r in inputs.resources_to_reconcile] == ["new"] + # Prune still needs the full desired set, or it would delete "old". + assert [r.spec["name"] for r in inputs.desired_resources_for_prune] == [ + "new", + "old", + ] + + +def test_deleted_event_reconciles_nothing_but_prunes(): + config = make_hook_config() + contexts = [ + { + "binding": BINDING, + "type": "Event", + "watchEvent": "Deleted", + "object": _cr("gone"), + "snapshots": {BINDING: [{"object": _cr("kept")}]}, + } + ] + + inputs = hook_inputs(contexts, config) + + assert inputs.resources_to_reconcile == [] + assert [r.spec["name"] for r in inputs.deleted_resources] == ["gone"] + assert inputs.prune_credentials == frozenset({("infrasetup", "understack")}) + + +def test_modified_event_skipped_when_status_already_current(): + """The hook's own status patch must not trigger another reconcile.""" + config = make_hook_config() + current = {"syncStatus": "Synced", "observedGeneration": 3} + obj = _cr("a", generation=3, status=current) + contexts = [ + { + "binding": BINDING, + "type": "Event", + "watchEvent": "Modified", + "object": obj, + "snapshots": {BINDING: [{"object": obj}]}, + } + ] + + inputs = hook_inputs(contexts, config) + + assert inputs.resources_to_reconcile == [] + + +def test_modified_event_reconciles_when_generation_bumped(): + config = make_hook_config() + stale = {"syncStatus": "Synced", "observedGeneration": 2} + obj = _cr("a", generation=3, status=stale) + contexts = [ + { + "binding": BINDING, + "type": "Event", + "watchEvent": "Modified", + "object": obj, + "snapshots": {BINDING: [{"object": obj}]}, + } + ] + + inputs = hook_inputs(contexts, config) + + assert [r.spec["name"] for r in inputs.resources_to_reconcile] == ["a"] + + +def test_event_context_without_snapshot_is_an_error(): + config = make_hook_config() + contexts = [ + {"binding": BINDING, "type": "Event", "watchEvent": "Added", "object": _cr("a")} + ] + + with pytest.raises(ConfigError, match="snapshot"): + hook_inputs(contexts, config) + + +def test_unrecognised_context_is_an_error(): + config = make_hook_config() + + with pytest.raises(ConfigError, match="does not contain"): + hook_inputs([{"binding": "something-else", "type": "Event"}], config) + + +# --------------------------------------------------------------------------- +# run_sync +# --------------------------------------------------------------------------- + + +def test_run_sync_reconciles_and_reports_synced(): + plugin = StubPlugin(make_hook_config()) + + code, patch_status, _ = _drive(plugin, _inputs([_resource("a"), _resource("b")])) + + assert code == 0 + assert plugin.reconciled == ["a", "b"] + statuses = [call.kwargs["sync_status"] for call in patch_status.call_args_list] + assert statuses == ["Synced", "Synced"] + assert patch_status.call_args_list[0].kwargs["message"] == ( + "Successfully reconciled widget" + ) + + +def test_run_sync_waits_for_api_once_per_credential_group(): + plugin = StubPlugin(make_hook_config()) + resources = [ + _resource("a"), + _resource("b"), + _resource("c", secret="other", cloud="other-cloud"), + ] + + _drive(plugin, _inputs(resources)) + + assert plugin.waits == 2 + # One cache per group, so lookups are shared within a group but not across. + assert len(plugin.caches) == 2 + + +def test_run_sync_connects_with_each_resources_own_credentials(): + plugin = StubPlugin(make_hook_config()) + resources = [ + _resource("a", secret="secret-a", cloud="cloud-a"), + _resource("b", secret="secret-b", cloud="cloud-b"), + ] + + _, _, connect = _drive(plugin, _inputs(resources)) + + assert sorted(call.args for call in connect.call_args_list) == [ + ("secret-a", "cloud-a"), + ("secret-b", "cloud-b"), + ] + + +def test_run_sync_forwards_crd_identity_and_current_status_to_the_patch(): + """Forward everything patch_resource_status needs. + + The CRD identity targets kubectl, and the current status decides whether the + patch can be skipped. + """ + config = make_hook_config(status_enabled=True) + plugin = StubPlugin(config) + status = {"syncStatus": "Synced", "observedGeneration": 1} + resource = SyncResource( + spec={"name": "a"}, + name="a", + namespace="openstack", + generation=1, + secret_name="infrasetup", + cloud_name="understack", + current_status=status, + ) + + _, patch_status, _ = _drive(plugin, _inputs([resource])) + + kwargs = patch_status.call_args.kwargs + assert kwargs["crd_resource"] == CRD_RESOURCE + assert kwargs["crd_kind"] == CRD_KIND + assert kwargs["status_enabled"] is True + assert kwargs["current_status"] == status + assert kwargs["generation"] == 1 + assert kwargs["namespace"] == "openstack" + + +def test_run_sync_reports_notes_without_failing(): + plugin = StubPlugin(make_hook_config(), notes_for={"a": ["thing drifted"]}) + + code, patch_status, _ = _drive(plugin, _inputs([_resource("a")])) + + assert code == 0 + assert patch_status.call_args.kwargs["sync_status"] == "Synced" + message = patch_status.call_args.kwargs["message"] + assert message.startswith("Successfully reconciled widget") + assert "thing drifted" in message + + +def test_run_sync_marks_failure_and_skips_prune(): + """A failed reconcile means the desired set is unknown, so prune must not run.""" + plugin = StubPlugin(make_hook_config(prune=True), fail_for=("b",)) + + code, patch_status, _ = _drive(plugin, _inputs([_resource("a"), _resource("b")])) + + assert code == 1 + assert plugin.pruned == [] + by_name = { + call.kwargs["name"]: call.kwargs["sync_status"] + for call in patch_status.call_args_list + } + assert by_name == {"a": "Synced", "b": "Failed"} + + +def test_run_sync_continues_after_one_failure(): + plugin = StubPlugin(make_hook_config(), fail_for=("a",)) + + _drive(plugin, _inputs([_resource("a"), _resource("b")])) + + assert plugin.reconciled == ["a", "b"] + + +def test_run_sync_marks_whole_group_failed_when_connection_fails(): + plugin = StubPlugin(make_hook_config()) + inputs = _inputs([_resource("a"), _resource("b")]) + + with ( + mock.patch.object( + framework, + "get_openstack_connection", + side_effect=RuntimeError("no route to keystone"), + ), + mock.patch.object(framework, "patch_resource_status") as patch_status, + ): + code = run_sync(plugin, inputs) + + assert code == 1 + assert plugin.reconciled == [] + statuses = {call.kwargs["sync_status"] for call in patch_status.call_args_list} + assert statuses == {"Failed"} + assert "no route to keystone" in patch_status.call_args.kwargs["message"] + + +def test_run_sync_marks_group_failed_when_api_never_becomes_ready(): + class NeverReady(StubPlugin): + def wait_for_api(self, conn): + raise RuntimeError("api not ready") + + plugin = NeverReady(make_hook_config()) + + code, patch_status, _ = _drive(plugin, _inputs([_resource("a")])) + + assert code == 1 + assert plugin.reconciled == [] + assert patch_status.call_args.kwargs["sync_status"] == "Failed" + assert "api not ready" in patch_status.call_args.kwargs["message"] + + +def test_run_sync_prunes_after_successful_reconcile(): + plugin = StubPlugin(make_hook_config(prune=True)) + + code, _, _ = _drive(plugin, _inputs([_resource("a")])) + + assert code == 0 + assert plugin.pruned == [(["a"], False)] + + +def test_run_sync_prune_is_authoritative_for_deleted_credentials(): + """A confirmed deletion lets prune act on an empty desired set.""" + plugin = StubPlugin(make_hook_config(prune=True)) + deleted = _resource("gone") + inputs = _inputs([], desired=[], deleted=[deleted]) + + code, _, _ = _drive(plugin, inputs) + + assert code == 0 + assert plugin.pruned == [([], True)] + + +def test_run_sync_skips_prune_for_credentials_with_no_desired_resources(): + """An empty desired set with no deletion may be an unreadable snapshot.""" + plugin = StubPlugin(make_hook_config(prune=True)) + inputs = HookInputs([], [], [], frozenset({("infrasetup", "understack")})) + + code, _, _ = _drive(plugin, inputs) + + assert code == 0 + assert plugin.pruned == [] + + +def test_run_sync_does_not_connect_for_prune_when_prune_disabled(): + """A deleted-only run must not open a connection just to do nothing.""" + plugin = StubPlugin(make_hook_config(prune=False)) + inputs = _inputs([], desired=[], deleted=[_resource("gone")]) + + code, _, connect = _drive(plugin, inputs) + + assert code == 0 + assert connect.call_count == 0 + assert plugin.pruned == [] + + +def test_run_sync_returns_error_when_prune_fails(): + plugin = StubPlugin(make_hook_config(prune=True), prune_raises=True) + + code, _, _ = _drive(plugin, _inputs([_resource("a")])) + + assert code == 1 + + +def test_run_sync_skips_status_patch_without_metadata_name(): + plugin = StubPlugin(make_hook_config()) + nameless = SyncResource( + spec={"name": "a"}, + name=None, + namespace="openstack", + generation=1, + secret_name="infrasetup", + cloud_name="understack", + ) + + code, patch_status, _ = _drive(plugin, _inputs([nameless])) + + assert code == 0 + patch_status.assert_not_called() + + +def test_synced_message_is_unqualified_without_notes(): + assert synced_message("widget", []) == "Successfully reconciled widget" + + +def test_synced_message_lists_every_note(): + message = synced_message("widget", ["first", "second"]) + + assert "first" in message + assert "second" in message + + +# --------------------------------------------------------------------------- +# run_hook +# --------------------------------------------------------------------------- + + +def _write_context(path: Path, payload: str) -> str: + context_path = path / "binding-context.json" + context_path.write_text(payload, encoding="utf-8") + return str(context_path) + + +def test_run_hook_prints_config_and_exits(monkeypatch, capsys): + monkeypatch.setattr(framework.sys, "argv", ["hook.py", "--config"]) + + code = run_hook(lambda: {"configVersion": "v1"}, lambda contexts: 99) + + assert code == 0 + assert json.loads(capsys.readouterr().out) == {"configVersion": "v1"} + + +def test_run_hook_returns_zero_without_context_path(monkeypatch): + monkeypatch.setattr(framework.sys, "argv", ["hook.py"]) + monkeypatch.delenv("BINDING_CONTEXT_PATH", raising=False) + called = [] + + code = run_hook(dict, lambda contexts: called.append(contexts) or 0) + + assert code == 0 + assert called == [] + + +def test_run_hook_returns_zero_on_empty_context(monkeypatch, tmp_path): + monkeypatch.setattr(framework.sys, "argv", ["hook.py"]) + monkeypatch.setenv("BINDING_CONTEXT_PATH", _write_context(tmp_path, " ")) + called = [] + + code = run_hook(dict, lambda contexts: called.append(contexts) or 0) + + assert code == 0 + assert called == [] + + +def test_run_hook_returns_error_on_invalid_json(monkeypatch, tmp_path, caplog): + monkeypatch.setattr(framework.sys, "argv", ["hook.py"]) + monkeypatch.setenv("BINDING_CONTEXT_PATH", _write_context(tmp_path, "{not json")) + + code = run_hook(dict, lambda contexts: 0) + + assert code == 1 + assert "binding context" in caplog.text + + +def test_run_hook_returns_error_when_context_is_not_a_list(monkeypatch, tmp_path): + monkeypatch.setattr(framework.sys, "argv", ["hook.py"]) + monkeypatch.setenv("BINDING_CONTEXT_PATH", _write_context(tmp_path, '{"a": 1}')) + + assert run_hook(dict, lambda contexts: 0) == 1 + + +def test_run_hook_converts_an_unexpected_error_into_exit_one(monkeypatch, tmp_path): + monkeypatch.setattr(framework.sys, "argv", ["hook.py"]) + monkeypatch.setenv("BINDING_CONTEXT_PATH", _write_context(tmp_path, "[{}]")) + + def boom(contexts): + raise ConfigError("bad spec") + + assert run_hook(dict, boom) == 1 diff --git a/python/openstack-sync/tests/test_plugins_common.py b/python/openstack-sync/tests/test_plugins_common.py index 40f069d92..8e4b2368f 100644 --- a/python/openstack-sync/tests/test_plugins_common.py +++ b/python/openstack-sync/tests/test_plugins_common.py @@ -90,17 +90,19 @@ def test_get_value_returns_default_for_missing_or_none_values(): ) -def test_service_profile_ids_requires_list(): - with pytest.raises(TypeError, match="service_profile_ids"): - common.service_profile_ids({"service_profile_ids": "profile-id"}) - - -def test_sdk_exception_classifiers_match_openstacksdk_classes(): - assert common.is_not_found(sdk_exceptions.NotFoundException("missing")) - assert not common.is_not_found(sdk_exceptions.ConflictException("conflict")) - - assert common.is_conflict(sdk_exceptions.ConflictException("conflict")) - assert not common.is_conflict(sdk_exceptions.NotFoundException("missing")) +def test_sdk_not_found_and_conflict_are_independent(): + """reconcile.py and prune.py catch these in separate except clauses. + + If either became a subclass of the other, the first clause would swallow + both and, for example, a 409 "still in use" would be logged as "already + absent" while the resource stayed attached. + """ + assert not issubclass( + sdk_exceptions.ConflictException, sdk_exceptions.NotFoundException + ) + assert not issubclass( + sdk_exceptions.NotFoundException, sdk_exceptions.ConflictException + ) def test_meta_info_payload_canonicalizes_json_strings(): diff --git a/python/openstack-sync/tests/test_prune.py b/python/openstack-sync/tests/test_prune.py new file mode 100644 index 000000000..255c28977 --- /dev/null +++ b/python/openstack-sync/tests/test_prune.py @@ -0,0 +1,228 @@ +"""Tests for router flavor prune behaviour. + +Pruning is gated entirely on the operator's ownership markers, so these tests +are mostly about what must *not* be deleted. Whether pruning runs at all is the +hook's decision (``config.prune``), tested in ``test_framework.py``. +""" + +from __future__ import annotations + +import types +from types import SimpleNamespace +from typing import Any + +from openstack import exceptions as openstack_exceptions + +from openstack_sync.plugins.neutron.router_flavors import markers +from openstack_sync.plugins.neutron.router_flavors import prune +from openstack_sync.plugins.neutron.router_flavors.config import SERVICE_TYPE + +_DRIVER = "neutron_understack.l3_router.vrf.Vrf" + + +class FakeNetwork: + """Minimal Neutron network API recording flavor and profile deletes.""" + + def __init__(self, flavors: list[dict[str, Any]], profiles: dict[str, Any]): + self._flavors = flavors + self._profiles = profiles + self.deleted_flavors: list[str] = [] + self.deleted_profiles: list[str] = [] + self.flavor_list_calls = 0 + + def flavors(self, service_type: str | None = None) -> list[dict[str, Any]]: + self.flavor_list_calls += 1 + return [ + flavor + for flavor in self._flavors + if service_type is None or flavor["service_type"] == service_type + ] + + def routers(self, flavor_id: str) -> list[dict[str, Any]]: + return [] + + def service_profiles(self) -> list[Any]: + return [p for p in self._profiles.values() if p is not None] + + def get_service_profile(self, profile_id: str) -> Any: + profile = self._profiles.get(profile_id) + if profile is None: + raise openstack_exceptions.NotFoundException(f"no profile {profile_id}") + return profile + + def delete_flavor( + self, flavor: dict[str, Any], ignore_missing: bool = True + ) -> None: + self.deleted_flavors.append(flavor["id"]) + self._flavors = [f for f in self._flavors if f["id"] != flavor["id"]] + + def delete_service_profile(self, profile: Any, ignore_missing: bool = True) -> None: + profile_id = profile.id if hasattr(profile, "id") else profile["id"] + self.deleted_profiles.append(profile_id) + self._profiles[profile_id] = None + + +def _owned_profile(profile_id: str, driver: str = _DRIVER) -> Any: + return types.SimpleNamespace( + id=profile_id, + driver=driver, + meta_info=markers.managed_meta_info({"vni_alloc": "auto"}), + ) + + +def _owned_flavor( + flavor_id: str, name: str, service_profile_ids: list[str] | None = None +) -> dict[str, Any]: + return { + "id": flavor_id, + "name": name, + "service_type": SERVICE_TYPE, + "description": markers.managed_flavor_description("created by operator"), + "service_profile_ids": list(service_profile_ids or []), + } + + +def _conn(flavors: list[dict[str, Any]], profiles: dict[str, Any] | None = None) -> Any: + return SimpleNamespace(network=FakeNetwork(flavors, profiles or {})) + + +# --------------------------------------------------------------------------- +# Ownership gates deletion +# --------------------------------------------------------------------------- + + +def test_prune_keeps_unowned_flavor_even_with_owned_profile(): + flavor = { + "id": "manual-flavor-id", + "name": "manual-flavor", + "service_type": SERVICE_TYPE, + "description": "created outside the operator", + "service_profile_ids": ["owned-profile-id"], + } + profile = _owned_profile("owned-profile-id") + conn = _conn([flavor], {profile.id: profile}) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == [] + + +def test_prune_deletes_removed_owned_flavor(): + conn = _conn([_owned_flavor("managed-flavor-id", "removed-managed-flavor")]) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == ["managed-flavor-id"] + + +def test_prune_deletes_removed_flavor_and_its_unused_profile(): + profile = _owned_profile("managed-profile-id") + flavor = _owned_flavor("managed-flavor-id", "removed-managed-flavor", [profile.id]) + conn = _conn([flavor], {profile.id: profile}) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == ["managed-flavor-id"] + assert conn.network.deleted_profiles == ["managed-profile-id"] + + +# --------------------------------------------------------------------------- +# The empty-desired guard +# --------------------------------------------------------------------------- + + +def test_prune_keeps_owned_flavors_when_desired_list_is_empty(): + """An empty desired set may be an unreadable snapshot, not a deletion.""" + conn = _conn([_owned_flavor("managed-flavor-id", "removed-managed-flavor")]) + + prune.prune_removed_flavors(conn, []) + + assert conn.network.deleted_flavors == [] + + +def test_prune_deletes_when_empty_desired_is_authoritative(): + """A confirmed CR deletion makes the empty desired set actionable.""" + conn = _conn([_owned_flavor("managed-flavor-id", "removed-managed-flavor")]) + + prune.prune_removed_flavors(conn, [], authoritative_empty=True) + + assert conn.network.deleted_flavors == ["managed-flavor-id"] + + +# --------------------------------------------------------------------------- +# Orphaned profile sweep +# --------------------------------------------------------------------------- + + +def test_prune_deletes_orphaned_owned_profile(): + """A profile whose parent flavor is already gone is collected.""" + orphan = _owned_profile("orphan-profile-id") + conn = _conn([], {orphan.id: orphan}) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_profiles == ["orphan-profile-id"] + + +def test_prune_keeps_unowned_profile(): + """A profile without the ownership marker is never touched.""" + unowned = types.SimpleNamespace( + id="unmanaged-profile-id", + driver=_DRIVER, + meta_info={"vni_alloc": "auto"}, # no ownership marker + ) + conn = _conn([], {unowned.id: unowned}) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_profiles == [] + + +def test_prune_keeps_attached_profile(): + """A profile still bound to a surviving flavor is kept.""" + attached = _owned_profile("attached-profile-id") + kept = _owned_flavor("kept-flavor-id", "kept-flavor", [attached.id]) + conn = _conn([kept], {attached.id: attached}) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == [] + assert conn.network.deleted_profiles == [] + + +def test_prune_lists_flavors_once_for_all_profile_checks(): + """Attachment counts come from a single flavor listing, not one per profile.""" + removed_profile = _owned_profile("removed-profile-id") + orphan_profile = _owned_profile("orphan-profile-id") + attached_profile = _owned_profile("attached-profile-id") + conn = _conn( + [ + _owned_flavor("removed-flavor-id", "removed-flavor", [removed_profile.id]), + _owned_flavor("kept-flavor-id", "kept-flavor", [attached_profile.id]), + ], + { + removed_profile.id: removed_profile, + orphan_profile.id: orphan_profile, + attached_profile.id: attached_profile, + }, + ) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.flavor_list_calls == 1 + assert conn.network.deleted_flavors == ["removed-flavor-id"] + assert conn.network.deleted_profiles == [ + "removed-profile-id", + "orphan-profile-id", + ] + + +def test_prune_skips_flavor_still_used_by_routers(): + """A flavor with routers attached is never deleted.""" + flavor = _owned_flavor("in-use-flavor-id", "removed-flavor") + conn = _conn([flavor]) + conn.network.routers = lambda flavor_id: [{"id": "router-1"}] + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == [] diff --git a/python/openstack-sync/tests/test_reconcile.py b/python/openstack-sync/tests/test_reconcile.py new file mode 100644 index 000000000..357681b51 --- /dev/null +++ b/python/openstack-sync/tests/test_reconcile.py @@ -0,0 +1,758 @@ +"""Tests for router flavor reconciliation. + +Covers the profile cache, create-or-reuse by ``(driver, meta_info)``, the +owned-only reuse rule, drift reporting on reused profiles, the flavor +``service_type`` guard and ``is_enabled``/description reconcile, and the +flavor-to-profile binding set. +""" + +from __future__ import annotations + +import logging +import types +from typing import Any +from unittest import mock + +import pytest + +from openstack_sync.plugins import common as plugin_common +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.neutron.router_flavors import markers +from openstack_sync.plugins.neutron.router_flavors import reconcile +from openstack_sync.plugins.neutron.router_flavors.config import SERVICE_TYPE + +_DRIVER = "neutron_understack.l3_router.vrf.Vrf" +_NAME = "test-flavor" +_DESCRIPTION = "my flavor" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_profile( + profile_id: str, + driver: str = _DRIVER, + meta_info: Any = None, + managed: bool = True, + is_enabled: bool = True, + description: str = "desc", +) -> Any: + """Build an openstacksdk-shaped service profile. + + ``description`` defaults to the ``_profile_spec`` default so a profile and a + spec built with defaults are drift-free; drift tests pass a mismatch. + """ + raw_meta = dict(meta_info or {}) + if managed: + raw_meta.update(markers.OPERATOR_META_INFO_MARKERS) + return types.SimpleNamespace( + id=profile_id, + driver=driver, + is_enabled=is_enabled, + description=description, + meta_info=plugin_common.meta_info_payload(raw_meta), + ) + + +def _profile_spec( + driver: str = _DRIVER, + description: str = "desc", + meta_info: dict[str, Any] | None = None, + is_enabled: bool = True, +) -> dict[str, Any]: + return { + "driver": driver, + "description": description, + "meta_info": meta_info if meta_info is not None else {}, + "is_enabled": is_enabled, + } + + +def _make_flavor( + flavor_id: str = "flavor-id", + name: str = _NAME, + service_profile_ids: list[str] | None = None, + service_type: str = SERVICE_TYPE, + description: str = f"{_DESCRIPTION} {markers.FLAVOR_DESCRIPTION_MARKER}", + is_enabled: bool = True, +) -> Any: + return types.SimpleNamespace( + id=flavor_id, + name=name, + service_type=service_type, + description=description, + is_enabled=is_enabled, + service_profile_ids=list(service_profile_ids or []), + ) + + +def _flavor_spec( + name: str = _NAME, + description: str = _DESCRIPTION, + service_type: str = SERVICE_TYPE, + is_enabled: bool = True, + service_profiles: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build a CR spec as the API server materialises it (defaults applied).""" + return { + "name": name, + "description": description, + "service_type": service_type, + "is_enabled": is_enabled, + "service_profiles": ( + service_profiles if service_profiles is not None else [_profile_spec()] + ), + } + + +def _reuse_conn(profile: Any) -> Any: + """A connection whose only existing service profile is *profile*.""" + network = mock.MagicMock() + network.service_profiles.return_value = [profile] + return types.SimpleNamespace(network=network) + + +def _create_conn(created: Any, existing: list[Any] | None = None) -> Any: + network = mock.MagicMock() + network.service_profiles.return_value = list(existing or []) + network.create_service_profile.return_value = created + return types.SimpleNamespace(network=network) + + +def _bindings_conn(flavor: Any, lookup: dict[str, Any] | None = None) -> Any: + """A connection for binding tests. + + ``get_flavor`` returns *flavor*; ``get_service_profile`` resolves unbind + candidates from *lookup* so ownership can be evaluated. + """ + resolved = lookup or {} + network = mock.MagicMock() + network.get_flavor.return_value = flavor + network.get_service_profile.side_effect = lambda pid: resolved.get(pid) + return types.SimpleNamespace(network=network) + + +# --------------------------------------------------------------------------- +# Profile cache +# --------------------------------------------------------------------------- + + +def test_profiles_for_driver_queries_by_driver(): + network = mock.MagicMock() + network.service_profiles.return_value = [_make_profile("profile-id")] + conn = types.SimpleNamespace(network=network) + + result = reconcile.profiles_for_driver(conn, "some.Driver", {}) + + assert result == list(network.service_profiles.return_value) + network.service_profiles.assert_called_once_with(driver="some.Driver") + + +def test_profiles_for_driver_caches_per_driver(): + first = _make_profile("first-profile", driver="first.Driver") + second = _make_profile("second-profile", driver="second.Driver") + network = mock.MagicMock() + network.service_profiles.side_effect = [[first], [second]] + conn = types.SimpleNamespace(network=network) + cache: reconcile.ProfileCache = {} + + first_result = reconcile.profiles_for_driver(conn, "first.Driver", cache) + cached = reconcile.profiles_for_driver(conn, "first.Driver", cache) + second_result = reconcile.profiles_for_driver(conn, "second.Driver", cache) + + assert first_result == [first] + assert cached is first_result + assert second_result == [second] + assert network.service_profiles.call_args_list == [ + mock.call(driver="first.Driver"), + mock.call(driver="second.Driver"), + ] + + +# --------------------------------------------------------------------------- +# ensure_profile: create or reuse by (driver, meta_info) +# --------------------------------------------------------------------------- + + +def test_ensure_profile_creates_with_ownership_markers(): + conn = _create_conn(_make_profile("new-profile")) + + reconcile.ensure_profile( + conn, _NAME, _profile_spec(meta_info={"vni_alloc": "auto"}), {}, [] + ) + + kwargs = conn.network.create_service_profile.call_args.kwargs + assert kwargs["driver"] == _DRIVER + assert kwargs["is_enabled"] is True + meta_info = plugin_common.normalize_meta_info(kwargs["meta_info"]) + assert meta_info["vni_alloc"] == "auto" + for key, value in markers.OPERATOR_META_INFO_MARKERS.items(): + assert meta_info[key] == value + + +def test_ensure_profile_creates_disabled_when_spec_disables(): + conn = _create_conn(_make_profile("new-profile", is_enabled=False)) + + reconcile.ensure_profile(conn, _NAME, _profile_spec(is_enabled=False), {}, []) + + assert conn.network.create_service_profile.call_args.kwargs["is_enabled"] is False + + +def test_ensure_profile_reuses_existing_owned_profile(): + meta_info = {"vni_alloc": "auto"} + existing = _make_profile("existing-profile", meta_info=meta_info) + conn = _reuse_conn(existing) + + result = reconcile.ensure_profile( + conn, _NAME, _profile_spec(meta_info=meta_info), {}, [] + ) + + assert result is existing + conn.network.create_service_profile.assert_not_called() + + +def test_ensure_profile_appends_created_profile_to_driver_cache(): + """A profile created for one flavor must be visible to the next flavor. + + The cache is shared across all flavors in a credential group, so two + flavors with an identical ``(driver, meta_info)`` spec share one profile + rather than each creating a duplicate. + """ + meta_info = {"vni_alloc": "auto"} + created = _make_profile("new-profile", meta_info=meta_info) + conn = _create_conn(created) + cache: reconcile.ProfileCache = {} + + first = reconcile.ensure_profile( + conn, "flavor-a", _profile_spec(meta_info=meta_info), cache, [] + ) + second = reconcile.ensure_profile( + conn, "flavor-b", _profile_spec(meta_info=meta_info), cache, [] + ) + + assert first is created + assert second is first + conn.network.create_service_profile.assert_called_once() + conn.network.service_profiles.assert_called_once_with(driver=_DRIVER) + + +def test_ensure_profile_does_not_reuse_across_drivers(): + meta_info = {"vni_alloc": "auto"} + first_profile = _make_profile("first-profile", driver="first.Driver") + second_profile = _make_profile("second-profile", driver="second.Driver") + network = mock.MagicMock() + network.service_profiles.side_effect = [[], []] + network.create_service_profile.side_effect = [first_profile, second_profile] + conn = types.SimpleNamespace(network=network) + cache: reconcile.ProfileCache = {} + + first = reconcile.ensure_profile( + conn, + "flavor-a", + _profile_spec(driver="first.Driver", meta_info=meta_info), + cache, + [], + ) + second = reconcile.ensure_profile( + conn, + "flavor-b", + _profile_spec(driver="second.Driver", meta_info=meta_info), + cache, + [], + ) + + assert first is first_profile + assert second is second_profile + assert network.create_service_profile.call_count == 2 + + +# --------------------------------------------------------------------------- +# Only operator-owned profiles are reused +# --------------------------------------------------------------------------- + + +def test_find_matching_profile_ignores_unowned_match(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + + assert reconcile.find_matching_profile([unowned], meta_info) is None + + +def test_find_matching_profile_prefers_owned_over_unowned(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + owned = _make_profile("owned-profile", meta_info=meta_info) + + assert reconcile.find_matching_profile([unowned, owned], meta_info) is owned + + +def test_ensure_profile_creates_owned_profile_instead_of_reusing_unowned(): + """An unowned profile must never be bound, because it can never be unbound. + + ``reconcile_flavor_profiles`` only unbinds profiles carrying the ownership + marker, so reusing somebody else's profile would create a binding that + outlives the spec that created it and that nothing can ever remove. + """ + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + created = _make_profile("new-profile", meta_info=meta_info) + conn = _create_conn(created, existing=[unowned]) + + result = reconcile.ensure_profile( + conn, _NAME, _profile_spec(meta_info=meta_info), {}, [] + ) + + assert result is created + conn.network.create_service_profile.assert_called_once() + new_meta = plugin_common.normalize_meta_info( + conn.network.create_service_profile.call_args.kwargs["meta_info"] + ) + for key, value in markers.OPERATOR_META_INFO_MARKERS.items(): + assert new_meta[key] == value + + +def test_ensure_profile_never_adopts_an_unowned_profile(): + """The unowned profile is left alone, not stamped with the marker. + + Adopting it would enrol somebody else's profile into the prune sweep, which + deletes owned unattached profiles -- an irreversible side effect on a + resource the operator did not create. + """ + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + conn = _create_conn(_make_profile("new-profile"), existing=[unowned]) + + reconcile.ensure_profile(conn, _NAME, _profile_spec(meta_info=meta_info), {}, []) + + conn.network.update_service_profile.assert_not_called() + conn.network.delete_service_profile.assert_not_called() + + +def test_ensure_profile_reuses_owned_when_unowned_match_also_exists(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + owned = _make_profile("owned-profile", meta_info=meta_info) + network = mock.MagicMock() + network.service_profiles.return_value = [unowned, owned] + conn = types.SimpleNamespace(network=network) + + result = reconcile.ensure_profile( + conn, _NAME, _profile_spec(meta_info=meta_info), {}, [] + ) + + assert result is owned + network.create_service_profile.assert_not_called() + + +def test_profile_created_beside_unowned_match_can_later_be_unbound(): + """End-to-end guard for why unowned profiles are not reused. + + Resolve a profile while an unowned match exists, then reconcile the flavor + against a different spec. The profile bound earlier must be unbindable, + which holds only because the operator created and owns it. + """ + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + created = _make_profile("prof-a", meta_info=meta_info) + conn = _create_conn(created, existing=[unowned]) + + bound = reconcile.ensure_profile( + conn, _NAME, _profile_spec(meta_info=meta_info), {}, [] + ) + + flavor = _make_flavor(service_profile_ids=["prof-a"]) + bindings = _bindings_conn(flavor, {"prof-a": bound}) + + reconcile.reconcile_flavor_profiles(bindings, flavor, [_make_profile("prof-b")]) + + disassociate = bindings.network.disassociate_flavor_from_service_profile + disassociate.assert_called_once() + assert disassociate.call_args.args[1] is bound + + +# --------------------------------------------------------------------------- +# Drift reporting on reused profiles +# --------------------------------------------------------------------------- + + +def test_ensure_profile_reports_is_enabled_drift_on_reuse(caplog): + """A profile disabled out-of-band is reported, not silently accepted. + + Neutron's get_flavor_next_provider raises ServiceProfileDisabled for the + profile it selects, so every router create against the flavor fails while + the flavor itself still looks converged. + """ + existing = _make_profile("owned-profile", is_enabled=False) + conn = _reuse_conn(existing) + drift: list[reconcile.ProfileDrift] = [] + + with caplog.at_level(logging.WARNING): + result = reconcile.ensure_profile( + conn, _NAME, _profile_spec(is_enabled=True), {}, drift + ) + + assert result is existing + assert [(d.field, d.have, d.want) for d in drift] == [("is_enabled", False, True)] + assert drift[0].profile_id == "owned-profile" + assert "is_enabled" in caplog.text + # Neutron rejects updates to a profile bound to any flavor: never try one. + conn.network.update_service_profile.assert_not_called() + + +def test_ensure_profile_reports_description_drift_on_reuse(): + existing = _make_profile("owned-profile", description="stale") + conn = _reuse_conn(existing) + drift: list[reconcile.ProfileDrift] = [] + + reconcile.ensure_profile( + conn, _NAME, _profile_spec(description="wanted"), {}, drift + ) + + assert [(d.field, d.have, d.want) for d in drift] == [ + ("description", "stale", "wanted") + ] + + +def test_ensure_profile_reports_every_drifted_field(): + existing = _make_profile("owned-profile", is_enabled=False, description="stale") + conn = _reuse_conn(existing) + drift: list[reconcile.ProfileDrift] = [] + + reconcile.ensure_profile( + conn, _NAME, _profile_spec(description="wanted", is_enabled=True), {}, drift + ) + + assert sorted(d.field for d in drift) == ["description", "is_enabled"] + + +def test_ensure_profile_accumulates_drift_across_profiles(): + """sync_flavor passes one list across every profile in the spec.""" + existing = _make_profile("owned-profile", is_enabled=False) + conn = _reuse_conn(existing) + already = reconcile.ProfileDrift( + profile_id="other", field="is_enabled", have=False, want=True + ) + drift = [already] + + reconcile.ensure_profile(conn, _NAME, _profile_spec(is_enabled=True), {}, drift) + + assert len(drift) == 2 + assert drift[0] is already + + +def test_ensure_profile_reports_no_drift_when_profile_matches_spec(): + conn = _reuse_conn(_make_profile("owned-profile")) + drift: list[reconcile.ProfileDrift] = [] + + reconcile.ensure_profile(conn, _NAME, _profile_spec(), {}, drift) + + assert drift == [] + + +def test_ensure_profile_reports_no_drift_for_freshly_created_profile(): + """A profile the operator just created from the spec cannot have drifted.""" + conn = _create_conn(_make_profile("new-profile", is_enabled=False)) + drift: list[reconcile.ProfileDrift] = [] + + reconcile.ensure_profile(conn, _NAME, _profile_spec(is_enabled=False), {}, drift) + + assert drift == [] + + +def test_profile_drift_describe_names_profile_and_field(): + drift = reconcile.ProfileDrift( + profile_id="prof-a", field="is_enabled", have=False, want=True + ) + + described = drift.describe() + + assert "prof-a" in described + assert "is_enabled" in described + assert "False" in described and "True" in described + + +# --------------------------------------------------------------------------- +# ensure_flavor: service_type is immutable +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_raises_on_service_type_mismatch(): + flavor = _make_flavor(service_type="DIFFERENT_TYPE") + conn = mock.MagicMock() + conn.network.flavors.return_value = [flavor] + + with pytest.raises(ConfigError, match="service_type"): + reconcile.ensure_flavor(conn, _flavor_spec()) + + +def test_ensure_flavor_error_message_contains_both_service_types(): + flavor = _make_flavor(service_type="WRONG") + conn = mock.MagicMock() + conn.network.flavors.return_value = [flavor] + + with pytest.raises(ConfigError) as exc_info: + reconcile.ensure_flavor(conn, _flavor_spec()) + + message = str(exc_info.value) + assert "WRONG" in message + assert SERVICE_TYPE in message + assert _NAME in message + + +# --------------------------------------------------------------------------- +# ensure_flavor: is_enabled and description reconcile +# --------------------------------------------------------------------------- + + +def _existing_flavor_conn(flavor: Any, updated: Any | None = None) -> Any: + conn = mock.MagicMock() + conn.network.flavors.return_value = [flavor] + conn.network.update_flavor.return_value = updated or _make_flavor() + return conn + + +def test_ensure_flavor_reenables_disabled_flavor(caplog): + conn = _existing_flavor_conn(_make_flavor(is_enabled=False)) + + with caplog.at_level(logging.INFO, logger="openstack_sync"): + reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=True)) + + conn.network.update_flavor.assert_called_once() + assert conn.network.update_flavor.call_args.kwargs["is_enabled"] is True + assert "is_enabled drift" in caplog.text + assert "have=False" in caplog.text + assert "want=True" in caplog.text + + +def test_ensure_flavor_disables_when_spec_disables(caplog): + conn = _existing_flavor_conn(_make_flavor(is_enabled=True)) + + with caplog.at_level(logging.INFO, logger="openstack_sync"): + reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=False)) + + conn.network.update_flavor.assert_called_once() + assert conn.network.update_flavor.call_args.kwargs["is_enabled"] is False + assert "have=True" in caplog.text + assert "want=False" in caplog.text + + +def test_ensure_flavor_no_update_when_both_disabled(): + flavor = _make_flavor(is_enabled=False) + conn = _existing_flavor_conn(flavor) + + result = reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=False)) + + conn.network.update_flavor.assert_not_called() + assert result is flavor + + +def test_ensure_flavor_no_update_when_already_correct(): + flavor = _make_flavor(is_enabled=True) + conn = _existing_flavor_conn(flavor) + + result = reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=True)) + + conn.network.update_flavor.assert_not_called() + assert result is flavor + + +def test_ensure_flavor_reenables_even_when_description_matches(): + """is_enabled drift must trigger an update even if the description is current.""" + conn = _existing_flavor_conn(_make_flavor(is_enabled=False)) + + reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=True)) + + conn.network.update_flavor.assert_called_once() + + +def test_ensure_flavor_updates_changed_description(): + conn = _existing_flavor_conn(_make_flavor(description="old description")) + + reconcile.ensure_flavor(conn, _flavor_spec()) + + conn.network.update_flavor.assert_called_once() + + +def test_ensure_flavor_adds_missing_marker(): + conn = _existing_flavor_conn(_make_flavor(description="no marker here")) + + reconcile.ensure_flavor(conn, _flavor_spec()) + + conn.network.update_flavor.assert_called_once() + kwargs = conn.network.update_flavor.call_args.kwargs + assert markers.FLAVOR_DESCRIPTION_MARKER in kwargs["description"] + + +# --------------------------------------------------------------------------- +# ensure_flavor: creates when absent +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_creates_when_not_found(): + conn = mock.MagicMock() + conn.network.flavors.return_value = [] + conn.network.create_flavor.return_value = _make_flavor() + + reconcile.ensure_flavor(conn, _flavor_spec()) + + kwargs = conn.network.create_flavor.call_args.kwargs + assert kwargs["name"] == _NAME + assert kwargs["service_type"] == SERVICE_TYPE + assert kwargs["is_enabled"] is True + assert markers.FLAVOR_DESCRIPTION_MARKER in kwargs["description"] + + +def test_ensure_flavor_creates_disabled_from_spec(): + """A CR that opts out of enabled must create the Neutron flavor disabled.""" + conn = mock.MagicMock() + conn.network.flavors.return_value = [] + conn.network.create_flavor.return_value = _make_flavor(is_enabled=False) + + reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=False)) + + assert conn.network.create_flavor.call_args.kwargs["is_enabled"] is False + + +def test_find_flavor_ignores_partial_name_match(): + """Guards against a future change to substring query semantics.""" + conn = mock.MagicMock() + conn.network.flavors.return_value = [_make_flavor(name="test-flavor-other")] + + assert reconcile.find_flavor(conn, _NAME) is None + + +# --------------------------------------------------------------------------- +# reconcile_flavor_profiles: bind missing, unbind owned extras +# --------------------------------------------------------------------------- + + +def test_reconcile_flavor_profiles_no_op_when_matching(): + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-b"]) + conn = _bindings_conn(flavor) + + result = reconcile.reconcile_flavor_profiles( + conn, flavor, [_make_profile("prof-a"), _make_profile("prof-b")] + ) + + conn.network.associate_flavor_with_service_profile.assert_not_called() + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + assert result is flavor + + +def test_reconcile_flavor_profiles_binds_missing(): + flavor = _make_flavor(service_profile_ids=[]) + conn = _bindings_conn(flavor) + + reconcile.reconcile_flavor_profiles( + conn, flavor, [_make_profile("prof-a"), _make_profile("prof-b")] + ) + + calls = conn.network.associate_flavor_with_service_profile.call_args_list + assert sorted(call.args[1].id for call in calls) == ["prof-a", "prof-b"] + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + + +def test_reconcile_flavor_profiles_unbinds_owned_extra(): + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-extra"]) + extra = _make_profile("prof-extra") + conn = _bindings_conn(flavor, {"prof-extra": extra}) + + reconcile.reconcile_flavor_profiles(conn, flavor, [_make_profile("prof-a")]) + + conn.network.associate_flavor_with_service_profile.assert_not_called() + conn.network.disassociate_flavor_from_service_profile.assert_called_once() + assert ( + conn.network.disassociate_flavor_from_service_profile.call_args.args[1] is extra + ) + + +def test_reconcile_flavor_profiles_keeps_unowned_extra(): + """A profile attached out-of-band must not be unbound.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-adhoc"]) + unowned = _make_profile("prof-adhoc", managed=False) + conn = _bindings_conn(flavor, {"prof-adhoc": unowned}) + + reconcile.reconcile_flavor_profiles(conn, flavor, [_make_profile("prof-a")]) + + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + + +def test_reconcile_flavor_profiles_binds_and_unbinds_together(): + flavor = _make_flavor(service_profile_ids=["prof-old"]) + old = _make_profile("prof-old") + conn = _bindings_conn(flavor, {"prof-old": old}) + + reconcile.reconcile_flavor_profiles(conn, flavor, [_make_profile("prof-new")]) + + conn.network.associate_flavor_with_service_profile.assert_called_once() + assert ( + conn.network.associate_flavor_with_service_profile.call_args.args[1].id + == "prof-new" + ) + conn.network.disassociate_flavor_from_service_profile.assert_called_once() + assert ( + conn.network.disassociate_flavor_from_service_profile.call_args.args[1] is old + ) + + +def test_reconcile_flavor_profiles_skips_deleted_extra(): + """An unbind candidate that no longer exists is a silent no-op.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-gone"]) + conn = _bindings_conn(flavor, {"prof-gone": None}) + + reconcile.reconcile_flavor_profiles(conn, flavor, [_make_profile("prof-a")]) + + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + + +# --------------------------------------------------------------------------- +# sync_flavor +# --------------------------------------------------------------------------- + + +def _sync_conn(flavor: Any, existing_profiles: list[Any] | None = None) -> Any: + conn = mock.MagicMock() + conn.network.flavors.return_value = [flavor] + conn.network.get_flavor.return_value = flavor + conn.network.service_profiles.return_value = list(existing_profiles or []) + conn.network.create_service_profile.return_value = _make_profile("prof-a") + return conn + + +def test_sync_flavor_returns_no_notes_when_nothing_drifted(): + flavor = _make_flavor(service_profile_ids=["prof-a"]) + conn = _sync_conn(flavor) + + assert reconcile.sync_flavor(conn, _flavor_spec(), {}) == [] + + +def test_sync_flavor_reports_profile_drift(): + """Drift found while resolving profiles reaches the caller as notes. + + The flavor itself is converged, so this is not a failure -- but the caller + must be able to qualify the status it reports. + """ + flavor = _make_flavor(service_profile_ids=["owned-profile"]) + drifted_profile = _make_profile("owned-profile", is_enabled=False) + conn = _sync_conn(flavor, existing_profiles=[drifted_profile]) + + notes = reconcile.sync_flavor( + conn, _flavor_spec(service_profiles=[_profile_spec(is_enabled=True)]), {} + ) + + assert len(notes) == 1 + assert "owned-profile" in notes[0] + assert "is_enabled" in notes[0] + + +def test_sync_flavor_passes_is_enabled_from_spec(): + """The value the API server put on the CR reaches the Neutron flavor.""" + flavor = _make_flavor(is_enabled=True, service_profile_ids=["prof-a"]) + conn = _sync_conn(flavor) + conn.network.update_flavor.return_value = flavor + + reconcile.sync_flavor(conn, _flavor_spec(is_enabled=False), {}) + + conn.network.update_flavor.assert_called_once() + assert conn.network.update_flavor.call_args.kwargs["is_enabled"] is False diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py deleted file mode 100644 index 761c9d5a7..000000000 --- a/python/openstack-sync/tests/test_router_flavors.py +++ /dev/null @@ -1,349 +0,0 @@ -"""Tests for the Neutron router flavors hook.""" - -from __future__ import annotations - -import json -import logging -from unittest import mock - -import pytest - -import openstack_sync.utils as utils -from openstack_sync.hooks import router_flavors -from openstack_sync.plugins.neutron.router_flavors import ( - router_flavors_common as common, -) - -FAKE_CLOUDS_YAML = """ -clouds: - understack: - auth: - auth_url: https://keystone.example.com/v3 - username: infrasetup - password: secret - project_name: baremetal - region_name: iad3 -""" - - -def _fake_conn(): - return mock.MagicMock(name="fake_conn") - - -def _router_flavor_object( - name: str, - spec: dict | None = None, - status: dict | None = None, -) -> dict: - flavor_spec = { - "name": name, - "driver": "some.Driver", - "cloudCredentialsRef": { - "secretName": "infrasetup", - "cloudName": "understack", - }, - } - flavor_spec.update(spec or {}) - obj = { - "metadata": { - "name": name, - "namespace": "openstack", - "generation": 1, - }, - "spec": flavor_spec, - } - if status is not None: - obj["status"] = status - return obj - - -def _snapshot_context(*objects: dict) -> list[dict]: - return [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [{"object": obj} for obj in objects], - }, - } - ] - - -# --------------------------------------------------------------------------- -# build_hook_config: reads env at call time so monkeypatch works directly -# --------------------------------------------------------------------------- - - -def test_router_flavor_hook_config_disabled(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "false") - - config = router_flavors.build_hook_config() - - assert config["onStartup"] == 10 - assert "kubernetes" not in config - assert "schedule" not in config - - -def test_router_flavor_hook_config_omits_schedule_without_crontab(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.delenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", raising=False) - monkeypatch.delenv("POD_NAMESPACE", raising=False) - - config = router_flavors.build_hook_config() - - assert config["kubernetes"][0]["name"] == common.CRD_BINDING_NAME - assert "schedule" not in config - - -def test_router_flavor_hook_config_omits_schedule_with_empty_crontab(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "") - monkeypatch.delenv("POD_NAMESPACE", raising=False) - - config = router_flavors.build_hook_config() - - assert config["kubernetes"][0]["name"] == common.CRD_BINDING_NAME - assert "schedule" not in config - - -def test_router_flavor_hook_config_uses_pod_namespace(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - config = router_flavors.build_hook_config() - - assert config["kubernetes"][0]["namespace"] == { - "nameSelector": {"matchNames": ["openstack"]} - } - assert config["kubernetes"][0]["queue"] == common.CRD_BINDING_NAME - assert config["schedule"][0]["crontab"] == "0 * * * *" - assert config["schedule"][0]["queue"] == common.CRD_BINDING_NAME - assert "onStartup" not in config - - -def test_router_flavor_hook_config_custom_crontab(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") - monkeypatch.delenv("POD_NAMESPACE", raising=False) - - config = router_flavors.build_hook_config() - - assert config["schedule"][0]["crontab"] == "*/15 * * * *" - - -def test_router_flavor_hook_config_uses_full_object_filter(monkeypatch): - """JqFilter must be '.' so cloudCredentialsRef is available at reconcile time.""" - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.delenv("POD_NAMESPACE", raising=False) - - config = router_flavors.build_hook_config() - - assert config["kubernetes"][0]["jqFilter"] == "." - - -def test_router_flavor_hook_config_printed_on_config_flag(monkeypatch, capsys): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - with mock.patch.object( - router_flavors.sys, "argv", ["router_flavors.py", "--config"] - ): - assert router_flavors.main() == 0 - - config = json.loads(capsys.readouterr().out) - assert config["kubernetes"][0]["namespace"]["nameSelector"]["matchNames"] == [ - "openstack" - ] - assert config["schedule"][0]["crontab"] == "*/15 * * * *" - - -# --------------------------------------------------------------------------- -# binding context parsing -# --------------------------------------------------------------------------- - - -def test_load_router_flavor_hook_inputs_keeps_current_status(): - status = { - "syncStatus": "Synced", - "message": "Successfully reconciled router flavor", - "observedGeneration": 1, - } - contexts = _snapshot_context(_router_flavor_object("flavor-a", status=status)) - - hook_inputs = router_flavors.load_router_flavor_hook_inputs(contexts) - - assert hook_inputs.resources_to_reconcile[0].current_status == status - - -def test_patch_flavor_status_passes_current_status(): - status = {"syncStatus": "Synced", "message": "ok", "observedGeneration": 1} - secret_name = "infrasetup" # noqa: S105 - resource = router_flavors.RouterFlavorResource( - flavor={"name": "flavor-a", "driver": "some.Driver"}, - name="flavor-a", - namespace="openstack", - generation=1, - secret_name=secret_name, - cloud_name="understack", - current_status=status, - ) - - with mock.patch( - "openstack_sync.hooks.router_flavors.patch_resource_status" - ) as mock_patch: - router_flavors.patch_flavor_status(resource, "Synced", "ok") - - assert mock_patch.call_args.kwargs["current_status"] == status - - -# --------------------------------------------------------------------------- -# reconcile_router_flavor_resources: credential resolution and sync delegation -# --------------------------------------------------------------------------- - - -def test_reconcile_uses_cloudcredentialsref(): - """Per-resource cloudCredentialsRef is used to connect to OpenStack.""" - resource = router_flavors.load_router_flavor_hook_inputs( - _snapshot_context( - _router_flavor_object( - "test-flavor", - { - "cloudCredentialsRef": { - "secretName": "baremetal-manage", - "cloudName": "understack", - }, - }, - ) - ) - ).resources_to_reconcile[0] - conn = _fake_conn() - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ) as mock_connect, - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - ): - result = router_flavors.reconcile_router_flavor_resources([resource]) - - assert result == 0 - mock_connect.assert_called_once_with("baremetal-manage", "understack") - mock_sync.assert_called_once_with(conn, resource.flavor, {}) - mock_prune.assert_called_once_with(conn, [resource.flavor]) - - -def test_reconcile_requires_cloudcredentialsref(): - obj = { - "metadata": {"name": "no-ref-flavor"}, - "spec": {"name": "no-ref-flavor", "driver": "some.Driver"}, - } - - with pytest.raises( - router_flavors.ConfigError, - match="cloudCredentialsRef is required", - ): - router_flavors.load_router_flavor_hook_inputs(_snapshot_context(obj)) - - -def test_reconcile_requires_complete_cloudcredentialsref(): - obj = { - "metadata": {"name": "partial-flavor"}, - "spec": { - "name": "partial-flavor", - "driver": "some.Driver", - "cloudCredentialsRef": {"secretName": "custom-secret"}, - }, - } - - with pytest.raises( - router_flavors.ConfigError, - match=r"cloudCredentialsRef\.cloudName", - ): - router_flavors.load_router_flavor_hook_inputs(_snapshot_context(obj)) - - -# --------------------------------------------------------------------------- -# main(): binding context dispatch -# --------------------------------------------------------------------------- - - -def test_main_dispatches_to_reconcile(monkeypatch, tmp_path): - """main() reads BINDING_CONTEXT_PATH and dispatches each object.""" - monkeypatch.setattr(utils, "_connection_cache", {}) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - binding_context = json.dumps(_snapshot_context(_router_flavor_object("flavor-a"))) - - ctx_file = tmp_path / "binding_context.json" - ctx_file.write_text(binding_context) - monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) - - with ( - mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=_fake_conn(), - ), - mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]), - ): - result = router_flavors.main() - - assert result == 0 - mock_sync.assert_called_once() - - -def test_main_returns_error_on_invalid_json(monkeypatch, caplog, tmp_path): - ctx_file = tmp_path / "binding_context.json" - ctx_file.write_text("not-json") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) - - with ( - caplog.at_level(logging.ERROR, logger="openstack_sync.hooks.router_flavors"), - mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]), - ): - result = router_flavors.main() - - assert result == 1 - assert "failed to parse binding context" in caplog.text - - -def test_main_returns_zero_on_empty_context(monkeypatch, tmp_path): - ctx_file = tmp_path / "binding_context.json" - ctx_file.write_text("") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) - - with mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]): - result = router_flavors.main() - - assert result == 0 - - -def test_main_returns_zero_when_no_context_path(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.delenv("BINDING_CONTEXT_PATH", raising=False) - - with mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]): - result = router_flavors.main() - - assert result == 0 diff --git a/python/openstack-sync/tests/test_router_flavors_create.py b/python/openstack-sync/tests/test_router_flavors_create.py deleted file mode 100644 index d9a747493..000000000 --- a/python/openstack-sync/tests/test_router_flavors_create.py +++ /dev/null @@ -1,642 +0,0 @@ -"""Tests for create.py helpers: ensure_profile and reconcile_flavor_profiles.""" - -from __future__ import annotations - -import logging -import types -from typing import Any -from unittest import mock - -from openstack_sync.plugins import common as plugin_common -from openstack_sync.plugins.neutron.router_flavors import create -from openstack_sync.plugins.neutron.router_flavors import ( - router_flavors_common as common, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_profile( - profile_id: str, - driver: str = "neutron_understack.l3_router.vrf.Vrf", - meta_info: Any = None, - managed: bool = True, - is_enabled: bool = True, - description: str = "desc", -) -> Any: - """Build an openstacksdk-shaped service profile. - - ``description`` defaults to the ``_profile_spec`` default so that a profile - and a spec built with defaults are drift-free; drift tests opt in by passing - a mismatching value. - """ - raw_meta = dict(meta_info or {}) - if managed: - raw_meta.update(common.operator_meta_info_markers()) - return types.SimpleNamespace( - id=profile_id, - driver=driver, - is_enabled=is_enabled, - description=description, - meta_info=plugin_common.meta_info_payload(raw_meta), - ) - - -def _make_flavor( - flavor_id: str = "flavor-id", - name: str = "test-flavor", - service_profile_ids: list[str] | None = None, -) -> Any: - return types.SimpleNamespace( - id=flavor_id, - name=name, - service_profile_ids=list(service_profile_ids or []), - ) - - -def _profile_spec( - driver: str = "neutron_understack.l3_router.vrf.Vrf", - description: str = "desc", - meta_info: dict[str, Any] | None = None, - is_enabled: bool = True, -) -> dict[str, Any]: - return { - "driver": driver, - "description": description, - "meta_info": meta_info if meta_info is not None else {}, - "is_enabled": is_enabled, - } - - -# --------------------------------------------------------------------------- -# service profile query cache -# --------------------------------------------------------------------------- - - -def test_list_service_profiles_queries_by_driver(): - network = mock.MagicMock() - network.service_profiles.return_value = [_make_profile("profile-id")] - conn = types.SimpleNamespace(network=network) - - result = create.list_service_profiles(conn, "some.Driver") - - assert result == list(network.service_profiles.return_value) - network.service_profiles.assert_called_once_with(driver="some.Driver") - - -def test_service_profiles_for_driver_caches_per_driver(): - first_driver = "first.Driver" - second_driver = "second.Driver" - first_profile = _make_profile("first-profile", driver=first_driver) - second_profile = _make_profile("second-profile", driver=second_driver) - network = mock.MagicMock() - network.service_profiles.side_effect = [[first_profile], [second_profile]] - conn = types.SimpleNamespace(network=network) - profile_cache: create.ServiceProfileCache = {} - - first_result = create.service_profiles_for_driver(conn, first_driver, profile_cache) - cached_result = create.service_profiles_for_driver( - conn, first_driver, profile_cache - ) - second_result = create.service_profiles_for_driver( - conn, second_driver, profile_cache - ) - - assert first_result == [first_profile] - assert cached_result is first_result - assert second_result == [second_profile] - assert network.service_profiles.call_args_list == [ - mock.call(driver=first_driver), - mock.call(driver=second_driver), - ] - - -# --------------------------------------------------------------------------- -# ensure_profile: create-or-reuse by (driver, meta_info) -# --------------------------------------------------------------------------- - - -def test_ensure_profile_creates_service_profile_with_management_markers(): - network = mock.MagicMock() - network.service_profiles.return_value = [] - network.create_service_profile.return_value = _make_profile("new-profile") - conn = types.SimpleNamespace(network=network) - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info={"vni_alloc": "auto"}), - profile_cache={}, - ) - - kwargs = conn.network.create_service_profile.call_args.kwargs - assert kwargs["driver"] == "neutron_understack.l3_router.vrf.Vrf" - assert kwargs["is_enabled"] is True - meta_info = plugin_common.normalize_meta_info(kwargs["meta_info"]) - assert meta_info["vni_alloc"] == "auto" - for key, value in common.operator_meta_info_markers().items(): - assert meta_info[key] == value - - -def test_ensure_profile_creates_disabled_profile_when_spec_disables(): - network = mock.MagicMock() - network.service_profiles.return_value = [] - network.create_service_profile.return_value = _make_profile( - "new-profile", is_enabled=False - ) - conn = types.SimpleNamespace(network=network) - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(is_enabled=False), - profile_cache={}, - ) - - assert conn.network.create_service_profile.call_args.kwargs["is_enabled"] is False - - -def test_ensure_profile_reuses_existing_matching_profile(): - """When Neutron already has a managed profile matching (driver, meta_info).""" - meta_info = {"vni_alloc": "auto"} - existing = _make_profile("existing-profile", meta_info=meta_info, managed=True) - network = mock.MagicMock() - network.service_profiles.return_value = [existing] - conn = types.SimpleNamespace(network=network) - - result = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info=meta_info), - profile_cache={}, - ) - - assert result is existing - conn.network.create_service_profile.assert_not_called() - - -def test_ensure_profile_appends_newly_created_profile_to_driver_cache(): - """A profile created for one flavor must be visible to the next flavor. - - profile_cache is caller-owned and shared across all flavors in the same - credential group during one reconcile pass. Two flavors with an identical - ``(driver, meta_info)`` spec must share one profile rather than each - creating a duplicate. - """ - driver = "some.Driver" - meta_info = {"vni_alloc": "auto"} - created_profile = _make_profile("new-profile", driver=driver, meta_info=meta_info) - network = mock.MagicMock() - network.service_profiles.return_value = [] - network.create_service_profile.return_value = created_profile - conn = types.SimpleNamespace(network=network) - profile_cache: create.ServiceProfileCache = {} - - created = create.ensure_profile( - conn, - flavor_name="flavor-a", - profile_spec=_profile_spec(driver=driver, meta_info=meta_info), - profile_cache=profile_cache, - ) - reused = create.ensure_profile( - conn, - flavor_name="flavor-b", - profile_spec=_profile_spec(driver=driver, meta_info=meta_info), - profile_cache=profile_cache, - ) - - assert created is created_profile - assert reused is created - conn.network.create_service_profile.assert_called_once() - conn.network.service_profiles.assert_called_once_with(driver=driver) - - -def test_ensure_profile_does_not_reuse_profiles_across_drivers(): - meta_info = {"vni_alloc": "auto"} - first_driver = "first.Driver" - second_driver = "second.Driver" - first_profile = _make_profile( - "first-profile", driver=first_driver, meta_info=meta_info - ) - second_profile = _make_profile( - "second-profile", driver=second_driver, meta_info=meta_info - ) - network = mock.MagicMock() - network.service_profiles.side_effect = [[], []] - network.create_service_profile.side_effect = [first_profile, second_profile] - conn = types.SimpleNamespace(network=network) - profile_cache: create.ServiceProfileCache = {} - - first_result = create.ensure_profile( - conn, - flavor_name="flavor-a", - profile_spec=_profile_spec(driver=first_driver, meta_info=meta_info), - profile_cache=profile_cache, - ) - second_result = create.ensure_profile( - conn, - flavor_name="flavor-b", - profile_spec=_profile_spec(driver=second_driver, meta_info=meta_info), - profile_cache=profile_cache, - ) - - assert first_result is first_profile - assert second_result is second_profile - assert network.create_service_profile.call_count == 2 - - -# --------------------------------------------------------------------------- -# find_matching_profile / ensure_profile: only operator-owned profiles are reused -# --------------------------------------------------------------------------- - - -def _reuse_conn(profile: Any) -> Any: - """Build a connection whose only existing service profile is *profile*.""" - network = mock.MagicMock() - network.service_profiles.return_value = [profile] - return types.SimpleNamespace(network=network) - - -def test_find_matching_profile_ignores_unowned_match(): - meta_info = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) - - assert create.find_matching_profile([unowned], meta_info) is None - - -def test_find_matching_profile_prefers_owned_over_unowned(): - meta_info = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) - owned = _make_profile("owned-profile", meta_info=meta_info, managed=True) - - assert create.find_matching_profile([unowned, owned], meta_info) is owned - - -def test_ensure_profile_creates_owned_profile_instead_of_reusing_unowned(): - """An unowned profile must never be bound, because it can never be unbound. - - ``reconcile_flavor_profiles`` only unbinds profiles carrying the ownership - marker, so reusing somebody else's profile would create a binding that - outlives the spec that created it and that nothing can ever remove. - """ - meta_info = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) - created = _make_profile("new-profile", meta_info=meta_info, managed=True) - network = mock.MagicMock() - network.service_profiles.return_value = [unowned] - network.create_service_profile.return_value = created - conn = types.SimpleNamespace(network=network) - - result = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info=meta_info), - profile_cache={}, - ) - - assert result is created - network.create_service_profile.assert_called_once() - new_meta = plugin_common.normalize_meta_info( - network.create_service_profile.call_args.kwargs["meta_info"] - ) - for key, value in common.operator_meta_info_markers().items(): - assert new_meta[key] == value - - -def test_ensure_profile_never_adopts_an_unowned_profile(): - """The unowned profile is left alone, not stamped with the ownership marker. - - Adopting it would enrol somebody else's profile into - ``prune_orphaned_service_profiles``, which deletes owned, unattached - profiles -- an irreversible side effect on a resource the operator did not - create. - """ - meta_info = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) - network = mock.MagicMock() - network.service_profiles.return_value = [unowned] - network.create_service_profile.return_value = _make_profile("new-profile") - conn = types.SimpleNamespace(network=network) - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info=meta_info), - profile_cache={}, - ) - - network.update_service_profile.assert_not_called() - network.delete_service_profile.assert_not_called() - - -def test_ensure_profile_reuses_owned_profile_when_unowned_match_also_exists(): - meta_info = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) - owned = _make_profile("owned-profile", meta_info=meta_info, managed=True) - network = mock.MagicMock() - network.service_profiles.return_value = [unowned, owned] - conn = types.SimpleNamespace(network=network) - - result = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info=meta_info), - profile_cache={}, - ) - - assert result is owned - network.create_service_profile.assert_not_called() - - -def test_profile_created_beside_unowned_match_can_later_be_unbound(): - """End-to-end guard for why unowned profiles are not reused. - - Resolve a profile for spec A while an unowned match exists, then reconcile - the flavor against spec B. The profile bound for spec A must be unbindable, - which holds only because the operator created and owns it. - """ - meta_a = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_a, managed=False) - created_for_a = _make_profile("prof-a", meta_info=meta_a, managed=True) - network = mock.MagicMock() - network.service_profiles.return_value = [unowned] - network.create_service_profile.return_value = created_for_a - conn = types.SimpleNamespace(network=network) - - profile_for_a = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info=meta_a), - profile_cache={}, - ) - - # The spec moves on to a different profile; the flavor still carries prof-a. - flavor = _make_flavor(service_profile_ids=["prof-a"]) - reconcile_conn = _reconcile_conn(flavor, {"prof-a": profile_for_a}) - - create.reconcile_flavor_profiles( - reconcile_conn, flavor, [_make_profile("prof-b", managed=True)] - ) - - disassociate = reconcile_conn.network.disassociate_flavor_from_service_profile - disassociate.assert_called_once() - assert disassociate.call_args.args[1] is profile_for_a - - -# --------------------------------------------------------------------------- -# ensure_profile: drift reporting for reused profiles -# --------------------------------------------------------------------------- - - -def test_ensure_profile_reports_is_enabled_drift_on_reuse(caplog): - """A profile disabled out-of-band is reported instead of silently accepted. - - Neutron's ``get_flavor_next_provider`` raises ``ServiceProfileDisabled`` - when the profile it selects is disabled, so every router create against the - flavor fails while the flavor itself still looks converged. - """ - existing = _make_profile("owned-profile", managed=True, is_enabled=False) - conn = _reuse_conn(existing) - drift: list[common.ProfileDrift] = [] - - with caplog.at_level(logging.WARNING): - result = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(is_enabled=True), - profile_cache={}, - drift=drift, - ) - - assert result is existing - assert [(item.field, item.have, item.want) for item in drift] == [ - ("is_enabled", False, True) - ] - assert drift[0].profile_id == "owned-profile" - assert "is_enabled" in caplog.text - # Neutron rejects updates to a profile bound to any flavor: never try one. - conn.network.update_service_profile.assert_not_called() - - -def test_ensure_profile_reports_description_drift_on_reuse(): - existing = _make_profile("owned-profile", managed=True, description="stale") - conn = _reuse_conn(existing) - drift: list[common.ProfileDrift] = [] - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(description="wanted"), - profile_cache={}, - drift=drift, - ) - - assert [(item.field, item.have, item.want) for item in drift] == [ - ("description", "stale", "wanted") - ] - - -def test_ensure_profile_reports_every_drifted_field(): - existing = _make_profile( - "owned-profile", managed=True, is_enabled=False, description="stale" - ) - conn = _reuse_conn(existing) - drift: list[common.ProfileDrift] = [] - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(description="wanted", is_enabled=True), - profile_cache={}, - drift=drift, - ) - - assert sorted(item.field for item in drift) == ["description", "is_enabled"] - - -def test_ensure_profile_appends_to_existing_drift_collection(): - """sync_flavor passes one list across every profile in the spec.""" - existing = _make_profile("owned-profile", managed=True, is_enabled=False) - conn = _reuse_conn(existing) - already_found = common.ProfileDrift( - profile_id="other-profile", - driver="other.Driver", - field="is_enabled", - have=False, - want=True, - ) - drift = [already_found] - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(is_enabled=True), - profile_cache={}, - drift=drift, - ) - - assert len(drift) == 2 - assert drift[0] is already_found - - -def test_ensure_profile_reports_no_drift_when_profile_matches_spec(): - existing = _make_profile("owned-profile", managed=True) - conn = _reuse_conn(existing) - drift: list[common.ProfileDrift] = [] - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(), - profile_cache={}, - drift=drift, - ) - - assert drift == [] - - -def test_ensure_profile_reports_no_drift_for_freshly_created_profile(): - """A profile the operator just created from the spec cannot have drifted.""" - network = mock.MagicMock() - network.service_profiles.return_value = [] - network.create_service_profile.return_value = _make_profile( - "new-profile", is_enabled=False - ) - conn = types.SimpleNamespace(network=network) - drift: list[common.ProfileDrift] = [] - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(is_enabled=False), - profile_cache={}, - drift=drift, - ) - - assert drift == [] - - -def test_ensure_profile_drift_collection_is_optional(): - """Callers that do not track drift keep working unchanged.""" - existing = _make_profile("owned-profile", managed=True, is_enabled=False) - conn = _reuse_conn(existing) - - result = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(is_enabled=True), - profile_cache={}, - ) - - assert result is existing - - -# --------------------------------------------------------------------------- -# reconcile_flavor_profiles: set-based associate + disassociate-if-managed -# --------------------------------------------------------------------------- - - -def _reconcile_conn( - flavor: Any, disassociate_profile_lookup: dict[str, Any] | None = None -) -> Any: - """Build a connection mock whose network exposes these behaviors. - - * ``get_flavor`` returns *flavor* on every call - * ``associate_flavor_with_service_profile`` succeeds silently - * ``disassociate_flavor_from_service_profile`` succeeds silently - * ``get_service_profile`` returns matching profile from - *disassociate_profile_lookup* so ``is_managed_service_profile`` can be - evaluated on candidates for removal. - """ - lookup = disassociate_profile_lookup or {} - network = mock.MagicMock() - network.get_flavor.return_value = flavor - network.get_service_profile.side_effect = lambda pid: lookup.get(pid) - return types.SimpleNamespace(network=network) - - -def test_reconcile_flavor_profiles_no_op_when_matches(): - """Current == desired → no associate/disassociate calls.""" - flavor = _make_flavor(service_profile_ids=["prof-a", "prof-b"]) - desired = [ - _make_profile("prof-a"), - _make_profile("prof-b"), - ] - conn = _reconcile_conn(flavor) - - result = create.reconcile_flavor_profiles(conn, flavor, desired) - - conn.network.associate_flavor_with_service_profile.assert_not_called() - conn.network.disassociate_flavor_from_service_profile.assert_not_called() - assert result is flavor - - -def test_reconcile_flavor_profiles_associates_missing(): - flavor = _make_flavor(service_profile_ids=[]) - desired = [_make_profile("prof-a"), _make_profile("prof-b")] - conn = _reconcile_conn(flavor) - - create.reconcile_flavor_profiles(conn, flavor, desired) - - associate_calls = conn.network.associate_flavor_with_service_profile.call_args_list - associated_ids = sorted(call.args[1].id for call in associate_calls) - assert associated_ids == ["prof-a", "prof-b"] - conn.network.disassociate_flavor_from_service_profile.assert_not_called() - - -def test_reconcile_flavor_profiles_disassociates_managed_extra(): - """A managed profile currently on the flavor but not desired must be unbound.""" - flavor = _make_flavor(service_profile_ids=["prof-a", "prof-extra"]) - desired = [_make_profile("prof-a")] - extra_profile = _make_profile("prof-extra", managed=True) - conn = _reconcile_conn(flavor, {"prof-extra": extra_profile}) - - create.reconcile_flavor_profiles(conn, flavor, desired) - - conn.network.associate_flavor_with_service_profile.assert_not_called() - conn.network.disassociate_flavor_from_service_profile.assert_called_once() - call = conn.network.disassociate_flavor_from_service_profile.call_args - assert call.args[1] is extra_profile - - -def test_reconcile_flavor_profiles_keeps_unmanaged_extra(): - """An unmanaged profile attached out-of-band must not be disassociated.""" - flavor = _make_flavor(service_profile_ids=["prof-a", "prof-adhoc"]) - desired = [_make_profile("prof-a")] - unmanaged = _make_profile("prof-adhoc", managed=False) - conn = _reconcile_conn(flavor, {"prof-adhoc": unmanaged}) - - create.reconcile_flavor_profiles(conn, flavor, desired) - - conn.network.disassociate_flavor_from_service_profile.assert_not_called() - - -def test_reconcile_flavor_profiles_handles_add_and_remove_together(): - """Simultaneous associate + disassociate in one reconcile pass.""" - flavor = _make_flavor(service_profile_ids=["prof-old"]) - desired = [_make_profile("prof-new")] - old_profile = _make_profile("prof-old", managed=True) - conn = _reconcile_conn(flavor, {"prof-old": old_profile}) - - create.reconcile_flavor_profiles(conn, flavor, desired) - - conn.network.associate_flavor_with_service_profile.assert_called_once() - associate_call = conn.network.associate_flavor_with_service_profile.call_args - assert associate_call.args[1].id == "prof-new" - conn.network.disassociate_flavor_from_service_profile.assert_called_once() - disassociate_call = conn.network.disassociate_flavor_from_service_profile.call_args - assert disassociate_call.args[1] is old_profile - - -def test_reconcile_flavor_profiles_skips_deleted_extra_profile(): - """A candidate for disassociation that no longer exists is a silent no-op.""" - flavor = _make_flavor(service_profile_ids=["prof-a", "prof-gone"]) - desired = [_make_profile("prof-a")] - # Neutron says prof-gone doesn't exist anymore. - conn = _reconcile_conn(flavor, {"prof-gone": None}) - - create.reconcile_flavor_profiles(conn, flavor, desired) - - conn.network.disassociate_flavor_from_service_profile.assert_not_called() diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py index d17125ce0..4705ce19b 100644 --- a/python/openstack-sync/tests/test_router_flavors_hook.py +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -1,29 +1,38 @@ -"""Integration-style tests for the Neutron router flavor hook run loop.""" +"""Tests for the router flavor hook: how the plugin wires into the framework. + +The generic driver is covered in ``test_framework.py``; these tests cover only +what is specific to this plugin, plus one end-to-end run through ``main()``. +""" from __future__ import annotations import importlib import json +import types from pathlib import Path +from typing import Any from unittest import mock import pytest import openstack_sync.utils as utils from openstack_sync.hooks import router_flavors as hook -from openstack_sync.plugins.neutron.router_flavors import ( - router_flavors_common as common, -) - -ROUTER_ENV_NAMES = ( +from openstack_sync.plugins.neutron.router_flavors import markers +from openstack_sync.plugins.neutron.router_flavors.config import BINDING_NAME +from openstack_sync.plugins.neutron.router_flavors.config import ENV_PREFIX +from openstack_sync.plugins.neutron.router_flavors.config import SERVICE_TYPE +from tests.conftest import CRD_API_VERSION +from tests.conftest import CRD_KIND +from tests.conftest import make_hook_config + +ENV_NAMES = ( "BINDING_CONTEXT_PATH", - "NEUTRON_ROUTER_FLAVOR_ENABLED", - "NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", - "NEUTRON_ROUTER_FLAVOR_CRD_BINDING_NAME", - "NEUTRON_ROUTER_FLAVOR_PRUNE", - "NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", - "NEUTRON_ROUTER_FLAVOR_READY_RETRIES", - "NEUTRON_ROUTER_FLAVOR_READY_DELAY", + f"{ENV_PREFIX}_ENABLED", + f"{ENV_PREFIX}_SYNC_CRONTAB", + f"{ENV_PREFIX}_PRUNE", + f"{ENV_PREFIX}_STATUS_ENABLED", + f"{ENV_PREFIX}_READY_RETRIES", + f"{ENV_PREFIX}_READY_DELAY", "POD_NAMESPACE", ) @@ -40,24 +49,24 @@ def clear_env(monkeypatch: pytest.MonkeyPatch) -> None: - for name in ROUTER_ENV_NAMES: + for name in ENV_NAMES: monkeypatch.delenv(name, raising=False) -def write_binding_context(path: Path, contexts: list[dict]) -> str: - context_path = path / "binding-context.json" - context_path.write_text(json.dumps(contexts), encoding="utf-8") - return str(context_path) - - def router_flavor_object(name: str, spec: dict | None = None) -> dict: - flavor_spec = { + flavor_spec: dict[str, Any] = { "name": name, - "service_type": "L3_ROUTER_NAT", + "service_type": SERVICE_TYPE, "description": f"{name} description", - "driver": "neutron_understack.l3_router.vrf.Vrf", - "profile_description": f"{name} profile", - "meta_info": {"vni_alloc": "auto"}, + "is_enabled": True, + "service_profiles": [ + { + "driver": "neutron_understack.l3_router.vrf.Vrf", + "description": f"{name} profile", + "meta_info": {"vni_alloc": "auto"}, + "is_enabled": True, + } + ], "cloudCredentialsRef": { "secretName": "infrasetup", "cloudName": "understack", @@ -65,1001 +74,304 @@ def router_flavor_object(name: str, spec: dict | None = None) -> dict: } flavor_spec.update(spec or {}) return { - "apiVersion": "neutron.understack.rackspace.net/v1alpha1", - "kind": "NeutronRouterFlavor", - "metadata": { - "name": name, - "namespace": "openstack", - "generation": 3, - }, + "apiVersion": CRD_API_VERSION, + "kind": CRD_KIND, + "metadata": {"name": name, "namespace": "openstack", "generation": 3}, "spec": flavor_spec, } -# --------------------------------------------------------------------------- -# hook config shape -# --------------------------------------------------------------------------- - - -def test_disabled_hook_config_is_valid_noop(monkeypatch, capsys): - clear_env(monkeypatch) - - config = hook.build_hook_config() - - assert config["onStartup"] == 10 - assert "kubernetes" not in config - assert "schedule" not in config - - with mock.patch.object(hook.sys, "argv", ["router_flavors.py", "--config"]): - assert hook.main() == 0 - - assert json.loads(capsys.readouterr().out) == config - - -def test_common_import_is_safe_with_bad_runtime_env(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "maybe") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", "maybe") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "soon") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "later") - - importlib.reload(common) - - -def test_disabled_hook_config_does_not_parse_runtime_env(monkeypatch): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "maybe") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", "maybe") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "soon") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "later") +def write_binding_context(path: Path, contexts: list[dict]) -> str: + context_path = path / "binding-context.json" + context_path.write_text(json.dumps(contexts), encoding="utf-8") + return str(context_path) - config = hook.build_hook_config() - assert config["onStartup"] == 10 - assert "kubernetes" not in config +# --------------------------------------------------------------------------- +# Import safety +# --------------------------------------------------------------------------- -def test_crontab_does_not_enable_disabled_hook(monkeypatch): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "false") +def test_module_import_is_safe_with_bad_runtime_env(monkeypatch): + """Importing must not read runtime config. - config = hook.build_hook_config() + Shell-operator imports the hook to ask for its config before the full + environment is guaranteed, so a malformed value must not break import. + """ + monkeypatch.setenv(f"{ENV_PREFIX}_READY_RETRIES", "not-a-number") + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "not-a-bool") - assert config["onStartup"] == 10 - assert "kubernetes" not in config - assert "schedule" not in config + importlib.reload(hook) -def test_enabled_hook_config_omits_schedule_without_crontab(monkeypatch): +def test_config_flag_prints_json(monkeypatch, capsys): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - - config = hook.build_hook_config() + monkeypatch.setattr(hook.sys, "argv", ["router_flavors.py", "--config"]) - assert config["kubernetes"][0]["name"] == common.CRD_BINDING_NAME - assert "schedule" not in config + assert hook.main() == 0 + assert json.loads(capsys.readouterr().out)["onStartup"] == 10 -def test_enabled_hook_config_watches_router_flavors(monkeypatch): +def test_enabled_config_flag_watches_this_crd(monkeypatch, capsys): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setattr(hook.sys, "argv", ["router_flavors.py", "--config"]) - config = hook.build_hook_config() - - binding = config["kubernetes"][0] - assert "onStartup" not in config - assert binding["name"] == common.CRD_BINDING_NAME - assert binding["apiVersion"] == common.crd_api_version() - assert binding["kind"] == common.crd_kind() - assert binding["executeHookOnEvent"] == ["Added", "Modified", "Deleted"] - assert binding["jqFilter"] == "." - assert binding["includeSnapshotsFrom"] == [common.CRD_BINDING_NAME] - assert binding["namespace"]["nameSelector"]["matchNames"] == ["openstack"] - assert binding["queue"] == common.CRD_BINDING_NAME - assert config["schedule"] == [ - { - "name": "hourly sync", - "crontab": "*/15 * * * *", - "includeSnapshotsFrom": [common.CRD_BINDING_NAME], - "queue": common.CRD_BINDING_NAME, - } - ] + assert hook.main() == 0 + config = json.loads(capsys.readouterr().out) + (binding,) = config["kubernetes"] + assert binding["name"] == BINDING_NAME + assert binding["kind"] == CRD_KIND # --------------------------------------------------------------------------- -# load_router_flavor_hook_inputs: binding context parsing +# Plugin wiring # --------------------------------------------------------------------------- -def test_load_router_flavors_from_snapshot(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") +def test_plugin_reconcile_delegates_to_sync_flavor(): + plugin = hook.RouterFlavorPlugin(make_hook_config()) + conn = mock.MagicMock() + cache: dict[str, Any] = {} + spec = {"name": "flavor-a"} - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - { - "object": router_flavor_object( - "dynamic-vrf", - {"name": "dynamic_vrf"}, - ), - }, - ], - }, - }, - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - hook_inputs = hook.load_router_flavor_hook_inputs() - resources = hook_inputs.resources_to_reconcile - - assert len(resources) == 1 - assert resources[0].name == "dynamic-vrf" - assert resources[0].namespace == "openstack" - assert resources[0].generation == 3 - assert resources[0].flavor["name"] == "dynamic_vrf" - assert resources[0].flavor["driver"] == "neutron_understack.l3_router.vrf.Vrf" - # cloudCredentialsRef is popped into secret_name / cloud_name - assert resources[0].secret_name == "infrasetup" # noqa: S105 - assert resources[0].cloud_name == "understack" - assert "cloudCredentialsRef" not in resources[0].flavor - # Schedule contexts fall through to snapshot parsing, so desired equals - # resources_to_reconcile. - assert hook_inputs.desired_resources_for_prune == resources - assert hook_inputs.deleted_resources == [] + with mock.patch.object( + hook.reconcile_module, "sync_flavor", return_value=["a note"] + ) as sync_flavor: + notes = plugin.reconcile(conn, spec, cache) + assert notes == ["a note"] + sync_flavor.assert_called_once_with(conn, spec, cache) -# --------------------------------------------------------------------------- -# main() dispatches per-object reconciliation -# --------------------------------------------------------------------------- - -def test_main_reconciles_binding_context_objects(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setattr(utils, "_connection_cache", {}) - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": router_flavor_object("pa1410")}, - ] - }, - } - ], +def test_plugin_wait_for_api_uses_configured_retry_budget(): + plugin = hook.RouterFlavorPlugin( + make_hook_config(ready_retries=5, ready_delay=0.25) ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + conn = mock.MagicMock() - synced = [] + with mock.patch.object(hook, "wait_for_openstack_network") as wait: + plugin.wait_for_api(conn) - with ( - mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=mock.MagicMock(), - ), - mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", - side_effect=lambda conn, flavor, profiles: synced.append(flavor["name"]), - ), - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() + wait.assert_called_once_with(conn, retries=5, delay=0.25) - assert result == 0 - assert synced == ["pa1410"] +def test_plugin_prune_is_a_noop_when_disabled(): + plugin = hook.RouterFlavorPlugin(make_hook_config(prune=False)) -def _drift_context(monkeypatch, tmp_path) -> None: - """Set up a single-flavor schedule binding context for status assertions.""" - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setattr(utils, "_connection_cache", {}) + with mock.patch.object(hook.prune_module, "prune_removed_flavors") as prune: + plugin.prune(mock.MagicMock(), [{"name": "a"}], authoritative_empty=False) - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": router_flavor_object("pa1410")}, - ] - }, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + prune.assert_not_called() -def test_main_reports_plain_success_when_no_profile_drift(monkeypatch, tmp_path): - """The drift-free status message must stay exactly as it was.""" - _drift_context(monkeypatch, tmp_path) +def test_plugin_prune_forwards_authoritative_empty_when_enabled(): + plugin = hook.RouterFlavorPlugin(make_hook_config(prune=True)) + conn = mock.MagicMock() + specs = [{"name": "a"}] - with ( - mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=mock.MagicMock(), - ), - mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch( - "openstack_sync.hooks.router_flavors.patch_flavor_status" - ) as mock_status, - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor", return_value=[]), - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() + with mock.patch.object(hook.prune_module, "prune_removed_flavors") as prune: + plugin.prune(conn, specs, authoritative_empty=True) - assert result == 0 - assert mock_status.call_args.args[1] == "Synced" - assert mock_status.call_args.args[2] == "Successfully reconciled router flavor" + prune.assert_called_once_with(conn, specs, authoritative_empty=True) -def test_main_reports_service_profile_drift_in_synced_status(monkeypatch, tmp_path): - """Drift must reach the CR status. +def test_plugin_cache_is_per_credential_group(): + plugin = hook.RouterFlavorPlugin(make_hook_config()) - The flavor is converged, so the status stays Synced -- but reporting a bare - success is how a disabled service profile stays invisible until every router - create against the flavor fails. - """ - _drift_context(monkeypatch, tmp_path) - drift = [ - common.ProfileDrift( - profile_id="prof-a", - driver="neutron_understack.l3_router.vrf.Vrf", - field="is_enabled", - have=False, - want=True, - ) - ] + assert plugin.new_cache() == {} + assert plugin.new_cache() is not plugin.new_cache() - with ( - mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=mock.MagicMock(), - ), - mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch( - "openstack_sync.hooks.router_flavors.patch_flavor_status" - ) as mock_status, - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=drift - ), - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - # Drift is not a reconcile failure: the flavor still converged. - assert result == 0 - assert mock_status.call_args.args[1] == "Synced" - message = mock_status.call_args.args[2] - assert message.startswith("Successfully reconciled router flavor") - assert "prof-a" in message - assert "is_enabled" in message +# --------------------------------------------------------------------------- +# End to end through main() +# --------------------------------------------------------------------------- -def test_main_returns_error_when_reconcile_fails(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setattr(utils, "_connection_cache", {}) - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": router_flavor_object("bad-flavor")}, - ] - }, - } - ], +def _neutron_conn() -> Any: + """A Neutron connection that already holds the desired flavor and profile.""" + profile = types.SimpleNamespace( + id="profile-id", + driver="neutron_understack.l3_router.vrf.Vrf", + is_enabled=True, + description="pa1410 profile", + meta_info=markers.managed_meta_info({"vni_alloc": "auto"}), ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( - mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=mock.MagicMock(), - ), - mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", - side_effect=RuntimeError("bad flavor config"), - ), - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 1 - - -def test_main_prunes_after_successful_full_set_reconcile(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - conn = mock.MagicMock() - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": router_flavor_object("pa1410")}, - {"object": router_flavor_object("dynamic-vrf")}, - ] - }, - } - ], + flavor = types.SimpleNamespace( + id="flavor-id", + name="pa1410", + service_type=SERVICE_TYPE, + description=markers.managed_flavor_description("pa1410 description"), + is_enabled=True, + service_profile_ids=["profile-id"], ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 0 - assert [call.args[1]["name"] for call in mock_sync.call_args_list] == [ - "dynamic-vrf", - "pa1410", - ] - mock_prune.assert_called_once() - assert mock_prune.call_args.args[0] is conn - assert [flavor["name"] for flavor in mock_prune.call_args.args[1]] == [ - "dynamic-vrf", - "pa1410", - ] - - -def test_main_prunes_deleted_only_credentials(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") conn = mock.MagicMock() + conn.network.service_profiles.return_value = [profile] + conn.network.flavors.return_value = [flavor] + conn.network.get_flavor.return_value = flavor + return conn - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": router_flavor_object("pa1410"), - "snapshots": {common.CRD_BINDING_NAME: []}, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ) as mock_connect, - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 0 - mock_connect.assert_called_once_with("infrasetup", "understack") - mock_sync.assert_not_called() - mock_prune.assert_called_once_with(conn, [], authoritative_empty_desired=True) - - -def test_main_returns_error_when_deleted_only_connection_fails(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": router_flavor_object("pa1410"), - "snapshots": {common.CRD_BINDING_NAME: []}, - } - ], +def _run_main(monkeypatch, tmp_path, contexts: list[dict], conn: Any): + monkeypatch.setenv( + "BINDING_CONTEXT_PATH", write_binding_context(tmp_path, contexts) ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - side_effect=RuntimeError("secret missing"), - ) as mock_connect, - mock.patch( - "openstack_sync.hooks.router_flavors.wait_for_openstack_network" - ) as mock_wait, - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 1 - mock_connect.assert_called_once_with("infrasetup", "understack") - mock_wait.assert_not_called() - mock_sync.assert_not_called() - mock_prune.assert_not_called() - - -def test_main_returns_error_when_deleted_only_prune_fails(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") - conn = mock.MagicMock() - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": router_flavor_object("pa1410"), - "snapshots": {common.CRD_BINDING_NAME: []}, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", + "openstack_sync.hooks.framework.get_openstack_connection", return_value=conn, - ) as mock_connect, - mock.patch( - "openstack_sync.hooks.router_flavors.wait_for_openstack_network" - ) as mock_wait, - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors", - side_effect=RuntimeError("delete failed"), - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 1 - mock_connect.assert_called_once_with("infrasetup", "understack") - mock_wait.assert_called_once_with(conn) - mock_sync.assert_not_called() - mock_prune.assert_called_once_with(conn, [], authoritative_empty_desired=True) - - -def test_main_ignores_deleted_only_credentials_when_prune_is_disabled( - monkeypatch, tmp_path -): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "false") - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": router_flavor_object("pa1410"), - "snapshots": {common.CRD_BINDING_NAME: []}, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection" - ) as mock_connect, - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, + ), mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + "openstack_sync.hooks.framework.patch_resource_status" + ) as patch_status, + mock.patch.object(hook, "wait_for_openstack_network"), ): - result = hook.main() - - assert result == 0 - mock_connect.assert_not_called() - mock_sync.assert_not_called() - mock_prune.assert_not_called() - + code = hook.main() + return code, patch_status -def test_main_prunes_active_and_deleted_only_credentials(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") - active_conn = mock.MagicMock(name="active_conn") - deleted_conn = mock.MagicMock(name="deleted_conn") - active_object = router_flavor_object("pa1410") - deleted_object = router_flavor_object( - "other-cloud-flavor", +def _schedule_context(*names: str) -> list[dict]: + return [ { - "cloudCredentialsRef": { - "secretName": "other-secret", - "cloudName": "other-cloud", - } - }, - ) - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": deleted_object, - "snapshots": { - common.CRD_BINDING_NAME: [{"object": active_object}], - }, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - def connect(secret_name, cloud_name): - if (secret_name, cloud_name) == ("infrasetup", "understack"): - return active_conn - if (secret_name, cloud_name) == ("other-secret", "other-cloud"): - return deleted_conn - raise AssertionError((secret_name, cloud_name)) - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - side_effect=connect, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor", return_value=[]), - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 0 - assert mock_prune.call_args_list == [ - mock.call(active_conn, [mock.ANY]), - mock.call(deleted_conn, [], authoritative_empty_desired=True), + "binding": BINDING_NAME, + "type": "Schedule", + "snapshots": { + BINDING_NAME: [{"object": router_flavor_object(n)} for n in names] + }, + } ] - assert mock_prune.call_args_list[0].args[1][0]["name"] == "pa1410" -def test_main_skips_empty_snapshot_prune_without_credentials(monkeypatch, tmp_path): +def test_main_returns_zero_when_hook_disabled(monkeypatch, tmp_path): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + conn = _neutron_conn() - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": {common.CRD_BINDING_NAME: []}, - } - ], + code, patch_status = _run_main( + monkeypatch, tmp_path, _schedule_context("pa1410"), conn ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection" - ) as mock_connect, - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 0 - mock_connect.assert_not_called() - mock_sync.assert_not_called() - mock_prune.assert_not_called() + assert code == 0 + patch_status.assert_not_called() + conn.network.flavors.assert_not_called() -def test_main_continues_after_failure_and_skips_prune(monkeypatch, tmp_path): +def test_main_reconciles_an_already_converged_flavor(monkeypatch, tmp_path): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") monkeypatch.setenv("POD_NAMESPACE", "openstack") - conn = mock.MagicMock() + conn = _neutron_conn() - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": router_flavor_object("bad-flavor")}, - {"object": router_flavor_object("good-flavor")}, - ] - }, - } - ], + code, patch_status = _run_main( + monkeypatch, tmp_path, _schedule_context("pa1410"), conn ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - seen = [] - - def sync_flavor(conn, flavor, profiles): - seen.append(flavor["name"]) - if flavor["name"] == "bad-flavor": - raise RuntimeError("bad flavor config") - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch( - "openstack_sync.hooks.router_flavors.patch_flavor_status" - ) as mock_status, - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", - side_effect=sync_flavor, - ), - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 1 - assert seen == ["bad-flavor", "good-flavor"] - assert [call.args[1] for call in mock_status.call_args_list] == [ - "Failed", - "Synced", - ] - mock_prune.assert_not_called() - - -# --------------------------------------------------------------------------- -# Event-driven scenarios: reconcile only the changed CR while prune uses -# the full snapshot delivered by shell-operator. -# --------------------------------------------------------------------------- - - -def router_flavor_object_with_status( - name: str, - *, - generation: int = 3, - status: dict | None = None, - spec: dict | None = None, -) -> dict: - """Build a NeutronRouterFlavor object with optional status/generation. - - Mirrors :func:`router_flavor_object` but allows tests to control the - metadata.generation and status subresource used by the Modified-event - status-current guard. - """ - obj = router_flavor_object(name, spec) - obj["metadata"]["generation"] = generation - if status is not None: - obj["status"] = status - return obj + assert code == 0 + assert patch_status.call_args.kwargs["sync_status"] == "Synced" + assert patch_status.call_args.kwargs["message"] == ( + "Successfully reconciled router flavor" + ) + # Already converged: no writes to Neutron. + conn.network.create_flavor.assert_not_called() + conn.network.create_service_profile.assert_not_called() + conn.network.associate_flavor_with_service_profile.assert_not_called() -def test_added_event_reconciles_only_added_resource(monkeypatch, tmp_path): - """An Added event reconciles only the new CR; prune sees the full snapshot. - Regression guard for the noise-on-create scenario: creating a new CR must - not reconcile the four unrelated CRs already present in Neutron. - """ +def test_main_reports_profile_drift_on_the_cr_status(monkeypatch, tmp_path): + """A disabled profile keeps the flavor Synced but must show on the status.""" clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - conn = mock.MagicMock() - - added = router_flavor_object("crud_svi") - other_names = ["dynamic_vrf", "pa1410", "static_vrf", "svi"] - snapshot_objects = [router_flavor_object(name) for name in other_names] + [added] + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + conn = _neutron_conn() + conn.network.service_profiles.return_value[0].is_enabled = False - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Added", - "object": added, - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": obj} for obj in snapshot_objects - ], - }, - } - ], + code, patch_status = _run_main( + monkeypatch, tmp_path, _schedule_context("pa1410"), conn ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 0 - assert [call.args[1]["name"] for call in mock_sync.call_args_list] == ["crud_svi"] - mock_prune.assert_called_once() - prune_flavors = mock_prune.call_args.args[1] - assert sorted(flavor["name"] for flavor in prune_flavors) == sorted( - other_names + ["crud_svi"] - ) - assert "authoritative_empty_desired" not in mock_prune.call_args.kwargs + assert code == 0 + assert patch_status.call_args.kwargs["sync_status"] == "Synced" + message = patch_status.call_args.kwargs["message"] + assert "is_enabled" in message + assert "profile-id" in message -def test_deleted_event_reconciles_none_and_prunes_with_remaining_snapshot( - monkeypatch, tmp_path -): - """Delete of one CR while others remain in the same credential group. - Regression guard for the exact log scenario: deleting crud_svi while - four remain must not reconcile any of the remaining flavors. Prune - receives the snapshot of the remaining four and does NOT set - authoritative_empty_desired, so it only removes the flavor that is - absent from the snapshot. - """ +def test_main_reports_failure_and_skips_prune(monkeypatch, tmp_path): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") - conn = mock.MagicMock() + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setenv(f"{ENV_PREFIX}_PRUNE", "true") + conn = _neutron_conn() + # An existing flavor whose service_type cannot be changed is a hard failure. + conn.network.flavors.return_value[0].service_type = "WRONG_TYPE" + + with mock.patch.object(hook.prune_module, "prune_removed_flavors") as prune: + code, patch_status = _run_main( + monkeypatch, tmp_path, _schedule_context("pa1410"), conn + ) - deleted = router_flavor_object("crud_svi") - remaining_names = ["dynamic_vrf", "pa1410", "static_vrf", "svi"] - remaining = [router_flavor_object(name) for name in remaining_names] + assert code == 1 + assert patch_status.call_args.kwargs["sync_status"] == "Failed" + assert "service_type" in patch_status.call_args.kwargs["message"] + prune.assert_not_called() - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": deleted, - "snapshots": { - common.CRD_BINDING_NAME: [{"object": obj} for obj in remaining], - }, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() +def test_main_prunes_after_a_successful_reconcile(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setenv(f"{ENV_PREFIX}_PRUNE", "true") + conn = _neutron_conn() + + with mock.patch.object(hook.prune_module, "prune_removed_flavors") as prune: + code, _ = _run_main(monkeypatch, tmp_path, _schedule_context("pa1410"), conn) - assert result == 0 - mock_sync.assert_not_called() - mock_prune.assert_called_once() - prune_flavors = mock_prune.call_args.args[1] - assert sorted(flavor["name"] for flavor in prune_flavors) == sorted(remaining_names) - # authoritative_empty_desired must NOT be set: snapshot still has items. - assert mock_prune.call_args.kwargs.get("authoritative_empty_desired") is not True + assert code == 0 + prune.assert_called_once() + assert [spec["name"] for spec in prune.call_args.args[1]] == ["pa1410"] -def test_modified_event_skipped_when_status_already_current(monkeypatch, tmp_path): - """Status-only Modified events must not trigger OpenStack work. +def test_main_fails_loudly_on_a_cr_missing_cloud_credentials(monkeypatch, tmp_path): + """A CR without credentials must fail the run, not be skipped. - The hook's own status patch surfaces as a Modified event with the same - metadata.generation. If status already reflects that generation as Synced, - the hook must skip both reconcile and prune to break the feedback loop. + The CRD marks cloudCredentialsRef required, so the API server should reject + it first; this guards the case where something bypasses that. """ clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - obj = router_flavor_object_with_status( - "crud_svi", - generation=7, - status={ - "syncStatus": "Synced", - "observedGeneration": 7, - "message": "Successfully reconciled router flavor", - }, - ) - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Modified", - "object": obj, - "snapshots": {common.CRD_BINDING_NAME: [{"object": obj}]}, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection" - ) as mock_connect, - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + obj = router_flavor_object("pa1410") + del obj["spec"]["cloudCredentialsRef"] + contexts = [ + { + "binding": BINDING_NAME, + "type": "Schedule", + "snapshots": {BINDING_NAME: [{"object": obj}]}, + } + ] - assert result == 0 - mock_connect.assert_not_called() - mock_sync.assert_not_called() - mock_prune.assert_not_called() + code, _ = _run_main(monkeypatch, tmp_path, contexts, _neutron_conn()) + assert code == 1 -def test_modified_event_reconciles_when_generation_bumped(monkeypatch, tmp_path): - """A real spec change bumps metadata.generation past observedGeneration. - The status-current guard must not skip these events: the spec is drifted - from what the operator last reconciled, so reconcile must run. - """ +def test_main_uses_the_credentials_named_by_each_cr(monkeypatch, tmp_path): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - conn = mock.MagicMock() - - obj = router_flavor_object_with_status( - "crud_svi", - generation=8, - status={ - "syncStatus": "Synced", - "observedGeneration": 7, - "message": "Successfully reconciled router flavor", - }, - ) - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Modified", - "object": obj, - "snapshots": {common.CRD_BINDING_NAME: [{"object": obj}]}, - } - ], + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setattr(utils, "_connection_cache", {}) + contexts = _schedule_context("pa1410") + contexts[0]["snapshots"][BINDING_NAME][0]["object"]["spec"][ + "cloudCredentialsRef" + ] = {"secretName": "other-secret", "cloudName": "other-cloud"} + monkeypatch.setenv( + "BINDING_CONTEXT_PATH", write_binding_context(tmp_path, contexts) ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + mock.patch( + "openstack_sync.hooks.framework.get_openstack_connection", + return_value=_neutron_conn(), + ) as connect, + mock.patch("openstack_sync.hooks.framework.patch_resource_status"), + mock.patch.object(hook, "wait_for_openstack_network"), ): - result = hook.main() + assert hook.main() == 0 - assert result == 0 - assert [call.args[1]["name"] for call in mock_sync.call_args_list] == ["crud_svi"] + connect.assert_called_once_with("other-secret", "other-cloud") diff --git a/python/openstack-sync/tests/test_router_flavors_prune.py b/python/openstack-sync/tests/test_router_flavors_prune.py deleted file mode 100644 index fa38a3557..000000000 --- a/python/openstack-sync/tests/test_router_flavors_prune.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Tests for Neutron router flavor prune behavior.""" - -from __future__ import annotations - -from types import SimpleNamespace -from typing import Any - -from openstack_sync.plugins.neutron.router_flavors import delete -from openstack_sync.plugins.neutron.router_flavors import ( - router_flavors_common as common, -) - - -def enable_prune(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") - - -def enable_profile_delete(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DELETE_UNUSED_PROFILES", "true") - - -class FakeNetwork: - def __init__(self, flavors: list[dict[str, Any]], profiles: dict[str, Any]): - self._flavors = flavors - self._profiles = profiles - self.deleted_flavors: list[str] = [] - self.flavor_list_calls = 0 - - def flavors(self, service_type: str | None = None) -> list[dict[str, Any]]: - self.flavor_list_calls += 1 - return [ - flavor - for flavor in self._flavors - if service_type is None or flavor["service_type"] == service_type - ] - - def routers(self, flavor_id: str) -> list[dict[str, Any]]: - return [] - - def service_profiles(self) -> list[Any]: - return [p for p in self._profiles.values() if p is not None] - - def get_service_profile(self, profile_id: str) -> Any: - return self._profiles.get(profile_id) - - def delete_flavor( - self, flavor: dict[str, Any], ignore_missing: bool = True - ) -> None: - self.deleted_flavors.append(flavor["id"]) - self._flavors = [ - current for current in self._flavors if current["id"] != flavor["id"] - ] - - -def test_prune_keeps_manual_flavor_with_managed_service_profile(monkeypatch): - enable_prune(monkeypatch) - flavor = { - "id": "manual-flavor-id", - "name": "manual-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": "created outside the operator", - "service_profile_ids": ["managed-profile-id"], - } - profile = SimpleNamespace( - id="managed-profile-id", - driver="neutron_understack.l3_router.vrf.Vrf", - meta_info=common.managed_meta_info({"vni_alloc": "auto"}), - ) - conn = SimpleNamespace(network=FakeNetwork([flavor], {profile.id: profile})) - - delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) - - assert conn.network.deleted_flavors == [] - - -def test_prune_keeps_managed_flavors_when_desired_list_is_empty(monkeypatch): - enable_prune(monkeypatch) - flavor = { - "id": "managed-flavor-id", - "name": "removed-managed-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [], - } - conn = SimpleNamespace(network=FakeNetwork([flavor], {})) - - delete.prune_removed_flavors(conn, []) - - assert conn.network.deleted_flavors == [] - - -def test_prune_deletes_managed_flavors_when_empty_desired_is_explicit(monkeypatch): - enable_prune(monkeypatch) - flavor = { - "id": "managed-flavor-id", - "name": "removed-managed-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [], - } - conn = SimpleNamespace(network=FakeNetwork([flavor], {})) - - delete.prune_removed_flavors(conn, [], authoritative_empty_desired=True) - - assert conn.network.deleted_flavors == ["managed-flavor-id"] - - -def test_prune_deletes_removed_managed_flavor(monkeypatch): - enable_prune(monkeypatch) - flavor = { - "id": "managed-flavor-id", - "name": "removed-managed-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [], - } - conn = SimpleNamespace(network=FakeNetwork([flavor], {})) - - delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) - - assert conn.network.deleted_flavors == ["managed-flavor-id"] - - -def test_prune_deletes_removed_managed_flavor_and_unused_profile(monkeypatch): - enable_prune(monkeypatch) - enable_profile_delete(monkeypatch) - profile = _make_orphan_profile("managed-profile-id") - flavor = { - "id": "managed-flavor-id", - "name": "removed-managed-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [profile.id], - } - network = FakeNetworkWithProfiles([flavor], {profile.id: profile}) - conn = SimpleNamespace(network=network) - - delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) - - assert network.deleted_flavors == ["managed-flavor-id"] - assert network.deleted_profiles == ["managed-profile-id"] - - -# --------------------------------------------------------------------------- -# prune_orphaned_service_profiles: second-pass GC for partial-failure orphans -# --------------------------------------------------------------------------- - - -class FakeNetworkWithProfiles(FakeNetwork): - """FakeNetwork extended to track service profile deletes.""" - - def __init__( - self, - flavors: list[dict[str, Any]], - profiles: dict[str, Any], - ): - super().__init__(flavors, profiles) - self.deleted_profiles: list[str] = [] - - def service_profiles(self) -> list[Any]: - return [p for p in self._profiles.values() if p is not None] - - def delete_service_profile(self, profile: Any, ignore_missing: bool = True) -> None: - profile_id = profile.id if hasattr(profile, "id") else profile["id"] - self.deleted_profiles.append(profile_id) - self._profiles[profile_id] = None - - def get_service_profile(self, profile_id: str) -> Any: - profile = self._profiles.get(profile_id) - if profile is None: - raise Exception(f"Profile {profile_id} not found") - return profile - - -def _make_orphan_profile( - profile_id: str, driver: str = "neutron_understack.l3_router.vrf.Vrf" -): - """Return a SimpleNamespace service profile with operator ownership markers.""" - import types - - return types.SimpleNamespace( - id=profile_id, - driver=driver, - meta_info=common.managed_meta_info({"vni_alloc": "auto"}), - ) - - -def test_prune_orphaned_profiles_deletes_unattached_managed_profile(monkeypatch): - """A managed profile with no parent flavor is deleted by the second pass.""" - enable_prune(monkeypatch) - enable_profile_delete(monkeypatch) - - orphan = _make_orphan_profile("orphan-profile-id") - # No flavors in Neutron; the orphan's parent was already deleted. - network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) - conn = SimpleNamespace(network=network) - - delete.prune_orphaned_service_profiles( - conn, - {}, - delete.service_profile_attachment_counts([]), - ) - - assert "orphan-profile-id" in network.deleted_profiles - - -def test_prune_orphaned_profiles_keeps_non_managed_profile(monkeypatch): - """A profile without the operator ownership marker is not touched.""" - enable_profile_delete(monkeypatch) - import types - - unmanaged = types.SimpleNamespace( - id="unmanaged-profile-id", - driver="neutron_understack.l3_router.vrf.Vrf", - meta_info={"vni_alloc": "auto"}, # no MANAGED_META_INFO_KEY - ) - network = FakeNetworkWithProfiles(flavors=[], profiles={unmanaged.id: unmanaged}) - conn = SimpleNamespace(network=network) - - delete.prune_orphaned_service_profiles( - conn, - {}, - delete.service_profile_attachment_counts([]), - ) - - assert network.deleted_profiles == [] - - -def test_prune_removed_flavors_cleans_up_orphaned_profile_on_next_run(monkeypatch): - """Simulate a partial failure: flavor deleted, profile cleanup threw last run. - - On the next prune_removed_flavors call the flavor no longer exists in - Neutron, so the flavor loop skips it. The second-pass GC should find and - delete the orphaned profile. - """ - enable_prune(monkeypatch) - enable_profile_delete(monkeypatch) - - # Neutron state after the partial failure: flavor is gone, profile remains. - orphan = _make_orphan_profile("orphan-after-partial-failure") - network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) - conn = SimpleNamespace(network=network) - - # desired list is non-empty so the empty-list guard does not fire. - delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) - - assert "orphan-after-partial-failure" in network.deleted_profiles - - -def test_prune_removed_flavors_lists_l3_flavors_once_for_profile_checks(monkeypatch): - enable_prune(monkeypatch) - enable_profile_delete(monkeypatch) - - removed_profile = _make_orphan_profile("removed-profile-id") - orphan_profile = _make_orphan_profile("orphan-profile-id") - attached_profile = _make_orphan_profile("attached-profile-id") - removed_flavor = { - "id": "removed-flavor-id", - "name": "removed-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [removed_profile.id], - } - kept_flavor = { - "id": "kept-flavor-id", - "name": "kept-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [attached_profile.id], - } - network = FakeNetworkWithProfiles( - [removed_flavor, kept_flavor], - { - removed_profile.id: removed_profile, - orphan_profile.id: orphan_profile, - attached_profile.id: attached_profile, - }, - ) - conn = SimpleNamespace(network=network) - - delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) - - assert network.flavor_list_calls == 1 - assert network.deleted_flavors == ["removed-flavor-id"] - assert network.deleted_profiles == ["removed-profile-id", "orphan-profile-id"] diff --git a/python/openstack-sync/tests/test_router_flavors_update.py b/python/openstack-sync/tests/test_router_flavors_update.py deleted file mode 100644 index d6978f15e..000000000 --- a/python/openstack-sync/tests/test_router_flavors_update.py +++ /dev/null @@ -1,377 +0,0 @@ -"""Tests for update.ensure_flavor and update.sync_flavor. - -Covers the service_type guard, is_enabled drift reconcile (both directions), -create-with-is_enabled-from-spec, and the sync_flavor spec pass-through. -""" - -from __future__ import annotations - -import types -from typing import Any -from unittest import mock - -import pytest - -from openstack_sync.plugins.common import ConfigError -from openstack_sync.plugins.neutron.router_flavors import update -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - FLAVOR_DESCRIPTION_MARKER, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - ProfileDrift, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_NAME = "test-flavor" -_SERVICE_TYPE = "L3_ROUTER_NAT" -_DESCRIPTION = "my flavor" - - -def _make_flavor( - *, - name: str = _NAME, - service_type: str = _SERVICE_TYPE, - description: str = f"{_DESCRIPTION} {FLAVOR_DESCRIPTION_MARKER}", - is_enabled: bool = True, -) -> Any: - return types.SimpleNamespace( - name=name, - service_type=service_type, - description=description, - is_enabled=is_enabled, - ) - - -# --------------------------------------------------------------------------- -# service_type mismatch — must raise ConfigError -# --------------------------------------------------------------------------- - - -def test_ensure_flavor_raises_on_service_type_mismatch(): - flavor = _make_flavor(service_type="DIFFERENT_TYPE") - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - with pytest.raises(ConfigError, match="service_type"): - update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True - ) - - -def test_ensure_flavor_error_message_contains_both_service_types(): - flavor = _make_flavor(service_type="WRONG") - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - with pytest.raises(ConfigError) as exc_info: - update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True - ) - msg = str(exc_info.value) - assert "WRONG" in msg - assert _SERVICE_TYPE in msg - assert _NAME in msg - - -# --------------------------------------------------------------------------- -# is_enabled reconcile -# --------------------------------------------------------------------------- - - -def test_ensure_flavor_reenables_disabled_flavor(caplog): - """Neutron has is_enabled=False but spec says True → update to True.""" - flavor = _make_flavor(is_enabled=False) - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - conn.network.update_flavor.return_value = _make_flavor(is_enabled=True) - with caplog.at_level("INFO", logger="openstack_sync"): - update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True - ) - - conn.network.update_flavor.assert_called_once() - _, kwargs = conn.network.update_flavor.call_args - assert kwargs["is_enabled"] is True - assert "is_enabled drift" in caplog.text - assert "have=False" in caplog.text - assert "want=True" in caplog.text - - -def test_ensure_flavor_disables_enabled_flavor_when_spec_disables(caplog): - """Neutron has is_enabled=True but spec says False → update to False.""" - flavor = _make_flavor(is_enabled=True) - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - conn.network.update_flavor.return_value = _make_flavor(is_enabled=False) - with caplog.at_level("INFO", logger="openstack_sync"): - update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False - ) - - conn.network.update_flavor.assert_called_once() - _, kwargs = conn.network.update_flavor.call_args - assert kwargs["is_enabled"] is False - assert "is_enabled drift" in caplog.text - assert "have=True" in caplog.text - assert "want=False" in caplog.text - - -def test_ensure_flavor_no_update_when_both_disabled(): - """Neutron has is_enabled=False and spec says False → no Neutron call.""" - flavor = _make_flavor(is_enabled=False) - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - result = update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False - ) - - conn.network.update_flavor.assert_not_called() - assert result is flavor - - -def test_ensure_flavor_reenables_disabled_flavor_even_when_description_matches(): - """is_enabled=False must trigger an update even if description is current.""" - flavor = _make_flavor(is_enabled=False) - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - conn.network.update_flavor.return_value = _make_flavor(is_enabled=True) - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) - - conn.network.update_flavor.assert_called_once() - - -def test_ensure_flavor_no_update_when_already_correct(): - """No Neutron call when description and is_enabled are already correct.""" - flavor = _make_flavor(is_enabled=True) - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - result = update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True - ) - - conn.network.update_flavor.assert_not_called() - assert result is flavor - - -# --------------------------------------------------------------------------- -# description drift still triggers update -# --------------------------------------------------------------------------- - - -def test_ensure_flavor_updates_changed_description(): - flavor = _make_flavor(description="old description") - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - conn.network.update_flavor.return_value = _make_flavor() - update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, "new description", is_enabled=True - ) - - conn.network.update_flavor.assert_called_once() - - -def test_ensure_flavor_adds_missing_marker(): - flavor = _make_flavor(description="no marker here") - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - conn.network.update_flavor.return_value = _make_flavor() - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) - - conn.network.update_flavor.assert_called_once() - _, kwargs = conn.network.update_flavor.call_args - assert FLAVOR_DESCRIPTION_MARKER in kwargs["description"] - - -# --------------------------------------------------------------------------- -# flavor not found — creates it -# --------------------------------------------------------------------------- - - -def test_ensure_flavor_creates_when_not_found(): - with ( - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=None, - ), - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.create_flavor", - return_value=_make_flavor(), - ) as mock_create, - ): - conn = mock.MagicMock() - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) - - mock_create.assert_called_once_with( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True - ) - - -def test_ensure_flavor_creates_with_is_enabled_from_spec(): - """A CR that opts out of enabled must create the Neutron flavor disabled.""" - with ( - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=None, - ), - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.create_flavor", - return_value=_make_flavor(is_enabled=False), - ) as mock_create, - ): - conn = mock.MagicMock() - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False) - - mock_create.assert_called_once_with( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False - ) - - -# --------------------------------------------------------------------------- -# sync_flavor: reads is_enabled from the CR spec -# --------------------------------------------------------------------------- - - -def _sync_flavor_config(*, is_enabled: bool) -> dict[str, Any]: - """Build a CR-shaped flavor_config. - - ``is_enabled`` mirrors the CRD default (true) that the k8s API server - materialises on admission; every real spec reaching the hook carries it. - """ - return { - "name": _NAME, - "description": _DESCRIPTION, - "service_type": _SERVICE_TYPE, - "is_enabled": is_enabled, - "service_profiles": [ - { - "driver": "neutron_understack.l3_router.vrf.Vrf", - "description": "profile description", - "meta_info": {}, - "is_enabled": True, - } - ], - } - - -def _sync_flavor_mocks(flavor: Any): - """Yield the mock stack used by sync_flavor pass-through tests. - - Uses a real openstacksdk-shaped flavor (SimpleNamespace with - ``service_profile_ids``) so ``render_flavor`` succeeds when - ``sync_flavor`` logs the reconciled result. - """ - rendered = types.SimpleNamespace( - id="flavor-id", - name=_NAME, - service_type=_SERVICE_TYPE, - description=flavor.description, - is_enabled=flavor.is_enabled, - service_profile_ids=["profile-id"], - ) - return ( - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.update.ensure_flavor", - return_value=rendered, - ), - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.ensure_profile" - ), - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create." - "reconcile_flavor_profiles", - return_value=rendered, - ), - ) - - -def test_sync_flavor_passes_is_enabled_true_from_spec(): - """The value the k8s API server put on the CR reaches ensure_flavor.""" - conn = mock.MagicMock() - flavor = _make_flavor(is_enabled=True) - ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) - with ensure_patch as mock_ensure, profile_patch, attached_patch: - update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) - - assert mock_ensure.call_args.kwargs["is_enabled"] is True - - -def test_sync_flavor_passes_is_enabled_false_from_spec(): - conn = mock.MagicMock() - flavor = _make_flavor(is_enabled=False) - ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) - with ensure_patch as mock_ensure, profile_patch, attached_patch: - update.sync_flavor(conn, _sync_flavor_config(is_enabled=False), {}) - - assert mock_ensure.call_args.kwargs["is_enabled"] is False - - -# --------------------------------------------------------------------------- -# sync_flavor: service profile drift reaches the caller -# --------------------------------------------------------------------------- - - -def test_sync_flavor_returns_empty_drift_when_nothing_drifted(): - conn = mock.MagicMock() - flavor = _make_flavor(is_enabled=True) - ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) - with ensure_patch, profile_patch, attached_patch: - result = update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) - - assert result == [] - - -def test_sync_flavor_propagates_profile_drift(): - """Drift collected while resolving profiles is returned to the caller. - - The flavor itself is converged, so this is not a reconcile failure -- but - the caller must be able to qualify the status it reports. - """ - conn = mock.MagicMock() - flavor = _make_flavor(is_enabled=True) - ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) - drifted = ProfileDrift( - profile_id="prof-a", - driver="neutron_understack.l3_router.vrf.Vrf", - field="is_enabled", - have=False, - want=True, - ) - - def ensure_profile(conn, name, profile_spec, profile_cache, drift=None): - if drift is not None: - drift.append(drifted) - return types.SimpleNamespace(id="prof-a") - - with ensure_patch, profile_patch as mock_profile, attached_patch: - mock_profile.side_effect = ensure_profile - result = update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) - - assert result == [drifted]