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.
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/PKG-INFO +4 -1
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/README.md +3 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/pyproject.toml +1 -1
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/__main__.py +106 -2
- csrd_utils-0.3.10/src/csrd_utils/cluster.py +457 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/.gitignore +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/__init__.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/augmentor.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/doctor.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/manifest.yaml +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/requirements.fragment +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/fragments.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/generator.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/resources.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
- {csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
297
|
-
generated = generate_service(**
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/docker-compose.fragment.yaml
RENAMED
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/requirements.fragment
RENAMED
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/src/app/workers/__init__.py
RENAMED
|
File without changes
|
{csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/src/app/workers/celery_app.py
RENAMED
|
File without changes
|
{csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/src/app/workers/tasks.py
RENAMED
|
File without changes
|
{csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/features/workers/tests/test_workers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.8 → csrd_utils-0.3.10}/src/csrd_utils/templates/cookiecutter-service/README.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|