Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
2e052b5
Ironic: add custom hardware manager to inject IPs
skrobul Sep 17, 2025
02bfeb5
add understack_hwm module to IPA image
skrobul Sep 22, 2025
de7caab
ironic-images: add dev container for local IPA image builds
skrobul Sep 22, 2025
5d13d59
deliver IPs as NoCloud data source
skrobul Sep 23, 2025
45098af
add skeleton of the nautobot storage ip obtainer
skrobul Sep 23, 2025
9ceaeca
add Nova IronicUnderstackDriver
skrobul Sep 24, 2025
8fe190f
install IronicUnderstackDriver into the nova container
skrobul Sep 24, 2025
53658de
nova: add logging to IronicUnderstackDriver
skrobul Oct 2, 2025
e6ffca4
nova: inject IPs through _get_network_metadata override
skrobul Sep 25, 2025
52326a9
nova: use Nautobot client to obtain storage IPs
skrobul Sep 29, 2025
1cb9eac
nova: create and mount nautobot token secret
skrobul Sep 29, 2025
7d27fc0
nova: add secret and role bindings for Argo access
skrobul Sep 30, 2025
d51a5e5
nova: add ArgoClient
skrobul Sep 30, 2025
ce294d3
nova: make ArgoClient more generic
skrobul Sep 30, 2025
9c0e0c0
nova: use dynamic routes for A/B side
skrobul Sep 30, 2025
94e0a8c
nova: inject storage info only when requested
skrobul Sep 30, 2025
9802865
nova: run ansible playbook before configdrive build
skrobul Oct 1, 2025
94176af
sensor-ironic: don't run storage_on_server_create
skrobul Oct 1, 2025
e6949a9
nova: normalize project_id before calling ansible
skrobul Oct 1, 2025
47dc284
scripts: normalise uuids in cleanup_storage_stuff_in_nautobot
skrobul Oct 2, 2025
0d301e2
Revert Ironic hardware manager version
skrobul Oct 2, 2025
2f75728
nova: make ansible playbook name configurable
skrobul Oct 6, 2025
a9c4e7b
nova: add conf option to disable IP injection feature
skrobul Oct 6, 2025
843c57e
nova: refactor the IronicUnderstackDriver to make testing easier
skrobul Oct 6, 2025
bba9037
nova: refactor the IronicUnderstackDriver to make testing easier
skrobul Oct 7, 2025
ab35ff1
nova: add tests for IronicUnderstackDriver and related classes
skrobul Oct 6, 2025
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
3 changes: 3 additions & 0 deletions components/nova/kustomization.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,6 @@ resources:
# working due to the way the chart hardcodes the config-file parameter which then
# takes precedence over the directory
- configmap-nova-bin.yaml
- nova-nautobot-token.yaml
- secret-nova-argo-token.yaml
- roles-nova-argo-token.yaml
27 changes: 27 additions & 0 deletions components/nova/nova-nautobot-token.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: nova-nautobot
namespace: openstack
spec:
refreshInterval: 1h
secretStoreRef:
kind: ClusterSecretStore
name: nautobot
target:
name: nova-nautobot
creationPolicy: Owner
deletionPolicy: Delete
template:
engineVersion: v2
data:
nova-nautobot.conf: |
[nova_understack]
nautobot_base_url = http://nautobot-default.nautobot.svc.cluster.local
nautobot_api_key = {{ .token }}
data:
- secretKey: token
remoteRef:
key: nautobot-superuser
property: apitoken
34 changes: 34 additions & 0 deletions components/nova/roles-nova-argo-token.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# These roles allow nova-compute-ironic to create and list Argo workflows
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
creationTimestamp: null
name: nova-argo-workflows
namespace: argo-events
rules:
- apiGroups:
- argoproj.io
resources:
- workflows
verbs:
- get
- list
- create
- watch
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
creationTimestamp: null
name: nova-argo-workflows
namespace: argo-events
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: nova-argo-workflows
subjects:
- kind: ServiceAccount
name: nova-compute-ironic
namespace: openstack
---
8 changes: 8 additions & 0 deletions components/nova/secret-nova-argo-token.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
apiVersion: v1
kind: Secret
metadata:
name: nova-argo.token
annotations:
kubernetes.io/service-account.name: nova-conductor
type: kubernetes.io/service-account-token
2 changes: 2 additions & 0 deletions components/nova/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,8 @@ pod:
sources:
- secret:
name: nova-ks-etc
- secret:
name: nova-nautobot
lifecycle:
disruption_budget:
osapi:
Expand Down
1 change: 1 addition & 0 deletions containers/nova/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ RUN apt-get update && \
COPY containers/nova/patches /tmp/patches/
RUN cd /var/lib/openstack/lib/python3.10/site-packages && \
QUILT_PATCHES=/tmp/patches quilt push -a
COPY python/nova-understack/ironic_understack /var/lib/openstack/lib/python3.10/site-packages/nova/virt/ironic_understack
1 change: 1 addition & 0 deletions python/nova-understack/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Nova drivers for Understack
3 changes: 3 additions & 0 deletions python/nova-understack/ironic_understack/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .driver import IronicUnderstackDriver

__all__ = ["IronicUnderstackDriver"]
165 changes: 165 additions & 0 deletions python/nova-understack/ironic_understack/argo_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import time

import requests
import urllib3


class ArgoClient:
"""Client for interacting with Argo Workflows REST API."""

def __init__(
self, url: str, token: str | None, namespace="argo-events", ssl_verify=False
):
"""Initialize the Argo client.

Args:
url: Base URL of the Argo Workflows server
(Optional) token: Authentication token for API access. If not provided
the default token from
/var/run/secrets/kubernetes.io/serviceaccount/token is used.
"""
self.url = url.rstrip("/")
self.token = token or self._kubernetes_token
self.session = requests.Session()
self.session.verify = ssl_verify
if not ssl_verify:
urllib3.disable_warnings()
self.session.headers.update(
{
"Authorization": f"Bearer {self.token}",
"Content-Type": "application/json",
}
)
self.namespace = namespace

def _generate_workflow_name(self, playbook_name: str) -> str:
"""Generate workflow name based on playbook name.

Strips .yaml/.yml suffix and creates ansible-<name>- format.

Args:
playbook_name: Name of the Ansible playbook

Returns:
str: Generated workflow name in format ansible-<name>-

Examples:
storage_on_server_create.yml -> ansible-storage_on_server_create-
network_setup.yaml -> ansible-network_setup-
deploy_app -> ansible-deploy_app-
"""
base_name = playbook_name.replace("_", "-")
if base_name.endswith((".yaml", ".yml")):
base_name = base_name.rsplit(".", 1)[0]
return f"ansible-{base_name}-"

@property
def _kubernetes_token(self) -> str:
"""Reads pod's Kubernetes token.

Args:
None
Returns:
str: value of the token
"""
with open("/var/run/secrets/kubernetes.io/serviceaccount/token") as f:
return f.read()

def run_playbook(self, playbook_name: str, **extra_vars) -> dict:
"""Run an Ansible playbook via Argo Workflows.

This method creates a workflow from the ansible-workflow-template and waits
for it to complete synchronously.

Args:
playbook_name: Name of the Ansible playbook to run
**extra_vars: Arbitrary key/value pairs to pass as extra_vars to Ansible

Returns:
dict: The final workflow status

Raises:
requests.RequestException: If API requests fail
RuntimeError: If workflow fails or times out
"""
# Convert extra_vars dict to space-separated key=value string
extra_vars_str = " ".join(f"{key}={value}" for key, value in extra_vars.items())

# Generate workflow name based on playbook name
generate_name = self._generate_workflow_name(playbook_name)

# Create workflow from template
workflow_request = {
"workflow": {
"metadata": {"generateName": generate_name},
"spec": {
"workflowTemplateRef": {"name": "ansible-workflow-template"},
"entrypoint": "ansible-run",
"arguments": {
"parameters": [
{"name": "playbook", "value": playbook_name},
{
"name": "extra_vars",
"value": extra_vars_str,
},
]
},
},
}
}

# Submit workflow
response = self.session.post(
f"{self.url}/api/v1/workflows/{self.namespace}", json=workflow_request
)
response.raise_for_status()

workflow = response.json()
workflow_name = workflow["metadata"]["name"]

# Wait for workflow completion
return self._wait_for_completion(workflow_name)

def _wait_for_completion(
self, workflow_name: str, timeout: int = 600, poll_interval: int = 5
) -> dict:
"""Wait for workflow to complete.

Args:
workflow_name: Name of the workflow to monitor
timeout: Maximum time to wait in seconds (default: 10 minutes)
poll_interval: Time between status checks in seconds

Returns:
dict: Final workflow status

Raises:
RuntimeError: If workflow fails or times out
"""
start_time = time.time()

while time.time() - start_time < timeout:
response = self.session.get(
f"{self.url}/api/v1/workflows/{self.namespace}/{workflow_name}"
)
response.raise_for_status()

workflow = response.json()
phase = workflow.get("status", {}).get("phase")

if phase == "Succeeded":
return workflow
elif phase == "Failed":
status = workflow.get("status", {}).get("message", "Unknown error")
raise RuntimeError(f"Workflow {workflow_name} failed: {status}")
elif phase == "Error":
status = workflow.get("status", {}).get("message", "Unknown error")
raise RuntimeError(
f"Workflow {workflow_name} encountered an error: {status}"
)

time.sleep(poll_interval)

raise RuntimeError(
f"Workflow {workflow_name} timed out after {timeout} seconds"
)
35 changes: 35 additions & 0 deletions python/nova-understack/ironic_understack/conf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from oslo_config import cfg

CONF = cfg.CONF


def setup_conf():
grp = cfg.OptGroup("nova_understack")
opts = [
cfg.StrOpt(
"nautobot_base_url",
help="Nautobot's base URL",
default="https://nautobot.nautobot.svc",
),
cfg.StrOpt("nautobot_api_key", help="Nautotbot's API key", default=""),
cfg.StrOpt(
"argo_api_url",
help="Argo Workflows API url",
default="https://argo-server.argo.svc:2746",
),
cfg.StrOpt(
"ansible_playbook_filename",
help="Name of the Ansible playbook to execute when server is created.",
default="storage_on_server_create.yml",
),
cfg.BoolOpt(
"ip_injection_enabled",
help="Controls if Nova should inject storage IPs to config drive.",
default=True,
),
]
cfg.CONF.register_group(grp)
cfg.CONF.register_opts(opts, group=grp)


setup_conf()
Loading
Loading