@@ -129,19 +129,25 @@ def truncate_message(message: Any, max_length: int = 2048) -> str:
129129
130130
131131def _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 (
0 commit comments