diff --git a/components/openstack-sync-operator/crds/baremetal.ironicproject.org_ironicrunbooks.yaml b/components/openstack-sync-operator/crds/baremetal.ironicproject.org_ironicrunbooks.yaml new file mode 100644 index 000000000..82736c1db --- /dev/null +++ b/components/openstack-sync-operator/crds/baremetal.ironicproject.org_ironicrunbooks.yaml @@ -0,0 +1,244 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: ironicrunbooks.baremetal.ironicproject.org +spec: + group: baremetal.ironicproject.org + names: + kind: IronicRunbook + listKind: IronicRunbookList + plural: ironicrunbooks + shortNames: + - rb + singular: ironicrunbook + scope: Namespaced + versions: + - name: v1alpha1 + served: true + storage: true + additionalPrinterColumns: + - name: Runbook Name + type: string + jsonPath: .spec.runbookName + - name: Description + type: string + jsonPath: .spec.description + priority: 1 + - name: Public + type: boolean + jsonPath: .spec.public + - name: SyncStatus + type: string + jsonPath: .status.syncStatus + - name: Age + type: date + jsonPath: .metadata.creationTimestamp + schema: + openAPIV3Schema: + description: IronicRunbook represents a collection of ordered steps that define automated operations on baremetal nodes. + type: object + required: + - spec + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + description: IronicRunbookSpec defines the desired state of IronicRunbook. + type: object + required: + - runbookName + - steps + - 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 + runbookName: + description: >- + RunbookName is the unique name of the runbook. From API + microversion 1.112+, this is a logical identifier and can be + any string of 1-255 characters. Node eligibility is + determined by the traits field instead. + type: string + pattern: ^[A-Za-z0-9._-]+$ + minLength: 1 + maxLength: 255 + description: + description: >- + Description is a human-readable description of the runbook. + Available from API microversion 1.112 onwards. + type: string + nullable: true + maxLength: 1000 + traits: + description: >- + Traits is a list of traits that determine which nodes are + permitted to use this runbook. Decouples runbook eligibility + from the runbook name. Each trait must follow the CUSTOM_* + naming convention. Available from API microversion 1.112 + onwards. + type: array + default: [] + items: + type: string + pattern: ^CUSTOM_[A-Z0-9_]+$ + minLength: 1 + maxLength: 255 + steps: + description: Steps is an ordered list of operations to execute. Minimum 1 step required. + type: array + minItems: 1 + items: + description: RunbookStep defines a single step in the runbook. + type: object + required: + - interface + - step + - order + properties: + interface: + description: >- + Interface specifies which hardware interface handles + this step. Must be one of the valid Ironic cleaning + interfaces. + type: string + enum: + - bios + - raid + - deploy + - management + - power + - storage + - vendor + - rescue + - console + - boot + - inspect + - network + - firmware + step: + description: >- + Step is the name of the step to execute. Must be a + valid step name for the specified interface. + type: string + minLength: 1 + maxLength: 255 + order: + description: >- + Order defines the execution sequence. Must be >= 0 and + unique within the runbook. Lower numbers execute + first. + type: integer + minimum: 0 + args: + description: >- + Args contains step-specific arguments. Structure + depends on the interface and step. + type: object + x-kubernetes-preserve-unknown-fields: true + disableRamdisk: + description: >- + DisableRamdisk skips booting the ramdisk for cleaning + operations. Use when steps can run without IPA (Ironic + Python Agent). + type: boolean + default: false + public: + description: >- + Public makes the runbook accessible to all projects/tenants. + Cannot be true if owner is set. + type: boolean + default: false + owner: + description: >- + Owner identifies the project/tenant that owns this runbook. + Cannot be set if public is true. + type: string + nullable: true + maxLength: 255 + extra: + description: >- + Extra contains additional metadata. Use for descriptions, + versions, maintainer info, etc. + type: object + x-kubernetes-preserve-unknown-fields: true + status: + description: IronicRunbookStatus defines the observed sync state. + type: object + properties: + ironicUUID: + description: IronicUUID is the UUID of this runbook in the Ironic API. + type: string + syncStatus: + description: SyncStatus indicates the synchronization state with Ironic. + type: string + enum: + - Synced + - Failed + - Unknown + lastSyncTime: + description: LastSyncTime is the last time the operator attempted to sync the runbook. + 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 ab89eed9b..cd18bf789 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 + ironicRunbooks: true pluginData: openstackPlaceholder: @@ -52,6 +53,15 @@ pluginData: DEFAULT_SECRET: infrasetup DEFAULT_CLOUD: understack + ironicRunbooks: + hook: + path: /hooks/ironic_runbooks.py + crd: crds/baremetal.ironicproject.org_ironicrunbooks.yaml + envPrefix: IRONIC_RUNBOOK + env: + DEFAULT_SECRET: infrasetup + DEFAULT_CLOUD: understack + hooks: {} podAnnotations: {} diff --git a/containers/openstack-sync-operator/Dockerfile b/containers/openstack-sync-operator/Dockerfile index 698876cad..25cd950c5 100644 --- a/containers/openstack-sync-operator/Dockerfile +++ b/containers/openstack-sync-operator/Dockerfile @@ -18,3 +18,4 @@ RUN --mount=type=cache,target=/root/.cache/uv \ COPY --chmod=755 python/openstack-sync/openstack_sync/hooks/placeholder.py /hooks/placeholder.py COPY --chmod=755 python/openstack-sync/openstack_sync/hooks/router_flavors.py /hooks/router_flavors.py +COPY --chmod=755 python/openstack-sync/openstack_sync/hooks/ironic_runbooks.py /hooks/ironic_runbooks.py diff --git a/python/openstack-sync/openstack_sync/hooks/ironic_runbooks.py b/python/openstack-sync/openstack_sync/hooks/ironic_runbooks.py new file mode 100644 index 000000000..0bc9d91d1 --- /dev/null +++ b/python/openstack-sync/openstack_sync/hooks/ironic_runbooks.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +"""Shell-operator hook for Ironic runbook connectivity verification. + +When ``IRONIC_RUNBOOK_ENABLED`` is ``true`` this hook runs on startup to +verify that the operator can authenticate against OpenStack using the +credentials that will be used for Ironic runbook reconciliation. When the +flag is ``false`` (the default) the hook registers only an ``onStartup`` +binding so the base image satisfies shell-operator's requirement for at +least one binding without needing any Kubernetes watches or extra RBAC. + +This is a connectivity placeholder. Full reconciliation of ``IronicRunbook`` +custom resources against Ironic (create/update/delete, trait management) is +not implemented here yet. +""" + +from __future__ import annotations + +import json +import os +import sys +from typing import Any + +from openstack_sync.utils import get_openstack_connection + +TRUTHY_VALUES = {"1", "true", "yes", "on"} + + +def env_is_truthy(name: str, default: str = "false") -> bool: + return os.environ.get(name, default).lower() in TRUTHY_VALUES + + +def build_hook_config() -> dict[str, Any]: + hook_config: dict[str, Any] = { + "configVersion": "v1", + "settings": { + "executionMinInterval": "30s", + "executionBurst": 1, + }, + "onStartup": 10, + } + return hook_config + + +HOOK_CONFIG = build_hook_config() + + +def check_openstack_connectivity() -> None: + """Attempt to authenticate against OpenStack and log the result. + + Reads credentials from the Kubernetes Secret named by + ``IRONIC_RUNBOOK_DEFAULT_SECRET`` using the cloud entry + ``IRONIC_RUNBOOK_DEFAULT_CLOUD``. + + Raises: + Exception: Re-raises any connection failure after logging it. + """ + secret_name = os.environ.get("IRONIC_RUNBOOK_DEFAULT_SECRET") + cloud_name = os.environ.get("IRONIC_RUNBOOK_DEFAULT_CLOUD") + + print( + f"connectivity check: authenticating against cloud={cloud_name!r} " + f"secret={secret_name!r}", + flush=True, + ) + conn = get_openstack_connection(secret_name, cloud_name) + # Lightweight probe: check_token(str) -> bool confirms the token is valid + # and Keystone is reachable without any side effects. + conn.identity.check_token(conn.auth_token) + print( + f"connectivity check: OK cloud={cloud_name!r} secret={secret_name!r}", + flush=True, + ) + + +def main() -> int: + if len(sys.argv) > 1 and sys.argv[1] == "--config": + print(json.dumps(build_hook_config(), indent=2)) + return 0 + + context_path = os.environ.get("BINDING_CONTEXT_PATH") + if not context_path: + return 0 + with open(context_path) as f: + raw = f.read() + if not raw.strip(): + return 0 + + try: + binding_contexts = json.loads(raw) + except json.JSONDecodeError as exc: + print(f"failed to parse binding context: {exc}", file=sys.stderr) + return 1 + + for context in binding_contexts: + # Shell-operator passes [{"binding": "onStartup"}] for startup runs. + if context.get("binding") == "onStartup": + if not env_is_truthy("IRONIC_RUNBOOK_ENABLED"): + print( + "connectivity check: skipped" + " (IRONIC_RUNBOOK_ENABLED is not set)", + flush=True, + ) + continue + try: + check_openstack_connectivity() + except Exception as exc: # noqa: BLE001 + print( + f"connectivity check FAILED: {exc}", + file=sys.stderr, + flush=True, + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/openstack-sync/tests/test_ironic_runbooks.py b/python/openstack-sync/tests/test_ironic_runbooks.py new file mode 100644 index 000000000..51e1b0f2f --- /dev/null +++ b/python/openstack-sync/tests/test_ironic_runbooks.py @@ -0,0 +1,24 @@ +"""Tests for the Ironic runbooks placeholder hook.""" + +from __future__ import annotations + +import json +from unittest import mock + +from openstack_sync.hooks import ironic_runbooks + + +def test_ironic_runbooks_hook_config(capsys): + with mock.patch.object( + ironic_runbooks.sys, "argv", ["ironic_runbooks.py", "--config"] + ): + assert ironic_runbooks.main() == 0 + + config = json.loads(capsys.readouterr().out) + assert config == ironic_runbooks.HOOK_CONFIG + assert config["onStartup"] == 10 + + +def test_ironic_runbooks_hook_run_is_noop(): + with mock.patch.object(ironic_runbooks.sys, "argv", ["ironic_runbooks.py"]): + assert ironic_runbooks.main() == 0