Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,23 @@ spec:
description: Message provides details about the last sync attempt.
type: string
maxLength: 2048
prefixes:
description: >-
The Nautobot prefixes resolved for this pool, one entry per
spec.nautobot.prefix_refs item.
type: array
items:
type: object
properties:
id:
description: Nautobot prefix UUID (spec.nautobot.prefix_refs[].id).
type: string
cidr:
description: CIDR Nautobot resolved for this prefix.
type: string
url:
description: Nautobot UI URL for this prefix record.
type: string
conditions:
description: Conditions describe current observed state.
type: array
Expand Down
11 changes: 10 additions & 1 deletion python/openstack-sync/openstack_sync/hooks/framework/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
from openstack_sync.hooks.framework.contracts import CredentialKey
from openstack_sync.hooks.framework.contracts import HookConfig
from openstack_sync.hooks.framework.contracts import PruneRequest
from openstack_sync.hooks.framework.contracts import ReconcileResult
from openstack_sync.hooks.framework.contracts import SyncPlan
from openstack_sync.hooks.framework.contracts import SyncPlugin
from openstack_sync.hooks.framework.contracts import SyncResource
Expand All @@ -51,6 +52,7 @@
"FINALIZER",
"HookConfig",
"PruneRequest",
"ReconcileResult",
"SyncPlan",
"SyncPlugin",
"SyncResource",
Expand All @@ -75,14 +77,21 @@


def _patch_status(
plugin: SyncPlugin, resource: SyncResource, sync_status: str, message: str
plugin: SyncPlugin,
resource: SyncResource,
sync_status: str,
message: str,
extra_status: dict[str, Any] | None = None,
reason: str | None = None,
) -> None:
patch_status(
plugin.config,
resource,
sync_status,
message,
patch_resource_status=patch_resource_status,
extra_status=extra_status,
reason=reason,
)


Expand Down
50 changes: 44 additions & 6 deletions python/openstack-sync/openstack_sync/hooks/framework/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,19 +129,25 @@ def truncate_message(message: Any, max_length: int = 2048) -> str:


def _desired_condition(
sync_status: str, message: str, generation: int | None = None
sync_status: str,
message: str,
generation: int | None = None,
reason: str | None = None,
) -> dict[str, Any]:
"""Return the Ready condition to write, without its timestamp.

A CR has one notion of success, so it reports one condition. ``Ready`` is
the name Kubernetes tooling expects: ``kubectl wait --for=condition=Ready``
and kubernetes-entrypoint's ``custom_resources`` dependency both work off it.

``reason`` overrides "ReconcileError" on failure; a synced CR always
reports "Reconciled".
"""
synced = sync_status == "Synced"
condition: dict[str, Any] = {
"type": "Ready",
"status": "True" if synced else "False",
"reason": "Reconciled" if synced else "ReconcileError",
"reason": "Reconciled" if synced else (reason or "ReconcileError"),
"message": truncate_message(message),
}
if generation is not None:
Expand Down Expand Up @@ -183,11 +189,23 @@ def _status_is_current(
sync_status: str,
message: str,
generation: int | None,
extra_status: dict[str, Any] | None = None,
reason: str | None = 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.

``extra_status`` is opaque plugin-supplied status (see
:class:`~openstack_sync.hooks.framework.contracts.ReconcileResult`): every
key it carries must already be present on *current* with an equal value,
or the status is not current. A key missing from *current* -- for example
right after a CRD adds a new status field -- counts as a mismatch, so the
one-time backfill patch always goes out rather than being silently skipped
forever.

``reason`` must match too, so a changed reason is not skipped as a no-op.
"""
if not current:
return False
Expand All @@ -203,8 +221,15 @@ def _status_is_current(
existing = _ready_condition(current)
if existing is None:
return False
desired = _desired_condition(sync_status, truncated_message, generation)
return all(existing.get(key) == value for key, value in desired.items())
desired = _desired_condition(sync_status, truncated_message, generation, reason)
if not all(existing.get(key) == value for key, value in desired.items()):
return False

if extra_status:
if any(current.get(key) != value for key, value in extra_status.items()):
return False

return True


#: Memoised CustomObjectsApi, so one config load serves every patch in a run.
Expand Down Expand Up @@ -528,6 +553,8 @@ def patch_resource_status(
crd_kind: str,
status_enabled: bool,
current_status: dict[str, Any] | None = None,
extra_status: dict[str, Any] | None = None,
reason: str | None = None,
) -> None:
"""Patch the status subresource of a CR.

Expand All @@ -550,11 +577,20 @@ def patch_resource_status(
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.
extra_status: Additional top-level status fields to merge in verbatim,
beyond syncStatus/lastSyncTime/message/conditions/
observedGeneration. Opaque to this function: only the caller's CRD
schema gives these fields meaning. ``None`` or empty writes
nothing extra.
reason: Condition reason to report instead of "ReconcileError" on
failure. Ignored when ``sync_status`` is "Synced".
"""
if not status_enabled:
return

if _status_is_current(current_status, sync_status, message, generation):
if _status_is_current(
current_status, sync_status, message, generation, extra_status, reason
):
LOG.debug(
"skipping %s status patch for %s; status is already current",
crd_kind,
Expand All @@ -577,7 +613,7 @@ def patch_resource_status(
return

timestamp = utc_timestamp()
condition = _desired_condition(sync_status, message, generation)
condition = _desired_condition(sync_status, message, generation, reason)
condition["lastTransitionTime"] = _transition_time(
current_status, str(condition["status"]), timestamp
)
Expand All @@ -589,6 +625,8 @@ def patch_resource_status(
}
if generation is not None:
status["observedGeneration"] = generation
if extra_status:
status.update(extra_status)

try:
_customobjects_api().patch_namespaced_custom_object_status(
Expand Down
29 changes: 24 additions & 5 deletions python/openstack-sync/openstack_sync/hooks/framework/contracts.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from abc import ABC
from abc import abstractmethod
from dataclasses import dataclass
from dataclasses import field
from enum import Enum
from typing import Any

Expand Down Expand Up @@ -149,6 +150,21 @@ class PruneRequest:
authoritative_empty: bool


@dataclass(frozen=True)
class ReconcileResult:
"""Outcome of one CR's reconcile.

``notes`` are folded into the status message. ``extra_status`` is merged
into the CR's status subresource verbatim, alongside the framework's own
``syncStatus``/``message``/``conditions`` fields -- the framework never
inspects or validates its contents, so only the plugin and its CRD's
status schema give it meaning.
"""

notes: list[str] = field(default_factory=list)
extra_status: dict[str, Any] | None = None


class CleanupPolicy(Enum):
"""How a plugin wants the framework to handle prune and finalizers."""

Expand Down Expand Up @@ -179,13 +195,16 @@ 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]:
def reconcile(self, conn: Any, spec: dict[str, Any], cache: Any) -> ReconcileResult:
"""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.
Returns a :class:`ReconcileResult` carrying human-readable notes about
state that diverges from the spec but that the operator cannot correct
on its own -- usually empty -- plus optional ``extra_status`` fields to
merge into the CR's status subresource, for a plugin whose CRD reports
structured status beyond syncStatus/message/conditions. 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:
Expand Down
48 changes: 42 additions & 6 deletions python/openstack-sync/openstack_sync/hooks/framework/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import logging
from collections.abc import Callable
from typing import Any
from typing import Protocol

from openstack_sync.hooks.framework.contracts import CleanupPolicy
from openstack_sync.hooks.framework.contracts import CredentialKey
Expand All @@ -15,10 +16,29 @@
from openstack_sync.hooks.framework.resources import _resource_key
from openstack_sync.hooks.framework.resources import group_by_credentials
from openstack_sync.hooks.framework.status import synced_message
from openstack_sync.plugins.common import ConfigError

LOG = logging.getLogger(__name__)

PatchStatus = Callable[[SyncPlugin, SyncResource, str, str], None]

class PatchStatus(Protocol):
"""Patch one CR's status.

``extra_status`` and ``reason`` default to None: a failed reconcile has
nothing extra to report, and reports the generic failure reason.
"""

def __call__(
self,
plugin: SyncPlugin,
resource: SyncResource,
sync_status: str,
message: str,
extra_status: dict[str, Any] | None = None,
reason: str | None = None,
) -> None: ...


SyncLiveFinalizers = Callable[
[SyncPlugin, list[SyncResource], CleanupPolicy],
set[tuple[str | None, str | None]],
Expand Down Expand Up @@ -109,23 +129,31 @@ def run_sync(

for resource in group:
try:
notes = plugin.reconcile(conn, resource.spec, cache)
result = plugin.reconcile(conn, resource.spec, cache)
except Exception as exc: # noqa: BLE001
failed += 1
patch_status(plugin, resource, "Failed", str(exc))
patch_status(
plugin, resource, "Failed", str(exc), reason=_failure_reason(exc)
)
LOG.error(
"Failed to reconcile %s %s: %s", noun, resource.display_name, exc
)
continue

if notes:
if result.notes:
LOG.warning(
"%s %s converged but needs manual action: %s",
noun.capitalize(),
resource.display_name,
"; ".join(notes),
"; ".join(result.notes),
)
patch_status(plugin, resource, "Synced", synced_message(noun, notes))
patch_status(
plugin,
resource,
"Synced",
synced_message(noun, result.notes),
extra_status=result.extra_status,
)

if failed or unreadable:
# Pruning deletes resources absent from the desired set. A CR that
Expand Down Expand Up @@ -173,3 +201,11 @@ def _fail_group(
) -> None:
for resource in group:
patch_status(plugin, resource, "Failed", message)


def _failure_reason(exc: Exception) -> str | None:
"""Return the reason a ConfigError names, or None for any other error."""
if not isinstance(exc, ConfigError):
return None
reason = exc.reason
return reason if isinstance(reason, str) and reason else None
13 changes: 12 additions & 1 deletion python/openstack-sync/openstack_sync/hooks/framework/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,17 @@ def patch_status(
message: str,
*,
patch_resource_status: Callable[..., None],
extra_status: dict[str, object] | None = None,
reason: str | None = None,
) -> None:
"""Patch one CR status with the sync outcome."""
"""Patch one CR status with the sync outcome.

``extra_status`` and ``reason`` are passed straight through to
*patch_resource_status*; see
:class:`~openstack_sync.hooks.framework.contracts.ReconcileResult` for
what ``extra_status`` is for. A failed reconcile has none to report, so
that call site omits it.
"""
if not resource.name:
LOG.error(
"Unable to patch %s status; Kubernetes metadata.name is missing",
Expand All @@ -37,6 +46,8 @@ def patch_status(
crd_kind=config.crd_kind,
status_enabled=config.status_enabled,
current_status=resource.current_status,
extra_status=extra_status,
reason=reason,
)


Expand Down
6 changes: 4 additions & 2 deletions python/openstack-sync/openstack_sync/hooks/ironic_runbooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from openstack_sync.hooks.framework import HookConfig
from openstack_sync.hooks.framework import PruneRequest
from openstack_sync.hooks.framework import ReconcileResult
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
Expand All @@ -33,8 +34,9 @@ def wait_for_api(self, conn: Any) -> None:
delay=self.config.ready_delay,
)

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

def prune_resources(self, conn: Any, request: PruneRequest) -> None:
prune_module.prune_removed_runbooks(
Expand Down
6 changes: 4 additions & 2 deletions python/openstack-sync/openstack_sync/hooks/router_flavors.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from openstack_sync.hooks.framework import CleanupPolicy
from openstack_sync.hooks.framework import HookConfig
from openstack_sync.hooks.framework import PruneRequest
from openstack_sync.hooks.framework import ReconcileResult
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
Expand Down Expand Up @@ -42,8 +43,9 @@ def new_cache(self) -> reconcile_module.ProfileCache:

def reconcile(
self, conn: Any, spec: dict[str, Any], cache: reconcile_module.ProfileCache
) -> list[str]:
return reconcile_module.sync_flavor(conn, spec, cache)
) -> ReconcileResult:
notes = reconcile_module.sync_flavor(conn, spec, cache)
return ReconcileResult(notes=notes)

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