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,560 @@
1
+ """
2
+ Configuration service module.
3
+ Manages reading/writing .env and compose.yaml, validation and Docker client.
4
+ """
5
+ from typing import Dict, Any, List, Optional
6
+ from pathlib import Path
7
+ import re
8
+ import logging
9
+ import time
10
+
11
+ import yaml
12
+ from dotenv import dotenv_values, set_key
13
+ from python_on_whales import DockerClient
14
+
15
+ from ..models.deployment import DeploymentStatus, DeploymentMetadata
16
+ from ..models.compose import ComposeActionResponse
17
+
18
+ logger = logging.getLogger(__name__)
19
+
20
+
21
+ class ConfigService:
22
+ """
23
+ Configuration and Docker access service.
24
+
25
+ Centralizes reading/writing of `.env` and `compose.yaml`, validation
26
+ of variables according to `x-env-vars`, and Docker client access.
27
+
28
+ Args:
29
+ compose_file: Path to Docker Compose file (default "compose.yaml").
30
+ env_file: Path to environment variables file (default ".env").
31
+
32
+ Attributes:
33
+ compose_path: Path to compose.yaml file as `Path`.
34
+ env_path: Path to .env file as `Path`.
35
+ compose_schema: Parsed compose.yaml content as dictionary.
36
+ compose_services: Services section from compose.yaml.
37
+ env_to_services_map: Mapping of environment variables to services that use them.
38
+ """
39
+
40
+ def __init__(self, compose_file: str = "compose.yaml", env_file: str = ".env") -> None:
41
+ """
42
+ Initialize the configuration service.
43
+
44
+ Args:
45
+ compose_file: Path to Docker Compose file.
46
+ env_file: Path to environment variables file.
47
+
48
+ Raises:
49
+ FileNotFoundError: If compose file doesn't exist.
50
+ """
51
+ self.compose_path = Path(compose_file)
52
+ self.env_path = Path(env_file)
53
+ self.compose_schema: Dict[str, Any] = self._load_compose_schema()
54
+ self.compose_services: Dict[str, Any] = self._load_compose_services()
55
+ self.env_to_services_map: Dict[str, List[str]] = self._build_env_to_services_map()
56
+
57
+ def _load_compose_schema(self) -> Dict[str, Any]:
58
+ """
59
+ Load and parse the compose.yaml file.
60
+
61
+ Returns:
62
+ Dict containing the parsed compose.yaml content.
63
+
64
+ Raises:
65
+ FileNotFoundError: If compose.yaml doesn't exist.
66
+ yaml.YAMLError: If YAML parsing fails.
67
+ """
68
+ if not self.compose_path.exists():
69
+ raise FileNotFoundError(f"Compose file not found: {self.compose_path}")
70
+ with open(self.compose_path, "r") as f:
71
+ return yaml.safe_load(f)
72
+
73
+ def _load_compose_services(self) -> Dict[str, Any]:
74
+ """
75
+ Load services section from compose.yaml.
76
+
77
+ Returns:
78
+ Dictionary containing service definitions from compose.yaml.
79
+ """
80
+ try:
81
+ compose = self.compose_schema
82
+ return compose.get("services", {})
83
+ except Exception as e:
84
+ logger.warning(f"Failed to load services from compose.yaml: {e}")
85
+ return {}
86
+
87
+ # File Operations
88
+ def _build_env_to_services_map(self) -> Dict[str, List[str]]:
89
+ """
90
+ Build a mapping of environment variables to services that use them.
91
+
92
+ Parses the compose.yaml to identify which services reference each environment
93
+ variable in their environment section or network configuration.
94
+
95
+ Returns:
96
+ Dict mapping environment variable names to list of service names that use them.
97
+ """
98
+ env_to_services: Dict[str, List[str]] = {}
99
+
100
+ try:
101
+ services = self.compose_services
102
+
103
+ # Pattern to match ${VAR_NAME} in strings
104
+ env_var_pattern = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
105
+
106
+ for service_name, service_config in services.items():
107
+ # Check environment variables section
108
+ environment = service_config.get("environment", [])
109
+ if isinstance(environment, dict):
110
+ # Dict format: key: value
111
+ for key, value in environment.items():
112
+ if isinstance(value, str):
113
+ matches = env_var_pattern.findall(value)
114
+ for var_name in matches:
115
+ if var_name not in env_to_services:
116
+ env_to_services[var_name] = []
117
+ if service_name not in env_to_services[var_name]:
118
+ env_to_services[var_name].append(service_name)
119
+ elif isinstance(environment, list):
120
+ # List format: ["KEY=value", ...]
121
+ for entry in environment:
122
+ if isinstance(entry, str):
123
+ matches = env_var_pattern.findall(entry)
124
+ for var_name in matches:
125
+ if var_name not in env_to_services:
126
+ env_to_services[var_name] = []
127
+ if service_name not in env_to_services[var_name]:
128
+ env_to_services[var_name].append(service_name)
129
+
130
+ # Check network configuration (IPv4 addresses often use env vars)
131
+ networks = service_config.get("networks", {})
132
+ if isinstance(networks, dict):
133
+ for network_name, network_config in networks.items():
134
+ if isinstance(network_config, dict):
135
+ ipv4_addr = network_config.get("ipv4_address", "")
136
+ if isinstance(ipv4_addr, str):
137
+ matches = env_var_pattern.findall(ipv4_addr)
138
+ for var_name in matches:
139
+ if var_name not in env_to_services:
140
+ env_to_services[var_name] = []
141
+ if service_name not in env_to_services[var_name]:
142
+ env_to_services[var_name].append(service_name)
143
+
144
+ # Check ports configuration
145
+ ports = service_config.get("ports", [])
146
+ for port_mapping in ports:
147
+ if isinstance(port_mapping, str):
148
+ matches = env_var_pattern.findall(port_mapping)
149
+ for var_name in matches:
150
+ if var_name not in env_to_services:
151
+ env_to_services[var_name] = []
152
+ if service_name not in env_to_services[var_name]:
153
+ env_to_services[var_name].append(service_name)
154
+
155
+ except Exception as e:
156
+ # If we can't build the map, log the error but don't fail initialization
157
+ logger.warning(f"Failed to build env-to-services map: {e}")
158
+ return {}
159
+
160
+ return env_to_services
161
+
162
+ def get_env_vars_schema(self) -> Dict[str, Dict[str, Any]]:
163
+ """
164
+ Extract the x-env-vars schema from compose.yaml.
165
+
166
+ Returns:
167
+ Dict mapping variable names to their metadata.
168
+ """
169
+ compose = self.compose_schema
170
+ return compose.get("x-env-vars", {})
171
+
172
+ def load_env_values(self) -> Dict[str, Optional[str]]:
173
+ """
174
+ Load current values from .env file.
175
+
176
+ Returns:
177
+ Dict mapping variable names to their current values.
178
+ """
179
+ if not self.env_path.exists():
180
+ return {}
181
+ return dict(dotenv_values(self.env_path))
182
+
183
+ def update_env_file(self, updates: Dict[str, str]) -> None:
184
+ """
185
+ Update variables in .env file atomically.
186
+ Only modifies specified variables, preserves others and comments.
187
+
188
+ Args:
189
+ updates: Dict mapping variable names to new values.
190
+
191
+ Raises:
192
+ IOError: If file operations fail.
193
+ """
194
+ if not self.env_path.exists():
195
+ self.env_path.touch()
196
+ for key, value in updates.items():
197
+ set_key(self.env_path, key, value)
198
+
199
+ # Validation
200
+ def parse_type_constraint(self, type_str: str) -> Dict[str, Any]:
201
+ """
202
+ Parse the type constraint string from x-env-vars.
203
+
204
+ Format examples:
205
+ - "string:0;^\\d{3}$" -> string with regex pattern (0 = show in UI, 1 = do not show)
206
+ - "integer:0;2048" -> integer with min/max bounds
207
+ - "enum:tun,tap" -> enum with allowed values
208
+
209
+ Args:
210
+ type_str: Type constraint string.
211
+
212
+ Returns:
213
+ Dict with parsed constraint information.
214
+ """
215
+ parts = type_str.split(":", 1)
216
+ type_name = parts[0]
217
+ result: Dict[str, Any] = {"type": type_name}
218
+ if len(parts) > 1:
219
+ constraint = parts[1]
220
+ if type_name == "string":
221
+ cp = constraint.split(";", 1)
222
+ if len(cp) == 2:
223
+ result["hide"] = bool(int(cp[0]))
224
+ result["pattern"] = cp[1]
225
+ elif type_name == "integer":
226
+ cp = constraint.split(";")
227
+ if len(cp) == 2:
228
+ result["min"] = int(cp[0])
229
+ result["max"] = int(cp[1])
230
+ elif type_name == "enum":
231
+ result["values"] = constraint.split(",")
232
+ return result
233
+
234
+ def validate_variable_value(self, name: str, value: str, type_str: str) -> None:
235
+ """
236
+ Validate a variable value against its type constraint.
237
+
238
+ Args:
239
+ name: Variable name.
240
+ value: Value to validate.
241
+ type_str: Type constraint string from x-env-vars.
242
+
243
+ Raises:
244
+ ValueError: If validation fails.
245
+ """
246
+ constraint = self.parse_type_constraint(type_str)
247
+ if constraint["type"] == "string":
248
+ pattern = constraint.get("pattern")
249
+ if pattern and not re.match(pattern, value):
250
+ raise ValueError(
251
+ f"Variable '{name}' value '{value}' doesn't match pattern {pattern}"
252
+ )
253
+ elif constraint["type"] == "integer":
254
+ try:
255
+ int_value = int(value)
256
+ except ValueError:
257
+ raise ValueError(f"Variable '{name}' must be an integer, got '{value}'")
258
+ if "min" in constraint and int_value < constraint["min"]:
259
+ raise ValueError(
260
+ f"Variable '{name}' value {int_value} is below minimum {constraint['min']}"
261
+ )
262
+ if "max" in constraint and int_value > constraint["max"]:
263
+ raise ValueError(
264
+ f"Variable '{name}' value {int_value} exceeds maximum {constraint['max']}"
265
+ )
266
+ elif constraint["type"] == "enum":
267
+ values = constraint.get("values", [])
268
+ if values and value not in values:
269
+ raise ValueError(
270
+ f"Variable '{name}' value '{value}' not in allowed values: {values}"
271
+ )
272
+
273
+ def get_service_dependencies(self, service_name: str) -> List[str]:
274
+ """
275
+ Extract service dependencies from compose.yaml.
276
+
277
+ Args:
278
+ service_name: Name of the service.
279
+
280
+ Returns:
281
+ List of service names this service depends on.
282
+ """
283
+ service = self.compose_services.get(service_name, {})
284
+ depends_on = service.get("depends_on", [])
285
+ if isinstance(depends_on, dict):
286
+ return list(depends_on.keys())
287
+ return depends_on
288
+
289
+ def get_container_name_by_service(self, service_name: str) -> str:
290
+ """
291
+ Get the container name for a given service from compose.yaml.
292
+
293
+ Args:
294
+ service_name: Name of the service.
295
+
296
+ Returns:
297
+ Container name if specified, else service name.
298
+ """
299
+ service = self.compose_services.get(service_name, {})
300
+ return service.get("container_name", "")
301
+
302
+ # Docker
303
+ def get_docker_client(self) -> DockerClient:
304
+ """
305
+ Get Docker client instance.
306
+
307
+ Returns:
308
+ Docker client connected to local daemon.
309
+ """
310
+ docker = DockerClient(compose_files=[str(self.compose_path)])
311
+ return docker
312
+
313
+ def get_affected_services(self, changed_vars: List[str]) -> List[str]:
314
+ """
315
+ Get list of services affected by changes to specific environment variables.
316
+
317
+ Args:
318
+ changed_vars: List of environment variable names that changed.
319
+
320
+ Returns:
321
+ List of unique service names that use any of the changed variables.
322
+ """
323
+ affected_services = set()
324
+ for var_name in changed_vars:
325
+ services = self.env_to_services_map.get(var_name, [])
326
+ affected_services.update(services)
327
+ return list(affected_services)
328
+
329
+ def restart_services(self, service_names: List[str]) -> Dict[str, bool]:
330
+ """
331
+ Restart specified Docker services/containers.
332
+
333
+ Args:
334
+ service_names: List of service names from compose.yaml to restart.
335
+
336
+ Returns:
337
+ Dict mapping service names to restart success status (True/False).
338
+ """
339
+ results: Dict[str, bool] = {}
340
+
341
+ if not service_names:
342
+ return results
343
+
344
+ try:
345
+ client = self.get_docker_client()
346
+
347
+ for service_name in service_names:
348
+ # Get container name from service
349
+ container_name = self.get_container_name_by_service(service_name)
350
+ if not container_name:
351
+ results[service_name] = False
352
+ continue
353
+
354
+ try:
355
+ if client.container.exists(container_name):
356
+ # Only restart if container exists
357
+ container_inspect = client.container.inspect(container_name)
358
+ if container_inspect.state.status == "running":
359
+ client.container.restart(container_name)
360
+ results[service_name] = True
361
+ else:
362
+ # Container exists but not running, don't restart
363
+ results[service_name] = False
364
+ else:
365
+ # Container doesn't exist, can't restart
366
+ results[service_name] = False
367
+ except Exception as e:
368
+ logger.error(f"Error restarting service {service_name}: {e}")
369
+ results[service_name] = False
370
+
371
+ except Exception as e:
372
+ logger.error(f"Error in restart_services: {e}")
373
+
374
+ return results
375
+
376
+ # Docker Compose Operations
377
+ def get_deployment_metadata(self) -> DeploymentMetadata:
378
+ """
379
+ Extract metadata from x-metadata section in compose.yaml.
380
+
381
+ Returns:
382
+ Dict with deployment metadata (id, name, description, version, author, changelog, documentation_url).
383
+ """
384
+ compose = self.compose_schema
385
+ metadata = compose.get("x-metadata", {})
386
+ return DeploymentMetadata(
387
+ id=metadata.get("id", "unknown"),
388
+ name=metadata.get("name", "Unknown"),
389
+ description=metadata.get("description", ""),
390
+ version=metadata.get("version", "1.0"),
391
+ author=metadata.get("author", ""),
392
+ changelog=metadata.get("changelog", ""),
393
+ documentation_url=metadata.get("documentation_url", ""),
394
+ )
395
+
396
+ def get_deployment_status(self) -> DeploymentStatus:
397
+ """
398
+ Get current deployment status by checking if services are running.
399
+
400
+ Analyzes Docker containers and determines overall deployment state:
401
+ - "running": All or most critical services are running
402
+ - "partially_running": Some services are running but others are stopped
403
+ - "stopped": All services are stopped
404
+ - "unknown": Unable to determine overall state
405
+
406
+ Returns:
407
+ Dict with current_state, desired_state, transitioning, and last_state_change.
408
+ """
409
+ services = self.compose_services
410
+ if not services:
411
+ return DeploymentStatus.UNKNOWN
412
+
413
+ client = self.get_docker_client()
414
+ running = 0
415
+ total = 0
416
+
417
+ for service_name, service_config in services.items():
418
+ container_name = service_config.get("container_name", service_name)
419
+ total += 1
420
+ if client.container.exists(container_name):
421
+ container_inspect = client.container.inspect(container_name)
422
+ if container_inspect.state.status == "running":
423
+ running += 1
424
+
425
+ if running == 0:
426
+ return DeploymentStatus.STOPPED
427
+ elif running == total:
428
+ return DeploymentStatus.RUNNING
429
+ else:
430
+ return DeploymentStatus.PARTIALLY_RUNNING
431
+
432
+ def docker_compose_up(self) -> ComposeActionResponse:
433
+ """
434
+ Execute docker compose up and return the result.
435
+
436
+ Returns:
437
+ ComposeActionResponse with success status and message.
438
+ """
439
+ try:
440
+ client = self.get_docker_client()
441
+ client.compose.up(detach=True)
442
+ return ComposeActionResponse(
443
+ success=True,
444
+ message="Deployment started successfully",
445
+ )
446
+ except Exception as e:
447
+ logger.error(f"Failed to start deployment: {e}")
448
+ return ComposeActionResponse(
449
+ success=False,
450
+ message=str(e),
451
+ )
452
+
453
+ def docker_compose_stop(self) -> ComposeActionResponse:
454
+ """
455
+ Execute docker compose stop and return the result.
456
+
457
+ Returns:
458
+ ComposeActionResponse with success status and message.
459
+ """
460
+ try:
461
+ client = self.get_docker_client()
462
+ client.compose.stop()
463
+ return ComposeActionResponse(
464
+ success=True,
465
+ message="Deployment stopped successfully",
466
+ )
467
+ except Exception as e:
468
+ logger.error(f"Failed to stop deployment: {e}")
469
+ return ComposeActionResponse(
470
+ success=False,
471
+ message=str(e),
472
+ )
473
+
474
+ def docker_compose_down(self) -> ComposeActionResponse:
475
+ """
476
+ Execute docker compose down and return the result.
477
+
478
+ Returns:
479
+ ComposeActionResponse with success status and message
480
+ """
481
+ try:
482
+ client = self.get_docker_client()
483
+ client.compose.down(volumes=True)
484
+ return ComposeActionResponse(
485
+ success=True,
486
+ message="Deployment downed successfully"
487
+ )
488
+ except Exception as e:
489
+ logger.error(f"Failed to down deployment: {e}")
490
+ return ComposeActionResponse(
491
+ success=False,
492
+ message=str(e)
493
+ )
494
+
495
+ def docker_compose_restart(self) -> ComposeActionResponse:
496
+ """
497
+ Execute docker compose stop then up and return the result.
498
+
499
+ Returns:
500
+ ComposeActionResponse with success status and message.
501
+ """
502
+ try:
503
+ down_result = self.docker_compose_stop()
504
+ if not down_result.success:
505
+ return ComposeActionResponse(
506
+ success=False,
507
+ message=down_result.message,
508
+ )
509
+ up_result = self.docker_compose_up()
510
+ return ComposeActionResponse(
511
+ success=up_result.success,
512
+ message=up_result.message,
513
+ )
514
+ except Exception as e:
515
+ logger.error(f"Failed to restart deployment: {e}")
516
+ return ComposeActionResponse(
517
+ success=False,
518
+ message=str(e),
519
+ )
520
+
521
+ def wait_for_containers(self, service_names: List[str], timeout: int = 60) -> Dict[str, bool]:
522
+ """
523
+ Wait for specified containers to become running.
524
+
525
+ Args:
526
+ service_names: List of service names to wait for.
527
+ timeout: Timeout in seconds.
528
+
529
+ Returns:
530
+ Dict mapping service names to True if running, False otherwise.
531
+ """
532
+ start_time = time.time()
533
+ results: Dict[str, bool] = {}
534
+
535
+ while time.time() - start_time < timeout:
536
+ all_running = True
537
+ for service_name in service_names:
538
+ container_name = self.get_container_name_by_service(service_name)
539
+ if not container_name:
540
+ results[service_name] = False
541
+ all_running = False
542
+ continue
543
+ client = self.get_docker_client()
544
+ if not client.container.exists(container_name):
545
+ results[service_name] = False
546
+ all_running = False
547
+ continue
548
+ container_inspect = client.container.inspect(container_name)
549
+ if container_inspect.state.status != "running":
550
+ results[service_name] = False
551
+ all_running = False
552
+ continue
553
+ results[service_name] = True
554
+
555
+ if all_running:
556
+ return results
557
+
558
+ time.sleep(2)
559
+
560
+ return results