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
Original file line number Diff line number Diff line change
@@ -0,0 +1,244 @@
---
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: ironicrunbooks.baremetal.ironicproject.org
spec:
group: baremetal.ironicproject.org
names:
kind: IronicRunbook
listKind: IronicRunbookList
plural: ironicrunbooks
shortNames:
- rb
singular: ironicrunbook
scope: Namespaced
versions:
- name: v1alpha1
served: true
storage: true
additionalPrinterColumns:
- name: Runbook Name
type: string
jsonPath: .spec.runbookName
- name: Description
type: string
jsonPath: .spec.description
priority: 1
- name: Public
type: boolean
jsonPath: .spec.public
- name: SyncStatus
type: string
jsonPath: .status.syncStatus
- name: Age
type: date
jsonPath: .metadata.creationTimestamp
schema:
openAPIV3Schema:
description: IronicRunbook represents a collection of ordered steps that define automated operations on baremetal nodes.
type: object
required:
- spec
properties:
apiVersion:
type: string
kind:
type: string
metadata:
type: object
spec:
description: IronicRunbookSpec defines the desired state of IronicRunbook.
type: object
required:
- runbookName
- steps
- cloudCredentialsRef
properties:
cloudCredentialsRef:
description: >-
cloudCredentialsRef points to a Kubernetes Secret containing
an OpenStack clouds.yaml file. The operator reads this secret
directly at reconcile time — no volume mount is required.
type: object
required:
- secretName
- cloudName
properties:
secretName:
description: >-
Name of a Secret in the same namespace as this resource.
The Secret must contain a key named clouds.yaml holding
an OpenStack clouds.yaml file.
type: string
minLength: 1
maxLength: 253
cloudName:
description: >-
Name of the cloud entry within the clouds.yaml to
authenticate as.
type: string
minLength: 1
maxLength: 256
runbookName:
description: >-
RunbookName is the unique name of the runbook. From API
microversion 1.112+, this is a logical identifier and can be
any string of 1-255 characters. Node eligibility is
determined by the traits field instead.
type: string
pattern: ^[A-Za-z0-9._-]+$
minLength: 1
maxLength: 255
description:
description: >-
Description is a human-readable description of the runbook.
Available from API microversion 1.112 onwards.
type: string
nullable: true
maxLength: 1000
traits:
description: >-
Traits is a list of traits that determine which nodes are
permitted to use this runbook. Decouples runbook eligibility
from the runbook name. Each trait must follow the CUSTOM_*
naming convention. Available from API microversion 1.112
onwards.
type: array
default: []
items:
type: string
pattern: ^CUSTOM_[A-Z0-9_]+$
minLength: 1
maxLength: 255
steps:
description: Steps is an ordered list of operations to execute. Minimum 1 step required.
type: array
minItems: 1
items:
description: RunbookStep defines a single step in the runbook.
type: object
required:
- interface
- step
- order
properties:
interface:
description: >-
Interface specifies which hardware interface handles
this step. Must be one of the valid Ironic cleaning
interfaces.
type: string
enum:
- bios
- raid
- deploy
- management
- power
- storage
- vendor
- rescue
- console
- boot
- inspect
- network
- firmware
step:
description: >-
Step is the name of the step to execute. Must be a
valid step name for the specified interface.
type: string
minLength: 1
maxLength: 255
order:
description: >-
Order defines the execution sequence. Must be >= 0 and
unique within the runbook. Lower numbers execute
first.
type: integer
minimum: 0
args:
description: >-
Args contains step-specific arguments. Structure
depends on the interface and step.
type: object
x-kubernetes-preserve-unknown-fields: true
disableRamdisk:
description: >-
DisableRamdisk skips booting the ramdisk for cleaning
operations. Use when steps can run without IPA (Ironic
Python Agent).
type: boolean
default: false
public:
description: >-
Public makes the runbook accessible to all projects/tenants.
Cannot be true if owner is set.
type: boolean
default: false
owner:
description: >-
Owner identifies the project/tenant that owns this runbook.
Cannot be set if public is true.
type: string
nullable: true
maxLength: 255
extra:
description: >-
Extra contains additional metadata. Use for descriptions,
versions, maintainer info, etc.
type: object
x-kubernetes-preserve-unknown-fields: true
status:
description: IronicRunbookStatus defines the observed sync state.
type: object
properties:
ironicUUID:
description: IronicUUID is the UUID of this runbook in the Ironic API.
type: string
syncStatus:
description: SyncStatus indicates the synchronization state with Ironic.
type: string
enum:
- Synced
- Failed
- Unknown
lastSyncTime:
description: LastSyncTime is the last time the operator attempted to sync the runbook.
type: string
format: date-time
observedGeneration:
description: ObservedGeneration is the metadata generation last processed by the operator.
type: integer
format: int64
message:
description: Message provides details about the last sync attempt.
type: string
maxLength: 2048
conditions:
description: Conditions describe current observed state.
type: array
items:
type: object
required:
- type
- status
properties:
type:
type: string
status:
type: string
enum:
- "True"
- "False"
- Unknown
reason:
type: string
message:
type: string
maxLength: 2048
lastTransitionTime:
type: string
format: date-time
subresources:
status: {}
10 changes: 10 additions & 0 deletions components/openstack-sync-operator/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ rbac:
plugins:
openstackPlaceholder: false
neutronRouterFlavors: false
ironicRunbooks: true

pluginData:
openstackPlaceholder:
Expand All @@ -52,6 +53,15 @@ pluginData:
DEFAULT_SECRET: infrasetup
DEFAULT_CLOUD: understack

ironicRunbooks:
hook:
path: /hooks/ironic_runbooks.py
crd: crds/baremetal.ironicproject.org_ironicrunbooks.yaml
envPrefix: IRONIC_RUNBOOK
env:
DEFAULT_SECRET: infrasetup
DEFAULT_CLOUD: understack

hooks: {}

podAnnotations: {}
Expand Down
1 change: 1 addition & 0 deletions containers/openstack-sync-operator/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
118 changes: 118 additions & 0 deletions python/openstack-sync/openstack_sync/hooks/ironic_runbooks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
#!/usr/bin/env python3
"""Shell-operator hook for Ironic runbook connectivity verification.

When ``IRONIC_RUNBOOK_ENABLED`` is ``true`` this hook runs on startup to
verify that the operator can authenticate against OpenStack using the
credentials that will be used for Ironic runbook reconciliation. When the
flag is ``false`` (the default) the hook registers only an ``onStartup``
binding so the base image satisfies shell-operator's requirement for at
least one binding without needing any Kubernetes watches or extra RBAC.

This is a connectivity placeholder. Full reconciliation of ``IronicRunbook``
custom resources against Ironic (create/update/delete, trait management) is
not implemented here yet.
"""

from __future__ import annotations

import json
import os
import sys
from typing import Any

from openstack_sync.utils import get_openstack_connection

TRUTHY_VALUES = {"1", "true", "yes", "on"}


def env_is_truthy(name: str, default: str = "false") -> bool:
return os.environ.get(name, default).lower() in TRUTHY_VALUES


def build_hook_config() -> dict[str, Any]:
hook_config: dict[str, Any] = {
"configVersion": "v1",
"settings": {
"executionMinInterval": "30s",
"executionBurst": 1,
},
"onStartup": 10,
}
return hook_config


HOOK_CONFIG = build_hook_config()


def check_openstack_connectivity() -> None:
"""Attempt to authenticate against OpenStack and log the result.

Reads credentials from the Kubernetes Secret named by
``IRONIC_RUNBOOK_DEFAULT_SECRET`` using the cloud entry
``IRONIC_RUNBOOK_DEFAULT_CLOUD``.

Raises:
Exception: Re-raises any connection failure after logging it.
"""
secret_name = os.environ.get("IRONIC_RUNBOOK_DEFAULT_SECRET")
cloud_name = os.environ.get("IRONIC_RUNBOOK_DEFAULT_CLOUD")

print(
f"connectivity check: authenticating against cloud={cloud_name!r} "
f"secret={secret_name!r}",
flush=True,
)
conn = get_openstack_connection(secret_name, cloud_name)
# Lightweight probe: check_token(str) -> bool confirms the token is valid
# and Keystone is reachable without any side effects.
conn.identity.check_token(conn.auth_token)
print(
f"connectivity check: OK cloud={cloud_name!r} secret={secret_name!r}",
flush=True,
)


def main() -> int:
if len(sys.argv) > 1 and sys.argv[1] == "--config":
print(json.dumps(build_hook_config(), indent=2))
return 0

context_path = os.environ.get("BINDING_CONTEXT_PATH")
if not context_path:
return 0
with open(context_path) as f:
raw = f.read()
if not raw.strip():
return 0

try:
binding_contexts = json.loads(raw)
except json.JSONDecodeError as exc:
print(f"failed to parse binding context: {exc}", file=sys.stderr)
return 1

for context in binding_contexts:
# Shell-operator passes [{"binding": "onStartup"}] for startup runs.
if context.get("binding") == "onStartup":
if not env_is_truthy("IRONIC_RUNBOOK_ENABLED"):
print(
"connectivity check: skipped"
" (IRONIC_RUNBOOK_ENABLED is not set)",
flush=True,
)
continue
try:
check_openstack_connectivity()
except Exception as exc: # noqa: BLE001
print(
f"connectivity check FAILED: {exc}",
file=sys.stderr,
flush=True,
)
return 1

return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading