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.
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/PKG-INFO +10 -3
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/README.md +9 -2
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/pyproject.toml +1 -1
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/__main__.py +150 -32
- csrd_utils-0.3.9/src/csrd_utils/cluster.py +457 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/.gitignore +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/__init__.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/augmentor.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/doctor.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/conftest.fragment.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/docker-compose.fragment.yaml +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/manifest.yaml +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/requirements.fragment +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/settings.fragment.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/src/app/workers/__init__.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/src/app/workers/celery_app.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/src/app/workers/tasks.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/tests/test_workers.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/fragments.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/generator.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/resources.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/README.md +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/cookiecutter.json +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/hooks/post_gen_project.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.dockerignore +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/.env.example +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/Dockerfile +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/README.md +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/docker-compose.yml +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/requirements.txt +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/__init__.py +0 -0
- {csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/templates/cookiecutter-service/{{cookiecutter.service_name}}/tests/conftest.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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="
|
|
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=
|
|
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
|
-
|
|
281
|
-
generated = generate_service(**
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/docker-compose.fragment.yaml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/src/app/workers/__init__.py
RENAMED
|
File without changes
|
{csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/src/app/workers/celery_app.py
RENAMED
|
File without changes
|
{csrd_utils-0.3.7 → csrd_utils-0.3.9}/src/csrd_utils/features/workers/src/app/workers/tasks.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{csrd_utils-0.3.7 → csrd_utils-0.3.9}/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
|