csrd-utils 0.3.7__tar.gz → 0.3.9__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.7 → csrd_utils-0.3.9}/PKG-INFO +10 -3
  2. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/README.md +9 -2
  3. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/pyproject.toml +1 -1
  4. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/__main__.py +150 -32
  5. csrd_utils-0.3.9/src/csrd_utils/cluster.py +457 -0
  6. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/.gitignore +0 -0
  7. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/__init__.py +0 -0
  8. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/augmentor.py +0 -0
  9. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/doctor.py +0 -0
  10. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
  11. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
  12. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/manifest.yaml +0 -0
  13. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/requirements.fragment +0 -0
  14. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
  15. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
  16. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
  17. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
  18. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
  19. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/fragments.py +0 -0
  20. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/generator.py +0 -0
  21. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/resources.py +0 -0
  22. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
  23. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
  24. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
  25. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
  26. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +0 -0
  27. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
  28. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +0 -0
  29. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +0 -0
  30. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
  31. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/__init__.py +0 -0
  32. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/cache.py +0 -0
  33. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/__init__.py +0 -0
  34. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/delegates/upstreams.py +0 -0
  35. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/__init__.py +0 -0
  36. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/delegates.py +0 -0
  37. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/item_repository.py +0 -0
  38. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/dependencies/worker_broker.py +0 -0
  39. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/__init__.py +0 -0
  40. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/middleware/logging.py +0 -0
  41. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/__init__.py +0 -0
  42. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/repositories/item_repository.py +0 -0
  43. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/settings.py +0 -0
  44. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/__init__.py +0 -0
  45. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/__init__.py +0 -0
  46. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/health_view.py +0 -0
  47. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/views/unversioned/items_view.py +0 -0
  48. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/__init__.py +0 -0
  49. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/src/app/workers/tasks.py +0 -0
  50. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
  51. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +0 -0
  52. {csrd_utils-0.3.7 → csrd_utils-0.3.9}/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.7
3
+ Version: 0.3.9
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
@@ -26,9 +26,11 @@ pip install "csrd-utils @ git+https://github.com/csrd-api/fastapi-common.git#sub
26
26
  ```bash
27
27
  csrd --help
28
28
  csrd --version
29
- csrd new service # interactive prompts
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
@@ -40,10 +42,14 @@ csrd doctor --service .
40
42
 
41
43
  ### Interactive generation behavior
42
44
 
43
- - `csrd new service` runs interactive prompts by default.
45
+ - `csrd new service` runs a compact interactive flow by default (core prompts 1-6), then asks whether to show advanced options.
46
+ - In compact mode, advanced options are hidden by default.
47
+ - `csrd new service --interactive` runs the full verbose interactive flow.
44
48
  - Passing `--name <value>` in interactive mode uses that value as the name prompt default.
45
49
  - You can still overwrite the default by entering a different value at the prompt.
46
50
  - Use `--no-interactive` for fully flag-driven generation.
51
+ - Database default is `none`.
52
+ - All yes/no prompts default to `No` (`y/N`).
47
53
 
48
54
  ## Typical workflow
49
55
 
@@ -96,6 +102,7 @@ If an agent can only see the installed CLI (not source files), use this explorat
96
102
  ```bash
97
103
  csrd --help
98
104
  csrd new --help
105
+ csrd new cluster --help
99
106
  csrd feature --help
100
107
  csrd doctor --help
101
108
  csrd feature list
@@ -13,9 +13,11 @@ pip install "csrd-utils @ git+https://github.com/csrd-api/fastapi-common.git#sub
13
13
  ```bash
14
14
  csrd --help
15
15
  csrd --version
16
- csrd new service # interactive prompts
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
@@ -27,10 +29,14 @@ csrd doctor --service .
27
29
 
28
30
  ### Interactive generation behavior
29
31
 
30
- - `csrd new service` runs interactive prompts by default.
32
+ - `csrd new service` runs a compact interactive flow by default (core prompts 1-6), then asks whether to show advanced options.
33
+ - In compact mode, advanced options are hidden by default.
34
+ - `csrd new service --interactive` runs the full verbose interactive flow.
31
35
  - Passing `--name <value>` in interactive mode uses that value as the name prompt default.
32
36
  - You can still overwrite the default by entering a different value at the prompt.
33
37
  - Use `--no-interactive` for fully flag-driven generation.
38
+ - Database default is `none`.
39
+ - All yes/no prompts default to `No` (`y/N`).
34
40
 
35
41
  ## Typical workflow
36
42
 
@@ -83,6 +89,7 @@ If an agent can only see the installed CLI (not source files), use this explorat
83
89
  ```bash
84
90
  csrd --help
85
91
  csrd new --help
92
+ csrd new cluster --help
86
93
  csrd feature --help
87
94
  csrd doctor --help
88
95
  csrd feature list
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "csrd-utils"
3
- version = "0.3.7"
3
+ version = "0.3.9"
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
@@ -70,6 +77,7 @@ def _prompt_int(label: str, default: int) -> int:
70
77
 
71
78
  def _collect_new_service_config(args: argparse.Namespace) -> GenerateServiceArgs:
72
79
  explicit_interactive = getattr(args, "interactive", None)
80
+ is_explicit_interactive = explicit_interactive is True
73
81
  interactive = (
74
82
  bool(explicit_interactive) if explicit_interactive is not None else args.name is None
75
83
  )
@@ -112,36 +120,51 @@ def _collect_new_service_config(args: argparse.Namespace) -> GenerateServiceArgs
112
120
  plural_default = f"{model_name.lower()}s"
113
121
  model_name_plural = _prompt_text("Model name plural", plural_default)
114
122
 
115
- database = _prompt_choice("Database", ["sqlite", "maria", "postgres", "none"], args.database)
116
- has_database = database != "none"
117
- selected_database = "sqlite" if not has_database else database
118
-
119
- has_delegates = _prompt_bool("Enable delegates", args.delegates)
120
- upstreams_default = args.upstreams
121
- if has_delegates and not upstreams_default:
122
- upstreams_default = "http://service-a:8080"
123
- upstreams = (
124
- _prompt_text("Delegates upstreams (comma-separated)", upstreams_default)
125
- if has_delegates
126
- else ""
127
- )
128
-
129
- has_workers = _prompt_bool("Enable workers", args.workers)
130
- broker = (
131
- _prompt_choice("Worker broker", ["redis", "rabbitmq"], args.broker)
132
- if has_workers
133
- else args.broker
134
- )
123
+ show_advanced = True
124
+ if not is_explicit_interactive:
125
+ show_advanced = _prompt_bool("Show advanced configuration options", False)
135
126
 
136
- has_caching = _prompt_bool("Enable caching", args.caching)
137
- cache_backend = (
138
- _prompt_choice("Cache backend", ["redis"], args.cache_backend)
139
- if has_caching
140
- else args.cache_backend
141
- )
127
+ if show_advanced:
128
+ database = _prompt_choice(
129
+ "Database", ["sqlite", "maria", "postgres", "none"], args.database
130
+ )
131
+ has_database = database != "none"
132
+ has_delegates = _prompt_bool("Enable delegates", False)
133
+ upstreams_default = args.upstreams
134
+ if has_delegates and not upstreams_default:
135
+ upstreams_default = "http://service-a:8080"
136
+ upstreams = (
137
+ _prompt_text("Delegates upstreams (comma-separated)", upstreams_default)
138
+ if has_delegates
139
+ else ""
140
+ )
141
+ has_workers = _prompt_bool("Enable workers", False)
142
+ broker = (
143
+ _prompt_choice("Worker broker", ["redis", "rabbitmq"], args.broker)
144
+ if has_workers
145
+ else args.broker
146
+ )
147
+ has_caching = _prompt_bool("Enable caching", False)
148
+ cache_backend = (
149
+ _prompt_choice("Cache backend", ["redis"], args.cache_backend)
150
+ if has_caching
151
+ else args.cache_backend
152
+ )
153
+ has_structured_logging = _prompt_bool("Enable structured logging", False)
154
+ overwrite_if_exists = _prompt_bool("Overwrite if target exists", False)
155
+ else:
156
+ database = args.database
157
+ has_database = database != "none"
158
+ has_delegates = args.delegates
159
+ upstreams = args.upstreams if has_delegates else ""
160
+ has_workers = args.workers
161
+ broker = args.broker
162
+ has_caching = args.caching
163
+ cache_backend = args.cache_backend
164
+ has_structured_logging = args.structured_logging
165
+ overwrite_if_exists = args.force
142
166
 
143
- has_structured_logging = _prompt_bool("Enable structured logging", args.structured_logging)
144
- overwrite_if_exists = _prompt_bool("Overwrite if target exists", args.force)
167
+ selected_database = "sqlite" if not has_database else database
145
168
 
146
169
  return GenerateServiceArgs(
147
170
  {
@@ -165,6 +188,72 @@ def _collect_new_service_config(args: argparse.Namespace) -> GenerateServiceArgs
165
188
  )
166
189
 
167
190
 
191
+ def _collect_new_cluster_config(args: argparse.Namespace) -> GenerateClusterArgs:
192
+ if args.spec is not None:
193
+ return GenerateClusterArgs(
194
+ {
195
+ "spec": load_cluster_spec(args.spec),
196
+ "output_dir": args.output,
197
+ "overwrite_if_exists": args.force,
198
+ }
199
+ )
200
+
201
+ print("Interactive cluster mode: press Enter to accept defaults.")
202
+ cluster_name = _prompt_text("Cluster name", args.name or "my-cluster")
203
+ preset = _prompt_choice("Preset", list(PRESETS.keys()), args.preset)
204
+ services: list[ServiceSpec] = []
205
+
206
+ add_more = _prompt_bool("Add more services", False)
207
+ index = 1
208
+ default_port = 8091
209
+ while add_more:
210
+ print(f"Configuring service #{index}")
211
+ service_name = _prompt_text("Service name", f"service-{index}")
212
+ service_type = _prompt_choice(
213
+ "Service type", ["db", "delegate", "worker", "platform"], "db"
214
+ )
215
+ service: ServiceSpec = {
216
+ "name": service_name,
217
+ "type": service_type,
218
+ "port": _prompt_int("Service port", default_port),
219
+ }
220
+
221
+ if service_type == "db":
222
+ service["database"] = _prompt_choice(
223
+ "Database", ["sqlite", "maria", "postgres"], "sqlite"
224
+ )
225
+ elif service_type == "delegate":
226
+ delegates_raw = _prompt_text("Delegates (comma-separated)", "")
227
+ service["delegates"] = [
228
+ entry.strip() for entry in delegates_raw.split(",") if entry.strip()
229
+ ]
230
+ elif service_type == "worker":
231
+ service["broker"] = _prompt_choice("Broker", ["redis", "rabbitmq"], "redis")
232
+ else:
233
+ service["role"] = _prompt_text("Platform role", "custom")
234
+
235
+ requires_raw = _prompt_text("Requires (comma-separated)", "")
236
+ service["requires"] = [entry.strip() for entry in requires_raw.split(",") if entry.strip()]
237
+ features_raw = _prompt_text("Features (comma-separated)", "")
238
+ service["features"] = [entry.strip() for entry in features_raw.split(",") if entry.strip()]
239
+
240
+ services.append(service)
241
+ index += 1
242
+ default_port += 1
243
+ add_more = _prompt_bool("Add more services", False)
244
+
245
+ return GenerateClusterArgs(
246
+ {
247
+ "spec": {
248
+ "cluster": {"name": cluster_name, "preset": preset},
249
+ "services": services,
250
+ },
251
+ "output_dir": args.output,
252
+ "overwrite_if_exists": args.force,
253
+ }
254
+ )
255
+
256
+
168
257
  def _build_parser() -> argparse.ArgumentParser:
169
258
  parser = argparse.ArgumentParser(prog="csrd", description="csrd utilities")
170
259
  parser.add_argument(
@@ -207,7 +296,7 @@ def _build_parser() -> argparse.ArgumentParser:
207
296
  service.add_argument("--model-name", default="Item", help="Primary model name")
208
297
  service.add_argument("--model-name-plural", default="items", help="Plural model name")
209
298
  service.add_argument(
210
- "--database", choices=["sqlite", "maria", "postgres", "none"], default="sqlite"
299
+ "--database", choices=["sqlite", "maria", "postgres", "none"], default="none"
211
300
  )
212
301
  service.add_argument("--delegates", action="store_true", help="Enable delegates")
213
302
  service.add_argument("--upstreams", default="", help="Comma-separated upstream URLs")
@@ -219,7 +308,7 @@ def _build_parser() -> argparse.ArgumentParser:
219
308
  "--structured-logging",
220
309
  dest="structured_logging",
221
310
  action="store_true",
222
- default=True,
311
+ default=False,
223
312
  help="Enable structured logging",
224
313
  )
225
314
  service.add_argument(
@@ -232,6 +321,18 @@ def _build_parser() -> argparse.ArgumentParser:
232
321
  service.add_argument("--no-interactive", dest="interactive", action="store_false")
233
322
  service.add_argument("--force", action="store_true", help="Overwrite existing generated path")
234
323
 
324
+ cluster = new_sub.add_parser("cluster", help="Generate a cluster scaffold from spec or prompts")
325
+ cluster.add_argument("--name", help="Cluster name (used in interactive mode)")
326
+ cluster.add_argument("--spec", type=Path, help="Path to cluster YAML spec")
327
+ cluster.add_argument("--output", type=Path, default=Path.cwd(), help="Output directory")
328
+ cluster.add_argument(
329
+ "--preset",
330
+ choices=list(PRESETS.keys()),
331
+ default="none",
332
+ help="Interactive preset to seed platform services",
333
+ )
334
+ cluster.add_argument("--force", action="store_true", help="Overwrite existing generated path")
335
+
235
336
  return parser
236
337
 
237
338
 
@@ -277,8 +378,8 @@ def main() -> int:
277
378
 
278
379
  if args.command == "new" and args.new_command == "service":
279
380
  try:
280
- config = _collect_new_service_config(args)
281
- generated = generate_service(**config)
381
+ service_config = _collect_new_service_config(args)
382
+ generated = generate_service(**service_config)
282
383
  except (FileExistsError, ValueError) as exc:
283
384
  print(f"ERROR: {exc}")
284
385
  return 1
@@ -288,6 +389,23 @@ def main() -> int:
288
389
  print(f"Generated service at: {generated}")
289
390
  return 0
290
391
 
392
+ if args.command == "new" and args.new_command == "cluster":
393
+ try:
394
+ cluster_config = _collect_new_cluster_config(args)
395
+ generated = generate_cluster(
396
+ cluster_config["spec"],
397
+ cluster_config["output_dir"],
398
+ force=cluster_config["overwrite_if_exists"],
399
+ )
400
+ except (FileExistsError, ValueError) as exc:
401
+ print(f"ERROR: {exc}")
402
+ return 1
403
+ except KeyboardInterrupt:
404
+ print("\nCancelled.")
405
+ return 1
406
+ print(f"Generated cluster at: {generated}")
407
+ return 0
408
+
291
409
  if args.command == "doctor":
292
410
  return _run_doctor(args.service, args.json)
293
411
 
@@ -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