Skip to content

Commit da6b21a

Browse files
authored
Merge pull request #1155 from rackerlabs/workflow-create-svm
feat: Workflow to create SVM on project creation
2 parents 340096a + 3a9b1f2 commit da6b21a

19 files changed

+1044
-14
lines changed

.pre-commit-config.yaml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ repos:
9090
hooks:
9191
- id: pyright
9292
files: '^python/understack-workflows/'
93-
args: ["--threads"]
93+
args: ["--threads", "python/understack-workflows"]
9494
additional_dependencies:
9595
# python-pyright stupidly does not allow local paths
9696
# https://github.com/pre-commit/pre-commit/issues/1752#issuecomment-754252663
@@ -105,6 +105,8 @@ repos:
105105
- "requests"
106106
- "sushy"
107107
- "types-requests"
108+
- "netapp_ontap"
109+
108110
- repo: local
109111
hooks:
110112
- id: trufflehog

components/argo-events/kustomization.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@ resources:
2020

2121
## allow neutron's service account to submit workflows
2222
- svc-neutron.yaml
23+
24+
## copy openstack/cinder-netapp-config to argo-events/netapp-config
25+
- secret-netapp.yaml
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
apiVersion: external-secrets.io/v1beta1
3+
kind: ExternalSecret
4+
metadata:
5+
name: netapp-config
6+
spec:
7+
refreshInterval: 1h
8+
secretStoreRef:
9+
kind: ClusterSecretStore
10+
name: openstack
11+
target:
12+
name: netapp-config
13+
creationPolicy: Owner
14+
deletionPolicy: Delete
15+
dataFrom:
16+
- extract:
17+
key: cinder-netapp-config

components/openstack/templates/secretstore-openstack.yaml.tpl

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ rules:
2727
resourceNames:
2828
- svc-acct-argoworkflow
2929
- svc-acct-netapp
30+
- cinder-netapp-config
3031
- apiGroups:
3132
- authorization.k8s.io
3233
resources:

python/understack-workflows/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dependencies = [
2626
"sushy>=5.3.0,<6",
2727
"kubernetes==33.1.0",
2828
"understack-flavor-matcher",
29+
"netapp-ontap>=9.17.1.0",
2930
]
3031

3132
[project.scripts]
@@ -38,6 +39,7 @@ bmc-password = "understack_workflows.main.print_bmc_password:main"
3839
bmc-kube-password = "understack_workflows.main.bmc_display_password:main"
3940
sync-network-segment-range = "understack_workflows.main.sync_ucvni_group_range:main"
4041
openstack-oslo-event = "understack_workflows.main.openstack_oslo_event:main"
42+
netapp-create-svm = "understack_workflows.main.netapp_create_svm:main"
4143

4244
[dependency-groups]
4345
test = [
@@ -87,6 +89,7 @@ target-version = "py310"
8789
"tests/**/*.py" = [
8890
"S101", # allow 'assert' for pytest
8991
"S105", # allow hardcoded passwords for testing
92+
"S106", # allow hardcoded passwords for testing
9093
]
9194
"understack_workflows/main/bmc_display_password.py" = [
9295
"S607", # allow the kubectl call

python/understack-workflows/tests/conftest.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ def project_data(domain_id: uuid.UUID, project_id: uuid.UUID):
5252

5353

5454
@pytest.fixture
55-
def os_conn(project_data: dict) -> openstack.connection.Connection:
55+
def os_conn(project_data: dict) -> openstack.connection.Connection: # pyright: ignore[reportAttributeAccessIssue]
5656
def _get_project(project_id):
5757
if project_id == project_data["id"].hex:
5858
data = {
@@ -67,10 +67,10 @@ def _get_project(project_id):
6767
"domain_id": "default",
6868
}
6969
else:
70-
raise openstack.exceptions.NotFoundException
71-
return openstack.identity.v3.project.Project(**data)
70+
raise openstack.exceptions.NotFoundException # pyright: ignore[reportAttributeAccessIssue]
71+
return openstack.identity.v3.project.Project(**data) # pyright: ignore[reportAttributeAccessIssue]
7272

73-
conn = MagicMock(spec_set=openstack.connection.Connection)
73+
conn = MagicMock(spec_set=openstack.connection.Connection) # pyright: ignore[reportAttributeAccessIssue]
7474
conn.identity.get_project.side_effect = _get_project
7575
return conn
7676

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"oslo.version": "2.0",
3+
"oslo.message": "{\"message_id\": \"17007b45-83b9-45ae-9f0d-58ba04c256af\", \"publisher_id\": \"identity.keystone-api-795dc7d644-nmm8j\", \"event_type\": \"identity.project.created\", \"priority\": \"INFO\", \"payload\": {\"typeURI\": \"http://schemas.dmtf.org/cloud/audit/1.0/event\", \"eventType\": \"activity\", \"id\": \"7da95c47-8b60-57e7-9c61-3a248a8a7c98\", \"eventTime\": \"2025-08-18T10:38:07.168294+0000\", \"action\": \"created.project\", \"outcome\": \"success\", \"observer\": {\"id\": \"ad1c83a7f5f746d2a04fcc8dda226368\", \"typeURI\": \"service/security\"}, \"initiator\": {\"id\": \"99b5533ea19f4009a2b4a047e7b05533\", \"typeURI\": \"service/security/account/user\", \"host\": {\"address\": \"10.2.148.161\", \"agent\": \"python-keystoneclient\"}, \"user_id\": \"99b5533ea19f4009a2b4a047e7b05533\", \"project_id\": \"fe57b90d20084270a21a9e15ecf397bb\", \"request_id\": \"req-afd9642e-bc57-4feb-95c2-fbd7370d9f70\", \"username\": \"admin\"}, \"target\": {\"id\": \"148f2f86b96440a1ba0934f837b2c77b\", \"typeURI\": \"data/security/project\"}, \"resource_info\": \"148f2f86b96440a1ba0934f837b2c77b\"}, \"timestamp\": \"2025-08-18 10:38:07.168945\", \"_unique_id\": \"f91b7719b9084d81802a95f62f3c18cb\"}"
4+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"oslo.version": "2.0",
3+
"oslo.message": "{\"message_id\": \"ce5e8e18-3705-4514-8985-52c765f581e9\", \"publisher_id\": \"identity.keystone-api-795dc7d644-k26d4\", \"event_type\": \"identity.project.deleted\", \"priority\": \"INFO\", \"payload\": {\"typeURI\": \"http://schemas.dmtf.org/cloud/audit/1.0/event\", \"eventType\": \"activity\", \"id\": \"93cff481-dd11-5017-8048-fa2cda5f53b6\", \"eventTime\": \"2025-08-18T10:38:25.282120+0000\", \"action\": \"deleted.project\", \"outcome\": \"success\", \"observer\": {\"id\": \"ad1c83a7f5f746d2a04fcc8dda226368\", \"typeURI\": \"service/security\"}, \"initiator\": {\"id\": \"99b5533ea19f4009a2b4a047e7b05533\", \"typeURI\": \"service/security/account/user\", \"host\": {\"address\": \"10.2.148.161\", \"agent\": \"python-keystoneclient\"}, \"user_id\": \"99b5533ea19f4009a2b4a047e7b05533\", \"project_id\": \"fe57b90d20084270a21a9e15ecf397bb\", \"request_id\": \"req-980d35c5-5c9e-4308-89fd-5770480ce19e\", \"username\": \"admin\"}, \"target\": {\"id\": \"148f2f86b96440a1ba0934f837b2c77b\", \"typeURI\": \"data/security/project\"}, \"resource_info\": \"148f2f86b96440a1ba0934f837b2c77b\"}, \"timestamp\": \"2025-08-18 10:38:25.282831\", \"_unique_id\": \"a60e846a839f42fa9779dd09e79375af\"}"
4+
}
Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
1+
from unittest.mock import MagicMock
2+
from unittest.mock import patch
3+
4+
import pytest
5+
6+
from understack_workflows.oslo_event.keystone_project import AGGREGATE_NAME
7+
from understack_workflows.oslo_event.keystone_project import SVM_PROJECT_TAG
8+
from understack_workflows.oslo_event.keystone_project import VOLUME_SIZE
9+
from understack_workflows.oslo_event.keystone_project import KeystoneProjectEvent
10+
from understack_workflows.oslo_event.keystone_project import _keystone_project_tags
11+
from understack_workflows.oslo_event.keystone_project import handle_project_created
12+
13+
14+
class TestKeystoneProjectEvent:
15+
"""Test cases for KeystoneProjectEvent class."""
16+
17+
def test_from_event_dict_success(self):
18+
"""Test successful event parsing."""
19+
event_data = {"payload": {"target": {"id": "test-project-123"}}}
20+
21+
event = KeystoneProjectEvent.from_event_dict(event_data)
22+
assert event.project_id == "test-project-123"
23+
24+
def test_from_event_dict_no_payload(self):
25+
"""Test event parsing with missing payload."""
26+
event_data = {}
27+
28+
with pytest.raises(Exception, match="Invalid event. No 'payload'"):
29+
KeystoneProjectEvent.from_event_dict(event_data)
30+
31+
def test_from_event_dict_no_target(self):
32+
"""Test event parsing with missing target."""
33+
event_data = {"payload": {}}
34+
35+
with pytest.raises(Exception, match="no target information in payload"):
36+
KeystoneProjectEvent.from_event_dict(event_data)
37+
38+
def test_from_event_dict_no_project_id(self):
39+
"""Test event parsing with missing project ID."""
40+
event_data = {"payload": {"target": {}}}
41+
42+
with pytest.raises(Exception, match="no project_id found in payload"):
43+
KeystoneProjectEvent.from_event_dict(event_data)
44+
45+
def test_dataclass_initialization(self):
46+
"""Test direct dataclass initialization."""
47+
event = KeystoneProjectEvent("test-project-456")
48+
assert event.project_id == "test-project-456"
49+
50+
51+
class TestKeystoneProjectTags:
52+
"""Test cases for _keystone_project_tags function."""
53+
54+
def test_project_tags_with_tags(self):
55+
"""Test getting project tags when project has tags."""
56+
mock_project = MagicMock()
57+
mock_project.tags = ["tag1", "tag2", SVM_PROJECT_TAG]
58+
59+
mock_conn = MagicMock()
60+
mock_conn.identity.get_project.return_value = mock_project
61+
62+
tags = _keystone_project_tags(mock_conn, "test-project-id")
63+
64+
assert tags == ["tag1", "tag2", SVM_PROJECT_TAG]
65+
mock_conn.identity.get_project.assert_called_once_with("test-project-id")
66+
67+
def test_project_tags_no_tags_attribute(self):
68+
"""Test getting project tags when project has no tags attribute."""
69+
mock_project = MagicMock()
70+
del mock_project.tags # Remove tags attribute
71+
72+
mock_conn = MagicMock()
73+
mock_conn.identity.get_project.return_value = mock_project
74+
75+
tags = _keystone_project_tags(mock_conn, "test-project-id")
76+
77+
assert tags == []
78+
mock_conn.identity.get_project.assert_called_once_with("test-project-id")
79+
80+
def test_project_tags_empty_tags(self):
81+
"""Test getting project tags when project has empty tags."""
82+
mock_project = MagicMock()
83+
mock_project.tags = []
84+
85+
mock_conn = MagicMock()
86+
mock_conn.identity.get_project.return_value = mock_project
87+
88+
tags = _keystone_project_tags(mock_conn, "test-project-id")
89+
90+
assert tags == []
91+
mock_conn.identity.get_project.assert_called_once_with("test-project-id")
92+
93+
94+
class TestHandleProjectCreated:
95+
"""Test cases for handle_project_created function."""
96+
97+
@pytest.fixture
98+
def mock_conn(self):
99+
"""Create a mock OpenStack connection."""
100+
return MagicMock()
101+
102+
@pytest.fixture
103+
def mock_nautobot(self):
104+
"""Create a mock Nautobot instance."""
105+
return MagicMock()
106+
107+
@pytest.fixture
108+
def valid_event_data(self):
109+
"""Create valid event data for testing."""
110+
return {
111+
"event_type": "identity.project.created",
112+
"payload": {"target": {"id": "test-project-123"}},
113+
}
114+
115+
def test_handle_project_created_wrong_event_type(self, mock_conn, mock_nautobot):
116+
"""Test handling event with wrong event type."""
117+
event_data = {
118+
"event_type": "identity.project.updated",
119+
"payload": {"target": {"id": "test-project-123"}},
120+
}
121+
122+
result = handle_project_created(mock_conn, mock_nautobot, event_data)
123+
assert result == 1
124+
125+
@patch("understack_workflows.oslo_event.keystone_project._keystone_project_tags")
126+
def test_handle_project_created_no_svm_tag(
127+
self, mock_tags, mock_conn, mock_nautobot, valid_event_data
128+
):
129+
"""Test handling project creation without SVM tag."""
130+
mock_tags.return_value = ["tag1", "tag2"]
131+
132+
with pytest.raises(SystemExit) as exc_info:
133+
handle_project_created(mock_conn, mock_nautobot, valid_event_data)
134+
135+
assert exc_info.value.code == 0
136+
mock_tags.assert_called_once_with(mock_conn, "test-project-123")
137+
138+
@patch("understack_workflows.oslo_event.keystone_project.NetAppManager")
139+
@patch("understack_workflows.oslo_event.keystone_project._keystone_project_tags")
140+
def test_handle_project_created_with_svm_tag(
141+
self, mock_tags, mock_netapp_class, mock_conn, mock_nautobot, valid_event_data
142+
):
143+
"""Test successful project creation handling with SVM tag."""
144+
mock_tags.return_value = ["tag1", SVM_PROJECT_TAG, "tag2"]
145+
mock_netapp_manager = MagicMock()
146+
mock_netapp_class.return_value = mock_netapp_manager
147+
148+
result = handle_project_created(mock_conn, mock_nautobot, valid_event_data)
149+
150+
assert result == 0
151+
mock_tags.assert_called_once_with(mock_conn, "test-project-123")
152+
mock_netapp_class.assert_called_once()
153+
mock_netapp_manager.create_svm.assert_called_once_with(
154+
project_id="test-project-123", aggregate_name=AGGREGATE_NAME
155+
)
156+
mock_netapp_manager.create_volume.assert_called_once_with(
157+
project_id="test-project-123",
158+
volume_size=VOLUME_SIZE,
159+
aggregate_name=AGGREGATE_NAME,
160+
)
161+
162+
@patch("understack_workflows.oslo_event.keystone_project.NetAppManager")
163+
@patch("understack_workflows.oslo_event.keystone_project._keystone_project_tags")
164+
def test_handle_project_created_netapp_manager_failure(
165+
self, mock_tags, mock_netapp_class, mock_conn, mock_nautobot, valid_event_data
166+
):
167+
"""Test handling when NetAppManager creation fails."""
168+
mock_tags.return_value = [SVM_PROJECT_TAG]
169+
mock_netapp_class.side_effect = Exception("NetApp connection failed")
170+
171+
with pytest.raises(Exception, match="NetApp connection failed"):
172+
handle_project_created(mock_conn, mock_nautobot, valid_event_data)
173+
174+
mock_tags.assert_called_once_with(mock_conn, "test-project-123")
175+
mock_netapp_class.assert_called_once()
176+
177+
@patch("understack_workflows.oslo_event.keystone_project.NetAppManager")
178+
@patch("understack_workflows.oslo_event.keystone_project._keystone_project_tags")
179+
def test_handle_project_created_svm_creation_failure(
180+
self, mock_tags, mock_netapp_class, mock_conn, mock_nautobot, valid_event_data
181+
):
182+
"""Test handling when SVM creation fails."""
183+
mock_tags.return_value = [SVM_PROJECT_TAG]
184+
mock_netapp_manager = MagicMock()
185+
mock_netapp_manager.create_svm.side_effect = Exception("SVM creation failed")
186+
mock_netapp_class.return_value = mock_netapp_manager
187+
188+
with pytest.raises(Exception, match="SVM creation failed"):
189+
handle_project_created(mock_conn, mock_nautobot, valid_event_data)
190+
191+
mock_tags.assert_called_once_with(mock_conn, "test-project-123")
192+
mock_netapp_class.assert_called_once()
193+
mock_netapp_manager.create_svm.assert_called_once_with(
194+
project_id="test-project-123", aggregate_name=AGGREGATE_NAME
195+
)
196+
197+
@patch("understack_workflows.oslo_event.keystone_project.NetAppManager")
198+
@patch("understack_workflows.oslo_event.keystone_project._keystone_project_tags")
199+
def test_handle_project_created_volume_creation_failure(
200+
self, mock_tags, mock_netapp_class, mock_conn, mock_nautobot, valid_event_data
201+
):
202+
"""Test handling when volume creation fails."""
203+
mock_tags.return_value = [SVM_PROJECT_TAG]
204+
mock_netapp_manager = MagicMock()
205+
mock_netapp_manager.create_volume.side_effect = Exception(
206+
"Volume creation failed"
207+
)
208+
mock_netapp_class.return_value = mock_netapp_manager
209+
210+
with pytest.raises(Exception, match="Volume creation failed"):
211+
handle_project_created(mock_conn, mock_nautobot, valid_event_data)
212+
213+
mock_tags.assert_called_once_with(mock_conn, "test-project-123")
214+
mock_netapp_class.assert_called_once()
215+
mock_netapp_manager.create_svm.assert_called_once_with(
216+
project_id="test-project-123", aggregate_name=AGGREGATE_NAME
217+
)
218+
mock_netapp_manager.create_volume.assert_called_once_with(
219+
project_id="test-project-123",
220+
volume_size=VOLUME_SIZE,
221+
aggregate_name=AGGREGATE_NAME,
222+
)
223+
224+
def test_handle_project_created_invalid_event_data(self, mock_conn, mock_nautobot):
225+
"""Test handling with invalid event data."""
226+
invalid_event_data = {
227+
"event_type": "identity.project.created",
228+
"payload": {}, # Missing target
229+
}
230+
231+
with pytest.raises(Exception, match="no target information in payload"):
232+
handle_project_created(mock_conn, mock_nautobot, invalid_event_data)
233+
234+
@patch("understack_workflows.oslo_event.keystone_project._keystone_project_tags")
235+
def test_handle_project_created_constants_used(
236+
self, mock_tags, mock_conn, mock_nautobot, valid_event_data
237+
):
238+
"""Test constants used for aggregate name and volume size."""
239+
mock_tags.return_value = [SVM_PROJECT_TAG]
240+
241+
with patch(
242+
"understack_workflows.oslo_event.keystone_project.NetAppManager"
243+
) as mock_netapp_class:
244+
mock_netapp_manager = MagicMock()
245+
mock_netapp_class.return_value = mock_netapp_manager
246+
247+
handle_project_created(mock_conn, mock_nautobot, valid_event_data)
248+
249+
# Verify the constants are used correctly
250+
mock_netapp_manager.create_svm.assert_called_once_with(
251+
project_id="test-project-123",
252+
aggregate_name="aggr02_n02_NVME", # AGGREGATE_NAME constant
253+
)
254+
mock_netapp_manager.create_volume.assert_called_once_with(
255+
project_id="test-project-123",
256+
volume_size="1GB", # VOLUME_SIZE constant
257+
aggregate_name="aggr02_n02_NVME", # AGGREGATE_NAME constant
258+
)

0 commit comments

Comments
 (0)