diff --git a/charts/site-workflows/templates/sensor-keystone-automation-user-upsert.yaml b/charts/site-workflows/templates/sensor-keystone-automation-user-upsert.yaml index 887e84536..53c074012 100644 --- a/charts/site-workflows/templates/sensor-keystone-automation-user-upsert.yaml +++ b/charts/site-workflows/templates/sensor-keystone-automation-user-upsert.yaml @@ -75,6 +75,10 @@ spec: infra-readwrite) openstack role add --user "${SVC_ID}" --project-domain infra --project baremetal admin ;; + system-readwrite) + openstack role add --user "${SVC_ID}" --system all reader + openstack role add --user "${SVC_ID}" --system all member + ;; *) echo "Invalid role ${svc_role}" ;; diff --git a/components/ironic/kustomization.yaml b/components/ironic/kustomization.yaml index 897b75690..fe935660f 100644 --- a/components/ironic/kustomization.yaml +++ b/components/ironic/kustomization.yaml @@ -11,8 +11,6 @@ resources: # less than ideal addition but necessary so that we can have the ironic.conf.d loading # working due to the way the chart hardcodes the config-file parameter which then # takes precedence over the directory - - ./runbook-crd - - ./runbook-operator # Alerting - pr-clean-failed-servers.yaml - pr-resource-availability.yaml diff --git a/components/ironic/runbook-crd/README.md b/components/ironic/runbook-crd/README.md deleted file mode 100644 index da43dbb71..000000000 --- a/components/ironic/runbook-crd/README.md +++ /dev/null @@ -1,154 +0,0 @@ -# Ironic Runbook Kubernetes CRD - -Kubernetes Custom Resource Definition (CRD) for managing Ironic baremetal runbooks. Runbooks define automated sequences of operations (cleaning, configuration, firmware updates) to be executed on baremetal nodes. - -## What is a Runbook? - -A Runbook is a collection of ordered steps that define automated operations on baremetal nodes in Ironic. Runbooks enable: - -- **Automated Cleaning**: Prepare nodes for reuse (disk wiping, BIOS config, firmware updates) -- **Declarative Workflows**: Define repeatable, version-controlled sequences -- **Trait-Based Matching**: Runbooks match to nodes when the runbook name matches a node trait - -## Quick Start - -### Installation - -```bash -# Install the CRD -kubectl apply -f bases/baremetal.ironicproject.org_runbooks.yaml -``` - -### Create Your First Runbook - -```bash -# Apply a minimal example -kubectl apply -f samples/runbook_v1alpha1_minimal.yaml - -# Verify it was created -kubectl get runbooks -kubectl describe runbook minimal-runbook -``` - -### View Available Samples - -```bash -# List all sample runbooks -ls samples/ - -# Apply a specific sample -kubectl apply -f samples/runbook_bios_config.yaml -``` - -## Field Requirements - -### ✅ Required Fields - -| Field | Type | Description | -|-------|------|-------------| -| `spec.runbookName` | string | Runbook name matching CUSTOM_* pattern | -| `spec.steps` | array | Ordered list of steps (minimum 1) | -| `steps[].interface` | enum | Hardware interface (bios, raid, deploy, etc.) | -| `steps[].step` | string | Step name (non-empty) | -| `steps[].order` | integer | Execution order (>= 0, unique) | - -### ❌ Optional Fields - -| Field | Type | Default | Description | -|-------|------|---------|-------------| -| `spec.disableRamdisk` | boolean | `false` | Skip ramdisk booting | -| `spec.public` | boolean | `false` | Public accessibility | -| `spec.owner` | string | `null` | Project/tenant owner | -| `spec.extra` | object | `{}` | Additional metadata | -| `steps[].args` | object | `{}` | Step-specific arguments | - -## Minimal Example - -```yaml -apiVersion: baremetal.ironicproject.org/v1alpha1 -kind: IronicRunbook -metadata: - name: minimal-runbook - namespace: default -spec: - runbookName: CUSTOM_MINIMAL - steps: - - interface: deploy - step: erase_devices - order: 1 -``` - -## Sample Runbooks - -| Sample | Use Case | Description | -|--------|----------|-------------| -| `runbook_v1alpha1_minimal.yaml` | Learning | Minimal example with required fields only | -| `runbook_v1alpha1_complete.yaml` | Reference | Complete example with all fields | -| `runbook_bios_config.yaml` | Compute Nodes | BIOS configuration for virtualization | -| `runbook_raid_config.yaml` | Storage Nodes | RAID setup (OS + data volumes) | -| `runbook_firmware_update.yaml` | Maintenance | Firmware updates (BIOS, BMC, NIC) | -| `runbook_disk_cleaning.yaml` | Node Reuse | Secure disk erasure | -| `runbook_gpu_node_setup.yaml` | ML/AI | GPU node configuration | - -## Running a Runbook - -Once the operator syncs the CRD into Ironic, you can execute a runbook against -a node using one of two CLI commands depending on the node's current -provisioning state: - -- **`node clean --runbook`** — node must be in `manageable` state -- **`node service --runbook`** — node must be in `active` or `available` state - -### OpenStack CLI - -```bash -# For nodes in 'manageable' state -openstack baremetal node clean --runbook CUSTOM_BMC_MAINTENANCE - -# For nodes in 'active' or 'available' state -openstack baremetal node service --runbook CUSTOM_BMC_MAINTENANCE - -# Check node state while the runbook executes -openstack baremetal node show -f value -c provision_state -``` - -### Python SDK - -```python -from understack_workflows.ironic_node import transition - -# node must already be in manageable state -transition( - node, - "clean", - expected_state="manageable", - runbook=runbook_uuid, -) -``` - -The `transition` helper calls `set_node_provision_state` and waits for the -node to return to `manageable` once all steps complete. - -### Trait-Based Automatic Execution - -Runbooks can also be triggered automatically by matching node traits. Add the -runbook name as a trait on the node: - -```bash -openstack baremetal node add trait CUSTOM_BMC_MAINTENANCE -``` - -Workflow code (e.g. `apply_firmware_updates` in `ironic_node.py`) can then -discover matching traits and execute the corresponding runbooks in order. - -## Support - -- **Ironic Documentation**: https://docs.openstack.org/ironic/latest/ -- **Kubernetes CRDs**: https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/ - ---- - -**Version**: v1alpha1 -**API Group**: baremetal.ironicproject.org -**Kind**: IronicRunbook -**Short Name**: rb diff --git a/components/ironic/runbook-crd/bases/baremetal.ironicproject.org_runbooks.yaml b/components/ironic/runbook-crd/bases/baremetal.ironicproject.org_runbooks.yaml deleted file mode 100644 index 437ceafd2..000000000 --- a/components/ironic/runbook-crd/bases/baremetal.ironicproject.org_runbooks.yaml +++ /dev/null @@ -1,198 +0,0 @@ -apiVersion: apiextensions.k8s.io/v1 -kind: CustomResourceDefinition -metadata: - name: ironicrunbooks.baremetal.ironicproject.org - annotations: - controller-gen.kubebuilder.io/version: v0.13.0 -spec: - group: baremetal.ironicproject.org - names: - kind: IronicRunbook - listKind: IronicRunbookList - plural: ironicrunbooks - singular: ironicrunbook - shortNames: - - rb - scope: Namespaced - versions: - - name: v1alpha1 - served: true - storage: true - schema: - openAPIV3Schema: - description: IronicRunbook represents a collection of ordered steps that define automated operations on baremetal nodes - type: object - required: - - spec - properties: - apiVersion: - description: 'APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' - type: string - kind: - description: 'Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' - type: string - metadata: - type: object - spec: - description: IronicRunbookSpec defines the desired state of IronicRunbook - type: object - required: - - runbookName - - steps - properties: - runbookName: - description: 'RunbookName is the unique name of the runbook (REQUIRED). 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 (OPTIONAL). Consistent with other Ironic objects. 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 (OPTIONAL). Decouples runbook eligibility from the runbook name. Each trait must follow the CUSTOM_* naming convention. Available from API microversion 1.112 onwards. Default: []' - 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 (REQUIRED). 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 (REQUIRED). 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 (REQUIRED). Must be a valid step name for the specified interface.' - type: string - minLength: 1 - maxLength: 255 - order: - description: 'Order defines the execution sequence (REQUIRED). Must be >= 0 and unique within the runbook. Lower numbers execute first.' - type: integer - minimum: 0 - args: - description: 'Args contains step-specific arguments (OPTIONAL). Structure depends on the interface and step. Default: {}' - type: object - x-kubernetes-preserve-unknown-fields: true - disableRamdisk: - description: 'DisableRamdisk skips booting the ramdisk for cleaning operations (OPTIONAL). Use when steps can run without IPA (Ironic Python Agent). Default: false' - type: boolean - default: false - public: - description: 'Public makes the runbook accessible to all projects/tenants (OPTIONAL). Cannot be true if owner is set. Default: false' - type: boolean - default: false - owner: - description: 'Owner identifies the project/tenant that owns this runbook (OPTIONAL). Cannot be set if public is true. Default: null' - type: string - nullable: true - maxLength: 255 - extra: - description: 'Extra contains additional metadata (OPTIONAL). Use for descriptions, versions, maintainer info, etc. Default: {}' - type: object - x-kubernetes-preserve-unknown-fields: true - status: - description: RunbookStatus defines the observed state of Runbook - 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 - - Pending - - Failed - - Unknown - lastSyncTime: - description: LastSyncTime is the timestamp of the last successful sync with Ironic - type: string - format: date-time - observedGeneration: - description: ObservedGeneration reflects the generation of the most recently observed Runbook - type: integer - format: int64 - conditions: - description: Conditions represent the latest available observations of the runbook's state - type: array - items: - description: Condition contains details for one aspect of the current state of this API Resource - type: object - required: - - type - - status - - lastTransitionTime - properties: - type: - description: Type of condition (e.g., Ready, Validated, Synced) - type: string - status: - description: Status of the condition (True, False, Unknown) - type: string - enum: - - "True" - - "False" - - Unknown - lastTransitionTime: - description: LastTransitionTime is the last time the condition transitioned from one status to another - type: string - format: date-time - reason: - description: Reason contains a programmatic identifier indicating the reason for the condition's last transition - type: string - message: - description: Message is a human readable message indicating details about the transition - type: string - subresources: - status: {} - additionalPrinterColumns: - - name: Runbook Name - type: string - description: The runbook name - jsonPath: .spec.runbookName - - name: Description - type: string - description: Human-readable description of the runbook - jsonPath: .spec.description - priority: 1 - - name: Public - type: boolean - description: Whether the runbook is public - jsonPath: .spec.public - - name: Sync Status - type: string - description: Synchronization status with Ironic - jsonPath: .status.syncStatus - - name: Age - type: date - jsonPath: .metadata.creationTimestamp diff --git a/components/ironic/runbook-crd/kustomization.yaml b/components/ironic/runbook-crd/kustomization.yaml deleted file mode 100644 index 416ca9728..000000000 --- a/components/ironic/runbook-crd/kustomization.yaml +++ /dev/null @@ -1,10 +0,0 @@ -apiVersion: kustomize.config.k8s.io/v1beta1 -kind: Kustomization - -# Namespace for runbook resources -namespace: openstack - -# Create namespace if it doesn't exist -resources: - - bases/baremetal.ironicproject.org_runbooks.yaml - - runbooks/runbook_bmc_maintenance.yaml diff --git a/components/ironic/runbook-crd/runbooks/runbook_bmc_maintenance.yaml b/components/ironic/runbook-crd/runbooks/runbook_bmc_maintenance.yaml deleted file mode 100644 index 8b525c907..000000000 --- a/components/ironic/runbook-crd/runbooks/runbook_bmc_maintenance.yaml +++ /dev/null @@ -1,20 +0,0 @@ -apiVersion: baremetal.ironicproject.org/v1alpha1 -kind: IronicRunbook -metadata: - name: bmc-maintenance - namespace: openstack -spec: - runbookName: bmc-maintenance - description: "Performs BMC maintenance operations including clearing the job queue and synchronizing the BMC clock." - disableRamdisk: true - traits: - - CUSTOM_DELL_IDRAC - - steps: - - interface: management - step: clear_job_queue - order: 1 - - - interface: management - step: set_bmc_clock - order: 2 diff --git a/components/ironic/runbook-crd/samples/runbook_v1alpha1_minimal.yaml b/components/ironic/runbook-crd/samples/runbook_v1alpha1_minimal.yaml deleted file mode 100644 index a2c865a01..000000000 --- a/components/ironic/runbook-crd/samples/runbook_v1alpha1_minimal.yaml +++ /dev/null @@ -1,26 +0,0 @@ -# Minimal Runbook Example - Required Fields Only -# -# This example shows the absolute minimum required to create a valid runbook. -# It includes only the 5 required fields: -# 1. spec.runbookName -# 2. spec.steps (array with min 1 step) -# 3. steps[].interface -# 4. steps[].step -# 5. steps[].order -# -# Use this as a starting point and add optional fields as needed. - -apiVersion: baremetal.ironicproject.org/v1alpha1 -kind: IronicRunbook -metadata: - name: minimal-runbook - namespace: default -spec: - # ✅ REQUIRED: Runbook name matching trait convention - runbookName: CUSTOM_MINIMAL - - # ✅ REQUIRED: At least one step - steps: - - interface: deploy # ✅ REQUIRED: Hardware interface - step: erase_devices # ✅ REQUIRED: Step name - order: 1 # ✅ REQUIRED: Execution order (unique) 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..7254bc6bb --- /dev/null +++ b/components/openstack-sync-operator/crds/baremetal.ironicproject.org_ironicrunbooks.yaml @@ -0,0 +1,218 @@ +--- +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 + 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 defines one Ironic runbook. The operator-owned API + contract keeps OpenStack credentials on every CR so reconciliation can + be grouped by cloud, matching the other openstack-sync plugins. + type: object + required: + - spec + properties: + apiVersion: + type: string + kind: + type: string + metadata: + type: object + spec: + description: IronicRunbookSpec defines the desired runbook data. + type: object + required: + - cloudCredentialsRef + - runbookName + - steps + 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: >- + Runbook name, and the identity the operator syncs by. Renaming + creates a new runbook rather than renaming the existing one. + type: string + minLength: 1 + maxLength: 255 + pattern: ^[A-Za-z0-9._~-]+$ + description: + description: Human-readable runbook description. + type: string + maxLength: 255 + traits: + description: >- + Traits deciding which nodes this runbook may act on. A node + must carry them all; a runbook with no traits matches no nodes. + type: array + default: [] + items: + type: string + minLength: 1 + maxLength: 255 + pattern: ^CUSTOM_[A-Z0-9_]+$ + steps: + description: Ordered runbook steps. + type: array + minItems: 1 + items: + type: object + required: + - interface + - step + - order + properties: + interface: + description: Interface that owns this cleaning step. + type: string + enum: + - bios + - deploy + - firmware + - management + - power + - raid + - vendor + step: + description: Step name for the selected interface. + type: string + minLength: 1 + maxLength: 255 + args: + description: Step-specific arguments. + type: object + x-kubernetes-preserve-unknown-fields: true + order: + description: Execution order. Lower numbers run first. + type: integer + minimum: 0 + disableRamdisk: + description: Whether to run without booting the cleaning ramdisk. + type: boolean + default: false + public: + description: >- + Whether the runbook is available to all projects. A public + runbook cannot have an owner. + type: boolean + default: false + owner: + description: >- + Project that owns this runbook. Leave unset to let Ironic + assign the credentials' own project. + type: string + maxLength: 255 + extra: + description: >- + Additional runbook metadata. The operator also keeps its + ownership markers here, under _understack_runbook_ keys. + type: object + x-kubernetes-preserve-unknown-fields: true + status: + description: IronicRunbookStatus defines the observed sync state. + type: object + properties: + ironicUUID: + description: Ironic UUID of this runbook. + 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 b985e59ff..54ca513cf 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: false pluginData: openstackPlaceholder: @@ -55,3 +56,18 @@ pluginData: # When true, removing a NeutronRouterFlavor CR also deletes its unused # operator-managed OpenStack flavor. Enable this before removing the CR. PRUNE: false + + ironicRunbooks: + hook: + path: /hooks/ironic_runbooks.py + crd: crds/baremetal.ironicproject.org_ironicrunbooks.yaml + envPrefix: IRONIC_RUNBOOK + env: + SYNC_CRONTAB: "0 * * * *" + # Ironic readiness wait before a runbook reconcile fails. + # Total wait is READY_RETRIES * READY_DELAY seconds. + READY_RETRIES: 30 + READY_DELAY: 10 + # When true, removing an IronicRunbook CR also deletes its + # operator-owned Ironic runbook. Enable this before removing the CR. + PRUNE: false diff --git a/components/openstack-sync-plugins/ironic-runbooks/bmc_maintenance.yaml b/components/openstack-sync-plugins/ironic-runbooks/bmc_maintenance.yaml new file mode 100644 index 000000000..7f01e700c --- /dev/null +++ b/components/openstack-sync-plugins/ironic-runbooks/bmc_maintenance.yaml @@ -0,0 +1,43 @@ +# yaml-language-server: $schema=https://rackerlabs.github.io/understack/schema/openstack-sync/ironic-runbook.schema.json +# BMC Maintenance Runbook +# +# Clears the BMC job queue and resynchronizes the BMC clock on Dell iDRAC nodes. +# Runs without booting the cleaning ramdisk, so it is safe for out-of-band only work. +# +# Only nodes carrying the traits below are eligible. + +apiVersion: baremetal.ironicproject.org/v1alpha1 +kind: IronicRunbook +metadata: + name: bmc-maintenance + namespace: openstack + labels: + app.kubernetes.io/name: openstack-sync-plugins + app.kubernetes.io/component: ironic-runbooks + app.kubernetes.io/part-of: openstack-sync + use-case: bmc-maintenance + hardware-type: general +spec: + cloudCredentialsRef: + # System-scoped credential: Ironic requires system_scope:all to publish a + # runbook, which the project-scoped infrasetup credential cannot satisfy. + secretName: infrasetup-system + cloudName: understack + runbookName: bmc-maintenance + description: "Performs BMC maintenance operations including clearing the job queue and synchronizing the BMC clock." + public: true + disableRamdisk: true + traits: + - CUSTOM_DELL_IDRAC + steps: + - interface: management + step: clear_job_queue + order: 1 + - interface: management + step: set_bmc_clock + order: 2 + extra: + version: "1.0.0" + use_case: "BMC housekeeping and clock synchronization" + warnings: + - "Clearing the job queue discards pending BMC jobs, including scheduled firmware updates" diff --git a/components/openstack-sync-plugins/ironic-runbooks/examples/README.md b/components/openstack-sync-plugins/ironic-runbooks/examples/README.md new file mode 100644 index 000000000..055f94116 --- /dev/null +++ b/components/openstack-sync-plugins/ironic-runbooks/examples/README.md @@ -0,0 +1,45 @@ +# IronicRunbook Examples + +Reference CRs for the `ironicRunbooks` openstack-sync hook. **Nothing here is +applied.** The parent `kustomization.yaml` lists only the shared runbooks, and +this directory is not one of its `resources`. + +## Using one + +Copy the file to where the CRs for your site live, usually +`//openstack-sync-plugins/`, add it to that directory's +`kustomization.yaml`, then adjust three things: + +1. `metadata.namespace`: must be the namespace the operator watches + (`POD_NAMESPACE`, `openstack` in current sites). The samples use + `baremetal-system` and `default`, which the hook will not see. +2. `spec.cloudCredentialsRef`: the Secret holding `clouds.yaml` and the cloud + entry to authenticate with. +3. `spec.traits`: Ironic only runs a runbook on a node carrying all of them, so + a runbook with no traits matches no nodes. + +Step names and arguments in these files are illustrative. Check that the +`interface` and `step` you want exist on the target hardware before relying on +them, and replace the firmware URLs and checksums with real ones. + +## The examples + +| File | Purpose | +|------|---------| +| `runbook_v1alpha1_minimal.yaml` | Smallest valid CR: required fields only | +| `runbook_v1alpha1_complete.yaml` | Every field, with each one annotated | +| `runbook_bios_config.yaml` | BIOS settings for virtualization on compute nodes | +| `runbook_raid_config.yaml` | RAID setup, OS volume plus data volume | +| `runbook_firmware_update.yaml` | BIOS, BMC and NIC firmware updates | +| `runbook_disk_cleaning.yaml` | Disk erasure for node reuse | +| `runbook_gpu_node_setup.yaml` | BIOS and firmware for GPU nodes | + +## Validation + +Editors pick up the published spec schema from the `yaml-language-server` line at +the top of `../bmc_maintenance.yaml`; add the same line to a copied example to +get completion and checking. Kubernetes validates the full CR against the CRD in +`components/openstack-sync-operator/crds/` when ArgoCD applies it. + +Running a synced runbook against a node is covered in the deploy guide, under +`docs/deploy-guide/components/openstack-sync-plugins.md`. diff --git a/components/ironic/runbook-crd/samples/runbook_bios_config.yaml b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_bios_config.yaml similarity index 83% rename from components/ironic/runbook-crd/samples/runbook_bios_config.yaml rename to components/openstack-sync-plugins/ironic-runbooks/examples/runbook_bios_config.yaml index 5e875bc42..796f2cd8d 100644 --- a/components/ironic/runbook-crd/samples/runbook_bios_config.yaml +++ b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_bios_config.yaml @@ -3,7 +3,7 @@ # This runbook configures BIOS settings for compute nodes. # Common use case: Enabling virtualization features for hypervisor nodes. # -# Matches nodes with trait: CUSTOM_COMPUTE_BIOS +# Selects nodes carrying the trait: CUSTOM_COMPUTE_BIOS apiVersion: baremetal.ironicproject.org/v1alpha1 kind: IronicRunbook @@ -14,8 +14,17 @@ metadata: use-case: bios-configuration hardware-type: compute spec: + # REQUIRED: Secret holding the clouds.yaml, and the cloud entry to use. + cloudCredentialsRef: + secretName: infrasetup + cloudName: understack + runbookName: CUSTOM_COMPUTE_BIOS + # Nodes must carry these traits for this runbook to act on them. + traits: + - CUSTOM_COMPUTE_BIOS + steps: - interface: bios step: apply_configuration diff --git a/components/ironic/runbook-crd/samples/runbook_disk_cleaning.yaml b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_disk_cleaning.yaml similarity index 80% rename from components/ironic/runbook-crd/samples/runbook_disk_cleaning.yaml rename to components/openstack-sync-plugins/ironic-runbooks/examples/runbook_disk_cleaning.yaml index 2599b4698..f25a7cbe2 100644 --- a/components/ironic/runbook-crd/samples/runbook_disk_cleaning.yaml +++ b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_disk_cleaning.yaml @@ -3,7 +3,7 @@ # This runbook performs secure disk erasure for node reuse. # Common use case: Preparing nodes for redeployment or decommissioning. # -# Matches nodes with trait: CUSTOM_DISK_CLEAN +# Selects nodes carrying the trait: CUSTOM_DISK_CLEAN apiVersion: baremetal.ironicproject.org/v1alpha1 kind: IronicRunbook @@ -14,8 +14,17 @@ metadata: use-case: disk-cleaning security-level: standard spec: + # REQUIRED: Secret holding the clouds.yaml, and the cloud entry to use. + cloudCredentialsRef: + secretName: infrasetup + cloudName: understack + runbookName: CUSTOM_DISK_CLEAN + # Nodes must carry these traits for this runbook to act on them. + traits: + - CUSTOM_DISK_CLEAN + steps: # Step 1: Erase all devices - interface: deploy diff --git a/components/ironic/runbook-crd/samples/runbook_firmware_update.yaml b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_firmware_update.yaml similarity index 88% rename from components/ironic/runbook-crd/samples/runbook_firmware_update.yaml rename to components/openstack-sync-plugins/ironic-runbooks/examples/runbook_firmware_update.yaml index 943d89773..576dc8a8f 100644 --- a/components/ironic/runbook-crd/samples/runbook_firmware_update.yaml +++ b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_firmware_update.yaml @@ -3,7 +3,7 @@ # This runbook updates firmware components on baremetal nodes. # Common use case: Updating BIOS, BMC, and NIC firmware. # -# Matches nodes with trait: CUSTOM_FIRMWARE_UPDATE +# Selects nodes carrying the trait: CUSTOM_FIRMWARE_UPDATE apiVersion: baremetal.ironicproject.org/v1alpha1 kind: IronicRunbook @@ -14,8 +14,17 @@ metadata: use-case: firmware-update hardware-type: general spec: + # REQUIRED: Secret holding the clouds.yaml, and the cloud entry to use. + cloudCredentialsRef: + secretName: infrasetup + cloudName: understack + runbookName: CUSTOM_FIRMWARE_UPDATE + # Nodes must carry these traits for this runbook to act on them. + traits: + - CUSTOM_FIRMWARE_UPDATE + steps: # Step 1: Update BIOS firmware - interface: management diff --git a/components/ironic/runbook-crd/samples/runbook_gpu_node_setup.yaml b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_gpu_node_setup.yaml similarity index 89% rename from components/ironic/runbook-crd/samples/runbook_gpu_node_setup.yaml rename to components/openstack-sync-plugins/ironic-runbooks/examples/runbook_gpu_node_setup.yaml index 0862763e2..9d764d083 100644 --- a/components/ironic/runbook-crd/samples/runbook_gpu_node_setup.yaml +++ b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_gpu_node_setup.yaml @@ -3,7 +3,7 @@ # This runbook configures nodes for GPU workloads. # Common use case: Preparing nodes for ML/AI or GPU compute workloads. # -# Matches nodes with trait: CUSTOM_GPU_SETUP +# Selects nodes carrying the trait: CUSTOM_GPU_SETUP apiVersion: baremetal.ironicproject.org/v1alpha1 kind: IronicRunbook @@ -14,8 +14,17 @@ metadata: use-case: gpu-configuration hardware-type: gpu-compute spec: + # REQUIRED: Secret holding the clouds.yaml, and the cloud entry to use. + cloudCredentialsRef: + secretName: infrasetup + cloudName: understack + runbookName: CUSTOM_GPU_SETUP + # Nodes must carry these traits for this runbook to act on them. + traits: + - CUSTOM_GPU_SETUP + steps: # Step 1: Configure BIOS for GPU support - interface: bios diff --git a/components/ironic/runbook-crd/samples/runbook_raid_config.yaml b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_raid_config.yaml similarity index 85% rename from components/ironic/runbook-crd/samples/runbook_raid_config.yaml rename to components/openstack-sync-plugins/ironic-runbooks/examples/runbook_raid_config.yaml index 54c43e73f..9ff560c78 100644 --- a/components/ironic/runbook-crd/samples/runbook_raid_config.yaml +++ b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_raid_config.yaml @@ -3,7 +3,7 @@ # This runbook configures RAID arrays for storage nodes. # Common use case: Setting up RAID 1 for OS and RAID 6 for data. # -# Matches nodes with trait: CUSTOM_STORAGE_RAID +# Selects nodes carrying the trait: CUSTOM_STORAGE_RAID apiVersion: baremetal.ironicproject.org/v1alpha1 kind: IronicRunbook @@ -14,8 +14,17 @@ metadata: use-case: raid-configuration hardware-type: storage spec: + # REQUIRED: Secret holding the clouds.yaml, and the cloud entry to use. + cloudCredentialsRef: + secretName: infrasetup + cloudName: understack + runbookName: CUSTOM_STORAGE_RAID + # Nodes must carry these traits for this runbook to act on them. + traits: + - CUSTOM_STORAGE_RAID + steps: # Step 1: Delete existing RAID configuration - interface: raid diff --git a/components/ironic/runbook-crd/samples/runbook_v1alpha1_complete.yaml b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_v1alpha1_complete.yaml similarity index 51% rename from components/ironic/runbook-crd/samples/runbook_v1alpha1_complete.yaml rename to components/openstack-sync-plugins/ironic-runbooks/examples/runbook_v1alpha1_complete.yaml index 5fcacc31b..0c8d01fcb 100644 --- a/components/ironic/runbook-crd/samples/runbook_v1alpha1_complete.yaml +++ b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_v1alpha1_complete.yaml @@ -3,8 +3,8 @@ # This example demonstrates all available fields in a runbook, # including both required and optional fields. # -# Required fields (✅): runbookName, steps, interface, step, order -# Optional fields (❌): disableRamdisk, public, owner, extra, args +# Required fields: cloudCredentialsRef, runbookName, steps, interface, step, order +# Optional fields: description, traits, disableRamdisk, public, owner, extra, args apiVersion: baremetal.ironicproject.org/v1alpha1 kind: IronicRunbook @@ -18,16 +18,28 @@ metadata: annotations: description: "Complete example showing all available fields" spec: - # ✅ REQUIRED: Runbook name (must match CUSTOM_* pattern) - runbookName: CUSTOM_COMPLETE + # REQUIRED: Secret holding the clouds.yaml, and the cloud entry to use + cloudCredentialsRef: + secretName: infrasetup + cloudName: understack - # ✅ REQUIRED: Ordered list of steps (minimum 1 step) + # REQUIRED: Runbook name. Any URL-safe string (letters, digits, - . _ ~) + runbookName: complete-example + + # OPTIONAL: Human-readable description (max 255 characters) + description: "Complete example runbook exercising every field" + + # OPTIONAL: Traits deciding which nodes this runbook may act on + traits: + - CUSTOM_COMPLETE_EXAMPLE + + # REQUIRED: Ordered list of steps (minimum 1 step) steps: # Step 1: BIOS Configuration - - interface: bios # ✅ REQUIRED - step: apply_configuration # ✅ REQUIRED - order: 1 # ✅ REQUIRED - args: # ❌ OPTIONAL + - interface: bios # REQUIRED + step: apply_configuration # REQUIRED + order: 1 # REQUIRED + args: # OPTIONAL settings: - name: LogicalProc value: Enabled @@ -37,10 +49,10 @@ spec: value: Enabled # Step 2: RAID Configuration - - interface: raid # ✅ REQUIRED - step: create_configuration # ✅ REQUIRED - order: 2 # ✅ REQUIRED - args: # ❌ OPTIONAL + - interface: raid # REQUIRED + step: create_configuration # REQUIRED + order: 2 # REQUIRED + args: # OPTIONAL logical_disks: - size_gb: 100 raid_level: "1" @@ -50,24 +62,24 @@ spec: is_root_volume: false # Step 3: Disk Cleaning - - interface: deploy # ✅ REQUIRED - step: erase_devices # ✅ REQUIRED - order: 3 # ✅ REQUIRED - args: # ❌ OPTIONAL + - interface: deploy # REQUIRED + step: erase_devices # REQUIRED + order: 3 # REQUIRED + args: # OPTIONAL erase_skip_list: [] - # ❌ OPTIONAL: Skip ramdisk booting (default: false) + # OPTIONAL: Skip ramdisk booting (default: false) disableRamdisk: false - # ❌ OPTIONAL: Make runbook public (default: false) + # OPTIONAL: Make runbook public (default: false) # Note: Cannot be true if owner is set public: false - # ❌ OPTIONAL: Project/tenant owner (default: null) + # OPTIONAL: Project/tenant owner (default: null) # Note: Cannot be set if public is true owner: "project-123" - # ❌ OPTIONAL: Additional metadata (default: {}) + # OPTIONAL: Additional metadata (default: {}) extra: description: "Complete example runbook with all fields" version: "1.0.0" diff --git a/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_v1alpha1_minimal.yaml b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_v1alpha1_minimal.yaml new file mode 100644 index 000000000..7e80d4a5a --- /dev/null +++ b/components/openstack-sync-plugins/ironic-runbooks/examples/runbook_v1alpha1_minimal.yaml @@ -0,0 +1,33 @@ +# Minimal Runbook Example - Required Fields Only +# +# This example shows the absolute minimum required to create a valid runbook. +# It includes only the 6 required fields: +# 1. spec.cloudCredentialsRef (secretName + cloudName) +# 2. spec.runbookName +# 3. spec.steps (array with min 1 step) +# 4. steps[].interface +# 5. steps[].step +# 6. steps[].order +# +# Use this as a starting point and add optional fields as needed. + +apiVersion: baremetal.ironicproject.org/v1alpha1 +kind: IronicRunbook +metadata: + name: minimal-runbook + namespace: default +spec: + # REQUIRED: Secret holding the clouds.yaml, and the cloud entry to use + cloudCredentialsRef: + secretName: infrasetup + cloudName: understack + + # REQUIRED: Runbook name. Any URL-safe string. + # Without spec.traits this runbook matches no nodes; see the other samples. + runbookName: minimal-example + + # REQUIRED: At least one step + steps: + - interface: deploy # REQUIRED: Hardware interface + step: erase_devices # REQUIRED: Step name + order: 1 # REQUIRED: Execution order (unique) diff --git a/components/openstack-sync-plugins/ironic-runbooks/kustomization.yaml b/components/openstack-sync-plugins/ironic-runbooks/kustomization.yaml new file mode 100644 index 000000000..c884452e2 --- /dev/null +++ b/components/openstack-sync-plugins/ironic-runbooks/kustomization.yaml @@ -0,0 +1,6 @@ +--- +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization + +resources: + - bmc_maintenance.yaml diff --git a/components/openstack-sync-plugins/kustomization.yaml b/components/openstack-sync-plugins/kustomization.yaml index d6a869e7a..b079cb854 100644 --- a/components/openstack-sync-plugins/kustomization.yaml +++ b/components/openstack-sync-plugins/kustomization.yaml @@ -4,3 +4,4 @@ kind: Kustomization resources: - neutron-router-flavors + - ironic-runbooks 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/docs/deploy-guide/components/openstack-sync-operator.md b/docs/deploy-guide/components/openstack-sync-operator.md index 00484be79..7d22e3393 100644 --- a/docs/deploy-guide/components/openstack-sync-operator.md +++ b/docs/deploy-guide/components/openstack-sync-operator.md @@ -96,11 +96,12 @@ intend to run in `//openstack-sync-operator/values.yaml`. Built-in hooks are declared in `components/openstack-sync-operator/values.yaml`. -For Neutron router flavors, the default is: +For built-in CRD hooks, the defaults are: ```yaml plugins: neutronRouterFlavors: false + ironicRunbooks: false pluginData: neutronRouterFlavors: @@ -108,6 +109,11 @@ pluginData: path: /hooks/router_flavors.py crd: crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml envPrefix: NEUTRON_ROUTER_FLAVOR + ironicRunbooks: + hook: + path: /hooks/ironic_runbooks.py + crd: crds/baremetal.ironicproject.org_ironicrunbooks.yaml + envPrefix: IRONIC_RUNBOOK ``` Enable the hook from the deployment repo after the site is pinned to an @@ -116,16 +122,16 @@ operator image built from this code: ```yaml title="$CLUSTER_NAME/openstack-sync-operator/values.yaml" plugins: neutronRouterFlavors: true + ironicRunbooks: true ``` -The image build in `containers/openstack-sync-operator/Dockerfile` copies both -`python/openstack-sync/openstack_sync/hooks/placeholder.py` and -`python/openstack-sync/openstack_sync/hooks/router_flavors.py` into `/hooks/`. +The image build in `containers/openstack-sync-operator/Dockerfile` copies the +enabled hook executables into `/hooks/`. -When `plugins.neutronRouterFlavors: false`, the router-flavor hook still exists -in the image but publishes only a no-op startup binding. That keeps -shell-operator startup valid while preventing any watch, schedule, OpenStack -sync, or hook-specific RBAC for router flavors. +When `plugins.: false`, that hook may still exist in the image but +publishes only a no-op startup binding. That keeps shell-operator startup valid +while preventing any watch, schedule, OpenStack sync, or hook-specific RBAC for +that resource. When a hook is enabled, the chart: @@ -159,11 +165,26 @@ initContainers: exit "${missing}" ``` -For Neutron router flavors, the enabled hook registers a `kubernetes` binding -that watches `NeutronRouterFlavor` CRs and a `schedule` binding for periodic -sync. Reconciliation logic (reading CRs, calling `openstacksdk`, and patching CR -status) is not yet implemented; the hook currently exits 0 without taking action -on events. +For Neutron router flavors, the enabled hook watches `NeutronRouterFlavor` CRs, +adds the configured periodic schedule, reconciles Neutron flavors and service +profiles through `openstacksdk`, and patches CR status. + +For Ironic runbooks, the enabled hook watches `IronicRunbook` CRs, adds the +configured periodic schedule, reconciles Ironic runbooks and their traits, and +patches CR status. The legacy `shell-operator-ironic` runbook controller is no +longer deployed by `components/ironic`; do not re-enable that old controller +alongside this hook. + +The hook requires **Ironic API microversion 1.112**, for the runbook +`description` field and the runbook traits endpoints. It checks this at readiness +and fails with a message naming the requirement. + +Two things for CR authors: + +- `spec.runbookName` is the identity the operator syncs by, so changing it + creates a new runbook rather than renaming the existing one. +- `spec.traits` decides which nodes a runbook may act on. A runbook with no + traits matches no nodes. When no hook is enabled, the operator can still start. In that state the Role has no custom-resource permissions and no OpenStack sync work is expected. @@ -194,24 +215,25 @@ The plugin Application should continue to apply only CR manifests. ## CRDs and Validation -The Neutron router flavor CRD is in: -`components/openstack-sync-operator/crds/neutron.understack.rackspace.net_neutronrouterflavors.yaml` +The CRDs live under `components/openstack-sync-operator/crds/`: + +- `neutron.understack.rackspace.net_neutronrouterflavors.yaml` +- `baremetal.ironicproject.org_ironicrunbooks.yaml` -It defines: +Each plugin CRD defines: -- API version: `neutron.understack.rackspace.net/v1alpha1` -- Kind: `NeutronRouterFlavor` -- Resource: `neutronrouterflavors` - Scope: namespaced - Status subresource: enabled +- Required `spec.cloudCredentialsRef.secretName` +- Required `spec.cloudCredentialsRef.cloudName` The chart reads this CRD through `components/openstack-sync-operator/templates/_crd.tpl` so RBAC and hook environment variables are derived from the same schema Kubernetes applies. -Neutron router flavor CR files also reference the editor schema at: -`schema/openstack-sync/neutron-router-flavor.schema.json` +Plugin CR files can also reference editor schemas under: +`schema/openstack-sync/` That schema focuses on the flavor data under `spec`. Kubernetes validates the full custom resource through the operator-owned CRD when ArgoCD applies it. diff --git a/docs/deploy-guide/components/openstack-sync-plugins.md b/docs/deploy-guide/components/openstack-sync-plugins.md index 40d9a7aab..1a9533586 100644 --- a/docs/deploy-guide/components/openstack-sync-plugins.md +++ b/docs/deploy-guide/components/openstack-sync-plugins.md @@ -36,6 +36,11 @@ understack repo under: `components/openstack-sync-plugins/neutron-router-flavors/` +Ironic runbook CRs are site data today. Shared hardware runbooks can live in the +deployment repo under: + +`/hardware/runbooks/` + The shared data entrypoint is: `components/openstack-sync-plugins/kustomization.yaml` @@ -44,20 +49,55 @@ Cluster-specific CRs live in the deployment repo under `//openstack-sync-plugins/` and are listed by that directory's `kustomization.yaml`. -Hook enablement is separate. Set `plugins.neutronRouterFlavors: true` in +Hook enablement is separate. Set `plugins.neutronRouterFlavors: true` or +`plugins.ironicRunbooks: true` in `//openstack-sync-operator/values.yaml` only after the site -is pinned to an operator image built with `/hooks/router_flavors.py`. +is pinned to an operator image built with the matching hook under `/hooks/`. -`plugins.neutronRouterFlavors: false` does not stop this Application from -creating `NeutronRouterFlavor` CRs. It only disables the operator hook that -reconciles those CRs into OpenStack. To stop creating the CRs, disable -`site.openstack_sync_plugins.enabled` or remove the CR files from the relevant -`kustomization.yaml`. +`plugins.: false` does not stop this Application from creating that +plugin's CRs. It only disables the operator hook that processes those CRs. To +stop creating the CRs, disable `site.openstack_sync_plugins.enabled` or remove +the CR files from the relevant `kustomization.yaml`. + +Plugin CR files use published editor schemas under `schema/openstack-sync/`. +Those schemas validate the data under `spec`, not the Kubernetes wrapper fields. +Kubernetes validates required fields, types, enums, and defaults through the +operator-owned CRD when ArgoCD applies the CR. The editor schemas are stricter +about unknown spec fields, so add new schema fields with the matching +operator hook/CRD change when a plugin needs new data. + +## Running a Synced Ironic Runbook + +Syncing a runbook does not execute it. Once the hook reports `Synced`, run it +against a node with whichever command matches the node's provision state: + +```bash +# node in 'manageable' state +openstack baremetal node clean --runbook + +# node in 'active' or 'available' state +openstack baremetal node service --runbook + +# watch progress +openstack baremetal node show -f value -c provision_state +``` + +Ironic only runs a runbook on a node that carries all of the runbook's +`spec.traits`, so add them to the node first: + +```bash +openstack baremetal node add trait CUSTOM_DELL_IDRAC +``` + +From workflow code, `understack_workflows.ironic_node.transition` wraps the same +call and waits for the node to come back to its starting state: + +```python +transition(node, "clean", expected_state="manageable", runbook=runbook_uuid) +``` -Neutron router flavor CR files use the published editor schema -`schema/openstack-sync/neutron-router-flavor.schema.json`. That schema validates -the flavor data under `spec`, not the Kubernetes wrapper fields. Kubernetes -validates required fields, types, enums, and defaults through the operator-owned -CRD when ArgoCD applies the CR. The editor schema is stricter about unknown spec -fields, so add new schema fields with the matching operator hook/CRD change when -a driver needs new service-profile data. +One coupling to be aware of when naming runbooks: the trait-driven firmware path, +`apply_firmware_updates` in `understack_workflows/ironic_node.py`, reads a node's +`CUSTOM_FIRMWARE_UPDATE_*` traits and resolves each one to a runbook **by name**. +A runbook meant to be found that way must therefore be named for the trait, even +though `spec.runbookName` otherwise accepts any URL-safe name. diff --git a/docs/operator-guide/baremetal-ironic-cleanup-runbook.md b/docs/operator-guide/baremetal-ironic-cleanup-runbook.md index a78922f00..f252135f5 100644 --- a/docs/operator-guide/baremetal-ironic-cleanup-runbook.md +++ b/docs/operator-guide/baremetal-ironic-cleanup-runbook.md @@ -498,10 +498,10 @@ or event-source logs when verifying this behavior in an environment. ### Runbook CRD -The runbook CRD is defined under -`runbook-crd`, and the shell operator hook -that syncs Kubernetes `IronicRunbook` objects into Ironic is -`create_runbook.sh`. +The runbook CRD is defined under `components/openstack-sync-operator/crds/`. +The `ironic_runbooks.py` hook in `openstack-sync-operator` currently loads +Kubernetes `IronicRunbook` objects and patches sync status. Ironic API +create/update/delete reconciliation will be added separately. Checked-in sample runbooks include: @@ -525,4 +525,4 @@ they are deployed unless the environment confirms that. | `sensor-ironic-node-reclean.yaml` | Existing clean-failed event sensor | | `pr-clean-failed-servers.yaml` | Existing clean-failed Prometheus alert | | `server-firmware-update.yaml` | Existing firmware runbook workflow | -| `runbook-crd/samples` | Sample runbook manifests, not assumed deployed | +| `components/openstack-sync-operator/crds` | OpenStack sync CRD manifests | diff --git a/docs/operator-guide/server-firmware-update.md b/docs/operator-guide/server-firmware-update.md index 836d025ad..a53abc14b 100644 --- a/docs/operator-guide/server-firmware-update.md +++ b/docs/operator-guide/server-firmware-update.md @@ -62,9 +62,11 @@ flowchart TB E --> F(Run Matching Runbooks) ``` -## Runbook Operator +## Runbook Sync -The Ironic Runbook Operator was written using [shell-operator](https://github.com/flant/shell-operator). Essentially it listens for create, update or delete events on any Runbook resources, and then issues the appropriate calls to the Openstack Ironic API. These operations are defined by basic shell hooks, which can be found [here](https://github.com/rackerlabs/understack/tree/main/containers/shell-operator-ironic/hooks) +`IronicRunbook` resources are owned by the `openstack-sync-operator` framework. +The current hook loads runbook CRs and patches sync status; Ironic API +create/update/delete reconciliation will be added separately. ```mermaid architecture-beta diff --git a/python/openstack-sync/README.md b/python/openstack-sync/README.md index ff610b1f6..058193ffd 100644 --- a/python/openstack-sync/README.md +++ b/python/openstack-sync/README.md @@ -16,8 +16,15 @@ openstack_sync/ framework.py HookConfig, SyncPlugin, run_sync(), run_hook() placeholder.py connectivity probe (no CRs) router_flavors.py NeutronRouterFlavor hook + ironic_runbooks.py IronicRunbook hook plugins/ common.py OpenStack helpers shared by all plugins + ironic/runbooks/ + config.py plugin constants + client.py runbook REST calls + markers.py ownership markers + reconcile.py converge one CR + prune.py delete runbooks whose CR was removed neutron/router_flavors/ config.py plugin constants markers.py ownership markers @@ -77,9 +84,10 @@ reading the binding context, and the exit code. keys, such as `PRUNE`, `SYNC_CRONTAB`, `READY_RETRIES` and `READY_DELAY`. Plugins read custom prefixed env vars directly. -3. **Write the plugin package** under `plugins///` with the - same four modules as `router_flavors`: `config.py` (constants), `markers.py` - (how you record that the operator owns a resource), `reconcile.py`, `prune.py`. +3. **Write the plugin package** under `plugins///`. + `config.py` and `reconcile.py` are the usual minimum. Add `markers.py` when + the plugin stamps ownership into OpenStack resources, and `prune.py` only + when deleting resources after CR removal is safe and implemented. 4. **Write the hook** — subclass `SyncPlugin` and wire it up: 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..519712a1f --- /dev/null +++ b/python/openstack-sync/openstack_sync/hooks/ironic_runbooks.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Shell-operator hook for Ironic runbook reconciliation.""" + +from __future__ import annotations + +import sys +from typing import Any + +from openstack_sync.hooks.framework import HookConfig +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 +from openstack_sync.hooks.framework import hook_inputs +from openstack_sync.hooks.framework import run_hook +from openstack_sync.hooks.framework import run_sync +from openstack_sync.plugins.ironic.runbooks import client +from openstack_sync.plugins.ironic.runbooks import prune as prune_module +from openstack_sync.plugins.ironic.runbooks import reconcile as reconcile_module +from openstack_sync.plugins.ironic.runbooks.config import BINDING_NAME +from openstack_sync.plugins.ironic.runbooks.config import ENV_PREFIX + + +class IronicRunbookPlugin(SyncPlugin): + """Sync IronicRunbook CRs into Ironic runbooks.""" + + noun = "ironic runbook" + + def wait_for_api(self, conn: Any) -> None: + client.wait_for_runbook_api( + conn, + retries=self.config.ready_retries, + delay=self.config.ready_delay, + ) + + def reconcile(self, conn: Any, spec: dict[str, Any], cache: Any) -> list[str]: + # No cache: the API version is a constant, and a runbook is reconciled + # entirely from its own CR, so there is nothing for one CR to hand the + # next. + return reconcile_module.sync_runbook(conn, spec, cache) + + def prune( + self, + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool, + ) -> None: + if not self.config.prune: + return + prune_module.prune_removed_runbooks( + conn, desired_specs, authoritative_empty=authoritative_empty + ) + + +def main() -> int: + def run(contexts: list[dict[str, Any]]) -> int: + if not hook_enabled(ENV_PREFIX): + return 0 + config = HookConfig.from_env(ENV_PREFIX, binding_name=BINDING_NAME) + return run_sync(IronicRunbookPlugin(config), hook_inputs(contexts, config)) + + return run_hook(lambda: build_crd_hook_config(ENV_PREFIX, BINDING_NAME), run) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/python/openstack-sync/openstack_sync/plugins/common.py b/python/openstack-sync/openstack_sync/plugins/common.py index 887486798..c9a8b2b3a 100644 --- a/python/openstack-sync/openstack_sync/plugins/common.py +++ b/python/openstack-sync/openstack_sync/plugins/common.py @@ -12,6 +12,7 @@ import logging import os import time +from collections.abc import Callable from typing import Any from openstack import exceptions as openstack_exceptions @@ -146,38 +147,75 @@ def meta_info_payload(value: Any) -> str: # --------------------------------------------------------------------------- -# Neutron network readiness probe +# API readiness probes # --------------------------------------------------------------------------- -def wait_for_openstack_network( - conn: Any, +def wait_for_openstack_api( + service: str, + probe: Callable[[], Any], retries: int = 30, delay: float = 10.0, ) -> None: - """Poll until the Neutron network API is reachable. + """Poll *probe* until it succeeds, or raise once *retries* is exhausted. + + A probe should be the cheapest read that proves the plugin can do its work: + reaching the endpoint is not the same as being allowed to list the resource + the plugin reconciles. + + :exc:`ConfigError` is re-raised on the first attempt rather than retried. It + means the credentials, the cloud, or the API version cannot satisfy the + plugin at all, and no amount of waiting changes that -- retrying would only + delay the failure by ``retries * delay`` seconds. Args: - conn: An authenticated OpenStack connection. + service: OpenStack service name, used in log and error messages. + probe: Zero-argument callable that raises while the API is not ready. retries: Maximum number of attempts before raising. delay: Seconds to wait between attempts. Raises: + ConfigError: Immediately, when *probe* raises it. RuntimeError: When the API does not become ready within *retries*. """ for attempt in range(1, retries + 1): try: - next(iter(conn.network.flavors()), None) + probe() return + except ConfigError: + raise except Exception as exc: if attempt >= retries: raise RuntimeError( - f"Neutron API did not become ready after {retries} attempt(s)" + f"{service} API did not become ready after {retries} attempt(s)" ) from exc - LOG.info("Waiting for Neutron API (%s/%s): %s", attempt, retries, exc) + LOG.info("Waiting for %s API (%s/%s): %s", service, attempt, retries, exc) time.sleep(delay) +def wait_for_openstack_network( + conn: Any, + retries: int = 30, + delay: float = 10.0, +) -> None: + """Poll until the Neutron network API is reachable. + + Args: + conn: An authenticated OpenStack connection. + retries: Maximum number of attempts before raising. + delay: Seconds to wait between attempts. + + Raises: + RuntimeError: When the API does not become ready within *retries*. + """ + wait_for_openstack_api( + "Neutron", + lambda: next(iter(conn.network.flavors()), None), + retries=retries, + delay=delay, + ) + + # --------------------------------------------------------------------------- # Service profile helpers # --------------------------------------------------------------------------- diff --git a/python/openstack-sync/openstack_sync/plugins/ironic/__init__.py b/python/openstack-sync/openstack_sync/plugins/ironic/__init__.py new file mode 100644 index 000000000..1286fb768 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/ironic/__init__.py @@ -0,0 +1 @@ +"""Ironic sync plugins.""" diff --git a/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/__init__.py b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/__init__.py new file mode 100644 index 000000000..3009f2b42 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/__init__.py @@ -0,0 +1 @@ +"""Ironic runbook sync package.""" diff --git a/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/client.py b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/client.py new file mode 100644 index 000000000..cfb6ac4d7 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/client.py @@ -0,0 +1,165 @@ +"""Ironic runbook API calls. + +Neither client library covers the whole CRD: openstacksdk's ``Runbook`` resource +models none of ``description``, ``traits`` or ``disable_ramdisk``, and +ironicclient's ``RunbookManager`` rejects ``disable_ramdisk`` on create and +offers no bulk trait write. So this module drives the runbook endpoints through +the baremetal proxy's HTTP session -- a keystoneauth ``Adapter`` -- at +:data:`RUNBOOK_MICROVERSION`. + +Two things the session leaves to the caller, both handled here: +``Proxy.request`` returns a non-2xx response instead of raising, so every call +passes it through :func:`openstack.exceptions.raise_from_response` to get a typed +``NotFoundException`` or ``ConflictException``; and the microversion is +per-request, so every request states it and :func:`check_microversion` confirms +once that the cloud can serve it. + +Runbooks are addressed by name, which is unique in Ironic and is what the CR +names them by. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from openstack import exceptions as openstack_exceptions +from openstack import utils as openstack_utils + +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import wait_for_openstack_api +from openstack_sync.plugins.ironic.runbooks.config import RUNBOOK_MICROVERSION + +LOG = logging.getLogger(__name__) + +_RUNBOOKS_PATH = "/runbooks" + + +# --------------------------------------------------------------------------- +# Readiness +# --------------------------------------------------------------------------- + + +def _version_tuple(microversion: str) -> tuple[int, ...]: + """Return *microversion* as a comparable tuple of ints.""" + try: + return tuple(int(part) for part in str(microversion).split(".")) + except ValueError as exc: + raise ConfigError( + f"Ironic reported an unusable API microversion {microversion!r}" + ) from exc + + +def check_microversion(conn: Any) -> None: + """Raise unless the cloud can serve :data:`RUNBOOK_MICROVERSION`. + + Called at readiness so a cloud that cannot is named for it, rather than + turning up as a 406 mid-reconcile. + + Raises: + ConfigError: When the version cannot be determined or is too old. + Neither is transient, so callers must not retry it. + """ + supported = openstack_utils.maximum_supported_microversion( + conn.baremetal, RUNBOOK_MICROVERSION + ) + if supported is None: + raise ConfigError( + "Could not determine the Ironic API microversion; the baremetal " + "endpoint did not report its supported versions, so the runbook " + f"API cannot be used (requires {RUNBOOK_MICROVERSION})" + ) + if _version_tuple(supported) < _version_tuple(RUNBOOK_MICROVERSION): + raise ConfigError( + f"Ironic supports API microversion {supported} but this hook " + f"requires {RUNBOOK_MICROVERSION} for runbook descriptions and " + "traits; upgrade Ironic or disable the ironicRunbooks hook" + ) + + +def wait_for_runbook_api( + conn: Any, + retries: int = 30, + delay: float = 10.0, +) -> None: + """Poll until the runbook API is reachable and listable. + + Listing, not just reaching the endpoint: a token that cannot read runbooks + fails every reconcile that follows. + """ + + def probe() -> None: + check_microversion(conn) + list_runbooks(conn, limit=1) + + wait_for_openstack_api("Ironic", probe, retries=retries, delay=delay) + + +# --------------------------------------------------------------------------- +# Requests +# --------------------------------------------------------------------------- + + +def _request(conn: Any, method: str, path: str, **kwargs: Any) -> Any: + """Send one baremetal request and raise for any non-2xx response.""" + response = conn.baremetal.request( + path, method, microversion=RUNBOOK_MICROVERSION, **kwargs + ) + openstack_exceptions.raise_from_response(response) + return response + + +def _json_body(response: Any) -> dict[str, Any]: + """Return the JSON body of *response*, or an empty dict when it has none.""" + if not response.content: + return {} + body = response.json() + return body if isinstance(body, dict) else {} + + +def list_runbooks(conn: Any, limit: int | None = None) -> list[dict[str, Any]]: + """Return every runbook visible to these credentials, with all fields.""" + params: dict[str, Any] = {"detail": "true"} + if limit is not None: + params["limit"] = limit + response = _request(conn, "GET", _RUNBOOKS_PATH, params=params) + runbooks = _json_body(response).get("runbooks", []) + return [runbook for runbook in runbooks if isinstance(runbook, dict)] + + +def get_runbook(conn: Any, name: str) -> dict[str, Any] | None: + """Return the runbook named *name*, or None when Ironic does not have it.""" + try: + response = _request(conn, "GET", f"{_RUNBOOKS_PATH}/{name}") + except openstack_exceptions.NotFoundException: + return None + return _json_body(response) + + +def create_runbook(conn: Any, payload: dict[str, Any]) -> dict[str, Any]: + """Create a runbook from *payload* and return it as Ironic stored it.""" + response = _request(conn, "POST", _RUNBOOKS_PATH, json=payload) + return _json_body(response) + + +def patch_runbook(conn: Any, name: str, patch: list[dict[str, Any]]) -> dict[str, Any]: + """Apply a JSON patch to the runbook named *name*.""" + response = _request(conn, "PATCH", f"{_RUNBOOKS_PATH}/{name}", json=patch) + return _json_body(response) + + +def delete_runbook(conn: Any, name: str) -> None: + """Delete the runbook named *name*, treating an absent one as success.""" + try: + _request(conn, "DELETE", f"{_RUNBOOKS_PATH}/{name}") + except openstack_exceptions.NotFoundException: + LOG.info("Runbook %s is already absent from Ironic", name) + + +def set_traits(conn: Any, name: str, traits: list[str]) -> None: + """Replace every trait on the runbook named *name* with *traits*. + + One request for the whole set, so the runbook is never left matching a + partial set of nodes. An empty list clears them. + """ + _request(conn, "PUT", f"{_RUNBOOKS_PATH}/{name}/traits", json={"traits": traits}) diff --git a/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/config.py b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/config.py new file mode 100644 index 000000000..c07b06adb --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/config.py @@ -0,0 +1,17 @@ +"""Ironic runbook plugin constants. + +Runtime configuration comes from :class:`openstack_sync.hooks.framework.HookConfig`, +built from the ``IRONIC_RUNBOOK`` env prefix the Helm chart injects. +""" + +from __future__ import annotations + +#: The Ironic API microversion this plugin requires. It is the first with runbook +#: descriptions and the traits sub-resource, both of which the CRD exposes. +RUNBOOK_MICROVERSION = "1.112" + +#: Env prefix the Helm chart uses for this plugin's variables. +ENV_PREFIX = "IRONIC_RUNBOOK" + +#: shell-operator binding label for the CRD watch. +BINDING_NAME = "ironic-runbooks" diff --git a/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/markers.py b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/markers.py new file mode 100644 index 000000000..d141416b5 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/markers.py @@ -0,0 +1,48 @@ +"""Ownership markers for operator-managed Ironic runbooks. + +An IronicRunbook CR is an ownership claim for the Ironic runbook of the same +name. Runbooks the operator creates or adopts carry these markers in ``extra``, +Ironic's arbitrary metadata field; prune only deletes runbooks that have already +entered that managed set. +""" + +from __future__ import annotations + +from typing import Any + +from openstack_sync.plugins.common import get_value + +MANAGED_EXTRA_KEY = "_understack_runbook_operator" +MANAGED_EXTRA_VALUE = "managed" +MARKER_VERSION_EXTRA_KEY = "_understack_runbook_marker_version" +MARKER_VERSION_EXTRA_VALUE = "v1" +MARKER_SOURCE_EXTRA_KEY = "_understack_runbook_source" +MARKER_SOURCE_EXTRA_VALUE = "IronicRunbook" + +#: Marker keys stamped into a managed runbook's ``extra``. +OPERATOR_EXTRA_MARKERS = { + MANAGED_EXTRA_KEY: MANAGED_EXTRA_VALUE, + MARKER_VERSION_EXTRA_KEY: MARKER_VERSION_EXTRA_VALUE, + MARKER_SOURCE_EXTRA_KEY: MARKER_SOURCE_EXTRA_VALUE, +} + + +def runbook_extra(runbook: Any) -> dict[str, Any]: + """Return the ``extra`` of *runbook* as a dict. + + Ironic models ``extra`` as nullable, so a runbook without one comes back as + ``None``; an empty dict is the safe reading of that. + """ + extra = get_value(runbook, "extra", default={}) + return extra if isinstance(extra, dict) else {} + + +def managed_extra(value: Any) -> dict[str, Any]: + """Return *value* with the operator ownership markers merged in.""" + extra = value if isinstance(value, dict) else {} + return {**extra, **OPERATOR_EXTRA_MARKERS} + + +def is_managed_runbook(runbook: Any) -> bool: + """Return True when *runbook* carries the operator ownership marker.""" + return runbook_extra(runbook).get(MANAGED_EXTRA_KEY) == MANAGED_EXTRA_VALUE diff --git a/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/prune.py b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/prune.py new file mode 100644 index 000000000..d20a5d499 --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/prune.py @@ -0,0 +1,66 @@ +"""Delete Ironic runbooks whose CR was removed. + +Everything here is gated on the operator's ownership marker. A hand-made runbook +is untouched until a CR causes the operator to create or adopt it; a runbook +carrying the marker is in the operator-managed set, which makes any further +filtering redundant. + +There is no in-use check to make: a runbook is named in a clean or service +request as that request is made, and Ironic keeps no reference from a node back +to a runbook. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from openstack import exceptions as openstack_exceptions + +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.ironic.runbooks import client +from openstack_sync.plugins.ironic.runbooks.markers import is_managed_runbook + +LOG = logging.getLogger(__name__) + + +def _delete_runbook(conn: Any, name: str) -> None: + LOG.info("Deleting removed Ironic runbook %s", name) + try: + client.delete_runbook(conn, name) + except openstack_exceptions.ConflictException: + LOG.info("Ironic runbook %s is still in use; skipping delete", name) + + +def prune_removed_runbooks( + conn: Any, + desired_specs: list[dict[str, Any]], + *, + authoritative_empty: bool = False, +) -> None: + """Delete operator-owned runbooks absent from *desired_specs*. + + An empty *desired_specs* is only acted on when *authoritative_empty* says a + CR really was deleted; otherwise it may be a snapshot we could not read, and + pruning against it would delete every managed runbook. + """ + if not desired_specs and not authoritative_empty: + LOG.warning( + "No desired Ironic runbooks found; skipping prune to avoid deleting " + "all managed runbooks" + ) + return + + desired_names = { + str(spec["runbookName"]) for spec in desired_specs if spec.get("runbookName") + } + + LOG.info("Pruning removed Ironic runbooks") + for runbook in client.list_runbooks(conn): + name = get_value(runbook, "name") + if not name or name in desired_names: + continue + if not is_managed_runbook(runbook): + LOG.info("Keeping Ironic runbook %s; it is not operator-owned", name) + continue + _delete_runbook(conn, str(name)) diff --git a/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/reconcile.py b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/reconcile.py new file mode 100644 index 000000000..1a71bcb7b --- /dev/null +++ b/python/openstack-sync/openstack_sync/plugins/ironic/runbooks/reconcile.py @@ -0,0 +1,302 @@ +"""Reconcile an IronicRunbook CR onto Ironic. + +Ordered as the reconcile reads: turn the spec into the payload Ironic wants, +converge the runbook, then converge its traits. + +The CR is the ownership claim for the runbook of the same name. A runbook that +already exists under that name is adopted -- the ownership markers are written +into its ``extra`` along with whatever else drifted -- after which prune may +delete it when the CR goes away. +""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.common import get_value +from openstack_sync.plugins.ironic.runbooks import client +from openstack_sync.plugins.ironic.runbooks.markers import is_managed_runbook +from openstack_sync.plugins.ironic.runbooks.markers import managed_extra +from openstack_sync.plugins.ironic.runbooks.markers import runbook_extra + +LOG = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Spec -> Ironic payload +# --------------------------------------------------------------------------- + + +def validate_spec(spec: dict[str, Any]) -> str: + """Return the runbook name once the spec is one Ironic can sustain. + + ``public`` with ``owner`` is refused rather than attempted: Ironic rejects an + owner on a public runbook, so such a CR would create once and then fail on + every update, looking like drift rather than like the CR it came from. + """ + name = str(spec.get("runbookName") or "") + if not name: + raise ConfigError("spec.runbookName must be set") + if spec.get("public") and spec.get("owner"): + raise ConfigError( + f"Runbook {name!r} sets both public and owner. Ironic does not allow " + "an owner on a public runbook. Drop spec.owner to share it with every " + "project, or set spec.public to false to keep it owned." + ) + return name + + +def _step_payload(index: int, step: Any) -> dict[str, Any]: + """Return one CR step as Ironic's runbook step. + + ``args`` is always sent: the CRD leaves it optional, but Ironic stores it + NOT NULL with no default, so omitting it fails the insert. + """ + if not isinstance(step, dict): + raise ConfigError(f"spec.steps[{index}] must be an object, got {step!r}") + + missing = [key for key in ("interface", "step", "order") if step.get(key) is None] + if missing: + raise ConfigError( + f"spec.steps[{index}] is missing required field(s): {', '.join(missing)}" + ) + + try: + order = int(step["order"]) + except (TypeError, ValueError) as exc: + raise ConfigError( + f"spec.steps[{index}].order must be an integer, got {step['order']!r}" + ) from exc + + return { + "interface": str(step["interface"]), + "step": str(step["step"]), + "args": step.get("args") or {}, + "order": order, + } + + +def desired_steps(spec: dict[str, Any]) -> list[dict[str, Any]]: + """Return the runbook steps *spec* describes, in Ironic's shape.""" + steps = spec.get("steps") + if not isinstance(steps, list) or not steps: + raise ConfigError("spec.steps must be a non-empty list") + return [_step_payload(index, step) for index, step in enumerate(steps)] + + +def canonical_steps(steps: Any) -> list[tuple[str, str, str, str]]: + """Return *steps* as an order-insensitive comparison key. + + Ironic does not promise to return steps in the order they were sent, and the + ``order`` field is what decides execution, so comparing positions would + report drift that is not there. ``order`` compares as text because Ironic + accepts an integer or a numeric string. + """ + if not isinstance(steps, list): + return [] + return sorted( + ( + str(step.get("interface", "")), + str(step.get("step", "")), + str(step.get("order", "")), + json.dumps(step.get("args") or {}, sort_keys=True), + ) + for step in steps + if isinstance(step, dict) + ) + + +def desired_extra(spec: dict[str, Any]) -> dict[str, Any]: + """Return the ``extra`` to store, with the ownership markers merged in.""" + return managed_extra(spec.get("extra") or {}) + + +def desired_traits(spec: dict[str, Any]) -> list[str]: + """Return the traits *spec* asks for.""" + return [str(trait) for trait in spec.get("traits") or []] + + +def build_payload(spec: dict[str, Any]) -> dict[str, Any]: + """Return the body that creates the runbook *spec* describes. + + ``owner`` is sent only when the spec sets one: Ironic assigns a + project-scoped requester's own project, and sending ``null`` would fight + that. Traits are absent because Ironic takes them only through the traits + sub-resource. + """ + payload: dict[str, Any] = { + "name": spec["runbookName"], + "steps": desired_steps(spec), + "public": bool(spec.get("public", False)), + "disable_ramdisk": bool(spec.get("disableRamdisk", False)), + "extra": desired_extra(spec), + } + if spec.get("owner"): + payload["owner"] = str(spec["owner"]) + if spec.get("description"): + payload["description"] = str(spec["description"]) + return payload + + +# --------------------------------------------------------------------------- +# The runbook +# --------------------------------------------------------------------------- + + +def _patch_operations( + existing: dict[str, Any], spec: dict[str, Any] +) -> list[dict[str, Any]]: + """Return the JSON patch that converges *existing* onto *spec*. + + Every operation is ``add``, which for an object member means "set it" whether + or not the member is there. ``name`` is absent because it is the identity the + runbook was looked up by, and ``traits`` because Ironic refuses them in a + patch body. + """ + operations: list[dict[str, Any]] = [] + + def set_field(field: str, value: Any) -> None: + operations.append({"op": "add", "path": f"/{field}", "value": value}) + + steps = desired_steps(spec) + if canonical_steps(existing.get("steps")) != canonical_steps(steps): + set_field("steps", steps) + + extra = desired_extra(spec) + if runbook_extra(existing) != extra: + set_field("extra", extra) + + public = bool(spec.get("public", False)) + if bool(existing.get("public", False)) != public: + set_field("public", public) + + disable_ramdisk = bool(spec.get("disableRamdisk", False)) + if bool(existing.get("disable_ramdisk", False)) != disable_ramdisk: + set_field("disable_ramdisk", disable_ramdisk) + + description = str(spec.get("description") or "") + if str(existing.get("description") or "") != description: + set_field("description", description) + + # Only when the spec claims the owner; see build_payload. + if spec.get("owner"): + owner = str(spec["owner"]) + if str(existing.get("owner") or "") != owner: + set_field("owner", owner) + + return operations + + +def ensure_runbook(conn: Any, spec: dict[str, Any]) -> dict[str, Any]: + """Create or converge the runbook *spec* describes, and return it.""" + name = str(spec["runbookName"]) + existing = client.get_runbook(conn, name) + + if existing is None: + payload = build_payload(spec) + LOG.info( + "Creating Ironic runbook %s with %s step(s)", name, len(payload["steps"]) + ) + return client.create_runbook(conn, payload) + + if is_managed_runbook(existing): + LOG.info("Ironic runbook %s already exists and is operator-owned", name) + else: + LOG.info( + "Adopting existing Ironic runbook %s; the CR is an ownership claim " + "for it, so the operator markers are being written to its extra", + name, + ) + + operations = _patch_operations(existing, spec) + if not operations: + return existing + + LOG.info( + "Updating Ironic runbook %s: %s", + name, + ", ".join(operation["path"] for operation in operations), + ) + return client.patch_runbook(conn, name, operations) + + +# --------------------------------------------------------------------------- +# Traits +# --------------------------------------------------------------------------- + + +def reconcile_traits( + conn: Any, runbook: dict[str, Any], spec: dict[str, Any] +) -> list[str]: + """Converge the traits of *runbook* onto *spec*, and return the result. + + Ironic returns a runbook's traits with the runbook, so this needs no read of + its own. Every trait on an operator-owned runbook is the operator's, so the + whole set is replaced when it differs. + """ + name = str(spec["runbookName"]) + desired = desired_traits(spec) + current = [str(trait) for trait in runbook.get("traits") or []] + if sorted(current) == sorted(desired): + return current + + LOG.info( + "Setting traits on Ironic runbook %s: have=%s want=%s", + name, + sorted(current), + sorted(desired), + ) + client.set_traits(conn, name, desired) + return desired + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def render_runbook(runbook: dict[str, Any]) -> dict[str, Any]: + """Return the reconciled runbook as a loggable dict. + + Step arguments are summarised, not logged: they carry hardware settings and, + for some interfaces, credentials. + """ + steps = runbook.get("steps") if isinstance(runbook.get("steps"), list) else [] + return { + "uuid": get_value(runbook, "uuid"), + "name": get_value(runbook, "name"), + "description": get_value(runbook, "description"), + "public": get_value(runbook, "public"), + "owner": get_value(runbook, "owner"), + "disable_ramdisk": get_value(runbook, "disable_ramdisk"), + "traits": sorted(str(trait) for trait in runbook.get("traits") or []), + "steps": [ + f"{step.get('order')}:{step.get('interface')}.{step.get('step')}" + for step in steps + if isinstance(step, dict) + ], + "extra_keys": sorted(runbook_extra(runbook)), + } + + +def sync_runbook(conn: Any, spec: dict[str, Any], _cache: Any = None) -> list[str]: + """Converge one IronicRunbook spec. + + Returns notes needing manual action, per the plugin contract. There are none + to report: every field of the CRD is reconcilable. + """ + name = validate_spec(spec) + + LOG.info("Reconciling Ironic runbook %s", name) + runbook = ensure_runbook(conn, spec) + traits = reconcile_traits(conn, runbook, spec) + + LOG.info( + "Reconciled Ironic runbook: %s", + # The traits the PUT just set are not in the body it answered with. + json.dumps(render_runbook({**runbook, "traits": traits}), sort_keys=True), + ) + return [] diff --git a/python/openstack-sync/tests/test_ironic_runbooks_hook.py b/python/openstack-sync/tests/test_ironic_runbooks_hook.py new file mode 100644 index 000000000..35a699007 --- /dev/null +++ b/python/openstack-sync/tests/test_ironic_runbooks_hook.py @@ -0,0 +1,384 @@ +"""Tests for the Ironic runbook hook wiring. + +The hook registers the right CRD watch, delegates reconcile and prune to the +plugin package, and processes CRs through the shared framework. What each +delegate does is covered in ``test_ironic_runbooks_reconcile.py`` and +``test_ironic_runbooks_prune.py``. +""" + +from __future__ import annotations + +import importlib +import json +import types +from pathlib import Path +from typing import Any +from unittest import mock + +import pytest + +from openstack_sync.hooks import ironic_runbooks as hook +from openstack_sync.hooks.framework import HookConfig +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.ironic.runbooks import markers +from openstack_sync.plugins.ironic.runbooks.config import BINDING_NAME +from openstack_sync.plugins.ironic.runbooks.config import ENV_PREFIX +from openstack_sync.plugins.ironic.runbooks.config import RUNBOOK_MICROVERSION +from tests.test_ironic_runbooks_reconcile import FakeBaremetal + +CRD_API_VERSION = "baremetal.ironicproject.org/v1alpha1" +CRD_KIND = "IronicRunbook" +CRD_RESOURCE = "ironicrunbooks.baremetal.ironicproject.org" + +RUNBOOK_NAME = "firmware-r740xd" + +ENV_NAMES = ( + "BINDING_CONTEXT_PATH", + f"{ENV_PREFIX}_ENABLED", + f"{ENV_PREFIX}_SYNC_CRONTAB", + f"{ENV_PREFIX}_PRUNE", + f"{ENV_PREFIX}_STATUS_ENABLED", + f"{ENV_PREFIX}_READY_RETRIES", + f"{ENV_PREFIX}_READY_DELAY", + f"{ENV_PREFIX}_CRD_API_VERSION", + f"{ENV_PREFIX}_CRD_KIND", + f"{ENV_PREFIX}_CRD_RESOURCE", + "POD_NAMESPACE", +) + + +def clear_env(monkeypatch: pytest.MonkeyPatch) -> None: + for name in ENV_NAMES: + monkeypatch.delenv(name, raising=False) + + +def set_crd_identity(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv(f"{ENV_PREFIX}_CRD_API_VERSION", CRD_API_VERSION) + monkeypatch.setenv(f"{ENV_PREFIX}_CRD_KIND", CRD_KIND) + monkeypatch.setenv(f"{ENV_PREFIX}_CRD_RESOURCE", CRD_RESOURCE) + + +def make_ironic_config(**overrides: Any) -> HookConfig: + defaults = { + "prefix": ENV_PREFIX, + "crd_api_version": CRD_API_VERSION, + "crd_kind": CRD_KIND, + "crd_resource": CRD_RESOURCE, + "binding_name": BINDING_NAME, + "namespace": "openstack", + "status_enabled": True, + "prune": False, + "sync_crontab": "", + "ready_retries": 30, + "ready_delay": 10.0, + } + return HookConfig(**{**defaults, **overrides}) + + +def ironic_runbook_object(name: str, spec: dict[str, Any] | None = None) -> dict: + runbook_spec: dict[str, Any] = { + "cloudCredentialsRef": { + "secretName": "infrasetup", + "cloudName": "understack", + }, + "runbookName": name, + "description": f"{name} description", + "public": True, + "traits": ["CUSTOM_DELL_POWEREDGE_R740XD"], + "steps": [ + { + "interface": "firmware", + "step": "update", + "args": {"settings": [{"component": "bios", "wait": 1200}]}, + "order": 1, + } + ], + } + runbook_spec.update(spec or {}) + return { + "apiVersion": CRD_API_VERSION, + "kind": CRD_KIND, + "metadata": {"name": name, "namespace": "openstack", "generation": 3}, + "spec": runbook_spec, + } + + +def write_binding_context(path: Path, contexts: list[dict[str, Any]]) -> str: + context_path = path / "binding-context.json" + context_path.write_text(json.dumps(contexts), encoding="utf-8") + return str(context_path) + + +def schedule_context(*names: str) -> list[dict[str, Any]]: + return [ + { + "binding": BINDING_NAME, + "type": "Schedule", + "snapshots": { + BINDING_NAME: [{"object": ironic_runbook_object(n)} for n in names] + }, + } + ] + + +def test_module_import_is_safe_with_bad_runtime_env(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setenv(f"{ENV_PREFIX}_READY_RETRIES", "not-a-number") + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "not-a-bool") + + importlib.reload(hook) + + +def test_config_flag_prints_disabled_startup_config( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + clear_env(monkeypatch) + monkeypatch.setattr(hook.sys, "argv", ["ironic_runbooks.py", "--config"]) + + assert hook.main() == 0 + assert json.loads(capsys.readouterr().out)["onStartup"] == 10 + + +def test_enabled_config_flag_watches_ironic_runbook_crd( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + clear_env(monkeypatch) + set_crd_identity(monkeypatch) + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setenv("POD_NAMESPACE", "openstack") + monkeypatch.setattr(hook.sys, "argv", ["ironic_runbooks.py", "--config"]) + + assert hook.main() == 0 + config = json.loads(capsys.readouterr().out) + (binding,) = config["kubernetes"] + assert binding["name"] == BINDING_NAME + assert binding["apiVersion"] == CRD_API_VERSION + assert binding["kind"] == CRD_KIND + assert binding["namespace"] == {"nameSelector": {"matchNames": ["openstack"]}} + assert "schedule" not in config + + +def test_enabled_config_flag_adds_schedule_when_configured( + monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] +): + clear_env(monkeypatch) + set_crd_identity(monkeypatch) + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setenv(f"{ENV_PREFIX}_SYNC_CRONTAB", "*/10 * * * *") + monkeypatch.setattr(hook.sys, "argv", ["ironic_runbooks.py", "--config"]) + + assert hook.main() == 0 + config = json.loads(capsys.readouterr().out) + (schedule,) = config["schedule"] + assert schedule["crontab"] == "*/10 * * * *" + assert schedule["includeSnapshotsFrom"] == [BINDING_NAME] + assert schedule["queue"] == BINDING_NAME + + +def test_plugin_reconcile_delegates_to_sync_runbook(): + plugin = hook.IronicRunbookPlugin(make_ironic_config()) + conn = mock.MagicMock() + cache: dict[str, Any] = {} + spec = {"runbookName": "CUSTOM_BIOS_R740XD", "steps": []} + + with mock.patch.object( + hook.reconcile_module, "sync_runbook", return_value=["a note"] + ) as sync_runbook: + notes = plugin.reconcile(conn, spec, cache) + + assert notes == ["a note"] + sync_runbook.assert_called_once_with(conn, spec, cache) + + +def test_plugin_waits_for_the_runbook_api_with_the_configured_budget(): + plugin = hook.IronicRunbookPlugin( + make_ironic_config(ready_retries=5, ready_delay=2) + ) + conn = mock.MagicMock() + + with mock.patch.object(hook.client, "wait_for_runbook_api") as wait: + plugin.wait_for_api(conn) + + wait.assert_called_once_with(conn, retries=5, delay=2) + + +def test_plugin_prunes_only_when_the_chart_enabled_it(): + """PRUNE is opt-in: deleting a runbook is not undone by re-adding the CR.""" + conn = mock.MagicMock() + specs = [{"runbookName": "CUSTOM_KEEP", "steps": []}] + + with mock.patch.object(hook.prune_module, "prune_removed_runbooks") as do_prune: + hook.IronicRunbookPlugin(make_ironic_config(prune=False)).prune( + conn, specs, authoritative_empty=False + ) + do_prune.assert_not_called() + + hook.IronicRunbookPlugin(make_ironic_config(prune=True)).prune( + conn, specs, authoritative_empty=True + ) + do_prune.assert_called_once_with(conn, specs, authoritative_empty=True) + + +def test_main_returns_zero_when_hook_disabled( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + clear_env(monkeypatch) + set_crd_identity(monkeypatch) + monkeypatch.setenv( + "BINDING_CONTEXT_PATH", + write_binding_context(tmp_path, schedule_context(RUNBOOK_NAME)), + ) + + with ( + mock.patch.object(hook.sys, "argv", ["ironic_runbooks.py"]), + mock.patch( + "openstack_sync.hooks.framework.get_openstack_connection" + ) as connect, + mock.patch("openstack_sync.hooks.framework.patch_resource_status") as status, + ): + assert hook.main() == 0 + + connect.assert_not_called() + status.assert_not_called() + + +def test_main_reconciles_the_runbook_and_reports_synced( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + clear_env(monkeypatch) + set_crd_identity(monkeypatch) + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setenv(f"{ENV_PREFIX}_STATUS_ENABLED", "true") + monkeypatch.setenv( + "BINDING_CONTEXT_PATH", + write_binding_context(tmp_path, schedule_context(RUNBOOK_NAME)), + ) + + with ( + mock.patch.object(hook.sys, "argv", ["ironic_runbooks.py"]), + mock.patch( + "openstack_sync.hooks.framework.get_openstack_connection", + return_value=mock.MagicMock(), + ) as connect, + mock.patch("openstack_sync.hooks.framework.patch_resource_status") as status, + mock.patch.object(hook.client, "wait_for_runbook_api"), + mock.patch.object( + hook.reconcile_module, "sync_runbook", return_value=[] + ) as sync_runbook, + ): + assert hook.main() == 0 + + connect.assert_called_once_with("infrasetup", "understack") + assert sync_runbook.call_args.args[1]["runbookName"] == RUNBOOK_NAME + assert status.call_args.kwargs["sync_status"] == "Synced" + assert status.call_args.kwargs["crd_kind"] == CRD_KIND + assert status.call_args.kwargs["crd_resource"] == CRD_RESOURCE + assert ( + status.call_args.kwargs["message"] == "Successfully reconciled ironic runbook" + ) + + +def test_main_reports_failed_when_the_runbook_cannot_be_reconciled( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + clear_env(monkeypatch) + set_crd_identity(monkeypatch) + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + monkeypatch.setenv(f"{ENV_PREFIX}_STATUS_ENABLED", "true") + monkeypatch.setenv(f"{ENV_PREFIX}_PRUNE", "true") + monkeypatch.setenv( + "BINDING_CONTEXT_PATH", + write_binding_context(tmp_path, schedule_context(RUNBOOK_NAME)), + ) + + with ( + mock.patch.object(hook.sys, "argv", ["ironic_runbooks.py"]), + mock.patch( + "openstack_sync.hooks.framework.get_openstack_connection", + return_value=mock.MagicMock(), + ), + mock.patch("openstack_sync.hooks.framework.patch_resource_status") as status, + mock.patch.object(hook.client, "wait_for_runbook_api"), + mock.patch.object( + hook.reconcile_module, + "sync_runbook", + side_effect=ConfigError("steps must be a non-empty list"), + ), + mock.patch.object(hook.prune_module, "prune_removed_runbooks") as do_prune, + ): + assert hook.main() == 1 + + assert status.call_args.kwargs["sync_status"] == "Failed" + assert status.call_args.kwargs["message"] == "steps must be a non-empty list" + # The desired set is unknown once a CR failed, so nothing may be deleted. + do_prune.assert_not_called() + + +# --------------------------------------------------------------------------- +# End to end +# --------------------------------------------------------------------------- + + +def test_main_creates_then_prunes_against_a_fake_ironic( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +): + """One pass through the whole chain with nothing below the hook mocked. + + Binding context -> framework -> reconcile -> runbook client -> Ironic routes, + then the same for prune once the CR is gone. Only the connection, the + microversion discovery and kubectl are stood in for. + """ + fake = FakeBaremetal() + conn = types.SimpleNamespace(baremetal=fake) + + def run(contexts: list[dict[str, Any]], prune: str) -> int: + monkeypatch.setenv( + "BINDING_CONTEXT_PATH", write_binding_context(tmp_path, contexts) + ) + monkeypatch.setenv(f"{ENV_PREFIX}_PRUNE", prune) + with ( + mock.patch.object(hook.sys, "argv", ["ironic_runbooks.py"]), + mock.patch( + "openstack_sync.hooks.framework.get_openstack_connection", + return_value=conn, + ), + mock.patch("openstack_sync.hooks.framework.patch_resource_status"), + mock.patch.object( + hook.client.openstack_utils, + "maximum_supported_microversion", + return_value=RUNBOOK_MICROVERSION, + ), + ): + return hook.main() + + clear_env(monkeypatch) + set_crd_identity(monkeypatch) + monkeypatch.setenv(f"{ENV_PREFIX}_ENABLED", "true") + + assert run(schedule_context(RUNBOOK_NAME), "false") == 0 + created = fake.runbooks[RUNBOOK_NAME] + assert created["steps"][0]["args"] == { + "settings": [{"component": "bios", "wait": 1200}] + } + assert created["description"] == f"{RUNBOOK_NAME} description" + assert created["traits"] == ["CUSTOM_DELL_POWEREDGE_R740XD"] + assert markers.is_managed_runbook(created) + + # A second pass over an unchanged CR must not write anything. + fake.calls.clear() + assert run(schedule_context(RUNBOOK_NAME), "false") == 0 + assert fake.calls_for("PATCH") == [] + assert fake.calls_for("POST") == [] + assert fake.calls_for("PUT") == [] + + # The CR is deleted: with PRUNE on, the runbook goes with it. + deleted = [ + { + "binding": BINDING_NAME, + "type": "Event", + "watchEvent": "Deleted", + "object": ironic_runbook_object(RUNBOOK_NAME), + "snapshots": {BINDING_NAME: []}, + } + ] + assert run(deleted, "true") == 0 + assert fake.runbooks == {} diff --git a/python/openstack-sync/tests/test_ironic_runbooks_prune.py b/python/openstack-sync/tests/test_ironic_runbooks_prune.py new file mode 100644 index 000000000..a3001f21d --- /dev/null +++ b/python/openstack-sync/tests/test_ironic_runbooks_prune.py @@ -0,0 +1,128 @@ +"""Tests for Ironic runbook prune behaviour. + +Pruning is gated entirely on the operator's ownership marker, so these tests are +mostly about what must *not* be deleted. Whether pruning runs at all is the +hook's decision (``config.prune``), tested in ``test_ironic_runbooks_hook.py``. +""" + +from __future__ import annotations + +from typing import Any +from unittest import mock + +import pytest +from openstack import exceptions as openstack_exceptions + +from openstack_sync.plugins.ironic.runbooks import markers +from openstack_sync.plugins.ironic.runbooks import prune +from openstack_sync.plugins.ironic.runbooks.config import RUNBOOK_MICROVERSION +from tests.test_ironic_runbooks_reconcile import FakeBaremetal +from tests.test_ironic_runbooks_reconcile import _conn + + +def _owned(name: str) -> dict[str, Any]: + return { + "uuid": f"{name}-uuid", + "name": name, + "steps": [], + "extra": markers.managed_extra({"version": "1.0.0"}), + } + + +def _unowned(name: str) -> dict[str, Any]: + return {"uuid": f"{name}-uuid", "name": name, "steps": [], "extra": {}} + + +def _spec(name: str) -> dict[str, Any]: + return {"runbookName": name, "steps": []} + + +def _prune(fake: FakeBaremetal, specs: list[dict[str, Any]], **kwargs: Any) -> None: + prune.prune_removed_runbooks(_conn(fake), specs, **kwargs) + + +def test_owned_runbook_absent_from_the_desired_set_is_deleted(): + fake = FakeBaremetal([_owned("CUSTOM_KEEP"), _owned("CUSTOM_GONE")]) + + _prune(fake, [_spec("CUSTOM_KEEP")]) + + assert sorted(fake.runbooks) == ["CUSTOM_KEEP"] + assert fake.calls_for("DELETE") == ["/runbooks/CUSTOM_GONE"] + + +def test_runbook_the_operator_does_not_own_is_kept(): + """A hand-made runbook is not the operator's to delete.""" + fake = FakeBaremetal([_unowned("CUSTOM_HANDMADE")]) + + _prune(fake, [_spec("CUSTOM_KEEP")]) + + assert sorted(fake.runbooks) == ["CUSTOM_HANDMADE"] + assert fake.calls_for("DELETE") == [] + + +def test_runbook_without_a_name_is_skipped(): + fake = FakeBaremetal() + fake.runbooks["unnamed"] = {"uuid": "u", "extra": markers.managed_extra({})} + + _prune(fake, [_spec("CUSTOM_KEEP")]) + + assert fake.calls_for("DELETE") == [] + + +def test_empty_desired_set_is_refused_unless_a_cr_was_deleted(): + """An unreadable snapshot must not read as "delete everything".""" + fake = FakeBaremetal([_owned("CUSTOM_GONE")]) + + _prune(fake, []) + assert sorted(fake.runbooks) == ["CUSTOM_GONE"] + assert fake.calls == [] + + _prune(fake, [], authoritative_empty=True) + assert fake.runbooks == {} + + +def test_a_runbook_deleted_out_of_band_is_not_an_error(): + fake = FakeBaremetal([_owned("CUSTOM_GONE")]) + + def vanish(path: str, method: str, **kwargs: Any) -> Any: + if method == "DELETE": + fake.calls.append((method, path)) + fake.bodies.append(None) + fake.microversions.append(RUNBOOK_MICROVERSION) + raise openstack_exceptions.NotFoundException("already gone") + return FakeBaremetal.request(fake, path, method, **kwargs) + + with mock.patch.object(fake, "request", side_effect=vanish): + _prune(fake, [_spec("CUSTOM_KEEP")]) + + assert fake.calls_for("DELETE") == ["/runbooks/CUSTOM_GONE"] + + +def test_a_conflict_leaves_the_runbook_in_place(): + fake = FakeBaremetal([_owned("CUSTOM_GONE")]) + + def conflict(path: str, method: str, **kwargs: Any) -> Any: + if method == "DELETE": + raise openstack_exceptions.ConflictException("still in use") + return FakeBaremetal.request(fake, path, method, **kwargs) + + with mock.patch.object(fake, "request", side_effect=conflict): + _prune(fake, [_spec("CUSTOM_KEEP")]) + + assert sorted(fake.runbooks) == ["CUSTOM_GONE"] + + +def test_a_failure_other_than_conflict_or_not_found_stops_the_prune(): + """The framework reports a failed prune as a non-zero exit.""" + fake = FakeBaremetal([_owned("CUSTOM_GONE")]) + + def forbidden(path: str, method: str, **kwargs: Any) -> Any: + if method == "DELETE": + raise openstack_exceptions.ForbiddenException("not allowed") + return FakeBaremetal.request(fake, path, method, **kwargs) + + with ( + mock.patch.object(fake, "request", side_effect=forbidden), + pytest.raises(openstack_exceptions.ForbiddenException), + ): + _prune(fake, [_spec("CUSTOM_KEEP")]) diff --git a/python/openstack-sync/tests/test_ironic_runbooks_reconcile.py b/python/openstack-sync/tests/test_ironic_runbooks_reconcile.py new file mode 100644 index 000000000..c1978974d --- /dev/null +++ b/python/openstack-sync/tests/test_ironic_runbooks_reconcile.py @@ -0,0 +1,640 @@ +"""Tests for Ironic runbook reconciliation. + +Covers the spec-to-payload translation, create-or-adopt by name, the drift patch +and what it leaves alone, and trait convergence. + +The fake below answers Ironic's runbook routes with real ``requests.Response`` +objects, so ``raise_from_response`` runs for real rather than being stubbed. Any +route it does not model answers 405, so a call this plugin should not be making +fails the test that makes it. +""" + +from __future__ import annotations + +import json +import types +from typing import Any +from unittest import mock + +import pytest +import requests +from openstack import exceptions as openstack_exceptions + +from openstack_sync.plugins.common import ConfigError +from openstack_sync.plugins.ironic.runbooks import client +from openstack_sync.plugins.ironic.runbooks import markers +from openstack_sync.plugins.ironic.runbooks import reconcile +from openstack_sync.plugins.ironic.runbooks.config import RUNBOOK_MICROVERSION + +_NAME = "bmc-maintenance" + + +# --------------------------------------------------------------------------- +# Fake Ironic +# --------------------------------------------------------------------------- + + +def _response(status_code: int, body: Any = None) -> requests.Response: + response = requests.Response() + response.status_code = status_code + response.reason = "fake" + if body is not None: + response.headers["content-type"] = "application/json" + response._content = json.dumps(body).encode("utf-8") + else: + response._content = b"" + return response + + +class FakeBaremetal: + """In-memory stand-in for Ironic's runbook endpoints.""" + + def __init__(self, runbooks: list[dict[str, Any]] | None = None) -> None: + self.runbooks = {book["name"]: dict(book) for book in runbooks or []} + #: Every call as ``(method, path)``, in order. + self.calls: list[tuple[str, str]] = [] + self.bodies: list[Any] = [] + self.microversions: list[str] = [] + + # -- helpers for assertions --------------------------------------------- + + def calls_for(self, method: str) -> list[str]: + return [path for call_method, path in self.calls if call_method == method] + + def _bodies_for(self, method: str) -> list[Any]: + return [ + body + for (call_method, _), body in zip(self.calls, self.bodies, strict=True) + if call_method == method + ] + + @property + def patches(self) -> list[Any]: + return self._bodies_for("PATCH") + + @property + def trait_writes(self) -> list[Any]: + return self._bodies_for("PUT") + + @property + def created(self) -> Any: + posted = self._bodies_for("POST") + return posted[0] if posted else None + + def traits_of(self, name: str) -> list[str]: + return list(self.runbooks[name].get("traits") or []) + + # -- the API ------------------------------------------------------------ + + def request( + self, + path: str, + method: str, + microversion: str | None = None, + params: dict[str, Any] | None = None, + json: Any = None, + ) -> requests.Response: + self.calls.append((method, path)) + self.bodies.append(json) + self.microversions.append(str(microversion)) + + parts = path.strip("/").split("/") + if parts[0] != "runbooks": + return _response(404, {"error_message": f"no route {path}"}) + + if len(parts) == 1: + if method == "GET": + return _response(200, {"runbooks": list(self.runbooks.values())}) + if method == "POST": + book = dict(json) + # Ironic rejects traits in a create body and answers with the + # empty set it stored. + if "traits" in book: + return _response(400, {"error_message": "traits not allowed"}) + book["traits"] = [] + self.runbooks[book["name"]] = book + return _response(201, book) + + if len(parts) == 2: + name = parts[1] + book = self.runbooks.get(name) + if book is None: + return _response(404, {"error_message": f"no runbook {name}"}) + if method == "GET": + return _response(200, book) + if method == "PATCH": + for operation in json: + field = operation["path"].lstrip("/") + if field == "traits": + return _response(400, {"error_message": "traits not patchable"}) + book[field] = operation["value"] + return _response(200, book) + if method == "DELETE": + del self.runbooks[name] + return _response(204) + + # Only the whole-collection PUT is modelled: that is the one call this + # plugin makes. + if len(parts) == 3 and parts[2] == "traits": + name = parts[1] + book = self.runbooks.get(name) + if book is None: + return _response(404, {"error_message": f"no runbook {name}"}) + if method == "PUT": + book["traits"] = list((json or {}).get("traits") or []) + return _response(204) + + return _response(405, {"error_message": f"{method} {path} not allowed"}) + + +def _conn(fake: FakeBaremetal) -> Any: + return types.SimpleNamespace(baremetal=fake) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _spec(**overrides: Any) -> dict[str, Any]: + """A CR spec as the API server materialises it, with defaults applied.""" + spec: dict[str, Any] = { + "runbookName": _NAME, + "description": "Performs BMC maintenance", + "public": True, + "disableRamdisk": True, + "traits": ["CUSTOM_DELL_IDRAC"], + "steps": [ + {"interface": "management", "step": "clear_job_queue", "order": 1}, + {"interface": "management", "step": "set_bmc_clock", "order": 2}, + ], + "extra": {"version": "1.0.0"}, + } + spec.update(overrides) + return spec + + +def _runbook(**overrides: Any) -> dict[str, Any]: + """An Ironic runbook that matches ``_spec()`` exactly.""" + book: dict[str, Any] = { + "uuid": "runbook-uuid", + "name": _NAME, + "description": "Performs BMC maintenance", + "public": True, + "owner": None, + "disable_ramdisk": True, + "traits": ["CUSTOM_DELL_IDRAC"], + "steps": [ + { + "interface": "management", + "step": "clear_job_queue", + "args": {}, + "order": 1, + }, + { + "interface": "management", + "step": "set_bmc_clock", + "args": {}, + "order": 2, + }, + ], + "extra": markers.managed_extra({"version": "1.0.0"}), + } + book.update(overrides) + return book + + +# --------------------------------------------------------------------------- +# Spec validation +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "name", ["bmc-maintenance", "CUSTOM_BMC_MAINTENANCE", "firmware.r740xd_2.23~0"] +) +def test_any_url_safe_runbook_name_is_accepted(name: str): + """A runbook name is a logical name; eligibility comes from spec.traits.""" + assert reconcile.validate_spec(_spec(runbookName=name)) == name + + +@pytest.mark.parametrize("name", ["", None]) +def test_a_runbook_without_a_name_fails_the_cr(name: Any): + with pytest.raises(ConfigError, match="spec.runbookName must be set"): + reconcile.validate_spec(_spec(runbookName=name)) + + +def test_a_public_runbook_may_not_also_have_an_owner(): + """Ironic's runbook PATCH refuses an owner on a public runbook. + + Such a CR would create once and fail on every update after that, so it is + refused up front where the message can name the CR fields. + """ + fake = FakeBaremetal() + + with pytest.raises(ConfigError, match="both public and owner"): + reconcile.sync_runbook(_conn(fake), _spec(owner="project-123")) + + assert fake.calls == [] + + +def test_an_owned_private_runbook_is_fine(): + assert reconcile.validate_spec(_spec(public=False, owner="project-123")) == _NAME + + +# --------------------------------------------------------------------------- +# Spec -> payload +# --------------------------------------------------------------------------- + + +def test_steps_always_carry_args(): + """Ironic stores step args NOT NULL with no default.""" + steps = reconcile.desired_steps(_spec()) + + assert [step["args"] for step in steps] == [{}, {}] + assert steps[0] == { + "interface": "management", + "step": "clear_job_queue", + "args": {}, + "order": 1, + } + + +def test_steps_keep_supplied_args_and_coerce_order(): + steps = reconcile.desired_steps( + _spec( + steps=[ + { + "interface": "bios", + "step": "apply_configuration", + "order": "3", + "args": {"settings": [{"name": "LogicalProc"}]}, + } + ] + ) + ) + + assert steps == [ + { + "interface": "bios", + "step": "apply_configuration", + "args": {"settings": [{"name": "LogicalProc"}]}, + "order": 3, + } + ] + + +@pytest.mark.parametrize( + ("steps", "match"), + [ + ([], "non-empty list"), + (None, "non-empty list"), + (["not-an-object"], "must be an object"), + ([{"interface": "bios", "order": 1}], "missing required field"), + ([{"interface": "bios", "step": "x", "order": "later"}], "must be an integer"), + ], +) +def test_step_problems_fail_the_cr_by_name(steps: Any, match: str): + with pytest.raises(ConfigError, match=match): + reconcile.desired_steps(_spec(steps=steps)) + + +def test_payload_omits_owner_when_the_spec_does_not_claim_one(): + """Ironic assigns a project-scoped requester's project as the owner. + + Sending null would fight that assignment on every cycle. + """ + payload = reconcile.build_payload(_spec()) + + assert "owner" not in payload + assert payload["public"] is True + assert payload["disable_ramdisk"] is True + assert payload["description"] == "Performs BMC maintenance" + assert payload["extra"] == markers.managed_extra({"version": "1.0.0"}) + + +def test_payload_carries_the_owner_the_spec_claims(): + payload = reconcile.build_payload(_spec(public=False, owner="project-123")) + + assert payload["owner"] == "project-123" + + +def test_payload_never_sends_traits(): + """Ironic rejects traits in a create or patch body.""" + assert "traits" not in reconcile.build_payload(_spec()) + + +# --------------------------------------------------------------------------- +# Create +# --------------------------------------------------------------------------- + + +def test_create_when_the_runbook_is_absent(): + fake = FakeBaremetal() + + assert reconcile.sync_runbook(_conn(fake), _spec()) == [] + + assert fake.calls_for("POST") == ["/runbooks"] + assert fake.created["name"] == _NAME + assert fake.created["extra"][markers.MANAGED_EXTRA_KEY] == ( + markers.MANAGED_EXTRA_VALUE + ) + assert fake.microversions == [RUNBOOK_MICROVERSION] * len(fake.calls) + + +def test_create_sets_traits_through_the_sub_resource(): + fake = FakeBaremetal() + + reconcile.sync_runbook(_conn(fake), _spec()) + + assert fake.calls_for("PUT") == [f"/runbooks/{_NAME}/traits"] + assert fake.trait_writes == [{"traits": ["CUSTOM_DELL_IDRAC"]}] + assert fake.traits_of(_NAME) == ["CUSTOM_DELL_IDRAC"] + + +def test_create_without_traits_writes_none(): + fake = FakeBaremetal() + + reconcile.sync_runbook(_conn(fake), _spec(traits=[])) + + assert fake.calls_for("PUT") == [] + + +# --------------------------------------------------------------------------- +# Converged +# --------------------------------------------------------------------------- + + +def test_converged_runbook_is_not_written_to_at_all(): + """A needless PATCH is a Modified event the hook watches, so it requeues.""" + fake = FakeBaremetal([_runbook()]) + + assert reconcile.sync_runbook(_conn(fake), _spec()) == [] + assert fake.calls == [("GET", f"/runbooks/{_NAME}")] + + +def test_step_order_from_ironic_does_not_count_as_drift(): + """Ironic does not promise to return steps in the order they were sent.""" + book = _runbook() + book["steps"] = list(reversed(book["steps"])) + fake = FakeBaremetal([book]) + + reconcile.sync_runbook(_conn(fake), _spec()) + + assert fake.patches == [] + + +def test_trait_order_from_ironic_does_not_count_as_drift(): + fake = FakeBaremetal([_runbook(traits=["CUSTOM_B", "CUSTOM_A"])]) + + reconcile.sync_runbook(_conn(fake), _spec(traits=["CUSTOM_A", "CUSTOM_B"])) + + assert fake.calls_for("PUT") == [] + + +# --------------------------------------------------------------------------- +# Drift +# --------------------------------------------------------------------------- + + +def test_a_runbook_with_no_steps_at_all_is_patched_back(): + """Ironic omits ``steps`` from a fields-limited response; treat it as empty.""" + book = _runbook() + del book["steps"] + fake = FakeBaremetal([book]) + + reconcile.sync_runbook(_conn(fake), _spec()) + + (patch,) = fake.patches + assert patch[0]["path"] == "/steps" + + +def test_step_drift_is_patched(): + book = _runbook() + book["steps"] = book["steps"][:1] + fake = FakeBaremetal([book]) + + reconcile.sync_runbook(_conn(fake), _spec()) + + (patch,) = fake.patches + assert patch == [ + {"op": "add", "path": "/steps", "value": reconcile.desired_steps(_spec())} + ] + + +def test_extra_drift_is_patched_with_the_markers_intact(): + fake = FakeBaremetal([_runbook(extra=markers.managed_extra({"version": "0.9.0"}))]) + + reconcile.sync_runbook(_conn(fake), _spec()) + + (patch,) = fake.patches + assert patch[0]["path"] == "/extra" + assert patch[0]["value"] == markers.managed_extra({"version": "1.0.0"}) + + +def test_unowned_runbook_is_adopted_by_stamping_its_extra(): + """The CR is an ownership claim; adoption is what makes prune safe later.""" + fake = FakeBaremetal([_runbook(extra={"version": "1.0.0"})]) + + reconcile.sync_runbook(_conn(fake), _spec()) + + (patch,) = fake.patches + assert patch == [ + { + "op": "add", + "path": "/extra", + "value": markers.managed_extra({"version": "1.0.0"}), + } + ] + assert markers.is_managed_runbook(fake.runbooks[_NAME]) + + +def test_public_drift_is_patched(): + fake = FakeBaremetal([_runbook(public=False)]) + + reconcile.sync_runbook(_conn(fake), _spec()) + + (patch,) = fake.patches + assert patch == [{"op": "add", "path": "/public", "value": True}] + + +def test_disable_ramdisk_drift_is_patched(): + fake = FakeBaremetal([_runbook(disable_ramdisk=False)]) + + assert reconcile.sync_runbook(_conn(fake), _spec()) == [] + + (patch,) = fake.patches + assert patch == [{"op": "add", "path": "/disable_ramdisk", "value": True}] + assert fake.runbooks[_NAME]["disable_ramdisk"] is True + + +def test_description_drift_is_patched(): + fake = FakeBaremetal([_runbook(description="stale")]) + + reconcile.sync_runbook(_conn(fake), _spec(description="fresh")) + + (patch,) = fake.patches + assert patch == [{"op": "add", "path": "/description", "value": "fresh"}] + + +def test_a_dropped_description_is_cleared(): + fake = FakeBaremetal([_runbook()]) + spec = _spec() + del spec["description"] + + reconcile.sync_runbook(_conn(fake), spec) + + (patch,) = fake.patches + assert patch == [{"op": "add", "path": "/description", "value": ""}] + + +def test_owner_is_patched_only_when_the_spec_claims_one(): + """An unset owner is Ironic's to assign, so it is left as Ironic set it.""" + fake = FakeBaremetal([_runbook(public=False, owner="project-123")]) + private = _spec(public=False) + + reconcile.sync_runbook(_conn(fake), private) + assert fake.patches == [] + + reconcile.sync_runbook(_conn(fake), {**private, "owner": "project-456"}) + (patch,) = fake.patches + assert patch == [{"op": "add", "path": "/owner", "value": "project-456"}] + + +def test_patch_uses_add_so_it_works_on_fields_ironic_omits(): + """``replace`` on a member Ironic does not return is rejected by the patch.""" + fake = FakeBaremetal([_runbook(public=False, description="stale")]) + + reconcile.sync_runbook(_conn(fake), _spec()) + + assert {operation["op"] for patch in fake.patches for operation in patch} == {"add"} + + +# --------------------------------------------------------------------------- +# Traits +# --------------------------------------------------------------------------- + + +def test_traits_are_replaced_in_one_request(): + """One PUT for the whole set, so no node sees a half-applied runbook.""" + fake = FakeBaremetal([_runbook(traits=["CUSTOM_STALE", "CUSTOM_DELL_IDRAC"])]) + + reconcile.sync_runbook( + _conn(fake), _spec(traits=["CUSTOM_DELL_IDRAC", "CUSTOM_NEW"]) + ) + + assert fake.calls_for("PUT") == [f"/runbooks/{_NAME}/traits"] + assert fake.trait_writes == [{"traits": ["CUSTOM_DELL_IDRAC", "CUSTOM_NEW"]}] + assert fake.traits_of(_NAME) == ["CUSTOM_DELL_IDRAC", "CUSTOM_NEW"] + + +def test_dropping_every_trait_clears_them(): + fake = FakeBaremetal([_runbook()]) + + reconcile.sync_runbook(_conn(fake), _spec(traits=[])) + + assert fake.trait_writes == [{"traits": []}] + assert fake.traits_of(_NAME) == [] + + +# --------------------------------------------------------------------------- +# Microversion +# --------------------------------------------------------------------------- + + +def _check(reported: str | None) -> None: + with mock.patch.object( + client.openstack_utils, + "maximum_supported_microversion", + return_value=reported, + ): + client.check_microversion(_conn(FakeBaremetal())) + + +def test_check_microversion_accepts_a_cloud_at_the_required_version(): + _check(RUNBOOK_MICROVERSION) + + +def test_check_microversion_rejects_a_cloud_that_is_too_old(): + with pytest.raises(ConfigError, match=f"requires {RUNBOOK_MICROVERSION}"): + _check("1.101") + + +def test_check_microversion_rejects_an_undiscoverable_endpoint(): + with pytest.raises(ConfigError, match="Could not determine"): + _check(None) + + +def test_check_microversion_rejects_a_version_it_cannot_compare(): + with pytest.raises(ConfigError, match="unusable API microversion"): + _check("latest") + + +def test_readiness_probe_does_not_retry_a_cloud_that_cannot_be_fixed(): + """A too-old Ironic will not become new by waiting retries * delay seconds.""" + conn = _conn(FakeBaremetal()) + + with ( + mock.patch.object( + client.openstack_utils, + "maximum_supported_microversion", + return_value="1.101", + ), + mock.patch("openstack_sync.plugins.common.time.sleep") as sleep, + pytest.raises(ConfigError), + ): + client.wait_for_runbook_api(conn, retries=5, delay=0) + + sleep.assert_not_called() + + +def test_readiness_probe_lists_runbooks_so_policy_failures_surface_early(): + fake = FakeBaremetal() + + with mock.patch.object( + client.openstack_utils, + "maximum_supported_microversion", + return_value=RUNBOOK_MICROVERSION, + ): + client.wait_for_runbook_api(_conn(fake), retries=1, delay=0) + + assert fake.calls == [("GET", "/runbooks")] + + +# --------------------------------------------------------------------------- +# Client edges +# --------------------------------------------------------------------------- + + +def test_get_runbook_returns_none_for_an_absent_name(): + assert client.get_runbook(_conn(FakeBaremetal()), _NAME) is None + + +def test_client_raises_typed_errors_for_other_failures(): + fake = FakeBaremetal() + # POST /runbooks//traits is not a route Ironic serves. + with pytest.raises(openstack_exceptions.HttpException): + client._request(_conn(fake), "POST", f"/runbooks/{_NAME}/traits") + + +def test_delete_runbook_treats_an_absent_runbook_as_done(): + client.delete_runbook(_conn(FakeBaremetal()), _NAME) + + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- + + +def test_render_runbook_summarises_steps_without_their_args(): + """Step args carry hardware settings and, for some interfaces, secrets.""" + rendered = reconcile.render_runbook( + _runbook( + steps=[{"interface": "bios", "step": "apply", "args": {"p": "s3cret"}}] + ) + ) + + assert rendered["steps"] == ["None:bios.apply"] + assert "s3cret" not in json.dumps(rendered) + assert rendered["traits"] == ["CUSTOM_DELL_IDRAC"] + assert rendered["description"] == "Performs BMC maintenance" + assert rendered["extra_keys"] == sorted(markers.managed_extra({"version": "1.0.0"})) diff --git a/python/openstack-sync/tests/test_plugins_common.py b/python/openstack-sync/tests/test_plugins_common.py index 8e4b2368f..bf0006f85 100644 --- a/python/openstack-sync/tests/test_plugins_common.py +++ b/python/openstack-sync/tests/test_plugins_common.py @@ -2,6 +2,8 @@ from __future__ import annotations +from unittest import mock + import pytest from openstack import exceptions as sdk_exceptions from openstack.network.v2 import flavor as sdk_flavor @@ -111,3 +113,52 @@ def test_meta_info_payload_canonicalizes_json_strings(): def test_normalize_meta_info_leaves_non_json_strings_unchanged(): assert common.normalize_meta_info("{'b': 2, 'a': 1}") == "{'b': 2, 'a': 1}" + + +# --------------------------------------------------------------------------- +# API readiness +# --------------------------------------------------------------------------- + + +def test_wait_for_openstack_api_returns_as_soon_as_the_probe_succeeds(): + probe = mock.Mock(side_effect=[RuntimeError("not yet"), None]) + + with mock.patch.object(common.time, "sleep") as sleep: + common.wait_for_openstack_api("Ironic", probe, retries=5, delay=1) + + assert probe.call_count == 2 + sleep.assert_called_once_with(1) + + +def test_wait_for_openstack_api_gives_up_after_retries(): + probe = mock.Mock(side_effect=RuntimeError("down")) + + with ( + mock.patch.object(common.time, "sleep"), + pytest.raises(RuntimeError, match="Ironic API did not become ready after 3"), + ): + common.wait_for_openstack_api("Ironic", probe, retries=3, delay=0) + + assert probe.call_count == 3 + + +def test_wait_for_openstack_api_does_not_retry_a_config_error(): + """A misconfigured or too-old API does not become ready by waiting.""" + probe = mock.Mock(side_effect=common.ConfigError("this cloud is too old")) + + with ( + mock.patch.object(common.time, "sleep") as sleep, + pytest.raises(common.ConfigError), + ): + common.wait_for_openstack_api("Ironic", probe, retries=30, delay=10) + + assert probe.call_count == 1 + sleep.assert_not_called() + + +def test_wait_for_openstack_network_probes_neutron_flavors(): + conn = mock.MagicMock() + + common.wait_for_openstack_network(conn, retries=1, delay=0) + + conn.network.flavors.assert_called_once_with() diff --git a/schema/openstack-sync/ironic-runbook.schema.json b/schema/openstack-sync/ironic-runbook.schema.json new file mode 100644 index 000000000..667bdc3d4 --- /dev/null +++ b/schema/openstack-sync/ironic-runbook.schema.json @@ -0,0 +1,142 @@ +{ + "$schema": "https://json-schema.org/draft-07/schema#", + "$id": "https://rackerlabs.github.io/understack/schema/openstack-sync/ironic-runbook.schema.json", + "title": "OpenStack Sync Ironic Runbook Spec", + "description": "Schema for Ironic runbook spec data consumed by openstack-sync. When attached to an IronicRunbook custom resource, only spec is constrained.", + "oneOf": [ + { + "$ref": "#/definitions/ironicRunbookSpec" + }, + { + "type": "object", + "additionalProperties": true, + "properties": { + "spec": { + "$ref": "#/definitions/ironicRunbookSpec" + } + }, + "required": [ + "spec" + ] + } + ], + "definitions": { + "cloudCredentialsRef": { + "description": "Reference to a Kubernetes Secret containing the OpenStack clouds.yaml.", + "type": "object", + "additionalProperties": false, + "required": ["secretName", "cloudName"], + "properties": { + "secretName": { + "type": "string", + "minLength": 1, + "maxLength": 253 + }, + "cloudName": { + "type": "string", + "minLength": 1, + "maxLength": 256 + } + } + }, + "ironicRunbookSpec": { + "description": "Ironic runbook data stored under spec.", + "type": "object", + "additionalProperties": false, + "required": ["cloudCredentialsRef", "runbookName", "steps"], + "properties": { + "cloudCredentialsRef": { + "$ref": "#/definitions/cloudCredentialsRef" + }, + "runbookName": { + "description": "Runbook name, and the identity the operator syncs by. Renaming creates a new runbook rather than renaming the existing one.", + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^[A-Za-z0-9._~-]+$" + }, + "description": { + "description": "Human-readable runbook description.", + "type": "string", + "maxLength": 255 + }, + "traits": { + "description": "Traits deciding which nodes this runbook may act on. A node must carry them all; a runbook with no traits matches no nodes.", + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^CUSTOM_[A-Z0-9_]+$" + }, + "default": [] + }, + "steps": { + "description": "Ordered runbook steps.", + "type": "array", + "minItems": 1, + "items": { + "$ref": "#/definitions/runbookStep" + } + }, + "disableRamdisk": { + "description": "Whether to run without booting the cleaning ramdisk.", + "type": "boolean", + "default": false + }, + "public": { + "description": "Whether the runbook is available to all projects. A public runbook cannot have an owner.", + "type": "boolean", + "default": false + }, + "owner": { + "description": "Project that owns this runbook. Leave unset to let Ironic assign the credentials' own project.", + "type": "string", + "maxLength": 255 + }, + "extra": { + "description": "Additional runbook metadata. The operator also keeps its ownership markers here, under _understack_runbook_ keys.", + "type": "object", + "additionalProperties": true + } + } + }, + "runbookStep": { + "description": "A single Ironic runbook step.", + "type": "object", + "additionalProperties": false, + "required": ["interface", "step", "order"], + "properties": { + "interface": { + "description": "Interface that owns this cleaning step.", + "type": "string", + "enum": [ + "bios", + "deploy", + "firmware", + "management", + "power", + "raid", + "vendor" + ] + }, + "step": { + "description": "Step name for the selected interface.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "args": { + "description": "Step-specific arguments.", + "type": "object", + "additionalProperties": true + }, + "order": { + "description": "Execution order. Lower numbers run first.", + "type": "integer", + "minimum": 0 + } + } + } + } +}