csrd-utils 0.3.8__tar.gz → 0.3.10__tar.gz

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 (52) hide show
  1. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/PKG-INFO +4 -1
  2. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/README.md +3 -0
  3. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/pyproject.toml +1 -1
  4. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/__main__.py +106 -2
  5. csrd_utils-0.3.10/src/csrd_utils/cluster.py +457 -0
  6. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/.gitignore +0 -0
  7. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/__init__.py +0 -0
  8. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/augmentor.py +0 -0
  9. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/doctor.py +0 -0
  10. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  11. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  12. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  13. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  14. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
  15. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  16. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  17. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
  18. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
  19. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/fragments.py +0 -0
  20. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/generator.py +0 -0
  21. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/resources.py +0 -0
  22. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
  23. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  24. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  25. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
  26. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +0 -0
  27. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
  28. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +0 -0
  29. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +0 -0
  30. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
  31. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/__init__.py +0 -0
  32. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/cache.py +0 -0
  33. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/__init__.py +0 -0
  34. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/upstreams.py +0 -0
  35. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/__init__.py +0 -0
  36. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/delegates.py +0 -0
  37. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/item_repository.py +0 -0
  38. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/worker_broker.py +0 -0
  39. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/__init__.py +0 -0
  40. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/logging.py +0 -0
  41. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/__init__.py +0 -0
  42. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/item_repository.py +0 -0
  43. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/settings.py +0 -0
  44. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/__init__.py +0 -0
  45. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/__init__.py +0 -0
  46. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/health_view.py +0 -0
  47. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +0 -0
  48. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/__init__.py +0 -0
  49. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/tasks.py +0 -0
  50. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
  51. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +0 -0
  52. {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/test_items.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: csrd-utils
3
- Version: 0.3.8
3
+ Version: 0.3.10
4
4
  Summary: CLI utilities for csrd service generation and feature augmentation
5
5
  Project-URL: Repository, https://github.com/csrd-api/fastapi-common
6
6
  Project-URL: Documentation, https://github.com/csrd-api/fastapi-common/tree/main/packages/utils
@@ -29,6 +29,8 @@ csrd --version
29
29
  csrd new service # compact interactive mode (prompts 1-6, then advanced gate)
30
30
  csrd new service --interactive --name items # pre-fills name prompt with 'items'
31
31
  csrd new service --name inventory --no-interactive --output .
32
+ csrd new cluster --spec ./cluster.yaml --output .
33
+ csrd new cluster # interactive cluster builder (preset + service prompts)
32
34
  csrd feature list
33
35
  csrd feature plan workers --service .
34
36
  csrd feature plan workers --service . --json
@@ -100,6 +102,7 @@ If an agent can only see the installed CLI (not source files), use this explorat
100
102
  ```bash
101
103
  csrd --help
102
104
  csrd new --help
105
+ csrd new cluster --help
103
106
  csrd feature --help
104
107
  csrd doctor --help
105
108
  csrd feature list
@@ -16,6 +16,8 @@ csrd --version
16
16
  csrd new service # compact interactive mode (prompts 1-6, then advanced gate)
17
17
  csrd new service --interactive --name items # pre-fills name prompt with 'items'
18
18
  csrd new service --name inventory --no-interactive --output .
19
+ csrd new cluster --spec ./cluster.yaml --output .
20
+ csrd new cluster # interactive cluster builder (preset + service prompts)
19
21
  csrd feature list
20
22
  csrd feature plan workers --service .
21
23
  csrd feature plan workers --service . --json
@@ -87,6 +89,7 @@ If an agent can only see the installed CLI (not source files), use this explorat
87
89
  ```bash
88
90
  csrd --help
89
91
  csrd new --help
92
+ csrd new cluster --help
90
93
  csrd feature --help
91
94
  csrd doctor --help
92
95
  csrd feature list
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "csrd-utils"
3
- version = "0.3.8"
3
+ version = "0.3.10"
4
4
  description = "CLI utilities for csrd service generation and feature augmentation"
5
5
  license = { text = "MIT" }
6
6
  requires-python = ">=3.12"
@@ -9,6 +9,7 @@ from pathlib import Path
9
9
  from typing import TypedDict
10
10
 
11
11
  from .augmentor import ServiceAugmentor
12
+ from .cluster import PRESETS, ClusterSpec, ServiceSpec, generate_cluster, load_cluster_spec
12
13
  from .doctor import run_doctor
13
14
  from .generator import generate_service
14
15
  from .resources import features_path
@@ -33,6 +34,12 @@ class GenerateServiceArgs(TypedDict):
33
34
  overwrite_if_exists: bool
34
35
 
35
36
 
37
+ class GenerateClusterArgs(TypedDict):
38
+ spec: ClusterSpec
39
+ output_dir: Path
40
+ overwrite_if_exists: bool
41
+
42
+
36
43
  def _prompt_text(label: str, default: str) -> str:
37
44
  value = input(f"{label} [{default}]: ").strip()
38
45
  return value or default
@@ -181,6 +188,74 @@ def _collect_new_service_config(args: argparse.Namespace) -> GenerateServiceArgs
181
188
  )
182
189
 
183
190
 
191
+ def _collect_new_cluster_config(args: argparse.Namespace) -> GenerateClusterArgs:
192
+ if args.spec is not None:
193
+ if not args.spec.is_file():
194
+ raise ValueError(f"Spec file not found: {args.spec}")
195
+ return GenerateClusterArgs(
196
+ {
197
+ "spec": load_cluster_spec(args.spec),
198
+ "output_dir": args.output,
199
+ "overwrite_if_exists": args.force,
200
+ }
201
+ )
202
+
203
+ print("Interactive cluster mode: press Enter to accept defaults.")
204
+ cluster_name = _prompt_text("Cluster name", args.name or "my-cluster")
205
+ preset = _prompt_choice("Preset", list(PRESETS.keys()), args.preset)
206
+ services: list[ServiceSpec] = []
207
+
208
+ add_more = _prompt_bool("Add more services", False)
209
+ index = 1
210
+ default_port = 8091
211
+ while add_more:
212
+ print(f"Configuring service #{index}")
213
+ service_name = _prompt_text("Service name", f"service-{index}")
214
+ service_type = _prompt_choice(
215
+ "Service type", ["db", "delegate", "worker", "platform"], "db"
216
+ )
217
+ service: ServiceSpec = {
218
+ "name": service_name,
219
+ "type": service_type,
220
+ "port": _prompt_int("Service port", default_port),
221
+ }
222
+
223
+ if service_type == "db":
224
+ service["database"] = _prompt_choice(
225
+ "Database", ["sqlite", "maria", "postgres"], "sqlite"
226
+ )
227
+ elif service_type == "delegate":
228
+ delegates_raw = _prompt_text("Delegates (comma-separated)", "")
229
+ service["delegates"] = [
230
+ entry.strip() for entry in delegates_raw.split(",") if entry.strip()
231
+ ]
232
+ elif service_type == "worker":
233
+ service["broker"] = _prompt_choice("Broker", ["redis", "rabbitmq"], "redis")
234
+ else:
235
+ service["role"] = _prompt_text("Platform role", "custom")
236
+
237
+ requires_raw = _prompt_text("Requires (comma-separated)", "")
238
+ service["requires"] = [entry.strip() for entry in requires_raw.split(",") if entry.strip()]
239
+ features_raw = _prompt_text("Features (comma-separated)", "")
240
+ service["features"] = [entry.strip() for entry in features_raw.split(",") if entry.strip()]
241
+
242
+ services.append(service)
243
+ index += 1
244
+ default_port += 1
245
+ add_more = _prompt_bool("Add more services", False)
246
+
247
+ return GenerateClusterArgs(
248
+ {
249
+ "spec": {
250
+ "cluster": {"name": cluster_name, "preset": preset},
251
+ "services": services,
252
+ },
253
+ "output_dir": args.output,
254
+ "overwrite_if_exists": args.force,
255
+ }
256
+ )
257
+
258
+
184
259
  def _build_parser() -> argparse.ArgumentParser:
185
260
  parser = argparse.ArgumentParser(prog="csrd", description="csrd utilities")
186
261
  parser.add_argument(
@@ -248,6 +323,18 @@ def _build_parser() -> argparse.ArgumentParser:
248
323
  service.add_argument("--no-interactive", dest="interactive", action="store_false")
249
324
  service.add_argument("--force", action="store_true", help="Overwrite existing generated path")
250
325
 
326
+ cluster = new_sub.add_parser("cluster", help="Generate a cluster scaffold from spec or prompts")
327
+ cluster.add_argument("--name", help="Cluster name (used in interactive mode)")
328
+ cluster.add_argument("--spec", type=Path, help="Path to cluster YAML spec")
329
+ cluster.add_argument("--output", type=Path, default=Path.cwd(), help="Output directory")
330
+ cluster.add_argument(
331
+ "--preset",
332
+ choices=list(PRESETS.keys()),
333
+ default="none",
334
+ help="Interactive preset to seed platform services",
335
+ )
336
+ cluster.add_argument("--force", action="store_true", help="Overwrite existing generated path")
337
+
251
338
  return parser
252
339
 
253
340
 
@@ -293,8 +380,8 @@ def main() -> int:
293
380
 
294
381
  if args.command == "new" and args.new_command == "service":
295
382
  try:
296
- config = _collect_new_service_config(args)
297
- generated = generate_service(**config)
383
+ service_config = _collect_new_service_config(args)
384
+ generated = generate_service(**service_config)
298
385
  except (FileExistsError, ValueError) as exc:
299
386
  print(f"ERROR: {exc}")
300
387
  return 1
@@ -304,6 +391,23 @@ def main() -> int:
304
391
  print(f"Generated service at: {generated}")
305
392
  return 0
306
393
 
394
+ if args.command == "new" and args.new_command == "cluster":
395
+ try:
396
+ cluster_config = _collect_new_cluster_config(args)
397
+ generated = generate_cluster(
398
+ cluster_config["spec"],
399
+ cluster_config["output_dir"],
400
+ force=cluster_config["overwrite_if_exists"],
401
+ )
402
+ except (FileExistsError, ValueError) as exc:
403
+ print(f"ERROR: {exc}")
404
+ return 1
405
+ except KeyboardInterrupt:
406
+ print("\nCancelled.")
407
+ return 1
408
+ print(f"Generated cluster at: {generated}")
409
+ return 0
410
+
307
411
  if args.command == "doctor":
308
412
  return _run_doctor(args.service, args.json)
309
413
 
@@ -0,0 +1,457 @@
1
+ """Cluster scaffolding helpers for csrd-utils."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from pathlib import Path
7
+ from typing import Any, TypedDict, cast
8
+
9
+ import yaml # type: ignore[import-untyped]
10
+
11
+
12
+ class ServiceSpec(TypedDict, total=False):
13
+ name: str
14
+ type: str
15
+ database: str
16
+ delegates: list[str]
17
+ broker: str
18
+ role: str
19
+ port: int
20
+ features: list[str]
21
+ requires: list[str]
22
+
23
+
24
+ class ClusterSpec(TypedDict):
25
+ cluster: dict[str, str]
26
+ services: list[ServiceSpec]
27
+
28
+
29
+ INFRA_NAMES = {"postgres", "mariadb", "redis", "rabbitmq"}
30
+ DEFAULT_PORT_START = 8091
31
+
32
+ PRESETS: dict[str, list[ServiceSpec]] = {
33
+ "none": [],
34
+ "authenticated-cluster": [
35
+ {"name": "auth-service", "type": "platform", "role": "auth", "port": 8081, "requires": []},
36
+ {
37
+ "name": "identity-service",
38
+ "type": "platform",
39
+ "role": "identity",
40
+ "port": 8082,
41
+ "requires": ["auth-service"],
42
+ },
43
+ ],
44
+ "minimal-cluster": [
45
+ {"name": "auth-service", "type": "platform", "role": "auth", "port": 8081, "requires": []},
46
+ {
47
+ "name": "core-db",
48
+ "type": "db",
49
+ "database": "sqlite",
50
+ "port": 8091,
51
+ "requires": ["auth-service"],
52
+ },
53
+ ],
54
+ "event-driven-cluster": [
55
+ {"name": "auth-service", "type": "platform", "role": "auth", "port": 8081, "requires": []},
56
+ {
57
+ "name": "events",
58
+ "type": "db",
59
+ "database": "postgres",
60
+ "port": 8091,
61
+ "requires": ["auth-service"],
62
+ },
63
+ {
64
+ "name": "worker-consume",
65
+ "type": "worker",
66
+ "broker": "redis",
67
+ "port": 8093,
68
+ "requires": ["events", "redis"],
69
+ },
70
+ ],
71
+ }
72
+
73
+
74
+ def _split_csv(raw: str) -> list[str]:
75
+ return [item.strip() for item in raw.split(",") if item.strip()]
76
+
77
+
78
+ def load_cluster_spec(spec_path: Path) -> ClusterSpec:
79
+ payload = yaml.safe_load(spec_path.read_text(encoding="utf-8"))
80
+ if not isinstance(payload, dict):
81
+ raise ValueError("Cluster spec must be a YAML object")
82
+ cluster = payload.get("cluster")
83
+ services = payload.get("services")
84
+ if not isinstance(cluster, dict) or not isinstance(services, list):
85
+ raise ValueError("Cluster spec must include 'cluster' object and 'services' list")
86
+
87
+ name = str(cluster.get("name", "")).strip()
88
+ if not name:
89
+ raise ValueError("cluster.name is required")
90
+
91
+ preset = str(cluster.get("preset", "none")).strip() or "none"
92
+ parsed_services: list[ServiceSpec] = [
93
+ cast(ServiceSpec, dict(service)) for service in services if isinstance(service, dict)
94
+ ]
95
+
96
+ return {
97
+ "cluster": {"name": name, "preset": preset},
98
+ "services": parsed_services,
99
+ }
100
+
101
+
102
+ def normalize_cluster_spec(spec: ClusterSpec) -> ClusterSpec:
103
+ cluster_name = spec["cluster"]["name"].strip()
104
+ preset = spec["cluster"].get("preset", "none").strip() or "none"
105
+ if preset not in PRESETS:
106
+ raise ValueError(f"Unknown preset: {preset}")
107
+
108
+ services: list[ServiceSpec] = [cast(ServiceSpec, dict(service)) for service in PRESETS[preset]]
109
+ services.extend(cast(ServiceSpec, dict(service)) for service in spec.get("services", []))
110
+
111
+ used_names: set[str] = set()
112
+ used_ports: set[int] = set()
113
+ next_port = DEFAULT_PORT_START
114
+ normalized: list[ServiceSpec] = []
115
+
116
+ for service in services:
117
+ name = str(service.get("name", "")).strip()
118
+ if not name:
119
+ raise ValueError("Each service requires a name")
120
+ if name in used_names:
121
+ raise ValueError(f"Duplicate service name: {name}")
122
+ used_names.add(name)
123
+
124
+ service_type = str(service.get("type", "")).strip()
125
+ if service_type not in {"db", "delegate", "worker", "platform"}:
126
+ raise ValueError(f"Unsupported service type for {name}: {service_type}")
127
+
128
+ normalized_service: ServiceSpec = {
129
+ "name": name,
130
+ "type": service_type,
131
+ "features": list(service.get("features", [])),
132
+ "requires": list(service.get("requires", [])),
133
+ }
134
+
135
+ port = service.get("port")
136
+ if port is None:
137
+ while next_port in used_ports:
138
+ next_port += 1
139
+ port = next_port
140
+ next_port += 1
141
+ if int(port) in used_ports:
142
+ raise ValueError(f"Duplicate port detected: {port}")
143
+ normalized_service["port"] = int(port)
144
+ used_ports.add(int(port))
145
+
146
+ if service_type == "db":
147
+ database = str(service.get("database", "")).strip()
148
+ if database not in {"sqlite", "maria", "postgres"}:
149
+ raise ValueError(f"DB service '{name}' requires database sqlite/maria/postgres")
150
+ normalized_service["database"] = database
151
+
152
+ if service_type == "delegate":
153
+ delegates = service.get("delegates", [])
154
+ if isinstance(delegates, str):
155
+ delegates = _split_csv(delegates)
156
+ normalized_service["delegates"] = list(delegates)
157
+
158
+ if service_type == "worker":
159
+ broker = str(service.get("broker", "")).strip() or "redis"
160
+ if broker not in {"redis", "rabbitmq"}:
161
+ raise ValueError(f"Worker service '{name}' requires broker redis/rabbitmq")
162
+ normalized_service["broker"] = broker
163
+
164
+ if service_type == "platform":
165
+ normalized_service["role"] = str(service.get("role", "custom")).strip() or "custom"
166
+
167
+ normalized.append(normalized_service)
168
+
169
+ _validate_dependencies(normalized)
170
+ _validate_cycles(normalized)
171
+
172
+ return {"cluster": {"name": cluster_name, "preset": preset}, "services": normalized}
173
+
174
+
175
+ def _validate_dependencies(services: list[ServiceSpec]) -> None:
176
+ names = {str(service["name"]) for service in services}
177
+
178
+ for service in services:
179
+ name = str(service["name"])
180
+ delegates = service.get("delegates", [])
181
+ for target in delegates:
182
+ if target not in names:
183
+ raise ValueError(f"Service '{name}' delegates to unknown service '{target}'")
184
+
185
+ requires = service.get("requires", [])
186
+ for required in requires:
187
+ if required not in names and required not in INFRA_NAMES:
188
+ raise ValueError(f"Service '{name}' requires unknown dependency '{required}'")
189
+
190
+
191
+ def _validate_cycles(services: list[ServiceSpec]) -> None:
192
+ names = {str(service["name"]) for service in services}
193
+ graph: dict[str, list[str]] = {}
194
+ for service in services:
195
+ name = str(service["name"])
196
+ graph[name] = [dep for dep in service.get("requires", []) if dep in names]
197
+
198
+ visiting: set[str] = set()
199
+ visited: set[str] = set()
200
+
201
+ def walk(node: str) -> None:
202
+ if node in visited:
203
+ return
204
+ if node in visiting:
205
+ raise ValueError(f"Circular dependency detected at '{node}'")
206
+ visiting.add(node)
207
+ for nxt in graph.get(node, []):
208
+ walk(nxt)
209
+ visiting.remove(node)
210
+ visited.add(node)
211
+
212
+ for node in graph:
213
+ walk(node)
214
+
215
+
216
+ def _infer_infra(services: Iterable[ServiceSpec]) -> set[str]:
217
+ infra: set[str] = set()
218
+ for service in services:
219
+ service_type = service["type"]
220
+ if service_type == "db":
221
+ db = service.get("database")
222
+ if db == "postgres":
223
+ infra.add("postgres")
224
+ if db == "maria":
225
+ infra.add("mariadb")
226
+ if service_type == "worker":
227
+ broker = service.get("broker", "redis")
228
+ infra.add("redis" if broker == "redis" else "rabbitmq")
229
+ if "caching" in service.get("features", []):
230
+ infra.add("redis")
231
+ return infra
232
+
233
+
234
+ def _service_environment(service: ServiceSpec, services: list[ServiceSpec]) -> dict[str, str]:
235
+ env: dict[str, str] = {"PORT": str(service["port"])}
236
+ name = str(service["name"])
237
+
238
+ if service["type"] == "db":
239
+ db = service.get("database")
240
+ if db == "sqlite":
241
+ env["DB_PATH"] = "./sandbox.db"
242
+ elif db == "maria":
243
+ env["DB_HOST"] = "mariadb"
244
+ env["DB_PORT"] = "3306"
245
+ env["DB_USER"] = "sandbox"
246
+ env["DB_PASSWORD"] = "sandbox"
247
+ env["DB_NAME"] = "sandbox"
248
+ elif db == "postgres":
249
+ env["DB_HOST"] = "postgres"
250
+ env["DB_PORT"] = "5432"
251
+ env["DB_USER"] = "sandbox"
252
+ env["DB_PASSWORD"] = "sandbox"
253
+ env["DB_NAME"] = "sandbox"
254
+
255
+ if service["type"] == "worker":
256
+ broker = service.get("broker", "redis")
257
+ if broker == "redis":
258
+ env["CELERY_BROKER_URL"] = "redis://redis:6379/0"
259
+ env["CELERY_RESULT_BACKEND"] = "redis://redis:6379/1"
260
+ else:
261
+ env["CELERY_BROKER_URL"] = "amqp://guest:guest@rabbitmq:5672//"
262
+ env["CELERY_RESULT_BACKEND"] = "rpc://"
263
+
264
+ for target in service.get("delegates", []):
265
+ target_service = next((s for s in services if s["name"] == target), None)
266
+ if target_service is not None:
267
+ key = f"{target.upper().replace('-', '_')}_URL"
268
+ env[key] = f"http://{target}:{target_service['port']}"
269
+
270
+ auth_service = next((s for s in services if s.get("role") == "auth"), None)
271
+ if auth_service and name != auth_service["name"]:
272
+ env["AUTH_SERVICE_URL"] = f"http://{auth_service['name']}:{auth_service['port']}"
273
+
274
+ return env
275
+
276
+
277
+ def render_compose(spec: ClusterSpec) -> dict[str, Any]:
278
+ services = spec["services"]
279
+ infra = _infer_infra(services)
280
+ compose: dict[str, Any] = {"version": "3.9", "services": {}}
281
+
282
+ for service in services:
283
+ name = str(service["name"])
284
+ requires = {
285
+ dep
286
+ for dep in service.get("requires", [])
287
+ if dep in INFRA_NAMES or any(s["name"] == dep for s in services)
288
+ }
289
+
290
+ # Auto-wire required infra based on selected DB/broker/features.
291
+ if service["type"] == "db":
292
+ database = service.get("database")
293
+ if database == "postgres":
294
+ requires.add("postgres")
295
+ elif database == "maria":
296
+ requires.add("mariadb")
297
+
298
+ if service["type"] == "worker":
299
+ requires.add("redis" if service.get("broker", "redis") == "redis" else "rabbitmq")
300
+
301
+ if "caching" in service.get("features", []):
302
+ requires.add("redis")
303
+
304
+ depends_on: dict[str, dict[str, str]] = {}
305
+ for dep in sorted(requires):
306
+ condition = "service_started"
307
+ if dep in {"postgres", "mariadb", "redis", "rabbitmq"}:
308
+ condition = "service_healthy"
309
+ depends_on[dep] = {"condition": condition}
310
+
311
+ app_service: dict[str, Any] = {
312
+ "image": f"ghcr.io/csrd-api/{name}:latest",
313
+ "ports": [f"{service['port']}:{service['port']}"],
314
+ "environment": _service_environment(service, services),
315
+ }
316
+ if depends_on:
317
+ app_service["depends_on"] = depends_on
318
+ compose["services"][name] = app_service
319
+
320
+ if "postgres" in infra:
321
+ compose["services"]["postgres"] = {
322
+ "image": "postgres:16",
323
+ "environment": {
324
+ "POSTGRES_DB": "sandbox",
325
+ "POSTGRES_USER": "sandbox",
326
+ "POSTGRES_PASSWORD": "sandbox",
327
+ },
328
+ "ports": ["5432:5432"],
329
+ "healthcheck": {
330
+ "test": ["CMD-SHELL", "pg_isready -U sandbox"],
331
+ "interval": "10s",
332
+ "timeout": "5s",
333
+ "retries": 5,
334
+ },
335
+ }
336
+
337
+ if "mariadb" in infra:
338
+ compose["services"]["mariadb"] = {
339
+ "image": "mariadb:11",
340
+ "environment": {
341
+ "MYSQL_ROOT_PASSWORD": "root",
342
+ "MYSQL_DATABASE": "sandbox",
343
+ "MYSQL_USER": "sandbox",
344
+ "MYSQL_PASSWORD": "sandbox",
345
+ },
346
+ "ports": ["3306:3306"],
347
+ "healthcheck": {
348
+ "test": ["CMD", "mysqladmin", "ping", "-h", "localhost"],
349
+ "interval": "10s",
350
+ "timeout": "5s",
351
+ "retries": 5,
352
+ },
353
+ }
354
+
355
+ if "redis" in infra:
356
+ compose["services"]["redis"] = {
357
+ "image": "redis:7",
358
+ "ports": ["6379:6379"],
359
+ "healthcheck": {
360
+ "test": ["CMD", "redis-cli", "ping"],
361
+ "interval": "10s",
362
+ "timeout": "5s",
363
+ "retries": 5,
364
+ },
365
+ }
366
+
367
+ if "rabbitmq" in infra:
368
+ compose["services"]["rabbitmq"] = {
369
+ "image": "rabbitmq:3-management",
370
+ "ports": ["5672:5672", "15672:15672"],
371
+ "healthcheck": {
372
+ "test": ["CMD", "rabbitmq-diagnostics", "-q", "ping"],
373
+ "interval": "10s",
374
+ "timeout": "5s",
375
+ "retries": 5,
376
+ },
377
+ }
378
+
379
+ return compose
380
+
381
+
382
+ def _build_env(spec: ClusterSpec) -> str:
383
+ lines = [
384
+ "# Cluster-level environment",
385
+ f"CLUSTER_NAME={spec['cluster']['name']}",
386
+ "",
387
+ "# Service URLs",
388
+ ]
389
+ for service in spec["services"]:
390
+ key = f"{service['name'].upper().replace('-', '_')}_URL"
391
+ lines.append(f"{key}=http://localhost:{service['port']}")
392
+ return "\n".join(lines).strip() + "\n"
393
+
394
+
395
+ def _build_readme(spec: ClusterSpec) -> str:
396
+ lines = [
397
+ f"# {spec['cluster']['name']}",
398
+ "",
399
+ "Generated cluster scaffold.",
400
+ "",
401
+ "## Services",
402
+ ]
403
+ for service in spec["services"]:
404
+ lines.append(f"- `{service['name']}` ({service['type']}) on port `{service['port']}`")
405
+ lines.extend(
406
+ [
407
+ "",
408
+ "## Run",
409
+ "",
410
+ "```bash",
411
+ "docker compose up -d",
412
+ "./smoke.sh",
413
+ "```",
414
+ ]
415
+ )
416
+ return "\n".join(lines) + "\n"
417
+
418
+
419
+ def _build_smoke(spec: ClusterSpec) -> str:
420
+ lines = [
421
+ "#!/usr/bin/env bash",
422
+ "set -euo pipefail",
423
+ "",
424
+ 'echo "Running cluster smoke checks..."',
425
+ ]
426
+ for service in spec["services"]:
427
+ lines.extend(
428
+ [
429
+ f'echo "- {service["name"]}"',
430
+ f'curl -fsS "http://localhost:{service["port"]}/health" >/dev/null || true',
431
+ ]
432
+ )
433
+ lines.append('echo "Smoke checks complete."')
434
+ return "\n".join(lines) + "\n"
435
+
436
+
437
+ def generate_cluster(spec: ClusterSpec, output_dir: Path, force: bool = False) -> Path:
438
+ normalized = normalize_cluster_spec(spec)
439
+ cluster_root = output_dir.resolve() / normalized["cluster"]["name"]
440
+ cluster_root.mkdir(parents=True, exist_ok=True)
441
+
442
+ if any(cluster_root.iterdir()) and not force:
443
+ raise FileExistsError(
444
+ f"Target directory already exists and is not empty: {cluster_root}. Use --force to overwrite."
445
+ )
446
+
447
+ compose = render_compose(normalized)
448
+ (cluster_root / "docker-compose.yml").write_text(
449
+ yaml.safe_dump(compose, sort_keys=False), encoding="utf-8"
450
+ )
451
+ (cluster_root / ".env.example").write_text(_build_env(normalized), encoding="utf-8")
452
+ (cluster_root / "README.md").write_text(_build_readme(normalized), encoding="utf-8")
453
+ smoke_path = cluster_root / "smoke.sh"
454
+ smoke_path.write_text(_build_smoke(normalized), encoding="utf-8")
455
+ smoke_path.chmod(0o755)
456
+
457
+ return cluster_root
File without changes