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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions components/ironic/runbook-crd/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,52 @@ spec:
| `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 execute a runbook by
transitioning a node into the `clean` provisioning state with the runbook
specified. The node must be in `manageable` state before you can trigger
cleaning.

### OpenStack CLI

```bash
# Run a runbook by name
openstack baremetal node clean <node-uuid> --runbook CUSTOM_BMC_MAINTENANCE

# Check node state while cleaning runs
openstack baremetal node show <node-uuid> -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 <node-uuid> 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/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,25 @@ spec:
- steps
properties:
runbookName:
description: 'RunbookName is the unique name of the runbook (REQUIRED). Must match trait naming convention, typically CUSTOM_*. This name is used to match runbooks to nodes via traits.'
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: '^CUSTOM_[A-Z0-9_]+$'
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
Expand Down Expand Up @@ -164,8 +178,13 @@ spec:
additionalPrinterColumns:
- name: Runbook Name
type: string
description: The runbook name used for trait matching
description: The runbook name
jsonPath: .spec.runbookName
- name: Description
type: string
description: Human-readable description of the runbook
jsonPath: .spec.description
priority: 1
- name: Steps
type: integer
description: Number of steps in the runbook
Expand Down
1 change: 1 addition & 0 deletions components/ironic/runbook-crd/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ namespace: openstack
# Create namespace if it doesn't exist
resources:
- bases/baremetal.ironicproject.org_runbooks.yaml
- runbooks/runbook_bmc_maintenance.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
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."
traits:
- CUSTOM_DELL_ABC
- CUSTOM_DELL_XYZ

steps:
- interface: management
step: clear_job_queue
order: 1

- interface: management
step: set_bmc_clock
order: 2
6 changes: 4 additions & 2 deletions components/ironic/runbook-operator/role.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ metadata:
app.kubernetes.io/name: ironicrunbook
app.kubernetes.io/component: rbac
rules:
# Read-only runbook permissions
# Runbook permissions
- apiGroups:
- baremetal.ironicproject.org
resources:
Expand All @@ -16,10 +16,12 @@ rules:
- list
- watch

# Read-only status permissions
# Status update permissions
- apiGroups:
- baremetal.ironicproject.org
resources:
- ironicrunbooks/status
verbs:
- get
- patch
- update
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ spec:
restartPolicy: Always
containers:
- name: shell-operator
image: ghcr.io/rackerlabs/understack/shell-operator-ironic:latest
image: ghcr.io/rackerlabs/understack/shell-operator-ironic:pr-2051
imagePullPolicy: Always
env:
- name: OS_CLOUD
Expand Down
160 changes: 138 additions & 22 deletions containers/shell-operator-ironic/hooks/create_runbook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,42 +10,158 @@
"executeHookOnEvent":[ "Added" ]
}],
"settings": {
"executionMinInterval": 30s,
"executionMinInterval": "30s",
"executionBurst": 1
}
}
EOF
else
patch_status() {
local namespace="$1"
local name="$2"
local sync_status="$3"
local message="${4:-}"
local now
now=$(date -u +"%Y-%m-%dT%H:%M:%SZ")

local condition_status="True"
local reason="SyncSucceeded"
if [[ "${sync_status}" == "Failed" ]]; then
condition_status="False"
reason="SyncFailed"
fi

local patch
patch=$(cat <<PATCH
{
"status": {
"syncStatus": "${sync_status}",
"lastSyncTime": "${now}",
"conditions": [
{
"type": "Ready",
"status": "${condition_status}",
"lastTransitionTime": "${now}",
"reason": "${reason}",
"message": "${message}"
}
]
}
}
PATCH
)
echo "[create_runbook] Patching status namespace=${namespace} name=${name} syncStatus=${sync_status}"
kubectl patch ironicrunbook "${name}" -n "${namespace}" \
--type merge --subresource status -p "${patch}" 2>/dev/null || \
echo "[create_runbook] WARNING: failed to patch status for ${name}"
}

sync_runbook() {
local i="$1"
local resource_name namespace kind runbook_name description public owner

resource_name=$(jq -r ".${i}.metadata.name" "${BINDING_CONTEXT_PATH}")
namespace=$(jq -r ".${i}.metadata.namespace" "${BINDING_CONTEXT_PATH}")
kind=$(jq -r ".${i}.kind" "${BINDING_CONTEXT_PATH}")
runbook_name=$(jq -r ".${i}.spec.runbookName" "${BINDING_CONTEXT_PATH}")
description=$(jq -r ".${i}.spec.description // empty" "${BINDING_CONTEXT_PATH}")
public=$(jq -r ".${i}.spec.public // empty" "${BINDING_CONTEXT_PATH}")
owner=$(jq -r ".${i}.spec.owner // empty" "${BINDING_CONTEXT_PATH}")

echo "[create_runbook] Creating runbook kind=${kind} name=${resource_name} namespace=${namespace} runbookName=${runbook_name} description=${description} public=${public} owner=${owner}"

jq -r ".${i}.spec.steps" "${BINDING_CONTEXT_PATH}" > /tmp/steps.json

command_args=(baremetal runbook create --name "${runbook_name}" --steps /tmp/steps.json)

if [[ -n "${description}" ]]; then
command_args+=(--description "${description}")
fi
if [[ -n "${public}" ]]; then
command_args+=(--public "${public}")
fi
if [[ -n "${owner}" ]]; then
command_args+=(--owner "${owner}")
fi

echo "[create_runbook] Running: openstack ${command_args[*]}"

if output=$(openstack "${command_args[@]}" 2>&1); then
echo "[create_runbook] SUCCESS: Runbook created in Ironic name=${resource_name} output=${output}"

traits_json=$(jq -c ".${i}.spec.traits // []" "${BINDING_CONTEXT_PATH}")
if [[ "${traits_json}" != "[]" ]]; then
echo "[create_runbook] Setting traits name=${resource_name} traits=${traits_json}"
ironic_endpoint=$(openstack endpoint list --service baremetal --interface internal -f value -c URL 2>/dev/null | head -1)
if [[ -n "${ironic_endpoint}" ]]; then
token=$(openstack token issue -f value -c id)
echo "[create_runbook] PUT ${ironic_endpoint}/v1/runbooks/${runbook_name}/traits"
trait_response=$(curl -s -X PUT \
-H "Content-Type: application/json" \
-H "X-Auth-Token: ${token}" \
-H "X-OpenStack-Ironic-API-Version: 1.112" \
-d "{\"traits\": ${traits_json}}" \
"${ironic_endpoint}/v1/runbooks/${runbook_name}/traits")
echo "[create_runbook] Traits response name=${resource_name} response=${trait_response}"
else
echo "[create_runbook] WARNING: Could not determine Ironic endpoint for traits"
fi
else
echo "[create_runbook] No traits to set name=${resource_name}"
fi

patch_status "${namespace}" "${resource_name}" "Synced" "Successfully created runbook in Ironic"
echo "[create_runbook] Completed name=${resource_name} status=Synced"
else
# If it already exists, that's OK during sync - not an error
if echo "${output}" | grep -qi "already exists\|Conflict\|409"; then
echo "[create_runbook] Runbook already exists in Ironic name=${resource_name}, skipping create"
patch_status "${namespace}" "${resource_name}" "Synced" "Runbook already exists in Ironic"
else
echo "[create_runbook] FAILED: name=${resource_name} error=${output}" >&2
patch_status "${namespace}" "${resource_name}" "Failed" "${output}"
return 1
fi
fi
}

echo "[create_runbook] Hook invoked, processing binding contexts"
binding_count=$(jq -r 'length' "${BINDING_CONTEXT_PATH}")
echo "[create_runbook] Found ${binding_count} binding context(s)"

for ((i = 0; i < binding_count; i++)); do
type=$(jq -r ".[$i].type" "${BINDING_CONTEXT_PATH}")
echo "[create_runbook] Processing context=${i} type=${type}"

if [[ $type == "Synchronization" ]] ; then
echo "Implement any reconciliation logic needed here."
echo "[create_runbook] Synchronization event, reconciling existing resources"
objects_count=$(jq -r ".[$i].objects | length" "${BINDING_CONTEXT_PATH}")
echo "[create_runbook] Found ${objects_count} existing IronicRunbook(s) to reconcile"
for ((j = 0; j < objects_count; j++)); do
obj_name=$(jq -r ".[$i].objects[$j].object.metadata.name" "${BINDING_CONTEXT_PATH}")
obj_sync=$(jq -r ".[$i].objects[$j].object.status.syncStatus // empty" "${BINDING_CONTEXT_PATH}")
echo "[create_runbook] Checking name=${obj_name} syncStatus=${obj_sync}"
if [[ -z "${obj_sync}" || "${obj_sync}" == "null" ]]; then
echo "[create_runbook] Resource name=${obj_name} has no syncStatus, needs reconciliation"
# Re-map the jq path to point at the object within the sync event
ORIG_BINDING_CONTEXT_PATH="${BINDING_CONTEXT_PATH}"
jq -r ".[$i].objects[$j].object" "${BINDING_CONTEXT_PATH}" > /tmp/sync_object.json
BINDING_CONTEXT_PATH=/tmp/sync_object.json
sync_runbook ""
BINDING_CONTEXT_PATH="${ORIG_BINDING_CONTEXT_PATH}"
else
echo "[create_runbook] Resource name=${obj_name} already synced, skipping"
fi
done
continue
fi

if [[ $type == "Event" ]] ; then
resource_name=$(jq -r ".[$i].object.metadata.name" "${BINDING_CONTEXT_PATH}")
kind=$(jq -r ".[$i].object.kind" "${BINDING_CONTEXT_PATH}")

runbook_name=$(jq -r ".[$i].object.spec.runbookName" "${BINDING_CONTEXT_PATH}")
public=$(jq -r ".[$i].object.spec.public" "${BINDING_CONTEXT_PATH}")
owner=$(jq -r ".[$i].object.spec.owner" "${BINDING_CONTEXT_PATH}")
jq -r ".[$i].object.spec.steps" "${BINDING_CONTEXT_PATH}" > /tmp/steps.yaml

# Ironic's runbook extra field is essentially a dict of dicts, representing a key values. baremetal cli allows you
# to pass in multiple --extra options, adding any you do pass. We would need to make an initial query to determine
# existing extras, and then sync the differences. This work is probably better suited to a full controller implementation.
# extra=$(jq -r '.spec.extra | [to_entries[] | "--extra \(.key)=\(.value | @json | @sh)"] | join(" ")' ${BINDING_CONTEXT_PATH})

command_args=(baremetal runbook create --name "${runbook_name}" --public "${public}" --steps /tmp/steps.yaml)
if [[ -n "${owner}" && "${owner}" != "null" ]]; then
command_args+=(--owner "${owner}")
sync_runbook "[$i].object"
if [[ $? -ne 0 ]]; then

Check notice on line 161 in containers/shell-operator-ironic/hooks/create_runbook.sh

View workflow job for this annotation

GitHub Actions / shellcheck

[shellcheck] containers/shell-operator-ironic/hooks/create_runbook.sh#L161 <ShellCheck.SC2181>

Check exit code directly with e.g. 'if ! mycmd;', not indirectly with $?.
Raw output
./containers/shell-operator-ironic/hooks/create_runbook.sh:161:13: info: Check exit code directly with e.g. 'if ! mycmd;', not indirectly with $?. (ShellCheck.SC2181)
exit 1
fi

echo "${kind}/${resource_name} created, running: openstack ${command_args[*]}"

openstack "${command_args[@]}"
fi
done
echo "[create_runbook] Hook finished"
fi
22 changes: 17 additions & 5 deletions containers/shell-operator-ironic/hooks/delete_runbook.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,30 +10,42 @@ if [[ $1 == "--config" ]] ; then
"executeHookOnEvent":[ "Deleted" ]
}],
"settings": {
"executionMinInterval": 30s,
"executionMinInterval": "30s",
"executionBurst": 1
}
}
EOF
else
echo "[delete_runbook] Hook invoked, processing binding contexts"
binding_count=$(jq -r 'length' "${BINDING_CONTEXT_PATH}")
echo "[delete_runbook] Found ${binding_count} binding context(s)"

for ((i = 0; i < binding_count; i++)); do
type=$(jq -r ".[$i].type" "${BINDING_CONTEXT_PATH}")
echo "[delete_runbook] Processing context=${i} type=${type}"

if [[ $type == "Synchronization" ]] ; then
echo "Implement any reconciliation logic needed here."
echo "[delete_runbook] Synchronization event, nothing to do for deletes"
continue
fi

if [[ $type == "Event" ]] ; then
resource_name=$(jq -r ".[$i].object.metadata.name" "${BINDING_CONTEXT_PATH}")
kind=$(jq -r ".[$i].object.kind" "${BINDING_CONTEXT_PATH}")

runbook_name=$(jq -r ".[$i].object.spec.runbookName" "${BINDING_CONTEXT_PATH}")

echo "[delete_runbook] Deleting runbook kind=${kind} name=${resource_name} runbookName=${runbook_name}"

command_args=(baremetal runbook delete "${runbook_name}")
echo "${kind}/${resource_name} deleted, running: openstack ${command_args[*]}"
echo "[delete_runbook] Running: openstack ${command_args[*]}"

openstack "${command_args[@]}"
if output=$(openstack "${command_args[@]}" 2>&1); then
echo "[delete_runbook] SUCCESS: Runbook deleted from Ironic name=${resource_name} output=${output}"
else
echo "[delete_runbook] FAILED: name=${resource_name} error=${output}" >&2
exit 1
fi
fi
done
echo "[delete_runbook] Hook finished"
fi
Loading
Loading