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 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 FastAPI, Header, Request, Response, HTTPException, status
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,
@@ -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 = None
233
+ token = ""
234
234
 
235
235
  url = os.environ.get("NETBOX_API", None)
236
236