From ca50e18fee592bf17f2deabdbb3956a1178e924a Mon Sep 17 00:00:00 2001 From: haseeb Date: Fri, 14 Aug 2026 21:08:40 +0530 Subject: [PATCH 1/2] CRUD implementation of the router_flavors plugin --- ...ck.rackspace.net_neutronrouterflavors.yaml | 107 +- .../examples/extra-rbac-rules-values.yaml | 39 + .../templates/_crd.tpl | 16 +- .../templates/_helpers.tpl | 5 +- .../templates/deployment.yaml.tpl | 13 +- .../values.schema.json | 86 +- .../openstack-sync-operator/values.yaml | 31 +- .../neutron-router-flavors/dynamic-vrf.yaml | 9 +- .../neutron-router-flavors/pa1410.yaml | 9 +- .../neutron-router-flavors/static-vrf.yaml | 9 +- .../neutron-router-flavors/svi.yaml | 6 +- python/openstack-sync/README.md | 6 +- .../openstack_sync/hooks/common.py | 254 ++++ .../openstack_sync/hooks/placeholder.py | 42 +- .../openstack_sync/hooks/router_flavors.py | 676 +++++++++-- .../openstack_sync/plugins/__init__.py | 1 + .../openstack_sync/plugins/common.py | 297 +++++ .../plugins/neutron/__init__.py | 1 + .../neutron/router_flavors/__init__.py | 1 + .../plugins/neutron/router_flavors/create.py | 350 ++++++ .../plugins/neutron/router_flavors/delete.py | 273 +++++ .../router_flavors/router_flavors_common.py | 283 +++++ .../plugins/neutron/router_flavors/update.py | 143 +++ python/openstack-sync/tests/conftest.py | 31 + .../openstack-sync/tests/test_hook_common.py | 350 ++++++ .../openstack-sync/tests/test_placeholder.py | 126 +- .../tests/test_plugins_common.py | 111 ++ .../tests/test_router_flavors.py | 434 +++---- .../tests/test_router_flavors_create.py | 642 ++++++++++ .../tests/test_router_flavors_hook.py | 1065 +++++++++++++++++ .../tests/test_router_flavors_prune.py | 285 +++++ .../tests/test_router_flavors_update.py | 377 ++++++ .../neutron-router-flavor.schema.json | 59 +- 33 files changed, 5700 insertions(+), 437 deletions(-) create mode 100644 components/openstack-sync-operator/examples/extra-rbac-rules-values.yaml create mode 100644 python/openstack-sync/openstack_sync/hooks/common.py create mode 100644 python/openstack-sync/openstack_sync/plugins/__init__.py create mode 100644 python/openstack-sync/openstack_sync/plugins/common.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/__init__.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/__init__.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py create mode 100644 python/openstack-sync/tests/conftest.py create mode 100644 python/openstack-sync/tests/test_hook_common.py create mode 100644 python/openstack-sync/tests/test_plugins_common.py create mode 100644 python/openstack-sync/tests/test_router_flavors_create.py create mode 100644 python/openstack-sync/tests/test_router_flavors_hook.py create mode 100644 python/openstack-sync/tests/test_router_flavors_prune.py create mode 100644 python/openstack-sync/tests/test_router_flavors_update.py diff --git a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml index b41887e83..52112ed38 100644 --- a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml +++ b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml @@ -24,9 +24,9 @@ spec: - name: ServiceType type: string jsonPath: .spec.service_type - - name: Driver - type: string - jsonPath: .spec.driver + - name: Enabled + type: boolean + jsonPath: .spec.is_enabled - name: SyncStatus type: string jsonPath: .status.syncStatus @@ -35,7 +35,13 @@ spec: jsonPath: .metadata.creationTimestamp schema: openAPIV3Schema: - description: NeutronRouterFlavor defines one Neutron router flavor and its service profile. + description: >- + NeutronRouterFlavor defines one Neutron router flavor and the + service profiles bound to it. Neutron supports many-to-one from + flavor to service_profile, so ``spec.service_profiles`` is a + list of profile specs. The operator find-or-creates each profile + by ``(driver, meta_info)`` and reconciles the flavor's set of + bound profiles to match. type: object required: - spec @@ -50,14 +56,14 @@ spec: type: object required: - name - - driver + - service_profiles - cloudCredentialsRef properties: cloudCredentialsRef: 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,13 +91,22 @@ 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 minLength: 1 maxLength: 255 default: L3_ROUTER_NAT + is_enabled: + description: >- + Whether the Neutron router flavor is enabled. The operator + reconciles drift toward this value, so setting it to false + disables an operator-managed flavor without deleting it. + type: boolean + default: true service_provider: description: Optional Neutron service provider name used when generating Neutron configuration. type: string @@ -102,37 +117,53 @@ spec: description: Description stored on the Neutron router flavor. type: string maxLength: 1024 - driver: - description: Service profile driver class. - type: string - minLength: 1 - maxLength: 1024 - pattern: ^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$ - profile_description: - description: Description stored on the Neutron service profile. - type: string - maxLength: 1024 - profile_id: - description: Existing Neutron service profile ID to attach instead of creating or discovering one. - type: string - format: uuid - meta_info: - description: Service profile metainfo payload. - type: object - properties: - resource_class: - description: Resource class consumed by a physical router provider. - type: string - minLength: 1 - maxLength: 255 - pattern: ^[A-Za-z0-9._:-]+$ - vni_alloc: - description: VNI allocation mode for VRF router providers. - type: string - enum: - - "off" - - "on" - - auto + service_profiles: + description: >- + Service profiles to associate with this flavor. Neutron + supports multiple profiles per flavor and the operator + reconciles the full set: profiles listed here are + find-or-created and associated, and any operator-managed + profile currently attached to the flavor but absent from + this list is disassociated. Unmanaged profiles attached + out-of-band are left untouched. + type: array + minItems: 1 + items: + type: object + required: + - driver + properties: + driver: + description: Service profile driver class. + type: string + minLength: 1 + maxLength: 1024 + pattern: ^[A-Za-z_][A-Za-z0-9_]*(\.[A-Za-z_][A-Za-z0-9_]*)+$ + description: + description: Description stored on the Neutron service profile. + type: string + maxLength: 1024 + is_enabled: + description: Whether the service profile is enabled in Neutron. + type: boolean + default: true + meta_info: + description: Service profile metadata payload. + type: object + properties: + resource_class: + description: Resource class consumed by a physical router provider. + type: string + minLength: 1 + maxLength: 255 + pattern: ^[A-Za-z0-9._:-]+$ + vni_alloc: + description: VNI allocation mode for VRF router providers. + type: string + enum: + - "off" + - "on" + - auto status: description: NeutronRouterFlavorStatus defines the observed sync state. type: object diff --git a/components/openstack-sync-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/components/openstack-sync-plugins/neutron-router-flavors/dynamic-vrf.yaml b/components/openstack-sync-plugins/neutron-router-flavors/dynamic-vrf.yaml index ac5bff691..ff14c3dc1 100644 --- a/components/openstack-sync-plugins/neutron-router-flavors/dynamic-vrf.yaml +++ b/components/openstack-sync-plugins/neutron-router-flavors/dynamic-vrf.yaml @@ -15,7 +15,8 @@ spec: name: dynamic_vrf service_type: L3_ROUTER_NAT description: Dynamic Fabric VRF (auto VNI) - driver: neutron_understack.l3_router.vrf.Vrf - profile_description: Dynamic Fabric VRF (auto VNI) - meta_info: - vni_alloc: auto + service_profiles: + - driver: neutron_understack.l3_router.vrf.Vrf + description: Dynamic Fabric VRF (auto VNI) + meta_info: + vni_alloc: auto diff --git a/components/openstack-sync-plugins/neutron-router-flavors/pa1410.yaml b/components/openstack-sync-plugins/neutron-router-flavors/pa1410.yaml index e71c9391c..62b36ab41 100644 --- a/components/openstack-sync-plugins/neutron-router-flavors/pa1410.yaml +++ b/components/openstack-sync-plugins/neutron-router-flavors/pa1410.yaml @@ -15,7 +15,8 @@ spec: name: pa1410 service_type: L3_ROUTER_NAT description: Physical PA 1410 - driver: neutron_understack.l3_router.palo_alto.PaloAlto - profile_description: Physical PA 1410 - meta_info: - resource_class: pa1410 + service_profiles: + - driver: neutron_understack.l3_router.palo_alto.PaloAlto + description: Physical PA 1410 + meta_info: + resource_class: pa1410 diff --git a/components/openstack-sync-plugins/neutron-router-flavors/static-vrf.yaml b/components/openstack-sync-plugins/neutron-router-flavors/static-vrf.yaml index 16ea464db..af1f5a563 100644 --- a/components/openstack-sync-plugins/neutron-router-flavors/static-vrf.yaml +++ b/components/openstack-sync-plugins/neutron-router-flavors/static-vrf.yaml @@ -15,7 +15,8 @@ spec: name: static_vrf service_type: L3_ROUTER_NAT description: Static Fabric VRF (admin supplied VNI) - driver: neutron_understack.l3_router.vrf.Vrf - profile_description: Static Fabric VRF (admin supplied VNI) - meta_info: - vni_alloc: "on" + service_profiles: + - driver: neutron_understack.l3_router.vrf.Vrf + description: Static Fabric VRF (admin supplied VNI) + meta_info: + vni_alloc: "on" diff --git a/components/openstack-sync-plugins/neutron-router-flavors/svi.yaml b/components/openstack-sync-plugins/neutron-router-flavors/svi.yaml index 6451b4ba2..dff04bcb3 100644 --- a/components/openstack-sync-plugins/neutron-router-flavors/svi.yaml +++ b/components/openstack-sync-plugins/neutron-router-flavors/svi.yaml @@ -15,6 +15,6 @@ spec: name: svi service_type: L3_ROUTER_NAT description: On-Fabric SVI Gateways - driver: neutron_understack.l3_router.svi.Svi - profile_description: On-Fabric SVI Gateways - meta_info: {} + service_profiles: + - driver: neutron_understack.l3_router.svi.Svi + description: On-Fabric SVI Gateways diff --git a/python/openstack-sync/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..ccf2e670a 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -4,58 +4,84 @@ from __future__ import annotations import json +import logging import os import sys +from dataclasses import dataclass from typing import Any +from openstack_sync.hooks.common import configure_logging +from openstack_sync.hooks.common import int_or_none +from openstack_sync.hooks.common import patch_resource_status +from openstack_sync.hooks.common import read_binding_context +from openstack_sync.hooks.common import snapshot_items +from openstack_sync.hooks.common import string_or_none +from openstack_sync.hooks.common import synchronization_items +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import env_bool +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.neutron.router_flavors.create import ServiceProfileCache +from openstack_sync.plugins.neutron.router_flavors.delete import prune_removed_flavors +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + ProfileDrift, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + crd_api_version, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + crd_binding_name, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import crd_kind +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + crd_namespace, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + crd_resource, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + describe_profile_drift, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + prune_removed_flavors_enabled, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + status_enabled, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + wait_for_openstack_network, +) +from openstack_sync.plugins.neutron.router_flavors.update import sync_flavor from openstack_sync.utils import get_openstack_connection -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", {}) +@dataclass(frozen=True) +class RouterFlavorResource: + """A single NeutronRouterFlavor CR with its resolved credentials.""" - creds_ref = spec.get("cloudCredentialsRef", {}) - secret_name = creds_ref.get("secretName") - cloud_name = creds_ref.get("cloudName") + 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 - if not secret_name or not cloud_name: - raise ValueError( - f"NeutronRouterFlavor {obj.get('metadata', {}).get('name')!r} " - "is missing spec.cloudCredentialsRef.secretName or .cloudName" - ) - conn = get_openstack_connection(secret_name, cloud_name) # noqa: F841 +@dataclass(frozen=True) +class RouterFlavorHookInputs: + """Parsed shell-operator context split by reconciliation purpose.""" - # 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. + resources_to_reconcile: list[RouterFlavorResource] + desired_resources_for_prune: list[RouterFlavorResource] + deleted_resources: list[RouterFlavorResource] + prune_credentials: frozenset[CredentialKey] # --------------------------------------------------------------------------- @@ -63,8 +89,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 +98,544 @@ 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") + binding_name = crd_binding_name() + kubernetes_binding: dict[str, Any] = { + "name": binding_name, + "apiVersion": crd_api_version(), + "kind": crd_kind(), "executeHookOnEvent": ["Added", "Modified", "Deleted"], "jqFilter": ".", - "includeSnapshotsFrom": ["neutron-router-flavors"], + "includeSnapshotsFrom": [binding_name], + # Dedicated queue so a slow Neutron readiness wait or reconciliation + # only delays this hook's own tasks, not other hooks sharing the + # default "main" queue. + "queue": binding_name, } - 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": [binding_name], + "queue": 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 _credentials_for_resources( + resources: list[RouterFlavorResource], +) -> frozenset[CredentialKey]: + return frozenset( + (resource.secret_name, resource.cloud_name) for resource in resources + ) + + +def _router_flavor_event_watch_events( + contexts: list[dict[str, Any]], +) -> frozenset[str] | None: + binding_name = crd_binding_name() + watch_events: set[str] = set() + for context in contexts: + if context.get("binding") != binding_name or context.get("type") != "Event": + continue + watch_event = context.get("watchEvent") + if not isinstance(watch_event, str) or not watch_event: + raise ConfigError( + f"{binding_name} event watchEvent must be a non-empty string" + ) + watch_events.add(watch_event) + return frozenset(watch_events) if watch_events else None + + +def _modified_event_status_is_current(resource: RouterFlavorResource) -> bool: + status = resource.current_status + return ( + resource.generation is not None + and status is not None + and status.get("syncStatus") == "Synced" + and status.get("observedGeneration") == resource.generation + ) + + +def changed_router_flavor_resources_from_binding_context( + contexts: list[dict[str, Any]], +) -> list[RouterFlavorResource] | None: + binding_name = crd_binding_name() + resources: list[RouterFlavorResource] = [] + saw_event = False + for index, context in enumerate(contexts): + if context.get("binding") != binding_name or context.get("type") != "Event": + continue + + saw_event = True + watch_event = context.get("watchEvent") + if watch_event == "Deleted": + continue + if watch_event not in {"Added", "Modified"}: + raise ConfigError( + f"{binding_name} event watchEvent must be Added, Modified, or Deleted" + ) + + obj = context.get("object") + if not obj: + raise ConfigError( + f"{watch_event} event {binding_name}[{index}] object is required" + ) + resource = _resource_from_object( + obj, + f"{watch_event} event {binding_name}[{index}]", + ) + if watch_event == "Modified" and _modified_event_status_is_current(resource): + LOG.info( + "Skipping router flavor %s Modified event; generation %s is already " + "Synced", + _resource_display_name(resource), + resource.generation, + ) + continue + resources.append(resource) + + if not saw_event: + return None + return sorted(resources, key=lambda r: str(r.flavor.get("name", ""))) + + +def deleted_router_flavor_resources_from_binding_context( + contexts: list[dict[str, Any]], +) -> list[RouterFlavorResource]: + binding_name = crd_binding_name() + resources: list[RouterFlavorResource] = [] + for index, context in enumerate(contexts): + if ( + context.get("binding") != binding_name + or context.get("type") != "Event" + or context.get("watchEvent") != "Deleted" + ): + continue + obj = context.get("object") + if not obj: + LOG.warning( + "Deleted %s event has no object; cannot use it for prune credentials", + crd_kind(), + ) + continue + resources.append( + _resource_from_object( + obj, + f"Deleted event {binding_name}[{index}]", + ) + ) + + return resources + + +def router_flavor_resources_from_binding_context( + contexts: list[dict[str, Any]], +) -> list[RouterFlavorResource] | None: + binding_name = crd_binding_name() + items = snapshot_items(contexts, binding_name) + if items is not None: + return _resources_from_items(items, f"Snapshot {binding_name}") + + items = synchronization_items(contexts, binding_name) + if items is not None: + return _resources_from_items(items, f"Synchronization {binding_name}") + + return None + + +def router_flavor_hook_inputs_from_binding_context( + contexts: list[dict[str, Any]], +) -> RouterFlavorHookInputs | None: + binding_name = crd_binding_name() + event_watch_events = _router_flavor_event_watch_events(contexts) + changed_resources = changed_router_flavor_resources_from_binding_context(contexts) + deleted_resources = deleted_router_flavor_resources_from_binding_context(contexts) + + if event_watch_events is not None: + items = snapshot_items(contexts, binding_name) + if items is None: + raise ConfigError( + f"Shell-operator {binding_name} event context does not contain " + f"{binding_name} snapshot objects" + ) + desired_resources = _resources_from_items(items, f"Snapshot {binding_name}") + if changed_resources or deleted_resources or "Deleted" in event_watch_events: + prune_credentials = _credentials_for_resources( + desired_resources + ) | _credentials_for_resources(deleted_resources) + else: + prune_credentials = frozenset() + return RouterFlavorHookInputs( + resources_to_reconcile=changed_resources or [], + desired_resources_for_prune=desired_resources, + deleted_resources=deleted_resources, + prune_credentials=prune_credentials, + ) + + resources = router_flavor_resources_from_binding_context(contexts) + if resources is not None: + return RouterFlavorHookInputs( + resources_to_reconcile=resources, + desired_resources_for_prune=resources, + deleted_resources=[], + prune_credentials=_credentials_for_resources(resources), + ) + + return None + + +def load_router_flavor_hook_inputs( + contexts: list[dict[str, Any]] | None = None, +) -> RouterFlavorHookInputs: + if contexts is None: + contexts = read_binding_context() + if not contexts: + raise ConfigError( + f"Shell-operator binding context is required to load {crd_kind()} objects" + ) + + hook_inputs = router_flavor_hook_inputs_from_binding_context(contexts) + if hook_inputs is not None: + return hook_inputs + + raise ConfigError( + f"Shell-operator binding context does not contain " + f"{crd_binding_name()} event, snapshot, or synchronization objects" + ) + + +# --------------------------------------------------------------------------- +# Status patching +# --------------------------------------------------------------------------- + + +def patch_flavor_status( + resource: RouterFlavorResource, + sync_status: str, + message: str, +) -> None: + kind = crd_kind() + if not resource.name: + LOG.warning( + "Unable to patch %s status; Kubernetes metadata.name is missing", + kind, + ) + return + patch_resource_status( + name=resource.name, + namespace=resource.namespace or crd_namespace(), + generation=resource.generation, + sync_status=sync_status, + message=message, + crd_resource=crd_resource(), + crd_kind=kind, + status_enabled=status_enabled(), + current_status=resource.current_status, + ) + + +# --------------------------------------------------------------------------- +# Reconciliation +# --------------------------------------------------------------------------- + + +def _resource_display_name(resource: RouterFlavorResource) -> str: + return str(get_value(resource.flavor, "name", default=resource.name or "")) + + +def _resources_by_credentials( + resources: list[RouterFlavorResource], +) -> dict[CredentialKey, list[RouterFlavorResource]]: + grouped: dict[CredentialKey, list[RouterFlavorResource]] = {} + for resource in resources: + key = (resource.secret_name, resource.cloud_name) + grouped.setdefault(key, []).append(resource) + return grouped + + +def _mark_resources_failed( + resources: list[RouterFlavorResource], + message: str, +) -> None: + for resource in resources: + patch_flavor_status(resource, "Failed", message) + + +def reconcile_router_flavor_resource( + conn: Any, resource: RouterFlavorResource, profile_cache: ServiceProfileCache +) -> list[ProfileDrift]: + return sync_flavor(conn, resource.flavor, profile_cache) + + +def _synced_status_message(drift: list[ProfileDrift]) -> str: + """Return the Synced status message, qualified by any unfixable drift. + + The flavor really is converged, so the status stays Synced; but reporting a + bare success while a reused service profile diverges from the spec is how a + disabled profile stays invisible until every router create against the + flavor fails. + """ + message = "Successfully reconciled router flavor" + if not drift: + return message + return ( + f"{message}; service profile drift requires manual action: " + f"{describe_profile_drift(drift)}" + ) + + +def reconcile_router_flavor_resources( + resources: list[RouterFlavorResource], + deleted_resources: list[RouterFlavorResource] | None = None, + prune_resources: list[RouterFlavorResource] | None = None, + prune_credentials: frozenset[CredentialKey] | None = None, +) -> int: + deleted_resources = deleted_resources or [] + prune_resources = resources if prune_resources is None else prune_resources + flavors = [resource.flavor for resource in resources] + LOG.info("Found %s router flavor(s) to reconcile", len(flavors)) + + grouped_resources = _resources_by_credentials(resources) + grouped_prune_resources = _resources_by_credentials(prune_resources) + deleted_resources_by_credentials = _resources_by_credentials(deleted_resources) + if prune_credentials is None: + prune_credentials = frozenset(grouped_resources) + connections: dict[CredentialKey, Any] = {} + failed_resources: list[RouterFlavorResource] = [] + + for credentials in sorted(grouped_resources): + credential_resources = grouped_resources[credentials] + secret_name, cloud_name = credentials + try: + conn = get_openstack_connection(secret_name, cloud_name) + except Exception as exc: # noqa: BLE001 + failed_resources.extend(credential_resources) + message = f"OpenStack connection failed: {exc}" + _mark_resources_failed(credential_resources, message) + LOG.error( + "Failed to connect to OpenStack cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + + connections[credentials] = conn + try: + wait_for_openstack_network(conn) + except Exception as exc: # noqa: BLE001 + failed_resources.extend(credential_resources) + _mark_resources_failed( + credential_resources, + f"Neutron API unavailable: {exc}", + ) + LOG.error( + "Neutron API unavailable for cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + + # Fetched lazily by driver once per credential group. ensure_profile() + # appends newly created profiles into the same driver cache entry so a + # later flavor with an identical meta_info spec reuses it. + profile_cache: ServiceProfileCache = {} + + for resource in credential_resources: + try: + drift = reconcile_router_flavor_resource(conn, resource, profile_cache) + except Exception as exc: # noqa: BLE001 + failed_resources.append(resource) + patch_flavor_status(resource, "Failed", str(exc)) + LOG.error( + "Failed to reconcile router flavor %s: %s", + _resource_display_name(resource), + exc, + ) + continue + + patch_flavor_status( + resource, + "Synced", + _synced_status_message(drift), + ) + + if failed_resources: + LOG.error( + "Skipping router flavor prune because %s flavor(s) failed to reconcile", + len(failed_resources), + ) + return 1 + + prune_failed = False + for credentials in sorted(prune_credentials): + secret_name, cloud_name = credentials + desired_resources = grouped_prune_resources.get(credentials, []) + authoritative_empty_desired = ( + credentials in deleted_resources_by_credentials and not desired_resources + ) + if not desired_resources and not authoritative_empty_desired: + LOG.info( + "Skipping router flavor prune for cloud=%r secret=%r; no desired " + "router flavors are available", + cloud_name, + secret_name, + ) + continue + + conn = connections.get(credentials) + if conn is None: + if not prune_removed_flavors_enabled(): + continue + try: + conn = get_openstack_connection(secret_name, cloud_name) + except Exception as exc: # noqa: BLE001 + prune_failed = True + LOG.error( + "Failed to connect to OpenStack for router flavor prune " + "cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + try: + wait_for_openstack_network(conn) + except Exception as exc: # noqa: BLE001 + prune_failed = True + LOG.error( + "Neutron API unavailable for router flavor prune " + "cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + connections[credentials] = conn + + try: + desired_flavors = [resource.flavor for resource in desired_resources] + if authoritative_empty_desired: + prune_removed_flavors( + conn, + desired_flavors, + authoritative_empty_desired=True, + ) + else: + prune_removed_flavors(conn, desired_flavors) + except Exception as exc: # noqa: BLE001 + prune_failed = True + LOG.error( + "Failed to prune router flavors cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + + if prune_failed: + return 1 + + if ( + not prune_credentials + and not grouped_resources + and not deleted_resources_by_credentials + ): + LOG.info( + "Skipping router flavor prune; no router flavor credentials are available" + ) + + LOG.info("Finished reconciling router flavors") + return 0 # --------------------------------------------------------------------------- -# Entry point +# Run loop # --------------------------------------------------------------------------- @@ -119,10 +644,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 +662,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: - 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") + hook_inputs = load_router_flavor_hook_inputs(binding_contexts) + return reconcile_router_flavor_resources( + hook_inputs.resources_to_reconcile, + hook_inputs.deleted_resources, + hook_inputs.desired_resources_for_prune, + hook_inputs.prune_credentials, + ) + except Exception as exc: # noqa: BLE001 + LOG.error("%s", exc) + return 1 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..39f02c98b --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/common.py @@ -0,0 +1,297 @@ +"""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_int(name: str, default: int) -> int: + """Return an integer from an environment variable.""" + value = os.environ.get(name) + if value is None: + return default + try: + return int(value) + except ValueError as exc: + raise ConfigError(f"{name} must be an integer") from exc + + +def env_float(name: str, default: float) -> float: + """Return a float from an environment variable.""" + value = os.environ.get(name) + if value is None: + return default + try: + return float(value) + except ValueError as exc: + raise ConfigError(f"{name} must be a number") from exc + + +def env_required(name: str) -> str: + """Return the value of a required environment variable. + + Raises :exc:`ConfigError` when the variable is absent or empty. Use + this for values that must be present at runtime but must not be read at + import time (e.g. CRD identity vars injected by the Helm chart). + """ + value = os.environ.get(name) + if not value: + raise ConfigError( + f"{name} is required but not set; " + "ensure the Helm chart has injected it before the hook runs" + ) + return value + + +def env_tuple(name: str, default: str) -> tuple[str, ...]: + """Return a tuple of strings parsed from a comma-separated env variable.""" + return tuple( + 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..3affa683e --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py @@ -0,0 +1,350 @@ +"""Create helpers for Neutron router flavors and service profiles.""" + +from __future__ import annotations + +import logging +from typing import Any + +from openstack_sync.plugins.common import get_service_profile +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import is_conflict +from openstack_sync.plugins.common import is_not_found +from openstack_sync.plugins.common import meta_info_payload +from openstack_sync.plugins.common import resource_id +from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + ProfileDrift, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + is_managed_service_profile, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + managed_flavor_description, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + managed_meta_info, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + meta_info_matches, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + service_profile_meta_info, +) + +LOG = logging.getLogger(__name__) +ServiceProfileCache = dict[str, list[Any]] + + +def list_service_profiles(conn: Any, driver: str) -> list[Any]: + """Fetch service profiles for a single driver from Neutron.""" + return list(conn.network.service_profiles(driver=driver)) + + +def service_profiles_for_driver( + conn: Any, driver: str, profile_cache: ServiceProfileCache +) -> list[Any]: + """Return a credential-group cache entry for service profiles by driver.""" + if driver not in profile_cache: + profile_cache[driver] = list_service_profiles(conn, driver) + return profile_cache[driver] + + +def find_matching_profile(profiles: list[Any], meta_info: Any) -> Any | None: + """Return the operator-managed profile matching *meta_info*, if any. + + Only operator-owned profiles are reuse candidates. Reusing a profile the + operator does not own would bind it to the flavor, and + ``reconcile_flavor_profiles`` unbinds only profiles carrying the ownership + marker -- so the operator would have created a binding it can never remove. + That binding outlives the spec that created it, and Neutron's + ``get_flavor_next_provider`` selects an arbitrary binding (``objs[0]``), so a + stale one can end up serving routers with the wrong ``meta_info``. + + An unowned profile that happens to match is therefore left completely + untouched -- not adopted by stamping the ownership marker onto it, which + would enrol somebody else's profile into ``prune_orphaned_service_profiles`` + for eventual deletion -- and ``ensure_profile`` creates a dedicated managed + profile alongside it. + """ + unowned_matches: list[str] = [] + for profile in profiles: + if not meta_info_matches(service_profile_meta_info(profile), meta_info): + continue + if is_managed_service_profile(profile): + return profile + unowned_matches.append(str(get_value(profile, "id", default=""))) + + if unowned_matches: + LOG.info( + "Not reusing service profile(s) %s: they match the desired meta_info " + "but are not operator-owned, and the operator only binds profiles it " + "can unbind again; creating a dedicated managed profile instead", + sorted(unowned_matches), + ) + + return None + + +def _collect_profile_drift( + profile: Any, + profile_id: str, + driver: str, + flavor_name: str, + *, + description: str, + is_enabled: bool, +) -> list[ProfileDrift]: + """Return the spec fields on a reused *profile* that Neutron disagrees on. + + ``meta_info`` is excluded by construction -- the profile was selected by + matching it -- and ``driver`` is excluded because profiles are queried per + driver. That leaves ``is_enabled`` and ``description``. + + ``is_enabled`` is the consequential one: Neutron's + ``get_flavor_next_provider`` raises ``ServiceProfileDisabled`` (HTTP 503) + when the selected profile is disabled, so every router create against the + flavor fails while the flavor itself still looks healthy. + """ + drifted: list[ProfileDrift] = [] + + current_is_enabled = bool(get_value(profile, "is_enabled", default=True)) + if current_is_enabled != bool(is_enabled): + drifted.append( + ProfileDrift( + profile_id=profile_id, + driver=driver, + field="is_enabled", + have=current_is_enabled, + want=bool(is_enabled), + ) + ) + + current_description = str(get_value(profile, "description", default="")) + if current_description != str(description): + drifted.append( + ProfileDrift( + profile_id=profile_id, + driver=driver, + field="description", + have=current_description, + want=str(description), + ) + ) + + for item in drifted: + LOG.warning( + "Service profile %s reused by router flavor %s has drifted from the " + "CR spec (%s). Neutron rejects updates to a profile bound to any " + "flavor, so the operator cannot correct this; unbind the profile " + "from every flavor to update it, or delete it and let the operator " + "recreate it", + profile_id, + flavor_name, + item.describe(), + ) + + return drifted + + +def ensure_profile( + conn: Any, + flavor_name: str, + profile_spec: dict[str, Any], + profile_cache: ServiceProfileCache, + drift: list[ProfileDrift] | None = None, +) -> Any: + """Find or create a service profile matching *profile_spec*. + + The CR schema guarantees ``driver`` is present and ``is_enabled`` carries + the CRD default (true). ``description`` and ``meta_info`` are optional in + the schema; missing values fall back to empty. + + Only operator-owned profiles are reused (see ``find_matching_profile``). + When a reused profile has drifted from the spec, each drifted field is + logged and appended to *drift* if a list was supplied, so the caller can + surface it on the CR status rather than reporting an unqualified success. + Drift is only ever detected here, because this is the only place that holds + the desired value from the CR spec. + """ + driver = profile_spec["driver"] + description = profile_spec.get("description", "") + meta_info = profile_spec.get("meta_info", {}) + is_enabled = profile_spec["is_enabled"] + + profiles = service_profiles_for_driver(conn, driver, profile_cache) + profile = find_matching_profile(profiles, meta_info) + if profile: + profile_id = resource_id(profile) + LOG.info( + "Reusing service profile %s for %s driver=%s", + profile_id, + flavor_name, + driver, + ) + drifted = _collect_profile_drift( + profile, + profile_id, + driver, + flavor_name, + description=description, + is_enabled=is_enabled, + ) + if drift is not None: + drift.extend(drifted) + return profile + + LOG.info( + "Creating service profile for %s driver=%s is_enabled=%s", + flavor_name, + driver, + is_enabled, + ) + new_profile = conn.network.create_service_profile( + description=description, + driver=driver, + meta_info=meta_info_payload(managed_meta_info(meta_info)), + is_enabled=is_enabled, + ) + # Make the new profile visible to any later flavor in this same run that + # has an identical (driver, meta_info) spec, so it gets reused instead of + # creating a duplicate profile. + profiles.append(new_profile) + return new_profile + + +def find_flavor(conn: Any, name: str) -> Any | None: + # The SDK passes name= as a server-side query parameter (?name=), + # which Neutron filters in SQL, so at most one record is returned. The + # equality check guards against a future change to substring/LIKE semantics. + for flavor in conn.network.flavors(name=name): + if get_value(flavor, "name") == name: + return flavor + return None + + +def create_flavor( + conn: Any, + name: str, + service_type: str, + description: str, + *, + is_enabled: bool, +) -> Any: + LOG.info( + "Creating router flavor %s service_type=%s is_enabled=%s", + name, + service_type, + is_enabled, + ) + return conn.network.create_flavor( + name=name, + service_type=service_type, + is_enabled=is_enabled, + description=managed_flavor_description(description), + ) + + +def _associate_profile(conn: Any, flavor: Any, profile: Any) -> None: + """Associate *profile* with *flavor*, treating a 409 as already-associated.""" + flavor_id = resource_id(flavor) + profile_id = resource_id(profile) + LOG.info("Binding service profile %s to router flavor %s", profile_id, flavor_id) + try: + conn.network.associate_flavor_with_service_profile(flavor, profile) + except Exception as exc: # noqa: BLE001 + if not is_conflict(exc): + raise + LOG.info( + "Router flavor %s already has service profile %s", + flavor_id, + profile_id, + ) + + +def _disassociate_profile(conn: Any, flavor: Any, profile: Any) -> None: + """Disassociate *profile* from *flavor*, tolerating not-found/conflict.""" + flavor_id = resource_id(flavor) + profile_id = resource_id(profile) + LOG.info( + "Unbinding operator-managed service profile %s from router flavor %s", + profile_id, + flavor_id, + ) + try: + conn.network.disassociate_flavor_from_service_profile(flavor, profile) + except Exception as exc: # noqa: BLE001 + if is_not_found(exc): + LOG.info( + "Service profile %s already absent from router flavor %s", + profile_id, + flavor_id, + ) + return + if is_conflict(exc): + LOG.warning( + "Cannot unbind service profile %s from router flavor %s " + "(Neutron reports conflict, likely in use); leaving attached", + profile_id, + flavor_id, + ) + return + raise + + +def reconcile_flavor_profiles( + conn: Any, + flavor: Any, + desired_profiles: list[Any], +) -> Any: + """Reconcile the set of service profiles bound to *flavor*. + + ``desired_profiles`` is the list resolved from the CR spec (post + ``ensure_profile``). Profiles missing from the flavor are associated; + operator-managed profiles present on the flavor but absent from the + desired set are disassociated. Unmanaged profiles attached out-of-band + are left untouched so an operator's ad-hoc attachments survive reconcile. + + Returns the flavor re-fetched from Neutron so callers see the current + ``service_profile_ids``. + """ + flavor = conn.network.get_flavor(flavor) + flavor_id = resource_id(flavor) + flavor_name = get_value(flavor, "name", default=flavor_id) + + desired_by_id: dict[str, Any] = {resource_id(p): p for p in desired_profiles} + current_ids = set(service_profile_ids(flavor)) + desired_ids = set(desired_by_id) + + to_associate = desired_ids - current_ids + to_disassociate_candidates = current_ids - desired_ids + + if not to_associate and not to_disassociate_candidates: + LOG.info( + "Router flavor %s already has the desired service profiles %s", + flavor_name, + sorted(current_ids), + ) + return flavor + + for profile_id in sorted(to_associate): + _associate_profile(conn, flavor, desired_by_id[profile_id]) + + for profile_id in sorted(to_disassociate_candidates): + profile = get_service_profile(conn, profile_id) + if profile is None: + LOG.info( + "Service profile %s already absent from Neutron; nothing to unbind", + profile_id, + ) + continue + if not is_managed_service_profile(profile): + LOG.info( + "Keeping unmanaged service profile %s on router flavor %s; " + "operator only unbinds profiles it owns", + profile_id, + flavor_name, + ) + continue + _disassociate_profile(conn, flavor, profile) + + return conn.network.get_flavor(flavor) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py new file mode 100644 index 000000000..e35502e39 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py @@ -0,0 +1,273 @@ +"""Delete/prune logic for removed Neutron router flavors.""" + +from __future__ import annotations + +import logging +from collections import Counter +from typing import Any + +from openstack_sync.plugins.common import get_service_profile +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import is_conflict +from openstack_sync.plugins.common import is_not_found +from openstack_sync.plugins.common import resource_id +from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + DEFAULT_SERVICE_TYPE, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + delete_unused_service_profiles_enabled, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + is_managed_flavor, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + is_managed_service_profile, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + prune_driver_prefixes, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + prune_removed_flavors_enabled, +) + +LOG = logging.getLogger(__name__) + + +def configured_flavor_names(flavors: list[dict[str, Any]]) -> set[str]: + return { + str(flavor_config["name"]) + for flavor_config in flavors + if flavor_config.get("name") + } + + +def service_profile_driver(profile: Any) -> str: + return str(get_value(profile, "driver", default="")) + + +def get_cached_service_profile( + conn: Any, + profile_id: str, + profile_cache: dict[str, Any | None], +) -> Any | None: + if profile_id not in profile_cache: + profile_cache[profile_id] = get_service_profile(conn, profile_id) + return profile_cache[profile_id] + + +def is_prunable_service_profile(profile: Any) -> bool: + driver = service_profile_driver(profile) + prefixes = prune_driver_prefixes() + return bool(prefixes) and any(driver.startswith(prefix) for prefix in prefixes) + + +def is_prunable_flavor(flavor: Any) -> bool: + if get_value(flavor, "service_type") != DEFAULT_SERVICE_TYPE: + return False + return is_managed_flavor(flavor) + + +def service_profile_attachment_counts(flavors: list[Any]) -> Counter[str]: + counts: Counter[str] = Counter() + for flavor in flavors: + counts.update(set(service_profile_ids(flavor))) + return counts + + +def detach_service_profile_ids( + profile_attachment_counts: Counter[str], + profile_ids: list[str], +) -> None: + for profile_id in profile_ids: + profile_attachment_counts[profile_id] -= 1 + if profile_attachment_counts[profile_id] <= 0: + del profile_attachment_counts[profile_id] + + +def flavor_has_routers(conn: Any, flavor: Any) -> bool: + flavor_id = resource_id(flavor) + flavor_name = get_value(flavor, "name", default=flavor_id) + + try: + routers = list(conn.network.routers(flavor_id=flavor_id)) + except Exception as exc: + LOG.warning( + "Unable to check routers for removed router flavor %s; " + "skipping deletion: %s", + flavor_name, + exc, + ) + return True + + if routers: + LOG.info( + "Router flavor %s is still used by %s router(s); skipping deletion", + flavor_name, + len(routers), + ) + return True + + return False + + +def service_profile_attached_to_any_flavor( + profile_attachment_counts: Counter[str], + profile_id: str, +) -> bool: + return profile_attachment_counts[profile_id] > 0 + + +def maybe_delete_service_profile( + conn: Any, + profile_id: str, + profile_cache: dict[str, Any | None], + profile_attachment_counts: Counter[str], +) -> None: + if not delete_unused_service_profiles_enabled(): + LOG.info("Keeping service profile %s; profile pruning is disabled", profile_id) + return + + profile = get_cached_service_profile(conn, profile_id, profile_cache) + if not profile: + return + + if not is_prunable_service_profile(profile): + LOG.info( + "Keeping service profile %s; driver %s is outside prune scope", + profile_id, + service_profile_driver(profile), + ) + return + + if not is_managed_service_profile(profile): + LOG.info("Keeping service profile %s; it is not operator-managed", profile_id) + return + + if service_profile_attached_to_any_flavor(profile_attachment_counts, profile_id): + LOG.info("Keeping service profile %s; it is still attached", profile_id) + return + + LOG.info("Deleting unused service profile %s", profile_id) + try: + conn.network.delete_service_profile(profile, ignore_missing=True) + profile_cache[profile_id] = None + except Exception as exc: + if is_not_found(exc): + profile_cache[profile_id] = None + return + if is_conflict(exc): + LOG.info("Service profile %s is still in use; skipping delete", profile_id) + return + raise + + +def delete_removed_flavor( + conn: Any, + flavor: Any, + profile_cache: dict[str, Any | None], + profile_attachment_counts: Counter[str], +) -> None: + flavor_id = resource_id(flavor) + flavor_name = get_value(flavor, "name", default=flavor_id) + profile_ids = service_profile_ids(flavor) + + if flavor_has_routers(conn, flavor): + return + + LOG.info("Deleting removed router flavor %s (%s)", flavor_name, flavor_id) + try: + conn.network.delete_flavor(flavor, ignore_missing=True) + except Exception as exc: + if is_not_found(exc): + LOG.info("Router flavor %s (%s) is already absent", flavor_name, flavor_id) + elif is_conflict(exc): + LOG.info( + "Router flavor %s is still in use; skipping delete", + flavor_name, + ) + return + else: + raise + + detach_service_profile_ids(profile_attachment_counts, profile_ids) + + for profile_id in profile_ids: + maybe_delete_service_profile( + conn, + profile_id, + profile_cache, + profile_attachment_counts, + ) + + +def prune_orphaned_service_profiles( + conn: Any, + profile_cache: dict[str, Any | None], + profile_attachment_counts: Counter[str], +) -> None: + """Delete orphaned operator-managed service profiles. + + Runs after the flavor prune loop to catch profiles left behind when + delete_flavor succeeded but maybe_delete_service_profile threw on the same + run. Safe to run every cycle because it only touches operator-owned, unattached + profiles. + """ + LOG.info("Scanning for orphaned operator-managed service profiles") + for profile in list(conn.network.service_profiles()): + profile_id = resource_id(profile) + if not is_prunable_service_profile(profile): + continue + if not is_managed_service_profile(profile): + continue + maybe_delete_service_profile( + conn, + profile_id, + profile_cache, + profile_attachment_counts, + ) + + +def prune_removed_flavors( + conn: Any, + flavors: list[dict[str, Any]], + *, + authoritative_empty_desired: bool = False, +) -> None: + if not prune_removed_flavors_enabled(): + LOG.info("Router flavor pruning is disabled") + return + + if not flavors and not authoritative_empty_desired: + LOG.warning( + "No desired router flavors found; skipping prune to avoid deleting " + "all managed router flavors" + ) + return + + desired_names = configured_flavor_names(flavors) + profile_cache: dict[str, Any | None] = {} + + LOG.info("Pruning removed router flavors") + current_flavors = list(conn.network.flavors(service_type=DEFAULT_SERVICE_TYPE)) + profile_attachment_counts = service_profile_attachment_counts(current_flavors) + for flavor in current_flavors: + flavor_name = get_value(flavor, "name") + if not flavor_name or flavor_name in desired_names: + continue + if not is_prunable_flavor(flavor): + continue + delete_removed_flavor( + conn, + flavor, + profile_cache, + profile_attachment_counts, + ) + + # Second pass: catch profiles orphaned by a partial failure on a previous + # run (delete_flavor succeeded but maybe_delete_service_profile threw). + prune_orphaned_service_profiles( + conn, + profile_cache, + profile_attachment_counts, + ) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py new file mode 100644 index 000000000..f44b39db5 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py @@ -0,0 +1,283 @@ +"""Router-flavor-specific constants and helpers. + +Generic utilities (env helpers, resource accessors, meta_info, exception +classifiers, etc.) live in :mod:`openstack_sync.plugins.common`. +""" + +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Any + +from openstack_sync.plugins.common import comparable_meta_info_without +from openstack_sync.plugins.common import env_bool +from openstack_sync.plugins.common import env_float +from openstack_sync.plugins.common import env_int +from openstack_sync.plugins.common import env_required +from openstack_sync.plugins.common import env_tuple +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import managed_meta_info as managed_meta_info_with +from openstack_sync.plugins.common import meta_info_matches_without +from openstack_sync.plugins.common import normalize_meta_info +from openstack_sync.plugins.common import wait_for_openstack_network as wait_for_network + +# --------------------------------------------------------------------------- +# Router-flavor CRD identity +# --------------------------------------------------------------------------- +# CRD_API_VERSION, CRD_KIND, and CRD_RESOURCE are injected by the Helm chart +# at runtime and must NOT be read at module import time. Importing this module +# happens before shell-operator invokes the hook with --config, and these vars +# are not guaranteed to be present at that point (e.g. broken chart rendering, +# unit tests that only exercise the --config path). +# +# Use the accessor functions below — crd_api_version(), crd_kind(), +# crd_resource() — everywhere these values are needed. They call +# env_required() which raises ConfigError with a clear message if a var is +# absent, rather than crashing at import with a raw KeyError. +# +# Internal shell-operator binding label default. +CRD_BINDING_NAME = "neutron-router-flavors" +DEFAULT_SERVICE_TYPE = "L3_ROUTER_NAT" + + +def crd_api_version() -> str: + """Return the CRD API version injected by the Helm chart.""" + return env_required("NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION") + + +def crd_kind() -> str: + """Return the CRD kind injected by the Helm chart.""" + return env_required("NEUTRON_ROUTER_FLAVOR_CRD_KIND") + + +def crd_resource() -> str: + """Return the fully-qualified CRD resource name injected by the Helm chart.""" + return env_required("NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE") + + +def crd_binding_name() -> str: + """Return the shell-operator binding label for the CRD watch.""" + return os.environ.get("NEUTRON_ROUTER_FLAVOR_CRD_BINDING_NAME", CRD_BINDING_NAME) + + +def crd_namespace() -> str | None: + """Return the namespace used for CRD status patches.""" + return os.environ.get("POD_NAMESPACE") + + +def status_enabled() -> bool: + """Return whether CRD status patching is enabled.""" + return env_bool("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", False) + + +# --------------------------------------------------------------------------- +# Prune / lifecycle config +# --------------------------------------------------------------------------- + + +def prune_removed_flavors_enabled() -> bool: + """Return whether removed router flavor pruning is enabled.""" + return env_bool("NEUTRON_ROUTER_FLAVOR_PRUNE", False) + + +def delete_unused_service_profiles_enabled() -> bool: + """Return whether unused service profile deletion is enabled.""" + return env_bool("NEUTRON_ROUTER_FLAVOR_DELETE_UNUSED_PROFILES", True) + + +def prune_driver_prefixes() -> tuple[str, ...]: + """Return service profile driver prefixes eligible for pruning.""" + return env_tuple( + "NEUTRON_ROUTER_FLAVOR_PRUNE_DRIVER_PREFIXES", + "neutron_understack.l3_router.", + ) + + +# --------------------------------------------------------------------------- +# Operator ownership markers +# --------------------------------------------------------------------------- + +MANAGED_META_INFO_KEY = os.environ.get( + "NEUTRON_ROUTER_FLAVOR_MANAGED_META_INFO_KEY", + "_understack_router_flavor_operator", +) +MANAGED_META_INFO_VALUE = "managed" +FLAVOR_DESCRIPTION_MARKER = os.environ.get( + "NEUTRON_ROUTER_FLAVOR_DESCRIPTION_MARKER", + "[understack-router-flavor-operator]", +) +MARKER_VERSION_META_INFO_KEY = "_understack_router_flavor_marker_version" +MARKER_VERSION_META_INFO_VALUE = "v1" +MARKER_SOURCE_META_INFO_KEY = "_understack_router_flavor_source" + +# --------------------------------------------------------------------------- +# Retry config +# --------------------------------------------------------------------------- + + +def ready_retries() -> int: + """Return the Neutron readiness retry count.""" + return env_int("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", 30) + + +def ready_delay() -> float: + """Return the Neutron readiness delay in seconds.""" + return env_float("NEUTRON_ROUTER_FLAVOR_READY_DELAY", 10) + + +# --------------------------------------------------------------------------- +# Runtime-resolved marker helpers +# --------------------------------------------------------------------------- +# MARKER_SOURCE defaults to the CRD kind, which is only available at runtime. +# Use marker_source() rather than a module-level constant. + + +def marker_source() -> str: + """Return the marker source value, defaulting to the CRD kind.""" + return os.environ.get("NEUTRON_ROUTER_FLAVOR_SOURCE") or crd_kind() + + +def operator_meta_info_markers() -> dict[str, str]: + """Return the operator ownership marker dict.""" + return { + MANAGED_META_INFO_KEY: MANAGED_META_INFO_VALUE, + MARKER_VERSION_META_INFO_KEY: MARKER_VERSION_META_INFO_VALUE, + MARKER_SOURCE_META_INFO_KEY: marker_source(), + } + + +def operator_meta_info_keys() -> frozenset[str]: + """Return the frozenset of operator marker keys.""" + return frozenset(operator_meta_info_markers()) + + +# --------------------------------------------------------------------------- +# meta_info helpers bound to this plugin's operator marker keys +# --------------------------------------------------------------------------- + + +def comparable_meta_info(value: Any) -> Any: + """Strip operator marker keys from *value* before comparison.""" + return comparable_meta_info_without(value, operator_meta_info_keys()) + + +def meta_info_matches(current: Any, desired: Any) -> bool: + """Return True when *current* and *desired* are logically equal. + + Operator-managed marker keys are ignored during comparison. + """ + return meta_info_matches_without(current, desired, operator_meta_info_keys()) + + +def managed_meta_info(value: Any) -> Any: + """Merge operator ownership markers into *value*.""" + return managed_meta_info_with(value, operator_meta_info_markers()) + + +# --------------------------------------------------------------------------- +# Flavor description marker helpers +# --------------------------------------------------------------------------- + + +def clean_flavor_description(value: Any) -> str: + """Return *value* with the operator description marker stripped.""" + description = "" if value is None else str(value) + return description.replace(FLAVOR_DESCRIPTION_MARKER, "").strip() + + +def managed_flavor_description(value: Any) -> str: + """Return *value* with the operator description marker appended.""" + description = clean_flavor_description(value) + if not description: + return FLAVOR_DESCRIPTION_MARKER + return f"{description} {FLAVOR_DESCRIPTION_MARKER}" + + +def flavor_description_has_marker(value: Any) -> bool: + """Return True when *value* contains the operator description marker.""" + return FLAVOR_DESCRIPTION_MARKER in str(value or "") + + +def is_managed_flavor(flavor: Any) -> bool: + """Return True when the flavor's description contains the operator marker.""" + return flavor_description_has_marker(get_value(flavor, "description", default="")) + + +# --------------------------------------------------------------------------- +# Service profile ownership helpers +# --------------------------------------------------------------------------- + + +def service_profile_meta_info(profile: Any) -> Any: + """Return the meta_info field of *profile*.""" + return get_value(profile, "meta_info", default={}) + + +def is_managed_service_profile(profile: Any) -> bool: + """Return True when the service profile carries the operator ownership marker.""" + meta_info = normalize_meta_info(service_profile_meta_info(profile)) + return ( + isinstance(meta_info, dict) + and meta_info.get(MANAGED_META_INFO_KEY) == MANAGED_META_INFO_VALUE + ) + + +# --------------------------------------------------------------------------- +# Service profile drift reporting +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class ProfileDrift: + """One field of a reused service profile that diverged from the CR spec. + + Profile drift is reported, never auto-corrected. Neutron's + ``update_service_profile`` calls ``_ensure_service_profile_not_in_use`` and + raises ``ServiceProfileInUse`` (HTTP 409) while *any* flavor binding exists + -- not merely while a router is using it -- and this operator binds every + profile it manages. An update attempt would therefore fail every cycle. + Correcting drift requires unbinding the profile from every flavor first, + which is an operator decision, not something to do behind their back. + """ + + profile_id: str + driver: str + field: str + have: Any + want: Any + + def describe(self) -> str: + """Return a short ``field: have=... want=...`` description.""" + return f"{self.field}: have={self.have!r} want={self.want!r}" + + +def describe_profile_drift(drift: list[ProfileDrift]) -> str: + """Return a single-line summary of *drift* for logs and CR status.""" + return "; ".join( + f"service profile {item.profile_id} {item.describe()}" for item in drift + ) + + +# --------------------------------------------------------------------------- +# Config validation +# --------------------------------------------------------------------------- + + +def config_meta_info(flavor_config: dict[str, Any]) -> Any: + """Return the canonical meta_info payload from a router flavor spec.""" + return flavor_config.get("meta_info", {}) + + +# --------------------------------------------------------------------------- +# Neutron readiness probe +# --------------------------------------------------------------------------- + + +def wait_for_openstack_network(conn: Any) -> None: + """Poll until the Neutron network API is reachable. + + Reads retry config at call time so malformed values do not break hook + import or shell-operator --config registration. + """ + wait_for_network(conn, retries=ready_retries(), delay=ready_delay()) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py new file mode 100644 index 000000000..77a3e1ac7 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py @@ -0,0 +1,143 @@ +"""Update and sync logic for configured Neutron router flavors.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors import create +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + DEFAULT_SERVICE_TYPE, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + ProfileDrift, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + clean_flavor_description, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + describe_profile_drift, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + flavor_description_has_marker, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + managed_flavor_description, +) + +LOG = logging.getLogger(__name__) + + +def ensure_flavor( + conn: Any, + name: str, + service_type: str, + description: str, + *, + is_enabled: bool, +) -> Any: + flavor = create.find_flavor(conn, name) + managed_description = managed_flavor_description(description) + if flavor: + LOG.info("Router flavor %s already exists", name) + + current_service_type = get_value(flavor, "service_type", default="") + if current_service_type != service_type: + raise ConfigError( + f"Router flavor {name!r} already exists in Neutron with " + f"service_type={current_service_type!r}; " + f"expected {service_type!r}. Neutron does not allow updating " + f"service_type on an existing flavor. Rename the CR or remove " + f"the existing Neutron flavor to let the operator recreate it." + ) + + current_description = get_value(flavor, "description", default="") + description_changed = clean_flavor_description( + current_description + ) != clean_flavor_description(description) + marker_missing = not flavor_description_has_marker(current_description) + current_is_enabled = bool(get_value(flavor, "is_enabled", default=True)) + is_enabled_drifted = current_is_enabled != is_enabled + + if is_enabled_drifted: + LOG.info( + "Router flavor %s is_enabled drift: have=%s want=%s; reconciling", + name, + current_is_enabled, + is_enabled, + ) + + if description_changed or marker_missing or is_enabled_drifted: + return conn.network.update_flavor( + flavor, + description=managed_description, + is_enabled=is_enabled, + ) + return flavor + + return create.create_flavor( + conn, name, service_type, description, is_enabled=is_enabled + ) + + +def render_flavor(flavor: Any) -> dict[str, Any]: + return { + "id": get_value(flavor, "id"), + "name": get_value(flavor, "name"), + "service_type": get_value(flavor, "service_type"), + "description": get_value(flavor, "description"), + "is_enabled": get_value(flavor, "is_enabled"), + "service_profile_ids": service_profile_ids(flavor), + } + + +def sync_flavor( + conn: Any, + flavor_config: dict[str, Any], + profile_cache: create.ServiceProfileCache, +) -> list[ProfileDrift]: + """Reconcile one router flavor CR to the desired Neutron state. + + ``flavor_config`` is the CR spec after cloudCredentialsRef has been + stripped. Schema-required keys are read via subscript so a missing key + fails loudly rather than being silently defaulted; schema-optional keys + (description, meta_info) fall back to their type's empty value. + + Returns the service profile drift detected while reconciling. An empty list + means spec and Neutron agree. Drift is not a reconcile failure -- the flavor + itself is still converged -- but it needs an operator to act, so the caller + is expected to qualify the status it reports rather than dropping it. + """ + name = flavor_config["name"] + service_type = flavor_config.get("service_type", DEFAULT_SERVICE_TYPE) + description = flavor_config.get("description", "") + is_enabled = flavor_config["is_enabled"] + profile_specs = flavor_config["service_profiles"] + + LOG.info( + "Reconciling router flavor %s with %s service profile(s)", + name, + len(profile_specs), + ) + drift: list[ProfileDrift] = [] + desired_profiles = [ + create.ensure_profile(conn, name, profile_spec, profile_cache, drift) + for profile_spec in profile_specs + ] + flavor = ensure_flavor(conn, name, service_type, description, is_enabled=is_enabled) + flavor = create.reconcile_flavor_profiles(conn, flavor, desired_profiles) + LOG.info( + "Reconciled router flavor: %s", + json.dumps(render_flavor(flavor), sort_keys=True), + ) + if drift: + LOG.warning( + "Router flavor %s converged but carries service profile drift: %s", + name, + describe_profile_drift(drift), + ) + return drift diff --git a/python/openstack-sync/tests/conftest.py b/python/openstack-sync/tests/conftest.py new file mode 100644 index 000000000..70eb5a7e1 --- /dev/null +++ b/python/openstack-sync/tests/conftest.py @@ -0,0 +1,31 @@ +"""Pytest configuration and shared fixtures for openstack-sync tests. + +Sets environment variables that router_flavors_common.py reads at runtime +via env_required(). These must be present when any function that calls +crd_kind() / crd_api_version() / crd_resource() runs, so they are set +via a session-scoped autouse fixture that runs before every test. +""" + +from __future__ import annotations + +import pytest + +_ROUTER_FLAVOR_REQUIRED_ENV = { + "NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION": ( + "neutron.understack.rackspace.net/v1alpha1" + ), + "NEUTRON_ROUTER_FLAVOR_CRD_KIND": "NeutronRouterFlavor", + "NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE": ( + "neutronrouterflavors.neutron.understack.rackspace.net" + ), +} + + +@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..761c9d5a7 100644 --- a/python/openstack-sync/tests/test_router_flavors.py +++ b/python/openstack-sync/tests/test_router_flavors.py @@ -3,24 +3,16 @@ 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) - +from openstack_sync.plugins.neutron.router_flavors import ( + router_flavors_common as common, +) FAKE_CLOUDS_YAML = """ clouds: @@ -34,13 +26,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": { + common.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 +85,265 @@ 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"] == common.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"] == common.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"] == common.CRD_BINDING_NAME + assert config["schedule"][0]["crontab"] == "0 * * * *" + assert config["schedule"][0]["queue"] == common.CRD_BINDING_NAME + assert "onStartup" not in config + + +def test_router_flavor_hook_config_custom_crontab(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") + monkeypatch.delenv("POD_NAMESPACE", raising=False) - 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_hook_inputs_keeps_current_status(): + status = { + "syncStatus": "Synced", + "message": "Successfully reconciled router flavor", + "observedGeneration": 1, + } + contexts = _snapshot_context(_router_flavor_object("flavor-a", status=status)) - 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") + hook_inputs = router_flavors.load_router_flavor_hook_inputs(contexts) - conn_a = mock.MagicMock(name="conn_a") - conn_b = mock.MagicMock(name="conn_b") + assert hook_inputs.resources_to_reconcile[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_hook_inputs( + _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, + ) + ) + ).resources_to_reconcile[0] + conn = _fake_conn() + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ) as mock_connect, + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, ): - 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_hook_inputs(_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_hook_inputs(_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", return_value=[] + ) 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..d9a747493 --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_create.py @@ -0,0 +1,642 @@ +"""Tests for create.py helpers: ensure_profile and reconcile_flavor_profiles.""" + +from __future__ import annotations + +import logging +import types +from typing import Any +from unittest import mock + +from openstack_sync.plugins import common as plugin_common +from openstack_sync.plugins.neutron.router_flavors import create +from openstack_sync.plugins.neutron.router_flavors import ( + router_flavors_common as common, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_profile( + profile_id: str, + driver: str = "neutron_understack.l3_router.vrf.Vrf", + meta_info: Any = None, + managed: bool = True, + is_enabled: bool = True, + description: str = "desc", +) -> Any: + """Build an openstacksdk-shaped service profile. + + ``description`` defaults to the ``_profile_spec`` default so that a profile + and a spec built with defaults are drift-free; drift tests opt in by passing + a mismatching value. + """ + raw_meta = dict(meta_info or {}) + if managed: + raw_meta.update(common.operator_meta_info_markers()) + return types.SimpleNamespace( + id=profile_id, + driver=driver, + is_enabled=is_enabled, + description=description, + meta_info=plugin_common.meta_info_payload(raw_meta), + ) + + +def _make_flavor( + flavor_id: str = "flavor-id", + name: str = "test-flavor", + service_profile_ids: list[str] | None = None, +) -> Any: + return types.SimpleNamespace( + id=flavor_id, + name=name, + service_profile_ids=list(service_profile_ids or []), + ) + + +def _profile_spec( + driver: str = "neutron_understack.l3_router.vrf.Vrf", + description: str = "desc", + meta_info: dict[str, Any] | None = None, + is_enabled: bool = True, +) -> dict[str, Any]: + return { + "driver": driver, + "description": description, + "meta_info": meta_info if meta_info is not None else {}, + "is_enabled": is_enabled, + } + + +# --------------------------------------------------------------------------- +# service profile query cache +# --------------------------------------------------------------------------- + + +def test_list_service_profiles_queries_by_driver(): + network = mock.MagicMock() + network.service_profiles.return_value = [_make_profile("profile-id")] + conn = types.SimpleNamespace(network=network) + + result = create.list_service_profiles(conn, "some.Driver") + + assert result == list(network.service_profiles.return_value) + network.service_profiles.assert_called_once_with(driver="some.Driver") + + +def test_service_profiles_for_driver_caches_per_driver(): + first_driver = "first.Driver" + second_driver = "second.Driver" + first_profile = _make_profile("first-profile", driver=first_driver) + second_profile = _make_profile("second-profile", driver=second_driver) + network = mock.MagicMock() + network.service_profiles.side_effect = [[first_profile], [second_profile]] + conn = types.SimpleNamespace(network=network) + profile_cache: create.ServiceProfileCache = {} + + first_result = create.service_profiles_for_driver(conn, first_driver, profile_cache) + cached_result = create.service_profiles_for_driver( + conn, first_driver, profile_cache + ) + second_result = create.service_profiles_for_driver( + conn, second_driver, profile_cache + ) + + assert first_result == [first_profile] + assert cached_result is first_result + assert second_result == [second_profile] + assert network.service_profiles.call_args_list == [ + mock.call(driver=first_driver), + mock.call(driver=second_driver), + ] + + +# --------------------------------------------------------------------------- +# ensure_profile: create-or-reuse by (driver, meta_info) +# --------------------------------------------------------------------------- + + +def test_ensure_profile_creates_service_profile_with_management_markers(): + network = mock.MagicMock() + network.service_profiles.return_value = [] + network.create_service_profile.return_value = _make_profile("new-profile") + conn = types.SimpleNamespace(network=network) + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info={"vni_alloc": "auto"}), + profile_cache={}, + ) + + kwargs = conn.network.create_service_profile.call_args.kwargs + assert kwargs["driver"] == "neutron_understack.l3_router.vrf.Vrf" + assert kwargs["is_enabled"] is True + meta_info = plugin_common.normalize_meta_info(kwargs["meta_info"]) + assert meta_info["vni_alloc"] == "auto" + for key, value in common.operator_meta_info_markers().items(): + assert meta_info[key] == value + + +def test_ensure_profile_creates_disabled_profile_when_spec_disables(): + network = mock.MagicMock() + network.service_profiles.return_value = [] + network.create_service_profile.return_value = _make_profile( + "new-profile", is_enabled=False + ) + conn = types.SimpleNamespace(network=network) + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(is_enabled=False), + profile_cache={}, + ) + + assert conn.network.create_service_profile.call_args.kwargs["is_enabled"] is False + + +def test_ensure_profile_reuses_existing_matching_profile(): + """When Neutron already has a managed profile matching (driver, meta_info).""" + meta_info = {"vni_alloc": "auto"} + existing = _make_profile("existing-profile", meta_info=meta_info, managed=True) + network = mock.MagicMock() + network.service_profiles.return_value = [existing] + conn = types.SimpleNamespace(network=network) + + result = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info=meta_info), + profile_cache={}, + ) + + assert result is existing + conn.network.create_service_profile.assert_not_called() + + +def test_ensure_profile_appends_newly_created_profile_to_driver_cache(): + """A profile created for one flavor must be visible to the next flavor. + + profile_cache is caller-owned and shared across all flavors in the same + credential group during one reconcile pass. Two flavors with an identical + ``(driver, meta_info)`` spec must share one profile rather than each + creating a duplicate. + """ + driver = "some.Driver" + meta_info = {"vni_alloc": "auto"} + created_profile = _make_profile("new-profile", driver=driver, meta_info=meta_info) + network = mock.MagicMock() + network.service_profiles.return_value = [] + network.create_service_profile.return_value = created_profile + conn = types.SimpleNamespace(network=network) + profile_cache: create.ServiceProfileCache = {} + + created = create.ensure_profile( + conn, + flavor_name="flavor-a", + profile_spec=_profile_spec(driver=driver, meta_info=meta_info), + profile_cache=profile_cache, + ) + reused = create.ensure_profile( + conn, + flavor_name="flavor-b", + profile_spec=_profile_spec(driver=driver, meta_info=meta_info), + profile_cache=profile_cache, + ) + + assert created is created_profile + assert reused is created + conn.network.create_service_profile.assert_called_once() + conn.network.service_profiles.assert_called_once_with(driver=driver) + + +def test_ensure_profile_does_not_reuse_profiles_across_drivers(): + meta_info = {"vni_alloc": "auto"} + first_driver = "first.Driver" + second_driver = "second.Driver" + first_profile = _make_profile( + "first-profile", driver=first_driver, meta_info=meta_info + ) + second_profile = _make_profile( + "second-profile", driver=second_driver, meta_info=meta_info + ) + network = mock.MagicMock() + network.service_profiles.side_effect = [[], []] + network.create_service_profile.side_effect = [first_profile, second_profile] + conn = types.SimpleNamespace(network=network) + profile_cache: create.ServiceProfileCache = {} + + first_result = create.ensure_profile( + conn, + flavor_name="flavor-a", + profile_spec=_profile_spec(driver=first_driver, meta_info=meta_info), + profile_cache=profile_cache, + ) + second_result = create.ensure_profile( + conn, + flavor_name="flavor-b", + profile_spec=_profile_spec(driver=second_driver, meta_info=meta_info), + profile_cache=profile_cache, + ) + + assert first_result is first_profile + assert second_result is second_profile + assert network.create_service_profile.call_count == 2 + + +# --------------------------------------------------------------------------- +# find_matching_profile / ensure_profile: only operator-owned profiles are reused +# --------------------------------------------------------------------------- + + +def _reuse_conn(profile: Any) -> Any: + """Build a connection whose only existing service profile is *profile*.""" + network = mock.MagicMock() + network.service_profiles.return_value = [profile] + return types.SimpleNamespace(network=network) + + +def test_find_matching_profile_ignores_unowned_match(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + + assert create.find_matching_profile([unowned], meta_info) is None + + +def test_find_matching_profile_prefers_owned_over_unowned(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + owned = _make_profile("owned-profile", meta_info=meta_info, managed=True) + + assert create.find_matching_profile([unowned, owned], meta_info) is owned + + +def test_ensure_profile_creates_owned_profile_instead_of_reusing_unowned(): + """An unowned profile must never be bound, because it can never be unbound. + + ``reconcile_flavor_profiles`` only unbinds profiles carrying the ownership + marker, so reusing somebody else's profile would create a binding that + outlives the spec that created it and that nothing can ever remove. + """ + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + created = _make_profile("new-profile", meta_info=meta_info, managed=True) + network = mock.MagicMock() + network.service_profiles.return_value = [unowned] + network.create_service_profile.return_value = created + conn = types.SimpleNamespace(network=network) + + result = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info=meta_info), + profile_cache={}, + ) + + assert result is created + network.create_service_profile.assert_called_once() + new_meta = plugin_common.normalize_meta_info( + network.create_service_profile.call_args.kwargs["meta_info"] + ) + for key, value in common.operator_meta_info_markers().items(): + assert new_meta[key] == value + + +def test_ensure_profile_never_adopts_an_unowned_profile(): + """The unowned profile is left alone, not stamped with the ownership marker. + + Adopting it would enrol somebody else's profile into + ``prune_orphaned_service_profiles``, which deletes owned, unattached + profiles -- an irreversible side effect on a resource the operator did not + create. + """ + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + network = mock.MagicMock() + network.service_profiles.return_value = [unowned] + network.create_service_profile.return_value = _make_profile("new-profile") + conn = types.SimpleNamespace(network=network) + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info=meta_info), + profile_cache={}, + ) + + network.update_service_profile.assert_not_called() + network.delete_service_profile.assert_not_called() + + +def test_ensure_profile_reuses_owned_profile_when_unowned_match_also_exists(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + owned = _make_profile("owned-profile", meta_info=meta_info, managed=True) + network = mock.MagicMock() + network.service_profiles.return_value = [unowned, owned] + conn = types.SimpleNamespace(network=network) + + result = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info=meta_info), + profile_cache={}, + ) + + assert result is owned + network.create_service_profile.assert_not_called() + + +def test_profile_created_beside_unowned_match_can_later_be_unbound(): + """End-to-end guard for why unowned profiles are not reused. + + Resolve a profile for spec A while an unowned match exists, then reconcile + the flavor against spec B. The profile bound for spec A must be unbindable, + which holds only because the operator created and owns it. + """ + meta_a = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_a, managed=False) + created_for_a = _make_profile("prof-a", meta_info=meta_a, managed=True) + network = mock.MagicMock() + network.service_profiles.return_value = [unowned] + network.create_service_profile.return_value = created_for_a + conn = types.SimpleNamespace(network=network) + + profile_for_a = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(meta_info=meta_a), + profile_cache={}, + ) + + # The spec moves on to a different profile; the flavor still carries prof-a. + flavor = _make_flavor(service_profile_ids=["prof-a"]) + reconcile_conn = _reconcile_conn(flavor, {"prof-a": profile_for_a}) + + create.reconcile_flavor_profiles( + reconcile_conn, flavor, [_make_profile("prof-b", managed=True)] + ) + + disassociate = reconcile_conn.network.disassociate_flavor_from_service_profile + disassociate.assert_called_once() + assert disassociate.call_args.args[1] is profile_for_a + + +# --------------------------------------------------------------------------- +# ensure_profile: drift reporting for reused profiles +# --------------------------------------------------------------------------- + + +def test_ensure_profile_reports_is_enabled_drift_on_reuse(caplog): + """A profile disabled out-of-band is reported instead of silently accepted. + + Neutron's ``get_flavor_next_provider`` raises ``ServiceProfileDisabled`` + when the profile it selects is disabled, so every router create against the + flavor fails while the flavor itself still looks converged. + """ + existing = _make_profile("owned-profile", managed=True, is_enabled=False) + conn = _reuse_conn(existing) + drift: list[common.ProfileDrift] = [] + + with caplog.at_level(logging.WARNING): + result = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(is_enabled=True), + profile_cache={}, + drift=drift, + ) + + assert result is existing + assert [(item.field, item.have, item.want) for item in drift] == [ + ("is_enabled", False, True) + ] + assert drift[0].profile_id == "owned-profile" + assert "is_enabled" in caplog.text + # Neutron rejects updates to a profile bound to any flavor: never try one. + conn.network.update_service_profile.assert_not_called() + + +def test_ensure_profile_reports_description_drift_on_reuse(): + existing = _make_profile("owned-profile", managed=True, description="stale") + conn = _reuse_conn(existing) + drift: list[common.ProfileDrift] = [] + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(description="wanted"), + profile_cache={}, + drift=drift, + ) + + assert [(item.field, item.have, item.want) for item in drift] == [ + ("description", "stale", "wanted") + ] + + +def test_ensure_profile_reports_every_drifted_field(): + existing = _make_profile( + "owned-profile", managed=True, is_enabled=False, description="stale" + ) + conn = _reuse_conn(existing) + drift: list[common.ProfileDrift] = [] + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(description="wanted", is_enabled=True), + profile_cache={}, + drift=drift, + ) + + assert sorted(item.field for item in drift) == ["description", "is_enabled"] + + +def test_ensure_profile_appends_to_existing_drift_collection(): + """sync_flavor passes one list across every profile in the spec.""" + existing = _make_profile("owned-profile", managed=True, is_enabled=False) + conn = _reuse_conn(existing) + already_found = common.ProfileDrift( + profile_id="other-profile", + driver="other.Driver", + field="is_enabled", + have=False, + want=True, + ) + drift = [already_found] + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(is_enabled=True), + profile_cache={}, + drift=drift, + ) + + assert len(drift) == 2 + assert drift[0] is already_found + + +def test_ensure_profile_reports_no_drift_when_profile_matches_spec(): + existing = _make_profile("owned-profile", managed=True) + conn = _reuse_conn(existing) + drift: list[common.ProfileDrift] = [] + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(), + profile_cache={}, + drift=drift, + ) + + assert drift == [] + + +def test_ensure_profile_reports_no_drift_for_freshly_created_profile(): + """A profile the operator just created from the spec cannot have drifted.""" + network = mock.MagicMock() + network.service_profiles.return_value = [] + network.create_service_profile.return_value = _make_profile( + "new-profile", is_enabled=False + ) + conn = types.SimpleNamespace(network=network) + drift: list[common.ProfileDrift] = [] + + create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(is_enabled=False), + profile_cache={}, + drift=drift, + ) + + assert drift == [] + + +def test_ensure_profile_drift_collection_is_optional(): + """Callers that do not track drift keep working unchanged.""" + existing = _make_profile("owned-profile", managed=True, is_enabled=False) + conn = _reuse_conn(existing) + + result = create.ensure_profile( + conn, + flavor_name="test-flavor", + profile_spec=_profile_spec(is_enabled=True), + profile_cache={}, + ) + + assert result is existing + + +# --------------------------------------------------------------------------- +# reconcile_flavor_profiles: set-based associate + disassociate-if-managed +# --------------------------------------------------------------------------- + + +def _reconcile_conn( + flavor: Any, disassociate_profile_lookup: dict[str, Any] | None = None +) -> Any: + """Build a connection mock whose network exposes these behaviors. + + * ``get_flavor`` returns *flavor* on every call + * ``associate_flavor_with_service_profile`` succeeds silently + * ``disassociate_flavor_from_service_profile`` succeeds silently + * ``get_service_profile`` returns matching profile from + *disassociate_profile_lookup* so ``is_managed_service_profile`` can be + evaluated on candidates for removal. + """ + lookup = disassociate_profile_lookup or {} + network = mock.MagicMock() + network.get_flavor.return_value = flavor + network.get_service_profile.side_effect = lambda pid: lookup.get(pid) + return types.SimpleNamespace(network=network) + + +def test_reconcile_flavor_profiles_no_op_when_matches(): + """Current == desired → no associate/disassociate calls.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-b"]) + desired = [ + _make_profile("prof-a"), + _make_profile("prof-b"), + ] + conn = _reconcile_conn(flavor) + + result = create.reconcile_flavor_profiles(conn, flavor, desired) + + conn.network.associate_flavor_with_service_profile.assert_not_called() + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + assert result is flavor + + +def test_reconcile_flavor_profiles_associates_missing(): + flavor = _make_flavor(service_profile_ids=[]) + desired = [_make_profile("prof-a"), _make_profile("prof-b")] + conn = _reconcile_conn(flavor) + + create.reconcile_flavor_profiles(conn, flavor, desired) + + associate_calls = conn.network.associate_flavor_with_service_profile.call_args_list + associated_ids = sorted(call.args[1].id for call in associate_calls) + assert associated_ids == ["prof-a", "prof-b"] + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + + +def test_reconcile_flavor_profiles_disassociates_managed_extra(): + """A managed profile currently on the flavor but not desired must be unbound.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-extra"]) + desired = [_make_profile("prof-a")] + extra_profile = _make_profile("prof-extra", managed=True) + conn = _reconcile_conn(flavor, {"prof-extra": extra_profile}) + + create.reconcile_flavor_profiles(conn, flavor, desired) + + conn.network.associate_flavor_with_service_profile.assert_not_called() + conn.network.disassociate_flavor_from_service_profile.assert_called_once() + call = conn.network.disassociate_flavor_from_service_profile.call_args + assert call.args[1] is extra_profile + + +def test_reconcile_flavor_profiles_keeps_unmanaged_extra(): + """An unmanaged profile attached out-of-band must not be disassociated.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-adhoc"]) + desired = [_make_profile("prof-a")] + unmanaged = _make_profile("prof-adhoc", managed=False) + conn = _reconcile_conn(flavor, {"prof-adhoc": unmanaged}) + + create.reconcile_flavor_profiles(conn, flavor, desired) + + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + + +def test_reconcile_flavor_profiles_handles_add_and_remove_together(): + """Simultaneous associate + disassociate in one reconcile pass.""" + flavor = _make_flavor(service_profile_ids=["prof-old"]) + desired = [_make_profile("prof-new")] + old_profile = _make_profile("prof-old", managed=True) + conn = _reconcile_conn(flavor, {"prof-old": old_profile}) + + create.reconcile_flavor_profiles(conn, flavor, desired) + + conn.network.associate_flavor_with_service_profile.assert_called_once() + associate_call = conn.network.associate_flavor_with_service_profile.call_args + assert associate_call.args[1].id == "prof-new" + conn.network.disassociate_flavor_from_service_profile.assert_called_once() + disassociate_call = conn.network.disassociate_flavor_from_service_profile.call_args + assert disassociate_call.args[1] is old_profile + + +def test_reconcile_flavor_profiles_skips_deleted_extra_profile(): + """A candidate for disassociation that no longer exists is a silent no-op.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-gone"]) + desired = [_make_profile("prof-a")] + # Neutron says prof-gone doesn't exist anymore. + conn = _reconcile_conn(flavor, {"prof-gone": None}) + + create.reconcile_flavor_profiles(conn, flavor, desired) + + conn.network.disassociate_flavor_from_service_profile.assert_not_called() diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py new file mode 100644 index 000000000..d17125ce0 --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -0,0 +1,1065 @@ +"""Integration-style tests for the Neutron router flavor hook run loop.""" + +from __future__ import annotations + +import importlib +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_BINDING_NAME", + "NEUTRON_ROUTER_FLAVOR_PRUNE", + "NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", + "NEUTRON_ROUTER_FLAVOR_READY_RETRIES", + "NEUTRON_ROUTER_FLAVOR_READY_DELAY", + "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_common_import_is_safe_with_bad_runtime_env(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "maybe") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", "maybe") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "soon") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "later") + + importlib.reload(common) + + +def test_disabled_hook_config_does_not_parse_runtime_env(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "maybe") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", "maybe") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "soon") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "later") + + config = hook.build_hook_config() + + assert config["onStartup"] == 10 + assert "kubernetes" not in config + + +def test_crontab_does_not_enable_disabled_hook(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") + 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_hook_inputs: 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) + + hook_inputs = hook.load_router_flavor_hook_inputs() + resources = hook_inputs.resources_to_reconcile + + assert len(resources) == 1 + assert resources[0].name == "dynamic-vrf" + assert resources[0].namespace == "openstack" + assert resources[0].generation == 3 + assert resources[0].flavor["name"] == "dynamic_vrf" + assert resources[0].flavor["driver"] == "neutron_understack.l3_router.vrf.Vrf" + # cloudCredentialsRef is popped into secret_name / cloud_name + assert resources[0].secret_name == "infrasetup" # noqa: S105 + assert resources[0].cloud_name == "understack" + assert "cloudCredentialsRef" not in resources[0].flavor + # Schedule contexts fall through to snapshot parsing, so desired equals + # resources_to_reconcile. + assert hook_inputs.desired_resources_for_prune == resources + assert hook_inputs.deleted_resources == [] + + +# --------------------------------------------------------------------------- +# 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 _drift_context(monkeypatch, tmp_path) -> None: + """Set up a single-flavor schedule binding context for status assertions.""" + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setattr(utils, "_connection_cache", {}) + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": "hourly sync", + "type": "Schedule", + "snapshots": { + common.CRD_BINDING_NAME: [ + {"object": router_flavor_object("pa1410")}, + ] + }, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + +def test_main_reports_plain_success_when_no_profile_drift(monkeypatch, tmp_path): + """The drift-free status message must stay exactly as it was.""" + _drift_context(monkeypatch, tmp_path) + + with ( + mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=mock.MagicMock(), + ), + mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch( + "openstack_sync.hooks.router_flavors.patch_flavor_status" + ) as mock_status, + mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor", return_value=[]), + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + assert mock_status.call_args.args[1] == "Synced" + assert mock_status.call_args.args[2] == "Successfully reconciled router flavor" + + +def test_main_reports_service_profile_drift_in_synced_status(monkeypatch, tmp_path): + """Drift must reach the CR status. + + The flavor is converged, so the status stays Synced -- but reporting a bare + success is how a disabled service profile stays invisible until every router + create against the flavor fails. + """ + _drift_context(monkeypatch, tmp_path) + drift = [ + common.ProfileDrift( + profile_id="prof-a", + driver="neutron_understack.l3_router.vrf.Vrf", + field="is_enabled", + have=False, + want=True, + ) + ] + + with ( + mock.patch( + "openstack_sync.utils.openstack.connection.Connection", + return_value=mock.MagicMock(), + ), + mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch( + "openstack_sync.hooks.router_flavors.patch_flavor_status" + ) as mock_status, + mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=drift + ), + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + # Drift is not a reconcile failure: the flavor still converged. + assert result == 0 + assert mock_status.call_args.args[1] == "Synced" + message = mock_status.call_args.args[2] + assert message.startswith("Successfully reconciled router flavor") + assert "prof-a" in message + assert "is_enabled" in message + + +def test_main_returns_error_when_reconcile_fails(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + 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", return_value=[] + ) as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + assert [call.args[1]["name"] for call in mock_sync.call_args_list] == [ + "dynamic-vrf", + "pa1410", + ] + mock_prune.assert_called_once() + assert mock_prune.call_args.args[0] is conn + assert [flavor["name"] for flavor in mock_prune.call_args.args[1]] == [ + "dynamic-vrf", + "pa1410", + ] + + +def test_main_prunes_deleted_only_credentials(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + conn = mock.MagicMock() + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": router_flavor_object("pa1410"), + "snapshots": {common.CRD_BINDING_NAME: []}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ) as mock_connect, + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + mock_connect.assert_called_once_with("infrasetup", "understack") + mock_sync.assert_not_called() + mock_prune.assert_called_once_with(conn, [], authoritative_empty_desired=True) + + +def test_main_returns_error_when_deleted_only_connection_fails(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": router_flavor_object("pa1410"), + "snapshots": {common.CRD_BINDING_NAME: []}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + side_effect=RuntimeError("secret missing"), + ) as mock_connect, + mock.patch( + "openstack_sync.hooks.router_flavors.wait_for_openstack_network" + ) as mock_wait, + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 1 + mock_connect.assert_called_once_with("infrasetup", "understack") + mock_wait.assert_not_called() + mock_sync.assert_not_called() + mock_prune.assert_not_called() + + +def test_main_returns_error_when_deleted_only_prune_fails(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + conn = mock.MagicMock() + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": router_flavor_object("pa1410"), + "snapshots": {common.CRD_BINDING_NAME: []}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ) as mock_connect, + mock.patch( + "openstack_sync.hooks.router_flavors.wait_for_openstack_network" + ) as mock_wait, + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors", + side_effect=RuntimeError("delete failed"), + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 1 + mock_connect.assert_called_once_with("infrasetup", "understack") + mock_wait.assert_called_once_with(conn) + mock_sync.assert_not_called() + mock_prune.assert_called_once_with(conn, [], authoritative_empty_desired=True) + + +def test_main_ignores_deleted_only_credentials_when_prune_is_disabled( + monkeypatch, tmp_path +): + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "false") + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": router_flavor_object("pa1410"), + "snapshots": {common.CRD_BINDING_NAME: []}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection" + ) as mock_connect, + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + 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.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + active_conn = mock.MagicMock(name="active_conn") + deleted_conn = mock.MagicMock(name="deleted_conn") + + active_object = router_flavor_object("pa1410") + deleted_object = router_flavor_object( + "other-cloud-flavor", + { + "cloudCredentialsRef": { + "secretName": "other-secret", + "cloudName": "other-cloud", + } + }, + ) + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": deleted_object, + "snapshots": { + common.CRD_BINDING_NAME: [{"object": active_object}], + }, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + def connect(secret_name, cloud_name): + if (secret_name, cloud_name) == ("infrasetup", "understack"): + return active_conn + if (secret_name, cloud_name) == ("other-secret", "other-cloud"): + return deleted_conn + raise AssertionError((secret_name, cloud_name)) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + side_effect=connect, + ), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch("openstack_sync.hooks.router_flavors.sync_flavor", return_value=[]), + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + assert mock_prune.call_args_list == [ + mock.call(active_conn, [mock.ANY]), + mock.call(deleted_conn, [], authoritative_empty_desired=True), + ] + 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.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "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", return_value=[] + ) as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + mock_connect.assert_not_called() + mock_sync.assert_not_called() + mock_prune.assert_not_called() + + +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() + + +# --------------------------------------------------------------------------- +# Event-driven scenarios: reconcile only the changed CR while prune uses +# the full snapshot delivered by shell-operator. +# --------------------------------------------------------------------------- + + +def router_flavor_object_with_status( + name: str, + *, + generation: int = 3, + status: dict | None = None, + spec: dict | None = None, +) -> dict: + """Build a NeutronRouterFlavor object with optional status/generation. + + Mirrors :func:`router_flavor_object` but allows tests to control the + metadata.generation and status subresource used by the Modified-event + status-current guard. + """ + obj = router_flavor_object(name, spec) + obj["metadata"]["generation"] = generation + if status is not None: + obj["status"] = status + return obj + + +def test_added_event_reconciles_only_added_resource(monkeypatch, tmp_path): + """An Added event reconciles only the new CR; prune sees the full snapshot. + + Regression guard for the noise-on-create scenario: creating a new CR must + not reconcile the four unrelated CRs already present in Neutron. + """ + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + conn = mock.MagicMock() + + added = router_flavor_object("crud_svi") + other_names = ["dynamic_vrf", "pa1410", "static_vrf", "svi"] + snapshot_objects = [router_flavor_object(name) for name in other_names] + [added] + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Added", + "object": added, + "snapshots": { + common.CRD_BINDING_NAME: [ + {"object": obj} for obj in snapshot_objects + ], + }, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + assert [call.args[1]["name"] for call in mock_sync.call_args_list] == ["crud_svi"] + mock_prune.assert_called_once() + prune_flavors = mock_prune.call_args.args[1] + assert sorted(flavor["name"] for flavor in prune_flavors) == sorted( + other_names + ["crud_svi"] + ) + assert "authoritative_empty_desired" not in mock_prune.call_args.kwargs + + +def test_deleted_event_reconciles_none_and_prunes_with_remaining_snapshot( + monkeypatch, tmp_path +): + """Delete of one CR while others remain in the same credential group. + + Regression guard for the exact log scenario: deleting crud_svi while + four remain must not reconcile any of the remaining flavors. Prune + receives the snapshot of the remaining four and does NOT set + authoritative_empty_desired, so it only removes the flavor that is + absent from the snapshot. + """ + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + conn = mock.MagicMock() + + deleted = router_flavor_object("crud_svi") + remaining_names = ["dynamic_vrf", "pa1410", "static_vrf", "svi"] + remaining = [router_flavor_object(name) for name in remaining_names] + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": deleted, + "snapshots": { + common.CRD_BINDING_NAME: [{"object": obj} for obj in remaining], + }, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + mock_sync.assert_not_called() + mock_prune.assert_called_once() + prune_flavors = mock_prune.call_args.args[1] + assert sorted(flavor["name"] for flavor in prune_flavors) == sorted(remaining_names) + # authoritative_empty_desired must NOT be set: snapshot still has items. + assert mock_prune.call_args.kwargs.get("authoritative_empty_desired") is not True + + +def test_modified_event_skipped_when_status_already_current(monkeypatch, tmp_path): + """Status-only Modified events must not trigger OpenStack work. + + The hook's own status patch surfaces as a Modified event with the same + metadata.generation. If status already reflects that generation as Synced, + the hook must skip both reconcile and prune to break the feedback loop. + """ + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + obj = router_flavor_object_with_status( + "crud_svi", + generation=7, + status={ + "syncStatus": "Synced", + "observedGeneration": 7, + "message": "Successfully reconciled router flavor", + }, + ) + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Modified", + "object": obj, + "snapshots": {common.CRD_BINDING_NAME: [{"object": obj}]}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection" + ) as mock_connect, + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, + mock.patch( + "openstack_sync.hooks.router_flavors.prune_removed_flavors" + ) as mock_prune, + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + mock_connect.assert_not_called() + mock_sync.assert_not_called() + mock_prune.assert_not_called() + + +def test_modified_event_reconciles_when_generation_bumped(monkeypatch, tmp_path): + """A real spec change bumps metadata.generation past observedGeneration. + + The status-current guard must not skip these events: the spec is drifted + from what the operator last reconciled, so reconcile must run. + """ + clear_env(monkeypatch) + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + conn = mock.MagicMock() + + obj = router_flavor_object_with_status( + "crud_svi", + generation=8, + status={ + "syncStatus": "Synced", + "observedGeneration": 7, + "message": "Successfully reconciled router flavor", + }, + ) + + context_path = write_binding_context( + tmp_path, + [ + { + "binding": common.CRD_BINDING_NAME, + "type": "Event", + "watchEvent": "Modified", + "object": obj, + "snapshots": {common.CRD_BINDING_NAME: [{"object": obj}]}, + } + ], + ) + monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + + with ( + mock.patch( + "openstack_sync.hooks.router_flavors.get_openstack_connection", + return_value=conn, + ), + mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), + mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), + mock.patch( + "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] + ) as mock_sync, + mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), + mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + ): + result = hook.main() + + assert result == 0 + assert [call.args[1]["name"] for call in mock_sync.call_args_list] == ["crud_svi"] 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..fa38a3557 --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_prune.py @@ -0,0 +1,285 @@ +"""Tests for Neutron router flavor prune behavior.""" + +from __future__ import annotations + +from types import SimpleNamespace +from typing import Any + +from openstack_sync.plugins.neutron.router_flavors import delete +from openstack_sync.plugins.neutron.router_flavors import ( + router_flavors_common as common, +) + + +def enable_prune(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + + +def enable_profile_delete(monkeypatch): + monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DELETE_UNUSED_PROFILES", "true") + + +class FakeNetwork: + def __init__(self, flavors: list[dict[str, Any]], profiles: dict[str, Any]): + self._flavors = flavors + self._profiles = profiles + self.deleted_flavors: list[str] = [] + self.flavor_list_calls = 0 + + def flavors(self, service_type: str | None = None) -> list[dict[str, Any]]: + self.flavor_list_calls += 1 + return [ + flavor + for flavor in self._flavors + if service_type is None or flavor["service_type"] == service_type + ] + + def routers(self, flavor_id: str) -> list[dict[str, Any]]: + return [] + + def service_profiles(self) -> list[Any]: + return [p for p in self._profiles.values() if p is not None] + + def get_service_profile(self, profile_id: str) -> Any: + return self._profiles.get(profile_id) + + def delete_flavor( + self, flavor: dict[str, Any], ignore_missing: bool = True + ) -> None: + self.deleted_flavors.append(flavor["id"]) + self._flavors = [ + current for current in self._flavors if current["id"] != flavor["id"] + ] + + +def test_prune_keeps_manual_flavor_with_managed_service_profile(monkeypatch): + enable_prune(monkeypatch) + flavor = { + "id": "manual-flavor-id", + "name": "manual-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": "created outside the operator", + "service_profile_ids": ["managed-profile-id"], + } + profile = SimpleNamespace( + id="managed-profile-id", + driver="neutron_understack.l3_router.vrf.Vrf", + meta_info=common.managed_meta_info({"vni_alloc": "auto"}), + ) + conn = SimpleNamespace(network=FakeNetwork([flavor], {profile.id: profile})) + + delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == [] + + +def test_prune_keeps_managed_flavors_when_desired_list_is_empty(monkeypatch): + enable_prune(monkeypatch) + flavor = { + "id": "managed-flavor-id", + "name": "removed-managed-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [], + } + conn = SimpleNamespace(network=FakeNetwork([flavor], {})) + + delete.prune_removed_flavors(conn, []) + + assert conn.network.deleted_flavors == [] + + +def test_prune_deletes_managed_flavors_when_empty_desired_is_explicit(monkeypatch): + enable_prune(monkeypatch) + flavor = { + "id": "managed-flavor-id", + "name": "removed-managed-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [], + } + conn = SimpleNamespace(network=FakeNetwork([flavor], {})) + + delete.prune_removed_flavors(conn, [], authoritative_empty_desired=True) + + assert conn.network.deleted_flavors == ["managed-flavor-id"] + + +def test_prune_deletes_removed_managed_flavor(monkeypatch): + enable_prune(monkeypatch) + flavor = { + "id": "managed-flavor-id", + "name": "removed-managed-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [], + } + conn = SimpleNamespace(network=FakeNetwork([flavor], {})) + + delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == ["managed-flavor-id"] + + +def test_prune_deletes_removed_managed_flavor_and_unused_profile(monkeypatch): + enable_prune(monkeypatch) + enable_profile_delete(monkeypatch) + profile = _make_orphan_profile("managed-profile-id") + flavor = { + "id": "managed-flavor-id", + "name": "removed-managed-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [profile.id], + } + network = FakeNetworkWithProfiles([flavor], {profile.id: profile}) + conn = SimpleNamespace(network=network) + + delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert network.deleted_flavors == ["managed-flavor-id"] + assert network.deleted_profiles == ["managed-profile-id"] + + +# --------------------------------------------------------------------------- +# prune_orphaned_service_profiles: second-pass GC for partial-failure orphans +# --------------------------------------------------------------------------- + + +class FakeNetworkWithProfiles(FakeNetwork): + """FakeNetwork extended to track service profile deletes.""" + + def __init__( + self, + flavors: list[dict[str, Any]], + profiles: dict[str, Any], + ): + super().__init__(flavors, profiles) + self.deleted_profiles: list[str] = [] + + def service_profiles(self) -> list[Any]: + return [p for p in self._profiles.values() if p is not None] + + def delete_service_profile(self, profile: Any, ignore_missing: bool = True) -> None: + profile_id = profile.id if hasattr(profile, "id") else profile["id"] + self.deleted_profiles.append(profile_id) + self._profiles[profile_id] = None + + def get_service_profile(self, profile_id: str) -> Any: + profile = self._profiles.get(profile_id) + if profile is None: + raise Exception(f"Profile {profile_id} not found") + return profile + + +def _make_orphan_profile( + profile_id: str, driver: str = "neutron_understack.l3_router.vrf.Vrf" +): + """Return a SimpleNamespace service profile with operator ownership markers.""" + import types + + return types.SimpleNamespace( + id=profile_id, + driver=driver, + meta_info=common.managed_meta_info({"vni_alloc": "auto"}), + ) + + +def test_prune_orphaned_profiles_deletes_unattached_managed_profile(monkeypatch): + """A managed profile with no parent flavor is deleted by the second pass.""" + enable_prune(monkeypatch) + enable_profile_delete(monkeypatch) + + orphan = _make_orphan_profile("orphan-profile-id") + # No flavors in Neutron; the orphan's parent was already deleted. + network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) + conn = SimpleNamespace(network=network) + + delete.prune_orphaned_service_profiles( + conn, + {}, + delete.service_profile_attachment_counts([]), + ) + + assert "orphan-profile-id" in network.deleted_profiles + + +def test_prune_orphaned_profiles_keeps_non_managed_profile(monkeypatch): + """A profile without the operator ownership marker is not touched.""" + enable_profile_delete(monkeypatch) + import types + + unmanaged = types.SimpleNamespace( + id="unmanaged-profile-id", + driver="neutron_understack.l3_router.vrf.Vrf", + meta_info={"vni_alloc": "auto"}, # no MANAGED_META_INFO_KEY + ) + network = FakeNetworkWithProfiles(flavors=[], profiles={unmanaged.id: unmanaged}) + conn = SimpleNamespace(network=network) + + delete.prune_orphaned_service_profiles( + conn, + {}, + delete.service_profile_attachment_counts([]), + ) + + assert network.deleted_profiles == [] + + +def test_prune_removed_flavors_cleans_up_orphaned_profile_on_next_run(monkeypatch): + """Simulate a partial failure: flavor deleted, profile cleanup threw last run. + + On the next prune_removed_flavors call the flavor no longer exists in + Neutron, so the flavor loop skips it. The second-pass GC should find and + delete the orphaned profile. + """ + enable_prune(monkeypatch) + enable_profile_delete(monkeypatch) + + # Neutron state after the partial failure: flavor is gone, profile remains. + orphan = _make_orphan_profile("orphan-after-partial-failure") + network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) + conn = SimpleNamespace(network=network) + + # desired list is non-empty so the empty-list guard does not fire. + delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert "orphan-after-partial-failure" in network.deleted_profiles + + +def test_prune_removed_flavors_lists_l3_flavors_once_for_profile_checks(monkeypatch): + enable_prune(monkeypatch) + enable_profile_delete(monkeypatch) + + removed_profile = _make_orphan_profile("removed-profile-id") + orphan_profile = _make_orphan_profile("orphan-profile-id") + attached_profile = _make_orphan_profile("attached-profile-id") + removed_flavor = { + "id": "removed-flavor-id", + "name": "removed-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [removed_profile.id], + } + kept_flavor = { + "id": "kept-flavor-id", + "name": "kept-flavor", + "service_type": common.DEFAULT_SERVICE_TYPE, + "description": common.managed_flavor_description("created by operator"), + "service_profile_ids": [attached_profile.id], + } + network = FakeNetworkWithProfiles( + [removed_flavor, kept_flavor], + { + removed_profile.id: removed_profile, + orphan_profile.id: orphan_profile, + attached_profile.id: attached_profile, + }, + ) + conn = SimpleNamespace(network=network) + + delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert network.flavor_list_calls == 1 + assert network.deleted_flavors == ["removed-flavor-id"] + assert network.deleted_profiles == ["removed-profile-id", "orphan-profile-id"] diff --git a/python/openstack-sync/tests/test_router_flavors_update.py b/python/openstack-sync/tests/test_router_flavors_update.py new file mode 100644 index 000000000..d6978f15e --- /dev/null +++ b/python/openstack-sync/tests/test_router_flavors_update.py @@ -0,0 +1,377 @@ +"""Tests for update.ensure_flavor and update.sync_flavor. + +Covers the service_type guard, is_enabled drift reconcile (both directions), +create-with-is_enabled-from-spec, and the sync_flavor spec pass-through. +""" + +from __future__ import annotations + +import types +from typing import Any +from unittest import mock + +import pytest + +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.neutron.router_flavors import update +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + FLAVOR_DESCRIPTION_MARKER, +) +from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( + ProfileDrift, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +_NAME = "test-flavor" +_SERVICE_TYPE = "L3_ROUTER_NAT" +_DESCRIPTION = "my flavor" + + +def _make_flavor( + *, + name: str = _NAME, + service_type: str = _SERVICE_TYPE, + description: str = f"{_DESCRIPTION} {FLAVOR_DESCRIPTION_MARKER}", + is_enabled: bool = True, +) -> Any: + return types.SimpleNamespace( + name=name, + service_type=service_type, + description=description, + is_enabled=is_enabled, + ) + + +# --------------------------------------------------------------------------- +# service_type mismatch — must raise ConfigError +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_raises_on_service_type_mismatch(): + flavor = _make_flavor(service_type="DIFFERENT_TYPE") + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + with pytest.raises(ConfigError, match="service_type"): + update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True + ) + + +def test_ensure_flavor_error_message_contains_both_service_types(): + flavor = _make_flavor(service_type="WRONG") + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + with pytest.raises(ConfigError) as exc_info: + update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True + ) + msg = str(exc_info.value) + assert "WRONG" in msg + assert _SERVICE_TYPE in msg + assert _NAME in msg + + +# --------------------------------------------------------------------------- +# is_enabled reconcile +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_reenables_disabled_flavor(caplog): + """Neutron has is_enabled=False but spec says True → update to True.""" + flavor = _make_flavor(is_enabled=False) + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + conn.network.update_flavor.return_value = _make_flavor(is_enabled=True) + with caplog.at_level("INFO", logger="openstack_sync"): + update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True + ) + + conn.network.update_flavor.assert_called_once() + _, kwargs = conn.network.update_flavor.call_args + assert kwargs["is_enabled"] is True + assert "is_enabled drift" in caplog.text + assert "have=False" in caplog.text + assert "want=True" in caplog.text + + +def test_ensure_flavor_disables_enabled_flavor_when_spec_disables(caplog): + """Neutron has is_enabled=True but spec says False → update to False.""" + flavor = _make_flavor(is_enabled=True) + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + conn.network.update_flavor.return_value = _make_flavor(is_enabled=False) + with caplog.at_level("INFO", logger="openstack_sync"): + update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False + ) + + conn.network.update_flavor.assert_called_once() + _, kwargs = conn.network.update_flavor.call_args + assert kwargs["is_enabled"] is False + assert "is_enabled drift" in caplog.text + assert "have=True" in caplog.text + assert "want=False" in caplog.text + + +def test_ensure_flavor_no_update_when_both_disabled(): + """Neutron has is_enabled=False and spec says False → no Neutron call.""" + flavor = _make_flavor(is_enabled=False) + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + result = update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False + ) + + conn.network.update_flavor.assert_not_called() + assert result is flavor + + +def test_ensure_flavor_reenables_disabled_flavor_even_when_description_matches(): + """is_enabled=False must trigger an update even if description is current.""" + flavor = _make_flavor(is_enabled=False) + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + conn.network.update_flavor.return_value = _make_flavor(is_enabled=True) + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) + + conn.network.update_flavor.assert_called_once() + + +def test_ensure_flavor_no_update_when_already_correct(): + """No Neutron call when description and is_enabled are already correct.""" + flavor = _make_flavor(is_enabled=True) + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + result = update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True + ) + + conn.network.update_flavor.assert_not_called() + assert result is flavor + + +# --------------------------------------------------------------------------- +# description drift still triggers update +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_updates_changed_description(): + flavor = _make_flavor(description="old description") + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + conn.network.update_flavor.return_value = _make_flavor() + update.ensure_flavor( + conn, _NAME, _SERVICE_TYPE, "new description", is_enabled=True + ) + + conn.network.update_flavor.assert_called_once() + + +def test_ensure_flavor_adds_missing_marker(): + flavor = _make_flavor(description="no marker here") + with mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=flavor, + ): + conn = mock.MagicMock() + conn.network.update_flavor.return_value = _make_flavor() + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) + + conn.network.update_flavor.assert_called_once() + _, kwargs = conn.network.update_flavor.call_args + assert FLAVOR_DESCRIPTION_MARKER in kwargs["description"] + + +# --------------------------------------------------------------------------- +# flavor not found — creates it +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_creates_when_not_found(): + with ( + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=None, + ), + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.create_flavor", + return_value=_make_flavor(), + ) as mock_create, + ): + conn = mock.MagicMock() + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) + + mock_create.assert_called_once_with( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True + ) + + +def test_ensure_flavor_creates_with_is_enabled_from_spec(): + """A CR that opts out of enabled must create the Neutron flavor disabled.""" + with ( + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", + return_value=None, + ), + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.create_flavor", + return_value=_make_flavor(is_enabled=False), + ) as mock_create, + ): + conn = mock.MagicMock() + update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False) + + mock_create.assert_called_once_with( + conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False + ) + + +# --------------------------------------------------------------------------- +# sync_flavor: reads is_enabled from the CR spec +# --------------------------------------------------------------------------- + + +def _sync_flavor_config(*, is_enabled: bool) -> dict[str, Any]: + """Build a CR-shaped flavor_config. + + ``is_enabled`` mirrors the CRD default (true) that the k8s API server + materialises on admission; every real spec reaching the hook carries it. + """ + return { + "name": _NAME, + "description": _DESCRIPTION, + "service_type": _SERVICE_TYPE, + "is_enabled": is_enabled, + "service_profiles": [ + { + "driver": "neutron_understack.l3_router.vrf.Vrf", + "description": "profile description", + "meta_info": {}, + "is_enabled": True, + } + ], + } + + +def _sync_flavor_mocks(flavor: Any): + """Yield the mock stack used by sync_flavor pass-through tests. + + Uses a real openstacksdk-shaped flavor (SimpleNamespace with + ``service_profile_ids``) so ``render_flavor`` succeeds when + ``sync_flavor`` logs the reconciled result. + """ + rendered = types.SimpleNamespace( + id="flavor-id", + name=_NAME, + service_type=_SERVICE_TYPE, + description=flavor.description, + is_enabled=flavor.is_enabled, + service_profile_ids=["profile-id"], + ) + return ( + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.update.ensure_flavor", + return_value=rendered, + ), + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create.ensure_profile" + ), + mock.patch( + "openstack_sync.plugins.neutron.router_flavors.create." + "reconcile_flavor_profiles", + return_value=rendered, + ), + ) + + +def test_sync_flavor_passes_is_enabled_true_from_spec(): + """The value the k8s API server put on the CR reaches ensure_flavor.""" + conn = mock.MagicMock() + flavor = _make_flavor(is_enabled=True) + ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) + with ensure_patch as mock_ensure, profile_patch, attached_patch: + update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) + + assert mock_ensure.call_args.kwargs["is_enabled"] is True + + +def test_sync_flavor_passes_is_enabled_false_from_spec(): + conn = mock.MagicMock() + flavor = _make_flavor(is_enabled=False) + ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) + with ensure_patch as mock_ensure, profile_patch, attached_patch: + update.sync_flavor(conn, _sync_flavor_config(is_enabled=False), {}) + + assert mock_ensure.call_args.kwargs["is_enabled"] is False + + +# --------------------------------------------------------------------------- +# sync_flavor: service profile drift reaches the caller +# --------------------------------------------------------------------------- + + +def test_sync_flavor_returns_empty_drift_when_nothing_drifted(): + conn = mock.MagicMock() + flavor = _make_flavor(is_enabled=True) + ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) + with ensure_patch, profile_patch, attached_patch: + result = update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) + + assert result == [] + + +def test_sync_flavor_propagates_profile_drift(): + """Drift collected while resolving profiles is returned to the caller. + + The flavor itself is converged, so this is not a reconcile failure -- but + the caller must be able to qualify the status it reports. + """ + conn = mock.MagicMock() + flavor = _make_flavor(is_enabled=True) + ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) + drifted = ProfileDrift( + profile_id="prof-a", + driver="neutron_understack.l3_router.vrf.Vrf", + field="is_enabled", + have=False, + want=True, + ) + + def ensure_profile(conn, name, profile_spec, profile_cache, drift=None): + if drift is not None: + drift.append(drifted) + return types.SimpleNamespace(id="prof-a") + + with ensure_patch, profile_patch as mock_profile, attached_patch: + mock_profile.side_effect = ensure_profile + result = update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) + + assert result == [drifted] diff --git a/schema/openstack-sync/neutron-router-flavor.schema.json b/schema/openstack-sync/neutron-router-flavor.schema.json index 6d3cbebdc..c24c90f58 100644 --- a/schema/openstack-sync/neutron-router-flavor.schema.json +++ b/schema/openstack-sync/neutron-router-flavor.schema.json @@ -26,6 +26,24 @@ "type": "object", "additionalProperties": false, "properties": { + "cloudCredentialsRef": { + "description": "Reference to a Kubernetes Secret containing the OpenStack clouds.yaml.", + "type": "object", + "additionalProperties": false, + "required": ["secretName", "cloudName"], + "properties": { + "secretName": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "cloudName": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, "name": { "description": "Neutron router flavor name.", "type": "string", @@ -52,6 +70,31 @@ "type": "string", "maxLength": 1024 }, + "is_enabled": { + "description": "Whether the Neutron router flavor is enabled.", + "type": "boolean", + "default": true + }, + "service_profiles": { + "description": "Service profiles to associate with this flavor. The operator find-or-creates each profile by (driver, meta_info) and reconciles the flavor's set of bound profiles to match.", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/serviceProfileSpec" + } + } + }, + "required": [ + "name", + "service_profiles" + ] + }, + "serviceProfileSpec": { + "description": "A single service profile entry.", + "type": "object", + "additionalProperties": false, + "required": ["driver"], + "properties": { "driver": { "description": "Service profile driver class.", "type": "string", @@ -59,24 +102,20 @@ "maxLength": 1024, "pattern": "^[A-Za-z_][A-Za-z0-9_]*(\\.[A-Za-z_][A-Za-z0-9_]*)+$" }, - "profile_description": { + "description": { "description": "Description stored on the Neutron service profile.", "type": "string", "maxLength": 1024 }, - "profile_id": { - "description": "Existing Neutron service profile ID to attach instead of creating or discovering one.", - "type": "string", - "format": "uuid" + "is_enabled": { + "description": "Whether the service profile is enabled in Neutron.", + "type": "boolean", + "default": true }, "meta_info": { "$ref": "#/definitions/metaInfo" } - }, - "required": [ - "name", - "driver" - ] + } }, "metaInfo": { "description": "Service profile metainfo payload.", From 357b25daef4badd769ca8fa0e979078b8e088019 Mon Sep 17 00:00:00 2001 From: haseeb Date: Sat, 22 Aug 2026 00:27:23 +0530 Subject: [PATCH 2/2] refactoring split The refactoring split the monolithic hook into: Generic framework (framework.py) - Reusable reconcile-then-prune lifecycle Plugin abstraction (SyncPlugin class) - Clear interfaces for future plugins Clean separation - Hook mechanics separate from Neutron business logic Better testability - Framework tested once, plugins implement simple reconcile/prune methods --- ...ck.rackspace.net_neutronrouterflavors.yaml | 6 - python/openstack-sync/README.md | 122 +- .../openstack_sync/hooks/common.py | 12 +- .../openstack_sync/hooks/framework.py | 619 +++++++++ .../openstack_sync/hooks/placeholder.py | 84 +- .../openstack_sync/hooks/router_flavors.py | 717 +--------- .../openstack_sync/plugins/common.py | 135 +- .../plugins/neutron/router_flavors/config.py | 19 + .../plugins/neutron/router_flavors/create.py | 350 ----- .../plugins/neutron/router_flavors/delete.py | 273 ---- .../plugins/neutron/router_flavors/markers.py | 106 ++ .../plugins/neutron/router_flavors/prune.py | 169 +++ .../neutron/router_flavors/reconcile.py | 420 ++++++ .../router_flavors/router_flavors_common.py | 283 ---- .../plugins/neutron/router_flavors/update.py | 143 -- python/openstack-sync/pyproject.toml | 8 +- python/openstack-sync/tests/conftest.py | 62 +- python/openstack-sync/tests/test_framework.py | 767 +++++++++++ .../tests/test_plugins_common.py | 24 +- python/openstack-sync/tests/test_prune.py | 228 ++++ python/openstack-sync/tests/test_reconcile.py | 758 +++++++++++ .../tests/test_router_flavors.py | 349 ----- .../tests/test_router_flavors_create.py | 642 --------- .../tests/test_router_flavors_hook.py | 1150 ++++------------- .../tests/test_router_flavors_prune.py | 285 ---- .../tests/test_router_flavors_update.py | 377 ------ 26 files changed, 3615 insertions(+), 4493 deletions(-) create mode 100644 python/openstack-sync/openstack_sync/hooks/framework.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/config.py delete mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py delete mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/markers.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/prune.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/reconcile.py delete mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py delete mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py create mode 100644 python/openstack-sync/tests/test_framework.py create mode 100644 python/openstack-sync/tests/test_prune.py create mode 100644 python/openstack-sync/tests/test_reconcile.py delete mode 100644 python/openstack-sync/tests/test_router_flavors.py delete mode 100644 python/openstack-sync/tests/test_router_flavors_create.py delete mode 100644 python/openstack-sync/tests/test_router_flavors_prune.py delete mode 100644 python/openstack-sync/tests/test_router_flavors_update.py diff --git a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml index 52112ed38..8778b4d5b 100644 --- a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml +++ b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml @@ -107,12 +107,6 @@ spec: disables an operator-managed flavor without deleting it. type: boolean default: true - service_provider: - description: Optional Neutron service provider name used when generating Neutron configuration. - type: string - minLength: 1 - maxLength: 255 - pattern: ^[A-Za-z0-9._-]+$ description: description: Description stored on the Neutron router flavor. type: string diff --git a/python/openstack-sync/README.md b/python/openstack-sync/README.md index a32ab3a90..d7e6c528b 100644 --- a/python/openstack-sync/README.md +++ b/python/openstack-sync/README.md @@ -2,7 +2,121 @@ Shell-operator package for OpenStack reconciliation hooks. -The operator image ships with a no-op placeholder hook and resource-specific -sync hooks under `openstack_sync/hooks/`. The Neutron router flavor hook is -implemented under `openstack_sync/plugins/neutron/router_flavors/` and exposed -to shell-operator as `/hooks/router_flavors.py`. +Each hook reconciles one Kubernetes CRD into one kind of OpenStack resource. The +generic machinery lives in `openstack_sync/hooks/framework.py`; a plugin supplies +only the parts that are specific to its resource. + +## Layout + +``` +openstack_sync/ + utils.py Kubernetes Secret access + memoised connections + hooks/ + common.py binding-context I/O, CR status patching + framework.py HookConfig, SyncPlugin, run_sync(), run_hook() + placeholder.py connectivity probe (no CRs) + router_flavors.py NeutronRouterFlavor hook + plugins/ + common.py OpenStack helpers shared by all plugins + neutron/router_flavors/ + config.py plugin constants + markers.py ownership markers + reconcile.py converge one CR + prune.py delete resources whose CR was removed +``` + +## What the framework does for you + +`run_sync` groups CRs by the credentials in `spec.cloudCredentialsRef`, opens one +connection per credential group, waits for the OpenStack service, reconciles each +CR, patches `Synced`/`Failed` onto the CR status, and then prunes. If any +reconcile fails it **skips the prune entirely** — a failed reconcile means the +desired state is unknown, so deleting anything would be unsafe. + +`run_hook` handles the shell-operator calling convention: `--config`, logging, +reading the binding context, and the exit code. + +## Adding a plugin + +1. **Write the CRD** in `components/openstack-sync-operator/crds/`. Include a + `status` subresource and a required `spec.cloudCredentialsRef` with + `secretName` and `cloudName` — the framework relies on both. Put validation + (`required`, `enum`, `minLength`, `default`) in the schema so the API server + rejects bad CRs and the Python side does not have to re-check them. + +2. **Register it** in `components/openstack-sync-operator/values.yaml`: + + ```yaml + plugins: + myResource: false # opt in per site + pluginData: + myResource: + hook: + path: /hooks/my_resource.py + crd: crds/_.yaml + envPrefix: MY_RESOURCE + env: + SYNC_CRONTAB: "0 * * * *" + ``` + + The chart derives `MY_RESOURCE_ENABLED`, `_CRD_API_VERSION`, `_CRD_KIND`, + `_CRD_RESOURCE` and `_STATUS_ENABLED` from the CRD file, and turns each `env` + key into `MY_RESOURCE_`. `HookConfig.from_env` reads exactly that set, so + the chart and Python cannot drift apart. + +3. **Write the plugin package** under `plugins///` with the + same four modules as `router_flavors`: `config.py` (constants), `markers.py` + (how you record that the operator owns a resource), `reconcile.py`, `prune.py`. + +4. **Write the hook** — subclass `SyncPlugin` and wire it up: + + ```python + class MyResourcePlugin(SyncPlugin): + noun = "my resource" + + def wait_for_api(self, conn) -> None: ... + + def reconcile(self, conn, spec, cache) -> list[str]: + return reconcile_module.sync(conn, spec, cache) + + def prune(self, conn, desired_specs, *, authoritative_empty) -> None: + if self.config.prune: + prune_module.prune(conn, desired_specs, + authoritative_empty=authoritative_empty) + + def main() -> int: + def run(contexts): + if not hook_enabled(ENV_PREFIX): + return 0 + config = HookConfig.from_env(ENV_PREFIX, binding_name=BINDING_NAME) + return run_sync(MyResourcePlugin(config), hook_inputs(contexts, config)) + + return run_hook(lambda: build_crd_hook_config(ENV_PREFIX, BINDING_NAME), run) + ``` + + `wait_for_api` and `reconcile` are required; `new_cache` and `prune` have + working defaults. + +## Two rules worth knowing + +**Only touch what you own.** Every plugin records ownership on the resources it +creates, and only ever updates or deletes resources carrying that marker. This is +what makes the operator safe to run against a cloud that also has hand-made +resources. Never adopt an existing resource by stamping the marker onto it — +that enrols somebody else's resource for eventual deletion. + +**Report what you cannot fix.** `reconcile` returns a list of notes. Use it for +state that diverges from the spec but that OpenStack will not let the operator +correct — for example Neutron rejects `update_service_profile` with a 409 while +the profile is bound to any flavor. The resource is still `Synced`, but the notes +appear on the CR status and in the logs so an operator can act. Raise an +exception only for an actual failure. + +## Tests + +```sh +.venv/bin/python -m pytest tests/ -q +``` + +`tests/test_framework.py` exercises the driver with a stub plugin and no +OpenStack at all — read it first to understand the contract a plugin gets. diff --git a/python/openstack-sync/openstack_sync/hooks/common.py b/python/openstack-sync/openstack_sync/hooks/common.py index 51f987efc..3191845f7 100644 --- a/python/openstack-sync/openstack_sync/hooks/common.py +++ b/python/openstack-sync/openstack_sync/hooks/common.py @@ -49,12 +49,20 @@ def int_or_none(value: Any) -> int | None: def read_binding_context() -> list[dict[str, Any]]: - """Read and parse the shell-operator binding context from BINDING_CONTEXT_PATH.""" + """Read and parse the shell-operator binding context. + + An absent ``BINDING_CONTEXT_PATH`` or an empty file yields no contexts; + shell-operator does invoke hooks with nothing to do. Malformed JSON raises + :exc:`json.JSONDecodeError`, a :exc:`ValueError`. + """ path = os.environ.get("BINDING_CONTEXT_PATH") if not path: return [] with open(path, encoding="utf-8") as f: - contexts = json.load(f) + raw = f.read() + if not raw.strip(): + return [] + contexts = json.loads(raw) if not isinstance(contexts, list): raise ValueError("Shell-operator binding context must be a list") return contexts diff --git a/python/openstack-sync/openstack_sync/hooks/framework.py b/python/openstack-sync/openstack_sync/hooks/framework.py new file mode 100644 index 000000000..6f394c82a --- /dev/null +++ b/python/openstack-sync/openstack_sync/hooks/framework.py @@ -0,0 +1,619 @@ +"""Framework for CR-driven OpenStack resource sync plugins. + +A plugin supplies four things: how to wait for its OpenStack service, how to +converge one CR spec, an optional per-credential-group cache, and an optional +prune. This module supplies everything else -- shell-operator hook config, +credential grouping, connection setup, per-resource status patching, the +reconcile-then-prune ordering, and the exit code contract. + +See ``README.md`` for the steps to add a plugin. +""" + +from __future__ import annotations + +import json +import logging +import os +import sys +from abc import ABC +from abc import abstractmethod +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from openstack_sync.hooks.common import configure_logging +from openstack_sync.hooks.common import patch_resource_status +from openstack_sync.hooks.common import read_binding_context +from openstack_sync.hooks.common import snapshot_items +from openstack_sync.hooks.common import synchronization_items +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import env_bool +from openstack_sync.plugins.common import env_float +from openstack_sync.plugins.common import env_int +from openstack_sync.plugins.common import env_required +from openstack_sync.utils import get_openstack_connection + +LOG = logging.getLogger(__name__) + +#: A plugin's OpenStack credentials: ``(secret_name, cloud_name)``. +CredentialKey = tuple[str, str] + + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class HookConfig: + """Runtime configuration for one hook, built from its chart env prefix. + + The Helm chart injects ``_ENABLED``, ``_CRD_API_VERSION``, + ``_CRD_KIND``, ``_CRD_RESOURCE`` and + ``_STATUS_ENABLED`` for every plugin that declares a CRD, plus one + variable per ``pluginData..hook.env`` key. This dataclass is the + Python half of that contract. + + Nothing here is read at import time. Shell-operator invokes ``--config`` + before the full environment is guaranteed to be present, so ``from_env`` is + called from ``main`` and only once the hook is known to be enabled. + """ + + prefix: str + crd_api_version: str + crd_kind: str + crd_resource: str + binding_name: str + namespace: str | None + status_enabled: bool + prune: bool + sync_crontab: str + ready_retries: int + ready_delay: float + + @classmethod + def from_env(cls, prefix: str, *, binding_name: str) -> HookConfig: + """Build config from the environment the Helm chart injected.""" + return cls( + prefix=prefix, + crd_api_version=env_required(f"{prefix}_CRD_API_VERSION"), + crd_kind=env_required(f"{prefix}_CRD_KIND"), + crd_resource=env_required(f"{prefix}_CRD_RESOURCE"), + binding_name=binding_name, + namespace=os.environ.get("POD_NAMESPACE"), + status_enabled=env_bool(f"{prefix}_STATUS_ENABLED", False), + prune=env_bool(f"{prefix}_PRUNE", False), + sync_crontab=os.environ.get(f"{prefix}_SYNC_CRONTAB", "").strip(), + ready_retries=env_int(f"{prefix}_READY_RETRIES", 30), + ready_delay=env_float(f"{prefix}_READY_DELAY", 10), + ) + + +def hook_enabled(prefix: str) -> bool: + """Return whether the chart enabled the plugin behind *prefix*.""" + return env_bool(f"{prefix}_ENABLED", False) + + +def build_crd_hook_config(prefix: str, binding_name: str) -> dict[str, Any]: + """Return the shell-operator hook config for a CRD-watching plugin. + + When the plugin is disabled the config carries only an ``onStartup`` + binding, because shell-operator requires every hook to declare at least + one binding but the hook must not register Kubernetes watches it will not + service. + """ + hook_config: dict[str, Any] = { + "configVersion": "v1", + "settings": {"executionMinInterval": "30s", "executionBurst": 1}, + } + + if not hook_enabled(prefix): + hook_config["onStartup"] = 10 + return hook_config + + config = HookConfig.from_env(prefix, binding_name=binding_name) + binding: dict[str, Any] = { + "name": config.binding_name, + "apiVersion": config.crd_api_version, + "kind": config.crd_kind, + "executeHookOnEvent": ["Added", "Modified", "Deleted"], + "jqFilter": ".", + "includeSnapshotsFrom": [config.binding_name], + # Dedicated queue so a slow readiness wait or reconcile only delays + # this hook's own tasks, not other hooks sharing the default queue. + "queue": config.binding_name, + } + if config.namespace: + binding["namespace"] = {"nameSelector": {"matchNames": [config.namespace]}} + + hook_config["kubernetes"] = [binding] + if config.sync_crontab: + hook_config["schedule"] = [ + { + "name": "periodic sync", + "crontab": config.sync_crontab, + "includeSnapshotsFrom": [config.binding_name], + "queue": config.binding_name, + } + ] + return hook_config + + +# --------------------------------------------------------------------------- +# Resources +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class SyncResource: + """One CR with its resolved OpenStack credentials. + + ``spec`` is the CR spec with ``cloudCredentialsRef`` removed, so a plugin + sees only its own fields. + """ + + spec: dict[str, Any] + name: str | None + namespace: str | None + generation: int | None + secret_name: str + cloud_name: str + current_status: dict[str, Any] | None = None + + @property + def credentials(self) -> CredentialKey: + return (self.secret_name, self.cloud_name) + + @property + def display_name(self) -> str: + """Return the OpenStack resource name, falling back to the CR name.""" + return str(self.spec.get("name") or self.name or "") + + +@dataclass(frozen=True) +class HookInputs: + """Binding context split by reconciliation purpose. + + The four-way split matters: an event-driven run reconciles only the changed + CRs, but must prune against the *full* desired set from the snapshot, and + must know which credentials a deleted CR used in order to prune at all. + """ + + resources_to_reconcile: list[SyncResource] + desired_resources_for_prune: list[SyncResource] + deleted_resources: list[SyncResource] + prune_credentials: frozenset[CredentialKey] + + +def group_by_credentials( + resources: list[SyncResource], +) -> dict[CredentialKey, list[SyncResource]]: + """Group *resources* by the credentials they authenticate with.""" + grouped: dict[CredentialKey, list[SyncResource]] = {} + for resource in resources: + grouped.setdefault(resource.credentials, []).append(resource) + return grouped + + +def _credentials(resources: list[SyncResource]) -> frozenset[CredentialKey]: + return frozenset(resource.credentials for resource in resources) + + +# --------------------------------------------------------------------------- +# Binding context -> resources +# --------------------------------------------------------------------------- + + +def _resource_from_object(obj: dict[str, Any]) -> SyncResource: + """Build a :class:`SyncResource` from a Kubernetes object. + + The CRD marks ``cloudCredentialsRef.secretName`` and ``.cloudName`` + required with ``minLength: 1``, so the API server rejects a CR missing them + long before a hook sees it. They are read directly rather than re-validated. + """ + spec = dict(obj["spec"]) + creds = spec.pop("cloudCredentialsRef") + metadata = obj.get("metadata", {}) + + return SyncResource( + spec=spec, + name=metadata.get("name"), + namespace=metadata.get("namespace"), + generation=metadata.get("generation"), + secret_name=creds["secretName"], + cloud_name=creds["cloudName"], + current_status=obj.get("status"), + ) + + +def _resources_from_items(items: list[Any]) -> list[SyncResource]: + """Build resources from snapshot or Synchronization items. + + Snapshot items wrap the object as ``{"object": {...}}``; Synchronization + items are the object itself. + """ + resources = [_resource_from_object(item.get("object", item)) for item in items] + return sorted(resources, key=lambda r: str(r.spec.get("name", ""))) + + +def _status_is_current(resource: SyncResource) -> bool: + """Return True when the CR status already records this generation as Synced. + + The hook's own status patch surfaces as a Modified event carrying the same + ``metadata.generation``. Without this check the hook would reconcile itself + in a loop. + """ + status = resource.current_status + return ( + resource.generation is not None + and status is not None + and status.get("syncStatus") == "Synced" + and status.get("observedGeneration") == resource.generation + ) + + +def _split_events( + contexts: list[dict[str, Any]], config: HookConfig +) -> tuple[list[SyncResource], list[SyncResource], frozenset[str]]: + """Split this binding's Event contexts into changed and deleted resources.""" + changed: list[SyncResource] = [] + deleted: list[SyncResource] = [] + watch_events: set[str] = set() + + for context in contexts: + if context.get("binding") != config.binding_name: + continue + if context.get("type") != "Event": + continue + + watch_event = context["watchEvent"] + watch_events.add(watch_event) + + obj = context.get("object") + if not obj: + LOG.warning( + "%s %s event carries no object; ignoring it", + config.crd_kind, + watch_event, + ) + continue + + resource = _resource_from_object(obj) + if watch_event == "Deleted": + deleted.append(resource) + elif watch_event == "Modified" and _status_is_current(resource): + LOG.info( + "Skipping %s Modified event; generation %s is already Synced", + resource.display_name, + resource.generation, + ) + else: + changed.append(resource) + + changed.sort(key=lambda r: str(r.spec.get("name", ""))) + return changed, deleted, frozenset(watch_events) + + +def hook_inputs(contexts: list[dict[str, Any]], config: HookConfig) -> HookInputs: + """Split a shell-operator binding context by reconciliation purpose. + + Event-driven runs reconcile only the changed CRs but prune against the full + desired set from the accompanying snapshot. Schedule and Synchronization + runs reconcile everything they are given. + """ + changed, deleted, watch_events = _split_events(contexts, config) + items = snapshot_items(contexts, config.binding_name) + + if watch_events: + if items is None: + raise ConfigError( + f"Shell-operator {config.binding_name} event context does not " + f"contain {config.binding_name} snapshot objects" + ) + desired = _resources_from_items(items) + # Only prune when something actually changed. A bare Added/Modified for + # an unrelated CR must not trigger a prune sweep. + if changed or deleted or "Deleted" in watch_events: + prune_credentials = _credentials(desired) | _credentials(deleted) + else: + prune_credentials = frozenset() + return HookInputs(changed, desired, deleted, prune_credentials) + + if items is None: + items = synchronization_items(contexts, config.binding_name) + if items is None: + raise ConfigError( + f"Shell-operator binding context does not contain " + f"{config.binding_name} event, snapshot, or synchronization objects" + ) + + resources = _resources_from_items(items) + return HookInputs(resources, resources, [], _credentials(resources)) + + +# --------------------------------------------------------------------------- +# Plugin contract +# --------------------------------------------------------------------------- + + +class SyncPlugin(ABC): + """One CR-driven OpenStack resource sync. + + Subclasses implement ``wait_for_api`` and ``reconcile``; ``new_cache`` and + ``prune`` have usable defaults. ``run_sync`` drives the rest. + """ + + #: Human-readable singular noun used in logs and CR status messages. + noun: str = "resource" + + def __init__(self, config: HookConfig) -> None: + self.config = config + + @abstractmethod + def wait_for_api(self, conn: Any) -> None: + """Block until the OpenStack service this plugin targets is reachable.""" + + @abstractmethod + def reconcile(self, conn: Any, spec: dict[str, Any], cache: Any) -> list[str]: + """Converge one CR spec onto OpenStack. + + Returns human-readable notes about state that diverges from the spec but + that the operator cannot correct on its own -- usually empty. Notes do + not make the reconcile a failure; they qualify the success reported on + the CR status. Raise to signal an actual failure. + """ + + def new_cache(self) -> Any: + """Return a scratch cache shared by every CR in one credential group.""" + return {} + + def prune( + self, + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool, + ) -> None: + """Delete resources whose CR was removed. + + Optional: the default does nothing, which is correct for a plugin whose + resources outlive their CR or that has nothing safe to delete. + """ + LOG.debug("%s defines no prune step", type(self).__name__) + + +# --------------------------------------------------------------------------- +# Status +# --------------------------------------------------------------------------- + + +def _patch_status( + plugin: SyncPlugin, resource: SyncResource, sync_status: str, message: str +) -> None: + config = plugin.config + if not resource.name: + LOG.warning( + "Unable to patch %s status; Kubernetes metadata.name is missing", + config.crd_kind, + ) + return + patch_resource_status( + name=resource.name, + namespace=resource.namespace or config.namespace, + generation=resource.generation, + sync_status=sync_status, + message=message, + crd_resource=config.crd_resource, + crd_kind=config.crd_kind, + status_enabled=config.status_enabled, + current_status=resource.current_status, + ) + + +def synced_message(noun: str, notes: list[str]) -> str: + """Return the Synced message, qualified by anything needing manual action. + + The resource really is converged, so the status stays Synced. Reporting a + bare success while state diverges from the spec is how a broken resource + stays invisible until it is used. + """ + message = f"Successfully reconciled {noun}" + if not notes: + return message + return f"{message}; needs manual action: {'; '.join(notes)}" + + +# --------------------------------------------------------------------------- +# Driver +# --------------------------------------------------------------------------- + + +def run_sync(plugin: SyncPlugin, inputs: HookInputs) -> int: + """Reconcile every CR, then prune. Returns a process exit code.""" + noun = plugin.noun + resources = inputs.resources_to_reconcile + LOG.info("Found %s %s(s) to reconcile", len(resources), noun) + + grouped = group_by_credentials(resources) + grouped_desired = group_by_credentials(inputs.desired_resources_for_prune) + grouped_deleted = group_by_credentials(inputs.deleted_resources) + connections: dict[CredentialKey, Any] = {} + failed = 0 + + for credentials in sorted(grouped): + secret_name, cloud_name = credentials + group = grouped[credentials] + + try: + conn = get_openstack_connection(secret_name, cloud_name) + except Exception as exc: # noqa: BLE001 + failed += len(group) + _fail_group(plugin, group, f"OpenStack connection failed: {exc}") + LOG.error( + "Failed to connect to OpenStack cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + + connections[credentials] = conn + try: + plugin.wait_for_api(conn) + except Exception as exc: # noqa: BLE001 + failed += len(group) + _fail_group(plugin, group, f"OpenStack API unavailable: {exc}") + LOG.error( + "OpenStack API unavailable for cloud=%r secret=%r: %s", + cloud_name, + secret_name, + exc, + ) + continue + + # Shared across every CR in this credential group so lookups made for + # one CR are reused by the next. + cache = plugin.new_cache() + + for resource in group: + try: + notes = plugin.reconcile(conn, resource.spec, cache) + except Exception as exc: # noqa: BLE001 + failed += 1 + _patch_status(plugin, resource, "Failed", str(exc)) + LOG.error( + "Failed to reconcile %s %s: %s", noun, resource.display_name, exc + ) + continue + + if notes: + LOG.warning( + "%s %s converged but needs manual action: %s", + noun.capitalize(), + resource.display_name, + "; ".join(notes), + ) + _patch_status(plugin, resource, "Synced", synced_message(noun, notes)) + + if failed: + # Pruning deletes resources absent from the desired set. A failed + # reconcile means the desired set could not be established, so deleting + # anything now risks removing a resource that should exist. + LOG.error( + "Skipping %s prune because %s resource(s) failed to reconcile", + noun, + failed, + ) + return 1 + + return _run_prune(plugin, inputs, grouped_desired, grouped_deleted, connections) + + +def _fail_group(plugin: SyncPlugin, group: list[SyncResource], message: str) -> None: + for resource in group: + _patch_status(plugin, resource, "Failed", message) + + +def _run_prune( + plugin: SyncPlugin, + inputs: HookInputs, + grouped_desired: dict[CredentialKey, list[SyncResource]], + grouped_deleted: dict[CredentialKey, list[SyncResource]], + connections: dict[CredentialKey, Any], +) -> int: + noun = plugin.noun + prune_failed = False + + for credentials in sorted(inputs.prune_credentials): + secret_name, cloud_name = credentials + desired = grouped_desired.get(credentials, []) + # An empty desired set is only authoritative when we know a CR was + # deleted; otherwise it may just be a snapshot we could not read, and + # pruning against it would delete everything. + authoritative_empty = credentials in grouped_deleted and not desired + if not desired and not authoritative_empty: + LOG.info( + "Skipping %s prune for cloud=%r secret=%r; no desired resources", + noun, + cloud_name, + secret_name, + ) + continue + + conn = connections.get(credentials) + if conn is None: + if not plugin.config.prune: + continue + try: + conn = get_openstack_connection(secret_name, cloud_name) + plugin.wait_for_api(conn) + except Exception as exc: # noqa: BLE001 + prune_failed = True + LOG.error( + "Cannot reach OpenStack for %s prune cloud=%r secret=%r: %s", + noun, + cloud_name, + secret_name, + exc, + ) + continue + connections[credentials] = conn + + try: + plugin.prune( + conn, + [resource.spec for resource in desired], + authoritative_empty=authoritative_empty, + ) + except Exception as exc: # noqa: BLE001 + prune_failed = True + LOG.error( + "Failed to prune %s cloud=%r secret=%r: %s", + noun, + cloud_name, + secret_name, + exc, + ) + + if prune_failed: + return 1 + + LOG.info("Finished reconciling %s(s)", noun) + return 0 + + +# --------------------------------------------------------------------------- +# Entrypoint +# --------------------------------------------------------------------------- + + +def run_hook( + build_config: Callable[[], dict[str, Any]], + run: Callable[[list[dict[str, Any]]], int], +) -> int: + """Handle the shell-operator calling convention shared by every hook. + + ``--config`` prints the hook config and exits; otherwise the binding + context is read and handed to *run*. An empty or absent binding context is + not an error -- shell-operator invokes hooks with no work to do. + """ + if len(sys.argv) > 1 and sys.argv[1] == "--config": + print(json.dumps(build_config(), indent=2)) + return 0 + + configure_logging() + + try: + contexts = read_binding_context() + except ValueError as exc: + LOG.error("failed to parse binding context: %s", exc) + return 1 + + if not contexts: + return 0 + + try: + return run(contexts) + except Exception as exc: # noqa: BLE001 + LOG.error("%s", exc) + return 1 diff --git a/python/openstack-sync/openstack_sync/hooks/placeholder.py b/python/openstack-sync/openstack_sync/hooks/placeholder.py index 407291e24..664226e9a 100644 --- a/python/openstack-sync/openstack_sync/hooks/placeholder.py +++ b/python/openstack-sync/openstack_sync/hooks/placeholder.py @@ -1,52 +1,50 @@ #!/usr/bin/env python3 """Shell-operator hook for OpenStack connectivity verification. -When ``OPENSTACK_PLACEHOLDER_ENABLED`` is ``true`` this hook runs on startup -to verify that the operator can authenticate against OpenStack. When the flag -is ``false`` (the default) the hook registers only an ``onStartup`` binding so -the base image satisfies shell-operator's requirement for at least one binding -without needing any Kubernetes watches or extra RBAC. +When ``OPENSTACK_PLACEHOLDER_ENABLED`` is ``true`` this hook runs on startup to +verify that the operator can authenticate against OpenStack. When it is ``false`` +(the default) the hook still registers an ``onStartup`` binding, because +shell-operator requires every hook to declare at least one binding -- but it +does no work, so the base image needs no Kubernetes watches or extra RBAC. + +This is a connectivity probe rather than a CR reconciler, so it uses only +``run_hook`` and not the :class:`~openstack_sync.hooks.framework.SyncPlugin` +machinery. """ from __future__ import annotations -import json import logging import os import sys from typing import Any -from openstack_sync.hooks.common import configure_logging -from openstack_sync.plugins.common import env_bool +from openstack_sync.hooks.framework import hook_enabled +from openstack_sync.hooks.framework import run_hook from openstack_sync.utils import get_openstack_connection LOG = logging.getLogger(__name__) +ENV_PREFIX = "OPENSTACK_PLACEHOLDER" + def build_hook_config() -> dict[str, Any]: - hook_config: dict[str, Any] = { + return { "configVersion": "v1", - "settings": { - "executionMinInterval": "30s", - "executionBurst": 1, - }, + "settings": {"executionMinInterval": "30s", "executionBurst": 1}, "onStartup": 10, } - return hook_config def check_openstack_connectivity() -> None: - """Attempt to authenticate against OpenStack and log the result. - - Reads credentials from the Kubernetes Secret named by - ``OPENSTACK_PLACEHOLDER_DEFAULT_SECRET`` using - the cloud entry ``OPENSTACK_PLACEHOLDER_DEFAULT_CLOUD``. + """Authenticate against OpenStack and log the result. - Raises: - Exception: Re-raises any connection failure after logging it. + Credentials come from the Secret named by + ``OPENSTACK_PLACEHOLDER_DEFAULT_SECRET`` using the cloud entry + ``OPENSTACK_PLACEHOLDER_DEFAULT_CLOUD``. """ - secret_name = os.environ.get("OPENSTACK_PLACEHOLDER_DEFAULT_SECRET") - cloud_name = os.environ.get("OPENSTACK_PLACEHOLDER_DEFAULT_CLOUD") + secret_name = os.environ.get(f"{ENV_PREFIX}_DEFAULT_SECRET") + cloud_name = os.environ.get(f"{ENV_PREFIX}_DEFAULT_CLOUD") LOG.info( "connectivity check: authenticating against cloud=%r secret=%r", @@ -54,40 +52,21 @@ def check_openstack_connectivity() -> None: secret_name, ) conn = get_openstack_connection(secret_name, cloud_name) - # Lightweight probe: check_token(str) -> bool confirms the token is valid - # and Keystone is reachable without any side effects. + # check_token(str) -> bool confirms the token is valid and Keystone is + # reachable, with no side effects. conn.identity.check_token(conn.auth_token) LOG.info("connectivity check: OK cloud=%r secret=%r", cloud_name, secret_name) def main() -> int: - if len(sys.argv) > 1 and sys.argv[1] == "--config": - print(json.dumps(build_hook_config(), indent=2)) - return 0 - - configure_logging() - - context_path = os.environ.get("BINDING_CONTEXT_PATH") - if not context_path: - return 0 - with open(context_path) as f: - raw = f.read() - if not raw.strip(): - return 0 - - try: - binding_contexts = json.loads(raw) - except json.JSONDecodeError as exc: - LOG.error("failed to parse binding context: %s", exc) - return 1 - - for context in binding_contexts: - # Shell-operator passes [{"binding": "onStartup"}] for startup runs. - if context.get("binding") == "onStartup": - if not env_bool("OPENSTACK_PLACEHOLDER_ENABLED", False): + def run(contexts: list[dict[str, Any]]) -> int: + for context in contexts: + # Shell-operator passes [{"binding": "onStartup"}] for startup runs. + if context.get("binding") != "onStartup": + continue + if not hook_enabled(ENV_PREFIX): LOG.info( - "connectivity check: skipped" - " (OPENSTACK_PLACEHOLDER_ENABLED is not set)" + "connectivity check: skipped (%s_ENABLED is not set)", ENV_PREFIX ) continue try: @@ -95,8 +74,9 @@ def main() -> int: except Exception as exc: # noqa: BLE001 LOG.error("connectivity check FAILED: %s", exc) return 1 + return 0 - return 0 + return run_hook(build_hook_config, run) if __name__ == "__main__": diff --git a/python/openstack-sync/openstack_sync/hooks/router_flavors.py b/python/openstack-sync/openstack_sync/hooks/router_flavors.py index ccf2e670a..1de539ec5 100644 --- a/python/openstack-sync/openstack_sync/hooks/router_flavors.py +++ b/python/openstack-sync/openstack_sync/hooks/router_flavors.py @@ -3,681 +3,68 @@ from __future__ import annotations -import json -import logging -import os import sys -from dataclasses import dataclass from typing import Any -from openstack_sync.hooks.common import configure_logging -from openstack_sync.hooks.common import int_or_none -from openstack_sync.hooks.common import patch_resource_status -from openstack_sync.hooks.common import read_binding_context -from openstack_sync.hooks.common import snapshot_items -from openstack_sync.hooks.common import string_or_none -from openstack_sync.hooks.common import synchronization_items -from openstack_sync.plugins.common import ConfigError -from openstack_sync.plugins.common import env_bool -from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.neutron.router_flavors.create import ServiceProfileCache -from openstack_sync.plugins.neutron.router_flavors.delete import prune_removed_flavors -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - ProfileDrift, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - crd_api_version, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - crd_binding_name, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import crd_kind -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - crd_namespace, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - crd_resource, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - describe_profile_drift, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - prune_removed_flavors_enabled, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - status_enabled, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - wait_for_openstack_network, -) -from openstack_sync.plugins.neutron.router_flavors.update import sync_flavor -from openstack_sync.utils import get_openstack_connection - -LOG = logging.getLogger(__name__) -CredentialKey = tuple[str, str] - -# --------------------------------------------------------------------------- -# Resource dataclass -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class RouterFlavorResource: - """A single NeutronRouterFlavor CR with its resolved credentials.""" - - flavor: dict[str, Any] - name: str | None - namespace: str | None - generation: int | None - secret_name: str - cloud_name: str - current_status: dict[str, Any] | None = None - - -@dataclass(frozen=True) -class RouterFlavorHookInputs: - """Parsed shell-operator context split by reconciliation purpose.""" - - resources_to_reconcile: list[RouterFlavorResource] - desired_resources_for_prune: list[RouterFlavorResource] - deleted_resources: list[RouterFlavorResource] - prune_credentials: frozenset[CredentialKey] - - -# --------------------------------------------------------------------------- -# Hook configuration -# --------------------------------------------------------------------------- - - -def build_hook_config() -> dict[str, Any]: - hook_config: dict[str, Any] = { - "configVersion": "v1", - "settings": { - "executionMinInterval": "30s", - "executionBurst": 1, - }, - } - - if not env_bool("NEUTRON_ROUTER_FLAVOR_ENABLED", False): - # Shell-operator requires at least one binding. - hook_config["onStartup"] = 10 - return hook_config - - sync_crontab = os.environ.get("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "").strip() - namespace = os.environ.get("POD_NAMESPACE") - binding_name = crd_binding_name() - kubernetes_binding: dict[str, Any] = { - "name": binding_name, - "apiVersion": crd_api_version(), - "kind": crd_kind(), - "executeHookOnEvent": ["Added", "Modified", "Deleted"], - "jqFilter": ".", - "includeSnapshotsFrom": [binding_name], - # Dedicated queue so a slow Neutron readiness wait or reconciliation - # only delays this hook's own tasks, not other hooks sharing the - # default "main" queue. - "queue": binding_name, - } - if namespace: - kubernetes_binding["namespace"] = { - "nameSelector": {"matchNames": [namespace]}, - } - - hook_config["kubernetes"] = [kubernetes_binding] - if sync_crontab: - hook_config["schedule"] = [ - { - "name": "hourly sync", - "crontab": sync_crontab, - "includeSnapshotsFrom": [binding_name], - "queue": binding_name, - } - ] - return hook_config - - -# --------------------------------------------------------------------------- -# Binding context parsing -# --------------------------------------------------------------------------- - - -def _required_cloud_credential( - creds_ref: dict[str, Any], - field: str, - source: str, -) -> str: - value = creds_ref.get(field) - if not isinstance(value, str) or not value.strip(): - raise ConfigError( - f"{source} spec.cloudCredentialsRef.{field} must be a non-empty string" - ) - return value.strip() - - -def _resource_from_object(obj: Any, source: str) -> RouterFlavorResource: - if not isinstance(obj, dict): - raise ConfigError(f"{source} object must be a Kubernetes object") - - spec = obj.get("spec") - if not isinstance(spec, dict): - raise ConfigError(f"{source} spec must be an object") - - flavor = dict(spec) - metadata = obj.get("metadata", {}) - resource_name = None - resource_namespace = None - generation = None - if isinstance(metadata, dict): - resource_name = string_or_none(metadata.get("name")) - resource_namespace = string_or_none(metadata.get("namespace")) - generation = int_or_none(metadata.get("generation")) - raw_status = obj.get("status") - current_status = raw_status if isinstance(raw_status, dict) else None - - try: - creds_ref = flavor.pop("cloudCredentialsRef") - except KeyError as exc: - raise ConfigError(f"{source} spec.cloudCredentialsRef is required") from exc - if not isinstance(creds_ref, dict): - raise ConfigError(f"{source} spec.cloudCredentialsRef must be an object") - secret_name = _required_cloud_credential(creds_ref, "secretName", source) - cloud_name = _required_cloud_credential(creds_ref, "cloudName", source) - - return RouterFlavorResource( - flavor=flavor, - name=resource_name, - namespace=resource_namespace, - generation=generation, - secret_name=secret_name, - cloud_name=cloud_name, - current_status=current_status, - ) - - -def _resources_from_items(items: list[Any], source: str) -> list[RouterFlavorResource]: - resources: list[RouterFlavorResource] = [] - for index, item in enumerate(items): - item_source = f"{source}[{index}]" - if not isinstance(item, dict): - raise ConfigError(f"{item_source} must be an object") - obj = item.get("object", item) - resources.append(_resource_from_object(obj, item_source)) - - return sorted(resources, key=lambda r: str(r.flavor.get("name", ""))) - - -def _credentials_for_resources( - resources: list[RouterFlavorResource], -) -> frozenset[CredentialKey]: - return frozenset( - (resource.secret_name, resource.cloud_name) for resource in resources - ) - - -def _router_flavor_event_watch_events( - contexts: list[dict[str, Any]], -) -> frozenset[str] | None: - binding_name = crd_binding_name() - watch_events: set[str] = set() - for context in contexts: - if context.get("binding") != binding_name or context.get("type") != "Event": - continue - watch_event = context.get("watchEvent") - if not isinstance(watch_event, str) or not watch_event: - raise ConfigError( - f"{binding_name} event watchEvent must be a non-empty string" - ) - watch_events.add(watch_event) - return frozenset(watch_events) if watch_events else None - - -def _modified_event_status_is_current(resource: RouterFlavorResource) -> bool: - status = resource.current_status - return ( - resource.generation is not None - and status is not None - and status.get("syncStatus") == "Synced" - and status.get("observedGeneration") == resource.generation - ) - - -def changed_router_flavor_resources_from_binding_context( - contexts: list[dict[str, Any]], -) -> list[RouterFlavorResource] | None: - binding_name = crd_binding_name() - resources: list[RouterFlavorResource] = [] - saw_event = False - for index, context in enumerate(contexts): - if context.get("binding") != binding_name or context.get("type") != "Event": - continue - - saw_event = True - watch_event = context.get("watchEvent") - if watch_event == "Deleted": - continue - if watch_event not in {"Added", "Modified"}: - raise ConfigError( - f"{binding_name} event watchEvent must be Added, Modified, or Deleted" - ) - - obj = context.get("object") - if not obj: - raise ConfigError( - f"{watch_event} event {binding_name}[{index}] object is required" - ) - resource = _resource_from_object( - obj, - f"{watch_event} event {binding_name}[{index}]", - ) - if watch_event == "Modified" and _modified_event_status_is_current(resource): - LOG.info( - "Skipping router flavor %s Modified event; generation %s is already " - "Synced", - _resource_display_name(resource), - resource.generation, - ) - continue - resources.append(resource) - - if not saw_event: - return None - return sorted(resources, key=lambda r: str(r.flavor.get("name", ""))) - - -def deleted_router_flavor_resources_from_binding_context( - contexts: list[dict[str, Any]], -) -> list[RouterFlavorResource]: - binding_name = crd_binding_name() - resources: list[RouterFlavorResource] = [] - for index, context in enumerate(contexts): - if ( - context.get("binding") != binding_name - or context.get("type") != "Event" - or context.get("watchEvent") != "Deleted" - ): - continue - obj = context.get("object") - if not obj: - LOG.warning( - "Deleted %s event has no object; cannot use it for prune credentials", - crd_kind(), - ) - continue - resources.append( - _resource_from_object( - obj, - f"Deleted event {binding_name}[{index}]", - ) - ) - - return resources - - -def router_flavor_resources_from_binding_context( - contexts: list[dict[str, Any]], -) -> list[RouterFlavorResource] | None: - binding_name = crd_binding_name() - items = snapshot_items(contexts, binding_name) - if items is not None: - return _resources_from_items(items, f"Snapshot {binding_name}") - - items = synchronization_items(contexts, binding_name) - if items is not None: - return _resources_from_items(items, f"Synchronization {binding_name}") - - return None - - -def router_flavor_hook_inputs_from_binding_context( - contexts: list[dict[str, Any]], -) -> RouterFlavorHookInputs | None: - binding_name = crd_binding_name() - event_watch_events = _router_flavor_event_watch_events(contexts) - changed_resources = changed_router_flavor_resources_from_binding_context(contexts) - deleted_resources = deleted_router_flavor_resources_from_binding_context(contexts) - - if event_watch_events is not None: - items = snapshot_items(contexts, binding_name) - if items is None: - raise ConfigError( - f"Shell-operator {binding_name} event context does not contain " - f"{binding_name} snapshot objects" - ) - desired_resources = _resources_from_items(items, f"Snapshot {binding_name}") - if changed_resources or deleted_resources or "Deleted" in event_watch_events: - prune_credentials = _credentials_for_resources( - desired_resources - ) | _credentials_for_resources(deleted_resources) - else: - prune_credentials = frozenset() - return RouterFlavorHookInputs( - resources_to_reconcile=changed_resources or [], - desired_resources_for_prune=desired_resources, - deleted_resources=deleted_resources, - prune_credentials=prune_credentials, +from openstack_sync.hooks.framework import HookConfig +from openstack_sync.hooks.framework import SyncPlugin +from openstack_sync.hooks.framework import build_crd_hook_config +from openstack_sync.hooks.framework import hook_enabled +from openstack_sync.hooks.framework import hook_inputs +from openstack_sync.hooks.framework import run_hook +from openstack_sync.hooks.framework import run_sync +from openstack_sync.plugins.common import wait_for_openstack_network +from openstack_sync.plugins.neutron.router_flavors import prune as prune_module +from openstack_sync.plugins.neutron.router_flavors import reconcile as reconcile_module +from openstack_sync.plugins.neutron.router_flavors.config import BINDING_NAME +from openstack_sync.plugins.neutron.router_flavors.config import ENV_PREFIX + + +class RouterFlavorPlugin(SyncPlugin): + """Sync NeutronRouterFlavor CRs into Neutron flavors and service profiles.""" + + noun = "router flavor" + + def wait_for_api(self, conn: Any) -> None: + wait_for_openstack_network( + conn, + retries=self.config.ready_retries, + delay=self.config.ready_delay, ) - resources = router_flavor_resources_from_binding_context(contexts) - if resources is not None: - return RouterFlavorHookInputs( - resources_to_reconcile=resources, - desired_resources_for_prune=resources, - deleted_resources=[], - prune_credentials=_credentials_for_resources(resources), + def new_cache(self) -> reconcile_module.ProfileCache: + # Keyed by driver and shared across every flavor in one credential + # group, so two flavors wanting the same profile share one lookup and + # end up sharing one profile. + return {} + + def reconcile( + self, conn: Any, spec: dict[str, Any], cache: reconcile_module.ProfileCache + ) -> list[str]: + return reconcile_module.sync_flavor(conn, spec, cache) + + def prune( + self, + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool, + ) -> None: + if not self.config.prune: + return + prune_module.prune_removed_flavors( + conn, desired_specs, authoritative_empty=authoritative_empty ) - return None - - -def load_router_flavor_hook_inputs( - contexts: list[dict[str, Any]] | None = None, -) -> RouterFlavorHookInputs: - if contexts is None: - contexts = read_binding_context() - if not contexts: - raise ConfigError( - f"Shell-operator binding context is required to load {crd_kind()} objects" - ) - - hook_inputs = router_flavor_hook_inputs_from_binding_context(contexts) - if hook_inputs is not None: - return hook_inputs - - raise ConfigError( - f"Shell-operator binding context does not contain " - f"{crd_binding_name()} event, snapshot, or synchronization objects" - ) - - -# --------------------------------------------------------------------------- -# Status patching -# --------------------------------------------------------------------------- - - -def patch_flavor_status( - resource: RouterFlavorResource, - sync_status: str, - message: str, -) -> None: - kind = crd_kind() - if not resource.name: - LOG.warning( - "Unable to patch %s status; Kubernetes metadata.name is missing", - kind, - ) - return - patch_resource_status( - name=resource.name, - namespace=resource.namespace or crd_namespace(), - generation=resource.generation, - sync_status=sync_status, - message=message, - crd_resource=crd_resource(), - crd_kind=kind, - status_enabled=status_enabled(), - current_status=resource.current_status, - ) - - -# --------------------------------------------------------------------------- -# Reconciliation -# --------------------------------------------------------------------------- - - -def _resource_display_name(resource: RouterFlavorResource) -> str: - return str(get_value(resource.flavor, "name", default=resource.name or "")) - - -def _resources_by_credentials( - resources: list[RouterFlavorResource], -) -> dict[CredentialKey, list[RouterFlavorResource]]: - grouped: dict[CredentialKey, list[RouterFlavorResource]] = {} - for resource in resources: - key = (resource.secret_name, resource.cloud_name) - grouped.setdefault(key, []).append(resource) - return grouped - - -def _mark_resources_failed( - resources: list[RouterFlavorResource], - message: str, -) -> None: - for resource in resources: - patch_flavor_status(resource, "Failed", message) - - -def reconcile_router_flavor_resource( - conn: Any, resource: RouterFlavorResource, profile_cache: ServiceProfileCache -) -> list[ProfileDrift]: - return sync_flavor(conn, resource.flavor, profile_cache) - - -def _synced_status_message(drift: list[ProfileDrift]) -> str: - """Return the Synced status message, qualified by any unfixable drift. - - The flavor really is converged, so the status stays Synced; but reporting a - bare success while a reused service profile diverges from the spec is how a - disabled profile stays invisible until every router create against the - flavor fails. - """ - message = "Successfully reconciled router flavor" - if not drift: - return message - return ( - f"{message}; service profile drift requires manual action: " - f"{describe_profile_drift(drift)}" - ) - - -def reconcile_router_flavor_resources( - resources: list[RouterFlavorResource], - deleted_resources: list[RouterFlavorResource] | None = None, - prune_resources: list[RouterFlavorResource] | None = None, - prune_credentials: frozenset[CredentialKey] | None = None, -) -> int: - deleted_resources = deleted_resources or [] - prune_resources = resources if prune_resources is None else prune_resources - flavors = [resource.flavor for resource in resources] - LOG.info("Found %s router flavor(s) to reconcile", len(flavors)) - - grouped_resources = _resources_by_credentials(resources) - grouped_prune_resources = _resources_by_credentials(prune_resources) - deleted_resources_by_credentials = _resources_by_credentials(deleted_resources) - if prune_credentials is None: - prune_credentials = frozenset(grouped_resources) - connections: dict[CredentialKey, Any] = {} - failed_resources: list[RouterFlavorResource] = [] - - for credentials in sorted(grouped_resources): - credential_resources = grouped_resources[credentials] - secret_name, cloud_name = credentials - try: - conn = get_openstack_connection(secret_name, cloud_name) - except Exception as exc: # noqa: BLE001 - failed_resources.extend(credential_resources) - message = f"OpenStack connection failed: {exc}" - _mark_resources_failed(credential_resources, message) - LOG.error( - "Failed to connect to OpenStack cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, - ) - continue - - connections[credentials] = conn - try: - wait_for_openstack_network(conn) - except Exception as exc: # noqa: BLE001 - failed_resources.extend(credential_resources) - _mark_resources_failed( - credential_resources, - f"Neutron API unavailable: {exc}", - ) - LOG.error( - "Neutron API unavailable for cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, - ) - continue - - # Fetched lazily by driver once per credential group. ensure_profile() - # appends newly created profiles into the same driver cache entry so a - # later flavor with an identical meta_info spec reuses it. - profile_cache: ServiceProfileCache = {} - - for resource in credential_resources: - try: - drift = reconcile_router_flavor_resource(conn, resource, profile_cache) - except Exception as exc: # noqa: BLE001 - failed_resources.append(resource) - patch_flavor_status(resource, "Failed", str(exc)) - LOG.error( - "Failed to reconcile router flavor %s: %s", - _resource_display_name(resource), - exc, - ) - continue - - patch_flavor_status( - resource, - "Synced", - _synced_status_message(drift), - ) - - if failed_resources: - LOG.error( - "Skipping router flavor prune because %s flavor(s) failed to reconcile", - len(failed_resources), - ) - return 1 - - prune_failed = False - for credentials in sorted(prune_credentials): - secret_name, cloud_name = credentials - desired_resources = grouped_prune_resources.get(credentials, []) - authoritative_empty_desired = ( - credentials in deleted_resources_by_credentials and not desired_resources - ) - if not desired_resources and not authoritative_empty_desired: - LOG.info( - "Skipping router flavor prune for cloud=%r secret=%r; no desired " - "router flavors are available", - cloud_name, - secret_name, - ) - continue - - conn = connections.get(credentials) - if conn is None: - if not prune_removed_flavors_enabled(): - continue - try: - conn = get_openstack_connection(secret_name, cloud_name) - except Exception as exc: # noqa: BLE001 - prune_failed = True - LOG.error( - "Failed to connect to OpenStack for router flavor prune " - "cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, - ) - continue - try: - wait_for_openstack_network(conn) - except Exception as exc: # noqa: BLE001 - prune_failed = True - LOG.error( - "Neutron API unavailable for router flavor prune " - "cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, - ) - continue - connections[credentials] = conn - - try: - desired_flavors = [resource.flavor for resource in desired_resources] - if authoritative_empty_desired: - prune_removed_flavors( - conn, - desired_flavors, - authoritative_empty_desired=True, - ) - else: - prune_removed_flavors(conn, desired_flavors) - except Exception as exc: # noqa: BLE001 - prune_failed = True - LOG.error( - "Failed to prune router flavors cloud=%r secret=%r: %s", - cloud_name, - secret_name, - exc, - ) - - if prune_failed: - return 1 - - if ( - not prune_credentials - and not grouped_resources - and not deleted_resources_by_credentials - ): - LOG.info( - "Skipping router flavor prune; no router flavor credentials are available" - ) - - LOG.info("Finished reconciling router flavors") - return 0 - - -# --------------------------------------------------------------------------- -# Run loop -# --------------------------------------------------------------------------- - def main() -> int: - if len(sys.argv) > 1 and sys.argv[1] == "--config": - print(json.dumps(build_hook_config(), indent=2)) - return 0 + def run(contexts: list[dict[str, Any]]) -> int: + if not hook_enabled(ENV_PREFIX): + return 0 + config = HookConfig.from_env(ENV_PREFIX, binding_name=BINDING_NAME) + return run_sync(RouterFlavorPlugin(config), hook_inputs(contexts, config)) - configure_logging() - - if not env_bool("NEUTRON_ROUTER_FLAVOR_ENABLED", False): - LOG.info("Router flavor sync is disabled") - return 0 - - context_path = os.environ.get("BINDING_CONTEXT_PATH") - if not context_path: - return 0 - - with open(context_path, encoding="utf-8") as f: - raw = f.read() - if not raw.strip(): - return 0 - - try: - binding_contexts = json.loads(raw) - except json.JSONDecodeError as exc: - LOG.error("failed to parse binding context: %s", exc) - return 1 - - try: - if not isinstance(binding_contexts, list): - raise ConfigError("Shell-operator binding context must be a list") - hook_inputs = load_router_flavor_hook_inputs(binding_contexts) - return reconcile_router_flavor_resources( - hook_inputs.resources_to_reconcile, - hook_inputs.deleted_resources, - hook_inputs.desired_resources_for_prune, - hook_inputs.prune_credentials, - ) - except Exception as exc: # noqa: BLE001 - LOG.error("%s", exc) - return 1 + return run_hook(lambda: build_crd_hook_config(ENV_PREFIX, BINDING_NAME), run) if __name__ == "__main__": diff --git a/python/openstack-sync/openstack_sync/plugins/common.py b/python/openstack-sync/openstack_sync/plugins/common.py index 39f02c98b..887486798 100644 --- a/python/openstack-sync/openstack_sync/plugins/common.py +++ b/python/openstack-sync/openstack_sync/plugins/common.py @@ -77,15 +77,6 @@ def env_required(name: str) -> str: return value -def env_tuple(name: str, default: str) -> tuple[str, ...]: - """Return a tuple of strings parsed from a comma-separated env variable.""" - return tuple( - item.strip() - for item in os.environ.get(name, default).split(",") - if item.strip() - ) - - # --------------------------------------------------------------------------- # Error type # --------------------------------------------------------------------------- @@ -99,61 +90,25 @@ class ConfigError(Exception): # OpenStack SDK resource accessors # --------------------------------------------------------------------------- -_MISSING = object() - - -def _mapping_value(mapping: dict[str, Any], name: str) -> Any: - """Read *name* from a mapping without invoking default values.""" - try: - return mapping[name] - except KeyError: - return _MISSING - - -def _attribute_value(resource: Any, name: str) -> Any: - """Read *name* through attribute access.""" - try: - return getattr(resource, name) - except AttributeError: - return _MISSING - -def _resource_value(resource: Any, name: str) -> Any: - """Read *name* from *resource* regardless of type. +def get_value(resource: Any, name: str, default: Any = None) -> Any: + """Return a field from a CR spec dict or an openstacksdk resource. - Plain dicts are the operator contract and are read by exact key. - OpenStack resources are read through their openstacksdk attribute names, - for example ``meta_info`` and ``service_profile_ids``. Neutron wire names - are mapped by openstacksdk before this layer reads them. + Specs are plain dicts read by exact key; OpenStack resources are read by + their openstacksdk attribute name (``meta_info``, ``service_profile_ids``), + which the SDK has already mapped from the Neutron wire name. """ - if type(resource) is dict: - return _mapping_value(resource, name) - - value = _attribute_value(resource, name) - if value is not _MISSING: - return value - - return _MISSING - - -def get_value(resource: Any, name: str, default: Any = None) -> Any: - """Return a non-None value from *resource* by canonical field name.""" - value = _resource_value(resource, name) - if value is not _MISSING and value is not None: - return value - return default + value = ( + resource.get(name) + if isinstance(resource, dict) + else getattr(resource, name, None) + ) + return default if value is None else value def resource_id(resource: Any) -> str: - """Return the string ID of an OpenStack resource. - - Raises: - RuntimeError: When no ID field can be found. - """ - value = get_value(resource, "id") - if not value: - raise RuntimeError(f"Unable to read ID from resource {resource!r}") - return str(value) + """Return the string ID of an OpenStack resource.""" + return str(get_value(resource, "id")) # --------------------------------------------------------------------------- @@ -190,51 +145,6 @@ def meta_info_payload(value: Any) -> str: return json.dumps(normalized, sort_keys=True, separators=(",", ":")) -def comparable_meta_info_without(value: Any, exclude_keys: frozenset[str]) -> Any: - """Strip *exclude_keys* from *value* before comparison.""" - normalized = normalize_meta_info(value) - if isinstance(normalized, dict): - return {k: v for k, v in normalized.items() if k not in exclude_keys} - return normalized - - -def meta_info_matches_without( - current: Any, desired: Any, exclude_keys: frozenset[str] -) -> bool: - """Return True when *current* and *desired* are logically equal. - - Keys in *exclude_keys* are stripped before comparison. - """ - return meta_info_payload( - comparable_meta_info_without(current, exclude_keys) - ) == meta_info_payload(comparable_meta_info_without(desired, exclude_keys)) - - -def managed_meta_info(value: Any, markers: dict[str, str]) -> Any: - """Merge *markers* into *value*, returning the combined meta_info dict.""" - normalized = normalize_meta_info(value) - if not isinstance(normalized, dict): - return normalized - managed = dict(normalized) - managed.update(markers) - return managed - - -# --------------------------------------------------------------------------- -# Exception classifiers -# --------------------------------------------------------------------------- - - -def is_not_found(exc: Exception) -> bool: - """Return True for openstacksdk 404 exceptions.""" - return isinstance(exc, openstack_exceptions.NotFoundException) - - -def is_conflict(exc: Exception) -> bool: - """Return True for openstacksdk 409 exceptions.""" - return isinstance(exc, openstack_exceptions.ConflictException) - - # --------------------------------------------------------------------------- # Neutron network readiness probe # --------------------------------------------------------------------------- @@ -274,24 +184,19 @@ def wait_for_openstack_network( def get_service_profile(conn: Any, profile_id: str) -> Any | None: - """Fetch a service profile by ID, returning None if not found.""" + """Fetch a service profile by ID, returning None if it no longer exists.""" try: return conn.network.get_service_profile(profile_id) - except Exception as exc: - if is_not_found(exc): - return None - raise + except openstack_exceptions.NotFoundException: + return None def service_profile_ids(flavor: Any) -> list[str]: - """Return the list of service profile IDs attached to *flavor*. + """Return the service profile IDs attached to *flavor*. The openstacksdk ``Flavor.service_profile_ids`` attribute maps Neutron's ``service_profiles`` wire field. """ - profiles = get_value(flavor, "service_profile_ids", default=[]) - if profiles is None: - return [] - if not isinstance(profiles, list): - raise TypeError("flavor.service_profile_ids must be a list") - return [str(profile) for profile in profiles] + return [ + str(profile) for profile in get_value(flavor, "service_profile_ids", default=[]) + ] diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/config.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/config.py new file mode 100644 index 000000000..0944f9530 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/config.py @@ -0,0 +1,19 @@ +"""Router-flavor plugin constants. + +Runtime configuration comes from :class:`openstack_sync.hooks.framework.HookConfig`, +built from the ``NEUTRON_ROUTER_FLAVOR`` env prefix the Helm chart injects. The +values here are not configurable at runtime: the chart never set them, so +carrying env plumbing for them only obscured what they are. +""" + +from __future__ import annotations + +#: Env prefix the Helm chart uses for this plugin's variables. +ENV_PREFIX = "NEUTRON_ROUTER_FLAVOR" + +#: shell-operator binding label for the CRD watch. +BINDING_NAME = "neutron-router-flavors" + +#: The only service type Neutron accepts for router flavors +#: (``plugin_constants.L3`` in neutron-lib). The CRD pins it with an enum. +SERVICE_TYPE = "L3_ROUTER_NAT" diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py deleted file mode 100644 index 3affa683e..000000000 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/create.py +++ /dev/null @@ -1,350 +0,0 @@ -"""Create helpers for Neutron router flavors and service profiles.""" - -from __future__ import annotations - -import logging -from typing import Any - -from openstack_sync.plugins.common import get_service_profile -from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.common import is_conflict -from openstack_sync.plugins.common import is_not_found -from openstack_sync.plugins.common import meta_info_payload -from openstack_sync.plugins.common import resource_id -from openstack_sync.plugins.common import service_profile_ids -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - ProfileDrift, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - is_managed_service_profile, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - managed_flavor_description, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - managed_meta_info, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - meta_info_matches, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - service_profile_meta_info, -) - -LOG = logging.getLogger(__name__) -ServiceProfileCache = dict[str, list[Any]] - - -def list_service_profiles(conn: Any, driver: str) -> list[Any]: - """Fetch service profiles for a single driver from Neutron.""" - return list(conn.network.service_profiles(driver=driver)) - - -def service_profiles_for_driver( - conn: Any, driver: str, profile_cache: ServiceProfileCache -) -> list[Any]: - """Return a credential-group cache entry for service profiles by driver.""" - if driver not in profile_cache: - profile_cache[driver] = list_service_profiles(conn, driver) - return profile_cache[driver] - - -def find_matching_profile(profiles: list[Any], meta_info: Any) -> Any | None: - """Return the operator-managed profile matching *meta_info*, if any. - - Only operator-owned profiles are reuse candidates. Reusing a profile the - operator does not own would bind it to the flavor, and - ``reconcile_flavor_profiles`` unbinds only profiles carrying the ownership - marker -- so the operator would have created a binding it can never remove. - That binding outlives the spec that created it, and Neutron's - ``get_flavor_next_provider`` selects an arbitrary binding (``objs[0]``), so a - stale one can end up serving routers with the wrong ``meta_info``. - - An unowned profile that happens to match is therefore left completely - untouched -- not adopted by stamping the ownership marker onto it, which - would enrol somebody else's profile into ``prune_orphaned_service_profiles`` - for eventual deletion -- and ``ensure_profile`` creates a dedicated managed - profile alongside it. - """ - unowned_matches: list[str] = [] - for profile in profiles: - if not meta_info_matches(service_profile_meta_info(profile), meta_info): - continue - if is_managed_service_profile(profile): - return profile - unowned_matches.append(str(get_value(profile, "id", default=""))) - - if unowned_matches: - LOG.info( - "Not reusing service profile(s) %s: they match the desired meta_info " - "but are not operator-owned, and the operator only binds profiles it " - "can unbind again; creating a dedicated managed profile instead", - sorted(unowned_matches), - ) - - return None - - -def _collect_profile_drift( - profile: Any, - profile_id: str, - driver: str, - flavor_name: str, - *, - description: str, - is_enabled: bool, -) -> list[ProfileDrift]: - """Return the spec fields on a reused *profile* that Neutron disagrees on. - - ``meta_info`` is excluded by construction -- the profile was selected by - matching it -- and ``driver`` is excluded because profiles are queried per - driver. That leaves ``is_enabled`` and ``description``. - - ``is_enabled`` is the consequential one: Neutron's - ``get_flavor_next_provider`` raises ``ServiceProfileDisabled`` (HTTP 503) - when the selected profile is disabled, so every router create against the - flavor fails while the flavor itself still looks healthy. - """ - drifted: list[ProfileDrift] = [] - - current_is_enabled = bool(get_value(profile, "is_enabled", default=True)) - if current_is_enabled != bool(is_enabled): - drifted.append( - ProfileDrift( - profile_id=profile_id, - driver=driver, - field="is_enabled", - have=current_is_enabled, - want=bool(is_enabled), - ) - ) - - current_description = str(get_value(profile, "description", default="")) - if current_description != str(description): - drifted.append( - ProfileDrift( - profile_id=profile_id, - driver=driver, - field="description", - have=current_description, - want=str(description), - ) - ) - - for item in drifted: - LOG.warning( - "Service profile %s reused by router flavor %s has drifted from the " - "CR spec (%s). Neutron rejects updates to a profile bound to any " - "flavor, so the operator cannot correct this; unbind the profile " - "from every flavor to update it, or delete it and let the operator " - "recreate it", - profile_id, - flavor_name, - item.describe(), - ) - - return drifted - - -def ensure_profile( - conn: Any, - flavor_name: str, - profile_spec: dict[str, Any], - profile_cache: ServiceProfileCache, - drift: list[ProfileDrift] | None = None, -) -> Any: - """Find or create a service profile matching *profile_spec*. - - The CR schema guarantees ``driver`` is present and ``is_enabled`` carries - the CRD default (true). ``description`` and ``meta_info`` are optional in - the schema; missing values fall back to empty. - - Only operator-owned profiles are reused (see ``find_matching_profile``). - When a reused profile has drifted from the spec, each drifted field is - logged and appended to *drift* if a list was supplied, so the caller can - surface it on the CR status rather than reporting an unqualified success. - Drift is only ever detected here, because this is the only place that holds - the desired value from the CR spec. - """ - driver = profile_spec["driver"] - description = profile_spec.get("description", "") - meta_info = profile_spec.get("meta_info", {}) - is_enabled = profile_spec["is_enabled"] - - profiles = service_profiles_for_driver(conn, driver, profile_cache) - profile = find_matching_profile(profiles, meta_info) - if profile: - profile_id = resource_id(profile) - LOG.info( - "Reusing service profile %s for %s driver=%s", - profile_id, - flavor_name, - driver, - ) - drifted = _collect_profile_drift( - profile, - profile_id, - driver, - flavor_name, - description=description, - is_enabled=is_enabled, - ) - if drift is not None: - drift.extend(drifted) - return profile - - LOG.info( - "Creating service profile for %s driver=%s is_enabled=%s", - flavor_name, - driver, - is_enabled, - ) - new_profile = conn.network.create_service_profile( - description=description, - driver=driver, - meta_info=meta_info_payload(managed_meta_info(meta_info)), - is_enabled=is_enabled, - ) - # Make the new profile visible to any later flavor in this same run that - # has an identical (driver, meta_info) spec, so it gets reused instead of - # creating a duplicate profile. - profiles.append(new_profile) - return new_profile - - -def find_flavor(conn: Any, name: str) -> Any | None: - # The SDK passes name= as a server-side query parameter (?name=), - # which Neutron filters in SQL, so at most one record is returned. The - # equality check guards against a future change to substring/LIKE semantics. - for flavor in conn.network.flavors(name=name): - if get_value(flavor, "name") == name: - return flavor - return None - - -def create_flavor( - conn: Any, - name: str, - service_type: str, - description: str, - *, - is_enabled: bool, -) -> Any: - LOG.info( - "Creating router flavor %s service_type=%s is_enabled=%s", - name, - service_type, - is_enabled, - ) - return conn.network.create_flavor( - name=name, - service_type=service_type, - is_enabled=is_enabled, - description=managed_flavor_description(description), - ) - - -def _associate_profile(conn: Any, flavor: Any, profile: Any) -> None: - """Associate *profile* with *flavor*, treating a 409 as already-associated.""" - flavor_id = resource_id(flavor) - profile_id = resource_id(profile) - LOG.info("Binding service profile %s to router flavor %s", profile_id, flavor_id) - try: - conn.network.associate_flavor_with_service_profile(flavor, profile) - except Exception as exc: # noqa: BLE001 - if not is_conflict(exc): - raise - LOG.info( - "Router flavor %s already has service profile %s", - flavor_id, - profile_id, - ) - - -def _disassociate_profile(conn: Any, flavor: Any, profile: Any) -> None: - """Disassociate *profile* from *flavor*, tolerating not-found/conflict.""" - flavor_id = resource_id(flavor) - profile_id = resource_id(profile) - LOG.info( - "Unbinding operator-managed service profile %s from router flavor %s", - profile_id, - flavor_id, - ) - try: - conn.network.disassociate_flavor_from_service_profile(flavor, profile) - except Exception as exc: # noqa: BLE001 - if is_not_found(exc): - LOG.info( - "Service profile %s already absent from router flavor %s", - profile_id, - flavor_id, - ) - return - if is_conflict(exc): - LOG.warning( - "Cannot unbind service profile %s from router flavor %s " - "(Neutron reports conflict, likely in use); leaving attached", - profile_id, - flavor_id, - ) - return - raise - - -def reconcile_flavor_profiles( - conn: Any, - flavor: Any, - desired_profiles: list[Any], -) -> Any: - """Reconcile the set of service profiles bound to *flavor*. - - ``desired_profiles`` is the list resolved from the CR spec (post - ``ensure_profile``). Profiles missing from the flavor are associated; - operator-managed profiles present on the flavor but absent from the - desired set are disassociated. Unmanaged profiles attached out-of-band - are left untouched so an operator's ad-hoc attachments survive reconcile. - - Returns the flavor re-fetched from Neutron so callers see the current - ``service_profile_ids``. - """ - flavor = conn.network.get_flavor(flavor) - flavor_id = resource_id(flavor) - flavor_name = get_value(flavor, "name", default=flavor_id) - - desired_by_id: dict[str, Any] = {resource_id(p): p for p in desired_profiles} - current_ids = set(service_profile_ids(flavor)) - desired_ids = set(desired_by_id) - - to_associate = desired_ids - current_ids - to_disassociate_candidates = current_ids - desired_ids - - if not to_associate and not to_disassociate_candidates: - LOG.info( - "Router flavor %s already has the desired service profiles %s", - flavor_name, - sorted(current_ids), - ) - return flavor - - for profile_id in sorted(to_associate): - _associate_profile(conn, flavor, desired_by_id[profile_id]) - - for profile_id in sorted(to_disassociate_candidates): - profile = get_service_profile(conn, profile_id) - if profile is None: - LOG.info( - "Service profile %s already absent from Neutron; nothing to unbind", - profile_id, - ) - continue - if not is_managed_service_profile(profile): - LOG.info( - "Keeping unmanaged service profile %s on router flavor %s; " - "operator only unbinds profiles it owns", - profile_id, - flavor_name, - ) - continue - _disassociate_profile(conn, flavor, profile) - - return conn.network.get_flavor(flavor) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py deleted file mode 100644 index e35502e39..000000000 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/delete.py +++ /dev/null @@ -1,273 +0,0 @@ -"""Delete/prune logic for removed Neutron router flavors.""" - -from __future__ import annotations - -import logging -from collections import Counter -from typing import Any - -from openstack_sync.plugins.common import get_service_profile -from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.common import is_conflict -from openstack_sync.plugins.common import is_not_found -from openstack_sync.plugins.common import resource_id -from openstack_sync.plugins.common import service_profile_ids -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - DEFAULT_SERVICE_TYPE, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - delete_unused_service_profiles_enabled, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - is_managed_flavor, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - is_managed_service_profile, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - prune_driver_prefixes, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - prune_removed_flavors_enabled, -) - -LOG = logging.getLogger(__name__) - - -def configured_flavor_names(flavors: list[dict[str, Any]]) -> set[str]: - return { - str(flavor_config["name"]) - for flavor_config in flavors - if flavor_config.get("name") - } - - -def service_profile_driver(profile: Any) -> str: - return str(get_value(profile, "driver", default="")) - - -def get_cached_service_profile( - conn: Any, - profile_id: str, - profile_cache: dict[str, Any | None], -) -> Any | None: - if profile_id not in profile_cache: - profile_cache[profile_id] = get_service_profile(conn, profile_id) - return profile_cache[profile_id] - - -def is_prunable_service_profile(profile: Any) -> bool: - driver = service_profile_driver(profile) - prefixes = prune_driver_prefixes() - return bool(prefixes) and any(driver.startswith(prefix) for prefix in prefixes) - - -def is_prunable_flavor(flavor: Any) -> bool: - if get_value(flavor, "service_type") != DEFAULT_SERVICE_TYPE: - return False - return is_managed_flavor(flavor) - - -def service_profile_attachment_counts(flavors: list[Any]) -> Counter[str]: - counts: Counter[str] = Counter() - for flavor in flavors: - counts.update(set(service_profile_ids(flavor))) - return counts - - -def detach_service_profile_ids( - profile_attachment_counts: Counter[str], - profile_ids: list[str], -) -> None: - for profile_id in profile_ids: - profile_attachment_counts[profile_id] -= 1 - if profile_attachment_counts[profile_id] <= 0: - del profile_attachment_counts[profile_id] - - -def flavor_has_routers(conn: Any, flavor: Any) -> bool: - flavor_id = resource_id(flavor) - flavor_name = get_value(flavor, "name", default=flavor_id) - - try: - routers = list(conn.network.routers(flavor_id=flavor_id)) - except Exception as exc: - LOG.warning( - "Unable to check routers for removed router flavor %s; " - "skipping deletion: %s", - flavor_name, - exc, - ) - return True - - if routers: - LOG.info( - "Router flavor %s is still used by %s router(s); skipping deletion", - flavor_name, - len(routers), - ) - return True - - return False - - -def service_profile_attached_to_any_flavor( - profile_attachment_counts: Counter[str], - profile_id: str, -) -> bool: - return profile_attachment_counts[profile_id] > 0 - - -def maybe_delete_service_profile( - conn: Any, - profile_id: str, - profile_cache: dict[str, Any | None], - profile_attachment_counts: Counter[str], -) -> None: - if not delete_unused_service_profiles_enabled(): - LOG.info("Keeping service profile %s; profile pruning is disabled", profile_id) - return - - profile = get_cached_service_profile(conn, profile_id, profile_cache) - if not profile: - return - - if not is_prunable_service_profile(profile): - LOG.info( - "Keeping service profile %s; driver %s is outside prune scope", - profile_id, - service_profile_driver(profile), - ) - return - - if not is_managed_service_profile(profile): - LOG.info("Keeping service profile %s; it is not operator-managed", profile_id) - return - - if service_profile_attached_to_any_flavor(profile_attachment_counts, profile_id): - LOG.info("Keeping service profile %s; it is still attached", profile_id) - return - - LOG.info("Deleting unused service profile %s", profile_id) - try: - conn.network.delete_service_profile(profile, ignore_missing=True) - profile_cache[profile_id] = None - except Exception as exc: - if is_not_found(exc): - profile_cache[profile_id] = None - return - if is_conflict(exc): - LOG.info("Service profile %s is still in use; skipping delete", profile_id) - return - raise - - -def delete_removed_flavor( - conn: Any, - flavor: Any, - profile_cache: dict[str, Any | None], - profile_attachment_counts: Counter[str], -) -> None: - flavor_id = resource_id(flavor) - flavor_name = get_value(flavor, "name", default=flavor_id) - profile_ids = service_profile_ids(flavor) - - if flavor_has_routers(conn, flavor): - return - - LOG.info("Deleting removed router flavor %s (%s)", flavor_name, flavor_id) - try: - conn.network.delete_flavor(flavor, ignore_missing=True) - except Exception as exc: - if is_not_found(exc): - LOG.info("Router flavor %s (%s) is already absent", flavor_name, flavor_id) - elif is_conflict(exc): - LOG.info( - "Router flavor %s is still in use; skipping delete", - flavor_name, - ) - return - else: - raise - - detach_service_profile_ids(profile_attachment_counts, profile_ids) - - for profile_id in profile_ids: - maybe_delete_service_profile( - conn, - profile_id, - profile_cache, - profile_attachment_counts, - ) - - -def prune_orphaned_service_profiles( - conn: Any, - profile_cache: dict[str, Any | None], - profile_attachment_counts: Counter[str], -) -> None: - """Delete orphaned operator-managed service profiles. - - Runs after the flavor prune loop to catch profiles left behind when - delete_flavor succeeded but maybe_delete_service_profile threw on the same - run. Safe to run every cycle because it only touches operator-owned, unattached - profiles. - """ - LOG.info("Scanning for orphaned operator-managed service profiles") - for profile in list(conn.network.service_profiles()): - profile_id = resource_id(profile) - if not is_prunable_service_profile(profile): - continue - if not is_managed_service_profile(profile): - continue - maybe_delete_service_profile( - conn, - profile_id, - profile_cache, - profile_attachment_counts, - ) - - -def prune_removed_flavors( - conn: Any, - flavors: list[dict[str, Any]], - *, - authoritative_empty_desired: bool = False, -) -> None: - if not prune_removed_flavors_enabled(): - LOG.info("Router flavor pruning is disabled") - return - - if not flavors and not authoritative_empty_desired: - LOG.warning( - "No desired router flavors found; skipping prune to avoid deleting " - "all managed router flavors" - ) - return - - desired_names = configured_flavor_names(flavors) - profile_cache: dict[str, Any | None] = {} - - LOG.info("Pruning removed router flavors") - current_flavors = list(conn.network.flavors(service_type=DEFAULT_SERVICE_TYPE)) - profile_attachment_counts = service_profile_attachment_counts(current_flavors) - for flavor in current_flavors: - flavor_name = get_value(flavor, "name") - if not flavor_name or flavor_name in desired_names: - continue - if not is_prunable_flavor(flavor): - continue - delete_removed_flavor( - conn, - flavor, - profile_cache, - profile_attachment_counts, - ) - - # Second pass: catch profiles orphaned by a partial failure on a previous - # run (delete_flavor succeeded but maybe_delete_service_profile threw). - prune_orphaned_service_profiles( - conn, - profile_cache, - profile_attachment_counts, - ) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/markers.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/markers.py new file mode 100644 index 000000000..3f372b2b5 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/markers.py @@ -0,0 +1,106 @@ +"""Ownership markers for operator-managed router flavors and service profiles. + +The operator only ever creates, updates or deletes resources carrying one of +these markers. That is what makes it safe to run alongside flavors and profiles +a human created by hand. + +Two mechanisms, because Neutron gives the two resources different places to +write to: service profiles carry marker keys inside ``meta_info``, flavors carry +a marker string appended to ``description``. +""" + +from __future__ import annotations + +from typing import Any + +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import meta_info_payload +from openstack_sync.plugins.common import normalize_meta_info + +MANAGED_META_INFO_KEY = "_understack_router_flavor_operator" +MANAGED_META_INFO_VALUE = "managed" +MARKER_VERSION_META_INFO_KEY = "_understack_router_flavor_marker_version" +MARKER_VERSION_META_INFO_VALUE = "v1" +MARKER_SOURCE_META_INFO_KEY = "_understack_router_flavor_source" +MARKER_SOURCE_META_INFO_VALUE = "NeutronRouterFlavor" + +FLAVOR_DESCRIPTION_MARKER = "[understack-router-flavor-operator]" + +#: Marker keys stamped into a managed service profile's ``meta_info``. +OPERATOR_META_INFO_MARKERS = { + MANAGED_META_INFO_KEY: MANAGED_META_INFO_VALUE, + MARKER_VERSION_META_INFO_KEY: MARKER_VERSION_META_INFO_VALUE, + MARKER_SOURCE_META_INFO_KEY: MARKER_SOURCE_META_INFO_VALUE, +} + +_MARKER_KEYS = frozenset(OPERATOR_META_INFO_MARKERS) + + +# --------------------------------------------------------------------------- +# Service profiles: markers live in meta_info +# --------------------------------------------------------------------------- + + +def service_profile_meta_info(profile: Any) -> Any: + """Return the ``meta_info`` of *profile*.""" + return get_value(profile, "meta_info", default={}) + + +def _comparable(value: Any) -> Any: + """Strip operator marker keys so specs and Neutron state compare equal.""" + normalized = normalize_meta_info(value) + if isinstance(normalized, dict): + return {k: v for k, v in normalized.items() if k not in _MARKER_KEYS} + return normalized + + +def meta_info_matches(current: Any, desired: Any) -> bool: + """Return True when *current* and *desired* meta_info are logically equal.""" + return meta_info_payload(_comparable(current)) == meta_info_payload( + _comparable(desired) + ) + + +def managed_meta_info(value: Any) -> Any: + """Return *value* with the operator ownership markers merged in.""" + normalized = normalize_meta_info(value) + if not isinstance(normalized, dict): + return normalized + return {**normalized, **OPERATOR_META_INFO_MARKERS} + + +def is_managed_service_profile(profile: Any) -> bool: + """Return True when *profile* carries the operator ownership marker.""" + meta_info = normalize_meta_info(service_profile_meta_info(profile)) + return ( + isinstance(meta_info, dict) + and meta_info.get(MANAGED_META_INFO_KEY) == MANAGED_META_INFO_VALUE + ) + + +# --------------------------------------------------------------------------- +# Flavors: the marker lives in description +# --------------------------------------------------------------------------- + + +def clean_flavor_description(value: Any) -> str: + """Return *value* with the operator description marker stripped.""" + return str(value or "").replace(FLAVOR_DESCRIPTION_MARKER, "").strip() + + +def managed_flavor_description(value: Any) -> str: + """Return *value* with the operator description marker appended.""" + description = clean_flavor_description(value) + if not description: + return FLAVOR_DESCRIPTION_MARKER + return f"{description} {FLAVOR_DESCRIPTION_MARKER}" + + +def flavor_description_has_marker(value: Any) -> bool: + """Return True when *value* contains the operator description marker.""" + return FLAVOR_DESCRIPTION_MARKER in str(value or "") + + +def is_managed_flavor(flavor: Any) -> bool: + """Return True when the flavor's description carries the operator marker.""" + return flavor_description_has_marker(get_value(flavor, "description", default="")) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/prune.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/prune.py new file mode 100644 index 000000000..49810ef51 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/prune.py @@ -0,0 +1,169 @@ +"""Delete router flavors and service profiles whose CR was removed. + +Everything here is gated on the operator's ownership markers, so a flavor or +profile created by hand is never touched. Ownership is the only gate needed: a +resource carrying the marker was created by this operator, which makes any +further filtering redundant. +""" + +from __future__ import annotations + +import logging +from collections import Counter +from typing import Any + +from openstack import exceptions as openstack_exceptions + +from openstack_sync.plugins.common import get_service_profile +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import resource_id +from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors.config import SERVICE_TYPE +from openstack_sync.plugins.neutron.router_flavors.markers import is_managed_flavor +from openstack_sync.plugins.neutron.router_flavors.markers import ( + is_managed_service_profile, +) + +LOG = logging.getLogger(__name__) + +#: Service profiles fetched during a prune, keyed by ID. None means "gone". +ProfileCache = dict[str, Any | None] + + +def _cached_profile(conn: Any, profile_id: str, cache: ProfileCache) -> Any | None: + if profile_id not in cache: + cache[profile_id] = get_service_profile(conn, profile_id) + return cache[profile_id] + + +def _attachment_counts(flavors: list[Any]) -> Counter[str]: + """Count how many flavors each service profile is bound to.""" + counts: Counter[str] = Counter() + for flavor in flavors: + counts.update(set(service_profile_ids(flavor))) + return counts + + +def _flavor_has_routers(conn: Any, flavor: Any, flavor_name: str) -> bool: + """Return True when routers still use *flavor*, or when we cannot tell.""" + try: + routers = list(conn.network.routers(flavor_id=resource_id(flavor))) + except Exception as exc: # noqa: BLE001 + LOG.warning( + "Unable to check routers for router flavor %s; skipping deletion: %s", + flavor_name, + exc, + ) + return True + + if routers: + LOG.info( + "Router flavor %s is still used by %s router(s); skipping deletion", + flavor_name, + len(routers), + ) + return True + return False + + +def maybe_delete_profile( + conn: Any, profile_id: str, cache: ProfileCache, counts: Counter[str] +) -> None: + """Delete *profile_id* when the operator owns it and nothing is bound to it.""" + profile = _cached_profile(conn, profile_id, cache) + if not profile: + return + if not is_managed_service_profile(profile): + LOG.info("Keeping service profile %s; it is not operator-owned", profile_id) + return + if counts[profile_id] > 0: + LOG.info("Keeping service profile %s; it is still attached", profile_id) + return + + LOG.info("Deleting unused service profile %s", profile_id) + try: + conn.network.delete_service_profile(profile, ignore_missing=True) + cache[profile_id] = None + except openstack_exceptions.NotFoundException: + cache[profile_id] = None + except openstack_exceptions.ConflictException: + LOG.info("Service profile %s is still in use; skipping delete", profile_id) + + +def _delete_flavor( + conn: Any, flavor: Any, cache: ProfileCache, counts: Counter[str] +) -> None: + flavor_id = resource_id(flavor) + flavor_name = get_value(flavor, "name", default=flavor_id) + profile_ids = service_profile_ids(flavor) + + if _flavor_has_routers(conn, flavor, flavor_name): + return + + LOG.info("Deleting removed router flavor %s (%s)", flavor_name, flavor_id) + try: + conn.network.delete_flavor(flavor, ignore_missing=True) + except openstack_exceptions.NotFoundException: + LOG.info("Router flavor %s (%s) is already absent", flavor_name, flavor_id) + except openstack_exceptions.ConflictException: + LOG.info("Router flavor %s is still in use; skipping delete", flavor_name) + return + + # The flavor is gone, so its profiles lost one attachment each. + for profile_id in profile_ids: + counts[profile_id] -= 1 + if counts[profile_id] <= 0: + del counts[profile_id] + + for profile_id in profile_ids: + maybe_delete_profile(conn, profile_id, cache, counts) + + +def _prune_orphaned_profiles( + conn: Any, cache: ProfileCache, counts: Counter[str] +) -> None: + """Delete owned, unattached profiles left behind by an earlier partial failure. + + Safe to run every cycle: it only ever touches operator-owned profiles that + no flavor is bound to. + """ + LOG.info("Scanning for orphaned operator-owned service profiles") + for profile in list(conn.network.service_profiles()): + if is_managed_service_profile(profile): + maybe_delete_profile(conn, resource_id(profile), cache, counts) + + +def prune_removed_flavors( + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool = False, +) -> None: + """Delete operator-owned router flavors absent from *desired_specs*. + + An empty *desired_specs* is only acted on when *authoritative_empty* says a + CR really was deleted; otherwise it may be a snapshot we could not read, and + pruning against it would delete every managed flavor. + """ + if not desired_specs and not authoritative_empty: + LOG.warning( + "No desired router flavors found; skipping prune to avoid deleting " + "all managed router flavors" + ) + return + + desired_names = {str(spec["name"]) for spec in desired_specs if spec.get("name")} + cache: ProfileCache = {} + + LOG.info("Pruning removed router flavors") + current = list(conn.network.flavors(service_type=SERVICE_TYPE)) + counts = _attachment_counts(current) + for flavor in current: + name = get_value(flavor, "name") + if not name or name in desired_names: + continue + if not is_managed_flavor(flavor): + continue + _delete_flavor(conn, flavor, cache, counts) + + _prune_orphaned_profiles(conn, cache, counts) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/reconcile.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/reconcile.py new file mode 100644 index 000000000..9ebe599fd --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/reconcile.py @@ -0,0 +1,420 @@ +"""Reconcile a NeutronRouterFlavor CR onto Neutron. + +Ordered as the reconcile reads: resolve the service profiles the spec asks for, +converge the flavor itself, then converge the set of profiles bound to it. +""" + +from __future__ import annotations + +import json +import logging +from dataclasses import dataclass +from typing import Any + +from openstack import exceptions as openstack_exceptions + +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import get_service_profile +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import meta_info_payload +from openstack_sync.plugins.common import resource_id +from openstack_sync.plugins.common import service_profile_ids +from openstack_sync.plugins.neutron.router_flavors.markers import ( + clean_flavor_description, +) +from openstack_sync.plugins.neutron.router_flavors.markers import ( + flavor_description_has_marker, +) +from openstack_sync.plugins.neutron.router_flavors.markers import ( + is_managed_service_profile, +) +from openstack_sync.plugins.neutron.router_flavors.markers import ( + managed_flavor_description, +) +from openstack_sync.plugins.neutron.router_flavors.markers import managed_meta_info +from openstack_sync.plugins.neutron.router_flavors.markers import meta_info_matches +from openstack_sync.plugins.neutron.router_flavors.markers import ( + service_profile_meta_info, +) + +LOG = logging.getLogger(__name__) + +#: Service profiles already fetched this run, keyed by driver. +ProfileCache = dict[str, list[Any]] + + +@dataclass(frozen=True) +class ProfileDrift: + """One field of a reused service profile that diverged from the CR spec. + + Profile drift is reported, never auto-corrected. Neutron's + ``update_service_profile`` calls ``_ensure_service_profile_not_in_use`` and + raises ``ServiceProfileInUse`` (HTTP 409) while *any* flavor binding exists + -- not merely while a router is using it -- and this operator binds every + profile it manages. An update attempt would fail every cycle. Correcting + drift means unbinding the profile from every flavor first, which is an + operator decision. + """ + + profile_id: str + field: str + have: Any + want: Any + + def describe(self) -> str: + return ( + f"service profile {self.profile_id} {self.field}: " + f"have={self.have!r} want={self.want!r}" + ) + + +# --------------------------------------------------------------------------- +# Service profiles +# --------------------------------------------------------------------------- + + +def profiles_for_driver(conn: Any, driver: str, cache: ProfileCache) -> list[Any]: + """Return every service profile for *driver*, fetched once per run.""" + if driver not in cache: + cache[driver] = list(conn.network.service_profiles(driver=driver)) + return cache[driver] + + +def find_matching_profile(profiles: list[Any], meta_info: Any) -> Any | None: + """Return the operator-owned profile matching *meta_info*, if any. + + Only operator-owned profiles are reuse candidates. Reusing a profile the + operator does not own would bind it to the flavor, and + ``reconcile_flavor_profiles`` unbinds only profiles carrying the ownership + marker -- so the operator would have created a binding it can never remove. + That binding outlives the spec that created it, and Neutron's + ``get_flavor_next_provider`` picks an arbitrary binding (``objs[0]``), so a + stale one can end up serving routers with the wrong ``meta_info``. + + An unowned profile that happens to match is left completely alone -- not + adopted by stamping the marker onto it, which would enrol somebody else's + profile into ``prune`` for eventual deletion -- and ``ensure_profile`` + creates a dedicated managed profile alongside it. + """ + unowned: list[str] = [] + for profile in profiles: + if not meta_info_matches(service_profile_meta_info(profile), meta_info): + continue + if is_managed_service_profile(profile): + return profile + unowned.append(str(get_value(profile, "id", default=""))) + + if unowned: + LOG.info( + "Not reusing service profile(s) %s: they match the desired meta_info " + "but are not operator-owned, and the operator only binds profiles it " + "can unbind again; creating a dedicated managed profile instead", + sorted(unowned), + ) + return None + + +def _profile_drift( + profile: Any, profile_id: str, flavor_name: str, spec: dict[str, Any] +) -> list[ProfileDrift]: + """Return the spec fields a reused *profile* disagrees with. + + ``meta_info`` is excluded by construction -- the profile was selected by + matching it -- and ``driver`` is excluded because profiles are queried per + driver. That leaves ``is_enabled`` and ``description``. + + ``is_enabled`` is the consequential one: Neutron's + ``get_flavor_next_provider`` raises ``ServiceProfileDisabled`` (HTTP 503) + when the profile it selects is disabled, so every router create against the + flavor fails while the flavor still looks healthy. + """ + checks = ( + ( + "is_enabled", + bool(get_value(profile, "is_enabled", default=True)), + bool(spec["is_enabled"]), + ), + ( + "description", + str(get_value(profile, "description", default="")), + str(spec.get("description", "")), + ), + ) + drift = [ + ProfileDrift(profile_id=profile_id, field=field, have=have, want=want) + for field, have, want in checks + if have != want + ] + + for item in drift: + LOG.warning( + "Service profile %s reused by router flavor %s has drifted from the " + "CR spec (%s: have=%r want=%r). Neutron rejects updates to a profile " + "bound to any flavor, so the operator cannot correct this; unbind it " + "from every flavor to update it, or delete it and let the operator " + "recreate it", + profile_id, + flavor_name, + item.field, + item.have, + item.want, + ) + return drift + + +def ensure_profile( + conn: Any, + flavor_name: str, + spec: dict[str, Any], + cache: ProfileCache, + drift: list[ProfileDrift], +) -> Any: + """Find or create the service profile *spec* describes. + + The CRD guarantees ``driver`` and ``is_enabled`` are present; ``description`` + and ``meta_info`` are optional and fall back to empty. Drift on a reused + profile is appended to *drift* -- this is the only place holding both the + desired spec value and the Neutron state, so it is the only place drift can + be detected. + """ + driver = spec["driver"] + meta_info = spec.get("meta_info", {}) + + profiles = profiles_for_driver(conn, driver, cache) + profile = find_matching_profile(profiles, meta_info) + if profile: + profile_id = resource_id(profile) + LOG.info( + "Reusing service profile %s for %s driver=%s", + profile_id, + flavor_name, + driver, + ) + drift.extend(_profile_drift(profile, profile_id, flavor_name, spec)) + return profile + + LOG.info( + "Creating service profile for %s driver=%s is_enabled=%s", + flavor_name, + driver, + spec["is_enabled"], + ) + created = conn.network.create_service_profile( + description=spec.get("description", ""), + driver=driver, + meta_info=meta_info_payload(managed_meta_info(meta_info)), + is_enabled=spec["is_enabled"], + ) + # Visible to any later flavor this run with an identical (driver, meta_info) + # spec, so it reuses this profile instead of creating a duplicate. + profiles.append(created) + return created + + +# --------------------------------------------------------------------------- +# The flavor +# --------------------------------------------------------------------------- + + +def find_flavor(conn: Any, name: str) -> Any | None: + """Return the flavor named *name*, or None. + + The SDK passes ``name=`` as a server-side query parameter which Neutron + filters in SQL, so at most one record comes back; the equality check guards + against a future change to substring semantics. + """ + for flavor in conn.network.flavors(name=name): + if get_value(flavor, "name") == name: + return flavor + return None + + +def ensure_flavor(conn: Any, spec: dict[str, Any]) -> Any: + """Find or create the router flavor *spec* describes, reconciling drift.""" + name = spec["name"] + service_type = spec["service_type"] + description = spec.get("description", "") + is_enabled = spec["is_enabled"] + + flavor = find_flavor(conn, name) + if not flavor: + LOG.info( + "Creating router flavor %s service_type=%s is_enabled=%s", + name, + service_type, + is_enabled, + ) + return conn.network.create_flavor( + name=name, + service_type=service_type, + is_enabled=is_enabled, + description=managed_flavor_description(description), + ) + + LOG.info("Router flavor %s already exists", name) + current_service_type = get_value(flavor, "service_type", default="") + if current_service_type != service_type: + raise ConfigError( + f"Router flavor {name!r} already exists in Neutron with " + f"service_type={current_service_type!r}; expected {service_type!r}. " + f"Neutron does not allow updating service_type on an existing " + f"flavor. Rename the CR or remove the existing Neutron flavor to " + f"let the operator recreate it." + ) + + current_description = get_value(flavor, "description", default="") + current_is_enabled = bool(get_value(flavor, "is_enabled", default=True)) + description_changed = clean_flavor_description( + current_description + ) != clean_flavor_description(description) + marker_missing = not flavor_description_has_marker(current_description) + is_enabled_changed = current_is_enabled != is_enabled + + if is_enabled_changed: + LOG.info( + "Router flavor %s is_enabled drift: have=%s want=%s; reconciling", + name, + current_is_enabled, + is_enabled, + ) + + if description_changed or marker_missing or is_enabled_changed: + return conn.network.update_flavor( + flavor, + description=managed_flavor_description(description), + is_enabled=is_enabled, + ) + return flavor + + +# --------------------------------------------------------------------------- +# Flavor <-> profile bindings +# --------------------------------------------------------------------------- + + +def _associate(conn: Any, flavor: Any, profile: Any) -> None: + flavor_id = resource_id(flavor) + profile_id = resource_id(profile) + LOG.info("Binding service profile %s to router flavor %s", profile_id, flavor_id) + try: + conn.network.associate_flavor_with_service_profile(flavor, profile) + except openstack_exceptions.ConflictException: + # Another reconcile bound it first. + LOG.info( + "Router flavor %s already has service profile %s", flavor_id, profile_id + ) + + +def _disassociate(conn: Any, flavor: Any, profile: Any) -> None: + flavor_id = resource_id(flavor) + profile_id = resource_id(profile) + LOG.info( + "Unbinding operator-managed service profile %s from router flavor %s", + profile_id, + flavor_id, + ) + try: + conn.network.disassociate_flavor_from_service_profile(flavor, profile) + except openstack_exceptions.NotFoundException: + LOG.info( + "Service profile %s already absent from router flavor %s", + profile_id, + flavor_id, + ) + except openstack_exceptions.ConflictException: + LOG.warning( + "Cannot unbind service profile %s from router flavor %s (Neutron " + "reports conflict, likely in use); leaving it attached", + profile_id, + flavor_id, + ) + + +def reconcile_flavor_profiles( + conn: Any, flavor: Any, desired_profiles: list[Any] +) -> Any: + """Converge the set of service profiles bound to *flavor*. + + Profiles missing from the flavor are bound; operator-owned profiles bound to + it but absent from the desired set are unbound. Profiles attached + out-of-band are left alone -- the operator only unbinds what it owns. + """ + flavor = conn.network.get_flavor(flavor) + flavor_name = get_value(flavor, "name", default=resource_id(flavor)) + + desired_by_id = {resource_id(p): p for p in desired_profiles} + current_ids = set(service_profile_ids(flavor)) + to_bind = set(desired_by_id) - current_ids + to_unbind = current_ids - set(desired_by_id) + + if not to_bind and not to_unbind: + LOG.info( + "Router flavor %s already has the desired service profiles %s", + flavor_name, + sorted(current_ids), + ) + return flavor + + for profile_id in sorted(to_bind): + _associate(conn, flavor, desired_by_id[profile_id]) + + for profile_id in sorted(to_unbind): + profile = get_service_profile(conn, profile_id) + if profile is None: + LOG.info( + "Service profile %s already absent from Neutron; nothing to unbind", + profile_id, + ) + continue + if not is_managed_service_profile(profile): + LOG.info( + "Keeping unowned service profile %s on router flavor %s; the " + "operator only unbinds profiles it owns", + profile_id, + flavor_name, + ) + continue + _disassociate(conn, flavor, profile) + + return conn.network.get_flavor(flavor) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def render_flavor(flavor: Any) -> dict[str, Any]: + """Return the reconciled flavor as a loggable dict.""" + return { + "id": get_value(flavor, "id"), + "name": get_value(flavor, "name"), + "service_type": get_value(flavor, "service_type"), + "description": get_value(flavor, "description"), + "is_enabled": get_value(flavor, "is_enabled"), + "service_profile_ids": service_profile_ids(flavor), + } + + +def sync_flavor(conn: Any, spec: dict[str, Any], cache: ProfileCache) -> list[str]: + """Converge one NeutronRouterFlavor spec, returning drift notes.""" + name = spec["name"] + profile_specs = spec["service_profiles"] + + LOG.info( + "Reconciling router flavor %s with %s service profile(s)", + name, + len(profile_specs), + ) + drift: list[ProfileDrift] = [] + desired_profiles = [ + ensure_profile(conn, name, profile_spec, cache, drift) + for profile_spec in profile_specs + ] + flavor = ensure_flavor(conn, spec) + flavor = reconcile_flavor_profiles(conn, flavor, desired_profiles) + LOG.info( + "Reconciled router flavor: %s", + json.dumps(render_flavor(flavor), sort_keys=True), + ) + return [item.describe() for item in drift] diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py deleted file mode 100644 index f44b39db5..000000000 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/router_flavors_common.py +++ /dev/null @@ -1,283 +0,0 @@ -"""Router-flavor-specific constants and helpers. - -Generic utilities (env helpers, resource accessors, meta_info, exception -classifiers, etc.) live in :mod:`openstack_sync.plugins.common`. -""" - -from __future__ import annotations - -import os -from dataclasses import dataclass -from typing import Any - -from openstack_sync.plugins.common import comparable_meta_info_without -from openstack_sync.plugins.common import env_bool -from openstack_sync.plugins.common import env_float -from openstack_sync.plugins.common import env_int -from openstack_sync.plugins.common import env_required -from openstack_sync.plugins.common import env_tuple -from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.common import managed_meta_info as managed_meta_info_with -from openstack_sync.plugins.common import meta_info_matches_without -from openstack_sync.plugins.common import normalize_meta_info -from openstack_sync.plugins.common import wait_for_openstack_network as wait_for_network - -# --------------------------------------------------------------------------- -# Router-flavor CRD identity -# --------------------------------------------------------------------------- -# CRD_API_VERSION, CRD_KIND, and CRD_RESOURCE are injected by the Helm chart -# at runtime and must NOT be read at module import time. Importing this module -# happens before shell-operator invokes the hook with --config, and these vars -# are not guaranteed to be present at that point (e.g. broken chart rendering, -# unit tests that only exercise the --config path). -# -# Use the accessor functions below — crd_api_version(), crd_kind(), -# crd_resource() — everywhere these values are needed. They call -# env_required() which raises ConfigError with a clear message if a var is -# absent, rather than crashing at import with a raw KeyError. -# -# Internal shell-operator binding label default. -CRD_BINDING_NAME = "neutron-router-flavors" -DEFAULT_SERVICE_TYPE = "L3_ROUTER_NAT" - - -def crd_api_version() -> str: - """Return the CRD API version injected by the Helm chart.""" - return env_required("NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION") - - -def crd_kind() -> str: - """Return the CRD kind injected by the Helm chart.""" - return env_required("NEUTRON_ROUTER_FLAVOR_CRD_KIND") - - -def crd_resource() -> str: - """Return the fully-qualified CRD resource name injected by the Helm chart.""" - return env_required("NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE") - - -def crd_binding_name() -> str: - """Return the shell-operator binding label for the CRD watch.""" - return os.environ.get("NEUTRON_ROUTER_FLAVOR_CRD_BINDING_NAME", CRD_BINDING_NAME) - - -def crd_namespace() -> str | None: - """Return the namespace used for CRD status patches.""" - return os.environ.get("POD_NAMESPACE") - - -def status_enabled() -> bool: - """Return whether CRD status patching is enabled.""" - return env_bool("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", False) - - -# --------------------------------------------------------------------------- -# Prune / lifecycle config -# --------------------------------------------------------------------------- - - -def prune_removed_flavors_enabled() -> bool: - """Return whether removed router flavor pruning is enabled.""" - return env_bool("NEUTRON_ROUTER_FLAVOR_PRUNE", False) - - -def delete_unused_service_profiles_enabled() -> bool: - """Return whether unused service profile deletion is enabled.""" - return env_bool("NEUTRON_ROUTER_FLAVOR_DELETE_UNUSED_PROFILES", True) - - -def prune_driver_prefixes() -> tuple[str, ...]: - """Return service profile driver prefixes eligible for pruning.""" - return env_tuple( - "NEUTRON_ROUTER_FLAVOR_PRUNE_DRIVER_PREFIXES", - "neutron_understack.l3_router.", - ) - - -# --------------------------------------------------------------------------- -# Operator ownership markers -# --------------------------------------------------------------------------- - -MANAGED_META_INFO_KEY = os.environ.get( - "NEUTRON_ROUTER_FLAVOR_MANAGED_META_INFO_KEY", - "_understack_router_flavor_operator", -) -MANAGED_META_INFO_VALUE = "managed" -FLAVOR_DESCRIPTION_MARKER = os.environ.get( - "NEUTRON_ROUTER_FLAVOR_DESCRIPTION_MARKER", - "[understack-router-flavor-operator]", -) -MARKER_VERSION_META_INFO_KEY = "_understack_router_flavor_marker_version" -MARKER_VERSION_META_INFO_VALUE = "v1" -MARKER_SOURCE_META_INFO_KEY = "_understack_router_flavor_source" - -# --------------------------------------------------------------------------- -# Retry config -# --------------------------------------------------------------------------- - - -def ready_retries() -> int: - """Return the Neutron readiness retry count.""" - return env_int("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", 30) - - -def ready_delay() -> float: - """Return the Neutron readiness delay in seconds.""" - return env_float("NEUTRON_ROUTER_FLAVOR_READY_DELAY", 10) - - -# --------------------------------------------------------------------------- -# Runtime-resolved marker helpers -# --------------------------------------------------------------------------- -# MARKER_SOURCE defaults to the CRD kind, which is only available at runtime. -# Use marker_source() rather than a module-level constant. - - -def marker_source() -> str: - """Return the marker source value, defaulting to the CRD kind.""" - return os.environ.get("NEUTRON_ROUTER_FLAVOR_SOURCE") or crd_kind() - - -def operator_meta_info_markers() -> dict[str, str]: - """Return the operator ownership marker dict.""" - return { - MANAGED_META_INFO_KEY: MANAGED_META_INFO_VALUE, - MARKER_VERSION_META_INFO_KEY: MARKER_VERSION_META_INFO_VALUE, - MARKER_SOURCE_META_INFO_KEY: marker_source(), - } - - -def operator_meta_info_keys() -> frozenset[str]: - """Return the frozenset of operator marker keys.""" - return frozenset(operator_meta_info_markers()) - - -# --------------------------------------------------------------------------- -# meta_info helpers bound to this plugin's operator marker keys -# --------------------------------------------------------------------------- - - -def comparable_meta_info(value: Any) -> Any: - """Strip operator marker keys from *value* before comparison.""" - return comparable_meta_info_without(value, operator_meta_info_keys()) - - -def meta_info_matches(current: Any, desired: Any) -> bool: - """Return True when *current* and *desired* are logically equal. - - Operator-managed marker keys are ignored during comparison. - """ - return meta_info_matches_without(current, desired, operator_meta_info_keys()) - - -def managed_meta_info(value: Any) -> Any: - """Merge operator ownership markers into *value*.""" - return managed_meta_info_with(value, operator_meta_info_markers()) - - -# --------------------------------------------------------------------------- -# Flavor description marker helpers -# --------------------------------------------------------------------------- - - -def clean_flavor_description(value: Any) -> str: - """Return *value* with the operator description marker stripped.""" - description = "" if value is None else str(value) - return description.replace(FLAVOR_DESCRIPTION_MARKER, "").strip() - - -def managed_flavor_description(value: Any) -> str: - """Return *value* with the operator description marker appended.""" - description = clean_flavor_description(value) - if not description: - return FLAVOR_DESCRIPTION_MARKER - return f"{description} {FLAVOR_DESCRIPTION_MARKER}" - - -def flavor_description_has_marker(value: Any) -> bool: - """Return True when *value* contains the operator description marker.""" - return FLAVOR_DESCRIPTION_MARKER in str(value or "") - - -def is_managed_flavor(flavor: Any) -> bool: - """Return True when the flavor's description contains the operator marker.""" - return flavor_description_has_marker(get_value(flavor, "description", default="")) - - -# --------------------------------------------------------------------------- -# Service profile ownership helpers -# --------------------------------------------------------------------------- - - -def service_profile_meta_info(profile: Any) -> Any: - """Return the meta_info field of *profile*.""" - return get_value(profile, "meta_info", default={}) - - -def is_managed_service_profile(profile: Any) -> bool: - """Return True when the service profile carries the operator ownership marker.""" - meta_info = normalize_meta_info(service_profile_meta_info(profile)) - return ( - isinstance(meta_info, dict) - and meta_info.get(MANAGED_META_INFO_KEY) == MANAGED_META_INFO_VALUE - ) - - -# --------------------------------------------------------------------------- -# Service profile drift reporting -# --------------------------------------------------------------------------- - - -@dataclass(frozen=True) -class ProfileDrift: - """One field of a reused service profile that diverged from the CR spec. - - Profile drift is reported, never auto-corrected. Neutron's - ``update_service_profile`` calls ``_ensure_service_profile_not_in_use`` and - raises ``ServiceProfileInUse`` (HTTP 409) while *any* flavor binding exists - -- not merely while a router is using it -- and this operator binds every - profile it manages. An update attempt would therefore fail every cycle. - Correcting drift requires unbinding the profile from every flavor first, - which is an operator decision, not something to do behind their back. - """ - - profile_id: str - driver: str - field: str - have: Any - want: Any - - def describe(self) -> str: - """Return a short ``field: have=... want=...`` description.""" - return f"{self.field}: have={self.have!r} want={self.want!r}" - - -def describe_profile_drift(drift: list[ProfileDrift]) -> str: - """Return a single-line summary of *drift* for logs and CR status.""" - return "; ".join( - f"service profile {item.profile_id} {item.describe()}" for item in drift - ) - - -# --------------------------------------------------------------------------- -# Config validation -# --------------------------------------------------------------------------- - - -def config_meta_info(flavor_config: dict[str, Any]) -> Any: - """Return the canonical meta_info payload from a router flavor spec.""" - return flavor_config.get("meta_info", {}) - - -# --------------------------------------------------------------------------- -# Neutron readiness probe -# --------------------------------------------------------------------------- - - -def wait_for_openstack_network(conn: Any) -> None: - """Poll until the Neutron network API is reachable. - - Reads retry config at call time so malformed values do not break hook - import or shell-operator --config registration. - """ - wait_for_network(conn, retries=ready_retries(), delay=ready_delay()) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py b/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py deleted file mode 100644 index 77a3e1ac7..000000000 --- a/python/openstack-sync/openstack_sync/plugins/neutron/router_flavors/update.py +++ /dev/null @@ -1,143 +0,0 @@ -"""Update and sync logic for configured Neutron router flavors.""" - -from __future__ import annotations - -import json -import logging -from typing import Any - -from openstack_sync.plugins.common import ConfigError -from openstack_sync.plugins.common import get_value -from openstack_sync.plugins.common import service_profile_ids -from openstack_sync.plugins.neutron.router_flavors import create -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - DEFAULT_SERVICE_TYPE, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - ProfileDrift, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - clean_flavor_description, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - describe_profile_drift, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - flavor_description_has_marker, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - managed_flavor_description, -) - -LOG = logging.getLogger(__name__) - - -def ensure_flavor( - conn: Any, - name: str, - service_type: str, - description: str, - *, - is_enabled: bool, -) -> Any: - flavor = create.find_flavor(conn, name) - managed_description = managed_flavor_description(description) - if flavor: - LOG.info("Router flavor %s already exists", name) - - current_service_type = get_value(flavor, "service_type", default="") - if current_service_type != service_type: - raise ConfigError( - f"Router flavor {name!r} already exists in Neutron with " - f"service_type={current_service_type!r}; " - f"expected {service_type!r}. Neutron does not allow updating " - f"service_type on an existing flavor. Rename the CR or remove " - f"the existing Neutron flavor to let the operator recreate it." - ) - - current_description = get_value(flavor, "description", default="") - description_changed = clean_flavor_description( - current_description - ) != clean_flavor_description(description) - marker_missing = not flavor_description_has_marker(current_description) - current_is_enabled = bool(get_value(flavor, "is_enabled", default=True)) - is_enabled_drifted = current_is_enabled != is_enabled - - if is_enabled_drifted: - LOG.info( - "Router flavor %s is_enabled drift: have=%s want=%s; reconciling", - name, - current_is_enabled, - is_enabled, - ) - - if description_changed or marker_missing or is_enabled_drifted: - return conn.network.update_flavor( - flavor, - description=managed_description, - is_enabled=is_enabled, - ) - return flavor - - return create.create_flavor( - conn, name, service_type, description, is_enabled=is_enabled - ) - - -def render_flavor(flavor: Any) -> dict[str, Any]: - return { - "id": get_value(flavor, "id"), - "name": get_value(flavor, "name"), - "service_type": get_value(flavor, "service_type"), - "description": get_value(flavor, "description"), - "is_enabled": get_value(flavor, "is_enabled"), - "service_profile_ids": service_profile_ids(flavor), - } - - -def sync_flavor( - conn: Any, - flavor_config: dict[str, Any], - profile_cache: create.ServiceProfileCache, -) -> list[ProfileDrift]: - """Reconcile one router flavor CR to the desired Neutron state. - - ``flavor_config`` is the CR spec after cloudCredentialsRef has been - stripped. Schema-required keys are read via subscript so a missing key - fails loudly rather than being silently defaulted; schema-optional keys - (description, meta_info) fall back to their type's empty value. - - Returns the service profile drift detected while reconciling. An empty list - means spec and Neutron agree. Drift is not a reconcile failure -- the flavor - itself is still converged -- but it needs an operator to act, so the caller - is expected to qualify the status it reports rather than dropping it. - """ - name = flavor_config["name"] - service_type = flavor_config.get("service_type", DEFAULT_SERVICE_TYPE) - description = flavor_config.get("description", "") - is_enabled = flavor_config["is_enabled"] - profile_specs = flavor_config["service_profiles"] - - LOG.info( - "Reconciling router flavor %s with %s service profile(s)", - name, - len(profile_specs), - ) - drift: list[ProfileDrift] = [] - desired_profiles = [ - create.ensure_profile(conn, name, profile_spec, profile_cache, drift) - for profile_spec in profile_specs - ] - flavor = ensure_flavor(conn, name, service_type, description, is_enabled=is_enabled) - flavor = create.reconcile_flavor_profiles(conn, flavor, desired_profiles) - LOG.info( - "Reconciled router flavor: %s", - json.dumps(render_flavor(flavor), sort_keys=True), - ) - if drift: - LOG.warning( - "Router flavor %s converged but carries service profile drift: %s", - name, - describe_profile_drift(drift), - ) - return drift diff --git a/python/openstack-sync/pyproject.toml b/python/openstack-sync/pyproject.toml index ef728ddd2..c43f68b26 100644 --- a/python/openstack-sync/pyproject.toml +++ b/python/openstack-sync/pyproject.toml @@ -81,4 +81,10 @@ force-single-line = true convention = "google" [tool.ruff.lint.per-file-ignores] -"tests/*" = ["S101"] # assert is the point in tests +"tests/*" = [ + "S101", # assert is the point in tests + # Fixtures name Kubernetes Secrets, which flake8-bandit reads as passwords. + "S105", + "S106", + "S107", +] diff --git a/python/openstack-sync/tests/conftest.py b/python/openstack-sync/tests/conftest.py index 70eb5a7e1..ca92df680 100644 --- a/python/openstack-sync/tests/conftest.py +++ b/python/openstack-sync/tests/conftest.py @@ -1,31 +1,61 @@ """Pytest configuration and shared fixtures for openstack-sync tests. -Sets environment variables that router_flavors_common.py reads at runtime -via env_required(). These must be present when any function that calls -crd_kind() / crd_api_version() / crd_resource() runs, so they are set -via a session-scoped autouse fixture that runs before every test. +Two levels of configuration, matching where the code reads it: + +* Hook-level tests drive ``main()``, which reads the environment the Helm chart + injects. The autouse fixture below provides the CRD identity variables the + chart always sets, so those tests exercise the real boundary. +* Everything below the hook takes a :class:`HookConfig` argument, so unit tests + use the ``hook_config`` fixture and never touch the environment. """ from __future__ import annotations import pytest -_ROUTER_FLAVOR_REQUIRED_ENV = { - "NEUTRON_ROUTER_FLAVOR_CRD_API_VERSION": ( - "neutron.understack.rackspace.net/v1alpha1" - ), - "NEUTRON_ROUTER_FLAVOR_CRD_KIND": "NeutronRouterFlavor", - "NEUTRON_ROUTER_FLAVOR_CRD_RESOURCE": ( - "neutronrouterflavors.neutron.understack.rackspace.net" - ), +from openstack_sync.hooks.framework import HookConfig +from openstack_sync.plugins.neutron.router_flavors.config import BINDING_NAME +from openstack_sync.plugins.neutron.router_flavors.config import ENV_PREFIX + +CRD_API_VERSION = "neutron.understack.rackspace.net/v1alpha1" +CRD_KIND = "NeutronRouterFlavor" +CRD_RESOURCE = "neutronrouterflavors.neutron.understack.rackspace.net" + +_CRD_IDENTITY_ENV = { + f"{ENV_PREFIX}_CRD_API_VERSION": CRD_API_VERSION, + f"{ENV_PREFIX}_CRD_KIND": CRD_KIND, + f"{ENV_PREFIX}_CRD_RESOURCE": CRD_RESOURCE, } @pytest.fixture(autouse=True) -def _router_flavor_env(monkeypatch: pytest.MonkeyPatch) -> None: - """Ensure required router flavor env vars are set for every test. +def _crd_identity_env(monkeypatch: pytest.MonkeyPatch) -> None: + """Provide the CRD identity variables the Helm chart always injects. - Individual tests may override these via their own monkeypatch calls. + Individual tests may override these with their own monkeypatch calls. """ - for key, value in _ROUTER_FLAVOR_REQUIRED_ENV.items(): + for key, value in _CRD_IDENTITY_ENV.items(): monkeypatch.setenv(key, value) + + +def make_hook_config(**overrides) -> HookConfig: + """Build a HookConfig without touching the environment.""" + defaults = { + "prefix": ENV_PREFIX, + "crd_api_version": CRD_API_VERSION, + "crd_kind": CRD_KIND, + "crd_resource": CRD_RESOURCE, + "binding_name": BINDING_NAME, + "namespace": "openstack", + "status_enabled": False, + "prune": False, + "sync_crontab": "", + "ready_retries": 30, + "ready_delay": 10.0, + } + return HookConfig(**{**defaults, **overrides}) + + +@pytest.fixture +def hook_config() -> HookConfig: + return make_hook_config() diff --git a/python/openstack-sync/tests/test_framework.py b/python/openstack-sync/tests/test_framework.py new file mode 100644 index 000000000..71b5595d8 --- /dev/null +++ b/python/openstack-sync/tests/test_framework.py @@ -0,0 +1,767 @@ +"""Tests for the generic sync framework. + +Deliberately free of Neutron: the driver is exercised through a stub plugin, so +these tests describe the contract any future plugin can rely on. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest import mock + +import pytest + +from openstack_sync.hooks import framework +from openstack_sync.hooks.framework import HookConfig +from openstack_sync.hooks.framework import HookInputs +from openstack_sync.hooks.framework import SyncPlugin +from openstack_sync.hooks.framework import SyncResource +from openstack_sync.hooks.framework import build_crd_hook_config +from openstack_sync.hooks.framework import hook_inputs +from openstack_sync.hooks.framework import run_hook +from openstack_sync.hooks.framework import run_sync +from openstack_sync.hooks.framework import synced_message +from openstack_sync.plugins.common import ConfigError +from tests.conftest import CRD_API_VERSION +from tests.conftest import CRD_KIND +from tests.conftest import CRD_RESOURCE +from tests.conftest import make_hook_config + +PREFIX = "NEUTRON_ROUTER_FLAVOR" +BINDING = "neutron-router-flavors" + +ENV_NAMES = ( + "BINDING_CONTEXT_PATH", + f"{PREFIX}_ENABLED", + f"{PREFIX}_SYNC_CRONTAB", + f"{PREFIX}_PRUNE", + f"{PREFIX}_STATUS_ENABLED", + f"{PREFIX}_READY_RETRIES", + f"{PREFIX}_READY_DELAY", + "POD_NAMESPACE", +) + + +def clear_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ENV_NAMES: + monkeypatch.delenv(name, raising=False) + + +# --------------------------------------------------------------------------- +# Stub plugin +# --------------------------------------------------------------------------- + + +class StubPlugin(SyncPlugin): + """Records what the driver asked it to do.""" + + noun = "widget" + + def __init__( + self, + config: HookConfig, + *, + fail_for: tuple[str, ...] = (), + notes_for: dict[str, list[str]] | None = None, + prune_raises: bool = False, + ) -> None: + super().__init__(config) + self.fail_for = set(fail_for) + self.notes_for = notes_for or {} + self.prune_raises = prune_raises + self.reconciled: list[str] = [] + self.pruned: list[tuple[list[str], bool]] = [] + self.waits = 0 + self.caches: list[Any] = [] + + def wait_for_api(self, conn: Any) -> None: + self.waits += 1 + + def new_cache(self) -> Any: + cache: dict[str, Any] = {} + self.caches.append(cache) + return cache + + def reconcile(self, conn: Any, spec: dict[str, Any], cache: Any) -> list[str]: + name = spec["name"] + self.reconciled.append(name) + if name in self.fail_for: + raise RuntimeError(f"reconcile failed for {name}") + return list(self.notes_for.get(name, [])) + + def prune( + self, + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool, + ) -> None: + if self.prune_raises: + raise RuntimeError("prune exploded") + self.pruned.append( + ([spec["name"] for spec in desired_specs], authoritative_empty) + ) + + +def _resource( + name: str, secret: str = "infrasetup", cloud: str = "understack" +) -> SyncResource: + return SyncResource( + spec={"name": name}, + name=name, + namespace="openstack", + generation=1, + secret_name=secret, + cloud_name=cloud, + ) + + +def _inputs( + reconcile: list[SyncResource], + desired: list[SyncResource] | None = None, + deleted: list[SyncResource] | None = None, + prune_credentials: frozenset[tuple[str, str]] | None = None, +) -> HookInputs: + desired = reconcile if desired is None else desired + deleted = deleted or [] + if prune_credentials is None: + prune_credentials = frozenset(r.credentials for r in desired + deleted) + return HookInputs(reconcile, desired, deleted, prune_credentials) + + +def _drive(plugin: StubPlugin, inputs: HookInputs): + """Run the driver with connections and status patching stubbed out.""" + with ( + mock.patch.object(framework, "get_openstack_connection") as connect, + mock.patch.object(framework, "patch_resource_status") as patch_status, + ): + code = run_sync(plugin, inputs) + return code, patch_status, connect + + +# --------------------------------------------------------------------------- +# HookConfig: the chart contract +# --------------------------------------------------------------------------- + + +def test_hook_config_reads_the_chart_contract(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_STATUS_ENABLED", "true") + monkeypatch.setenv(f"{PREFIX}_PRUNE", "true") + monkeypatch.setenv(f"{PREFIX}_SYNC_CRONTAB", "0 * * * *") + monkeypatch.setenv(f"{PREFIX}_READY_RETRIES", "7") + monkeypatch.setenv(f"{PREFIX}_READY_DELAY", "2.5") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + config = HookConfig.from_env(PREFIX, binding_name=BINDING) + + assert config.crd_api_version == CRD_API_VERSION + assert config.crd_kind == CRD_KIND + assert config.crd_resource == CRD_RESOURCE + assert config.binding_name == BINDING + assert config.namespace == "openstack" + assert config.status_enabled is True + assert config.prune is True + assert config.sync_crontab == "0 * * * *" + assert config.ready_retries == 7 + assert config.ready_delay == 2.5 + + +def test_hook_config_defaults_are_off(monkeypatch): + clear_env(monkeypatch) + + config = HookConfig.from_env(PREFIX, binding_name=BINDING) + + assert config.status_enabled is False + assert config.prune is False + assert config.sync_crontab == "" + assert config.ready_retries == 30 + assert config.ready_delay == 10 + + +def test_hook_config_requires_crd_identity(monkeypatch): + clear_env(monkeypatch) + monkeypatch.delenv(f"{PREFIX}_CRD_KIND", raising=False) + + with pytest.raises(ConfigError, match=f"{PREFIX}_CRD_KIND"): + HookConfig.from_env(PREFIX, binding_name=BINDING) + + +# --------------------------------------------------------------------------- +# Hook config JSON +# --------------------------------------------------------------------------- + + +def test_disabled_hook_config_is_a_valid_noop(monkeypatch): + clear_env(monkeypatch) + + config = build_crd_hook_config(PREFIX, BINDING) + + # shell-operator requires at least one binding, but a disabled hook must not + # register Kubernetes watches it will never service. + assert config["onStartup"] == 10 + assert "kubernetes" not in config + assert "schedule" not in config + + +def test_disabled_hook_config_does_not_read_runtime_env(monkeypatch): + """--config runs before the environment is guaranteed to be complete.""" + clear_env(monkeypatch) + for name in ( + f"{PREFIX}_CRD_API_VERSION", + f"{PREFIX}_CRD_KIND", + f"{PREFIX}_CRD_RESOURCE", + ): + monkeypatch.delenv(name, raising=False) + + config = build_crd_hook_config(PREFIX, BINDING) + + assert config["onStartup"] == 10 + + +def test_crontab_does_not_enable_a_disabled_hook(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_SYNC_CRONTAB", "0 * * * *") + + config = build_crd_hook_config(PREFIX, BINDING) + + assert "schedule" not in config + assert config["onStartup"] == 10 + + +def test_enabled_hook_config_watches_the_crd(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + + config = build_crd_hook_config(PREFIX, BINDING) + + (binding,) = config["kubernetes"] + assert binding["name"] == BINDING + assert binding["apiVersion"] == CRD_API_VERSION + assert binding["kind"] == CRD_KIND + assert binding["executeHookOnEvent"] == ["Added", "Modified", "Deleted"] + # The full object is needed: the reconcile reads spec and status. + assert binding["jqFilter"] == "." + assert binding["includeSnapshotsFrom"] == [BINDING] + # A dedicated queue keeps a slow reconcile from blocking other hooks. + assert binding["queue"] == BINDING + assert binding["namespace"] == {"nameSelector": {"matchNames": ["openstack"]}} + + +def test_enabled_hook_config_omits_schedule_without_crontab(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_ENABLED", "true") + + assert "schedule" not in build_crd_hook_config(PREFIX, BINDING) + + +def test_enabled_hook_config_adds_schedule_with_crontab(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_ENABLED", "true") + monkeypatch.setenv(f"{PREFIX}_SYNC_CRONTAB", "*/5 * * * *") + + (schedule,) = build_crd_hook_config(PREFIX, BINDING)["schedule"] + + assert schedule["crontab"] == "*/5 * * * *" + assert schedule["includeSnapshotsFrom"] == [BINDING] + assert schedule["queue"] == BINDING + + +def test_enabled_hook_config_omits_namespace_without_pod_namespace(monkeypatch): + clear_env(monkeypatch) + monkeypatch.setenv(f"{PREFIX}_ENABLED", "true") + + (binding,) = build_crd_hook_config(PREFIX, BINDING)["kubernetes"] + + assert "namespace" not in binding + + +# --------------------------------------------------------------------------- +# Binding context -> HookInputs +# --------------------------------------------------------------------------- + + +def _cr(name: str, generation: int = 3, status: dict | None = None) -> dict: + obj = { + "apiVersion": CRD_API_VERSION, + "kind": CRD_KIND, + "metadata": {"name": name, "namespace": "openstack", "generation": generation}, + "spec": { + "name": name, + "cloudCredentialsRef": { + "secretName": "infrasetup", + "cloudName": "understack", + }, + }, + } + if status is not None: + obj["status"] = status + return obj + + +def test_hook_inputs_from_snapshot_reconciles_everything(): + config = make_hook_config() + contexts = [ + { + "binding": BINDING, + "type": "Schedule", + "snapshots": {BINDING: [{"object": _cr("b")}, {"object": _cr("a")}]}, + } + ] + + inputs = hook_inputs(contexts, config) + + # Sorted so a run is deterministic. + assert [r.spec["name"] for r in inputs.resources_to_reconcile] == ["a", "b"] + assert inputs.desired_resources_for_prune == inputs.resources_to_reconcile + assert inputs.deleted_resources == [] + assert inputs.prune_credentials == frozenset({("infrasetup", "understack")}) + + +def test_hook_inputs_from_synchronization(): + config = make_hook_config() + contexts = [ + { + "binding": BINDING, + "type": "Synchronization", + "objects": [{"object": _cr("a")}], + } + ] + + inputs = hook_inputs(contexts, config) + + assert [r.spec["name"] for r in inputs.resources_to_reconcile] == ["a"] + + +def test_hook_inputs_strips_cloud_credentials_from_spec(): + """A plugin must see only its own fields.""" + config = make_hook_config() + contexts = [ + { + "binding": BINDING, + "type": "Schedule", + "snapshots": {BINDING: [{"object": _cr("a")}]}, + } + ] + + (resource,) = hook_inputs(contexts, config).resources_to_reconcile + + assert "cloudCredentialsRef" not in resource.spec + assert resource.secret_name == "infrasetup" + assert resource.cloud_name == "understack" + + +def test_hook_inputs_keeps_current_status(): + """The status is needed to break the status-patch feedback loop.""" + config = make_hook_config() + status = {"syncStatus": "Synced", "observedGeneration": 3} + contexts = [ + { + "binding": BINDING, + "type": "Schedule", + "snapshots": {BINDING: [{"object": _cr("a", status=status)}]}, + } + ] + + (resource,) = hook_inputs(contexts, config).resources_to_reconcile + + assert resource.current_status == status + + +def test_added_event_reconciles_only_the_changed_resource(): + config = make_hook_config() + contexts = [ + { + "binding": BINDING, + "type": "Event", + "watchEvent": "Added", + "object": _cr("new"), + "snapshots": {BINDING: [{"object": _cr("new")}, {"object": _cr("old")}]}, + } + ] + + inputs = hook_inputs(contexts, config) + + assert [r.spec["name"] for r in inputs.resources_to_reconcile] == ["new"] + # Prune still needs the full desired set, or it would delete "old". + assert [r.spec["name"] for r in inputs.desired_resources_for_prune] == [ + "new", + "old", + ] + + +def test_deleted_event_reconciles_nothing_but_prunes(): + config = make_hook_config() + contexts = [ + { + "binding": BINDING, + "type": "Event", + "watchEvent": "Deleted", + "object": _cr("gone"), + "snapshots": {BINDING: [{"object": _cr("kept")}]}, + } + ] + + inputs = hook_inputs(contexts, config) + + assert inputs.resources_to_reconcile == [] + assert [r.spec["name"] for r in inputs.deleted_resources] == ["gone"] + assert inputs.prune_credentials == frozenset({("infrasetup", "understack")}) + + +def test_modified_event_skipped_when_status_already_current(): + """The hook's own status patch must not trigger another reconcile.""" + config = make_hook_config() + current = {"syncStatus": "Synced", "observedGeneration": 3} + obj = _cr("a", generation=3, status=current) + contexts = [ + { + "binding": BINDING, + "type": "Event", + "watchEvent": "Modified", + "object": obj, + "snapshots": {BINDING: [{"object": obj}]}, + } + ] + + inputs = hook_inputs(contexts, config) + + assert inputs.resources_to_reconcile == [] + + +def test_modified_event_reconciles_when_generation_bumped(): + config = make_hook_config() + stale = {"syncStatus": "Synced", "observedGeneration": 2} + obj = _cr("a", generation=3, status=stale) + contexts = [ + { + "binding": BINDING, + "type": "Event", + "watchEvent": "Modified", + "object": obj, + "snapshots": {BINDING: [{"object": obj}]}, + } + ] + + inputs = hook_inputs(contexts, config) + + assert [r.spec["name"] for r in inputs.resources_to_reconcile] == ["a"] + + +def test_event_context_without_snapshot_is_an_error(): + config = make_hook_config() + contexts = [ + {"binding": BINDING, "type": "Event", "watchEvent": "Added", "object": _cr("a")} + ] + + with pytest.raises(ConfigError, match="snapshot"): + hook_inputs(contexts, config) + + +def test_unrecognised_context_is_an_error(): + config = make_hook_config() + + with pytest.raises(ConfigError, match="does not contain"): + hook_inputs([{"binding": "something-else", "type": "Event"}], config) + + +# --------------------------------------------------------------------------- +# run_sync +# --------------------------------------------------------------------------- + + +def test_run_sync_reconciles_and_reports_synced(): + plugin = StubPlugin(make_hook_config()) + + code, patch_status, _ = _drive(plugin, _inputs([_resource("a"), _resource("b")])) + + assert code == 0 + assert plugin.reconciled == ["a", "b"] + statuses = [call.kwargs["sync_status"] for call in patch_status.call_args_list] + assert statuses == ["Synced", "Synced"] + assert patch_status.call_args_list[0].kwargs["message"] == ( + "Successfully reconciled widget" + ) + + +def test_run_sync_waits_for_api_once_per_credential_group(): + plugin = StubPlugin(make_hook_config()) + resources = [ + _resource("a"), + _resource("b"), + _resource("c", secret="other", cloud="other-cloud"), + ] + + _drive(plugin, _inputs(resources)) + + assert plugin.waits == 2 + # One cache per group, so lookups are shared within a group but not across. + assert len(plugin.caches) == 2 + + +def test_run_sync_connects_with_each_resources_own_credentials(): + plugin = StubPlugin(make_hook_config()) + resources = [ + _resource("a", secret="secret-a", cloud="cloud-a"), + _resource("b", secret="secret-b", cloud="cloud-b"), + ] + + _, _, connect = _drive(plugin, _inputs(resources)) + + assert sorted(call.args for call in connect.call_args_list) == [ + ("secret-a", "cloud-a"), + ("secret-b", "cloud-b"), + ] + + +def test_run_sync_forwards_crd_identity_and_current_status_to_the_patch(): + """Forward everything patch_resource_status needs. + + The CRD identity targets kubectl, and the current status decides whether the + patch can be skipped. + """ + config = make_hook_config(status_enabled=True) + plugin = StubPlugin(config) + status = {"syncStatus": "Synced", "observedGeneration": 1} + resource = SyncResource( + spec={"name": "a"}, + name="a", + namespace="openstack", + generation=1, + secret_name="infrasetup", + cloud_name="understack", + current_status=status, + ) + + _, patch_status, _ = _drive(plugin, _inputs([resource])) + + kwargs = patch_status.call_args.kwargs + assert kwargs["crd_resource"] == CRD_RESOURCE + assert kwargs["crd_kind"] == CRD_KIND + assert kwargs["status_enabled"] is True + assert kwargs["current_status"] == status + assert kwargs["generation"] == 1 + assert kwargs["namespace"] == "openstack" + + +def test_run_sync_reports_notes_without_failing(): + plugin = StubPlugin(make_hook_config(), notes_for={"a": ["thing drifted"]}) + + code, patch_status, _ = _drive(plugin, _inputs([_resource("a")])) + + assert code == 0 + assert patch_status.call_args.kwargs["sync_status"] == "Synced" + message = patch_status.call_args.kwargs["message"] + assert message.startswith("Successfully reconciled widget") + assert "thing drifted" in message + + +def test_run_sync_marks_failure_and_skips_prune(): + """A failed reconcile means the desired set is unknown, so prune must not run.""" + plugin = StubPlugin(make_hook_config(prune=True), fail_for=("b",)) + + code, patch_status, _ = _drive(plugin, _inputs([_resource("a"), _resource("b")])) + + assert code == 1 + assert plugin.pruned == [] + by_name = { + call.kwargs["name"]: call.kwargs["sync_status"] + for call in patch_status.call_args_list + } + assert by_name == {"a": "Synced", "b": "Failed"} + + +def test_run_sync_continues_after_one_failure(): + plugin = StubPlugin(make_hook_config(), fail_for=("a",)) + + _drive(plugin, _inputs([_resource("a"), _resource("b")])) + + assert plugin.reconciled == ["a", "b"] + + +def test_run_sync_marks_whole_group_failed_when_connection_fails(): + plugin = StubPlugin(make_hook_config()) + inputs = _inputs([_resource("a"), _resource("b")]) + + with ( + mock.patch.object( + framework, + "get_openstack_connection", + side_effect=RuntimeError("no route to keystone"), + ), + mock.patch.object(framework, "patch_resource_status") as patch_status, + ): + code = run_sync(plugin, inputs) + + assert code == 1 + assert plugin.reconciled == [] + statuses = {call.kwargs["sync_status"] for call in patch_status.call_args_list} + assert statuses == {"Failed"} + assert "no route to keystone" in patch_status.call_args.kwargs["message"] + + +def test_run_sync_marks_group_failed_when_api_never_becomes_ready(): + class NeverReady(StubPlugin): + def wait_for_api(self, conn): + raise RuntimeError("api not ready") + + plugin = NeverReady(make_hook_config()) + + code, patch_status, _ = _drive(plugin, _inputs([_resource("a")])) + + assert code == 1 + assert plugin.reconciled == [] + assert patch_status.call_args.kwargs["sync_status"] == "Failed" + assert "api not ready" in patch_status.call_args.kwargs["message"] + + +def test_run_sync_prunes_after_successful_reconcile(): + plugin = StubPlugin(make_hook_config(prune=True)) + + code, _, _ = _drive(plugin, _inputs([_resource("a")])) + + assert code == 0 + assert plugin.pruned == [(["a"], False)] + + +def test_run_sync_prune_is_authoritative_for_deleted_credentials(): + """A confirmed deletion lets prune act on an empty desired set.""" + plugin = StubPlugin(make_hook_config(prune=True)) + deleted = _resource("gone") + inputs = _inputs([], desired=[], deleted=[deleted]) + + code, _, _ = _drive(plugin, inputs) + + assert code == 0 + assert plugin.pruned == [([], True)] + + +def test_run_sync_skips_prune_for_credentials_with_no_desired_resources(): + """An empty desired set with no deletion may be an unreadable snapshot.""" + plugin = StubPlugin(make_hook_config(prune=True)) + inputs = HookInputs([], [], [], frozenset({("infrasetup", "understack")})) + + code, _, _ = _drive(plugin, inputs) + + assert code == 0 + assert plugin.pruned == [] + + +def test_run_sync_does_not_connect_for_prune_when_prune_disabled(): + """A deleted-only run must not open a connection just to do nothing.""" + plugin = StubPlugin(make_hook_config(prune=False)) + inputs = _inputs([], desired=[], deleted=[_resource("gone")]) + + code, _, connect = _drive(plugin, inputs) + + assert code == 0 + assert connect.call_count == 0 + assert plugin.pruned == [] + + +def test_run_sync_returns_error_when_prune_fails(): + plugin = StubPlugin(make_hook_config(prune=True), prune_raises=True) + + code, _, _ = _drive(plugin, _inputs([_resource("a")])) + + assert code == 1 + + +def test_run_sync_skips_status_patch_without_metadata_name(): + plugin = StubPlugin(make_hook_config()) + nameless = SyncResource( + spec={"name": "a"}, + name=None, + namespace="openstack", + generation=1, + secret_name="infrasetup", + cloud_name="understack", + ) + + code, patch_status, _ = _drive(plugin, _inputs([nameless])) + + assert code == 0 + patch_status.assert_not_called() + + +def test_synced_message_is_unqualified_without_notes(): + assert synced_message("widget", []) == "Successfully reconciled widget" + + +def test_synced_message_lists_every_note(): + message = synced_message("widget", ["first", "second"]) + + assert "first" in message + assert "second" in message + + +# --------------------------------------------------------------------------- +# run_hook +# --------------------------------------------------------------------------- + + +def _write_context(path: Path, payload: str) -> str: + context_path = path / "binding-context.json" + context_path.write_text(payload, encoding="utf-8") + return str(context_path) + + +def test_run_hook_prints_config_and_exits(monkeypatch, capsys): + monkeypatch.setattr(framework.sys, "argv", ["hook.py", "--config"]) + + code = run_hook(lambda: {"configVersion": "v1"}, lambda contexts: 99) + + assert code == 0 + assert json.loads(capsys.readouterr().out) == {"configVersion": "v1"} + + +def test_run_hook_returns_zero_without_context_path(monkeypatch): + monkeypatch.setattr(framework.sys, "argv", ["hook.py"]) + monkeypatch.delenv("BINDING_CONTEXT_PATH", raising=False) + called = [] + + code = run_hook(dict, lambda contexts: called.append(contexts) or 0) + + assert code == 0 + assert called == [] + + +def test_run_hook_returns_zero_on_empty_context(monkeypatch, tmp_path): + monkeypatch.setattr(framework.sys, "argv", ["hook.py"]) + monkeypatch.setenv("BINDING_CONTEXT_PATH", _write_context(tmp_path, " ")) + called = [] + + code = run_hook(dict, lambda contexts: called.append(contexts) or 0) + + assert code == 0 + assert called == [] + + +def test_run_hook_returns_error_on_invalid_json(monkeypatch, tmp_path, caplog): + monkeypatch.setattr(framework.sys, "argv", ["hook.py"]) + monkeypatch.setenv("BINDING_CONTEXT_PATH", _write_context(tmp_path, "{not json")) + + code = run_hook(dict, lambda contexts: 0) + + assert code == 1 + assert "binding context" in caplog.text + + +def test_run_hook_returns_error_when_context_is_not_a_list(monkeypatch, tmp_path): + monkeypatch.setattr(framework.sys, "argv", ["hook.py"]) + monkeypatch.setenv("BINDING_CONTEXT_PATH", _write_context(tmp_path, '{"a": 1}')) + + assert run_hook(dict, lambda contexts: 0) == 1 + + +def test_run_hook_converts_an_unexpected_error_into_exit_one(monkeypatch, tmp_path): + monkeypatch.setattr(framework.sys, "argv", ["hook.py"]) + monkeypatch.setenv("BINDING_CONTEXT_PATH", _write_context(tmp_path, "[{}]")) + + def boom(contexts): + raise ConfigError("bad spec") + + assert run_hook(dict, boom) == 1 diff --git a/python/openstack-sync/tests/test_plugins_common.py b/python/openstack-sync/tests/test_plugins_common.py index 40f069d92..8e4b2368f 100644 --- a/python/openstack-sync/tests/test_plugins_common.py +++ b/python/openstack-sync/tests/test_plugins_common.py @@ -90,17 +90,19 @@ def test_get_value_returns_default_for_missing_or_none_values(): ) -def test_service_profile_ids_requires_list(): - with pytest.raises(TypeError, match="service_profile_ids"): - common.service_profile_ids({"service_profile_ids": "profile-id"}) - - -def test_sdk_exception_classifiers_match_openstacksdk_classes(): - assert common.is_not_found(sdk_exceptions.NotFoundException("missing")) - assert not common.is_not_found(sdk_exceptions.ConflictException("conflict")) - - assert common.is_conflict(sdk_exceptions.ConflictException("conflict")) - assert not common.is_conflict(sdk_exceptions.NotFoundException("missing")) +def test_sdk_not_found_and_conflict_are_independent(): + """reconcile.py and prune.py catch these in separate except clauses. + + If either became a subclass of the other, the first clause would swallow + both and, for example, a 409 "still in use" would be logged as "already + absent" while the resource stayed attached. + """ + assert not issubclass( + sdk_exceptions.ConflictException, sdk_exceptions.NotFoundException + ) + assert not issubclass( + sdk_exceptions.NotFoundException, sdk_exceptions.ConflictException + ) def test_meta_info_payload_canonicalizes_json_strings(): diff --git a/python/openstack-sync/tests/test_prune.py b/python/openstack-sync/tests/test_prune.py new file mode 100644 index 000000000..255c28977 --- /dev/null +++ b/python/openstack-sync/tests/test_prune.py @@ -0,0 +1,228 @@ +"""Tests for router flavor prune behaviour. + +Pruning is gated entirely on the operator's ownership markers, so these tests +are mostly about what must *not* be deleted. Whether pruning runs at all is the +hook's decision (``config.prune``), tested in ``test_framework.py``. +""" + +from __future__ import annotations + +import types +from types import SimpleNamespace +from typing import Any + +from openstack import exceptions as openstack_exceptions + +from openstack_sync.plugins.neutron.router_flavors import markers +from openstack_sync.plugins.neutron.router_flavors import prune +from openstack_sync.plugins.neutron.router_flavors.config import SERVICE_TYPE + +_DRIVER = "neutron_understack.l3_router.vrf.Vrf" + + +class FakeNetwork: + """Minimal Neutron network API recording flavor and profile deletes.""" + + def __init__(self, flavors: list[dict[str, Any]], profiles: dict[str, Any]): + self._flavors = flavors + self._profiles = profiles + self.deleted_flavors: list[str] = [] + self.deleted_profiles: list[str] = [] + self.flavor_list_calls = 0 + + def flavors(self, service_type: str | None = None) -> list[dict[str, Any]]: + self.flavor_list_calls += 1 + return [ + flavor + for flavor in self._flavors + if service_type is None or flavor["service_type"] == service_type + ] + + def routers(self, flavor_id: str) -> list[dict[str, Any]]: + return [] + + def service_profiles(self) -> list[Any]: + return [p for p in self._profiles.values() if p is not None] + + def get_service_profile(self, profile_id: str) -> Any: + profile = self._profiles.get(profile_id) + if profile is None: + raise openstack_exceptions.NotFoundException(f"no profile {profile_id}") + return profile + + def delete_flavor( + self, flavor: dict[str, Any], ignore_missing: bool = True + ) -> None: + self.deleted_flavors.append(flavor["id"]) + self._flavors = [f for f in self._flavors if f["id"] != flavor["id"]] + + def delete_service_profile(self, profile: Any, ignore_missing: bool = True) -> None: + profile_id = profile.id if hasattr(profile, "id") else profile["id"] + self.deleted_profiles.append(profile_id) + self._profiles[profile_id] = None + + +def _owned_profile(profile_id: str, driver: str = _DRIVER) -> Any: + return types.SimpleNamespace( + id=profile_id, + driver=driver, + meta_info=markers.managed_meta_info({"vni_alloc": "auto"}), + ) + + +def _owned_flavor( + flavor_id: str, name: str, service_profile_ids: list[str] | None = None +) -> dict[str, Any]: + return { + "id": flavor_id, + "name": name, + "service_type": SERVICE_TYPE, + "description": markers.managed_flavor_description("created by operator"), + "service_profile_ids": list(service_profile_ids or []), + } + + +def _conn(flavors: list[dict[str, Any]], profiles: dict[str, Any] | None = None) -> Any: + return SimpleNamespace(network=FakeNetwork(flavors, profiles or {})) + + +# --------------------------------------------------------------------------- +# Ownership gates deletion +# --------------------------------------------------------------------------- + + +def test_prune_keeps_unowned_flavor_even_with_owned_profile(): + flavor = { + "id": "manual-flavor-id", + "name": "manual-flavor", + "service_type": SERVICE_TYPE, + "description": "created outside the operator", + "service_profile_ids": ["owned-profile-id"], + } + profile = _owned_profile("owned-profile-id") + conn = _conn([flavor], {profile.id: profile}) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == [] + + +def test_prune_deletes_removed_owned_flavor(): + conn = _conn([_owned_flavor("managed-flavor-id", "removed-managed-flavor")]) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == ["managed-flavor-id"] + + +def test_prune_deletes_removed_flavor_and_its_unused_profile(): + profile = _owned_profile("managed-profile-id") + flavor = _owned_flavor("managed-flavor-id", "removed-managed-flavor", [profile.id]) + conn = _conn([flavor], {profile.id: profile}) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == ["managed-flavor-id"] + assert conn.network.deleted_profiles == ["managed-profile-id"] + + +# --------------------------------------------------------------------------- +# The empty-desired guard +# --------------------------------------------------------------------------- + + +def test_prune_keeps_owned_flavors_when_desired_list_is_empty(): + """An empty desired set may be an unreadable snapshot, not a deletion.""" + conn = _conn([_owned_flavor("managed-flavor-id", "removed-managed-flavor")]) + + prune.prune_removed_flavors(conn, []) + + assert conn.network.deleted_flavors == [] + + +def test_prune_deletes_when_empty_desired_is_authoritative(): + """A confirmed CR deletion makes the empty desired set actionable.""" + conn = _conn([_owned_flavor("managed-flavor-id", "removed-managed-flavor")]) + + prune.prune_removed_flavors(conn, [], authoritative_empty=True) + + assert conn.network.deleted_flavors == ["managed-flavor-id"] + + +# --------------------------------------------------------------------------- +# Orphaned profile sweep +# --------------------------------------------------------------------------- + + +def test_prune_deletes_orphaned_owned_profile(): + """A profile whose parent flavor is already gone is collected.""" + orphan = _owned_profile("orphan-profile-id") + conn = _conn([], {orphan.id: orphan}) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_profiles == ["orphan-profile-id"] + + +def test_prune_keeps_unowned_profile(): + """A profile without the ownership marker is never touched.""" + unowned = types.SimpleNamespace( + id="unmanaged-profile-id", + driver=_DRIVER, + meta_info={"vni_alloc": "auto"}, # no ownership marker + ) + conn = _conn([], {unowned.id: unowned}) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_profiles == [] + + +def test_prune_keeps_attached_profile(): + """A profile still bound to a surviving flavor is kept.""" + attached = _owned_profile("attached-profile-id") + kept = _owned_flavor("kept-flavor-id", "kept-flavor", [attached.id]) + conn = _conn([kept], {attached.id: attached}) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == [] + assert conn.network.deleted_profiles == [] + + +def test_prune_lists_flavors_once_for_all_profile_checks(): + """Attachment counts come from a single flavor listing, not one per profile.""" + removed_profile = _owned_profile("removed-profile-id") + orphan_profile = _owned_profile("orphan-profile-id") + attached_profile = _owned_profile("attached-profile-id") + conn = _conn( + [ + _owned_flavor("removed-flavor-id", "removed-flavor", [removed_profile.id]), + _owned_flavor("kept-flavor-id", "kept-flavor", [attached_profile.id]), + ], + { + removed_profile.id: removed_profile, + orphan_profile.id: orphan_profile, + attached_profile.id: attached_profile, + }, + ) + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.flavor_list_calls == 1 + assert conn.network.deleted_flavors == ["removed-flavor-id"] + assert conn.network.deleted_profiles == [ + "removed-profile-id", + "orphan-profile-id", + ] + + +def test_prune_skips_flavor_still_used_by_routers(): + """A flavor with routers attached is never deleted.""" + flavor = _owned_flavor("in-use-flavor-id", "removed-flavor") + conn = _conn([flavor]) + conn.network.routers = lambda flavor_id: [{"id": "router-1"}] + + prune.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) + + assert conn.network.deleted_flavors == [] diff --git a/python/openstack-sync/tests/test_reconcile.py b/python/openstack-sync/tests/test_reconcile.py new file mode 100644 index 000000000..357681b51 --- /dev/null +++ b/python/openstack-sync/tests/test_reconcile.py @@ -0,0 +1,758 @@ +"""Tests for router flavor reconciliation. + +Covers the profile cache, create-or-reuse by ``(driver, meta_info)``, the +owned-only reuse rule, drift reporting on reused profiles, the flavor +``service_type`` guard and ``is_enabled``/description reconcile, and the +flavor-to-profile binding set. +""" + +from __future__ import annotations + +import logging +import types +from typing import Any +from unittest import mock + +import pytest + +from openstack_sync.plugins import common as plugin_common +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.neutron.router_flavors import markers +from openstack_sync.plugins.neutron.router_flavors import reconcile +from openstack_sync.plugins.neutron.router_flavors.config import SERVICE_TYPE + +_DRIVER = "neutron_understack.l3_router.vrf.Vrf" +_NAME = "test-flavor" +_DESCRIPTION = "my flavor" + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_profile( + profile_id: str, + driver: str = _DRIVER, + meta_info: Any = None, + managed: bool = True, + is_enabled: bool = True, + description: str = "desc", +) -> Any: + """Build an openstacksdk-shaped service profile. + + ``description`` defaults to the ``_profile_spec`` default so a profile and a + spec built with defaults are drift-free; drift tests pass a mismatch. + """ + raw_meta = dict(meta_info or {}) + if managed: + raw_meta.update(markers.OPERATOR_META_INFO_MARKERS) + return types.SimpleNamespace( + id=profile_id, + driver=driver, + is_enabled=is_enabled, + description=description, + meta_info=plugin_common.meta_info_payload(raw_meta), + ) + + +def _profile_spec( + driver: str = _DRIVER, + description: str = "desc", + meta_info: dict[str, Any] | None = None, + is_enabled: bool = True, +) -> dict[str, Any]: + return { + "driver": driver, + "description": description, + "meta_info": meta_info if meta_info is not None else {}, + "is_enabled": is_enabled, + } + + +def _make_flavor( + flavor_id: str = "flavor-id", + name: str = _NAME, + service_profile_ids: list[str] | None = None, + service_type: str = SERVICE_TYPE, + description: str = f"{_DESCRIPTION} {markers.FLAVOR_DESCRIPTION_MARKER}", + is_enabled: bool = True, +) -> Any: + return types.SimpleNamespace( + id=flavor_id, + name=name, + service_type=service_type, + description=description, + is_enabled=is_enabled, + service_profile_ids=list(service_profile_ids or []), + ) + + +def _flavor_spec( + name: str = _NAME, + description: str = _DESCRIPTION, + service_type: str = SERVICE_TYPE, + is_enabled: bool = True, + service_profiles: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + """Build a CR spec as the API server materialises it (defaults applied).""" + return { + "name": name, + "description": description, + "service_type": service_type, + "is_enabled": is_enabled, + "service_profiles": ( + service_profiles if service_profiles is not None else [_profile_spec()] + ), + } + + +def _reuse_conn(profile: Any) -> Any: + """A connection whose only existing service profile is *profile*.""" + network = mock.MagicMock() + network.service_profiles.return_value = [profile] + return types.SimpleNamespace(network=network) + + +def _create_conn(created: Any, existing: list[Any] | None = None) -> Any: + network = mock.MagicMock() + network.service_profiles.return_value = list(existing or []) + network.create_service_profile.return_value = created + return types.SimpleNamespace(network=network) + + +def _bindings_conn(flavor: Any, lookup: dict[str, Any] | None = None) -> Any: + """A connection for binding tests. + + ``get_flavor`` returns *flavor*; ``get_service_profile`` resolves unbind + candidates from *lookup* so ownership can be evaluated. + """ + resolved = lookup or {} + network = mock.MagicMock() + network.get_flavor.return_value = flavor + network.get_service_profile.side_effect = lambda pid: resolved.get(pid) + return types.SimpleNamespace(network=network) + + +# --------------------------------------------------------------------------- +# Profile cache +# --------------------------------------------------------------------------- + + +def test_profiles_for_driver_queries_by_driver(): + network = mock.MagicMock() + network.service_profiles.return_value = [_make_profile("profile-id")] + conn = types.SimpleNamespace(network=network) + + result = reconcile.profiles_for_driver(conn, "some.Driver", {}) + + assert result == list(network.service_profiles.return_value) + network.service_profiles.assert_called_once_with(driver="some.Driver") + + +def test_profiles_for_driver_caches_per_driver(): + first = _make_profile("first-profile", driver="first.Driver") + second = _make_profile("second-profile", driver="second.Driver") + network = mock.MagicMock() + network.service_profiles.side_effect = [[first], [second]] + conn = types.SimpleNamespace(network=network) + cache: reconcile.ProfileCache = {} + + first_result = reconcile.profiles_for_driver(conn, "first.Driver", cache) + cached = reconcile.profiles_for_driver(conn, "first.Driver", cache) + second_result = reconcile.profiles_for_driver(conn, "second.Driver", cache) + + assert first_result == [first] + assert cached is first_result + assert second_result == [second] + assert network.service_profiles.call_args_list == [ + mock.call(driver="first.Driver"), + mock.call(driver="second.Driver"), + ] + + +# --------------------------------------------------------------------------- +# ensure_profile: create or reuse by (driver, meta_info) +# --------------------------------------------------------------------------- + + +def test_ensure_profile_creates_with_ownership_markers(): + conn = _create_conn(_make_profile("new-profile")) + + reconcile.ensure_profile( + conn, _NAME, _profile_spec(meta_info={"vni_alloc": "auto"}), {}, [] + ) + + kwargs = conn.network.create_service_profile.call_args.kwargs + assert kwargs["driver"] == _DRIVER + assert kwargs["is_enabled"] is True + meta_info = plugin_common.normalize_meta_info(kwargs["meta_info"]) + assert meta_info["vni_alloc"] == "auto" + for key, value in markers.OPERATOR_META_INFO_MARKERS.items(): + assert meta_info[key] == value + + +def test_ensure_profile_creates_disabled_when_spec_disables(): + conn = _create_conn(_make_profile("new-profile", is_enabled=False)) + + reconcile.ensure_profile(conn, _NAME, _profile_spec(is_enabled=False), {}, []) + + assert conn.network.create_service_profile.call_args.kwargs["is_enabled"] is False + + +def test_ensure_profile_reuses_existing_owned_profile(): + meta_info = {"vni_alloc": "auto"} + existing = _make_profile("existing-profile", meta_info=meta_info) + conn = _reuse_conn(existing) + + result = reconcile.ensure_profile( + conn, _NAME, _profile_spec(meta_info=meta_info), {}, [] + ) + + assert result is existing + conn.network.create_service_profile.assert_not_called() + + +def test_ensure_profile_appends_created_profile_to_driver_cache(): + """A profile created for one flavor must be visible to the next flavor. + + The cache is shared across all flavors in a credential group, so two + flavors with an identical ``(driver, meta_info)`` spec share one profile + rather than each creating a duplicate. + """ + meta_info = {"vni_alloc": "auto"} + created = _make_profile("new-profile", meta_info=meta_info) + conn = _create_conn(created) + cache: reconcile.ProfileCache = {} + + first = reconcile.ensure_profile( + conn, "flavor-a", _profile_spec(meta_info=meta_info), cache, [] + ) + second = reconcile.ensure_profile( + conn, "flavor-b", _profile_spec(meta_info=meta_info), cache, [] + ) + + assert first is created + assert second is first + conn.network.create_service_profile.assert_called_once() + conn.network.service_profiles.assert_called_once_with(driver=_DRIVER) + + +def test_ensure_profile_does_not_reuse_across_drivers(): + meta_info = {"vni_alloc": "auto"} + first_profile = _make_profile("first-profile", driver="first.Driver") + second_profile = _make_profile("second-profile", driver="second.Driver") + network = mock.MagicMock() + network.service_profiles.side_effect = [[], []] + network.create_service_profile.side_effect = [first_profile, second_profile] + conn = types.SimpleNamespace(network=network) + cache: reconcile.ProfileCache = {} + + first = reconcile.ensure_profile( + conn, + "flavor-a", + _profile_spec(driver="first.Driver", meta_info=meta_info), + cache, + [], + ) + second = reconcile.ensure_profile( + conn, + "flavor-b", + _profile_spec(driver="second.Driver", meta_info=meta_info), + cache, + [], + ) + + assert first is first_profile + assert second is second_profile + assert network.create_service_profile.call_count == 2 + + +# --------------------------------------------------------------------------- +# Only operator-owned profiles are reused +# --------------------------------------------------------------------------- + + +def test_find_matching_profile_ignores_unowned_match(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + + assert reconcile.find_matching_profile([unowned], meta_info) is None + + +def test_find_matching_profile_prefers_owned_over_unowned(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + owned = _make_profile("owned-profile", meta_info=meta_info) + + assert reconcile.find_matching_profile([unowned, owned], meta_info) is owned + + +def test_ensure_profile_creates_owned_profile_instead_of_reusing_unowned(): + """An unowned profile must never be bound, because it can never be unbound. + + ``reconcile_flavor_profiles`` only unbinds profiles carrying the ownership + marker, so reusing somebody else's profile would create a binding that + outlives the spec that created it and that nothing can ever remove. + """ + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + created = _make_profile("new-profile", meta_info=meta_info) + conn = _create_conn(created, existing=[unowned]) + + result = reconcile.ensure_profile( + conn, _NAME, _profile_spec(meta_info=meta_info), {}, [] + ) + + assert result is created + conn.network.create_service_profile.assert_called_once() + new_meta = plugin_common.normalize_meta_info( + conn.network.create_service_profile.call_args.kwargs["meta_info"] + ) + for key, value in markers.OPERATOR_META_INFO_MARKERS.items(): + assert new_meta[key] == value + + +def test_ensure_profile_never_adopts_an_unowned_profile(): + """The unowned profile is left alone, not stamped with the marker. + + Adopting it would enrol somebody else's profile into the prune sweep, which + deletes owned unattached profiles -- an irreversible side effect on a + resource the operator did not create. + """ + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + conn = _create_conn(_make_profile("new-profile"), existing=[unowned]) + + reconcile.ensure_profile(conn, _NAME, _profile_spec(meta_info=meta_info), {}, []) + + conn.network.update_service_profile.assert_not_called() + conn.network.delete_service_profile.assert_not_called() + + +def test_ensure_profile_reuses_owned_when_unowned_match_also_exists(): + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + owned = _make_profile("owned-profile", meta_info=meta_info) + network = mock.MagicMock() + network.service_profiles.return_value = [unowned, owned] + conn = types.SimpleNamespace(network=network) + + result = reconcile.ensure_profile( + conn, _NAME, _profile_spec(meta_info=meta_info), {}, [] + ) + + assert result is owned + network.create_service_profile.assert_not_called() + + +def test_profile_created_beside_unowned_match_can_later_be_unbound(): + """End-to-end guard for why unowned profiles are not reused. + + Resolve a profile while an unowned match exists, then reconcile the flavor + against a different spec. The profile bound earlier must be unbindable, + which holds only because the operator created and owns it. + """ + meta_info = {"vni_alloc": "auto"} + unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) + created = _make_profile("prof-a", meta_info=meta_info) + conn = _create_conn(created, existing=[unowned]) + + bound = reconcile.ensure_profile( + conn, _NAME, _profile_spec(meta_info=meta_info), {}, [] + ) + + flavor = _make_flavor(service_profile_ids=["prof-a"]) + bindings = _bindings_conn(flavor, {"prof-a": bound}) + + reconcile.reconcile_flavor_profiles(bindings, flavor, [_make_profile("prof-b")]) + + disassociate = bindings.network.disassociate_flavor_from_service_profile + disassociate.assert_called_once() + assert disassociate.call_args.args[1] is bound + + +# --------------------------------------------------------------------------- +# Drift reporting on reused profiles +# --------------------------------------------------------------------------- + + +def test_ensure_profile_reports_is_enabled_drift_on_reuse(caplog): + """A profile disabled out-of-band is reported, not silently accepted. + + Neutron's get_flavor_next_provider raises ServiceProfileDisabled for the + profile it selects, so every router create against the flavor fails while + the flavor itself still looks converged. + """ + existing = _make_profile("owned-profile", is_enabled=False) + conn = _reuse_conn(existing) + drift: list[reconcile.ProfileDrift] = [] + + with caplog.at_level(logging.WARNING): + result = reconcile.ensure_profile( + conn, _NAME, _profile_spec(is_enabled=True), {}, drift + ) + + assert result is existing + assert [(d.field, d.have, d.want) for d in drift] == [("is_enabled", False, True)] + assert drift[0].profile_id == "owned-profile" + assert "is_enabled" in caplog.text + # Neutron rejects updates to a profile bound to any flavor: never try one. + conn.network.update_service_profile.assert_not_called() + + +def test_ensure_profile_reports_description_drift_on_reuse(): + existing = _make_profile("owned-profile", description="stale") + conn = _reuse_conn(existing) + drift: list[reconcile.ProfileDrift] = [] + + reconcile.ensure_profile( + conn, _NAME, _profile_spec(description="wanted"), {}, drift + ) + + assert [(d.field, d.have, d.want) for d in drift] == [ + ("description", "stale", "wanted") + ] + + +def test_ensure_profile_reports_every_drifted_field(): + existing = _make_profile("owned-profile", is_enabled=False, description="stale") + conn = _reuse_conn(existing) + drift: list[reconcile.ProfileDrift] = [] + + reconcile.ensure_profile( + conn, _NAME, _profile_spec(description="wanted", is_enabled=True), {}, drift + ) + + assert sorted(d.field for d in drift) == ["description", "is_enabled"] + + +def test_ensure_profile_accumulates_drift_across_profiles(): + """sync_flavor passes one list across every profile in the spec.""" + existing = _make_profile("owned-profile", is_enabled=False) + conn = _reuse_conn(existing) + already = reconcile.ProfileDrift( + profile_id="other", field="is_enabled", have=False, want=True + ) + drift = [already] + + reconcile.ensure_profile(conn, _NAME, _profile_spec(is_enabled=True), {}, drift) + + assert len(drift) == 2 + assert drift[0] is already + + +def test_ensure_profile_reports_no_drift_when_profile_matches_spec(): + conn = _reuse_conn(_make_profile("owned-profile")) + drift: list[reconcile.ProfileDrift] = [] + + reconcile.ensure_profile(conn, _NAME, _profile_spec(), {}, drift) + + assert drift == [] + + +def test_ensure_profile_reports_no_drift_for_freshly_created_profile(): + """A profile the operator just created from the spec cannot have drifted.""" + conn = _create_conn(_make_profile("new-profile", is_enabled=False)) + drift: list[reconcile.ProfileDrift] = [] + + reconcile.ensure_profile(conn, _NAME, _profile_spec(is_enabled=False), {}, drift) + + assert drift == [] + + +def test_profile_drift_describe_names_profile_and_field(): + drift = reconcile.ProfileDrift( + profile_id="prof-a", field="is_enabled", have=False, want=True + ) + + described = drift.describe() + + assert "prof-a" in described + assert "is_enabled" in described + assert "False" in described and "True" in described + + +# --------------------------------------------------------------------------- +# ensure_flavor: service_type is immutable +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_raises_on_service_type_mismatch(): + flavor = _make_flavor(service_type="DIFFERENT_TYPE") + conn = mock.MagicMock() + conn.network.flavors.return_value = [flavor] + + with pytest.raises(ConfigError, match="service_type"): + reconcile.ensure_flavor(conn, _flavor_spec()) + + +def test_ensure_flavor_error_message_contains_both_service_types(): + flavor = _make_flavor(service_type="WRONG") + conn = mock.MagicMock() + conn.network.flavors.return_value = [flavor] + + with pytest.raises(ConfigError) as exc_info: + reconcile.ensure_flavor(conn, _flavor_spec()) + + message = str(exc_info.value) + assert "WRONG" in message + assert SERVICE_TYPE in message + assert _NAME in message + + +# --------------------------------------------------------------------------- +# ensure_flavor: is_enabled and description reconcile +# --------------------------------------------------------------------------- + + +def _existing_flavor_conn(flavor: Any, updated: Any | None = None) -> Any: + conn = mock.MagicMock() + conn.network.flavors.return_value = [flavor] + conn.network.update_flavor.return_value = updated or _make_flavor() + return conn + + +def test_ensure_flavor_reenables_disabled_flavor(caplog): + conn = _existing_flavor_conn(_make_flavor(is_enabled=False)) + + with caplog.at_level(logging.INFO, logger="openstack_sync"): + reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=True)) + + conn.network.update_flavor.assert_called_once() + assert conn.network.update_flavor.call_args.kwargs["is_enabled"] is True + assert "is_enabled drift" in caplog.text + assert "have=False" in caplog.text + assert "want=True" in caplog.text + + +def test_ensure_flavor_disables_when_spec_disables(caplog): + conn = _existing_flavor_conn(_make_flavor(is_enabled=True)) + + with caplog.at_level(logging.INFO, logger="openstack_sync"): + reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=False)) + + conn.network.update_flavor.assert_called_once() + assert conn.network.update_flavor.call_args.kwargs["is_enabled"] is False + assert "have=True" in caplog.text + assert "want=False" in caplog.text + + +def test_ensure_flavor_no_update_when_both_disabled(): + flavor = _make_flavor(is_enabled=False) + conn = _existing_flavor_conn(flavor) + + result = reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=False)) + + conn.network.update_flavor.assert_not_called() + assert result is flavor + + +def test_ensure_flavor_no_update_when_already_correct(): + flavor = _make_flavor(is_enabled=True) + conn = _existing_flavor_conn(flavor) + + result = reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=True)) + + conn.network.update_flavor.assert_not_called() + assert result is flavor + + +def test_ensure_flavor_reenables_even_when_description_matches(): + """is_enabled drift must trigger an update even if the description is current.""" + conn = _existing_flavor_conn(_make_flavor(is_enabled=False)) + + reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=True)) + + conn.network.update_flavor.assert_called_once() + + +def test_ensure_flavor_updates_changed_description(): + conn = _existing_flavor_conn(_make_flavor(description="old description")) + + reconcile.ensure_flavor(conn, _flavor_spec()) + + conn.network.update_flavor.assert_called_once() + + +def test_ensure_flavor_adds_missing_marker(): + conn = _existing_flavor_conn(_make_flavor(description="no marker here")) + + reconcile.ensure_flavor(conn, _flavor_spec()) + + conn.network.update_flavor.assert_called_once() + kwargs = conn.network.update_flavor.call_args.kwargs + assert markers.FLAVOR_DESCRIPTION_MARKER in kwargs["description"] + + +# --------------------------------------------------------------------------- +# ensure_flavor: creates when absent +# --------------------------------------------------------------------------- + + +def test_ensure_flavor_creates_when_not_found(): + conn = mock.MagicMock() + conn.network.flavors.return_value = [] + conn.network.create_flavor.return_value = _make_flavor() + + reconcile.ensure_flavor(conn, _flavor_spec()) + + kwargs = conn.network.create_flavor.call_args.kwargs + assert kwargs["name"] == _NAME + assert kwargs["service_type"] == SERVICE_TYPE + assert kwargs["is_enabled"] is True + assert markers.FLAVOR_DESCRIPTION_MARKER in kwargs["description"] + + +def test_ensure_flavor_creates_disabled_from_spec(): + """A CR that opts out of enabled must create the Neutron flavor disabled.""" + conn = mock.MagicMock() + conn.network.flavors.return_value = [] + conn.network.create_flavor.return_value = _make_flavor(is_enabled=False) + + reconcile.ensure_flavor(conn, _flavor_spec(is_enabled=False)) + + assert conn.network.create_flavor.call_args.kwargs["is_enabled"] is False + + +def test_find_flavor_ignores_partial_name_match(): + """Guards against a future change to substring query semantics.""" + conn = mock.MagicMock() + conn.network.flavors.return_value = [_make_flavor(name="test-flavor-other")] + + assert reconcile.find_flavor(conn, _NAME) is None + + +# --------------------------------------------------------------------------- +# reconcile_flavor_profiles: bind missing, unbind owned extras +# --------------------------------------------------------------------------- + + +def test_reconcile_flavor_profiles_no_op_when_matching(): + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-b"]) + conn = _bindings_conn(flavor) + + result = reconcile.reconcile_flavor_profiles( + conn, flavor, [_make_profile("prof-a"), _make_profile("prof-b")] + ) + + conn.network.associate_flavor_with_service_profile.assert_not_called() + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + assert result is flavor + + +def test_reconcile_flavor_profiles_binds_missing(): + flavor = _make_flavor(service_profile_ids=[]) + conn = _bindings_conn(flavor) + + reconcile.reconcile_flavor_profiles( + conn, flavor, [_make_profile("prof-a"), _make_profile("prof-b")] + ) + + calls = conn.network.associate_flavor_with_service_profile.call_args_list + assert sorted(call.args[1].id for call in calls) == ["prof-a", "prof-b"] + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + + +def test_reconcile_flavor_profiles_unbinds_owned_extra(): + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-extra"]) + extra = _make_profile("prof-extra") + conn = _bindings_conn(flavor, {"prof-extra": extra}) + + reconcile.reconcile_flavor_profiles(conn, flavor, [_make_profile("prof-a")]) + + conn.network.associate_flavor_with_service_profile.assert_not_called() + conn.network.disassociate_flavor_from_service_profile.assert_called_once() + assert ( + conn.network.disassociate_flavor_from_service_profile.call_args.args[1] is extra + ) + + +def test_reconcile_flavor_profiles_keeps_unowned_extra(): + """A profile attached out-of-band must not be unbound.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-adhoc"]) + unowned = _make_profile("prof-adhoc", managed=False) + conn = _bindings_conn(flavor, {"prof-adhoc": unowned}) + + reconcile.reconcile_flavor_profiles(conn, flavor, [_make_profile("prof-a")]) + + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + + +def test_reconcile_flavor_profiles_binds_and_unbinds_together(): + flavor = _make_flavor(service_profile_ids=["prof-old"]) + old = _make_profile("prof-old") + conn = _bindings_conn(flavor, {"prof-old": old}) + + reconcile.reconcile_flavor_profiles(conn, flavor, [_make_profile("prof-new")]) + + conn.network.associate_flavor_with_service_profile.assert_called_once() + assert ( + conn.network.associate_flavor_with_service_profile.call_args.args[1].id + == "prof-new" + ) + conn.network.disassociate_flavor_from_service_profile.assert_called_once() + assert ( + conn.network.disassociate_flavor_from_service_profile.call_args.args[1] is old + ) + + +def test_reconcile_flavor_profiles_skips_deleted_extra(): + """An unbind candidate that no longer exists is a silent no-op.""" + flavor = _make_flavor(service_profile_ids=["prof-a", "prof-gone"]) + conn = _bindings_conn(flavor, {"prof-gone": None}) + + reconcile.reconcile_flavor_profiles(conn, flavor, [_make_profile("prof-a")]) + + conn.network.disassociate_flavor_from_service_profile.assert_not_called() + + +# --------------------------------------------------------------------------- +# sync_flavor +# --------------------------------------------------------------------------- + + +def _sync_conn(flavor: Any, existing_profiles: list[Any] | None = None) -> Any: + conn = mock.MagicMock() + conn.network.flavors.return_value = [flavor] + conn.network.get_flavor.return_value = flavor + conn.network.service_profiles.return_value = list(existing_profiles or []) + conn.network.create_service_profile.return_value = _make_profile("prof-a") + return conn + + +def test_sync_flavor_returns_no_notes_when_nothing_drifted(): + flavor = _make_flavor(service_profile_ids=["prof-a"]) + conn = _sync_conn(flavor) + + assert reconcile.sync_flavor(conn, _flavor_spec(), {}) == [] + + +def test_sync_flavor_reports_profile_drift(): + """Drift found while resolving profiles reaches the caller as notes. + + The flavor itself is converged, so this is not a failure -- but the caller + must be able to qualify the status it reports. + """ + flavor = _make_flavor(service_profile_ids=["owned-profile"]) + drifted_profile = _make_profile("owned-profile", is_enabled=False) + conn = _sync_conn(flavor, existing_profiles=[drifted_profile]) + + notes = reconcile.sync_flavor( + conn, _flavor_spec(service_profiles=[_profile_spec(is_enabled=True)]), {} + ) + + assert len(notes) == 1 + assert "owned-profile" in notes[0] + assert "is_enabled" in notes[0] + + +def test_sync_flavor_passes_is_enabled_from_spec(): + """The value the API server put on the CR reaches the Neutron flavor.""" + flavor = _make_flavor(is_enabled=True, service_profile_ids=["prof-a"]) + conn = _sync_conn(flavor) + conn.network.update_flavor.return_value = flavor + + reconcile.sync_flavor(conn, _flavor_spec(is_enabled=False), {}) + + conn.network.update_flavor.assert_called_once() + assert conn.network.update_flavor.call_args.kwargs["is_enabled"] is False diff --git a/python/openstack-sync/tests/test_router_flavors.py b/python/openstack-sync/tests/test_router_flavors.py deleted file mode 100644 index 761c9d5a7..000000000 --- a/python/openstack-sync/tests/test_router_flavors.py +++ /dev/null @@ -1,349 +0,0 @@ -"""Tests for the Neutron router flavors hook.""" - -from __future__ import annotations - -import json -import logging -from unittest import mock - -import pytest - -import openstack_sync.utils as utils -from openstack_sync.hooks import router_flavors -from openstack_sync.plugins.neutron.router_flavors import ( - router_flavors_common as common, -) - -FAKE_CLOUDS_YAML = """ -clouds: - understack: - auth: - auth_url: https://keystone.example.com/v3 - username: infrasetup - password: secret - project_name: baremetal - region_name: iad3 -""" - - -def _fake_conn(): - return mock.MagicMock(name="fake_conn") - - -def _router_flavor_object( - name: str, - spec: dict | None = None, - status: dict | None = None, -) -> dict: - flavor_spec = { - "name": name, - "driver": "some.Driver", - "cloudCredentialsRef": { - "secretName": "infrasetup", - "cloudName": "understack", - }, - } - flavor_spec.update(spec or {}) - obj = { - "metadata": { - "name": name, - "namespace": "openstack", - "generation": 1, - }, - "spec": flavor_spec, - } - if status is not None: - obj["status"] = status - return obj - - -def _snapshot_context(*objects: dict) -> list[dict]: - return [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [{"object": obj} for obj in objects], - }, - } - ] - - -# --------------------------------------------------------------------------- -# build_hook_config: reads env at call time so monkeypatch works directly -# --------------------------------------------------------------------------- - - -def test_router_flavor_hook_config_disabled(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "false") - - config = router_flavors.build_hook_config() - - assert config["onStartup"] == 10 - assert "kubernetes" not in config - assert "schedule" not in config - - -def test_router_flavor_hook_config_omits_schedule_without_crontab(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.delenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", raising=False) - monkeypatch.delenv("POD_NAMESPACE", raising=False) - - config = router_flavors.build_hook_config() - - assert config["kubernetes"][0]["name"] == common.CRD_BINDING_NAME - assert "schedule" not in config - - -def test_router_flavor_hook_config_omits_schedule_with_empty_crontab(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "") - monkeypatch.delenv("POD_NAMESPACE", raising=False) - - config = router_flavors.build_hook_config() - - assert config["kubernetes"][0]["name"] == common.CRD_BINDING_NAME - assert "schedule" not in config - - -def test_router_flavor_hook_config_uses_pod_namespace(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - config = router_flavors.build_hook_config() - - assert config["kubernetes"][0]["namespace"] == { - "nameSelector": {"matchNames": ["openstack"]} - } - assert config["kubernetes"][0]["queue"] == common.CRD_BINDING_NAME - assert config["schedule"][0]["crontab"] == "0 * * * *" - assert config["schedule"][0]["queue"] == common.CRD_BINDING_NAME - assert "onStartup" not in config - - -def test_router_flavor_hook_config_custom_crontab(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") - monkeypatch.delenv("POD_NAMESPACE", raising=False) - - config = router_flavors.build_hook_config() - - assert config["schedule"][0]["crontab"] == "*/15 * * * *" - - -def test_router_flavor_hook_config_uses_full_object_filter(monkeypatch): - """JqFilter must be '.' so cloudCredentialsRef is available at reconcile time.""" - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.delenv("POD_NAMESPACE", raising=False) - - config = router_flavors.build_hook_config() - - assert config["kubernetes"][0]["jqFilter"] == "." - - -def test_router_flavor_hook_config_printed_on_config_flag(monkeypatch, capsys): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - with mock.patch.object( - router_flavors.sys, "argv", ["router_flavors.py", "--config"] - ): - assert router_flavors.main() == 0 - - config = json.loads(capsys.readouterr().out) - assert config["kubernetes"][0]["namespace"]["nameSelector"]["matchNames"] == [ - "openstack" - ] - assert config["schedule"][0]["crontab"] == "*/15 * * * *" - - -# --------------------------------------------------------------------------- -# binding context parsing -# --------------------------------------------------------------------------- - - -def test_load_router_flavor_hook_inputs_keeps_current_status(): - status = { - "syncStatus": "Synced", - "message": "Successfully reconciled router flavor", - "observedGeneration": 1, - } - contexts = _snapshot_context(_router_flavor_object("flavor-a", status=status)) - - hook_inputs = router_flavors.load_router_flavor_hook_inputs(contexts) - - assert hook_inputs.resources_to_reconcile[0].current_status == status - - -def test_patch_flavor_status_passes_current_status(): - status = {"syncStatus": "Synced", "message": "ok", "observedGeneration": 1} - secret_name = "infrasetup" # noqa: S105 - resource = router_flavors.RouterFlavorResource( - flavor={"name": "flavor-a", "driver": "some.Driver"}, - name="flavor-a", - namespace="openstack", - generation=1, - secret_name=secret_name, - cloud_name="understack", - current_status=status, - ) - - with mock.patch( - "openstack_sync.hooks.router_flavors.patch_resource_status" - ) as mock_patch: - router_flavors.patch_flavor_status(resource, "Synced", "ok") - - assert mock_patch.call_args.kwargs["current_status"] == status - - -# --------------------------------------------------------------------------- -# reconcile_router_flavor_resources: credential resolution and sync delegation -# --------------------------------------------------------------------------- - - -def test_reconcile_uses_cloudcredentialsref(): - """Per-resource cloudCredentialsRef is used to connect to OpenStack.""" - resource = router_flavors.load_router_flavor_hook_inputs( - _snapshot_context( - _router_flavor_object( - "test-flavor", - { - "cloudCredentialsRef": { - "secretName": "baremetal-manage", - "cloudName": "understack", - }, - }, - ) - ) - ).resources_to_reconcile[0] - conn = _fake_conn() - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ) as mock_connect, - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - ): - result = router_flavors.reconcile_router_flavor_resources([resource]) - - assert result == 0 - mock_connect.assert_called_once_with("baremetal-manage", "understack") - mock_sync.assert_called_once_with(conn, resource.flavor, {}) - mock_prune.assert_called_once_with(conn, [resource.flavor]) - - -def test_reconcile_requires_cloudcredentialsref(): - obj = { - "metadata": {"name": "no-ref-flavor"}, - "spec": {"name": "no-ref-flavor", "driver": "some.Driver"}, - } - - with pytest.raises( - router_flavors.ConfigError, - match="cloudCredentialsRef is required", - ): - router_flavors.load_router_flavor_hook_inputs(_snapshot_context(obj)) - - -def test_reconcile_requires_complete_cloudcredentialsref(): - obj = { - "metadata": {"name": "partial-flavor"}, - "spec": { - "name": "partial-flavor", - "driver": "some.Driver", - "cloudCredentialsRef": {"secretName": "custom-secret"}, - }, - } - - with pytest.raises( - router_flavors.ConfigError, - match=r"cloudCredentialsRef\.cloudName", - ): - router_flavors.load_router_flavor_hook_inputs(_snapshot_context(obj)) - - -# --------------------------------------------------------------------------- -# main(): binding context dispatch -# --------------------------------------------------------------------------- - - -def test_main_dispatches_to_reconcile(monkeypatch, tmp_path): - """main() reads BINDING_CONTEXT_PATH and dispatches each object.""" - monkeypatch.setattr(utils, "_connection_cache", {}) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - binding_context = json.dumps(_snapshot_context(_router_flavor_object("flavor-a"))) - - ctx_file = tmp_path / "binding_context.json" - ctx_file.write_text(binding_context) - monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) - - with ( - mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=_fake_conn(), - ), - mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]), - ): - result = router_flavors.main() - - assert result == 0 - mock_sync.assert_called_once() - - -def test_main_returns_error_on_invalid_json(monkeypatch, caplog, tmp_path): - ctx_file = tmp_path / "binding_context.json" - ctx_file.write_text("not-json") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) - - with ( - caplog.at_level(logging.ERROR, logger="openstack_sync.hooks.router_flavors"), - mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]), - ): - result = router_flavors.main() - - assert result == 1 - assert "failed to parse binding context" in caplog.text - - -def test_main_returns_zero_on_empty_context(monkeypatch, tmp_path): - ctx_file = tmp_path / "binding_context.json" - ctx_file.write_text("") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("BINDING_CONTEXT_PATH", str(ctx_file)) - - with mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]): - result = router_flavors.main() - - assert result == 0 - - -def test_main_returns_zero_when_no_context_path(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.delenv("BINDING_CONTEXT_PATH", raising=False) - - with mock.patch.object(router_flavors.sys, "argv", ["router_flavors.py"]): - result = router_flavors.main() - - assert result == 0 diff --git a/python/openstack-sync/tests/test_router_flavors_create.py b/python/openstack-sync/tests/test_router_flavors_create.py deleted file mode 100644 index d9a747493..000000000 --- a/python/openstack-sync/tests/test_router_flavors_create.py +++ /dev/null @@ -1,642 +0,0 @@ -"""Tests for create.py helpers: ensure_profile and reconcile_flavor_profiles.""" - -from __future__ import annotations - -import logging -import types -from typing import Any -from unittest import mock - -from openstack_sync.plugins import common as plugin_common -from openstack_sync.plugins.neutron.router_flavors import create -from openstack_sync.plugins.neutron.router_flavors import ( - router_flavors_common as common, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -def _make_profile( - profile_id: str, - driver: str = "neutron_understack.l3_router.vrf.Vrf", - meta_info: Any = None, - managed: bool = True, - is_enabled: bool = True, - description: str = "desc", -) -> Any: - """Build an openstacksdk-shaped service profile. - - ``description`` defaults to the ``_profile_spec`` default so that a profile - and a spec built with defaults are drift-free; drift tests opt in by passing - a mismatching value. - """ - raw_meta = dict(meta_info or {}) - if managed: - raw_meta.update(common.operator_meta_info_markers()) - return types.SimpleNamespace( - id=profile_id, - driver=driver, - is_enabled=is_enabled, - description=description, - meta_info=plugin_common.meta_info_payload(raw_meta), - ) - - -def _make_flavor( - flavor_id: str = "flavor-id", - name: str = "test-flavor", - service_profile_ids: list[str] | None = None, -) -> Any: - return types.SimpleNamespace( - id=flavor_id, - name=name, - service_profile_ids=list(service_profile_ids or []), - ) - - -def _profile_spec( - driver: str = "neutron_understack.l3_router.vrf.Vrf", - description: str = "desc", - meta_info: dict[str, Any] | None = None, - is_enabled: bool = True, -) -> dict[str, Any]: - return { - "driver": driver, - "description": description, - "meta_info": meta_info if meta_info is not None else {}, - "is_enabled": is_enabled, - } - - -# --------------------------------------------------------------------------- -# service profile query cache -# --------------------------------------------------------------------------- - - -def test_list_service_profiles_queries_by_driver(): - network = mock.MagicMock() - network.service_profiles.return_value = [_make_profile("profile-id")] - conn = types.SimpleNamespace(network=network) - - result = create.list_service_profiles(conn, "some.Driver") - - assert result == list(network.service_profiles.return_value) - network.service_profiles.assert_called_once_with(driver="some.Driver") - - -def test_service_profiles_for_driver_caches_per_driver(): - first_driver = "first.Driver" - second_driver = "second.Driver" - first_profile = _make_profile("first-profile", driver=first_driver) - second_profile = _make_profile("second-profile", driver=second_driver) - network = mock.MagicMock() - network.service_profiles.side_effect = [[first_profile], [second_profile]] - conn = types.SimpleNamespace(network=network) - profile_cache: create.ServiceProfileCache = {} - - first_result = create.service_profiles_for_driver(conn, first_driver, profile_cache) - cached_result = create.service_profiles_for_driver( - conn, first_driver, profile_cache - ) - second_result = create.service_profiles_for_driver( - conn, second_driver, profile_cache - ) - - assert first_result == [first_profile] - assert cached_result is first_result - assert second_result == [second_profile] - assert network.service_profiles.call_args_list == [ - mock.call(driver=first_driver), - mock.call(driver=second_driver), - ] - - -# --------------------------------------------------------------------------- -# ensure_profile: create-or-reuse by (driver, meta_info) -# --------------------------------------------------------------------------- - - -def test_ensure_profile_creates_service_profile_with_management_markers(): - network = mock.MagicMock() - network.service_profiles.return_value = [] - network.create_service_profile.return_value = _make_profile("new-profile") - conn = types.SimpleNamespace(network=network) - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info={"vni_alloc": "auto"}), - profile_cache={}, - ) - - kwargs = conn.network.create_service_profile.call_args.kwargs - assert kwargs["driver"] == "neutron_understack.l3_router.vrf.Vrf" - assert kwargs["is_enabled"] is True - meta_info = plugin_common.normalize_meta_info(kwargs["meta_info"]) - assert meta_info["vni_alloc"] == "auto" - for key, value in common.operator_meta_info_markers().items(): - assert meta_info[key] == value - - -def test_ensure_profile_creates_disabled_profile_when_spec_disables(): - network = mock.MagicMock() - network.service_profiles.return_value = [] - network.create_service_profile.return_value = _make_profile( - "new-profile", is_enabled=False - ) - conn = types.SimpleNamespace(network=network) - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(is_enabled=False), - profile_cache={}, - ) - - assert conn.network.create_service_profile.call_args.kwargs["is_enabled"] is False - - -def test_ensure_profile_reuses_existing_matching_profile(): - """When Neutron already has a managed profile matching (driver, meta_info).""" - meta_info = {"vni_alloc": "auto"} - existing = _make_profile("existing-profile", meta_info=meta_info, managed=True) - network = mock.MagicMock() - network.service_profiles.return_value = [existing] - conn = types.SimpleNamespace(network=network) - - result = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info=meta_info), - profile_cache={}, - ) - - assert result is existing - conn.network.create_service_profile.assert_not_called() - - -def test_ensure_profile_appends_newly_created_profile_to_driver_cache(): - """A profile created for one flavor must be visible to the next flavor. - - profile_cache is caller-owned and shared across all flavors in the same - credential group during one reconcile pass. Two flavors with an identical - ``(driver, meta_info)`` spec must share one profile rather than each - creating a duplicate. - """ - driver = "some.Driver" - meta_info = {"vni_alloc": "auto"} - created_profile = _make_profile("new-profile", driver=driver, meta_info=meta_info) - network = mock.MagicMock() - network.service_profiles.return_value = [] - network.create_service_profile.return_value = created_profile - conn = types.SimpleNamespace(network=network) - profile_cache: create.ServiceProfileCache = {} - - created = create.ensure_profile( - conn, - flavor_name="flavor-a", - profile_spec=_profile_spec(driver=driver, meta_info=meta_info), - profile_cache=profile_cache, - ) - reused = create.ensure_profile( - conn, - flavor_name="flavor-b", - profile_spec=_profile_spec(driver=driver, meta_info=meta_info), - profile_cache=profile_cache, - ) - - assert created is created_profile - assert reused is created - conn.network.create_service_profile.assert_called_once() - conn.network.service_profiles.assert_called_once_with(driver=driver) - - -def test_ensure_profile_does_not_reuse_profiles_across_drivers(): - meta_info = {"vni_alloc": "auto"} - first_driver = "first.Driver" - second_driver = "second.Driver" - first_profile = _make_profile( - "first-profile", driver=first_driver, meta_info=meta_info - ) - second_profile = _make_profile( - "second-profile", driver=second_driver, meta_info=meta_info - ) - network = mock.MagicMock() - network.service_profiles.side_effect = [[], []] - network.create_service_profile.side_effect = [first_profile, second_profile] - conn = types.SimpleNamespace(network=network) - profile_cache: create.ServiceProfileCache = {} - - first_result = create.ensure_profile( - conn, - flavor_name="flavor-a", - profile_spec=_profile_spec(driver=first_driver, meta_info=meta_info), - profile_cache=profile_cache, - ) - second_result = create.ensure_profile( - conn, - flavor_name="flavor-b", - profile_spec=_profile_spec(driver=second_driver, meta_info=meta_info), - profile_cache=profile_cache, - ) - - assert first_result is first_profile - assert second_result is second_profile - assert network.create_service_profile.call_count == 2 - - -# --------------------------------------------------------------------------- -# find_matching_profile / ensure_profile: only operator-owned profiles are reused -# --------------------------------------------------------------------------- - - -def _reuse_conn(profile: Any) -> Any: - """Build a connection whose only existing service profile is *profile*.""" - network = mock.MagicMock() - network.service_profiles.return_value = [profile] - return types.SimpleNamespace(network=network) - - -def test_find_matching_profile_ignores_unowned_match(): - meta_info = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) - - assert create.find_matching_profile([unowned], meta_info) is None - - -def test_find_matching_profile_prefers_owned_over_unowned(): - meta_info = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) - owned = _make_profile("owned-profile", meta_info=meta_info, managed=True) - - assert create.find_matching_profile([unowned, owned], meta_info) is owned - - -def test_ensure_profile_creates_owned_profile_instead_of_reusing_unowned(): - """An unowned profile must never be bound, because it can never be unbound. - - ``reconcile_flavor_profiles`` only unbinds profiles carrying the ownership - marker, so reusing somebody else's profile would create a binding that - outlives the spec that created it and that nothing can ever remove. - """ - meta_info = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) - created = _make_profile("new-profile", meta_info=meta_info, managed=True) - network = mock.MagicMock() - network.service_profiles.return_value = [unowned] - network.create_service_profile.return_value = created - conn = types.SimpleNamespace(network=network) - - result = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info=meta_info), - profile_cache={}, - ) - - assert result is created - network.create_service_profile.assert_called_once() - new_meta = plugin_common.normalize_meta_info( - network.create_service_profile.call_args.kwargs["meta_info"] - ) - for key, value in common.operator_meta_info_markers().items(): - assert new_meta[key] == value - - -def test_ensure_profile_never_adopts_an_unowned_profile(): - """The unowned profile is left alone, not stamped with the ownership marker. - - Adopting it would enrol somebody else's profile into - ``prune_orphaned_service_profiles``, which deletes owned, unattached - profiles -- an irreversible side effect on a resource the operator did not - create. - """ - meta_info = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) - network = mock.MagicMock() - network.service_profiles.return_value = [unowned] - network.create_service_profile.return_value = _make_profile("new-profile") - conn = types.SimpleNamespace(network=network) - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info=meta_info), - profile_cache={}, - ) - - network.update_service_profile.assert_not_called() - network.delete_service_profile.assert_not_called() - - -def test_ensure_profile_reuses_owned_profile_when_unowned_match_also_exists(): - meta_info = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_info, managed=False) - owned = _make_profile("owned-profile", meta_info=meta_info, managed=True) - network = mock.MagicMock() - network.service_profiles.return_value = [unowned, owned] - conn = types.SimpleNamespace(network=network) - - result = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info=meta_info), - profile_cache={}, - ) - - assert result is owned - network.create_service_profile.assert_not_called() - - -def test_profile_created_beside_unowned_match_can_later_be_unbound(): - """End-to-end guard for why unowned profiles are not reused. - - Resolve a profile for spec A while an unowned match exists, then reconcile - the flavor against spec B. The profile bound for spec A must be unbindable, - which holds only because the operator created and owns it. - """ - meta_a = {"vni_alloc": "auto"} - unowned = _make_profile("adhoc-profile", meta_info=meta_a, managed=False) - created_for_a = _make_profile("prof-a", meta_info=meta_a, managed=True) - network = mock.MagicMock() - network.service_profiles.return_value = [unowned] - network.create_service_profile.return_value = created_for_a - conn = types.SimpleNamespace(network=network) - - profile_for_a = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(meta_info=meta_a), - profile_cache={}, - ) - - # The spec moves on to a different profile; the flavor still carries prof-a. - flavor = _make_flavor(service_profile_ids=["prof-a"]) - reconcile_conn = _reconcile_conn(flavor, {"prof-a": profile_for_a}) - - create.reconcile_flavor_profiles( - reconcile_conn, flavor, [_make_profile("prof-b", managed=True)] - ) - - disassociate = reconcile_conn.network.disassociate_flavor_from_service_profile - disassociate.assert_called_once() - assert disassociate.call_args.args[1] is profile_for_a - - -# --------------------------------------------------------------------------- -# ensure_profile: drift reporting for reused profiles -# --------------------------------------------------------------------------- - - -def test_ensure_profile_reports_is_enabled_drift_on_reuse(caplog): - """A profile disabled out-of-band is reported instead of silently accepted. - - Neutron's ``get_flavor_next_provider`` raises ``ServiceProfileDisabled`` - when the profile it selects is disabled, so every router create against the - flavor fails while the flavor itself still looks converged. - """ - existing = _make_profile("owned-profile", managed=True, is_enabled=False) - conn = _reuse_conn(existing) - drift: list[common.ProfileDrift] = [] - - with caplog.at_level(logging.WARNING): - result = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(is_enabled=True), - profile_cache={}, - drift=drift, - ) - - assert result is existing - assert [(item.field, item.have, item.want) for item in drift] == [ - ("is_enabled", False, True) - ] - assert drift[0].profile_id == "owned-profile" - assert "is_enabled" in caplog.text - # Neutron rejects updates to a profile bound to any flavor: never try one. - conn.network.update_service_profile.assert_not_called() - - -def test_ensure_profile_reports_description_drift_on_reuse(): - existing = _make_profile("owned-profile", managed=True, description="stale") - conn = _reuse_conn(existing) - drift: list[common.ProfileDrift] = [] - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(description="wanted"), - profile_cache={}, - drift=drift, - ) - - assert [(item.field, item.have, item.want) for item in drift] == [ - ("description", "stale", "wanted") - ] - - -def test_ensure_profile_reports_every_drifted_field(): - existing = _make_profile( - "owned-profile", managed=True, is_enabled=False, description="stale" - ) - conn = _reuse_conn(existing) - drift: list[common.ProfileDrift] = [] - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(description="wanted", is_enabled=True), - profile_cache={}, - drift=drift, - ) - - assert sorted(item.field for item in drift) == ["description", "is_enabled"] - - -def test_ensure_profile_appends_to_existing_drift_collection(): - """sync_flavor passes one list across every profile in the spec.""" - existing = _make_profile("owned-profile", managed=True, is_enabled=False) - conn = _reuse_conn(existing) - already_found = common.ProfileDrift( - profile_id="other-profile", - driver="other.Driver", - field="is_enabled", - have=False, - want=True, - ) - drift = [already_found] - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(is_enabled=True), - profile_cache={}, - drift=drift, - ) - - assert len(drift) == 2 - assert drift[0] is already_found - - -def test_ensure_profile_reports_no_drift_when_profile_matches_spec(): - existing = _make_profile("owned-profile", managed=True) - conn = _reuse_conn(existing) - drift: list[common.ProfileDrift] = [] - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(), - profile_cache={}, - drift=drift, - ) - - assert drift == [] - - -def test_ensure_profile_reports_no_drift_for_freshly_created_profile(): - """A profile the operator just created from the spec cannot have drifted.""" - network = mock.MagicMock() - network.service_profiles.return_value = [] - network.create_service_profile.return_value = _make_profile( - "new-profile", is_enabled=False - ) - conn = types.SimpleNamespace(network=network) - drift: list[common.ProfileDrift] = [] - - create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(is_enabled=False), - profile_cache={}, - drift=drift, - ) - - assert drift == [] - - -def test_ensure_profile_drift_collection_is_optional(): - """Callers that do not track drift keep working unchanged.""" - existing = _make_profile("owned-profile", managed=True, is_enabled=False) - conn = _reuse_conn(existing) - - result = create.ensure_profile( - conn, - flavor_name="test-flavor", - profile_spec=_profile_spec(is_enabled=True), - profile_cache={}, - ) - - assert result is existing - - -# --------------------------------------------------------------------------- -# reconcile_flavor_profiles: set-based associate + disassociate-if-managed -# --------------------------------------------------------------------------- - - -def _reconcile_conn( - flavor: Any, disassociate_profile_lookup: dict[str, Any] | None = None -) -> Any: - """Build a connection mock whose network exposes these behaviors. - - * ``get_flavor`` returns *flavor* on every call - * ``associate_flavor_with_service_profile`` succeeds silently - * ``disassociate_flavor_from_service_profile`` succeeds silently - * ``get_service_profile`` returns matching profile from - *disassociate_profile_lookup* so ``is_managed_service_profile`` can be - evaluated on candidates for removal. - """ - lookup = disassociate_profile_lookup or {} - network = mock.MagicMock() - network.get_flavor.return_value = flavor - network.get_service_profile.side_effect = lambda pid: lookup.get(pid) - return types.SimpleNamespace(network=network) - - -def test_reconcile_flavor_profiles_no_op_when_matches(): - """Current == desired → no associate/disassociate calls.""" - flavor = _make_flavor(service_profile_ids=["prof-a", "prof-b"]) - desired = [ - _make_profile("prof-a"), - _make_profile("prof-b"), - ] - conn = _reconcile_conn(flavor) - - result = create.reconcile_flavor_profiles(conn, flavor, desired) - - conn.network.associate_flavor_with_service_profile.assert_not_called() - conn.network.disassociate_flavor_from_service_profile.assert_not_called() - assert result is flavor - - -def test_reconcile_flavor_profiles_associates_missing(): - flavor = _make_flavor(service_profile_ids=[]) - desired = [_make_profile("prof-a"), _make_profile("prof-b")] - conn = _reconcile_conn(flavor) - - create.reconcile_flavor_profiles(conn, flavor, desired) - - associate_calls = conn.network.associate_flavor_with_service_profile.call_args_list - associated_ids = sorted(call.args[1].id for call in associate_calls) - assert associated_ids == ["prof-a", "prof-b"] - conn.network.disassociate_flavor_from_service_profile.assert_not_called() - - -def test_reconcile_flavor_profiles_disassociates_managed_extra(): - """A managed profile currently on the flavor but not desired must be unbound.""" - flavor = _make_flavor(service_profile_ids=["prof-a", "prof-extra"]) - desired = [_make_profile("prof-a")] - extra_profile = _make_profile("prof-extra", managed=True) - conn = _reconcile_conn(flavor, {"prof-extra": extra_profile}) - - create.reconcile_flavor_profiles(conn, flavor, desired) - - conn.network.associate_flavor_with_service_profile.assert_not_called() - conn.network.disassociate_flavor_from_service_profile.assert_called_once() - call = conn.network.disassociate_flavor_from_service_profile.call_args - assert call.args[1] is extra_profile - - -def test_reconcile_flavor_profiles_keeps_unmanaged_extra(): - """An unmanaged profile attached out-of-band must not be disassociated.""" - flavor = _make_flavor(service_profile_ids=["prof-a", "prof-adhoc"]) - desired = [_make_profile("prof-a")] - unmanaged = _make_profile("prof-adhoc", managed=False) - conn = _reconcile_conn(flavor, {"prof-adhoc": unmanaged}) - - create.reconcile_flavor_profiles(conn, flavor, desired) - - conn.network.disassociate_flavor_from_service_profile.assert_not_called() - - -def test_reconcile_flavor_profiles_handles_add_and_remove_together(): - """Simultaneous associate + disassociate in one reconcile pass.""" - flavor = _make_flavor(service_profile_ids=["prof-old"]) - desired = [_make_profile("prof-new")] - old_profile = _make_profile("prof-old", managed=True) - conn = _reconcile_conn(flavor, {"prof-old": old_profile}) - - create.reconcile_flavor_profiles(conn, flavor, desired) - - conn.network.associate_flavor_with_service_profile.assert_called_once() - associate_call = conn.network.associate_flavor_with_service_profile.call_args - assert associate_call.args[1].id == "prof-new" - conn.network.disassociate_flavor_from_service_profile.assert_called_once() - disassociate_call = conn.network.disassociate_flavor_from_service_profile.call_args - assert disassociate_call.args[1] is old_profile - - -def test_reconcile_flavor_profiles_skips_deleted_extra_profile(): - """A candidate for disassociation that no longer exists is a silent no-op.""" - flavor = _make_flavor(service_profile_ids=["prof-a", "prof-gone"]) - desired = [_make_profile("prof-a")] - # Neutron says prof-gone doesn't exist anymore. - conn = _reconcile_conn(flavor, {"prof-gone": None}) - - create.reconcile_flavor_profiles(conn, flavor, desired) - - conn.network.disassociate_flavor_from_service_profile.assert_not_called() diff --git a/python/openstack-sync/tests/test_router_flavors_hook.py b/python/openstack-sync/tests/test_router_flavors_hook.py index d17125ce0..4705ce19b 100644 --- a/python/openstack-sync/tests/test_router_flavors_hook.py +++ b/python/openstack-sync/tests/test_router_flavors_hook.py @@ -1,29 +1,38 @@ -"""Integration-style tests for the Neutron router flavor hook run loop.""" +"""Tests for the router flavor hook: how the plugin wires into the framework. + +The generic driver is covered in ``test_framework.py``; these tests cover only +what is specific to this plugin, plus one end-to-end run through ``main()``. +""" from __future__ import annotations import importlib import json +import types from pathlib import Path +from typing import Any from unittest import mock import pytest import openstack_sync.utils as utils from openstack_sync.hooks import router_flavors as hook -from openstack_sync.plugins.neutron.router_flavors import ( - router_flavors_common as common, -) - -ROUTER_ENV_NAMES = ( +from openstack_sync.plugins.neutron.router_flavors import markers +from openstack_sync.plugins.neutron.router_flavors.config import BINDING_NAME +from openstack_sync.plugins.neutron.router_flavors.config import ENV_PREFIX +from openstack_sync.plugins.neutron.router_flavors.config import SERVICE_TYPE +from tests.conftest import CRD_API_VERSION +from tests.conftest import CRD_KIND +from tests.conftest import make_hook_config + +ENV_NAMES = ( "BINDING_CONTEXT_PATH", - "NEUTRON_ROUTER_FLAVOR_ENABLED", - "NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", - "NEUTRON_ROUTER_FLAVOR_CRD_BINDING_NAME", - "NEUTRON_ROUTER_FLAVOR_PRUNE", - "NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", - "NEUTRON_ROUTER_FLAVOR_READY_RETRIES", - "NEUTRON_ROUTER_FLAVOR_READY_DELAY", + f"{ENV_PREFIX}_ENABLED", + f"{ENV_PREFIX}_SYNC_CRONTAB", + f"{ENV_PREFIX}_PRUNE", + f"{ENV_PREFIX}_STATUS_ENABLED", + f"{ENV_PREFIX}_READY_RETRIES", + f"{ENV_PREFIX}_READY_DELAY", "POD_NAMESPACE", ) @@ -40,24 +49,24 @@ def clear_env(monkeypatch: pytest.MonkeyPatch) -> None: - for name in ROUTER_ENV_NAMES: + for name in ENV_NAMES: monkeypatch.delenv(name, raising=False) -def write_binding_context(path: Path, contexts: list[dict]) -> str: - context_path = path / "binding-context.json" - context_path.write_text(json.dumps(contexts), encoding="utf-8") - return str(context_path) - - def router_flavor_object(name: str, spec: dict | None = None) -> dict: - flavor_spec = { + flavor_spec: dict[str, Any] = { "name": name, - "service_type": "L3_ROUTER_NAT", + "service_type": SERVICE_TYPE, "description": f"{name} description", - "driver": "neutron_understack.l3_router.vrf.Vrf", - "profile_description": f"{name} profile", - "meta_info": {"vni_alloc": "auto"}, + "is_enabled": True, + "service_profiles": [ + { + "driver": "neutron_understack.l3_router.vrf.Vrf", + "description": f"{name} profile", + "meta_info": {"vni_alloc": "auto"}, + "is_enabled": True, + } + ], "cloudCredentialsRef": { "secretName": "infrasetup", "cloudName": "understack", @@ -65,1001 +74,304 @@ def router_flavor_object(name: str, spec: dict | None = None) -> dict: } flavor_spec.update(spec or {}) return { - "apiVersion": "neutron.understack.rackspace.net/v1alpha1", - "kind": "NeutronRouterFlavor", - "metadata": { - "name": name, - "namespace": "openstack", - "generation": 3, - }, + "apiVersion": CRD_API_VERSION, + "kind": CRD_KIND, + "metadata": {"name": name, "namespace": "openstack", "generation": 3}, "spec": flavor_spec, } -# --------------------------------------------------------------------------- -# hook config shape -# --------------------------------------------------------------------------- - - -def test_disabled_hook_config_is_valid_noop(monkeypatch, capsys): - clear_env(monkeypatch) - - config = hook.build_hook_config() - - assert config["onStartup"] == 10 - assert "kubernetes" not in config - assert "schedule" not in config - - with mock.patch.object(hook.sys, "argv", ["router_flavors.py", "--config"]): - assert hook.main() == 0 - - assert json.loads(capsys.readouterr().out) == config - - -def test_common_import_is_safe_with_bad_runtime_env(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "maybe") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", "maybe") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "soon") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "later") - - importlib.reload(common) - - -def test_disabled_hook_config_does_not_parse_runtime_env(monkeypatch): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "maybe") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_STATUS_ENABLED", "maybe") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_RETRIES", "soon") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_READY_DELAY", "later") +def write_binding_context(path: Path, contexts: list[dict]) -> str: + context_path = path / "binding-context.json" + context_path.write_text(json.dumps(contexts), encoding="utf-8") + return str(context_path) - config = hook.build_hook_config() - assert config["onStartup"] == 10 - assert "kubernetes" not in config +# --------------------------------------------------------------------------- +# Import safety +# --------------------------------------------------------------------------- -def test_crontab_does_not_enable_disabled_hook(monkeypatch): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "false") +def test_module_import_is_safe_with_bad_runtime_env(monkeypatch): + """Importing must not read runtime config. - config = hook.build_hook_config() + Shell-operator imports the hook to ask for its config before the full + environment is guaranteed, so a malformed value must not break import. + """ + monkeypatch.setenv(f"{ENV_PREFIX}_READY_RETRIES", "not-a-number") + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "not-a-bool") - assert config["onStartup"] == 10 - assert "kubernetes" not in config - assert "schedule" not in config + importlib.reload(hook) -def test_enabled_hook_config_omits_schedule_without_crontab(monkeypatch): +def test_config_flag_prints_json(monkeypatch, capsys): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - - config = hook.build_hook_config() + monkeypatch.setattr(hook.sys, "argv", ["router_flavors.py", "--config"]) - assert config["kubernetes"][0]["name"] == common.CRD_BINDING_NAME - assert "schedule" not in config + assert hook.main() == 0 + assert json.loads(capsys.readouterr().out)["onStartup"] == 10 -def test_enabled_hook_config_watches_router_flavors(monkeypatch): +def test_enabled_config_flag_watches_this_crd(monkeypatch, capsys): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "*/15 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setattr(hook.sys, "argv", ["router_flavors.py", "--config"]) - config = hook.build_hook_config() - - binding = config["kubernetes"][0] - assert "onStartup" not in config - assert binding["name"] == common.CRD_BINDING_NAME - assert binding["apiVersion"] == common.crd_api_version() - assert binding["kind"] == common.crd_kind() - assert binding["executeHookOnEvent"] == ["Added", "Modified", "Deleted"] - assert binding["jqFilter"] == "." - assert binding["includeSnapshotsFrom"] == [common.CRD_BINDING_NAME] - assert binding["namespace"]["nameSelector"]["matchNames"] == ["openstack"] - assert binding["queue"] == common.CRD_BINDING_NAME - assert config["schedule"] == [ - { - "name": "hourly sync", - "crontab": "*/15 * * * *", - "includeSnapshotsFrom": [common.CRD_BINDING_NAME], - "queue": common.CRD_BINDING_NAME, - } - ] + assert hook.main() == 0 + config = json.loads(capsys.readouterr().out) + (binding,) = config["kubernetes"] + assert binding["name"] == BINDING_NAME + assert binding["kind"] == CRD_KIND # --------------------------------------------------------------------------- -# load_router_flavor_hook_inputs: binding context parsing +# Plugin wiring # --------------------------------------------------------------------------- -def test_load_router_flavors_from_snapshot(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") +def test_plugin_reconcile_delegates_to_sync_flavor(): + plugin = hook.RouterFlavorPlugin(make_hook_config()) + conn = mock.MagicMock() + cache: dict[str, Any] = {} + spec = {"name": "flavor-a"} - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - { - "object": router_flavor_object( - "dynamic-vrf", - {"name": "dynamic_vrf"}, - ), - }, - ], - }, - }, - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - hook_inputs = hook.load_router_flavor_hook_inputs() - resources = hook_inputs.resources_to_reconcile - - assert len(resources) == 1 - assert resources[0].name == "dynamic-vrf" - assert resources[0].namespace == "openstack" - assert resources[0].generation == 3 - assert resources[0].flavor["name"] == "dynamic_vrf" - assert resources[0].flavor["driver"] == "neutron_understack.l3_router.vrf.Vrf" - # cloudCredentialsRef is popped into secret_name / cloud_name - assert resources[0].secret_name == "infrasetup" # noqa: S105 - assert resources[0].cloud_name == "understack" - assert "cloudCredentialsRef" not in resources[0].flavor - # Schedule contexts fall through to snapshot parsing, so desired equals - # resources_to_reconcile. - assert hook_inputs.desired_resources_for_prune == resources - assert hook_inputs.deleted_resources == [] + with mock.patch.object( + hook.reconcile_module, "sync_flavor", return_value=["a note"] + ) as sync_flavor: + notes = plugin.reconcile(conn, spec, cache) + assert notes == ["a note"] + sync_flavor.assert_called_once_with(conn, spec, cache) -# --------------------------------------------------------------------------- -# main() dispatches per-object reconciliation -# --------------------------------------------------------------------------- - -def test_main_reconciles_binding_context_objects(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setattr(utils, "_connection_cache", {}) - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": router_flavor_object("pa1410")}, - ] - }, - } - ], +def test_plugin_wait_for_api_uses_configured_retry_budget(): + plugin = hook.RouterFlavorPlugin( + make_hook_config(ready_retries=5, ready_delay=0.25) ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + conn = mock.MagicMock() - synced = [] + with mock.patch.object(hook, "wait_for_openstack_network") as wait: + plugin.wait_for_api(conn) - with ( - mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=mock.MagicMock(), - ), - mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", - side_effect=lambda conn, flavor, profiles: synced.append(flavor["name"]), - ), - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() + wait.assert_called_once_with(conn, retries=5, delay=0.25) - assert result == 0 - assert synced == ["pa1410"] +def test_plugin_prune_is_a_noop_when_disabled(): + plugin = hook.RouterFlavorPlugin(make_hook_config(prune=False)) -def _drift_context(monkeypatch, tmp_path) -> None: - """Set up a single-flavor schedule binding context for status assertions.""" - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setattr(utils, "_connection_cache", {}) + with mock.patch.object(hook.prune_module, "prune_removed_flavors") as prune: + plugin.prune(mock.MagicMock(), [{"name": "a"}], authoritative_empty=False) - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": router_flavor_object("pa1410")}, - ] - }, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) + prune.assert_not_called() -def test_main_reports_plain_success_when_no_profile_drift(monkeypatch, tmp_path): - """The drift-free status message must stay exactly as it was.""" - _drift_context(monkeypatch, tmp_path) +def test_plugin_prune_forwards_authoritative_empty_when_enabled(): + plugin = hook.RouterFlavorPlugin(make_hook_config(prune=True)) + conn = mock.MagicMock() + specs = [{"name": "a"}] - with ( - mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=mock.MagicMock(), - ), - mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch( - "openstack_sync.hooks.router_flavors.patch_flavor_status" - ) as mock_status, - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor", return_value=[]), - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() + with mock.patch.object(hook.prune_module, "prune_removed_flavors") as prune: + plugin.prune(conn, specs, authoritative_empty=True) - assert result == 0 - assert mock_status.call_args.args[1] == "Synced" - assert mock_status.call_args.args[2] == "Successfully reconciled router flavor" + prune.assert_called_once_with(conn, specs, authoritative_empty=True) -def test_main_reports_service_profile_drift_in_synced_status(monkeypatch, tmp_path): - """Drift must reach the CR status. +def test_plugin_cache_is_per_credential_group(): + plugin = hook.RouterFlavorPlugin(make_hook_config()) - The flavor is converged, so the status stays Synced -- but reporting a bare - success is how a disabled service profile stays invisible until every router - create against the flavor fails. - """ - _drift_context(monkeypatch, tmp_path) - drift = [ - common.ProfileDrift( - profile_id="prof-a", - driver="neutron_understack.l3_router.vrf.Vrf", - field="is_enabled", - have=False, - want=True, - ) - ] + assert plugin.new_cache() == {} + assert plugin.new_cache() is not plugin.new_cache() - with ( - mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=mock.MagicMock(), - ), - mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch( - "openstack_sync.hooks.router_flavors.patch_flavor_status" - ) as mock_status, - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=drift - ), - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - # Drift is not a reconcile failure: the flavor still converged. - assert result == 0 - assert mock_status.call_args.args[1] == "Synced" - message = mock_status.call_args.args[2] - assert message.startswith("Successfully reconciled router flavor") - assert "prof-a" in message - assert "is_enabled" in message +# --------------------------------------------------------------------------- +# End to end through main() +# --------------------------------------------------------------------------- -def test_main_returns_error_when_reconcile_fails(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_SYNC_CRONTAB", "0 * * * *") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setattr(utils, "_connection_cache", {}) - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": router_flavor_object("bad-flavor")}, - ] - }, - } - ], +def _neutron_conn() -> Any: + """A Neutron connection that already holds the desired flavor and profile.""" + profile = types.SimpleNamespace( + id="profile-id", + driver="neutron_understack.l3_router.vrf.Vrf", + is_enabled=True, + description="pa1410 profile", + meta_info=markers.managed_meta_info({"vni_alloc": "auto"}), ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( - mock.patch( - "openstack_sync.utils.openstack.connection.Connection", - return_value=mock.MagicMock(), - ), - mock.patch.object(utils, "read_secret_key", return_value=FAKE_CLOUDS_YAML), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", - side_effect=RuntimeError("bad flavor config"), - ), - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 1 - - -def test_main_prunes_after_successful_full_set_reconcile(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - conn = mock.MagicMock() - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": router_flavor_object("pa1410")}, - {"object": router_flavor_object("dynamic-vrf")}, - ] - }, - } - ], + flavor = types.SimpleNamespace( + id="flavor-id", + name="pa1410", + service_type=SERVICE_TYPE, + description=markers.managed_flavor_description("pa1410 description"), + is_enabled=True, + service_profile_ids=["profile-id"], ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 0 - assert [call.args[1]["name"] for call in mock_sync.call_args_list] == [ - "dynamic-vrf", - "pa1410", - ] - mock_prune.assert_called_once() - assert mock_prune.call_args.args[0] is conn - assert [flavor["name"] for flavor in mock_prune.call_args.args[1]] == [ - "dynamic-vrf", - "pa1410", - ] - - -def test_main_prunes_deleted_only_credentials(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") conn = mock.MagicMock() + conn.network.service_profiles.return_value = [profile] + conn.network.flavors.return_value = [flavor] + conn.network.get_flavor.return_value = flavor + return conn - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": router_flavor_object("pa1410"), - "snapshots": {common.CRD_BINDING_NAME: []}, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ) as mock_connect, - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 0 - mock_connect.assert_called_once_with("infrasetup", "understack") - mock_sync.assert_not_called() - mock_prune.assert_called_once_with(conn, [], authoritative_empty_desired=True) - - -def test_main_returns_error_when_deleted_only_connection_fails(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": router_flavor_object("pa1410"), - "snapshots": {common.CRD_BINDING_NAME: []}, - } - ], +def _run_main(monkeypatch, tmp_path, contexts: list[dict], conn: Any): + monkeypatch.setenv( + "BINDING_CONTEXT_PATH", write_binding_context(tmp_path, contexts) ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - side_effect=RuntimeError("secret missing"), - ) as mock_connect, - mock.patch( - "openstack_sync.hooks.router_flavors.wait_for_openstack_network" - ) as mock_wait, - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 1 - mock_connect.assert_called_once_with("infrasetup", "understack") - mock_wait.assert_not_called() - mock_sync.assert_not_called() - mock_prune.assert_not_called() - - -def test_main_returns_error_when_deleted_only_prune_fails(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") - conn = mock.MagicMock() - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": router_flavor_object("pa1410"), - "snapshots": {common.CRD_BINDING_NAME: []}, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", + "openstack_sync.hooks.framework.get_openstack_connection", return_value=conn, - ) as mock_connect, - mock.patch( - "openstack_sync.hooks.router_flavors.wait_for_openstack_network" - ) as mock_wait, - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors", - side_effect=RuntimeError("delete failed"), - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 1 - mock_connect.assert_called_once_with("infrasetup", "understack") - mock_wait.assert_called_once_with(conn) - mock_sync.assert_not_called() - mock_prune.assert_called_once_with(conn, [], authoritative_empty_desired=True) - - -def test_main_ignores_deleted_only_credentials_when_prune_is_disabled( - monkeypatch, tmp_path -): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "false") - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": router_flavor_object("pa1410"), - "snapshots": {common.CRD_BINDING_NAME: []}, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection" - ) as mock_connect, - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, + ), mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + "openstack_sync.hooks.framework.patch_resource_status" + ) as patch_status, + mock.patch.object(hook, "wait_for_openstack_network"), ): - result = hook.main() - - assert result == 0 - mock_connect.assert_not_called() - mock_sync.assert_not_called() - mock_prune.assert_not_called() - + code = hook.main() + return code, patch_status -def test_main_prunes_active_and_deleted_only_credentials(monkeypatch, tmp_path): - clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") - active_conn = mock.MagicMock(name="active_conn") - deleted_conn = mock.MagicMock(name="deleted_conn") - active_object = router_flavor_object("pa1410") - deleted_object = router_flavor_object( - "other-cloud-flavor", +def _schedule_context(*names: str) -> list[dict]: + return [ { - "cloudCredentialsRef": { - "secretName": "other-secret", - "cloudName": "other-cloud", - } - }, - ) - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": deleted_object, - "snapshots": { - common.CRD_BINDING_NAME: [{"object": active_object}], - }, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - def connect(secret_name, cloud_name): - if (secret_name, cloud_name) == ("infrasetup", "understack"): - return active_conn - if (secret_name, cloud_name) == ("other-secret", "other-cloud"): - return deleted_conn - raise AssertionError((secret_name, cloud_name)) - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - side_effect=connect, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch("openstack_sync.hooks.router_flavors.sync_flavor", return_value=[]), - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 0 - assert mock_prune.call_args_list == [ - mock.call(active_conn, [mock.ANY]), - mock.call(deleted_conn, [], authoritative_empty_desired=True), + "binding": BINDING_NAME, + "type": "Schedule", + "snapshots": { + BINDING_NAME: [{"object": router_flavor_object(n)} for n in names] + }, + } ] - assert mock_prune.call_args_list[0].args[1][0]["name"] == "pa1410" -def test_main_skips_empty_snapshot_prune_without_credentials(monkeypatch, tmp_path): +def test_main_returns_zero_when_hook_disabled(monkeypatch, tmp_path): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") + conn = _neutron_conn() - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": {common.CRD_BINDING_NAME: []}, - } - ], + code, patch_status = _run_main( + monkeypatch, tmp_path, _schedule_context("pa1410"), conn ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection" - ) as mock_connect, - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 0 - mock_connect.assert_not_called() - mock_sync.assert_not_called() - mock_prune.assert_not_called() + assert code == 0 + patch_status.assert_not_called() + conn.network.flavors.assert_not_called() -def test_main_continues_after_failure_and_skips_prune(monkeypatch, tmp_path): +def test_main_reconciles_an_already_converged_flavor(monkeypatch, tmp_path): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") monkeypatch.setenv("POD_NAMESPACE", "openstack") - conn = mock.MagicMock() + conn = _neutron_conn() - context_path = write_binding_context( - tmp_path, - [ - { - "binding": "hourly sync", - "type": "Schedule", - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": router_flavor_object("bad-flavor")}, - {"object": router_flavor_object("good-flavor")}, - ] - }, - } - ], + code, patch_status = _run_main( + monkeypatch, tmp_path, _schedule_context("pa1410"), conn ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - seen = [] - - def sync_flavor(conn, flavor, profiles): - seen.append(flavor["name"]) - if flavor["name"] == "bad-flavor": - raise RuntimeError("bad flavor config") - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch( - "openstack_sync.hooks.router_flavors.patch_flavor_status" - ) as mock_status, - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", - side_effect=sync_flavor, - ), - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 1 - assert seen == ["bad-flavor", "good-flavor"] - assert [call.args[1] for call in mock_status.call_args_list] == [ - "Failed", - "Synced", - ] - mock_prune.assert_not_called() - - -# --------------------------------------------------------------------------- -# Event-driven scenarios: reconcile only the changed CR while prune uses -# the full snapshot delivered by shell-operator. -# --------------------------------------------------------------------------- - - -def router_flavor_object_with_status( - name: str, - *, - generation: int = 3, - status: dict | None = None, - spec: dict | None = None, -) -> dict: - """Build a NeutronRouterFlavor object with optional status/generation. - - Mirrors :func:`router_flavor_object` but allows tests to control the - metadata.generation and status subresource used by the Modified-event - status-current guard. - """ - obj = router_flavor_object(name, spec) - obj["metadata"]["generation"] = generation - if status is not None: - obj["status"] = status - return obj + assert code == 0 + assert patch_status.call_args.kwargs["sync_status"] == "Synced" + assert patch_status.call_args.kwargs["message"] == ( + "Successfully reconciled router flavor" + ) + # Already converged: no writes to Neutron. + conn.network.create_flavor.assert_not_called() + conn.network.create_service_profile.assert_not_called() + conn.network.associate_flavor_with_service_profile.assert_not_called() -def test_added_event_reconciles_only_added_resource(monkeypatch, tmp_path): - """An Added event reconciles only the new CR; prune sees the full snapshot. - Regression guard for the noise-on-create scenario: creating a new CR must - not reconcile the four unrelated CRs already present in Neutron. - """ +def test_main_reports_profile_drift_on_the_cr_status(monkeypatch, tmp_path): + """A disabled profile keeps the flavor Synced but must show on the status.""" clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - conn = mock.MagicMock() - - added = router_flavor_object("crud_svi") - other_names = ["dynamic_vrf", "pa1410", "static_vrf", "svi"] - snapshot_objects = [router_flavor_object(name) for name in other_names] + [added] + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + conn = _neutron_conn() + conn.network.service_profiles.return_value[0].is_enabled = False - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Added", - "object": added, - "snapshots": { - common.CRD_BINDING_NAME: [ - {"object": obj} for obj in snapshot_objects - ], - }, - } - ], + code, patch_status = _run_main( + monkeypatch, tmp_path, _schedule_context("pa1410"), conn ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() - - assert result == 0 - assert [call.args[1]["name"] for call in mock_sync.call_args_list] == ["crud_svi"] - mock_prune.assert_called_once() - prune_flavors = mock_prune.call_args.args[1] - assert sorted(flavor["name"] for flavor in prune_flavors) == sorted( - other_names + ["crud_svi"] - ) - assert "authoritative_empty_desired" not in mock_prune.call_args.kwargs + assert code == 0 + assert patch_status.call_args.kwargs["sync_status"] == "Synced" + message = patch_status.call_args.kwargs["message"] + assert "is_enabled" in message + assert "profile-id" in message -def test_deleted_event_reconciles_none_and_prunes_with_remaining_snapshot( - monkeypatch, tmp_path -): - """Delete of one CR while others remain in the same credential group. - Regression guard for the exact log scenario: deleting crud_svi while - four remain must not reconcile any of the remaining flavors. Prune - receives the snapshot of the remaining four and does NOT set - authoritative_empty_desired, so it only removes the flavor that is - absent from the snapshot. - """ +def test_main_reports_failure_and_skips_prune(monkeypatch, tmp_path): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") - conn = mock.MagicMock() + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setenv(f"{ENV_PREFIX}_PRUNE", "true") + conn = _neutron_conn() + # An existing flavor whose service_type cannot be changed is a hard failure. + conn.network.flavors.return_value[0].service_type = "WRONG_TYPE" + + with mock.patch.object(hook.prune_module, "prune_removed_flavors") as prune: + code, patch_status = _run_main( + monkeypatch, tmp_path, _schedule_context("pa1410"), conn + ) - deleted = router_flavor_object("crud_svi") - remaining_names = ["dynamic_vrf", "pa1410", "static_vrf", "svi"] - remaining = [router_flavor_object(name) for name in remaining_names] + assert code == 1 + assert patch_status.call_args.kwargs["sync_status"] == "Failed" + assert "service_type" in patch_status.call_args.kwargs["message"] + prune.assert_not_called() - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Deleted", - "object": deleted, - "snapshots": { - common.CRD_BINDING_NAME: [{"object": obj} for obj in remaining], - }, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() +def test_main_prunes_after_a_successful_reconcile(monkeypatch, tmp_path): + clear_env(monkeypatch) + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setenv(f"{ENV_PREFIX}_PRUNE", "true") + conn = _neutron_conn() + + with mock.patch.object(hook.prune_module, "prune_removed_flavors") as prune: + code, _ = _run_main(monkeypatch, tmp_path, _schedule_context("pa1410"), conn) - assert result == 0 - mock_sync.assert_not_called() - mock_prune.assert_called_once() - prune_flavors = mock_prune.call_args.args[1] - assert sorted(flavor["name"] for flavor in prune_flavors) == sorted(remaining_names) - # authoritative_empty_desired must NOT be set: snapshot still has items. - assert mock_prune.call_args.kwargs.get("authoritative_empty_desired") is not True + assert code == 0 + prune.assert_called_once() + assert [spec["name"] for spec in prune.call_args.args[1]] == ["pa1410"] -def test_modified_event_skipped_when_status_already_current(monkeypatch, tmp_path): - """Status-only Modified events must not trigger OpenStack work. +def test_main_fails_loudly_on_a_cr_missing_cloud_credentials(monkeypatch, tmp_path): + """A CR without credentials must fail the run, not be skipped. - The hook's own status patch surfaces as a Modified event with the same - metadata.generation. If status already reflects that generation as Synced, - the hook must skip both reconcile and prune to break the feedback loop. + The CRD marks cloudCredentialsRef required, so the API server should reject + it first; this guards the case where something bypasses that. """ clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - - obj = router_flavor_object_with_status( - "crud_svi", - generation=7, - status={ - "syncStatus": "Synced", - "observedGeneration": 7, - "message": "Successfully reconciled router flavor", - }, - ) - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Modified", - "object": obj, - "snapshots": {common.CRD_BINDING_NAME: [{"object": obj}]}, - } - ], - ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) - - with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection" - ) as mock_connect, - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch( - "openstack_sync.hooks.router_flavors.prune_removed_flavors" - ) as mock_prune, - mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), - ): - result = hook.main() + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + obj = router_flavor_object("pa1410") + del obj["spec"]["cloudCredentialsRef"] + contexts = [ + { + "binding": BINDING_NAME, + "type": "Schedule", + "snapshots": {BINDING_NAME: [{"object": obj}]}, + } + ] - assert result == 0 - mock_connect.assert_not_called() - mock_sync.assert_not_called() - mock_prune.assert_not_called() + code, _ = _run_main(monkeypatch, tmp_path, contexts, _neutron_conn()) + assert code == 1 -def test_modified_event_reconciles_when_generation_bumped(monkeypatch, tmp_path): - """A real spec change bumps metadata.generation past observedGeneration. - The status-current guard must not skip these events: the spec is drifted - from what the operator last reconciled, so reconcile must run. - """ +def test_main_uses_the_credentials_named_by_each_cr(monkeypatch, tmp_path): clear_env(monkeypatch) - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_ENABLED", "true") - monkeypatch.setenv("POD_NAMESPACE", "openstack") - conn = mock.MagicMock() - - obj = router_flavor_object_with_status( - "crud_svi", - generation=8, - status={ - "syncStatus": "Synced", - "observedGeneration": 7, - "message": "Successfully reconciled router flavor", - }, - ) - - context_path = write_binding_context( - tmp_path, - [ - { - "binding": common.CRD_BINDING_NAME, - "type": "Event", - "watchEvent": "Modified", - "object": obj, - "snapshots": {common.CRD_BINDING_NAME: [{"object": obj}]}, - } - ], + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setattr(utils, "_connection_cache", {}) + contexts = _schedule_context("pa1410") + contexts[0]["snapshots"][BINDING_NAME][0]["object"]["spec"][ + "cloudCredentialsRef" + ] = {"secretName": "other-secret", "cloudName": "other-cloud"} + monkeypatch.setenv( + "BINDING_CONTEXT_PATH", write_binding_context(tmp_path, contexts) ) - monkeypatch.setenv("BINDING_CONTEXT_PATH", context_path) with ( - mock.patch( - "openstack_sync.hooks.router_flavors.get_openstack_connection", - return_value=conn, - ), - mock.patch("openstack_sync.hooks.router_flavors.wait_for_openstack_network"), - mock.patch("openstack_sync.hooks.router_flavors.patch_flavor_status"), - mock.patch( - "openstack_sync.hooks.router_flavors.sync_flavor", return_value=[] - ) as mock_sync, - mock.patch("openstack_sync.hooks.router_flavors.prune_removed_flavors"), mock.patch.object(hook.sys, "argv", ["router_flavors.py"]), + mock.patch( + "openstack_sync.hooks.framework.get_openstack_connection", + return_value=_neutron_conn(), + ) as connect, + mock.patch("openstack_sync.hooks.framework.patch_resource_status"), + mock.patch.object(hook, "wait_for_openstack_network"), ): - result = hook.main() + assert hook.main() == 0 - assert result == 0 - assert [call.args[1]["name"] for call in mock_sync.call_args_list] == ["crud_svi"] + connect.assert_called_once_with("other-secret", "other-cloud") diff --git a/python/openstack-sync/tests/test_router_flavors_prune.py b/python/openstack-sync/tests/test_router_flavors_prune.py deleted file mode 100644 index fa38a3557..000000000 --- a/python/openstack-sync/tests/test_router_flavors_prune.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Tests for Neutron router flavor prune behavior.""" - -from __future__ import annotations - -from types import SimpleNamespace -from typing import Any - -from openstack_sync.plugins.neutron.router_flavors import delete -from openstack_sync.plugins.neutron.router_flavors import ( - router_flavors_common as common, -) - - -def enable_prune(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_PRUNE", "true") - - -def enable_profile_delete(monkeypatch): - monkeypatch.setenv("NEUTRON_ROUTER_FLAVOR_DELETE_UNUSED_PROFILES", "true") - - -class FakeNetwork: - def __init__(self, flavors: list[dict[str, Any]], profiles: dict[str, Any]): - self._flavors = flavors - self._profiles = profiles - self.deleted_flavors: list[str] = [] - self.flavor_list_calls = 0 - - def flavors(self, service_type: str | None = None) -> list[dict[str, Any]]: - self.flavor_list_calls += 1 - return [ - flavor - for flavor in self._flavors - if service_type is None or flavor["service_type"] == service_type - ] - - def routers(self, flavor_id: str) -> list[dict[str, Any]]: - return [] - - def service_profiles(self) -> list[Any]: - return [p for p in self._profiles.values() if p is not None] - - def get_service_profile(self, profile_id: str) -> Any: - return self._profiles.get(profile_id) - - def delete_flavor( - self, flavor: dict[str, Any], ignore_missing: bool = True - ) -> None: - self.deleted_flavors.append(flavor["id"]) - self._flavors = [ - current for current in self._flavors if current["id"] != flavor["id"] - ] - - -def test_prune_keeps_manual_flavor_with_managed_service_profile(monkeypatch): - enable_prune(monkeypatch) - flavor = { - "id": "manual-flavor-id", - "name": "manual-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": "created outside the operator", - "service_profile_ids": ["managed-profile-id"], - } - profile = SimpleNamespace( - id="managed-profile-id", - driver="neutron_understack.l3_router.vrf.Vrf", - meta_info=common.managed_meta_info({"vni_alloc": "auto"}), - ) - conn = SimpleNamespace(network=FakeNetwork([flavor], {profile.id: profile})) - - delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) - - assert conn.network.deleted_flavors == [] - - -def test_prune_keeps_managed_flavors_when_desired_list_is_empty(monkeypatch): - enable_prune(monkeypatch) - flavor = { - "id": "managed-flavor-id", - "name": "removed-managed-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [], - } - conn = SimpleNamespace(network=FakeNetwork([flavor], {})) - - delete.prune_removed_flavors(conn, []) - - assert conn.network.deleted_flavors == [] - - -def test_prune_deletes_managed_flavors_when_empty_desired_is_explicit(monkeypatch): - enable_prune(monkeypatch) - flavor = { - "id": "managed-flavor-id", - "name": "removed-managed-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [], - } - conn = SimpleNamespace(network=FakeNetwork([flavor], {})) - - delete.prune_removed_flavors(conn, [], authoritative_empty_desired=True) - - assert conn.network.deleted_flavors == ["managed-flavor-id"] - - -def test_prune_deletes_removed_managed_flavor(monkeypatch): - enable_prune(monkeypatch) - flavor = { - "id": "managed-flavor-id", - "name": "removed-managed-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [], - } - conn = SimpleNamespace(network=FakeNetwork([flavor], {})) - - delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) - - assert conn.network.deleted_flavors == ["managed-flavor-id"] - - -def test_prune_deletes_removed_managed_flavor_and_unused_profile(monkeypatch): - enable_prune(monkeypatch) - enable_profile_delete(monkeypatch) - profile = _make_orphan_profile("managed-profile-id") - flavor = { - "id": "managed-flavor-id", - "name": "removed-managed-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [profile.id], - } - network = FakeNetworkWithProfiles([flavor], {profile.id: profile}) - conn = SimpleNamespace(network=network) - - delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) - - assert network.deleted_flavors == ["managed-flavor-id"] - assert network.deleted_profiles == ["managed-profile-id"] - - -# --------------------------------------------------------------------------- -# prune_orphaned_service_profiles: second-pass GC for partial-failure orphans -# --------------------------------------------------------------------------- - - -class FakeNetworkWithProfiles(FakeNetwork): - """FakeNetwork extended to track service profile deletes.""" - - def __init__( - self, - flavors: list[dict[str, Any]], - profiles: dict[str, Any], - ): - super().__init__(flavors, profiles) - self.deleted_profiles: list[str] = [] - - def service_profiles(self) -> list[Any]: - return [p for p in self._profiles.values() if p is not None] - - def delete_service_profile(self, profile: Any, ignore_missing: bool = True) -> None: - profile_id = profile.id if hasattr(profile, "id") else profile["id"] - self.deleted_profiles.append(profile_id) - self._profiles[profile_id] = None - - def get_service_profile(self, profile_id: str) -> Any: - profile = self._profiles.get(profile_id) - if profile is None: - raise Exception(f"Profile {profile_id} not found") - return profile - - -def _make_orphan_profile( - profile_id: str, driver: str = "neutron_understack.l3_router.vrf.Vrf" -): - """Return a SimpleNamespace service profile with operator ownership markers.""" - import types - - return types.SimpleNamespace( - id=profile_id, - driver=driver, - meta_info=common.managed_meta_info({"vni_alloc": "auto"}), - ) - - -def test_prune_orphaned_profiles_deletes_unattached_managed_profile(monkeypatch): - """A managed profile with no parent flavor is deleted by the second pass.""" - enable_prune(monkeypatch) - enable_profile_delete(monkeypatch) - - orphan = _make_orphan_profile("orphan-profile-id") - # No flavors in Neutron; the orphan's parent was already deleted. - network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) - conn = SimpleNamespace(network=network) - - delete.prune_orphaned_service_profiles( - conn, - {}, - delete.service_profile_attachment_counts([]), - ) - - assert "orphan-profile-id" in network.deleted_profiles - - -def test_prune_orphaned_profiles_keeps_non_managed_profile(monkeypatch): - """A profile without the operator ownership marker is not touched.""" - enable_profile_delete(monkeypatch) - import types - - unmanaged = types.SimpleNamespace( - id="unmanaged-profile-id", - driver="neutron_understack.l3_router.vrf.Vrf", - meta_info={"vni_alloc": "auto"}, # no MANAGED_META_INFO_KEY - ) - network = FakeNetworkWithProfiles(flavors=[], profiles={unmanaged.id: unmanaged}) - conn = SimpleNamespace(network=network) - - delete.prune_orphaned_service_profiles( - conn, - {}, - delete.service_profile_attachment_counts([]), - ) - - assert network.deleted_profiles == [] - - -def test_prune_removed_flavors_cleans_up_orphaned_profile_on_next_run(monkeypatch): - """Simulate a partial failure: flavor deleted, profile cleanup threw last run. - - On the next prune_removed_flavors call the flavor no longer exists in - Neutron, so the flavor loop skips it. The second-pass GC should find and - delete the orphaned profile. - """ - enable_prune(monkeypatch) - enable_profile_delete(monkeypatch) - - # Neutron state after the partial failure: flavor is gone, profile remains. - orphan = _make_orphan_profile("orphan-after-partial-failure") - network = FakeNetworkWithProfiles(flavors=[], profiles={orphan.id: orphan}) - conn = SimpleNamespace(network=network) - - # desired list is non-empty so the empty-list guard does not fire. - delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) - - assert "orphan-after-partial-failure" in network.deleted_profiles - - -def test_prune_removed_flavors_lists_l3_flavors_once_for_profile_checks(monkeypatch): - enable_prune(monkeypatch) - enable_profile_delete(monkeypatch) - - removed_profile = _make_orphan_profile("removed-profile-id") - orphan_profile = _make_orphan_profile("orphan-profile-id") - attached_profile = _make_orphan_profile("attached-profile-id") - removed_flavor = { - "id": "removed-flavor-id", - "name": "removed-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [removed_profile.id], - } - kept_flavor = { - "id": "kept-flavor-id", - "name": "kept-flavor", - "service_type": common.DEFAULT_SERVICE_TYPE, - "description": common.managed_flavor_description("created by operator"), - "service_profile_ids": [attached_profile.id], - } - network = FakeNetworkWithProfiles( - [removed_flavor, kept_flavor], - { - removed_profile.id: removed_profile, - orphan_profile.id: orphan_profile, - attached_profile.id: attached_profile, - }, - ) - conn = SimpleNamespace(network=network) - - delete.prune_removed_flavors(conn, [{"name": "kept-flavor"}]) - - assert network.flavor_list_calls == 1 - assert network.deleted_flavors == ["removed-flavor-id"] - assert network.deleted_profiles == ["removed-profile-id", "orphan-profile-id"] diff --git a/python/openstack-sync/tests/test_router_flavors_update.py b/python/openstack-sync/tests/test_router_flavors_update.py deleted file mode 100644 index d6978f15e..000000000 --- a/python/openstack-sync/tests/test_router_flavors_update.py +++ /dev/null @@ -1,377 +0,0 @@ -"""Tests for update.ensure_flavor and update.sync_flavor. - -Covers the service_type guard, is_enabled drift reconcile (both directions), -create-with-is_enabled-from-spec, and the sync_flavor spec pass-through. -""" - -from __future__ import annotations - -import types -from typing import Any -from unittest import mock - -import pytest - -from openstack_sync.plugins.common import ConfigError -from openstack_sync.plugins.neutron.router_flavors import update -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - FLAVOR_DESCRIPTION_MARKER, -) -from openstack_sync.plugins.neutron.router_flavors.router_flavors_common import ( - ProfileDrift, -) - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - -_NAME = "test-flavor" -_SERVICE_TYPE = "L3_ROUTER_NAT" -_DESCRIPTION = "my flavor" - - -def _make_flavor( - *, - name: str = _NAME, - service_type: str = _SERVICE_TYPE, - description: str = f"{_DESCRIPTION} {FLAVOR_DESCRIPTION_MARKER}", - is_enabled: bool = True, -) -> Any: - return types.SimpleNamespace( - name=name, - service_type=service_type, - description=description, - is_enabled=is_enabled, - ) - - -# --------------------------------------------------------------------------- -# service_type mismatch — must raise ConfigError -# --------------------------------------------------------------------------- - - -def test_ensure_flavor_raises_on_service_type_mismatch(): - flavor = _make_flavor(service_type="DIFFERENT_TYPE") - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - with pytest.raises(ConfigError, match="service_type"): - update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True - ) - - -def test_ensure_flavor_error_message_contains_both_service_types(): - flavor = _make_flavor(service_type="WRONG") - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - with pytest.raises(ConfigError) as exc_info: - update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True - ) - msg = str(exc_info.value) - assert "WRONG" in msg - assert _SERVICE_TYPE in msg - assert _NAME in msg - - -# --------------------------------------------------------------------------- -# is_enabled reconcile -# --------------------------------------------------------------------------- - - -def test_ensure_flavor_reenables_disabled_flavor(caplog): - """Neutron has is_enabled=False but spec says True → update to True.""" - flavor = _make_flavor(is_enabled=False) - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - conn.network.update_flavor.return_value = _make_flavor(is_enabled=True) - with caplog.at_level("INFO", logger="openstack_sync"): - update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True - ) - - conn.network.update_flavor.assert_called_once() - _, kwargs = conn.network.update_flavor.call_args - assert kwargs["is_enabled"] is True - assert "is_enabled drift" in caplog.text - assert "have=False" in caplog.text - assert "want=True" in caplog.text - - -def test_ensure_flavor_disables_enabled_flavor_when_spec_disables(caplog): - """Neutron has is_enabled=True but spec says False → update to False.""" - flavor = _make_flavor(is_enabled=True) - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - conn.network.update_flavor.return_value = _make_flavor(is_enabled=False) - with caplog.at_level("INFO", logger="openstack_sync"): - update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False - ) - - conn.network.update_flavor.assert_called_once() - _, kwargs = conn.network.update_flavor.call_args - assert kwargs["is_enabled"] is False - assert "is_enabled drift" in caplog.text - assert "have=True" in caplog.text - assert "want=False" in caplog.text - - -def test_ensure_flavor_no_update_when_both_disabled(): - """Neutron has is_enabled=False and spec says False → no Neutron call.""" - flavor = _make_flavor(is_enabled=False) - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - result = update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False - ) - - conn.network.update_flavor.assert_not_called() - assert result is flavor - - -def test_ensure_flavor_reenables_disabled_flavor_even_when_description_matches(): - """is_enabled=False must trigger an update even if description is current.""" - flavor = _make_flavor(is_enabled=False) - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - conn.network.update_flavor.return_value = _make_flavor(is_enabled=True) - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) - - conn.network.update_flavor.assert_called_once() - - -def test_ensure_flavor_no_update_when_already_correct(): - """No Neutron call when description and is_enabled are already correct.""" - flavor = _make_flavor(is_enabled=True) - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - result = update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True - ) - - conn.network.update_flavor.assert_not_called() - assert result is flavor - - -# --------------------------------------------------------------------------- -# description drift still triggers update -# --------------------------------------------------------------------------- - - -def test_ensure_flavor_updates_changed_description(): - flavor = _make_flavor(description="old description") - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - conn.network.update_flavor.return_value = _make_flavor() - update.ensure_flavor( - conn, _NAME, _SERVICE_TYPE, "new description", is_enabled=True - ) - - conn.network.update_flavor.assert_called_once() - - -def test_ensure_flavor_adds_missing_marker(): - flavor = _make_flavor(description="no marker here") - with mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=flavor, - ): - conn = mock.MagicMock() - conn.network.update_flavor.return_value = _make_flavor() - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) - - conn.network.update_flavor.assert_called_once() - _, kwargs = conn.network.update_flavor.call_args - assert FLAVOR_DESCRIPTION_MARKER in kwargs["description"] - - -# --------------------------------------------------------------------------- -# flavor not found — creates it -# --------------------------------------------------------------------------- - - -def test_ensure_flavor_creates_when_not_found(): - with ( - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=None, - ), - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.create_flavor", - return_value=_make_flavor(), - ) as mock_create, - ): - conn = mock.MagicMock() - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True) - - mock_create.assert_called_once_with( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=True - ) - - -def test_ensure_flavor_creates_with_is_enabled_from_spec(): - """A CR that opts out of enabled must create the Neutron flavor disabled.""" - with ( - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.find_flavor", - return_value=None, - ), - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.create_flavor", - return_value=_make_flavor(is_enabled=False), - ) as mock_create, - ): - conn = mock.MagicMock() - update.ensure_flavor(conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False) - - mock_create.assert_called_once_with( - conn, _NAME, _SERVICE_TYPE, _DESCRIPTION, is_enabled=False - ) - - -# --------------------------------------------------------------------------- -# sync_flavor: reads is_enabled from the CR spec -# --------------------------------------------------------------------------- - - -def _sync_flavor_config(*, is_enabled: bool) -> dict[str, Any]: - """Build a CR-shaped flavor_config. - - ``is_enabled`` mirrors the CRD default (true) that the k8s API server - materialises on admission; every real spec reaching the hook carries it. - """ - return { - "name": _NAME, - "description": _DESCRIPTION, - "service_type": _SERVICE_TYPE, - "is_enabled": is_enabled, - "service_profiles": [ - { - "driver": "neutron_understack.l3_router.vrf.Vrf", - "description": "profile description", - "meta_info": {}, - "is_enabled": True, - } - ], - } - - -def _sync_flavor_mocks(flavor: Any): - """Yield the mock stack used by sync_flavor pass-through tests. - - Uses a real openstacksdk-shaped flavor (SimpleNamespace with - ``service_profile_ids``) so ``render_flavor`` succeeds when - ``sync_flavor`` logs the reconciled result. - """ - rendered = types.SimpleNamespace( - id="flavor-id", - name=_NAME, - service_type=_SERVICE_TYPE, - description=flavor.description, - is_enabled=flavor.is_enabled, - service_profile_ids=["profile-id"], - ) - return ( - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.update.ensure_flavor", - return_value=rendered, - ), - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create.ensure_profile" - ), - mock.patch( - "openstack_sync.plugins.neutron.router_flavors.create." - "reconcile_flavor_profiles", - return_value=rendered, - ), - ) - - -def test_sync_flavor_passes_is_enabled_true_from_spec(): - """The value the k8s API server put on the CR reaches ensure_flavor.""" - conn = mock.MagicMock() - flavor = _make_flavor(is_enabled=True) - ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) - with ensure_patch as mock_ensure, profile_patch, attached_patch: - update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) - - assert mock_ensure.call_args.kwargs["is_enabled"] is True - - -def test_sync_flavor_passes_is_enabled_false_from_spec(): - conn = mock.MagicMock() - flavor = _make_flavor(is_enabled=False) - ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) - with ensure_patch as mock_ensure, profile_patch, attached_patch: - update.sync_flavor(conn, _sync_flavor_config(is_enabled=False), {}) - - assert mock_ensure.call_args.kwargs["is_enabled"] is False - - -# --------------------------------------------------------------------------- -# sync_flavor: service profile drift reaches the caller -# --------------------------------------------------------------------------- - - -def test_sync_flavor_returns_empty_drift_when_nothing_drifted(): - conn = mock.MagicMock() - flavor = _make_flavor(is_enabled=True) - ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) - with ensure_patch, profile_patch, attached_patch: - result = update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) - - assert result == [] - - -def test_sync_flavor_propagates_profile_drift(): - """Drift collected while resolving profiles is returned to the caller. - - The flavor itself is converged, so this is not a reconcile failure -- but - the caller must be able to qualify the status it reports. - """ - conn = mock.MagicMock() - flavor = _make_flavor(is_enabled=True) - ensure_patch, profile_patch, attached_patch = _sync_flavor_mocks(flavor) - drifted = ProfileDrift( - profile_id="prof-a", - driver="neutron_understack.l3_router.vrf.Vrf", - field="is_enabled", - have=False, - want=True, - ) - - def ensure_profile(conn, name, profile_spec, profile_cache, drift=None): - if drift is not None: - drift.append(drifted) - return types.SimpleNamespace(id="prof-a") - - with ensure_patch, profile_patch as mock_profile, attached_patch: - mock_profile.side_effect = ensure_profile - result = update.sync_flavor(conn, _sync_flavor_config(is_enabled=True), {}) - - assert result == [drifted]