osism 0.20250804.0__py3-none-any.whl → 0.20250824.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- osism/api.py +154 -2
- osism/commands/baremetal.py +168 -0
- osism/commands/netbox.py +2 -2
- osism/services/event_bridge.py +304 -0
- osism/services/listener.py +130 -19
- osism/services/websocket_manager.py +271 -0
- osism/settings.py +1 -1
- osism/tasks/conductor/ironic.py +22 -17
- osism/tasks/conductor/netbox.py +58 -1
- osism/tasks/conductor/sonic/config_generator.py +341 -26
- osism/tasks/conductor/sonic/connections.py +123 -0
- osism/tasks/conductor/sonic/interface.py +3 -1
- osism/tasks/openstack.py +35 -15
- osism/utils/__init__.py +2 -2
- {osism-0.20250804.0.dist-info → osism-0.20250824.0.dist-info}/METADATA +7 -6
- {osism-0.20250804.0.dist-info → osism-0.20250824.0.dist-info}/RECORD +22 -20
- {osism-0.20250804.0.dist-info → osism-0.20250824.0.dist-info}/entry_points.txt +4 -0
- osism-0.20250824.0.dist-info/licenses/AUTHORS +1 -0
- osism-0.20250824.0.dist-info/pbr.json +1 -0
- osism-0.20250804.0.dist-info/licenses/AUTHORS +0 -1
- osism-0.20250804.0.dist-info/pbr.json +0 -1
- {osism-0.20250804.0.dist-info → osism-0.20250824.0.dist-info}/WHEEL +0 -0
- {osism-0.20250804.0.dist-info → osism-0.20250824.0.dist-info}/licenses/LICENSE +0 -0
- {osism-0.20250804.0.dist-info → osism-0.20250824.0.dist-info}/top_level.txt +0 -0
osism/api.py
CHANGED
@@ -3,16 +3,28 @@
|
|
3
3
|
import datetime
|
4
4
|
from logging.config import dictConfig
|
5
5
|
import logging
|
6
|
+
import json
|
6
7
|
from typing import Optional, Dict, Any
|
7
8
|
from uuid import UUID
|
8
9
|
|
9
|
-
from fastapi import
|
10
|
+
from fastapi import (
|
11
|
+
FastAPI,
|
12
|
+
Header,
|
13
|
+
Request,
|
14
|
+
Response,
|
15
|
+
HTTPException,
|
16
|
+
status,
|
17
|
+
WebSocket,
|
18
|
+
WebSocketDisconnect,
|
19
|
+
)
|
10
20
|
from pydantic import BaseModel, Field
|
11
21
|
from starlette.middleware.cors import CORSMiddleware
|
12
22
|
|
13
|
-
from osism.tasks import reconciler
|
23
|
+
from osism.tasks import reconciler, openstack
|
14
24
|
from osism import utils
|
15
25
|
from osism.services.listener import BaremetalEvents
|
26
|
+
from osism.services.websocket_manager import websocket_manager
|
27
|
+
from osism.services.event_bridge import event_bridge
|
16
28
|
|
17
29
|
|
18
30
|
class NotificationBaremetal(BaseModel):
|
@@ -95,6 +107,10 @@ app.add_middleware(
|
|
95
107
|
"Content-Type",
|
96
108
|
"Authorization",
|
97
109
|
"X-Hook-Signature",
|
110
|
+
"Sec-WebSocket-Protocol",
|
111
|
+
"Sec-WebSocket-Key",
|
112
|
+
"Sec-WebSocket-Version",
|
113
|
+
"Sec-WebSocket-Extensions",
|
98
114
|
],
|
99
115
|
)
|
100
116
|
|
@@ -103,12 +119,46 @@ logger = logging.getLogger("osism.api")
|
|
103
119
|
|
104
120
|
baremetal_events = BaremetalEvents()
|
105
121
|
|
122
|
+
# Connect event bridge to WebSocket manager
|
123
|
+
event_bridge.set_websocket_manager(websocket_manager)
|
124
|
+
|
106
125
|
|
107
126
|
class DeviceSearchResult(BaseModel):
|
108
127
|
result: str = Field(..., description="Operation result status")
|
109
128
|
device: Optional[str] = Field(None, description="Device name if found")
|
110
129
|
|
111
130
|
|
131
|
+
class BaremetalNode(BaseModel):
|
132
|
+
uuid: Optional[str] = Field(None, description="Unique identifier of the node")
|
133
|
+
name: Optional[str] = Field(None, description="Name of the node")
|
134
|
+
power_state: Optional[str] = Field(None, description="Current power state")
|
135
|
+
provision_state: Optional[str] = Field(None, description="Current provision state")
|
136
|
+
maintenance: Optional[bool] = Field(
|
137
|
+
None, description="Whether node is in maintenance mode"
|
138
|
+
)
|
139
|
+
instance_uuid: Optional[str] = Field(
|
140
|
+
None, description="UUID of associated instance"
|
141
|
+
)
|
142
|
+
driver: Optional[str] = Field(None, description="Driver used for the node")
|
143
|
+
resource_class: Optional[str] = Field(
|
144
|
+
None, description="Resource class of the node"
|
145
|
+
)
|
146
|
+
properties: Dict[str, Any] = Field(
|
147
|
+
default_factory=dict, description="Node properties"
|
148
|
+
)
|
149
|
+
extra: Dict[str, Any] = Field(
|
150
|
+
default_factory=dict, description="Extra node information"
|
151
|
+
)
|
152
|
+
last_error: Optional[str] = Field(None, description="Last error message")
|
153
|
+
created_at: Optional[str] = Field(None, description="Creation timestamp")
|
154
|
+
updated_at: Optional[str] = Field(None, description="Last update timestamp")
|
155
|
+
|
156
|
+
|
157
|
+
class BaremetalNodesResponse(BaseModel):
|
158
|
+
nodes: list[BaremetalNode] = Field(..., description="List of baremetal nodes")
|
159
|
+
count: int = Field(..., description="Total number of nodes")
|
160
|
+
|
161
|
+
|
112
162
|
def find_device_by_identifier(identifier: str):
|
113
163
|
"""Find a device in NetBox by various identifiers."""
|
114
164
|
if not utils.nb:
|
@@ -148,6 +198,16 @@ async def v1() -> Dict[str, str]:
|
|
148
198
|
return {"result": "ok"}
|
149
199
|
|
150
200
|
|
201
|
+
@app.get("/v1/events", tags=["events"])
|
202
|
+
async def events_info() -> Dict[str, str]:
|
203
|
+
"""Events endpoint info - WebSocket available at /v1/events/openstack."""
|
204
|
+
return {
|
205
|
+
"result": "ok",
|
206
|
+
"websocket_endpoint": "/v1/events/openstack",
|
207
|
+
"description": "Real-time OpenStack events via WebSocket",
|
208
|
+
}
|
209
|
+
|
210
|
+
|
151
211
|
class SinkResponse(BaseModel):
|
152
212
|
result: str = Field(..., description="Operation result status")
|
153
213
|
|
@@ -188,6 +248,31 @@ async def write_sink_events(request: Request) -> SinkResponse:
|
|
188
248
|
)
|
189
249
|
|
190
250
|
|
251
|
+
@app.get(
|
252
|
+
"/v1/baremetal/nodes", response_model=BaremetalNodesResponse, tags=["baremetal"]
|
253
|
+
)
|
254
|
+
async def get_baremetal_nodes_list() -> BaremetalNodesResponse:
|
255
|
+
"""Get list of all baremetal nodes managed by Ironic.
|
256
|
+
|
257
|
+
Returns information similar to the 'baremetal list' command,
|
258
|
+
including node details, power state, provision state, and more.
|
259
|
+
"""
|
260
|
+
try:
|
261
|
+
# Use the generalized function to get baremetal nodes
|
262
|
+
nodes_data = openstack.get_baremetal_nodes()
|
263
|
+
|
264
|
+
# Convert to response model
|
265
|
+
nodes = [BaremetalNode(**node) for node in nodes_data]
|
266
|
+
|
267
|
+
return BaremetalNodesResponse(nodes=nodes, count=len(nodes))
|
268
|
+
except Exception as e:
|
269
|
+
logger.error(f"Error retrieving baremetal nodes: {str(e)}")
|
270
|
+
raise HTTPException(
|
271
|
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
272
|
+
detail=f"Failed to retrieve baremetal nodes: {str(e)}",
|
273
|
+
)
|
274
|
+
|
275
|
+
|
191
276
|
@app.post("/v1/notifications/baremetal", status_code=204, tags=["notifications"])
|
192
277
|
async def notifications_baremetal(notification: NotificationBaremetal) -> None:
|
193
278
|
"""Handle baremetal notifications."""
|
@@ -294,6 +379,73 @@ def process_netbox_webhook(webhook_input: WebhookNetboxData) -> None:
|
|
294
379
|
logger.info(f"Ignoring change for unmanaged device {name}")
|
295
380
|
|
296
381
|
|
382
|
+
@app.websocket("/v1/events/openstack")
|
383
|
+
async def websocket_openstack_events(websocket: WebSocket):
|
384
|
+
"""WebSocket endpoint for streaming all OpenStack events in real-time.
|
385
|
+
|
386
|
+
Supports events from all OpenStack services: Ironic, Nova, Neutron, Cinder, Glance, Keystone
|
387
|
+
|
388
|
+
Clients can send filter messages in JSON format:
|
389
|
+
{
|
390
|
+
"action": "set_filters",
|
391
|
+
"event_filters": ["baremetal.node.power_set.end", "compute.instance.create.end", "network.port.create.end"],
|
392
|
+
"node_filters": ["server-01", "server-02"],
|
393
|
+
"service_filters": ["baremetal", "compute", "network"]
|
394
|
+
}
|
395
|
+
"""
|
396
|
+
await websocket_manager.connect(websocket)
|
397
|
+
try:
|
398
|
+
# Keep the connection alive and listen for client messages
|
399
|
+
while True:
|
400
|
+
try:
|
401
|
+
# Receive messages from client for filtering configuration
|
402
|
+
data = await websocket.receive_text()
|
403
|
+
logger.debug(f"Received WebSocket message: {data}")
|
404
|
+
|
405
|
+
try:
|
406
|
+
message = json.loads(data)
|
407
|
+
if message.get("action") == "set_filters":
|
408
|
+
event_filters = message.get("event_filters")
|
409
|
+
node_filters = message.get("node_filters")
|
410
|
+
service_filters = message.get("service_filters")
|
411
|
+
|
412
|
+
await websocket_manager.update_filters(
|
413
|
+
websocket,
|
414
|
+
event_filters=event_filters,
|
415
|
+
node_filters=node_filters,
|
416
|
+
service_filters=service_filters,
|
417
|
+
)
|
418
|
+
|
419
|
+
# Send acknowledgment
|
420
|
+
response = {
|
421
|
+
"type": "filter_update",
|
422
|
+
"status": "success",
|
423
|
+
"event_filters": event_filters,
|
424
|
+
"node_filters": node_filters,
|
425
|
+
"service_filters": service_filters,
|
426
|
+
}
|
427
|
+
await websocket.send_text(json.dumps(response))
|
428
|
+
|
429
|
+
except json.JSONDecodeError:
|
430
|
+
logger.warning(
|
431
|
+
f"Invalid JSON received from WebSocket client: {data}"
|
432
|
+
)
|
433
|
+
except Exception as e:
|
434
|
+
logger.error(f"Error processing WebSocket filter message: {e}")
|
435
|
+
|
436
|
+
except WebSocketDisconnect:
|
437
|
+
logger.info("WebSocket client disconnected")
|
438
|
+
break
|
439
|
+
except Exception as e:
|
440
|
+
logger.error(f"Error handling WebSocket message: {e}")
|
441
|
+
break
|
442
|
+
|
443
|
+
except WebSocketDisconnect:
|
444
|
+
logger.info("WebSocket client disconnected")
|
445
|
+
finally:
|
446
|
+
await websocket_manager.disconnect(websocket)
|
447
|
+
|
448
|
+
|
297
449
|
@app.post(
|
298
450
|
"/v1/webhook/netbox",
|
299
451
|
response_model=WebhookNetboxResponse,
|
osism/commands/baremetal.py
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
# SPDX-License-Identifier: Apache-2.0
|
2
2
|
|
3
3
|
from cliff.command import Command
|
4
|
+
from argparse import BooleanOptionalAction
|
4
5
|
|
5
6
|
import tempfile
|
6
7
|
import os
|
@@ -533,3 +534,170 @@ class BaremetalPing(Command):
|
|
533
534
|
except Exception as e:
|
534
535
|
logger.error(f"Error during ping operation: {e}")
|
535
536
|
return
|
537
|
+
|
538
|
+
|
539
|
+
class BaremetalBurnIn(Command):
|
540
|
+
def get_parser(self, prog_name):
|
541
|
+
parser = super(BaremetalBurnIn, self).get_parser(prog_name)
|
542
|
+
|
543
|
+
parser.add_argument(
|
544
|
+
"name",
|
545
|
+
nargs="?",
|
546
|
+
type=str,
|
547
|
+
help="Run burn-in on given baremetal node when in provision state available",
|
548
|
+
)
|
549
|
+
parser.add_argument(
|
550
|
+
"--all",
|
551
|
+
default=False,
|
552
|
+
help="Run burn-in on all baremetal nodes in provision state available",
|
553
|
+
action="store_true",
|
554
|
+
)
|
555
|
+
parser.add_argument(
|
556
|
+
"--cpu",
|
557
|
+
default=True,
|
558
|
+
help="Enable CPU burn-in",
|
559
|
+
action=BooleanOptionalAction,
|
560
|
+
)
|
561
|
+
parser.add_argument(
|
562
|
+
"--memory",
|
563
|
+
default=True,
|
564
|
+
help="Enable memory burn-in",
|
565
|
+
action=BooleanOptionalAction,
|
566
|
+
)
|
567
|
+
parser.add_argument(
|
568
|
+
"--disk",
|
569
|
+
default=True,
|
570
|
+
help="Enable disk burn-in",
|
571
|
+
action=BooleanOptionalAction,
|
572
|
+
)
|
573
|
+
return parser
|
574
|
+
|
575
|
+
def take_action(self, parsed_args):
|
576
|
+
all_nodes = parsed_args.all
|
577
|
+
name = parsed_args.name
|
578
|
+
|
579
|
+
stressor = {}
|
580
|
+
stressor["cpu"] = parsed_args.cpu
|
581
|
+
stressor["memory"] = parsed_args.memory
|
582
|
+
stressor["disk"] = parsed_args.disk
|
583
|
+
|
584
|
+
if not all_nodes and not name:
|
585
|
+
logger.error("Please specify a node name or use --all")
|
586
|
+
return
|
587
|
+
|
588
|
+
clean_steps = []
|
589
|
+
for step, activated in stressor.items():
|
590
|
+
if activated:
|
591
|
+
clean_steps.append({"step": "burnin_" + step, "interface": "deploy"})
|
592
|
+
if not clean_steps:
|
593
|
+
logger.error(
|
594
|
+
f"Please specify at least one of {', '.join(stressor.keys())} for burn-in"
|
595
|
+
)
|
596
|
+
return
|
597
|
+
|
598
|
+
conn = get_cloud_connection()
|
599
|
+
|
600
|
+
if all_nodes:
|
601
|
+
burn_in_nodes = list(conn.baremetal.nodes(details=True))
|
602
|
+
else:
|
603
|
+
node = conn.baremetal.find_node(name, ignore_missing=True, details=True)
|
604
|
+
if not node:
|
605
|
+
logger.warning(f"Could not find node {name}")
|
606
|
+
return
|
607
|
+
burn_in_nodes = [node]
|
608
|
+
|
609
|
+
for node in burn_in_nodes:
|
610
|
+
if not node:
|
611
|
+
continue
|
612
|
+
|
613
|
+
if node.provision_state in ["available"]:
|
614
|
+
# NOTE: Burn-In is available in the "manageable" provision state, so we move the node into this state
|
615
|
+
try:
|
616
|
+
node = conn.baremetal.set_node_provision_state(node.id, "manage")
|
617
|
+
node = conn.baremetal.wait_for_nodes_provision_state(
|
618
|
+
[node.id], "manageable"
|
619
|
+
)[0]
|
620
|
+
except Exception as exc:
|
621
|
+
logger.warning(
|
622
|
+
f"Node {node.name} ({node.id}) could not be moved to manageable state: {exc}"
|
623
|
+
)
|
624
|
+
continue
|
625
|
+
|
626
|
+
if node.provision_state in ["manageable"]:
|
627
|
+
try:
|
628
|
+
conn.baremetal.set_node_provision_state(
|
629
|
+
node.id, "clean", clean_steps=clean_steps
|
630
|
+
)
|
631
|
+
except Exception as exc:
|
632
|
+
logger.warning(
|
633
|
+
f"Burn-In of node {node.name} ({node.id}) failed: {exc}"
|
634
|
+
)
|
635
|
+
continue
|
636
|
+
else:
|
637
|
+
logger.warning(
|
638
|
+
f"Node {node.name} ({node.id}) not in supported state! Provision state: {node.provision_state}, maintenance mode: {node['maintenance']}"
|
639
|
+
)
|
640
|
+
continue
|
641
|
+
|
642
|
+
|
643
|
+
class BaremetalMaintenanceSet(Command):
|
644
|
+
def get_parser(self, prog_name):
|
645
|
+
parser = super(BaremetalMaintenanceSet, self).get_parser(prog_name)
|
646
|
+
|
647
|
+
parser.add_argument(
|
648
|
+
"name",
|
649
|
+
nargs="?",
|
650
|
+
type=str,
|
651
|
+
help="Set maintenance on given baremetal node",
|
652
|
+
)
|
653
|
+
parser.add_argument(
|
654
|
+
"--reason",
|
655
|
+
default=None,
|
656
|
+
type=str,
|
657
|
+
help="Reason for maintenance",
|
658
|
+
)
|
659
|
+
return parser
|
660
|
+
|
661
|
+
def take_action(self, parsed_args):
|
662
|
+
name = parsed_args.name
|
663
|
+
reason = parsed_args.reason
|
664
|
+
|
665
|
+
conn = get_cloud_connection()
|
666
|
+
node = conn.baremetal.find_node(name, ignore_missing=True, details=True)
|
667
|
+
if not node:
|
668
|
+
logger.warning(f"Could not find node {name}")
|
669
|
+
return
|
670
|
+
try:
|
671
|
+
conn.baremetal.set_node_maintenance(node, reason=reason)
|
672
|
+
except Exception as exc:
|
673
|
+
logger.error(
|
674
|
+
f"Setting maintenance mode on node {node.name} ({node.id}) failed: {exc}"
|
675
|
+
)
|
676
|
+
|
677
|
+
|
678
|
+
class BaremetalMaintenanceUnset(Command):
|
679
|
+
def get_parser(self, prog_name):
|
680
|
+
parser = super(BaremetalMaintenanceUnset, self).get_parser(prog_name)
|
681
|
+
|
682
|
+
parser.add_argument(
|
683
|
+
"name",
|
684
|
+
nargs="?",
|
685
|
+
type=str,
|
686
|
+
help="Unset maintenance on given baremetal node",
|
687
|
+
)
|
688
|
+
return parser
|
689
|
+
|
690
|
+
def take_action(self, parsed_args):
|
691
|
+
name = parsed_args.name
|
692
|
+
|
693
|
+
conn = get_cloud_connection()
|
694
|
+
node = conn.baremetal.find_node(name, ignore_missing=True, details=True)
|
695
|
+
if not node:
|
696
|
+
logger.warning(f"Could not find node {name}")
|
697
|
+
return
|
698
|
+
try:
|
699
|
+
conn.baremetal.unset_node_maintenance(node)
|
700
|
+
except Exception as exc:
|
701
|
+
logger.error(
|
702
|
+
f"Unsetting maintenance mode on node {node.name} ({node.id}) failed: {exc}"
|
703
|
+
)
|
osism/commands/netbox.py
CHANGED
@@ -228,9 +228,9 @@ class Console(Command):
|
|
228
228
|
if not os.path.exists(nbcli_file):
|
229
229
|
try:
|
230
230
|
with open("/run/secrets/NETBOX_TOKEN", "r") as fp:
|
231
|
-
token = fp.read().strip()
|
231
|
+
token = str(fp.read().strip())
|
232
232
|
except FileNotFoundError:
|
233
|
-
token =
|
233
|
+
token = ""
|
234
234
|
|
235
235
|
url = os.environ.get("NETBOX_API", None)
|
236
236
|
|