odctl 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.
Files changed (57) hide show
  1. odctl/__init__.py +0 -0
  2. odctl/config.py +65 -0
  3. odctl/docker.py +295 -0
  4. odctl/main.py +520 -0
  5. odctl/planner.py +133 -0
  6. odctl/registry.py +66 -0
  7. odctl/resources/clickhouse/config.xml +52 -0
  8. odctl/resources/clickhouse/init.sql +2 -0
  9. odctl/resources/clickhouse/keeper_config.xml +26 -0
  10. odctl/resources/clickhouse/macros-11.xml +1 -0
  11. odctl/resources/clickhouse/macros-12.xml +1 -0
  12. odctl/resources/clickhouse/macros-21.xml +1 -0
  13. odctl/resources/clickhouse/macros-22.xml +1 -0
  14. odctl/resources/clickhouse/users.xml +56 -0
  15. odctl/resources/compose-analytics.yml +243 -0
  16. odctl/resources/compose-deps.yml +24 -0
  17. odctl/resources/compose-flink.yml +102 -0
  18. odctl/resources/compose-infra.yml +144 -0
  19. odctl/resources/compose-kafka.yml +206 -0
  20. odctl/resources/compose-metadata.yml +160 -0
  21. odctl/resources/compose-mlops.yml +74 -0
  22. odctl/resources/compose-obsv.yml +115 -0
  23. odctl/resources/compose-orch.yml +126 -0
  24. odctl/resources/compose-spark.yml +95 -0
  25. odctl/resources/compose-store.yml +103 -0
  26. odctl/resources/deps/Dockerfile +72 -0
  27. odctl/resources/deps/build.gradle +51 -0
  28. odctl/resources/deps/build_connectors.sh +71 -0
  29. odctl/resources/deps/fetch_flink_dependencies.sh +47 -0
  30. odctl/resources/deps/fetch_spark_dependencies.sh +40 -0
  31. odctl/resources/deps/fetch_standalone_plugins.sh +63 -0
  32. odctl/resources/docker/airflow/Dockerfile +23 -0
  33. odctl/resources/docker/ray-mlops/Dockerfile +62 -0
  34. odctl/resources/docker/spark/Dockerfile +16 -0
  35. odctl/resources/flink/flink1-conf.yml +58 -0
  36. odctl/resources/grafana/prometheus.yml +8 -0
  37. odctl/resources/marquez/marquez.yml +20 -0
  38. odctl/resources/mlops/serving/lightgbm_inference.py.example +42 -0
  39. odctl/resources/mlops/serving/vector_search.py.example +44 -0
  40. odctl/resources/postgres/01-init-databases.sh +47 -0
  41. odctl/resources/prometheus/alertmanager.yml +6 -0
  42. odctl/resources/prometheus/prometheus.yml +32 -0
  43. odctl/resources/registry.yml +289 -0
  44. odctl/resources/seaweed/s3.json +15 -0
  45. odctl/resources/spark/log4j2.properties +13 -0
  46. odctl/resources/spark/spark-defaults.conf +25 -0
  47. odctl/resources/trino/catalog/clickhouse.properties +4 -0
  48. odctl/resources/trino/catalog/iceberg.properties +9 -0
  49. odctl/resources/trino/catalog/postgres.properties +4 -0
  50. odctl/resources/trino/catalog/valkey.properties +6 -0
  51. odctl/ui.py +373 -0
  52. odctl/workspace.py +74 -0
  53. odctl-0.1.0.dist-info/METADATA +211 -0
  54. odctl-0.1.0.dist-info/RECORD +57 -0
  55. odctl-0.1.0.dist-info/WHEEL +4 -0
  56. odctl-0.1.0.dist-info/entry_points.txt +2 -0
  57. odctl-0.1.0.dist-info/licenses/LICENSE +201 -0
odctl/main.py ADDED
@@ -0,0 +1,520 @@
1
+ from pathlib import Path
2
+ from typing import List, Optional
3
+
4
+ import typer
5
+
6
+ from odctl import ui
7
+ from odctl.docker import (
8
+ get_managed_containers,
9
+ get_managed_logs,
10
+ get_stack_details,
11
+ is_docker_running,
12
+ launch_stack,
13
+ pull_stack_images,
14
+ restart_managed_containers,
15
+ stop_stack,
16
+ )
17
+ from odctl.planner import build_execution_plan, get_profile_map
18
+ from odctl.registry import load_registry
19
+ from odctl.workspace import get_workspace_dir, init_workspace
20
+
21
+ app = typer.Typer(
22
+ name="odctl",
23
+ help="""
24
+ [bold cyan]ODCTL CLI[/bold cyan] - Orchestrator for the Open Data Stack.
25
+
26
+ Manage your local data engineering and MLOps infrastructure effortlessly.
27
+ Provides commands to inspect, provision, and tear down curated Docker Compose stacks.
28
+ """,
29
+ no_args_is_help=True,
30
+ rich_markup_mode="rich",
31
+ epilog="""
32
+ [bold underline]Examples:[/bold underline]\n
33
+ [dim]# View all profiles and exposed ports[/dim]\n
34
+ $ [bold cyan]odctl list -d[/bold cyan]\n\n
35
+ [dim]# See exactly what the airflow profile provisions[/dim]\n
36
+ $ [bold cyan]odctl explain kafka-lite[/bold cyan]\n\n
37
+ [dim]# Launch specific profiles and their dependencies[/dim]\n
38
+ $ [bold cyan]odctl up flink1-lite kafka-lite spark-lite[/bold cyan]\n\n
39
+ [dim]# Complete teardown and wipe all data[/dim]\n
40
+ $ [bold cyan]odctl down --all --volumes[/bold cyan]
41
+ """,
42
+ )
43
+
44
+
45
+ @app.callback(invoke_without_command=True)
46
+ def main(
47
+ ctx: typer.Context,
48
+ verbose: bool = typer.Option(
49
+ False,
50
+ "--verbose",
51
+ help="Enable debug-level logging across all commands.",
52
+ rich_help_panel="Global Options",
53
+ ),
54
+ workspace: Path = typer.Option(
55
+ "./.odctl",
56
+ "--workspace",
57
+ "-w",
58
+ help="Path to the ODCTL workspace directory.",
59
+ rich_help_panel="Global Options",
60
+ ),
61
+ ):
62
+ ctx.obj = {"verbose": verbose, "workspace": workspace}
63
+
64
+
65
+ @app.command(
66
+ name="list",
67
+ rich_help_panel="Inspection & Info",
68
+ epilog="""
69
+ [bold underline]Examples:[/bold underline]\n
70
+ [dim]# View all basic profiles[/dim]\n
71
+ $ [bold cyan]odctl list[/bold cyan]\n\n
72
+ [dim]# View profiles, underlying services, and ports[/dim]\n
73
+ $ [bold cyan]odctl list -d[/bold cyan]
74
+ """,
75
+ )
76
+ @app.command(name="ls", hidden=True)
77
+ def list_profiles(
78
+ details: bool = typer.Option(
79
+ False,
80
+ "--details",
81
+ "-d",
82
+ help="Inspect docker-compose files to show exact services and exposed host ports.",
83
+ ),
84
+ ):
85
+ """
86
+ List all available profiles and their capabilities.
87
+
88
+ A [bold]profile[/bold] is a specific capability or technology (e.g., `kafka-lite`, `spark-lite`, `airflow`)
89
+ provided by the Open Data Stack. This command lists them alongside their parent stack.
90
+ """
91
+ try:
92
+ registry = load_registry()
93
+ ui.print_profile_table(registry, details, get_stack_details)
94
+ except Exception as e:
95
+ ui.print_error(str(e))
96
+ raise typer.Exit(1)
97
+
98
+
99
+ @app.command(
100
+ rich_help_panel="Inspection & Info",
101
+ epilog="""
102
+ [bold underline]Examples:[/bold underline]\n
103
+ [dim]# See what the Spark profile provisions[/dim]\n
104
+ $ [bold cyan]odctl explain spark-lite[/bold cyan]
105
+ """,
106
+ )
107
+ def explain(
108
+ profile: str = typer.Argument(
109
+ ..., help="The target profile to inspect (e.g., 'airflow', 'kafka-lite')."
110
+ ),
111
+ ):
112
+ """
113
+ Explain the details, services, images, and dependencies of a profile.
114
+
115
+ Displays a detailed breakdown of what a profile provisions, its container images,
116
+ exposed host ports, and any prerequisite profiles it depends on.
117
+ """
118
+ registry = load_registry()
119
+ profile_map = get_profile_map()
120
+
121
+ if profile not in profile_map:
122
+ ui.print_error(f"Profile '{profile}' not found in registry.")
123
+ raise typer.Exit(1)
124
+
125
+ stack_id = profile_map[profile]["stack_id"]
126
+ stack = registry.stacks[stack_id]
127
+
128
+ # Extract all 4 values returned by the updated get_stack_details
129
+ services, ports, images, volumes = get_stack_details(stack.file, [profile])
130
+
131
+ usage_text = stack.usage
132
+ if isinstance(usage_text, dict):
133
+ usage_text = usage_text.get(
134
+ profile, f"• No specific usage guide for profile: {profile}"
135
+ )
136
+
137
+ if isinstance(stack.depends_on, dict):
138
+ resolved_deps = stack.depends_on.get(profile, [])
139
+ else:
140
+ resolved_deps = stack.depends_on
141
+
142
+ ui.print_explain_panel(
143
+ profile,
144
+ stack.parent or stack_id,
145
+ stack.file,
146
+ stack.description,
147
+ resolved_deps or ["None"],
148
+ services,
149
+ ports,
150
+ images,
151
+ volumes,
152
+ stack.role,
153
+ usage_text,
154
+ )
155
+
156
+
157
+ @app.command(
158
+ rich_help_panel="Workspace",
159
+ epilog="""
160
+ [bold underline]Examples:[/bold underline]\n
161
+ [dim]# Initialize a new workspace in ./.odctl/[/dim]\n
162
+ $ [bold cyan]odctl init[/bold cyan]\n\n
163
+ [dim]# Recreate workspace, overwriting any local changes[/dim]\n
164
+ $ [bold cyan]odctl init --force[/bold cyan]
165
+ """,
166
+ )
167
+ def init(
168
+ force: bool = typer.Option(
169
+ False,
170
+ "--force",
171
+ "-f",
172
+ help="Wipe out the existing workspace and recreate it from scratch.",
173
+ ),
174
+ ):
175
+ """
176
+ Initialize a local .odctl workspace for custom configurations.
177
+
178
+ Copies the bundled docker-compose files, configs, and `.env` template into a
179
+ local `./.odctl/` directory. You can then edit these files directly to customize the stack.
180
+ """
181
+ if force and get_workspace_dir().exists():
182
+ typer.confirm(
183
+ "⚠️ This will wipe out local modifications. Are you sure?", abort=True
184
+ )
185
+ init_workspace(force=force)
186
+ ui.print_success("Workspace initialized! You can now edit any file in ./.odctl/")
187
+
188
+
189
+ @app.command(
190
+ rich_help_panel="Cluster Lifecycle",
191
+ epilog="""
192
+ [bold underline]Examples:[/bold underline]\n
193
+ [dim]# Pre-fetch images for all profiles[/dim]\n
194
+ $ [bold cyan]odctl pull --all[/bold cyan]\n\n
195
+ [dim]# Pre-fetch images just for Flink 1.x and Spark[/dim]\n
196
+ $ [bold cyan]odctl pull flink1-lite kafka-lite[/bold cyan]
197
+ """,
198
+ )
199
+ def pull(
200
+ profiles: Optional[List[str]] = typer.Argument(
201
+ None, help="Specific profiles to pull images for."
202
+ ),
203
+ all: bool = typer.Option(
204
+ False,
205
+ "--all",
206
+ "-a",
207
+ help="Pull images for ALL available profiles in the registry.",
208
+ ),
209
+ ):
210
+ """
211
+ Pre-fetch Docker images without starting the containers.
212
+
213
+ Useful for downloading heavy images (like Spark, Flink, Kafka) ahead of time
214
+ or ensuring you have the latest versions before launching.
215
+ """
216
+ if not is_docker_running():
217
+ ui.print_error("Docker is not reachable.")
218
+ raise typer.Exit(1)
219
+
220
+ execution_plan = build_execution_plan(profiles, all, resolve_deps=True)
221
+ ui.print_info("📥 Pre-fetching Docker images...")
222
+
223
+ for file, profs in execution_plan.items():
224
+ ui.print_info(f"Fetching images for [yellow]{file}[/yellow]...", style="white")
225
+ try:
226
+ pull_stack_images(file, profs)
227
+ except Exception as e:
228
+ ui.print_error(f"Failed to pull images for {file}", details=str(e))
229
+ raise typer.Exit(1)
230
+ ui.print_success("All images pulled successfully!")
231
+
232
+
233
+ @app.command(
234
+ rich_help_panel="Cluster Lifecycle",
235
+ epilog="""
236
+ [bold underline]Examples:[/bold underline]\n
237
+ [dim]# Launch Clickhouse, Flink 1.x, and their dependencies[/dim]\n
238
+ $ [bold cyan]odctl up ch-lite flink1-lite[/bold cyan]\n\n
239
+ [dim]# Preview what would be launched for Airflow[/dim]\n
240
+ $ [bold cyan]odctl up airflow --dry-run[/bold cyan]\n\n
241
+ [dim]# Force pull latest images before launching[/dim]\n
242
+ $ [bold cyan]odctl up kafka-lite --pull[/bold cyan]
243
+ """,
244
+ )
245
+ def up(
246
+ profiles: List[str] = typer.Argument(
247
+ ..., help="One or more profiles to launch (e.g., 'ch-lite', 'kafka-lite')."
248
+ ),
249
+ dry_run: bool = typer.Option(
250
+ False,
251
+ "--dry-run",
252
+ help="Preview the execution plan without actually starting anything.",
253
+ ),
254
+ pull: bool = typer.Option(
255
+ False,
256
+ "--pull",
257
+ help="Force pull the latest images from the registry before starting.",
258
+ ),
259
+ ):
260
+ """
261
+ Launch Open Data profiles.
262
+
263
+ Resolves dependencies for the requested profiles and brings up the required
264
+ Docker Compose stacks in the correct topological order.
265
+ """
266
+ if not dry_run and not is_docker_running():
267
+ ui.print_error("Docker is not reachable.")
268
+ raise typer.Exit(1)
269
+
270
+ plan = build_execution_plan(profiles, resolve_deps=True)
271
+ if dry_run:
272
+ ui.print_dry_run(plan)
273
+ return
274
+
275
+ for file, profs in plan.items():
276
+ is_base = any(x in profs for x in ["deps", "postgres", "storage", "catalog"])
277
+ prefix = "🧱 Infrastructure" if is_base else "🚀 Target"
278
+
279
+ if pull:
280
+ ui.print_info(
281
+ f"📥 Pulling images for {prefix} ([cyan]{', '.join(profs)}[/cyan])..."
282
+ )
283
+ pull_stack_images(file, profs)
284
+
285
+ ui.print_info(f"⚙️ Launching {prefix} ([cyan]{', '.join(profs)}[/cyan])...")
286
+ try:
287
+ launch_stack(file, profs)
288
+ except Exception as e:
289
+ ui.print_error(f"Failed to start {file}", details=str(e), show_tip=True)
290
+ raise typer.Exit(1)
291
+
292
+ ui.print_success("All requested profiles successfully started!")
293
+
294
+
295
+ @app.command(
296
+ rich_help_panel="Cluster Lifecycle",
297
+ epilog="""
298
+ [bold underline]Examples:[/bold underline]\n
299
+ [dim]# Stop specific profile(s)[/dim]\n
300
+ $ [bold cyan]odctl down kafka-lite[/bold cyan]\n\n
301
+ [dim]# Stop all running profiles[/dim]\n
302
+ $ [bold cyan]odctl down --all[/bold cyan]\n\n
303
+ [dim]# Complete teardown and wipe all data[/dim]\n
304
+ $ [bold cyan]odctl down --all -v[/bold cyan]
305
+ """,
306
+ )
307
+ def down(
308
+ profiles: Optional[List[str]] = typer.Argument(
309
+ None, help="Specific profiles to stop."
310
+ ),
311
+ all: bool = typer.Option(False, "--all", "-a", help="Stop ALL running profiles."),
312
+ volumes: bool = typer.Option(
313
+ False,
314
+ "--volumes",
315
+ "-v",
316
+ help="Remove named volumes (⚠️ Destroys database/storage data!).",
317
+ ),
318
+ dry_run: bool = typer.Option(
319
+ False,
320
+ "--dry-run",
321
+ help="Preview the teardown plan without actually stopping anything.",
322
+ ),
323
+ ):
324
+ """
325
+ Stop and remove profile containers and networks.
326
+
327
+ Tears down the requested profiles. By default, data volumes are preserved.
328
+ Use [bold red]--volumes[/bold red] to completely wipe the data.
329
+ """
330
+ if not dry_run and not is_docker_running():
331
+ ui.print_error("Docker is not reachable.")
332
+ raise typer.Exit(1)
333
+
334
+ if all and volumes and not dry_run:
335
+ typer.confirm(
336
+ "⚠️ This will destroy ALL profiles and WIPE ALL DATA. Are you sure?",
337
+ abort=True,
338
+ )
339
+
340
+ plan = build_execution_plan(profiles, all, resolve_deps=False)
341
+
342
+ if all:
343
+ from odctl.docker import get_managed_containers, get_stack_details
344
+
345
+ containers = get_managed_containers(plan)
346
+ existing_services = set()
347
+ for c in containers:
348
+ labels = c.config.labels if c.config and c.config.labels else {}
349
+ svc = labels.get("com.docker.compose.service", "")
350
+ if svc:
351
+ existing_services.add(svc)
352
+
353
+ active_plan = {}
354
+ for file, profs in plan.items():
355
+ active_profs = []
356
+ for p in profs:
357
+ services, _, _, named_vols = get_stack_details(file, [p])
358
+ if any(s in existing_services for s in services) or (
359
+ volumes and named_vols
360
+ ):
361
+ active_profs.append(p)
362
+ if active_profs:
363
+ active_plan[file] = active_profs
364
+ plan = active_plan
365
+
366
+ plan = dict(reversed(list(plan.items())))
367
+
368
+ if dry_run:
369
+ ui.print_dry_run(plan, is_teardown=True)
370
+ return
371
+
372
+ for file, profs in plan.items():
373
+ ui.print_info(f"🛑 Stopping [cyan]{', '.join(profs)}[/cyan]...")
374
+ try:
375
+ stop_stack(file, profs, remove_volumes=volumes)
376
+ except Exception as e:
377
+ ui.print_error(f"Failed to stop {file}", details=str(e))
378
+ raise typer.Exit(1)
379
+ ui.print_success("Teardown complete.")
380
+
381
+
382
+ @app.command(
383
+ name="ps",
384
+ rich_help_panel="Inspection & Info",
385
+ epilog="""
386
+ [bold underline]Examples:[/bold underline]\n
387
+ [dim]# Show all running containers managed by ODCTL[/dim]\n
388
+ $ [bold cyan]odctl ps --all[/bold cyan]\n\n
389
+ [dim]# Show containers just for specific profiles[/dim]\n
390
+ $ [bold cyan]odctl ps kafka-lite spark-lite[/bold cyan]
391
+ """,
392
+ )
393
+ def ps(
394
+ profiles: Optional[List[str]] = typer.Argument(
395
+ None, help="Specific profiles to inspect."
396
+ ),
397
+ all: bool = typer.Option(
398
+ False, "--all", "-a", help="Show all containers managed by ODCTL."
399
+ ),
400
+ ):
401
+ """
402
+ List Docker containers managed by the Open Data Stack.
403
+
404
+ Filters out system containers and only shows those belonging to requested profiles.
405
+ """
406
+ if not is_docker_running():
407
+ ui.print_error("Docker is not reachable.")
408
+ raise typer.Exit(1)
409
+
410
+ # Use resolve_deps=False so we ONLY see what was explicitly requested
411
+ plan = build_execution_plan(profiles, all, resolve_deps=False)
412
+
413
+ containers = get_managed_containers(plan)
414
+
415
+ from odctl.docker import get_stack_details
416
+
417
+ running_profiles = set()
418
+ running_services = set()
419
+ for c in containers:
420
+ labels = c.config.labels if c.config and c.config.labels else {}
421
+ svc = labels.get("com.docker.compose.service", "")
422
+ if svc:
423
+ running_services.add(svc)
424
+
425
+ active_profile_services = {}
426
+ for file, profs in plan.items():
427
+ for p in profs:
428
+ services, _, _, _ = get_stack_details(file, [p])
429
+ import builtins
430
+
431
+ if services and builtins.all(s in running_services for s in services):
432
+ active_profile_services[p] = set(services)
433
+
434
+ for p, p_services in active_profile_services.items():
435
+ is_strict_subset = False
436
+ for other_p, other_services in active_profile_services.items():
437
+ if p != other_p and p_services < other_services:
438
+ is_strict_subset = True
439
+ break
440
+ if not is_strict_subset:
441
+ running_profiles.add(p)
442
+
443
+ running_profiles.discard("deps")
444
+ ui.print_ps_table(containers, running_profiles=sorted(list(running_profiles)))
445
+
446
+
447
+ @app.command(name="info", rich_help_panel="Inspection & Info")
448
+ def info():
449
+ ui.print_package_info()
450
+
451
+
452
+ @app.command(
453
+ name="logs",
454
+ rich_help_panel="Management",
455
+ epilog="""
456
+ [bold underline]Examples:[/bold underline]\n
457
+ [dim]# Tail the last 50 lines of all Flink containers and follow live[/dim]\n
458
+ $ [bold cyan]odctl logs flink1-lite -n 50 -f[/bold cyan]\n\n
459
+ [dim]# View logs strictly for the JobManager service[/dim]\n
460
+ $ [bold cyan]odctl logs flink1-lite -s jobmanager-1[/bold cyan]\n\n
461
+ [dim]# Show logs with timestamps for the last 10 minutes[/dim]\n
462
+ $ [bold cyan]odctl logs kafka-lite --since 10m -t[/bold cyan]
463
+ """,
464
+ )
465
+ def logs(
466
+ profiles: List[str] = typer.Argument(..., help="Profiles to fetch logs for."),
467
+ service: Optional[str] = typer.Option(
468
+ None,
469
+ "--service",
470
+ "-s",
471
+ help="Filter to a specific compose service (Use 'odctl ps' to find names).",
472
+ ),
473
+ follow: bool = typer.Option(
474
+ False, "--follow", "-f", help="Follow log output in real-time."
475
+ ),
476
+ tail: str = typer.Option(
477
+ "all", "--tail", "-n", help="Number of lines to show from the end of the logs."
478
+ ),
479
+ timestamps: bool = typer.Option(
480
+ False, "--timestamps", "-t", help="Show timestamps."
481
+ ),
482
+ since: Optional[str] = typer.Option(
483
+ None, "--since", help="Show logs since timestamp or relative (e.g. '42m')."
484
+ ),
485
+ until: Optional[str] = typer.Option(
486
+ None, "--until", help="Show logs before a timestamp or relative."
487
+ ),
488
+ ):
489
+ """Fetch the logs of containers managed by specific profiles."""
490
+ if not is_docker_running():
491
+ ui.print_error("Docker is not reachable.")
492
+ raise typer.Exit(1)
493
+
494
+ plan = build_execution_plan(profiles, resolve_deps=False)
495
+ get_managed_logs(
496
+ execution_plan=plan,
497
+ follow=follow,
498
+ tail=tail,
499
+ timestamps=timestamps,
500
+ since=since,
501
+ until=until,
502
+ service=service,
503
+ )
504
+
505
+
506
+ @app.command(name="restart", rich_help_panel="Management")
507
+ def restart(profiles: List[str] = typer.Argument(..., help="Profiles to restart.")):
508
+ """Restart one or more specific profiles."""
509
+ if not is_docker_running():
510
+ ui.print_error("Docker is not reachable.")
511
+ raise typer.Exit(1)
512
+
513
+ plan = build_execution_plan(profiles, resolve_deps=False)
514
+ ui.print_step("Restarting containers...")
515
+ restart_managed_containers(plan)
516
+ ui.print_success("Restart complete.")
517
+
518
+
519
+ if __name__ == "__main__":
520
+ app()
odctl/planner.py ADDED
@@ -0,0 +1,133 @@
1
+ from typing import Dict, List, Optional, Set
2
+
3
+ import typer
4
+ from rich.console import Console
5
+
6
+ from odctl.registry import load_registry
7
+
8
+ console = Console()
9
+
10
+
11
+ def get_profile_map() -> Dict[str, dict]:
12
+ """
13
+ Create a reverse lookup mapping a profile back to its stack configuration.
14
+
15
+ Returns:
16
+ Dict[str, dict]: A dictionary where keys are profile names and values contain
17
+ the associated 'stack_id' and 'file'.
18
+ """
19
+ registry = load_registry()
20
+ profile_map = {}
21
+ for stack_id, config in registry.stacks.items():
22
+ for profile in config.profiles:
23
+ profile_map[profile] = {"stack_id": stack_id, "file": config.file}
24
+ return profile_map
25
+
26
+
27
+ def validate_profiles(profiles: List[str], profile_map: Dict[str, dict]):
28
+ """
29
+ Check if requested profiles exist in the registry.
30
+
31
+ Args:
32
+ profiles (List[str]): The list of requested profile names.
33
+ profile_map (Dict[str, dict]): The map of valid profiles.
34
+
35
+ Raises:
36
+ typer.Exit: If any requested profile is not found in the profile map.
37
+ """
38
+ invalid = [p for p in profiles if p not in profile_map]
39
+ if invalid:
40
+ console.print(
41
+ f"[bold red]Error:[/bold red] Unknown profiles: {', '.join(invalid)}"
42
+ )
43
+ raise typer.Exit(1)
44
+
45
+
46
+ def resolve_dependencies(
47
+ requested_profiles: List[str], profile_map: Dict[str, dict], registry
48
+ ) -> Set[str]:
49
+ """
50
+ Traverse the dependency graph to ensure all required profiles are included.
51
+
52
+ Args:
53
+ requested_profiles (List[str]): The initial set of profiles requested by the user.
54
+ profile_map (Dict[str, dict]): The reverse lookup map of profiles.
55
+ registry: The loaded stack registry.
56
+
57
+ Returns:
58
+ Set[str]: A complete set of profile names, including all upstream dependencies.
59
+ """
60
+ resolved = set(requested_profiles)
61
+ queue = list(requested_profiles)
62
+
63
+ while queue:
64
+ current = queue.pop(0)
65
+ stack_id = profile_map[current]["stack_id"]
66
+ stack = registry.stacks[stack_id]
67
+ for dep in stack.depends_on.get(current, []):
68
+ if dep not in resolved:
69
+ resolved.add(dep)
70
+ queue.append(dep)
71
+ return resolved
72
+
73
+
74
+ def build_execution_plan(
75
+ profiles: Optional[List[str]] = None,
76
+ all_profiles: bool = False,
77
+ resolve_deps: bool = True,
78
+ ) -> Dict[str, List[str]]:
79
+ """
80
+ Generate a mapping of compose files to the profiles that need to be run.
81
+
82
+ This function resolves parent dependencies and applies a lightweight topological sort
83
+ so infrastructure layers are grouped before target execution layers.
84
+
85
+ Args:
86
+ profiles (List[str], optional): Specific profiles to execute. Defaults to None.
87
+ all_profiles (bool, optional): If True, targets all available profiles in the registry. Defaults to False.
88
+ resolve_deps (bool, optional): If True, traverses the registry to include dependencies. Defaults to True.
89
+
90
+ Returns:
91
+ Dict[str, List[str]]: A dictionary mapping compose filenames to lists of target profiles.
92
+
93
+ Raises:
94
+ typer.Exit: If no profiles are provided and `all_profiles` is False, or if validation fails.
95
+ """
96
+ registry = load_registry()
97
+ profile_map = get_profile_map()
98
+ execution_plan: Dict[str, List[str]] = {}
99
+
100
+ if all_profiles:
101
+ # For teardown/pull all: group all profiles by file
102
+ for stack_id, config in registry.stacks.items():
103
+ if config.file not in execution_plan:
104
+ execution_plan[config.file] = []
105
+ execution_plan[config.file].extend(config.profiles)
106
+ return execution_plan
107
+
108
+ if not profiles:
109
+ console.print(
110
+ "[bold red]Error:[/bold red] Please specify profile names or use --all"
111
+ )
112
+ raise typer.Exit(1)
113
+
114
+ validate_profiles(profiles, profile_map)
115
+
116
+ if resolve_deps:
117
+ final_profiles = resolve_dependencies(profiles, profile_map, registry)
118
+ else:
119
+ final_profiles = set(profiles)
120
+
121
+ # Enforce lightweight topological sort
122
+ order_weights = {"deps": 0, "postgres": 1, "storage": 1, "catalog": 2}
123
+ sorted_profiles = sorted(
124
+ list(final_profiles), key=lambda p: (order_weights.get(p, 99), p)
125
+ )
126
+
127
+ for p in sorted_profiles:
128
+ file = profile_map[p]["file"]
129
+ if file not in execution_plan:
130
+ execution_plan[file] = []
131
+ execution_plan[file].append(p)
132
+
133
+ return execution_plan