osism 0.20250621.0__py3-none-any.whl → 0.20250628.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,142 +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()
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")
71
103
 
72
- app.add_middleware(CORSMiddleware)
104
+ baremetal_events = BaremetalEvents()
73
105
 
74
- dictConfig(LogConfig().dict())
75
- logger = logging.getLogger("api")
76
106
 
77
- baremetal_events = BaremetalEvents()
107
+ class DeviceSearchResult(BaseModel):
108
+ result: str = Field(..., description="Operation result status")
109
+ device: Optional[str] = Field(None, description="Device name if found")
78
110
 
79
111
 
80
- @app.get("/")
81
- async def root():
82
- return {"message": "Hello World"}
112
+ def find_device_by_identifier(identifier: str):
113
+ """Find a device in NetBox by various identifiers."""
114
+ if not utils.nb:
115
+ return None
83
116
 
117
+ device = None
84
118
 
85
- @app.post("/meters/sink")
86
- async def write_sink_meters(request: Request):
87
- data = await request.json()
119
+ # Search by device name
120
+ devices = utils.nb.dcim.devices.filter(name=identifier)
121
+ if devices:
122
+ device = list(devices)[0]
88
123
 
124
+ # Search by inventory_hostname custom field
125
+ if not device:
126
+ devices = utils.nb.dcim.devices.filter(cf_inventory_hostname=identifier)
127
+ if devices:
128
+ device = list(devices)[0]
89
129
 
90
- @app.post("/events/sink")
91
- async def write_sink_events(request: Request):
92
- data = await request.json()
130
+ # Search by serial number
131
+ if not device:
132
+ devices = utils.nb.dcim.devices.filter(serial=identifier)
133
+ if devices:
134
+ device = list(devices)[0]
93
135
 
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
161
+ logger.info(
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",
170
+ )
94
171
 
95
- @app.post("/notifications/baremetal", status_code=204)
96
- async def notifications_baremetal(notification: NotificationBaremetal) -> None:
97
172
 
98
- handler = baremetal_events.get_handler(notification.event_type)
99
- handler(notification.payload)
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
+ )
100
189
 
101
190
 
102
- @app.post("/webhook/netbox", response_model=WebhookNetboxResponse, status_code=200)
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'])
293
+ else:
294
+ logger.info(f"Ignoring change for unmanaged device {name}")
295
+
296
+
297
+ @app.post(
298
+ "/v1/webhook/netbox",
299
+ response_model=WebhookNetboxResponse,
300
+ status_code=200,
301
+ tags=["webhooks"],
302
+ )
103
303
  async def webhook(
104
304
  webhook_input: WebhookNetboxData,
105
305
  request: Request,
106
306
  response: Response,
107
307
  content_length: int = Header(...),
108
- x_hook_signature: str = Header(None),
109
- ):
110
- if utils.nb:
111
- data = webhook_input.data
112
- url = data["url"]
113
- name = data["name"]
114
-
115
- if "devices" in url:
116
- tags = [x["name"] for x in data["tags"]]
117
-
118
- custom_fields = data["custom_fields"]
119
- device_type = custom_fields["device_type"]
120
-
121
- # NOTE: device without a defined device_type are nodes
122
- if not device_type:
123
- device_type = "node"
124
-
125
- elif "interfaces" in url:
126
- device_type = "interface"
127
-
128
- device_id = data["device"]["id"]
129
- device = utils.nb.dcim.devices.get(id=device_id)
130
- tags = [str(x) for x in device.tags]
131
- custom_fields = device.custom_fields
132
-
133
- if "Managed by OSISM" in tags:
134
- if device_type == "server":
135
- logger.info(
136
- f"Handling change for managed device {name} of type {device_type}"
137
- )
138
- reconciler.run.delay()
139
- elif device_type == "switch":
140
- logger.info(
141
- f"Handling change for managed device {name} of type {device_type}"
142
- )
143
- # netbox.generate.delay(name, custom_fields['configuration_template'])
144
- elif device_type == "interface":
145
- logger.info(
146
- f"Handling change for interface {name} on managed device {device.name} of type {custom_fields['device_type']}"
147
- )
148
- # netbox.generate.delay(device.name, custom_fields['configuration_template'])
149
-
150
- else:
151
- logger.info(f"Ignoring change for unmanaged device {name}")
152
-
153
- return {"result": "ok"}
154
-
155
- else:
156
- return {"result": "webhook netbox not enabled"}
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
+ )
316
+
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