From 6a06762c4ed68a810932c38e0d43b44383889184 Mon Sep 17 00:00:00 2001 From: Geetika Batra Date: Wed, 2 Sep 2026 14:40:19 +0530 Subject: [PATCH] feat(openstack-sync): add NeutronSegmentRange CRD and sync plugin Add a NeutronSegmentRange CRD to the openstack-sync-operator and a segment_ranges hook plus plugin package that reconciles Neutron network segment ranges (openstack network segment range) from custom resources. - CRD: neutron.understack.rackspace.net/v1alpha1 NeutronSegmentRange - plugin: reconcile/create/adopt by owner-prefixed name, prune on removal - immutable network_type/physical_network mismatches fail loudly - wired into operator values.yaml (plugins.neutronSegmentRanges, disabled) --- ...ck.rackspace.net_neutronsegmentranges.yaml | 216 ++++++++++++++++++ .../openstack-sync-operator/values.yaml | 17 ++ .../openstack_sync/hooks/segment_ranges.py | 71 ++++++ .../neutron/segment_ranges/__init__.py | 1 + .../plugins/neutron/segment_ranges/config.py | 22 ++ .../plugins/neutron/segment_ranges/markers.py | 48 ++++ .../plugins/neutron/segment_ranges/prune.py | 71 ++++++ .../neutron/segment_ranges/reconcile.py | 209 +++++++++++++++++ 8 files changed, 655 insertions(+) create mode 100644 components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronsegmentranges.yaml create mode 100644 python/openstack-sync/openstack_sync/hooks/segment_ranges.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/__init__.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/config.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/markers.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/prune.py create mode 100644 python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/reconcile.py diff --git a/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronsegmentranges.yaml b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronsegmentranges.yaml new file mode 100644 index 000000000..fb5cc83d6 --- /dev/null +++ b/components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronsegmentranges.yaml @@ -0,0 +1,216 @@ + +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: neutronsegmentranges.neutron.understack.rackspace.net +spec: + group: neutron.understack.rackspace.net + names: + kind: NeutronSegmentRange + listKind: NeutronSegmentRangeList + plural: neutronsegmentranges + shortNames: + - nsr + singular: neutronsegmentrange + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + additionalPrinterColumns: + - name: Name + type: string + jsonPath: .spec.name + - name: Type + type: string + jsonPath: .spec.network_type + - name: Physical + type: string + jsonPath: .spec.physical_network + - name: Min + type: integer + jsonPath: .spec.minimum + - name: Max + type: integer + jsonPath: .spec.maximum + - name: Shared + type: boolean + jsonPath: .spec.shared + - name: SyncStatus + type: string + jsonPath: .status.syncStatus + - name: Age + type: date + jsonPath: .metadata.creationTimestamp + schema: + openAPIV3Schema: + description: >- + NeutronSegmentRange defines one Neutron network segment range + (``openstack network segment range``). The operator finds, adopts, or + creates the range identified by ``spec.name`` and reconciles its + network type, physical network, and minimum/maximum segmentation IDs + toward the spec. Creating a CR claims ownership of the matching + OpenStack segment range. Neutron does not allow updating the + ``network_type`` or ``physical_network`` of an existing range, so a + CR that disagrees with an existing range on either field fails loudly + rather than silently diverging. + type: object + required: + - spec + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + type: object + required: + - name + - network_type + - minimum + - maximum + - 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. + type: object + required: + - secretName + - cloudName + properties: + secretName: + description: >- + Name of a Secret in the same namespace as this resource. + The Secret must contain a key named clouds.yaml holding + an OpenStack clouds.yaml file. + type: string + minLength: 1 + maxLength: 253 + cloudName: + description: >- + Name of the cloud entry within the clouds.yaml to + authenticate as. + type: string + minLength: 1 + maxLength: 256 + name: + description: >- + Neutron network segment range name. This is the identity the + operator uses to find, adopt, create, and prune the range, so + it must be unique per cloud. + type: string + minLength: 1 + maxLength: 255 + pattern: ^[A-Za-z0-9._/-]+$ + network_type: + description: >- + The network type of the segment range. VLAN IDs apply to the + ``vlan`` type; tunnel IDs apply to ``geneve``, ``gre`` and + ``vxlan``. + type: string + enum: + - vlan + - vxlan + - gre + - geneve + - flat + minLength: 1 + maxLength: 32 + physical_network: + description: >- + The physical network the segment range is bound to. Required + for ``vlan`` and ``flat`` ranges and must be omitted for the + tunnelled types (``vxlan``, ``gre``, ``geneve``); the operator + enforces this at reconcile time. + type: string + minLength: 1 + maxLength: 255 + minimum: + description: >- + The minimum segmentation ID in the range (inclusive). Must be + less than or equal to ``maximum``. + type: integer + format: int64 + minimum: 1 + maximum: + description: >- + The maximum segmentation ID in the range (inclusive). Must be + greater than or equal to ``minimum``. + type: integer + format: int64 + minimum: 1 + shared: + description: >- + Whether the segment range is shared with all projects. When + false the range is scoped to ``project_id``, which then + becomes required. + type: boolean + default: true + project_id: + description: >- + The project the range is scoped to when ``shared`` is false. + Ignored for shared ranges. + type: string + minLength: 1 + maxLength: 255 + x-kubernetes-validations: + - rule: self.maximum >= self.minimum + message: maximum must be greater than or equal to minimum + - rule: "self.shared || has(self.project_id)" + message: project_id is required when shared is false + status: + description: NeutronSegmentRangeStatus defines the observed sync state. + type: object + properties: + syncStatus: + description: SyncStatus indicates the synchronization state with Neutron. + type: string + enum: + - Synced + - Failed + - Unknown + lastSyncTime: + description: LastSyncTime is the last time the operator attempted to sync the range. + type: string + format: date-time + observedGeneration: + description: ObservedGeneration is the metadata generation last processed by the operator. + type: integer + format: int64 + message: + description: Message provides details about the last sync attempt. + type: string + maxLength: 2048 + conditions: + description: Conditions describe current observed state. + type: array + items: + type: object + required: + - type + - status + properties: + type: + type: string + status: + type: string + enum: + - "True" + - "False" + - Unknown + reason: + type: string + message: + type: string + maxLength: 2048 + lastTransitionTime: + type: string + format: date-time + subresources: + status: {} diff --git a/components/openstack-sync-operator/values.yaml b/components/openstack-sync-operator/values.yaml index b985e59ff..1f09c1a71 100644 --- a/components/openstack-sync-operator/values.yaml +++ b/components/openstack-sync-operator/values.yaml @@ -30,6 +30,7 @@ rbac: plugins: openstackPlaceholder: false neutronRouterFlavors: false + neutronSegmentRanges: false pluginData: openstackPlaceholder: @@ -55,3 +56,19 @@ pluginData: # When true, removing a NeutronRouterFlavor CR also deletes its unused # operator-managed OpenStack flavor. Enable this before removing the CR. PRUNE: false + + neutronSegmentRanges: + hook: + path: /hooks/segment_ranges.py + crd: crds/neutron.understack.rackspace.net_neutronsegmentranges.yaml + envPrefix: NEUTRON_SEGMENT_RANGE + env: + SYNC_CRONTAB: "0 * * * *" + # Neutron readiness wait before a segment range reconcile fails. + # Total wait is READY_RETRIES * READY_DELAY seconds. + READY_RETRIES: 30 + READY_DELAY: 10 + # When true, removing a NeutronSegmentRange CR also deletes its + # operator-managed OpenStack segment range. Enable this before + # removing the CR. + PRUNE: false diff --git a/python/openstack-sync/openstack_sync/hooks/segment_ranges.py b/python/openstack-sync/openstack_sync/hooks/segment_ranges.py new file mode 100644 index 000000000..22a5cf7cf --- /dev/null +++ b/python/openstack-sync/openstack_sync/hooks/segment_ranges.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Shell-operator hook for Neutron network segment range reconciliation.""" + +from __future__ import annotations + +import sys +from typing import Any + +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.segment_ranges import prune as prune_module +from openstack_sync.plugins.neutron.segment_ranges import reconcile as reconcile_module +from openstack_sync.plugins.neutron.segment_ranges.config import BINDING_NAME +from openstack_sync.plugins.neutron.segment_ranges.config import ENV_PREFIX + + +class SegmentRangePlugin(SyncPlugin): + """Sync NeutronSegmentRange CRs into Neutron network segment ranges.""" + + noun = "segment range" + + def wait_for_api(self, conn: Any) -> None: + wait_for_openstack_network( + conn, + retries=self.config.ready_retries, + delay=self.config.ready_delay, + ) + + def new_cache(self) -> reconcile_module.RangeCache: + # Keyed by managed range name and shared across every CR in one + # credential group, so the managed-range listing is fetched once and + # reused by each reconcile and the prune. + return {} + + def reconcile( + self, conn: Any, spec: dict[str, Any], cache: reconcile_module.RangeCache + ) -> list[str]: + return reconcile_module.sync_segment_range(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_ranges( + conn, desired_specs, authoritative_empty=authoritative_empty + ) + + +def main() -> int: + 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(SegmentRangePlugin(config), hook_inputs(contexts, config)) + + return run_hook(lambda: build_crd_hook_config(ENV_PREFIX, BINDING_NAME), run) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/__init__.py b/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/__init__.py new file mode 100644 index 000000000..bb7f501ff --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/__init__.py @@ -0,0 +1 @@ +"""Neutron network segment range sync plugin.""" diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/config.py b/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/config.py new file mode 100644 index 000000000..61d56e636 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/config.py @@ -0,0 +1,22 @@ +"""Segment-range plugin constants. + +Runtime configuration comes from +:class:`openstack_sync.hooks.framework.HookConfig`, built from the +``NEUTRON_SEGMENT_RANGE`` env prefix the Helm chart injects. The values here are +not configurable at runtime. +""" + +from __future__ import annotations + +#: Env prefix the Helm chart uses for this plugin's variables. +ENV_PREFIX = "NEUTRON_SEGMENT_RANGE" + +#: shell-operator binding label for the CRD watch. +BINDING_NAME = "neutron-segment-ranges" + +#: Network types Neutron binds to a physical network. VLAN and flat ranges +#: require ``physical_network``; the tunnelled types must omit it. +PHYSICAL_NETWORK_TYPES = frozenset({"vlan", "flat"}) + +#: Network types carried over a tunnel, which must not set ``physical_network``. +TUNNEL_NETWORK_TYPES = frozenset({"vxlan", "gre", "geneve"}) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/markers.py b/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/markers.py new file mode 100644 index 000000000..804ba62c4 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/markers.py @@ -0,0 +1,48 @@ +"""Ownership tracking for operator-managed network segment ranges. + +A NeutronSegmentRange CR is an ownership claim for the matching OpenStack +segment range. Unlike Neutron flavors and service profiles, a segment range has +no ``description`` or ``meta_info`` field the operator can stamp -- Neutron's +NetworkSegmentRange resource exposes only ``name``, ``network_type``, +``physical_network``, ``minimum``, ``maximum``, ``shared`` and ``project_id``. + +Ownership therefore rides on the range's ``name``. Every range the operator +creates or adopts carries an owner-prefixed name, and prune only ever deletes +ranges whose name carries that prefix. A range created out-of-band with a plain +name is never in the managed set, so it is never pruned. + +The prefix is transparent to CR authors: ``spec.name`` is the logical name, and +:func:`managed_name` / :func:`logical_name` translate between the logical name +and the name stored in Neutron. +""" + +from __future__ import annotations + +from typing import Any + +from openstack_sync.plugins.common import get_value + +#: Prepended to every operator-managed segment range name in Neutron. Chosen to +#: be unambiguous and to survive Neutron's name length limit (255) with room to +#: spare for a logical name. +NAME_PREFIX = "understack-sr:" + + +def managed_name(logical_name: str) -> str: + """Return the Neutron range name for a CR's logical *logical_name*.""" + if logical_name.startswith(NAME_PREFIX): + return logical_name + return f"{NAME_PREFIX}{logical_name}" + + +def logical_name(neutron_name: str) -> str: + """Return the CR-facing logical name for a Neutron range *neutron_name*.""" + if neutron_name.startswith(NAME_PREFIX): + return neutron_name[len(NAME_PREFIX) :] + return neutron_name + + +def is_managed_range(segment_range: Any) -> bool: + """Return True when *segment_range*'s name carries the operator prefix.""" + name = str(get_value(segment_range, "name", default="")) + return name.startswith(NAME_PREFIX) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/prune.py b/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/prune.py new file mode 100644 index 000000000..7c2d119ab --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/prune.py @@ -0,0 +1,71 @@ +"""Delete network segment ranges whose CR was removed. + +Everything here is gated on the operator's name-prefix ownership marker. A +range created out-of-band carries a plain name and is never in the managed set, +so it is untouched; a range carrying the owner prefix but absent from the +desired set is deleted. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from openstack import exceptions as openstack_exceptions + +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.common import resource_id +from openstack_sync.plugins.neutron.segment_ranges.markers import is_managed_range +from openstack_sync.plugins.neutron.segment_ranges.markers import logical_name +from openstack_sync.plugins.neutron.segment_ranges.markers import managed_name + +LOG = logging.getLogger(__name__) + + +def _delete_range(conn: Any, segment_range: Any) -> None: + range_id = resource_id(segment_range) + name = logical_name(str(get_value(segment_range, "name", default=range_id))) + LOG.info("Deleting removed segment range %s (%s)", name, range_id) + try: + conn.network.delete_network_segment_range( + segment_range, ignore_missing=True + ) + except openstack_exceptions.NotFoundException: + LOG.info("Segment range %s (%s) is already absent", name, range_id) + except openstack_exceptions.ConflictException: + LOG.info( + "Segment range %s is still in use; skipping delete", name + ) + + +def prune_removed_ranges( + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool = False, +) -> None: + """Delete operator-owned segment ranges 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 range. + """ + if not desired_specs and not authoritative_empty: + LOG.warning( + "No desired segment ranges found; skipping prune to avoid deleting " + "all managed ranges" + ) + return + + desired_names = { + managed_name(str(spec["name"])) for spec in desired_specs if spec.get("name") + } + + LOG.info("Pruning removed segment ranges") + for segment_range in list(conn.network.network_segment_ranges()): + if not is_managed_range(segment_range): + continue + name = str(get_value(segment_range, "name", default="")) + if name in desired_names: + continue + _delete_range(conn, segment_range) diff --git a/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/reconcile.py b/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/reconcile.py new file mode 100644 index 000000000..db4d1da01 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/neutron/segment_ranges/reconcile.py @@ -0,0 +1,209 @@ +"""Reconcile a NeutronSegmentRange CR onto Neutron. + +Find the operator-managed range by its owner-prefixed name; create it when +absent, or reconcile its mutable fields (``minimum``, ``maximum``, ``shared``, +``project_id``) when present. ``network_type`` and ``physical_network`` are +immutable in Neutron, so a mismatch on either fails the CR loudly rather than +silently diverging. +""" + +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 resource_id +from openstack_sync.plugins.neutron.segment_ranges.config import ( + PHYSICAL_NETWORK_TYPES, +) +from openstack_sync.plugins.neutron.segment_ranges.config import TUNNEL_NETWORK_TYPES +from openstack_sync.plugins.neutron.segment_ranges.markers import logical_name +from openstack_sync.plugins.neutron.segment_ranges.markers import managed_name + +LOG = logging.getLogger(__name__) + +#: Segment ranges already fetched this run, keyed by managed (Neutron) name. +RangeCache = dict[str, Any] + + +def _validate_spec(spec: dict[str, Any]) -> None: + """Reject a spec whose physical_network does not match its network_type. + + The CRD constrains ranges but cannot express the cross-field rule that VLAN + and flat ranges need a physical network while tunnelled types must not carry + one. Enforce it here so a bad spec fails its own CR by name rather than + reaching Neutron and erroring in a way that is harder to attribute. + """ + network_type = spec["network_type"] + physical_network = spec.get("physical_network") + minimum = int(spec["minimum"]) + maximum = int(spec["maximum"]) + + if minimum > maximum: + raise ConfigError( + f"minimum {minimum} is greater than maximum {maximum}; " + "the range is empty" + ) + + if network_type in PHYSICAL_NETWORK_TYPES and not physical_network: + raise ConfigError( + f"network_type {network_type!r} requires physical_network to be set" + ) + if network_type in TUNNEL_NETWORK_TYPES and physical_network: + raise ConfigError( + f"network_type {network_type!r} must not set physical_network " + f"(got {physical_network!r})" + ) + + if not spec.get("shared", True) and not spec.get("project_id"): + raise ConfigError("project_id is required when shared is false") + + +def load_managed_ranges(conn: Any, cache: RangeCache) -> RangeCache: + """Populate *cache* with every operator-managed range, keyed by name. + + Fetched once per credential group and shared across the group's CRs so a + reconcile and a later prune reuse one listing. + """ + if cache: + return cache + for segment_range in conn.network.network_segment_ranges(): + name = str(get_value(segment_range, "name", default="")) + if name.startswith(managed_name("")): + cache[name] = segment_range + return cache + + +def find_range(conn: Any, managed: str, cache: RangeCache) -> Any | None: + """Return the operator-managed range named *managed*, or None.""" + load_managed_ranges(conn, cache) + return cache.get(managed) + + +def _immutable_drift(segment_range: Any, spec: dict[str, Any]) -> str | None: + """Return a description of any immutable-field mismatch, else None.""" + checks = ( + ("network_type", str(get_value(segment_range, "network_type", default=""))), + ( + "physical_network", + get_value(segment_range, "physical_network", default=None), + ), + ) + want = { + "network_type": spec["network_type"], + "physical_network": spec.get("physical_network"), + } + for field, have in checks: + if have != want[field]: + return f"{field}: have={have!r} want={want[field]!r}" + return None + + +def _mutable_updates(segment_range: Any, spec: dict[str, Any]) -> dict[str, Any]: + """Return the mutable fields that diverge from *spec*, empty when in sync.""" + updates: dict[str, Any] = {} + + have_min = int(get_value(segment_range, "minimum", default=0)) + have_max = int(get_value(segment_range, "maximum", default=0)) + if have_min != int(spec["minimum"]): + updates["minimum"] = int(spec["minimum"]) + if have_max != int(spec["maximum"]): + updates["maximum"] = int(spec["maximum"]) + + want_shared = bool(spec.get("shared", True)) + if bool(get_value(segment_range, "shared", default=True)) != want_shared: + updates["shared"] = want_shared + + if not want_shared: + want_project = spec.get("project_id") + if get_value(segment_range, "project_id", default=None) != want_project: + updates["project_id"] = want_project + + return updates + + +def _create_kwargs(managed: str, spec: dict[str, Any]) -> dict[str, Any]: + kwargs: dict[str, Any] = { + "name": managed, + "network_type": spec["network_type"], + "minimum": int(spec["minimum"]), + "maximum": int(spec["maximum"]), + "shared": bool(spec.get("shared", True)), + } + if spec.get("physical_network"): + kwargs["physical_network"] = spec["physical_network"] + if not kwargs["shared"] and spec.get("project_id"): + kwargs["project_id"] = spec["project_id"] + return kwargs + + +def render_range(segment_range: Any) -> dict[str, Any]: + """Return the reconciled range as a loggable dict, with the logical name.""" + return { + "id": get_value(segment_range, "id"), + "name": logical_name(str(get_value(segment_range, "name", default=""))), + "network_type": get_value(segment_range, "network_type"), + "physical_network": get_value(segment_range, "physical_network"), + "minimum": get_value(segment_range, "minimum"), + "maximum": get_value(segment_range, "maximum"), + "shared": get_value(segment_range, "shared"), + "project_id": get_value(segment_range, "project_id"), + } + + +def sync_segment_range( + conn: Any, spec: dict[str, Any], cache: RangeCache +) -> list[str]: + """Converge one NeutronSegmentRange spec, returning drift notes.""" + _validate_spec(spec) + + name = str(spec["name"]) + managed = managed_name(name) + existing = find_range(conn, managed, cache) + + if existing is None: + LOG.info( + "Creating segment range %s type=%s physical=%s %s-%s", + name, + spec["network_type"], + spec.get("physical_network"), + spec["minimum"], + spec["maximum"], + ) + created = conn.network.create_network_segment_range( + **_create_kwargs(managed, spec) + ) + cache[managed] = created + LOG.info( + "Reconciled segment range: %s", + json.dumps(render_range(created), sort_keys=True), + ) + return [] + + drift = _immutable_drift(existing, spec) + if drift: + raise ConfigError( + f"Segment range {name!r} already exists in Neutron with a different " + f"immutable field ({drift}). Neutron does not allow updating " + f"network_type or physical_network on an existing range. Rename the " + f"CR or delete the existing range to let the operator recreate it." + ) + + updates = _mutable_updates(existing, spec) + if not updates: + LOG.info("Segment range %s already matches the spec", name) + return [] + + LOG.info("Reconciling segment range %s drift: %s", name, sorted(updates)) + updated = conn.network.update_network_segment_range( + resource_id(existing), **updates + ) + cache[managed] = updated + LOG.info( + "Reconciled segment range: %s", + json.dumps(render_range(updated), sort_keys=True), + ) + return []