Skip to content

Commit 9c8535a

Browse files
report each Nautobot prefix on NeutronSubnetPool status
1 parent 79e31c2 commit 9c8535a

20 files changed

Lines changed: 572 additions & 54 deletions

‎components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronsubnetpools.yaml‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,23 @@ spec:
350350
description: Message provides details about the last sync attempt.
351351
type: string
352352
maxLength: 2048
353+
prefixes:
354+
description: >-
355+
The Nautobot prefixes resolved for this pool, one entry per
356+
spec.nautobot.prefix_refs item.
357+
type: array
358+
items:
359+
type: object
360+
properties:
361+
id:
362+
description: Nautobot prefix UUID (spec.nautobot.prefix_refs[].id).
363+
type: string
364+
cidr:
365+
description: CIDR Nautobot resolved for this prefix.
366+
type: string
367+
url:
368+
description: Nautobot UI URL for this prefix record.
369+
type: string
353370
conditions:
354371
description: Conditions describe current observed state.
355372
type: array

‎python/openstack-sync/openstack_sync/hooks/framework/__init__.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
from openstack_sync.hooks.framework.contracts import CredentialKey
3131
from openstack_sync.hooks.framework.contracts import HookConfig
3232
from openstack_sync.hooks.framework.contracts import PruneRequest
33+
from openstack_sync.hooks.framework.contracts import ReconcileResult
3334
from openstack_sync.hooks.framework.contracts import SyncPlan
3435
from openstack_sync.hooks.framework.contracts import SyncPlugin
3536
from openstack_sync.hooks.framework.contracts import SyncResource
@@ -51,6 +52,7 @@
5152
"FINALIZER",
5253
"HookConfig",
5354
"PruneRequest",
55+
"ReconcileResult",
5456
"SyncPlan",
5557
"SyncPlugin",
5658
"SyncResource",
@@ -75,14 +77,21 @@
7577

7678

7779
def _patch_status(
78-
plugin: SyncPlugin, resource: SyncResource, sync_status: str, message: str
80+
plugin: SyncPlugin,
81+
resource: SyncResource,
82+
sync_status: str,
83+
message: str,
84+
extra_status: dict[str, Any] | None = None,
85+
reason: str | None = None,
7986
) -> None:
8087
patch_status(
8188
plugin.config,
8289
resource,
8390
sync_status,
8491
message,
8592
patch_resource_status=patch_resource_status,
93+
extra_status=extra_status,
94+
reason=reason,
8695
)
8796

8897

‎python/openstack-sync/openstack_sync/hooks/framework/common.py‎

Lines changed: 44 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,19 +129,25 @@ def truncate_message(message: Any, max_length: int = 2048) -> str:
129129

130130

131131
def _desired_condition(
132-
sync_status: str, message: str, generation: int | None = None
132+
sync_status: str,
133+
message: str,
134+
generation: int | None = None,
135+
reason: str | None = None,
133136
) -> dict[str, Any]:
134137
"""Return the Ready condition to write, without its timestamp.
135138
136139
A CR has one notion of success, so it reports one condition. ``Ready`` is
137140
the name Kubernetes tooling expects: ``kubectl wait --for=condition=Ready``
138141
and kubernetes-entrypoint's ``custom_resources`` dependency both work off it.
142+
143+
``reason`` overrides "ReconcileError" on failure; a synced CR always
144+
reports "Reconciled".
139145
"""
140146
synced = sync_status == "Synced"
141147
condition: dict[str, Any] = {
142148
"type": "Ready",
143149
"status": "True" if synced else "False",
144-
"reason": "Reconciled" if synced else "ReconcileError",
150+
"reason": "Reconciled" if synced else (reason or "ReconcileError"),
145151
"message": truncate_message(message),
146152
}
147153
if generation is not None:
@@ -183,11 +189,23 @@ def _status_is_current(
183189
sync_status: str,
184190
message: str,
185191
generation: int | None,
192+
extra_status: dict[str, Any] | None = None,
193+
reason: str | None = None,
186194
) -> bool:
187195
"""Return True when the existing CR status already matches desired state.
188196
189197
Timestamp fields are intentionally ignored. Rewriting them on every no-op
190198
reconcile creates a Kubernetes Modified event and can requeue the hook.
199+
200+
``extra_status`` is opaque plugin-supplied status (see
201+
:class:`~openstack_sync.hooks.framework.contracts.ReconcileResult`): every
202+
key it carries must already be present on *current* with an equal value,
203+
or the status is not current. A key missing from *current* -- for example
204+
right after a CRD adds a new status field -- counts as a mismatch, so the
205+
one-time backfill patch always goes out rather than being silently skipped
206+
forever.
207+
208+
``reason`` must match too, so a changed reason is not skipped as a no-op.
191209
"""
192210
if not current:
193211
return False
@@ -203,8 +221,15 @@ def _status_is_current(
203221
existing = _ready_condition(current)
204222
if existing is None:
205223
return False
206-
desired = _desired_condition(sync_status, truncated_message, generation)
207-
return all(existing.get(key) == value for key, value in desired.items())
224+
desired = _desired_condition(sync_status, truncated_message, generation, reason)
225+
if not all(existing.get(key) == value for key, value in desired.items()):
226+
return False
227+
228+
if extra_status:
229+
if any(current.get(key) != value for key, value in extra_status.items()):
230+
return False
231+
232+
return True
208233

209234

210235
#: Memoised CustomObjectsApi, so one config load serves every patch in a run.
@@ -528,6 +553,8 @@ def patch_resource_status(
528553
crd_kind: str,
529554
status_enabled: bool,
530555
current_status: dict[str, Any] | None = None,
556+
extra_status: dict[str, Any] | None = None,
557+
reason: str | None = None,
531558
) -> None:
532559
"""Patch the status subresource of a CR.
533560
@@ -550,11 +577,20 @@ def patch_resource_status(
550577
status_enabled: When False the function returns immediately.
551578
current_status: Current CR status from the binding context. When it
552579
already matches the desired stable fields, the patch is skipped.
580+
extra_status: Additional top-level status fields to merge in verbatim,
581+
beyond syncStatus/lastSyncTime/message/conditions/
582+
observedGeneration. Opaque to this function: only the caller's CRD
583+
schema gives these fields meaning. ``None`` or empty writes
584+
nothing extra.
585+
reason: Condition reason to report instead of "ReconcileError" on
586+
failure. Ignored when ``sync_status`` is "Synced".
553587
"""
554588
if not status_enabled:
555589
return
556590

557-
if _status_is_current(current_status, sync_status, message, generation):
591+
if _status_is_current(
592+
current_status, sync_status, message, generation, extra_status, reason
593+
):
558594
LOG.debug(
559595
"skipping %s status patch for %s; status is already current",
560596
crd_kind,
@@ -577,7 +613,7 @@ def patch_resource_status(
577613
return
578614

579615
timestamp = utc_timestamp()
580-
condition = _desired_condition(sync_status, message, generation)
616+
condition = _desired_condition(sync_status, message, generation, reason)
581617
condition["lastTransitionTime"] = _transition_time(
582618
current_status, str(condition["status"]), timestamp
583619
)
@@ -589,6 +625,8 @@ def patch_resource_status(
589625
}
590626
if generation is not None:
591627
status["observedGeneration"] = generation
628+
if extra_status:
629+
status.update(extra_status)
592630

593631
try:
594632
_customobjects_api().patch_namespaced_custom_object_status(

‎python/openstack-sync/openstack_sync/hooks/framework/contracts.py‎

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from abc import ABC
88
from abc import abstractmethod
99
from dataclasses import dataclass
10+
from dataclasses import field
1011
from enum import Enum
1112
from typing import Any
1213

@@ -149,6 +150,21 @@ class PruneRequest:
149150
authoritative_empty: bool
150151

151152

153+
@dataclass(frozen=True)
154+
class ReconcileResult:
155+
"""Outcome of one CR's reconcile.
156+
157+
``notes`` are folded into the status message. ``extra_status`` is merged
158+
into the CR's status subresource verbatim, alongside the framework's own
159+
``syncStatus``/``message``/``conditions`` fields -- the framework never
160+
inspects or validates its contents, so only the plugin and its CRD's
161+
status schema give it meaning.
162+
"""
163+
164+
notes: list[str] = field(default_factory=list)
165+
extra_status: dict[str, Any] | None = None
166+
167+
152168
class CleanupPolicy(Enum):
153169
"""How a plugin wants the framework to handle prune and finalizers."""
154170

@@ -179,13 +195,16 @@ def wait_for_api(self, conn: Any) -> None:
179195
"""Block until the OpenStack service this plugin targets is reachable."""
180196

181197
@abstractmethod
182-
def reconcile(self, conn: Any, spec: dict[str, Any], cache: Any) -> list[str]:
198+
def reconcile(self, conn: Any, spec: dict[str, Any], cache: Any) -> ReconcileResult:
183199
"""Converge one CR spec onto OpenStack.
184200
185-
Returns human-readable notes about state that diverges from the spec but
186-
that the operator cannot correct on its own -- usually empty. Notes do
187-
not make the reconcile a failure; they qualify the success reported on
188-
the CR status. Raise to signal an actual failure.
201+
Returns a :class:`ReconcileResult` carrying human-readable notes about
202+
state that diverges from the spec but that the operator cannot correct
203+
on its own -- usually empty -- plus optional ``extra_status`` fields to
204+
merge into the CR's status subresource, for a plugin whose CRD reports
205+
structured status beyond syncStatus/message/conditions. Notes do not
206+
make the reconcile a failure; they qualify the success reported on the
207+
CR status. Raise to signal an actual failure.
189208
"""
190209

191210
def new_cache(self) -> Any:

‎python/openstack-sync/openstack_sync/hooks/framework/runner.py‎

Lines changed: 42 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import logging
66
from collections.abc import Callable
77
from typing import Any
8+
from typing import Protocol
89

910
from openstack_sync.hooks.framework.contracts import CleanupPolicy
1011
from openstack_sync.hooks.framework.contracts import CredentialKey
@@ -15,10 +16,29 @@
1516
from openstack_sync.hooks.framework.resources import _resource_key
1617
from openstack_sync.hooks.framework.resources import group_by_credentials
1718
from openstack_sync.hooks.framework.status import synced_message
19+
from openstack_sync.plugins.common import ConfigError
1820

1921
LOG = logging.getLogger(__name__)
2022

21-
PatchStatus = Callable[[SyncPlugin, SyncResource, str, str], None]
23+
24+
class PatchStatus(Protocol):
25+
"""Patch one CR's status.
26+
27+
``extra_status`` and ``reason`` default to None: a failed reconcile has
28+
nothing extra to report, and reports the generic failure reason.
29+
"""
30+
31+
def __call__(
32+
self,
33+
plugin: SyncPlugin,
34+
resource: SyncResource,
35+
sync_status: str,
36+
message: str,
37+
extra_status: dict[str, Any] | None = None,
38+
reason: str | None = None,
39+
) -> None: ...
40+
41+
2242
SyncLiveFinalizers = Callable[
2343
[SyncPlugin, list[SyncResource], CleanupPolicy],
2444
set[tuple[str | None, str | None]],
@@ -109,23 +129,31 @@ def run_sync(
109129

110130
for resource in group:
111131
try:
112-
notes = plugin.reconcile(conn, resource.spec, cache)
132+
result = plugin.reconcile(conn, resource.spec, cache)
113133
except Exception as exc: # noqa: BLE001
114134
failed += 1
115-
patch_status(plugin, resource, "Failed", str(exc))
135+
patch_status(
136+
plugin, resource, "Failed", str(exc), reason=_failure_reason(exc)
137+
)
116138
LOG.error(
117139
"Failed to reconcile %s %s: %s", noun, resource.display_name, exc
118140
)
119141
continue
120142

121-
if notes:
143+
if result.notes:
122144
LOG.warning(
123145
"%s %s converged but needs manual action: %s",
124146
noun.capitalize(),
125147
resource.display_name,
126-
"; ".join(notes),
148+
"; ".join(result.notes),
127149
)
128-
patch_status(plugin, resource, "Synced", synced_message(noun, notes))
150+
patch_status(
151+
plugin,
152+
resource,
153+
"Synced",
154+
synced_message(noun, result.notes),
155+
extra_status=result.extra_status,
156+
)
129157

130158
if failed or unreadable:
131159
# Pruning deletes resources absent from the desired set. A CR that
@@ -173,3 +201,11 @@ def _fail_group(
173201
) -> None:
174202
for resource in group:
175203
patch_status(plugin, resource, "Failed", message)
204+
205+
206+
def _failure_reason(exc: Exception) -> str | None:
207+
"""Return the reason a ConfigError names, or None for any other error."""
208+
if not isinstance(exc, ConfigError):
209+
return None
210+
reason = exc.reason
211+
return reason if isinstance(reason, str) and reason else None

‎python/openstack-sync/openstack_sync/hooks/framework/status.py‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,17 @@ def patch_status(
1818
message: str,
1919
*,
2020
patch_resource_status: Callable[..., None],
21+
extra_status: dict[str, object] | None = None,
22+
reason: str | None = None,
2123
) -> None:
22-
"""Patch one CR status with the sync outcome."""
24+
"""Patch one CR status with the sync outcome.
25+
26+
``extra_status`` and ``reason`` are passed straight through to
27+
*patch_resource_status*; see
28+
:class:`~openstack_sync.hooks.framework.contracts.ReconcileResult` for
29+
what ``extra_status`` is for. A failed reconcile has none to report, so
30+
that call site omits it.
31+
"""
2332
if not resource.name:
2433
LOG.error(
2534
"Unable to patch %s status; Kubernetes metadata.name is missing",
@@ -37,6 +46,8 @@ def patch_status(
3746
crd_kind=config.crd_kind,
3847
status_enabled=config.status_enabled,
3948
current_status=resource.current_status,
49+
extra_status=extra_status,
50+
reason=reason,
4051
)
4152

4253

‎python/openstack-sync/openstack_sync/hooks/ironic_runbooks.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from openstack_sync.hooks.framework import HookConfig
1010
from openstack_sync.hooks.framework import PruneRequest
11+
from openstack_sync.hooks.framework import ReconcileResult
1112
from openstack_sync.hooks.framework import SyncPlugin
1213
from openstack_sync.hooks.framework import build_crd_hook_config
1314
from openstack_sync.hooks.framework import hook_enabled
@@ -33,8 +34,9 @@ def wait_for_api(self, conn: Any) -> None:
3334
delay=self.config.ready_delay,
3435
)
3536

36-
def reconcile(self, conn: Any, spec: dict[str, Any], cache: Any) -> list[str]:
37-
return reconcile_module.sync_runbook(conn, spec, cache)
37+
def reconcile(self, conn: Any, spec: dict[str, Any], cache: Any) -> ReconcileResult:
38+
notes = reconcile_module.sync_runbook(conn, spec, cache)
39+
return ReconcileResult(notes=notes)
3840

3941
def prune_resources(self, conn: Any, request: PruneRequest) -> None:
4042
prune_module.prune_removed_runbooks(

‎python/openstack-sync/openstack_sync/hooks/router_flavors.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from openstack_sync.hooks.framework import CleanupPolicy
1010
from openstack_sync.hooks.framework import HookConfig
1111
from openstack_sync.hooks.framework import PruneRequest
12+
from openstack_sync.hooks.framework import ReconcileResult
1213
from openstack_sync.hooks.framework import SyncPlugin
1314
from openstack_sync.hooks.framework import build_crd_hook_config
1415
from openstack_sync.hooks.framework import hook_enabled
@@ -42,8 +43,9 @@ def new_cache(self) -> reconcile_module.ProfileCache:
4243

4344
def reconcile(
4445
self, conn: Any, spec: dict[str, Any], cache: reconcile_module.ProfileCache
45-
) -> list[str]:
46-
return reconcile_module.sync_flavor(conn, spec, cache)
46+
) -> ReconcileResult:
47+
notes = reconcile_module.sync_flavor(conn, spec, cache)
48+
return ReconcileResult(notes=notes)
4749

4850
def prune_resources(self, conn: Any, request: PruneRequest) -> None:
4951
if self.cleanup_policy() is CleanupPolicy.BEST_EFFORT_PRUNE:

0 commit comments

Comments
 (0)