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..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 @@ -57,7 +57,7 @@ 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 required: - secretName @@ -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 @@ -117,7 +119,7 @@ spec: type: string format: uuid meta_info: - description: Service profile metainfo payload. + description: Service profile metadata payload. type: object properties: resource_class: 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..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: @@ -65,7 +72,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 @@ -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 3b31e71ad..4d8d47a35 100644 --- a/components/openstack-sync-operator/values.schema.json +++ b/components/openstack-sync-operator/values.schema.json @@ -3,12 +3,9 @@ "type": "object", "additionalProperties": true, "properties": { - "hooks": { - "type": "object", - "description": "Additional hook definitions keyed by hook name.", - "additionalProperties": { - "$ref": "#/definitions/hook" - } + "env": { + "description": "Operator-level environment variables injected directly into the container.", + "$ref": "#/definitions/operatorEnv" }, "plugins": { "type": "object", @@ -21,24 +18,44 @@ "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": { + "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, + "properties": { + "hook": { + "$ref": "#/definitions/pluginHook" + } + } + }, + "pluginHook": { "type": "object", "additionalProperties": false, "properties": { - "enabled": { - "type": "boolean" - }, "path": { "type": "string", "minLength": 1 @@ -53,17 +70,34 @@ "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": { + "$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 ab89eed9b..b985e59ff 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 @@ -13,12 +18,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,17 +48,10 @@ pluginData: envPrefix: NEUTRON_ROUTER_FLAVOR env: SYNC_CRONTAB: "0 * * * *" - PRUNE: "false" - DEFAULT_SECRET: infrasetup - DEFAULT_CLOUD: understack - -hooks: {} - -podAnnotations: {} -podLabels: {} - -resources: {} - -nodeSelector: {} -tolerations: [] -affinity: {} + # 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/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..51f987efc --- /dev/null +++ b/python/openstack-sync/openstack_sync/hooks/common.py @@ -0,0 +1,254 @@ +"""Generic shell-operator hook utilities shared across all hooks. + +Provides binding context I/O and status patching via kubectl. +""" + +from __future__ import annotations + +import datetime as dt +import json +import logging +import os +import subprocess +import sys +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("LOG_LEVEL", "info").upper(), + format="%(levelname)s:%(name)s:%(message)s", + stream=sys.stderr, + ) + + +# --------------------------------------------------------------------------- +# 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 _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, + namespace: str | None, + generation: int | None, + sync_status: str, + message: str, + 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. + + 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. + 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 = _desired_condition(sync_status, message) + condition["lastTransitionTime"] = timestamp + status: dict[str, Any] = { + "syncStatus": sync_status, + "lastSyncTime": timestamp, + "message": truncate_message(message), + "conditions": [condition], + } + 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.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.warning("failed to patch %s status for %s: %s", crd_kind, name, error) diff --git a/python/openstack-sync/openstack_sync/hooks/placeholder.py b/python/openstack-sync/openstack_sync/hooks/placeholder.py index 04923e3fb..407291e24 100644 --- a/python/openstack-sync/openstack_sync/hooks/placeholder.py +++ b/python/openstack-sync/openstack_sync/hooks/placeholder.py @@ -11,17 +11,16 @@ 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.utils import get_openstack_connection -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 +LOG = logging.getLogger(__name__) def build_hook_config() -> dict[str, Any]: @@ -36,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. @@ -52,19 +48,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 +65,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 +78,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( + if not env_bool("OPENSTACK_PLACEHOLDER_ENABLED", False): + 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 b8ce1c0bc..95fb275c5 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -4,58 +4,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 ( + 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 ( + PRUNE_REMOVED_FLAVORS, +) +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 -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 - ) +LOG = logging.getLogger(__name__) +CredentialKey = tuple[str, str] # --------------------------------------------------------------------------- -# 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") +@dataclass(frozen=True) +class RouterFlavorResource: + """A single NeutronRouterFlavor CR with its resolved credentials.""" - 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 - - # 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 + current_status: dict[str, Any] | None = None # --------------------------------------------------------------------------- @@ -63,8 +73,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,45 +82,343 @@ def build_hook_config() -> dict[str, object]: }, } - if not env_is_truthy("NEUTRON_ROUTER_FLAVOR_ENABLED"): + if not env_bool("NEUTRON_ROUTER_FLAVOR_ENABLED", False): # 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", + 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, + "apiVersion": CRD_API_VERSION, + "kind": CRD_KIND, "executeHookOnEvent": ["Added", "Modified", "Deleted"], "jqFilter": ".", - "includeSnapshotsFrom": ["neutron-router-flavors"], + "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, } - 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"], - } - ] + if sync_crontab: + hook_config["schedule"] = [ + { + "name": "hourly sync", + "crontab": sync_crontab, + "includeSnapshotsFrom": [CRD_BINDING_NAME], + "queue": CRD_BINDING_NAME, + } + ] return hook_config -HOOK_CONFIG = build_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 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: + 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}") + + 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" + ) + + +# --------------------------------------------------------------------------- +# Status patching +# --------------------------------------------------------------------------- + + +def patch_flavor_status( + resource: RouterFlavorResource, + sync_status: str, + message: str, +) -> None: + if not resource.name: + LOG.warning( + "Unable to patch %s status; Kubernetes metadata.name is missing", + CRD_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=CRD_KIND, + status_enabled=STATUS_ENABLED, + current_status=resource.current_status, + ) # --------------------------------------------------------------------------- -# Entry point +# 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 +) -> None: + sync_flavor(conn, resource.flavor, profile_cache) + + +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) + 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(): + 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: + 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", + "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], + ) + + 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 + + +# --------------------------------------------------------------------------- +# Run loop # --------------------------------------------------------------------------- @@ -119,10 +427,17 @@ 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 - with open(context_path) as f: + + with open(context_path, encoding="utf-8") as f: raw = f.read() if not raw.strip(): return 0 @@ -130,16 +445,20 @@ 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: - binding = context.get("binding", "") - if binding == "neutron-router-flavors": - for item in context.get("objects", []): - reconcile_router_flavor(item) - - return 0 + 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 + ) + return reconcile_router_flavor_resources(resources, deleted_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/__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..4d66c316e --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/common.py @@ -0,0 +1,259 @@ +"""Generic utilities shared across all openstack-sync plugins. + +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. +""" + +from __future__ import annotations + +import json +import logging +import os +import time +from typing import Any + +from openstack import exceptions as openstack_exceptions + +LOG = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Environment helpers +# --------------------------------------------------------------------------- + + +def env_bool(name: str, default: bool) -> bool: + """Return a boolean from an environment variable. + + 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 + 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, ...]: + """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.""" + + +# --------------------------------------------------------------------------- +# 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. + + 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 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 + + +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) + + +# --------------------------------------------------------------------------- +# meta_info helpers +# --------------------------------------------------------------------------- + + +def normalize_meta_info(value: Any) -> Any: + """Normalise a meta_info value into a Python dict (or passthrough). + + 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 {} + + if isinstance(value, str): + text = value.strip() + if not text: + return {} + try: + return json.loads(text) + except json.JSONDecodeError: + 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_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 +# --------------------------------------------------------------------------- + + +def wait_for_openstack_network( + conn: Any, + retries: int = 30, + delay: float = 10.0, +) -> 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. + + 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 + LOG.info("Waiting for Neutron API (%s/%s): %s", 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*. + + 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] 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..90aea7d63 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py @@ -0,0 +1,193 @@ +"""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 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 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 ( + 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: + matching_profiles = [] + 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 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. 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, + 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 + + 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" + ) + + 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) + 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: + # 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) -> Any: + LOG.info("Creating router flavor %s service_type=%s", name, 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", 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: + if not is_conflict(exc): + raise + 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 new file mode 100644 index 000000000..deb95c0c7 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py @@ -0,0 +1,253 @@ +"""Delete/prune logic for removed Neutron router flavors.""" + +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 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, +) + +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"]) + 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) + 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") != 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", 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(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.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 + + 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(conn, 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, + protected_profile_ids: set[str], + profile_cache: dict[str, Any | None], +) -> 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): + return + if is_conflict(exc): + LOG.info( + "Router flavor %s is still in use; skipping delete", + flavor_name, + ) + 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 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, protected_profile_ids, profile_cache + ) + + +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 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) + protected_profile_ids = configured_service_profile_ids(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)): + flavor_name = get_value(flavor, "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..c9f931129 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py @@ -0,0 +1,177 @@ +"""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 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_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 +# --------------------------------------------------------------------------- +# 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") +DEFAULT_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 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")) + + +# --------------------------------------------------------------------------- +# 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 + ) + + +# --------------------------------------------------------------------------- +# 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. + + Uses ``READY_RETRIES`` and ``READY_DELAY`` from this module's env config. + """ + 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 new file mode 100644 index 000000000..aa573fd06 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py @@ -0,0 +1,86 @@ +"""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 ( + 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 ( + 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.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) + 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"), + "name": get_value(flavor, "name"), + "service_type": get_value(flavor, "service_type"), + "description": get_value(flavor, "description"), + "service_profile_ids": service_profile_ids(flavor), + } + + +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: + 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.info("Reconciling router flavor %s", name) + profile = create.ensure_profile( + 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) + 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 new file mode 100644 index 000000000..3b8237cb4 --- /dev/null +++ b/python/openstack-sync/tests/conftest.py @@ -0,0 +1,40 @@ +"""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" + ), +} + +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..2a8d1bf21 --- /dev/null +++ b/python/openstack-sync/tests/test_hook_common.py @@ -0,0 +1,350 @@ +"""Tests for openstack_sync.hooks.common — generic shell-operator utilities.""" + +from __future__ import annotations + +import json +import logging +from unittest import mock + +import pytest + +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 +# --------------------------------------------------------------------------- + + +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..." + + +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 +# --------------------------------------------------------------------------- + + +def test_patch_resource_status_skips_when_disabled(): + 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(): + 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_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) + 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(caplog): + with mock.patch("subprocess.run", side_effect=FileNotFoundError): + 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="" + ) + 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 diff --git a/python/openstack-sync/tests/test_placeholder.py b/python/openstack-sync/tests/test_placeholder.py index 4df7cc7fe..a4105ecb6 100644 --- a/python/openstack-sync/tests/test_placeholder.py +++ b/python/openstack-sync/tests/test_placeholder.py @@ -1,22 +1,144 @@ -"""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"]): 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 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_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}" diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py index c500c90ab..938aa444b 100644 --- a/python/openstack-sync/tests/test_router_flavors.py +++ b/python/openstack-sync/tests/test_router_flavors.py @@ -3,25 +3,14 @@ from __future__ import annotations import json +import logging 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 +23,57 @@ def clear_router_flavor_env(monkeypatch): """ +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": { + router_flavors.CRD_BINDING_NAME: [{"object": obj} for obj in objects], + }, + } + ] + + # --------------------------------------------------------------------------- -# 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.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "false") config = router_flavors.build_hook_config() @@ -49,301 +82,261 @@ def test_router_flavor_hook_config_disabled(monkeypatch): assert "schedule" not in config -def test_router_flavor_hook_config_uses_pod_namespace(monkeypatch): - clear_router_flavor_env(monkeypatch) +def test_router_flavor_hook_config_omits_schedule_without_crontab(monkeypatch): monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.delenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", raising=False) + monkeypatch.delenv("POD_NAMESPACE", raising=False) config = router_flavors.build_hook_config() - kubernetes_binding = config["kubernetes"][0] - assert kubernetes_binding["namespace"] == { - "nameSelector": { - "matchNames": ["openstack"], - }, - } - assert config["schedule"][0]["crontab"] == "0 * * * *" - assert "onStartup" not in config + assert config["kubernetes"][0]["name"] == router_flavors.CRD_BINDING_NAME + assert "schedule" not in config -def test_router_flavor_hook_config_namespace_override(monkeypatch): - clear_router_flavor_env(monkeypatch) +def test_router_flavor_hook_config_omits_schedule_with_empty_crontab(monkeypatch): monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_NAMESPACE", "custom") - monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "") + monkeypatch.delenv("POD_NAMESPACE", raising=False) config = router_flavors.build_hook_config() - kubernetes_binding = config["kubernetes"][0] - assert kubernetes_binding["namespace"]["nameSelector"]["matchNames"] == ["custom"] + assert config["kubernetes"][0]["name"] == router_flavors.CRD_BINDING_NAME + assert "schedule" not in config -def test_router_flavor_hook_config_output_uses_runtime_environment(monkeypatch, capsys): - clear_router_flavor_env(monkeypatch) +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"] == 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 + + +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) - 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) + """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"] == "." -# --------------------------------------------------------------------------- -# k8s.read_secret_key (common module) -# --------------------------------------------------------------------------- - +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") -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") + router_flavors.sys, "argv", ["router_flavors.py", "--config"] ): - with pytest.raises(KeyError): - k8s_module.read_secret_key("infrasetup", "clouds.yaml", "openstack") + 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 * * * *" # --------------------------------------------------------------------------- -# k8s.get_openstack_connection (common module, used by all hooks) +# binding context parsing # --------------------------------------------------------------------------- -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") +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)) - 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", {}) - monkeypatch.setenv("POD_NAMESPACE", "openstack") + resources = router_flavors.load_router_flavor_resources(contexts) - conn_a = mock.MagicMock(name="conn_a") - conn_b = mock.MagicMock(name="conn_b") + assert resources[0].current_status == status - 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 +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.utils.openstack.connection.Connection", - side_effect=[conn_a, conn_b], - ): - 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" - ) + "openstack_sync.hooks.router_flavors.patch_resource_status" + ) as mock_patch: + router_flavors.patch_flavor_status(resource, "Synced", "ok") - assert result_a is conn_a - assert result_b is conn_b + assert mock_patch.call_args.kwargs["current_status"] == status # --------------------------------------------------------------------------- -# reconcile_router_flavor +# reconcile_router_flavor_resources: credential resolution and sync delegation # --------------------------------------------------------------------------- -def test_reconcile_router_flavor_reads_credentials_ref(monkeypatch): - """Hook reads secretName + cloudName from spec.cloudCredentialsRef.""" - monkeypatch.setattr(k8s_module, "_connection_cache", {}) - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - fake_conn = mock.MagicMock() - - event = { - "object": { - "metadata": {"name": "test-flavor"}, - "spec": { - "name": "test-flavor", - "driver": "some.Driver", - "cloudCredentialsRef": { - "secretName": "baremetal-manage", - "cloudName": "understack", +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", + }, }, - }, - } - } - - with mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=fake_conn, + ) + ) + )[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") as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, ): - with mock.patch.object( - k8s_module, "read_secret_key", return_value=FAKE_CLOUDS_YAML - ) as mock_read: - 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") - - -def test_reconcile_router_flavor_raises_when_creds_ref_missing(): - """Missing cloudCredentialsRef raises ValueError.""" - event = { - "object": { - "metadata": {"name": "bad-flavor"}, - "spec": {"name": "bad-flavor", "driver": "some.Driver"}, - } - } - - with pytest.raises(ValueError, match="cloudCredentialsRef"): - router_flavors.reconcile_router_flavor(event) + 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_router_flavor_raises_when_secret_name_missing(): - event = { - "object": { - "metadata": {"name": "bad-flavor"}, - "spec": { - "name": "bad-flavor", - "driver": "some.Driver", - "cloudCredentialsRef": {"cloudName": "understack"}, - }, - } +def test_reconcile_requires_cloudcredentialsref(): + obj = { + "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 pytest.raises( + router_flavors.ConfigError, + match="cloudCredentialsRef is required", + ): + router_flavors.load_router_flavor_resources(_snapshot_context(obj)) -def test_reconcile_router_flavor_raises_when_cloud_name_missing(): - event = { - "object": { - "metadata": {"name": "bad-flavor"}, - "spec": { - "name": "bad-flavor", - "driver": "some.Driver", - "cloudCredentialsRef": {"secretName": "baremetal-manage"}, - }, - } +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(ValueError, match="cloudCredentialsRef"): - router_flavors.reconcile_router_flavor(event) + with pytest.raises( + router_flavors.ConfigError, + match=r"cloudCredentialsRef\.cloudName", + ): + router_flavors.load_router_flavor_resources(_snapshot_context(obj)) # --------------------------------------------------------------------------- -# main() — binding context dispatch +# main(): binding context dispatch # --------------------------------------------------------------------------- -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("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") 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", - }, - }, - } - } - ], - } - ] - ) + 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( - 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() + 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_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("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 new file mode 100644 index 000000000..0817a57bf --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_create.py @@ -0,0 +1,382 @@ +"""Tests for ensure_profile drift detection in create.py.""" + +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, +) + +# --------------------------------------------------------------------------- +# 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=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 _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) + + +# --------------------------------------------------------------------------- +# 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 +# --------------------------------------------------------------------------- + + +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(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) + + 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 + + +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="some.Driver", + description="desc", + meta_info={}, + configured_profile_id="missing-profile", + profile_cache={}, + ) + + 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="", + 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 + + +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 new file mode 100644 index 000000000..518b107c9 --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -0,0 +1,579 @@ +"""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_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", + "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_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") + + 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, + } + ] + + +# --------------------------------------------------------------------------- +# 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") + + 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_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) + + synced = [] + + 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() + + 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_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")}, + ] + }, + } + ], + ) + 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")}, + ] + }, + } + ], + ) + 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_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") + 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, 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() 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..15701184b --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_prune.py @@ -0,0 +1,242 @@ +"""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"]) + self._flavors = [ + current for current in self._flavors if current["id"] != 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_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 = { + "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): + 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 +# --------------------------------------------------------------------------- + + +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