devctl 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- devctl/__init__.py +3 -0
- devctl/commands/__init__.py +3 -0
- devctl/commands/add.py +166 -0
- devctl/commands/deploy.py +61 -0
- devctl/commands/docker.py +65 -0
- devctl/commands/init.py +193 -0
- devctl/commands/run.py +67 -0
- devctl/generators/__init__.py +3 -0
- devctl/generators/angular.py +112 -0
- devctl/generators/django.py +61 -0
- devctl/generators/docker_scaffold.py +656 -0
- devctl/generators/fastapi.py +67 -0
- devctl/generators/go_fiber.py +61 -0
- devctl/generators/nestjs.py +49 -0
- devctl/generators/nextjs.py +53 -0
- devctl/generators/nodejs.py +109 -0
- devctl/generators/react.py +43 -0
- devctl/generators/scaffold_angular.py +163 -0
- devctl/generators/scaffold_django.py +79 -0
- devctl/generators/scaffold_fastapi.py +83 -0
- devctl/generators/scaffold_go.py +67 -0
- devctl/generators/scaffold_nestjs.py +52 -0
- devctl/generators/scaffold_nextjs.py +73 -0
- devctl/generators/scaffold_nodejs.py +81 -0
- devctl/generators/scaffold_react.py +80 -0
- devctl/generators/scaffold_spring.py +166 -0
- devctl/generators/scaffold_svelte.py +73 -0
- devctl/generators/scaffold_vue.py +111 -0
- devctl/generators/spring.py +221 -0
- devctl/generators/svelte.py +52 -0
- devctl/generators/vue.py +105 -0
- devctl/main.py +45 -0
- devctl/orchestrator/__init__.py +3 -0
- devctl/orchestrator/config_builder.py +64 -0
- devctl/orchestrator/runner.py +219 -0
- devctl/orchestrator/scanner.py +155 -0
- devctl/templates/angular/config/environment.development.ts.j2 +4 -0
- devctl/templates/angular/config/environment.ts.j2 +5 -0
- devctl/templates/angular/config/proxy.conf.json.j2 +8 -0
- devctl/templates/angular/feature/models/request.model.ts.j2 +5 -0
- devctl/templates/angular/feature/models/response.model.ts.j2 +6 -0
- devctl/templates/angular/feature/pages/form/form.component.html.j2 +21 -0
- devctl/templates/angular/feature/pages/form/form.component.scss.j2 +0 -0
- devctl/templates/angular/feature/pages/form/form.component.ts.j2 +63 -0
- devctl/templates/angular/feature/pages/list/list.component.html.j2 +28 -0
- devctl/templates/angular/feature/pages/list/list.component.scss.j2 +0 -0
- devctl/templates/angular/feature/pages/list/list.component.ts.j2 +34 -0
- devctl/templates/angular/feature/routes.ts.j2 +9 -0
- devctl/templates/angular/feature/services/service.ts.j2 +34 -0
- devctl/templates/docker/deploy.yml.j2 +37 -0
- devctl/templates/docker/django/Dockerfile.j2 +21 -0
- devctl/templates/docker/fastapi/Dockerfile.j2 +15 -0
- devctl/templates/docker/frontend/Dockerfile.j2 +31 -0
- devctl/templates/docker/go/Dockerfile.j2 +24 -0
- devctl/templates/docker/nestjs/Dockerfile.j2 +26 -0
- devctl/templates/docker/nextjs/Dockerfile.j2 +43 -0
- devctl/templates/docker/nodejs/Dockerfile.j2 +24 -0
- devctl/templates/docker/spring/Dockerfile.j2 +24 -0
- devctl/templates/docker/svelte/Dockerfile.j2 +24 -0
- devctl/templates/proxy.conf.json.j2 +0 -0
- devctl/templates/spring/Controller.java.j2 +50 -0
- devctl/templates/spring/Entity.java.j2 +22 -0
- devctl/templates/spring/Repository.java.j2 +9 -0
- devctl/templates/spring/Service.java.j2 +20 -0
- devctl/templates/spring/ServiceImpl.java.j2 +62 -0
- devctl/templates/spring/application.properties.j2 +19 -0
- devctl/templates/spring/config/ApplicationConfig.java.j2 +46 -0
- devctl/templates/spring/config/JwtAuthenticationFilter.java.j2 +54 -0
- devctl/templates/spring/config/JwtService.java.j2 +68 -0
- devctl/templates/spring/config/SecurityConfig.java.j2 +42 -0
- devctl/templates/spring/docker-compose.yml.j2 +29 -0
- devctl/templates/spring/dto/Request.java.j2 +20 -0
- devctl/templates/spring/dto/Response.java.j2 +22 -0
- devctl/templates/spring/mapper/Mapper.java.j2 +30 -0
- devctl/templates/vue/config/App.vue.j2 +35 -0
- devctl/templates/vue/config/main.ts.j2 +9 -0
- devctl/templates/vue/config/router.ts.j2 +18 -0
- devctl/templates/vue/config/vite.config.ts.j2 +16 -0
- devctl/templates/vue/feature/Form.vue.j2 +162 -0
- devctl/templates/vue/feature/List.vue.j2 +155 -0
- devctl/templates/vue/feature/models.ts.j2 +12 -0
- devctl/templates/vue/feature/routes.ts.j2 +19 -0
- devctl/templates/vue/feature/service.ts.j2 +44 -0
- devctl/utils/__init__.py +3 -0
- devctl/utils/dependencies.py +36 -0
- devctl/utils/env_loader.py +57 -0
- devctl-1.0.0.dist-info/METADATA +127 -0
- devctl-1.0.0.dist-info/RECORD +92 -0
- devctl-1.0.0.dist-info/WHEEL +5 -0
- devctl-1.0.0.dist-info/entry_points.txt +2 -0
- devctl-1.0.0.dist-info/licenses/LICENSE +21 -0
- devctl-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
"""Dockerfile scaffolding for supported devctl projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import xml.etree.ElementTree as ET
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Dict, Optional, Union
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
from jinja2 import Environment, FileSystemLoader
|
|
15
|
+
|
|
16
|
+
IGNORED_DIRECTORIES = {
|
|
17
|
+
".angular",
|
|
18
|
+
".git",
|
|
19
|
+
".mvn",
|
|
20
|
+
".next",
|
|
21
|
+
".pytest_cache",
|
|
22
|
+
".venv",
|
|
23
|
+
"__pycache__",
|
|
24
|
+
"build",
|
|
25
|
+
"coverage",
|
|
26
|
+
"dist",
|
|
27
|
+
"node_modules",
|
|
28
|
+
"target",
|
|
29
|
+
"venv",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class DockerScaffoldError(Exception):
|
|
34
|
+
"""Raised when Dockerfile scaffolding cannot be completed."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class DockerProject:
|
|
39
|
+
"""A project that can receive a generated Dockerfile."""
|
|
40
|
+
|
|
41
|
+
kind: str
|
|
42
|
+
path: Path
|
|
43
|
+
name: str
|
|
44
|
+
service_name: str
|
|
45
|
+
relative_context: str
|
|
46
|
+
java_version: Optional[str] = None
|
|
47
|
+
node_version: Optional[str] = None
|
|
48
|
+
angular_output_name: Optional[str] = None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass(frozen=True)
|
|
52
|
+
class FileOperation:
|
|
53
|
+
"""A single file operation performed or planned by the scaffold command."""
|
|
54
|
+
|
|
55
|
+
path: Path
|
|
56
|
+
action: str
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class DockerScaffoldResult:
|
|
61
|
+
"""Summary returned after Dockerfile scaffolding."""
|
|
62
|
+
|
|
63
|
+
root_path: Path
|
|
64
|
+
services: list[DockerProject]
|
|
65
|
+
operations: list[FileOperation]
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def created_count(self) -> int:
|
|
69
|
+
return sum(1 for operation in self.operations if operation.action == "created")
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def skipped_count(self) -> int:
|
|
73
|
+
return sum(1 for operation in self.operations if operation.action == "skipped")
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def planned_count(self) -> int:
|
|
77
|
+
return sum(1 for operation in self.operations if operation.action.startswith("would_"))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def sanitize_service_name(raw_name: str, fallback: str = "service") -> str:
|
|
81
|
+
"""Return a lowercase Docker-friendly service name."""
|
|
82
|
+
service_name = re.sub(r"[^a-z0-9-]+", "-", raw_name.lower()).strip("-")
|
|
83
|
+
service_name = re.sub(r"-{2,}", "-", service_name)
|
|
84
|
+
return service_name or fallback
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def discover_docker_projects(root_path: Union[str, Path]) -> list[DockerProject]:
|
|
88
|
+
"""Discover all supported projects under ``root_path``."""
|
|
89
|
+
root = Path(root_path).resolve()
|
|
90
|
+
if not root.exists():
|
|
91
|
+
raise DockerScaffoldError(f"Path does not exist: {root}")
|
|
92
|
+
if not root.is_dir():
|
|
93
|
+
raise DockerScaffoldError(f"Path is not a directory: {root}")
|
|
94
|
+
|
|
95
|
+
candidates: list[tuple[str, Path]] = []
|
|
96
|
+
|
|
97
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
98
|
+
dirnames[:] = [name for name in dirnames if name not in IGNORED_DIRECTORIES]
|
|
99
|
+
|
|
100
|
+
project_path = Path(dirpath)
|
|
101
|
+
filename_set = set(filenames)
|
|
102
|
+
|
|
103
|
+
if "pom.xml" in filename_set:
|
|
104
|
+
candidates.append(("spring", project_path))
|
|
105
|
+
if "angular.json" in filename_set:
|
|
106
|
+
candidates.append(("angular", project_path))
|
|
107
|
+
|
|
108
|
+
has_vite_config = {"vite.config.ts", "vite.config.js"} & filename_set
|
|
109
|
+
if has_vite_config and "angular.json" not in filename_set:
|
|
110
|
+
# Check package.json to distinguish between vue and react
|
|
111
|
+
pkg_path = project_path / "package.json"
|
|
112
|
+
if pkg_path.exists():
|
|
113
|
+
try:
|
|
114
|
+
pkg = json.loads(pkg_path.read_text(encoding="utf-8"))
|
|
115
|
+
deps = pkg.get("dependencies", {})
|
|
116
|
+
dev_deps = pkg.get("devDependencies", {})
|
|
117
|
+
all_deps = {**deps, **dev_deps}
|
|
118
|
+
|
|
119
|
+
if "vue" in all_deps:
|
|
120
|
+
candidates.append(("vue", project_path))
|
|
121
|
+
elif "react" in all_deps:
|
|
122
|
+
candidates.append(("react", project_path))
|
|
123
|
+
else:
|
|
124
|
+
candidates.append(("vue", project_path)) # Fallback to vue
|
|
125
|
+
except Exception:
|
|
126
|
+
candidates.append(("vue", project_path))
|
|
127
|
+
else:
|
|
128
|
+
candidates.append(("vue", project_path))
|
|
129
|
+
|
|
130
|
+
if "nest-cli.json" in filename_set:
|
|
131
|
+
candidates.append(("nest", project_path))
|
|
132
|
+
|
|
133
|
+
if any(f.startswith("next.config.") for f in filename_set):
|
|
134
|
+
candidates.append(("nextjs", project_path))
|
|
135
|
+
|
|
136
|
+
if "svelte.config.js" in filename_set:
|
|
137
|
+
candidates.append(("svelte", project_path))
|
|
138
|
+
|
|
139
|
+
if "main.py" in filename_set and "requirements.txt" in filename_set:
|
|
140
|
+
try:
|
|
141
|
+
reqs = (project_path / "requirements.txt").read_text(encoding="utf-8")
|
|
142
|
+
if "fastapi" in reqs.lower():
|
|
143
|
+
candidates.append(("fastapi", project_path))
|
|
144
|
+
elif "django" in reqs.lower():
|
|
145
|
+
candidates.append(("django", project_path))
|
|
146
|
+
except (OSError, UnicodeDecodeError):
|
|
147
|
+
# Best-effort discovery: unreadable requirements files should not stop scanning.
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
if "manage.py" in filename_set and "requirements.txt" in filename_set:
|
|
151
|
+
try:
|
|
152
|
+
reqs = (project_path / "requirements.txt").read_text(encoding="utf-8")
|
|
153
|
+
if "django" in reqs.lower():
|
|
154
|
+
candidates.append(("django", project_path))
|
|
155
|
+
except (OSError, UnicodeDecodeError):
|
|
156
|
+
# Best-effort discovery: ignore unreadable/invalid requirements.txt here.
|
|
157
|
+
pass
|
|
158
|
+
|
|
159
|
+
if "go.mod" in filename_set:
|
|
160
|
+
candidates.append(("go", project_path))
|
|
161
|
+
|
|
162
|
+
if "package.json" in filename_set and not any(
|
|
163
|
+
k in ["angular", "vue", "react", "nest", "nextjs", "svelte"]
|
|
164
|
+
for k, p in candidates
|
|
165
|
+
if p == project_path
|
|
166
|
+
):
|
|
167
|
+
candidates.append(("nodejs", project_path))
|
|
168
|
+
|
|
169
|
+
used_names: set[str] = set()
|
|
170
|
+
projects: list[DockerProject] = []
|
|
171
|
+
|
|
172
|
+
for kind, project_path in sorted(candidates, key=lambda item: (str(item[1]), item[0])):
|
|
173
|
+
name = _project_name(kind, project_path)
|
|
174
|
+
service_name = _unique_service_name(name, used_names, fallback=kind)
|
|
175
|
+
used_names.add(service_name)
|
|
176
|
+
|
|
177
|
+
projects.append(
|
|
178
|
+
DockerProject(
|
|
179
|
+
kind=kind,
|
|
180
|
+
path=project_path,
|
|
181
|
+
name=name,
|
|
182
|
+
service_name=service_name,
|
|
183
|
+
relative_context=_relative_context(root, project_path),
|
|
184
|
+
java_version=_spring_java_version(project_path) if kind == "spring" else None,
|
|
185
|
+
node_version=(
|
|
186
|
+
_node_version(project_path, kind)
|
|
187
|
+
if kind in {"angular", "vue", "react", "nest", "nodejs", "nextjs", "svelte"}
|
|
188
|
+
else None
|
|
189
|
+
),
|
|
190
|
+
angular_output_name=(
|
|
191
|
+
_angular_output_name(project_path) if kind == "angular" else None
|
|
192
|
+
),
|
|
193
|
+
)
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
return projects
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def scaffold_docker_assets(
|
|
200
|
+
root_path: Union[str, Path] = ".",
|
|
201
|
+
*,
|
|
202
|
+
force: bool = False,
|
|
203
|
+
dry_run: bool = False,
|
|
204
|
+
) -> DockerScaffoldResult:
|
|
205
|
+
"""Generate Dockerfiles and docker-compose-prod.yml for all supported projects in a tree."""
|
|
206
|
+
root = Path(root_path).resolve()
|
|
207
|
+
projects = discover_docker_projects(root)
|
|
208
|
+
if not projects:
|
|
209
|
+
raise DockerScaffoldError("No supported project detected.")
|
|
210
|
+
|
|
211
|
+
env = _template_environment()
|
|
212
|
+
operations = [
|
|
213
|
+
_write_file(
|
|
214
|
+
project.path / "Dockerfile",
|
|
215
|
+
_dockerfile_content(env, project),
|
|
216
|
+
force=force,
|
|
217
|
+
dry_run=dry_run,
|
|
218
|
+
)
|
|
219
|
+
for project in projects
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
# Also scaffold the global docker-compose-prod.yml
|
|
223
|
+
compose_path = root / "docker-compose-prod.yml"
|
|
224
|
+
compose_content = _generate_compose_content(projects)
|
|
225
|
+
operations.append(
|
|
226
|
+
_write_file(
|
|
227
|
+
compose_path,
|
|
228
|
+
compose_content,
|
|
229
|
+
force=force,
|
|
230
|
+
dry_run=dry_run,
|
|
231
|
+
)
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
return DockerScaffoldResult(root_path=root, services=projects, operations=operations)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _generate_compose_content(projects: list[DockerProject]) -> str:
|
|
238
|
+
services_data = []
|
|
239
|
+
databases = []
|
|
240
|
+
seen_db_names = set()
|
|
241
|
+
|
|
242
|
+
for project in projects:
|
|
243
|
+
service_dict = {
|
|
244
|
+
"service_name": project.service_name,
|
|
245
|
+
"kind": project.kind,
|
|
246
|
+
"relative_context": project.relative_context,
|
|
247
|
+
"db": None,
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
if project.kind == "spring":
|
|
251
|
+
db_info = extract_db_info(project.path)
|
|
252
|
+
if db_info:
|
|
253
|
+
service_dict["db"] = db_info
|
|
254
|
+
if db_info["service_name"] not in seen_db_names:
|
|
255
|
+
databases.append(db_info)
|
|
256
|
+
seen_db_names.add(db_info["service_name"])
|
|
257
|
+
|
|
258
|
+
services_data.append(service_dict)
|
|
259
|
+
|
|
260
|
+
template_dir = Path(__file__).resolve().parent.parent / "templates" / "docker"
|
|
261
|
+
env = Environment(
|
|
262
|
+
loader=FileSystemLoader(str(template_dir)),
|
|
263
|
+
trim_blocks=True,
|
|
264
|
+
lstrip_blocks=True,
|
|
265
|
+
)
|
|
266
|
+
template = env.get_template("deploy.yml.j2")
|
|
267
|
+
return template.render(services=services_data, databases=databases)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def extract_db_info(project_path: Path) -> Optional[Dict[str, Any]]:
|
|
271
|
+
"""
|
|
272
|
+
Extract database information from a Spring Boot project's application.properties.
|
|
273
|
+
"""
|
|
274
|
+
props_path = project_path / "src" / "main" / "resources" / "application.properties"
|
|
275
|
+
if not props_path.exists():
|
|
276
|
+
# Fallback to checking for docker-compose-db.yml in the same dir
|
|
277
|
+
return extract_db_from_compose(project_path / "docker-compose-db.yml")
|
|
278
|
+
|
|
279
|
+
content = props_path.read_text(encoding="utf-8", errors="ignore")
|
|
280
|
+
|
|
281
|
+
# spring.datasource.url=jdbc:postgresql://localhost:5432/sample_api_db
|
|
282
|
+
url_match = re.search(r"spring\.datasource\.url=jdbc:([^:]+)://[^:]+:(\d+)/([\w-]+)", content)
|
|
283
|
+
# spring.data.mongodb.uri=mongodb://admin:password@localhost:27017/db_name
|
|
284
|
+
mongo_match = re.search(
|
|
285
|
+
r"spring\.data\.mongodb\.uri=mongodb://([^:]+):([^@]+)@[^:]+:(\d+)/([\w-]+)", content
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
user_match = re.search(r"spring\.datasource\.username=([\w-]+)", content)
|
|
289
|
+
pass_match = re.search(r"spring\.datasource\.password=([\w-]+)", content)
|
|
290
|
+
|
|
291
|
+
if not url_match and not mongo_match:
|
|
292
|
+
return extract_db_from_compose(project_path / "docker-compose-db.yml")
|
|
293
|
+
|
|
294
|
+
if mongo_match:
|
|
295
|
+
db_type = "mongodb"
|
|
296
|
+
db_user = mongo_match.group(1)
|
|
297
|
+
db_pass = mongo_match.group(2)
|
|
298
|
+
db_port = mongo_match.group(3)
|
|
299
|
+
db_name = mongo_match.group(4)
|
|
300
|
+
else:
|
|
301
|
+
db_type_raw = url_match.group(1)
|
|
302
|
+
db_type = "postgresql" if "postgres" in db_type_raw else "mysql"
|
|
303
|
+
db_port = url_match.group(2)
|
|
304
|
+
db_name = url_match.group(3)
|
|
305
|
+
db_user = user_match.group(1) if user_match else "admin"
|
|
306
|
+
db_pass = pass_match.group(1) if pass_match else "password"
|
|
307
|
+
|
|
308
|
+
db_dict = _build_db_dict(db_type, db_port, db_name, db_user, db_pass)
|
|
309
|
+
|
|
310
|
+
# Try to refine service name from existing docker-compose if possible
|
|
311
|
+
compose_path = project_path / "docker-compose-db.yml"
|
|
312
|
+
if compose_path.exists():
|
|
313
|
+
try:
|
|
314
|
+
with open(compose_path, "r", encoding="utf-8") as f:
|
|
315
|
+
config = yaml.safe_load(f)
|
|
316
|
+
if config and "services" in config:
|
|
317
|
+
# Find the first service that looks like a database
|
|
318
|
+
for s_name, s_cfg in config["services"].items():
|
|
319
|
+
image = str(s_cfg.get("image", ""))
|
|
320
|
+
if db_type == "postgresql" and "postgres" in image:
|
|
321
|
+
db_dict["service_name"] = s_name
|
|
322
|
+
break
|
|
323
|
+
if db_type == "mysql" and "mysql" in image:
|
|
324
|
+
db_dict["service_name"] = s_name
|
|
325
|
+
break
|
|
326
|
+
if db_type == "mongodb" and "mongo" in image:
|
|
327
|
+
db_dict["service_name"] = s_name
|
|
328
|
+
break
|
|
329
|
+
except (OSError, yaml.YAMLError):
|
|
330
|
+
pass
|
|
331
|
+
|
|
332
|
+
return db_dict
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def extract_db_from_compose(compose_path: Path) -> Optional[Dict[str, Any]]:
|
|
336
|
+
"""
|
|
337
|
+
Extract database information from a docker-compose-db.yml file using PyYAML.
|
|
338
|
+
"""
|
|
339
|
+
if not compose_path.exists():
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
try:
|
|
343
|
+
with open(compose_path, "r", encoding="utf-8") as f:
|
|
344
|
+
config = yaml.safe_load(f)
|
|
345
|
+
except Exception:
|
|
346
|
+
return None
|
|
347
|
+
|
|
348
|
+
if not config or "services" not in config:
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
for service_name, service_cfg in config["services"].items():
|
|
352
|
+
image = str(service_cfg.get("image", ""))
|
|
353
|
+
if "postgres" in image or "mysql" in image or "mongo" in image:
|
|
354
|
+
if "postgres" in image:
|
|
355
|
+
db_type = "postgresql"
|
|
356
|
+
elif "mysql" in image:
|
|
357
|
+
db_type = "mysql"
|
|
358
|
+
else:
|
|
359
|
+
db_type = "mongodb"
|
|
360
|
+
|
|
361
|
+
env = service_cfg.get("environment", {})
|
|
362
|
+
env_dict = {}
|
|
363
|
+
if isinstance(env, list):
|
|
364
|
+
for item in env:
|
|
365
|
+
if "=" in item:
|
|
366
|
+
k, v = item.split("=", 1)
|
|
367
|
+
env_dict[k] = v
|
|
368
|
+
elif ":" in item:
|
|
369
|
+
k, v = item.split(":", 1)
|
|
370
|
+
env_dict[k] = v.strip()
|
|
371
|
+
elif isinstance(env, dict):
|
|
372
|
+
env_dict = env
|
|
373
|
+
|
|
374
|
+
if db_type == "postgresql":
|
|
375
|
+
user = env_dict.get("POSTGRES_USER", "admin")
|
|
376
|
+
password = env_dict.get("POSTGRES_PASSWORD", "password")
|
|
377
|
+
db_name = env_dict.get("POSTGRES_DB", "db")
|
|
378
|
+
elif db_type == "mysql":
|
|
379
|
+
user = env_dict.get("MYSQL_USER", env_dict.get("MYSQL_ROOT_PASSWORD", "admin"))
|
|
380
|
+
password = env_dict.get(
|
|
381
|
+
"MYSQL_PASSWORD", env_dict.get("MYSQL_ROOT_PASSWORD", "password")
|
|
382
|
+
)
|
|
383
|
+
db_name = env_dict.get("MYSQL_DATABASE", "db")
|
|
384
|
+
else:
|
|
385
|
+
user = env_dict.get("MONGO_INITDB_ROOT_USERNAME", "admin")
|
|
386
|
+
password = env_dict.get("MONGO_INITDB_ROOT_PASSWORD", "password")
|
|
387
|
+
db_name = env_dict.get("MONGO_INITDB_DATABASE", "db")
|
|
388
|
+
|
|
389
|
+
ports = service_cfg.get("ports", [])
|
|
390
|
+
host_port = None
|
|
391
|
+
if ports and isinstance(ports, list):
|
|
392
|
+
first_port = str(ports[0])
|
|
393
|
+
if ":" in first_port:
|
|
394
|
+
host_port = first_port.split(":")[0].strip("'").strip('"')
|
|
395
|
+
|
|
396
|
+
db_dict = _build_db_dict(
|
|
397
|
+
db_type,
|
|
398
|
+
host_port
|
|
399
|
+
or (
|
|
400
|
+
"5432"
|
|
401
|
+
if db_type == "postgresql"
|
|
402
|
+
else ("3306" if db_type == "mysql" else "27017")
|
|
403
|
+
),
|
|
404
|
+
db_name,
|
|
405
|
+
user,
|
|
406
|
+
password,
|
|
407
|
+
)
|
|
408
|
+
db_dict["service_name"] = service_name
|
|
409
|
+
return db_dict
|
|
410
|
+
|
|
411
|
+
return None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _build_db_dict(db_type: str, port: str, name: str, user: str, password: str) -> Dict[str, Any]:
|
|
415
|
+
is_postgres = db_type == "postgresql"
|
|
416
|
+
is_mysql = db_type == "mysql"
|
|
417
|
+
|
|
418
|
+
if is_postgres:
|
|
419
|
+
internal_port = "5432"
|
|
420
|
+
image = "postgres:15-alpine"
|
|
421
|
+
vol_path = "/var/lib/postgresql/data"
|
|
422
|
+
elif is_mysql:
|
|
423
|
+
internal_port = "3306"
|
|
424
|
+
image = "mysql:8.0"
|
|
425
|
+
vol_path = "/var/lib/mysql/data"
|
|
426
|
+
else:
|
|
427
|
+
internal_port = "27017"
|
|
428
|
+
image = "mongo:6.0"
|
|
429
|
+
vol_path = "/data/db"
|
|
430
|
+
|
|
431
|
+
env = {}
|
|
432
|
+
if is_postgres:
|
|
433
|
+
env = {
|
|
434
|
+
"POSTGRES_USER": user,
|
|
435
|
+
"POSTGRES_PASSWORD": password,
|
|
436
|
+
"POSTGRES_DB": name,
|
|
437
|
+
}
|
|
438
|
+
elif is_mysql:
|
|
439
|
+
env = {
|
|
440
|
+
"MYSQL_ROOT_PASSWORD": password,
|
|
441
|
+
"MYSQL_DATABASE": name,
|
|
442
|
+
"MYSQL_USER": user,
|
|
443
|
+
"MYSQL_PASSWORD": password,
|
|
444
|
+
}
|
|
445
|
+
else:
|
|
446
|
+
env = {
|
|
447
|
+
"MONGO_INITDB_ROOT_USERNAME": user,
|
|
448
|
+
"MONGO_INITDB_ROOT_PASSWORD": password,
|
|
449
|
+
"MONGO_INITDB_DATABASE": name,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
return {
|
|
453
|
+
"type": db_type,
|
|
454
|
+
"port": port,
|
|
455
|
+
"internal_port": internal_port,
|
|
456
|
+
"name": name,
|
|
457
|
+
"user": user,
|
|
458
|
+
"password": password,
|
|
459
|
+
"service_name": f"{name}-db",
|
|
460
|
+
"image": image,
|
|
461
|
+
"volume_name": f"{name}_data",
|
|
462
|
+
"volume_path": vol_path,
|
|
463
|
+
"env": env,
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
def _dockerfile_content(env: Environment, project: DockerProject) -> str:
|
|
468
|
+
if project.kind == "spring":
|
|
469
|
+
return env.get_template("spring/Dockerfile.j2").render(project=project)
|
|
470
|
+
if project.kind == "nest":
|
|
471
|
+
return env.get_template("nestjs/Dockerfile.j2").render(project=project)
|
|
472
|
+
if project.kind == "nodejs":
|
|
473
|
+
return env.get_template("nodejs/Dockerfile.j2").render(project=project)
|
|
474
|
+
if project.kind == "nextjs":
|
|
475
|
+
return env.get_template("nextjs/Dockerfile.j2").render(project=project)
|
|
476
|
+
if project.kind == "fastapi":
|
|
477
|
+
return env.get_template("fastapi/Dockerfile.j2").render(project=project)
|
|
478
|
+
if project.kind == "django":
|
|
479
|
+
return env.get_template("django/Dockerfile.j2").render(project=project)
|
|
480
|
+
if project.kind == "svelte":
|
|
481
|
+
return env.get_template("svelte/Dockerfile.j2").render(project=project)
|
|
482
|
+
if project.kind == "go":
|
|
483
|
+
return env.get_template("go/Dockerfile.j2").render(project=project)
|
|
484
|
+
return env.get_template("frontend/Dockerfile.j2").render(project=project)
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _write_file(path: Path, content: str, *, force: bool, dry_run: bool) -> FileOperation:
|
|
488
|
+
content = content.rstrip() + "\n"
|
|
489
|
+
exists = path.exists()
|
|
490
|
+
|
|
491
|
+
if exists and not force:
|
|
492
|
+
return FileOperation(path=path, action="skipped")
|
|
493
|
+
|
|
494
|
+
if dry_run:
|
|
495
|
+
action = "would_overwrite" if exists else "would_create"
|
|
496
|
+
return FileOperation(path=path, action=action)
|
|
497
|
+
|
|
498
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
499
|
+
path.write_text(content, encoding="utf-8")
|
|
500
|
+
return FileOperation(path=path, action="overwritten" if exists else "created")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _template_environment() -> Environment:
|
|
504
|
+
template_dir = Path(__file__).resolve().parent.parent / "templates" / "docker"
|
|
505
|
+
return Environment(
|
|
506
|
+
loader=FileSystemLoader(str(template_dir)),
|
|
507
|
+
trim_blocks=True,
|
|
508
|
+
lstrip_blocks=True,
|
|
509
|
+
keep_trailing_newline=True,
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _project_name(kind: str, project_path: Path) -> str:
|
|
514
|
+
if kind == "spring":
|
|
515
|
+
return _spring_artifact_id(project_path) or project_path.name
|
|
516
|
+
if kind == "angular":
|
|
517
|
+
return (
|
|
518
|
+
_angular_output_name(project_path) or _package_name(project_path) or project_path.name
|
|
519
|
+
)
|
|
520
|
+
return _package_name(project_path) or project_path.name
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _unique_service_name(raw_name: str, used_names: set[str], fallback: str) -> str:
|
|
524
|
+
base_name = sanitize_service_name(raw_name, fallback=fallback)
|
|
525
|
+
if base_name not in used_names:
|
|
526
|
+
return base_name
|
|
527
|
+
|
|
528
|
+
index = 2
|
|
529
|
+
while f"{base_name}-{index}" in used_names:
|
|
530
|
+
index += 1
|
|
531
|
+
return f"{base_name}-{index}"
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _relative_context(root: Path, project_path: Path) -> str:
|
|
535
|
+
try:
|
|
536
|
+
relative = project_path.relative_to(root)
|
|
537
|
+
except ValueError:
|
|
538
|
+
relative = Path(os.path.relpath(project_path, root))
|
|
539
|
+
|
|
540
|
+
if str(relative) == ".":
|
|
541
|
+
return "."
|
|
542
|
+
return f"./{relative.as_posix()}"
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _spring_artifact_id(project_path: Path) -> Optional[str]:
|
|
546
|
+
pom_path = project_path / "pom.xml"
|
|
547
|
+
if not pom_path.exists():
|
|
548
|
+
return None
|
|
549
|
+
|
|
550
|
+
try:
|
|
551
|
+
root = ET.parse(pom_path).getroot()
|
|
552
|
+
except ET.ParseError:
|
|
553
|
+
return None
|
|
554
|
+
|
|
555
|
+
for child in root:
|
|
556
|
+
if _local_name(child.tag) == "artifactId" and child.text:
|
|
557
|
+
return child.text.strip()
|
|
558
|
+
return None
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _spring_java_version(project_path: Path) -> str:
|
|
562
|
+
pom_path = project_path / "pom.xml"
|
|
563
|
+
if not pom_path.exists():
|
|
564
|
+
return "17"
|
|
565
|
+
|
|
566
|
+
content = pom_path.read_text(encoding="utf-8", errors="ignore")
|
|
567
|
+
match = re.search(r"<java\.version>\s*([^<\s]+)\s*</java\.version>", content)
|
|
568
|
+
if match:
|
|
569
|
+
version = match.group(1)
|
|
570
|
+
major = re.match(r"\d+", version)
|
|
571
|
+
if major:
|
|
572
|
+
return major.group(0)
|
|
573
|
+
return "17"
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _angular_output_name(project_path: Path) -> Optional[str]:
|
|
577
|
+
angular_json = project_path / "angular.json"
|
|
578
|
+
if not angular_json.exists():
|
|
579
|
+
return None
|
|
580
|
+
|
|
581
|
+
try:
|
|
582
|
+
config = json.loads(angular_json.read_text(encoding="utf-8"))
|
|
583
|
+
projects = config.get("projects", {})
|
|
584
|
+
except (json.JSONDecodeError, OSError):
|
|
585
|
+
return None
|
|
586
|
+
|
|
587
|
+
if isinstance(projects, dict) and projects:
|
|
588
|
+
return str(next(iter(projects.keys())))
|
|
589
|
+
return None
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def _package_name(project_path: Path) -> Optional[str]:
|
|
593
|
+
package_json = _read_package_json(project_path)
|
|
594
|
+
name = package_json.get("name")
|
|
595
|
+
return str(name) if name else None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _node_version(project_path: Path, kind: str) -> str:
|
|
599
|
+
package_json = _read_package_json(project_path)
|
|
600
|
+
engines = package_json.get("engines", {})
|
|
601
|
+
node_range = str(engines.get("node", "")) if isinstance(engines, dict) else ""
|
|
602
|
+
|
|
603
|
+
engine_major = _highest_supported_node_major(node_range)
|
|
604
|
+
if engine_major:
|
|
605
|
+
return engine_major
|
|
606
|
+
|
|
607
|
+
if kind == "angular":
|
|
608
|
+
angular_major = _angular_major(package_json)
|
|
609
|
+
if angular_major >= 20:
|
|
610
|
+
return "22"
|
|
611
|
+
if angular_major >= 17:
|
|
612
|
+
return "20"
|
|
613
|
+
return "18"
|
|
614
|
+
|
|
615
|
+
if kind in ["nest", "nodejs", "nextjs", "svelte"]:
|
|
616
|
+
return "20"
|
|
617
|
+
|
|
618
|
+
return "22"
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def _read_package_json(project_path: Path) -> dict[str, Any]:
|
|
622
|
+
package_path = project_path / "package.json"
|
|
623
|
+
if not package_path.exists():
|
|
624
|
+
return {}
|
|
625
|
+
|
|
626
|
+
try:
|
|
627
|
+
content = json.loads(package_path.read_text(encoding="utf-8"))
|
|
628
|
+
except (json.JSONDecodeError, OSError):
|
|
629
|
+
return {}
|
|
630
|
+
|
|
631
|
+
return content if isinstance(content, dict) else {}
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _highest_supported_node_major(node_range: str) -> Optional[str]:
|
|
635
|
+
supported = {18, 20, 22, 24}
|
|
636
|
+
majors = {int(value) for value in re.findall(r"(?<!\.)\b(18|20|22|24)\b", node_range)}
|
|
637
|
+
usable = sorted(majors & supported)
|
|
638
|
+
return str(usable[-1]) if usable else None
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def _angular_major(package_json: dict[str, Any]) -> int:
|
|
642
|
+
sections = ["dependencies", "devDependencies"]
|
|
643
|
+
for section in sections:
|
|
644
|
+
dependencies = package_json.get(section, {})
|
|
645
|
+
if not isinstance(dependencies, dict):
|
|
646
|
+
continue
|
|
647
|
+
version = dependencies.get("@angular/core") or dependencies.get("@angular/cli")
|
|
648
|
+
if version:
|
|
649
|
+
match = re.search(r"(\d+)", str(version))
|
|
650
|
+
if match:
|
|
651
|
+
return int(match.group(1))
|
|
652
|
+
return 20
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def _local_name(tag: str) -> str:
|
|
656
|
+
return tag.rsplit("}", 1)[-1]
|