base-deployment-controller 0.1.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.
@@ -0,0 +1,398 @@
1
+ """
2
+ Container management routes implemented with a class and dependency injection.
3
+ """
4
+ import asyncio
5
+ import logging
6
+ from typing import Any, Dict
7
+
8
+ from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
9
+ from python_on_whales.exceptions import DockerException
10
+
11
+ from ..models.container import (
12
+ ContainerInfo,
13
+ ContainersInfoResponse,
14
+ ContainerControlResponse,
15
+ )
16
+ from ..services.config import ConfigService
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ContainerRoutes:
22
+ """
23
+ Docker containers router built with dependency injection.
24
+
25
+ Provides endpoints for retrieving container status, controlling containers,
26
+ and streaming container logs via WebSocket.
27
+
28
+ Args:
29
+ config: Instance of `ConfigService` for Compose and Docker access.
30
+
31
+ Attributes:
32
+ config: Injected configuration service.
33
+ router: Instance of `APIRouter` with `/containers` endpoints.
34
+ """
35
+
36
+ def __init__(self, config: ConfigService) -> None:
37
+ """
38
+ Initialize container routes.
39
+
40
+ Args:
41
+ config: Configuration service instance for dependency injection.
42
+ """
43
+ self.config = config
44
+ self.router = self._build_router()
45
+
46
+ def _build_router(self) -> APIRouter:
47
+ """
48
+ Build and configure the router with container management endpoints.
49
+
50
+ Returns:
51
+ APIRouter configured with GET and POST handlers for /containers.
52
+ """
53
+ router = APIRouter(prefix="/containers", tags=["Containers"])
54
+ # GET /containers - list all containers
55
+ router.add_api_route(
56
+ "",
57
+ self.get_containers,
58
+ methods=["GET"],
59
+ response_model=ContainersInfoResponse,
60
+ )
61
+ # GET /containers/<container_name> - get specific container info
62
+ router.add_api_route(
63
+ "/{container_name}",
64
+ self.get_container,
65
+ methods=["GET"],
66
+ response_model=ContainerInfo,
67
+ )
68
+ # POST /containers/<container_name>/start - start container
69
+ router.add_api_route(
70
+ "/{container_name}/start",
71
+ self.container_start,
72
+ methods=["POST"],
73
+ response_model=ContainerControlResponse,
74
+ )
75
+ # POST /containers/<container_name>/stop - stop container
76
+ router.add_api_route(
77
+ "/{container_name}/stop",
78
+ self.container_stop,
79
+ methods=["POST"],
80
+ response_model=ContainerControlResponse,
81
+ )
82
+ # POST /containers/<container_name>/restart - restart container
83
+ router.add_api_route(
84
+ "/{container_name}/restart",
85
+ self.container_restart,
86
+ methods=["POST"],
87
+ response_model=ContainerControlResponse,
88
+ )
89
+ # WebSocket /containers/<container_name>/logs - stream container logs
90
+ router.websocket("/{container_name}/logs")(self.container_logs)
91
+ return router
92
+
93
+ async def get_containers(self) -> ContainersInfoResponse:
94
+ """
95
+ Get status of all containers defined in compose.yaml.
96
+
97
+ Returns:
98
+ ContainersInfoResponse with list of containers and their current states.
99
+
100
+ Raises:
101
+ HTTPException: If unable to retrieve container information from Docker.
102
+ """
103
+ try:
104
+ logger.debug("Fetching container status from Docker")
105
+ services = self.config.compose_services
106
+ client = self.config.get_docker_client()
107
+ containers = []
108
+ for service_name, service_config in services.items():
109
+ container_name = service_config.get("container_name", service_name)
110
+ ports = service_config.get("expose", [])
111
+ try:
112
+ if not client.container.exists(container_name):
113
+ containers.append(
114
+ ContainerInfo(
115
+ name=service_name,
116
+ image=service_config.get("image", ""),
117
+ status="Container not created",
118
+ started_at=None,
119
+ ports=ports,
120
+ depends_on=self.config.get_service_dependencies(
121
+ service_name
122
+ ),
123
+ )
124
+ )
125
+ continue
126
+ container_inspect = client.container.inspect(container_name)
127
+ status = container_inspect.state.status or "unknown"
128
+ started_at = container_inspect.state.started_at
129
+ except DockerException as e:
130
+ raise HTTPException(
131
+ status_code=500,
132
+ detail=f"Docker error while inspecting {container_name}: {e}",
133
+ )
134
+ containers.append(
135
+ ContainerInfo(
136
+ name=service_name,
137
+ image=service_config.get("image", ""),
138
+ status=status,
139
+ started_at=started_at,
140
+ ports=ports,
141
+ depends_on=self.config.get_service_dependencies(service_name),
142
+ )
143
+ )
144
+ logger.info(f"Successfully retrieved status for {len(containers)} containers")
145
+ return ContainersInfoResponse(containers=containers)
146
+ except Exception as e:
147
+ logger.error(f"Failed to get container status: {e}")
148
+ raise HTTPException(
149
+ status_code=500, detail=f"Failed to get container status: {e}"
150
+ )
151
+
152
+ async def get_container(self, container_name: str) -> ContainerInfo:
153
+ """
154
+ Get information about a specific container.
155
+
156
+ Args:
157
+ container_name: Name of the service/container from compose.yaml.
158
+
159
+ Returns:
160
+ ContainerInfo with container information.
161
+
162
+ Raises:
163
+ HTTPException: If service/container not found.
164
+ """
165
+ try:
166
+ logger.debug(f"Fetching info for container: {container_name}")
167
+ services = self.config.compose_services
168
+ if container_name not in services:
169
+ logger.warning(f"Service not found: {container_name}")
170
+ raise HTTPException(
171
+ status_code=404,
172
+ detail=f"Service '{container_name}' not found in compose.yaml",
173
+ )
174
+ service_config = services[container_name]
175
+ actual_container_name = service_config.get("container_name", container_name)
176
+ client = self.config.get_docker_client()
177
+
178
+ if not client.container.exists(actual_container_name):
179
+ logger.warning(f"Container not found: {actual_container_name}")
180
+ raise HTTPException(
181
+ status_code=404,
182
+ detail=f"Container '{actual_container_name}' not found in Docker",
183
+ )
184
+
185
+ container_inspect = client.container.inspect(actual_container_name)
186
+ logger.info(f"Successfully retrieved info for {container_name}")
187
+ return ContainerInfo(
188
+ name=container_name,
189
+ image=service_config.get("image", ""),
190
+ status=container_inspect.state.status or "unknown",
191
+ started_at=container_inspect.state.started_at,
192
+ ports=service_config.get("expose", []),
193
+ depends_on=self.config.get_service_dependencies(container_name),
194
+ )
195
+ except HTTPException:
196
+ raise
197
+ except Exception as e:
198
+ logger.error(f"Failed to get container info: {e}")
199
+ raise HTTPException(
200
+ status_code=500, detail=f"Failed to get container info: {e}"
201
+ )
202
+
203
+ async def container_start(
204
+ self, container_name: str
205
+ ) -> ContainerControlResponse:
206
+ """
207
+ Start a container.
208
+
209
+ Args:
210
+ container_name: Name of the service/container from compose.yaml.
211
+
212
+ Returns:
213
+ ContainerControlResponse with action result.
214
+
215
+ Raises:
216
+ HTTPException: If service/container not found or action fails.
217
+ """
218
+ return await self._control_container(container_name, "start")
219
+
220
+ async def container_stop(
221
+ self, container_name: str
222
+ ) -> ContainerControlResponse:
223
+ """
224
+ Stop a container.
225
+
226
+ Args:
227
+ container_name: Name of the service/container from compose.yaml.
228
+
229
+ Returns:
230
+ ContainerControlResponse with action result.
231
+
232
+ Raises:
233
+ HTTPException: If service/container not found or action fails.
234
+ """
235
+ return await self._control_container(container_name, "stop")
236
+
237
+ async def container_restart(
238
+ self, container_name: str
239
+ ) -> ContainerControlResponse:
240
+ """
241
+ Restart a container.
242
+
243
+ Args:
244
+ container_name: Name of the service/container from compose.yaml.
245
+
246
+ Returns:
247
+ ContainerControlResponse with action result.
248
+
249
+ Raises:
250
+ HTTPException: If service/container not found or action fails.
251
+ """
252
+ return await self._control_container(container_name, "restart")
253
+
254
+ async def _control_container(
255
+ self, container_name: str, action: str
256
+ ) -> ContainerControlResponse:
257
+ """
258
+ Internal method to control a container (start/stop/restart).
259
+
260
+ Args:
261
+ container_name: Name of the service/container from compose.yaml.
262
+ action: Action to perform (start, stop, restart).
263
+
264
+ Returns:
265
+ ContainerControlResponse with action result.
266
+
267
+ Raises:
268
+ HTTPException: If service/container not found or action fails.
269
+ """
270
+ try:
271
+ logger.debug(f"Control request for container: {container_name}, action: {action}")
272
+ services = self.config.compose_services
273
+ if container_name not in services:
274
+ logger.warning(f"Service not found: {container_name}")
275
+ raise HTTPException(
276
+ status_code=404,
277
+ detail=f"Service '{container_name}' not found in compose.yaml",
278
+ )
279
+ service_config = services[container_name]
280
+ actual_container_name = service_config.get("container_name", container_name)
281
+ client = self.config.get_docker_client()
282
+ if not client.container.exists(actual_container_name):
283
+ logger.warning(f"Container not found: {actual_container_name}")
284
+ raise HTTPException(
285
+ status_code=404,
286
+ detail=f"Container '{actual_container_name}' not found in Docker",
287
+ )
288
+
289
+ logger.info(f"Executing {action} on container: {actual_container_name}")
290
+ try:
291
+ if action == "start":
292
+ client.container.start(actual_container_name)
293
+ elif action == "stop":
294
+ client.container.stop(actual_container_name)
295
+ elif action == "restart":
296
+ client.container.restart(actual_container_name)
297
+ logger.info(f"Container {actual_container_name} {action}ed successfully")
298
+ except DockerException as e:
299
+ logger.error(
300
+ f"Docker error while executing {action} on {actual_container_name}: {e}"
301
+ )
302
+ raise HTTPException(status_code=500, detail=f"Docker error: {e}")
303
+ return ContainerControlResponse(
304
+ success=True,
305
+ container=container_name,
306
+ action=action,
307
+ message=f"Container {action}ed successfully",
308
+ )
309
+ except HTTPException:
310
+ raise
311
+ except Exception as e:
312
+ logger.error(f"Failed to {action} container {container_name}: {e}")
313
+ raise HTTPException(
314
+ status_code=500,
315
+ detail=f"Failed to {action} container: {e}",
316
+ )
317
+
318
+ async def container_logs(self, websocket: WebSocket, container_name: str) -> None:
319
+ """
320
+ Stream container logs in real-time via WebSocket.
321
+
322
+ Args:
323
+ websocket: WebSocket connection.
324
+ container_name: Name of the service/container from compose.yaml.
325
+ """
326
+ logger.info(
327
+ f"WebSocket connection established for logs of container: {container_name}"
328
+ )
329
+ await websocket.accept()
330
+ try:
331
+ services = self.config.compose_services
332
+ if container_name not in services:
333
+ logger.warning(
334
+ f"WebSocket logs requested for non-existent service: {container_name}"
335
+ )
336
+ await websocket.send_json(
337
+ {"error": f"Service '{container_name}' not found in compose.yaml"}
338
+ )
339
+ await websocket.close()
340
+ return
341
+ actual_container_name = self.config.get_container_name_by_service(
342
+ container_name
343
+ )
344
+ client = self.config.get_docker_client()
345
+ if not client.container.exists(actual_container_name):
346
+ logger.warning(
347
+ f"WebSocket logs requested for non-existent container: {actual_container_name}"
348
+ )
349
+ await websocket.send_json(
350
+ {"error": f"Container '{actual_container_name}' not found in Docker"}
351
+ )
352
+ await websocket.close()
353
+ return
354
+
355
+ try:
356
+ # Stream all logs (historical + follow new logs in real-time)
357
+ logger.debug(f"Starting log stream for {actual_container_name}")
358
+
359
+ # Get log generator - this call is fast and non-blocking
360
+ log_generator = client.container.logs(
361
+ actual_container_name,
362
+ follow=True,
363
+ stream=True,
364
+ )
365
+
366
+ for log_line in log_generator:
367
+ # Decode bytes to text
368
+ text_line = (
369
+ log_line.decode("utf-8")
370
+ if isinstance(log_line, (bytes, bytearray))
371
+ else str(log_line)
372
+ )
373
+
374
+ # Send to WebSocket client
375
+ await websocket.send_text(text_line)
376
+
377
+ # Yield control to allow other coroutines to run
378
+ await asyncio.sleep(0)
379
+
380
+ except DockerException as e:
381
+ logger.error(f"Failed to stream logs from {actual_container_name}: {e}")
382
+ await websocket.send_json({"error": f"Failed to stream logs: {e}"})
383
+ except WebSocketDisconnect:
384
+ logger.debug(f"WebSocket client disconnected for {container_name}")
385
+ except Exception as e:
386
+ logger.error(
387
+ f"Unexpected error in WebSocket handler for {container_name}: {e}"
388
+ )
389
+ try:
390
+ await websocket.send_json({"error": f"Failed to stream logs: {e}"})
391
+ except:
392
+ pass
393
+ finally:
394
+ try:
395
+ await websocket.close()
396
+ except:
397
+ pass
398
+ logger.info(f"WebSocket connection closed for {container_name}")
@@ -0,0 +1,281 @@
1
+ """
2
+ Deployment management routes implemented with a class and dependency injection.
3
+ Manages deployment-wide operations: status, up, stop, down, restart, ping.
4
+ """
5
+ import logging
6
+ from typing import Any, Dict
7
+
8
+ from fastapi import APIRouter, HTTPException
9
+
10
+ from ..models.deployment import DeploymentMetadata, DeploymentStatus, DeploymentInfoResponse, DeploymentPingResponse, DeploymentActionResponse
11
+ from ..models.compose import ComposeActionResponse
12
+ from ..models.environment import EnvVariable
13
+ from ..services.config import ConfigService
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class DeploymentRoutes:
19
+ """
20
+ Root deployment router built with dependency injection.
21
+
22
+ Manages deployment-wide operations at the root endpoint and control endpoints:
23
+ - GET / - Get deployment status with metadata and env-vars
24
+ - POST /up - Start deployment
25
+ - POST /stop - Stop deployment
26
+ - POST /down - Down deployment (stop and remove containers)
27
+ - POST /restart - Restart deployment
28
+ - GET /ping - Health check
29
+
30
+ Args:
31
+ config: Instance of `ConfigService` for Compose and Docker access.
32
+
33
+ Attributes:
34
+ config: Injected configuration service.
35
+ router: Instance of `APIRouter` with root endpoints.
36
+ """
37
+
38
+ def __init__(self, config: ConfigService) -> None:
39
+ """
40
+ Initialize deployment routes.
41
+
42
+ Args:
43
+ config: Configuration service instance for dependency injection.
44
+ """
45
+ self.config = config
46
+ self.router = self._build_router()
47
+
48
+ def _build_router(self) -> APIRouter:
49
+ """
50
+ Build and configure the router with deployment endpoints at root level.
51
+
52
+ Returns:
53
+ APIRouter configured with GET and POST handlers.
54
+ """
55
+ router = APIRouter(tags=["Deployment"])
56
+ # Note: These routes will be registered without prefix in main.py
57
+ # to mount them at root level: /, /ping, /up, /stop, /down, /restart
58
+ router.add_api_route(
59
+ "/",
60
+ self.get_deployment_info,
61
+ methods=["GET"],
62
+ )
63
+ router.add_api_route(
64
+ "/ping",
65
+ self.ping,
66
+ methods=["GET"],
67
+ )
68
+ router.add_api_route(
69
+ "/up",
70
+ self.deploy_up,
71
+ methods=["POST"],
72
+ )
73
+ router.add_api_route(
74
+ "/stop",
75
+ self.deploy_stop,
76
+ methods=["POST"],
77
+ )
78
+ router.add_api_route(
79
+ "/down",
80
+ self.deploy_down,
81
+ methods=["POST"],
82
+ )
83
+ router.add_api_route(
84
+ "/restart",
85
+ self.deploy_restart,
86
+ methods=["POST"],
87
+ )
88
+ return router
89
+
90
+ async def get_deployment_info(self) -> DeploymentInfoResponse:
91
+ """
92
+ Get deployment information with status, metadata, and environment variables.
93
+
94
+ Returns:
95
+ DeploymentInfoResponse with status, metadata, and env-vars fields.
96
+
97
+ Raises:
98
+ HTTPException: If unable to retrieve deployment information.
99
+ """
100
+ try:
101
+ logger.debug("Fetching deployment info")
102
+
103
+ # Get metadata
104
+ metadata_dict: DeploymentMetadata = self.config.get_deployment_metadata()
105
+
106
+ # Get status
107
+ status: DeploymentStatus = self.config.get_deployment_status()
108
+
109
+ # Get environment variables
110
+ schema = self.config.get_env_vars_schema()
111
+ current_values = self.config.load_env_values()
112
+ env_vars: dict[str, EnvVariable] = {}
113
+ for var_name, var_schema in schema.items():
114
+ default_val = var_schema.get("default", "")
115
+ current_val = current_values.get(var_name)
116
+ env_vars[var_name] = EnvVariable(
117
+ name=var_name,
118
+ description=var_schema.get("description", ""),
119
+ default=default_val,
120
+ value=current_val if current_val is not None else default_val,
121
+ type=var_schema.get("type", "string"),
122
+ advanced=var_schema.get("advanced", False),
123
+ )
124
+
125
+ logger.info("Successfully retrieved deployment info")
126
+ return DeploymentInfoResponse(
127
+ metadata=metadata_dict,
128
+ status=status,
129
+ env_vars=env_vars)
130
+ except Exception as e:
131
+ logger.error(f"Failed to get deployment info: {e}")
132
+ raise HTTPException(
133
+ status_code=500, detail=f"Failed to get deployment info: {e}"
134
+ )
135
+
136
+ async def ping(self) -> DeploymentPingResponse:
137
+ """
138
+ Health check endpoint.
139
+
140
+ Returns:
141
+ DeploymentPingResponse indicating API is operational.
142
+ """
143
+ logger.debug("Ping request received")
144
+ return DeploymentPingResponse(success=True, message="API is operational")
145
+
146
+
147
+ async def deploy_up(self) -> DeploymentActionResponse:
148
+ """
149
+ Start the deployment (docker compose up).
150
+
151
+ Returns:
152
+ DeploymentActionResponse with success status and message.
153
+
154
+ Raises:
155
+ HTTPException: If deployment startup fails.
156
+ """
157
+ try:
158
+ logger.info("Starting deployment (up)")
159
+ result: ComposeActionResponse = self.config.docker_compose_up()
160
+
161
+ if not result.success:
162
+ logger.error(f"Failed to start deployment: {result.message}")
163
+ raise HTTPException(
164
+ status_code=500, detail=result.message
165
+ )
166
+
167
+ logger.info("Deployment started successfully")
168
+ return DeploymentActionResponse(
169
+ success=result.success,
170
+ action="up",
171
+ message=result.message
172
+ )
173
+ except HTTPException:
174
+ raise
175
+ except Exception as e:
176
+ logger.error(f"Error starting deployment: {e}")
177
+ raise HTTPException(
178
+ status_code=500, detail=f"Failed to start deployment: {str(e)}"
179
+ )
180
+
181
+ async def deploy_stop(self) -> DeploymentActionResponse:
182
+ """
183
+ Stop the deployment (docker compose stop).
184
+
185
+ Returns:
186
+ DeploymentActionResponse with success status and message.
187
+
188
+ Raises:
189
+ HTTPException: If deployment stop fails.
190
+ """
191
+ try:
192
+ logger.info("Stopping deployment")
193
+ result: ComposeActionResponse = self.config.docker_compose_stop()
194
+
195
+ if not result.success:
196
+ logger.error(f"Failed to stop deployment: {result.message}")
197
+ raise HTTPException(
198
+ status_code=500, detail=result.message
199
+ )
200
+
201
+ logger.info("Deployment stopped successfully")
202
+ return DeploymentActionResponse(
203
+ success=result.success,
204
+ action="stop",
205
+ message=result.message
206
+ )
207
+ except HTTPException:
208
+ raise
209
+ except Exception as e:
210
+ logger.error(f"Error stopping deployment: {e}")
211
+ raise HTTPException(
212
+ status_code=500, detail=f"Failed to stop deployment: {str(e)}"
213
+ )
214
+
215
+ async def deploy_down(self) -> DeploymentActionResponse:
216
+ """
217
+ Down the deployment (docker compose down and remove volumes).
218
+
219
+ Returns:
220
+ DeploymentActionResponse with success status and message.
221
+
222
+ Raises:
223
+ HTTPException: If deployment down fails.
224
+ """
225
+ try:
226
+ logger.info("Downing deployment (removing containers and volumes)")
227
+ result: ComposeActionResponse = self.config.docker_compose_down()
228
+
229
+ if not result.success:
230
+ logger.error(f"Failed to down deployment: {result.message}")
231
+ raise HTTPException(
232
+ status_code=500, detail=result.message
233
+ )
234
+
235
+ logger.info("Deployment downed successfully")
236
+ return DeploymentActionResponse(
237
+ success=result.success,
238
+ action="down",
239
+ message=result.message
240
+ )
241
+ except HTTPException:
242
+ raise
243
+ except Exception as e:
244
+ logger.error(f"Error downing deployment: {e}")
245
+ raise HTTPException(
246
+ status_code=500, detail=f"Failed to down deployment: {str(e)}"
247
+ )
248
+
249
+ async def deploy_restart(self) -> DeploymentActionResponse:
250
+ """
251
+ Restart the deployment (docker compose down then up).
252
+
253
+ Returns:
254
+ DeploymentActionResponse with success status and message.
255
+
256
+ Raises:
257
+ HTTPException: If deployment restart fails.
258
+ """
259
+ try:
260
+ logger.info("Restarting deployment")
261
+ result: ComposeActionResponse = self.config.docker_compose_restart()
262
+
263
+ if not result.success:
264
+ logger.error(f"Failed to stop deployment (while restarting): {result.message}")
265
+ raise HTTPException(
266
+ status_code=500, detail=result.message
267
+ )
268
+
269
+ logger.info("Deployment restarted successfully")
270
+ return DeploymentActionResponse(
271
+ success=result.success,
272
+ action="restart",
273
+ message=result.message
274
+ )
275
+ except HTTPException:
276
+ raise
277
+ except Exception as e:
278
+ logger.error(f"Error restarting deployment: {e}")
279
+ raise HTTPException(
280
+ status_code=500, detail=f"Failed to restart deployment: {str(e)}"
281
+ )