osism 0.20250627.0__py3-none-any.whl → 0.20250701.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,10 +3,11 @@
3
3
  import datetime
4
4
  from logging.config import dictConfig
5
5
  import logging
6
+ from typing import Optional, Dict, Any
6
7
  from uuid import UUID
7
8
 
8
- from fastapi import FastAPI, Header, Request, Response
9
- from pydantic import BaseModel
9
+ from fastapi import FastAPI, Header, Request, Response, HTTPException, status
10
+ from pydantic import BaseModel, Field
10
11
  from starlette.middleware.cors import CORSMiddleware
11
12
 
12
13
  from osism.tasks import reconciler
@@ -15,186 +16,311 @@ from osism.services.listener import BaremetalEvents
15
16
 
16
17
 
17
18
  class NotificationBaremetal(BaseModel):
18
- priority: str
19
- event_type: str
20
- timestamp: str
21
- publisher_id: str
22
- message_id: UUID
23
- payload: dict
19
+ priority: str = Field(..., description="Notification priority level")
20
+ event_type: str = Field(..., description="Type of the event")
21
+ timestamp: str = Field(..., description="Event timestamp")
22
+ publisher_id: str = Field(..., description="ID of the event publisher")
23
+ message_id: UUID = Field(..., description="Unique message identifier")
24
+ payload: Dict[str, Any] = Field(..., description="Event payload data")
24
25
 
25
26
 
26
27
  class WebhookNetboxResponse(BaseModel):
27
- result: str
28
+ result: str = Field(..., description="Operation result status")
28
29
 
29
30
 
30
31
  class WebhookNetboxData(BaseModel):
31
- username: str
32
- data: dict
33
- snapshots: dict
34
- event: str
35
- timestamp: datetime.datetime
36
- model: str
37
- request_id: UUID
32
+ username: str = Field(..., description="Username triggering the webhook")
33
+ data: Dict[str, Any] = Field(..., description="Webhook data payload")
34
+ snapshots: Dict[str, Any] = Field(..., description="Data snapshots")
35
+ event: str = Field(..., description="Event type")
36
+ timestamp: datetime.datetime = Field(..., description="Event timestamp")
37
+ model: str = Field(..., description="Model type")
38
+ request_id: UUID = Field(..., description="Unique request identifier")
38
39
 
39
40
 
40
- # https://stackoverflow.com/questions/63510041/adding-python-logging-to-fastapi-endpoints-hosted-on-docker-doesnt-display-api
41
41
  class LogConfig(BaseModel):
42
- """Logging configuration to be set for the server"""
42
+ """Logging configuration for the OSISM API server."""
43
43
 
44
44
  LOGGER_NAME: str = "osism"
45
- LOG_FORMAT: str = "%(levelprefix)s | %(asctime)s | %(message)s"
46
- LOG_LEVEL: str = "DEBUG"
45
+ LOG_FORMAT: str = "%(levelname)s | %(asctime)s | %(name)s | %(message)s"
46
+ LOG_LEVEL: str = "INFO"
47
47
 
48
48
  # Logging config
49
49
  version: int = 1
50
50
  disable_existing_loggers: bool = False
51
- formatters: dict = {
51
+ formatters: Dict[str, Any] = {
52
52
  "default": {
53
- "()": "uvicorn.logging.DefaultFormatter",
54
- "fmt": LOG_FORMAT,
53
+ "format": LOG_FORMAT,
55
54
  "datefmt": "%Y-%m-%d %H:%M:%S",
56
55
  },
57
56
  }
58
- handlers: dict = {
57
+ handlers: Dict[str, Any] = {
59
58
  "default": {
60
59
  "formatter": "default",
61
60
  "class": "logging.StreamHandler",
62
61
  "stream": "ext://sys.stderr",
63
62
  },
64
63
  }
65
- loggers: dict = {
66
- "api": {"handlers": ["default"], "level": LOG_LEVEL},
64
+ loggers: Dict[str, Any] = {
65
+ "osism": {"handlers": ["default"], "level": LOG_LEVEL, "propagate": False},
66
+ "api": {"handlers": ["default"], "level": LOG_LEVEL, "propagate": False},
67
+ "uvicorn": {"handlers": ["default"], "level": "INFO", "propagate": False},
68
+ "uvicorn.error": {"handlers": ["default"], "level": "INFO", "propagate": False},
69
+ "uvicorn.access": {
70
+ "handlers": ["default"],
71
+ "level": "INFO",
72
+ "propagate": False,
73
+ },
67
74
  }
68
75
 
69
76
 
70
- app = FastAPI()
71
-
72
- app.add_middleware(CORSMiddleware)
73
-
74
- dictConfig(LogConfig().dict())
75
- logger = logging.getLogger("api")
77
+ app = FastAPI(
78
+ title="OSISM API",
79
+ description="API for OpenStack Infrastructure & Service Manager",
80
+ version="1.0.0",
81
+ docs_url="/docs",
82
+ redoc_url="/redoc",
83
+ )
84
+
85
+ # Configure CORS - in production, replace with specific origins
86
+ app.add_middleware(
87
+ CORSMiddleware,
88
+ allow_origins=["*"], # TODO: Replace with actual allowed origins in production
89
+ allow_credentials=True,
90
+ allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
91
+ allow_headers=[
92
+ "Accept",
93
+ "Accept-Language",
94
+ "Content-Language",
95
+ "Content-Type",
96
+ "Authorization",
97
+ "X-Hook-Signature",
98
+ ],
99
+ )
100
+
101
+ dictConfig(LogConfig().model_dump())
102
+ logger = logging.getLogger("osism.api")
76
103
 
77
104
  baremetal_events = BaremetalEvents()
78
105
 
79
106
 
80
- @app.get("/")
81
- async def root():
82
- return {"result": "ok"}
83
-
84
-
85
- @app.get("/v1")
86
- async def v1():
87
- return {"result": "ok"}
88
-
89
-
90
- @app.post("/v1/meters/sink")
91
- async def write_sink_meters(request: Request):
92
- data = await request.json()
93
-
94
-
95
- @app.post("/v1/events/sink")
96
- async def write_sink_events(request: Request):
97
- data = await request.json()
98
-
99
-
100
- @app.post("/v1/notifications/baremetal", status_code=204)
101
- async def notifications_baremetal(notification: NotificationBaremetal) -> None:
102
-
103
- handler = baremetal_events.get_handler(notification.event_type)
104
- handler(notification.payload)
107
+ class DeviceSearchResult(BaseModel):
108
+ result: str = Field(..., description="Operation result status")
109
+ device: Optional[str] = Field(None, description="Device name if found")
105
110
 
106
111
 
107
- @app.post("/v1/switches/{identifier}/ztp/complete")
108
- async def switches_ztp_complete(identifier: str):
112
+ def find_device_by_identifier(identifier: str):
113
+ """Find a device in NetBox by various identifiers."""
109
114
  if not utils.nb:
110
- return {"result": "netbox not enabled"}
115
+ return None
111
116
 
112
117
  device = None
113
118
 
114
119
  # Search by device name
115
120
  devices = utils.nb.dcim.devices.filter(name=identifier)
116
121
  if devices:
117
- device = devices[0]
122
+ device = list(devices)[0]
118
123
 
119
124
  # Search by inventory_hostname custom field
120
125
  if not device:
121
126
  devices = utils.nb.dcim.devices.filter(cf_inventory_hostname=identifier)
122
127
  if devices:
123
- device = devices[0]
128
+ device = list(devices)[0]
124
129
 
125
130
  # Search by serial number
126
131
  if not device:
127
132
  devices = utils.nb.dcim.devices.filter(serial=identifier)
128
133
  if devices:
129
- device = devices[0]
134
+ device = list(devices)[0]
130
135
 
131
- if device:
136
+ return device
137
+
138
+
139
+ @app.get("/", tags=["health"])
140
+ async def root() -> Dict[str, str]:
141
+ """Health check endpoint."""
142
+ return {"result": "ok"}
143
+
144
+
145
+ @app.get("/v1", tags=["health"])
146
+ async def v1() -> Dict[str, str]:
147
+ """API version 1 health check endpoint."""
148
+ return {"result": "ok"}
149
+
150
+
151
+ class SinkResponse(BaseModel):
152
+ result: str = Field(..., description="Operation result status")
153
+
154
+
155
+ @app.post("/v1/meters/sink", response_model=SinkResponse, tags=["telemetry"])
156
+ async def write_sink_meters(request: Request) -> SinkResponse:
157
+ """Write telemetry meters to sink."""
158
+ try:
159
+ data = await request.json()
160
+ # TODO: Implement meter processing logic
132
161
  logger.info(
133
- f"Found device {device.name} for ZTP complete with identifier {identifier}"
162
+ f"Received meters data: {len(data) if isinstance(data, list) else 1} entries"
163
+ )
164
+ return SinkResponse(result="ok")
165
+ except Exception as e:
166
+ logger.error(f"Error processing meters: {str(e)}")
167
+ raise HTTPException(
168
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
169
+ detail="Failed to process meters data",
134
170
  )
135
171
 
136
- # Set provision_state custom field to active
137
- device.custom_fields["provision_state"] = "active"
138
- device.save()
139
172
 
140
- return {"result": "ok", "device": device.name}
173
+ @app.post("/v1/events/sink", response_model=SinkResponse, tags=["telemetry"])
174
+ async def write_sink_events(request: Request) -> SinkResponse:
175
+ """Write telemetry events to sink."""
176
+ try:
177
+ data = await request.json()
178
+ # TODO: Implement event processing logic
179
+ logger.info(
180
+ f"Received events data: {len(data) if isinstance(data, list) else 1} entries"
181
+ )
182
+ return SinkResponse(result="ok")
183
+ except Exception as e:
184
+ logger.error(f"Error processing events: {str(e)}")
185
+ raise HTTPException(
186
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
187
+ detail="Failed to process events data",
188
+ )
189
+
190
+
191
+ @app.post("/v1/notifications/baremetal", status_code=204, tags=["notifications"])
192
+ async def notifications_baremetal(notification: NotificationBaremetal) -> None:
193
+ """Handle baremetal notifications."""
194
+ try:
195
+ handler = baremetal_events.get_handler(notification.event_type)
196
+ handler(notification.payload)
197
+ logger.info(
198
+ f"Successfully processed baremetal notification: {notification.event_type}"
199
+ )
200
+ except Exception as e:
201
+ logger.error(f"Error processing baremetal notification: {str(e)}")
202
+ if isinstance(e, HTTPException):
203
+ raise
204
+ raise HTTPException(
205
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
206
+ detail="Failed to process baremetal notification",
207
+ )
208
+
209
+
210
+ @app.post(
211
+ "/v1/switches/{identifier}/ztp/complete",
212
+ response_model=DeviceSearchResult,
213
+ tags=["switches"],
214
+ )
215
+ async def switches_ztp_complete(identifier: str) -> DeviceSearchResult:
216
+ """Mark a switch as ZTP complete by setting provision_state to active."""
217
+ if not utils.nb:
218
+ raise HTTPException(
219
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
220
+ detail="NetBox is not enabled",
221
+ )
222
+
223
+ try:
224
+ device = find_device_by_identifier(identifier)
225
+
226
+ if device:
227
+ logger.info(
228
+ f"Found device {device.name} for ZTP complete with identifier {identifier}"
229
+ )
230
+
231
+ # Set provision_state custom field to active
232
+ device.custom_fields["provision_state"] = "active"
233
+ device.save()
234
+
235
+ return DeviceSearchResult(result="ok", device=device.name)
236
+ else:
237
+ logger.warning(
238
+ f"No device found for ZTP complete with identifier {identifier}"
239
+ )
240
+ raise HTTPException(
241
+ status_code=status.HTTP_404_NOT_FOUND,
242
+ detail=f"Device not found with identifier: {identifier}",
243
+ )
244
+ except Exception as e:
245
+ logger.error(f"Error completing ZTP for device {identifier}: {str(e)}")
246
+ if isinstance(e, HTTPException):
247
+ raise
248
+ raise HTTPException(
249
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
250
+ detail="Failed to complete ZTP process",
251
+ )
252
+
253
+
254
+ def process_netbox_webhook(webhook_input: WebhookNetboxData) -> None:
255
+ """Process NetBox webhook data."""
256
+ data = webhook_input.data
257
+ url = data["url"]
258
+ name = data["name"]
259
+
260
+ if "devices" in url:
261
+ tags = [x["name"] for x in data["tags"]]
262
+ custom_fields = data["custom_fields"]
263
+ device_type = custom_fields.get("device_type") or "node"
264
+
265
+ elif "interfaces" in url:
266
+ device_type = "interface"
267
+ device_id = data["device"]["id"]
268
+ device = utils.nb.dcim.devices.get(id=device_id)
269
+ tags = [str(x) for x in device.tags]
270
+ custom_fields = device.custom_fields
271
+ else:
272
+ logger.warning(f"Unknown webhook URL type: {url}")
273
+ return
274
+
275
+ if "Managed by OSISM" in tags:
276
+ if device_type == "server":
277
+ logger.info(
278
+ f"Handling change for managed device {name} of type {device_type}"
279
+ )
280
+ reconciler.run.delay()
281
+ elif device_type == "switch":
282
+ logger.info(
283
+ f"Handling change for managed device {name} of type {device_type}"
284
+ )
285
+ # TODO: Implement switch configuration generation
286
+ # netbox.generate.delay(name, custom_fields['configuration_template'])
287
+ elif device_type == "interface":
288
+ logger.info(
289
+ f"Handling change for interface {name} on managed device {device.name} of type {custom_fields['device_type']}"
290
+ )
291
+ # TODO: Implement interface configuration generation
292
+ # netbox.generate.delay(device.name, custom_fields['configuration_template'])
141
293
  else:
142
- logger.warning(f"No device found for ZTP complete with identifier {identifier}")
143
- return {"result": "device not found"}
294
+ logger.info(f"Ignoring change for unmanaged device {name}")
144
295
 
145
296
 
146
- @app.post("/v1/webhook/netbox", response_model=WebhookNetboxResponse, status_code=200)
297
+ @app.post(
298
+ "/v1/webhook/netbox",
299
+ response_model=WebhookNetboxResponse,
300
+ status_code=200,
301
+ tags=["webhooks"],
302
+ )
147
303
  async def webhook(
148
304
  webhook_input: WebhookNetboxData,
149
305
  request: Request,
150
306
  response: Response,
151
307
  content_length: int = Header(...),
152
- x_hook_signature: str = Header(None),
153
- ):
154
- if utils.nb:
155
- data = webhook_input.data
156
- url = data["url"]
157
- name = data["name"]
158
-
159
- if "devices" in url:
160
- tags = [x["name"] for x in data["tags"]]
161
-
162
- custom_fields = data["custom_fields"]
163
- device_type = custom_fields["device_type"]
164
-
165
- # NOTE: device without a defined device_type are nodes
166
- if not device_type:
167
- device_type = "node"
168
-
169
- elif "interfaces" in url:
170
- device_type = "interface"
171
-
172
- device_id = data["device"]["id"]
173
- device = utils.nb.dcim.devices.get(id=device_id)
174
- tags = [str(x) for x in device.tags]
175
- custom_fields = device.custom_fields
176
-
177
- if "Managed by OSISM" in tags:
178
- if device_type == "server":
179
- logger.info(
180
- f"Handling change for managed device {name} of type {device_type}"
181
- )
182
- reconciler.run.delay()
183
- elif device_type == "switch":
184
- logger.info(
185
- f"Handling change for managed device {name} of type {device_type}"
186
- )
187
- # netbox.generate.delay(name, custom_fields['configuration_template'])
188
- elif device_type == "interface":
189
- logger.info(
190
- f"Handling change for interface {name} on managed device {device.name} of type {custom_fields['device_type']}"
191
- )
192
- # netbox.generate.delay(device.name, custom_fields['configuration_template'])
193
-
194
- else:
195
- logger.info(f"Ignoring change for unmanaged device {name}")
196
-
197
- return {"result": "ok"}
308
+ x_hook_signature: Optional[str] = Header(None),
309
+ ) -> WebhookNetboxResponse:
310
+ """Handle NetBox webhook notifications."""
311
+ if not utils.nb:
312
+ raise HTTPException(
313
+ status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
314
+ detail="NetBox webhook processing is not enabled",
315
+ )
198
316
 
199
- else:
200
- return {"result": "webhook netbox not enabled"}
317
+ try:
318
+ # TODO: Validate webhook signature if x_hook_signature is provided
319
+ process_netbox_webhook(webhook_input)
320
+ return WebhookNetboxResponse(result="ok")
321
+ except Exception as e:
322
+ logger.error(f"Error processing NetBox webhook: {str(e)}")
323
+ raise HTTPException(
324
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
325
+ detail="Failed to process NetBox webhook",
326
+ )
osism/commands/apply.py CHANGED
@@ -9,8 +9,8 @@ from cliff.command import Command
9
9
  from loguru import logger
10
10
  from tabulate import tabulate
11
11
 
12
- from osism.core import enums
13
- from osism.core.playbooks import MAP_ROLE2ENVIRONMENT, MAP_ROLE2RUNTIME
12
+ from osism.data import enums
13
+ from osism.data.playbooks import MAP_ROLE2ENVIRONMENT, MAP_ROLE2RUNTIME
14
14
  from osism.tasks import ansible, ceph, kolla, kubernetes, handle_task
15
15
 
16
16
 
@@ -12,6 +12,7 @@ import yaml
12
12
  from openstack.baremetal import configdrive as configdrive_builder
13
13
 
14
14
  from osism.commands import get_cloud_connection
15
+ from osism import utils
15
16
 
16
17
 
17
18
  class BaremetalList(Command):
@@ -169,16 +170,51 @@ class BaremetalDeploy(Command):
169
170
  continue
170
171
  # NOTE: Prepare osism config drive
171
172
  try:
173
+ # Get default vars from NetBox local_context_data if available
174
+ default_vars = {}
175
+ if utils.nb:
176
+ try:
177
+ # Try to find device by name first
178
+ device = utils.nb.dcim.devices.get(name=node.name)
179
+
180
+ # If not found by name, try by inventory_hostname custom field
181
+ if not device:
182
+ devices = utils.nb.dcim.devices.filter(
183
+ cf_inventory_hostname=node.name
184
+ )
185
+ if devices:
186
+ device = devices[0]
187
+
188
+ # Extract local_context_data if device found and has the field
189
+ if (
190
+ device
191
+ and hasattr(device, "local_context_data")
192
+ and device.local_context_data
193
+ ):
194
+ default_vars = device.local_context_data
195
+ logger.info(
196
+ f"Using NetBox local_context_data for node {node.name}"
197
+ )
198
+ else:
199
+ logger.debug(
200
+ f"No local_context_data found for node {node.name} in NetBox"
201
+ )
202
+ except Exception as e:
203
+ logger.warning(
204
+ f"Failed to fetch NetBox data for node {node.name}: {e}"
205
+ )
206
+
172
207
  playbook = []
173
208
  play = {
174
209
  "name": "Run bootstrap - part 2",
175
210
  "hosts": "localhost",
176
211
  "connection": "local",
177
212
  "gather_facts": True,
178
- "vars": {},
213
+ "vars": default_vars.copy(),
179
214
  "roles": [
180
215
  "osism.commons.hostname",
181
216
  "osism.commons.hosts",
217
+ "osism.commons.operator",
182
218
  ],
183
219
  }
184
220
  play["vars"].update(
@@ -293,11 +329,6 @@ class BaremetalUndeploy(Command):
293
329
  f"Node {node.name} ({node.id}) could not be moved to available state: {exc}"
294
330
  )
295
331
  continue
296
- # NOTE: Ironic removes "instance_info" on undeploy. It was saved to "extra" during sync and needs to be refreshed here.
297
- if "instance_info" in node["extra"]:
298
- node = conn.baremetal.update_node(
299
- node, instance_info=json.loads(node.extra["instance_info"])
300
- )
301
332
  else:
302
333
  logger.warning(
303
334
  f"Node {node.name} ({node.id}) not in supported provision state"