Skip to content

Commit cadbfc9

Browse files
authored
Merge pull request #1282 from rackerlabs/ironic-inject-storage-ips
feat: Nova compute driver with storage network IP injection
2 parents 36bac3a + ab35ff1 commit cadbfc9

File tree

20 files changed

+4355
-13
lines changed

20 files changed

+4355
-13
lines changed

components/nova/kustomization.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,6 @@ resources:
1111
# working due to the way the chart hardcodes the config-file parameter which then
1212
# takes precedence over the directory
1313
- configmap-nova-bin.yaml
14+
- nova-nautobot-token.yaml
15+
- secret-nova-argo-token.yaml
16+
- roles-nova-argo-token.yaml
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
apiVersion: external-secrets.io/v1
3+
kind: ExternalSecret
4+
metadata:
5+
name: nova-nautobot
6+
namespace: openstack
7+
spec:
8+
refreshInterval: 1h
9+
secretStoreRef:
10+
kind: ClusterSecretStore
11+
name: nautobot
12+
target:
13+
name: nova-nautobot
14+
creationPolicy: Owner
15+
deletionPolicy: Delete
16+
template:
17+
engineVersion: v2
18+
data:
19+
nova-nautobot.conf: |
20+
[nova_understack]
21+
nautobot_base_url = http://nautobot-default.nautobot.svc.cluster.local
22+
nautobot_api_key = {{ .token }}
23+
data:
24+
- secretKey: token
25+
remoteRef:
26+
key: nautobot-superuser
27+
property: apitoken
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# These roles allow nova-compute-ironic to create and list Argo workflows
2+
---
3+
apiVersion: rbac.authorization.k8s.io/v1
4+
kind: Role
5+
metadata:
6+
creationTimestamp: null
7+
name: nova-argo-workflows
8+
namespace: argo-events
9+
rules:
10+
- apiGroups:
11+
- argoproj.io
12+
resources:
13+
- workflows
14+
verbs:
15+
- get
16+
- list
17+
- create
18+
- watch
19+
---
20+
apiVersion: rbac.authorization.k8s.io/v1
21+
kind: RoleBinding
22+
metadata:
23+
creationTimestamp: null
24+
name: nova-argo-workflows
25+
namespace: argo-events
26+
roleRef:
27+
apiGroup: rbac.authorization.k8s.io
28+
kind: Role
29+
name: nova-argo-workflows
30+
subjects:
31+
- kind: ServiceAccount
32+
name: nova-compute-ironic
33+
namespace: openstack
34+
---
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
apiVersion: v1
3+
kind: Secret
4+
metadata:
5+
name: nova-argo.token
6+
annotations:
7+
kubernetes.io/service-account.name: nova-conductor
8+
type: kubernetes.io/service-account-token

components/nova/values.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,8 @@ pod:
232232
sources:
233233
- secret:
234234
name: nova-ks-etc
235+
- secret:
236+
name: nova-nautobot
235237
lifecycle:
236238
disruption_budget:
237239
osapi:

containers/nova/Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ RUN apt-get update && \
1212
COPY containers/nova/patches /tmp/patches/
1313
RUN cd /var/lib/openstack/lib/python3.10/site-packages && \
1414
QUILT_PATCHES=/tmp/patches quilt push -a
15+
COPY python/nova-understack/ironic_understack /var/lib/openstack/lib/python3.10/site-packages/nova/virt/ironic_understack

python/nova-understack/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Nova drivers for Understack
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .driver import IronicUnderstackDriver
2+
3+
__all__ = ["IronicUnderstackDriver"]
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
import time
2+
3+
import requests
4+
import urllib3
5+
6+
7+
class ArgoClient:
8+
"""Client for interacting with Argo Workflows REST API."""
9+
10+
def __init__(
11+
self, url: str, token: str | None, namespace="argo-events", ssl_verify=False
12+
):
13+
"""Initialize the Argo client.
14+
15+
Args:
16+
url: Base URL of the Argo Workflows server
17+
(Optional) token: Authentication token for API access. If not provided
18+
the default token from
19+
/var/run/secrets/kubernetes.io/serviceaccount/token is used.
20+
"""
21+
self.url = url.rstrip("/")
22+
self.token = token or self._kubernetes_token
23+
self.session = requests.Session()
24+
self.session.verify = ssl_verify
25+
if not ssl_verify:
26+
urllib3.disable_warnings()
27+
self.session.headers.update(
28+
{
29+
"Authorization": f"Bearer {self.token}",
30+
"Content-Type": "application/json",
31+
}
32+
)
33+
self.namespace = namespace
34+
35+
def _generate_workflow_name(self, playbook_name: str) -> str:
36+
"""Generate workflow name based on playbook name.
37+
38+
Strips .yaml/.yml suffix and creates ansible-<name>- format.
39+
40+
Args:
41+
playbook_name: Name of the Ansible playbook
42+
43+
Returns:
44+
str: Generated workflow name in format ansible-<name>-
45+
46+
Examples:
47+
storage_on_server_create.yml -> ansible-storage_on_server_create-
48+
network_setup.yaml -> ansible-network_setup-
49+
deploy_app -> ansible-deploy_app-
50+
"""
51+
base_name = playbook_name.replace("_", "-")
52+
if base_name.endswith((".yaml", ".yml")):
53+
base_name = base_name.rsplit(".", 1)[0]
54+
return f"ansible-{base_name}-"
55+
56+
@property
57+
def _kubernetes_token(self) -> str:
58+
"""Reads pod's Kubernetes token.
59+
60+
Args:
61+
None
62+
Returns:
63+
str: value of the token
64+
"""
65+
with open("/var/run/secrets/kubernetes.io/serviceaccount/token") as f:
66+
return f.read()
67+
68+
def run_playbook(self, playbook_name: str, **extra_vars) -> dict:
69+
"""Run an Ansible playbook via Argo Workflows.
70+
71+
This method creates a workflow from the ansible-workflow-template and waits
72+
for it to complete synchronously.
73+
74+
Args:
75+
playbook_name: Name of the Ansible playbook to run
76+
**extra_vars: Arbitrary key/value pairs to pass as extra_vars to Ansible
77+
78+
Returns:
79+
dict: The final workflow status
80+
81+
Raises:
82+
requests.RequestException: If API requests fail
83+
RuntimeError: If workflow fails or times out
84+
"""
85+
# Convert extra_vars dict to space-separated key=value string
86+
extra_vars_str = " ".join(f"{key}={value}" for key, value in extra_vars.items())
87+
88+
# Generate workflow name based on playbook name
89+
generate_name = self._generate_workflow_name(playbook_name)
90+
91+
# Create workflow from template
92+
workflow_request = {
93+
"workflow": {
94+
"metadata": {"generateName": generate_name},
95+
"spec": {
96+
"workflowTemplateRef": {"name": "ansible-workflow-template"},
97+
"entrypoint": "ansible-run",
98+
"arguments": {
99+
"parameters": [
100+
{"name": "playbook", "value": playbook_name},
101+
{
102+
"name": "extra_vars",
103+
"value": extra_vars_str,
104+
},
105+
]
106+
},
107+
},
108+
}
109+
}
110+
111+
# Submit workflow
112+
response = self.session.post(
113+
f"{self.url}/api/v1/workflows/{self.namespace}", json=workflow_request
114+
)
115+
response.raise_for_status()
116+
117+
workflow = response.json()
118+
workflow_name = workflow["metadata"]["name"]
119+
120+
# Wait for workflow completion
121+
return self._wait_for_completion(workflow_name)
122+
123+
def _wait_for_completion(
124+
self, workflow_name: str, timeout: int = 600, poll_interval: int = 5
125+
) -> dict:
126+
"""Wait for workflow to complete.
127+
128+
Args:
129+
workflow_name: Name of the workflow to monitor
130+
timeout: Maximum time to wait in seconds (default: 10 minutes)
131+
poll_interval: Time between status checks in seconds
132+
133+
Returns:
134+
dict: Final workflow status
135+
136+
Raises:
137+
RuntimeError: If workflow fails or times out
138+
"""
139+
start_time = time.time()
140+
141+
while time.time() - start_time < timeout:
142+
response = self.session.get(
143+
f"{self.url}/api/v1/workflows/{self.namespace}/{workflow_name}"
144+
)
145+
response.raise_for_status()
146+
147+
workflow = response.json()
148+
phase = workflow.get("status", {}).get("phase")
149+
150+
if phase == "Succeeded":
151+
return workflow
152+
elif phase == "Failed":
153+
status = workflow.get("status", {}).get("message", "Unknown error")
154+
raise RuntimeError(f"Workflow {workflow_name} failed: {status}")
155+
elif phase == "Error":
156+
status = workflow.get("status", {}).get("message", "Unknown error")
157+
raise RuntimeError(
158+
f"Workflow {workflow_name} encountered an error: {status}"
159+
)
160+
161+
time.sleep(poll_interval)
162+
163+
raise RuntimeError(
164+
f"Workflow {workflow_name} timed out after {timeout} seconds"
165+
)
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from oslo_config import cfg
2+
3+
CONF = cfg.CONF
4+
5+
6+
def setup_conf():
7+
grp = cfg.OptGroup("nova_understack")
8+
opts = [
9+
cfg.StrOpt(
10+
"nautobot_base_url",
11+
help="Nautobot's base URL",
12+
default="https://nautobot.nautobot.svc",
13+
),
14+
cfg.StrOpt("nautobot_api_key", help="Nautotbot's API key", default=""),
15+
cfg.StrOpt(
16+
"argo_api_url",
17+
help="Argo Workflows API url",
18+
default="https://argo-server.argo.svc:2746",
19+
),
20+
cfg.StrOpt(
21+
"ansible_playbook_filename",
22+
help="Name of the Ansible playbook to execute when server is created.",
23+
default="storage_on_server_create.yml",
24+
),
25+
cfg.BoolOpt(
26+
"ip_injection_enabled",
27+
help="Controls if Nova should inject storage IPs to config drive.",
28+
default=True,
29+
),
30+
]
31+
cfg.CONF.register_group(grp)
32+
cfg.CONF.register_opts(opts, group=grp)
33+
34+
35+
setup_conf()

0 commit comments

Comments
 (0)