|
1 | 1 | """Generic shell-operator hook utilities shared across all hooks. |
2 | 2 |
|
3 | | -Provides binding context I/O and status patching via kubectl. |
| 3 | +Provides binding context I/O and CR status patching. |
4 | 4 | """ |
5 | 5 |
|
6 | 6 | from __future__ import annotations |
|
9 | 9 | import json |
10 | 10 | import logging |
11 | 11 | import os |
12 | | -import subprocess |
13 | 12 | import sys |
14 | 13 | from typing import Any |
15 | 14 |
|
| 15 | +from kubernetes import client as k8s_client |
| 16 | +from kubernetes.client.exceptions import ApiException |
| 17 | + |
| 18 | +from openstack_sync.utils import load_kubernetes_config |
| 19 | + |
16 | 20 | LOG = logging.getLogger(__name__) |
17 | 21 |
|
18 | 22 |
|
@@ -181,27 +185,74 @@ def _status_is_current( |
181 | 185 | return True |
182 | 186 |
|
183 | 187 |
|
| 188 | +#: Memoised CustomObjectsApi, so one config load serves every patch in a run. |
| 189 | +_custom_objects_api: Any = None |
| 190 | + |
| 191 | + |
| 192 | +def _customobjects_api() -> Any: |
| 193 | + """Return the shared ``CustomObjectsApi``, configuring the client once.""" |
| 194 | + global _custom_objects_api |
| 195 | + if _custom_objects_api is None: |
| 196 | + load_kubernetes_config() |
| 197 | + _custom_objects_api = k8s_client.CustomObjectsApi() |
| 198 | + return _custom_objects_api |
| 199 | + |
| 200 | + |
| 201 | +def crd_request_target(crd_api_version: str, crd_resource: str) -> tuple[str, str, str]: |
| 202 | + """Return ``(group, version, plural)`` for addressing a CR. |
| 203 | +
|
| 204 | + Derived from the two environment values the chart already injects, rather |
| 205 | + than from new ones: ``<prefix>_CRD_API_VERSION`` is ``<group>/<version>`` |
| 206 | + and ``<prefix>_CRD_RESOURCE`` is ``<plural>.<group>``, which between them |
| 207 | + carry everything the API needs. |
| 208 | +
|
| 209 | + Raises: |
| 210 | + ValueError: When either value is not in the expected shape. |
| 211 | + """ |
| 212 | + group, _, version = crd_api_version.partition("/") |
| 213 | + plural = crd_resource.partition(".")[0] |
| 214 | + if not group or not version or not plural: |
| 215 | + raise ValueError( |
| 216 | + f"cannot derive a CRD request target from " |
| 217 | + f"api_version={crd_api_version!r} and resource={crd_resource!r}" |
| 218 | + ) |
| 219 | + return group, version, plural |
| 220 | + |
| 221 | + |
| 222 | +def _api_error_detail(exc: ApiException, max_body: int = 512) -> str: |
| 223 | + """Return a one-line description of *exc* for a log message.""" |
| 224 | + body = str(exc.body or "").strip().replace("\n", " ") |
| 225 | + detail = f"HTTP {exc.status} {exc.reason or ''}".strip() |
| 226 | + if not body: |
| 227 | + return detail |
| 228 | + return f"{detail}: {truncate_message(body, max_body)}" |
| 229 | + |
| 230 | + |
184 | 231 | def patch_resource_status( |
185 | 232 | *, |
186 | 233 | name: str, |
187 | 234 | namespace: str | None, |
188 | 235 | generation: int | None, |
189 | 236 | sync_status: str, |
190 | 237 | message: str, |
| 238 | + crd_api_version: str, |
191 | 239 | crd_resource: str, |
192 | 240 | crd_kind: str, |
193 | 241 | status_enabled: bool, |
194 | 242 | current_status: dict[str, Any] | None = None, |
195 | 243 | ) -> None: |
196 | | - """Patch the status subresource of a CR via kubectl. |
| 244 | + """Patch the status subresource of a CR. |
197 | 245 |
|
198 | 246 | Args: |
199 | 247 | name: CR metadata.name. |
200 | | - namespace: CR metadata.namespace (optional). |
| 248 | + namespace: CR metadata.namespace. Required to address the object; the |
| 249 | + patch is skipped with a warning when it is absent. |
201 | 250 | generation: CR metadata.generation for observedGeneration (optional). |
202 | 251 | sync_status: One of ``"Synced"`` or ``"Failed"``. |
203 | 252 | message: Human-readable detail for the status message. |
204 | | - crd_resource: Fully-qualified CRD resource name for kubectl (e.g. |
| 253 | + crd_api_version: CRD API version as ``<group>/<version>`` (e.g. |
| 254 | + ``neutron.understack.rackspace.net/v1alpha1``). |
| 255 | + crd_resource: Fully-qualified CRD resource name (e.g. |
205 | 256 | ``neutronrouterflavors.neutron.understack.rackspace.net``). |
206 | 257 | crd_kind: CRD kind used in log messages (e.g. ``NeutronRouterFlavor``). |
207 | 258 | status_enabled: When False the function returns immediately. |
@@ -231,32 +282,46 @@ def patch_resource_status( |
231 | 282 | if generation is not None: |
232 | 283 | status["observedGeneration"] = generation |
233 | 284 |
|
234 | | - command = [ |
235 | | - "kubectl", |
236 | | - "patch", |
237 | | - crd_resource, |
238 | | - name, |
239 | | - "--type", |
240 | | - "merge", |
241 | | - "--subresource", |
242 | | - "status", |
243 | | - "-p", |
244 | | - json.dumps({"status": status}, sort_keys=True), |
245 | | - ] |
246 | | - if namespace: |
247 | | - command.extend(["-n", namespace]) |
| 285 | + if not namespace: |
| 286 | + LOG.warning( |
| 287 | + "unable to patch %s status for %s; no namespace to address it in", |
| 288 | + crd_kind, |
| 289 | + name, |
| 290 | + ) |
| 291 | + return |
248 | 292 |
|
249 | 293 | try: |
250 | | - result = subprocess.run( # noqa: S603,S607 |
251 | | - command, |
252 | | - capture_output=True, |
253 | | - check=False, |
254 | | - text=True, |
255 | | - ) |
256 | | - except FileNotFoundError: |
257 | | - LOG.warning("kubectl not found; unable to patch %s status", crd_kind) |
| 294 | + group, version, plural = crd_request_target(crd_api_version, crd_resource) |
| 295 | + except ValueError as exc: |
| 296 | + LOG.warning("unable to patch %s status for %s: %s", crd_kind, name, exc) |
258 | 297 | return |
259 | 298 |
|
260 | | - if result.returncode != 0: |
261 | | - error = (result.stderr or result.stdout or "unknown error").strip() |
262 | | - LOG.warning("failed to patch %s status for %s: %s", crd_kind, name, error) |
| 299 | + # The client's default content type here is merge-patch+json, which is what |
| 300 | + # `kubectl patch --type merge --subresource status` sent before this moved |
| 301 | + # off kubectl, so the request on the wire is unchanged. |
| 302 | + try: |
| 303 | + _customobjects_api().patch_namespaced_custom_object_status( |
| 304 | + group=group, |
| 305 | + version=version, |
| 306 | + namespace=namespace, |
| 307 | + plural=plural, |
| 308 | + name=name, |
| 309 | + body={"status": status}, |
| 310 | + ) |
| 311 | + except ApiException as exc: |
| 312 | + LOG.warning( |
| 313 | + "failed to patch %s status for %s: %s", |
| 314 | + crd_kind, |
| 315 | + name, |
| 316 | + _api_error_detail(exc), |
| 317 | + ) |
| 318 | + except Exception as exc: # noqa: BLE001 |
| 319 | + # Config load and transport failures reach here. A status patch is |
| 320 | + # reporting, never the work itself, so it must not fail the reconcile. |
| 321 | + LOG.warning( |
| 322 | + "failed to patch %s status for %s: %s: %s", |
| 323 | + crd_kind, |
| 324 | + name, |
| 325 | + type(exc).__name__, |
| 326 | + exc, |
| 327 | + ) |
0 commit comments