xenfra-sdk 0.2.5__py3-none-any.whl → 0.2.7__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.
- xenfra_sdk/__init__.py +46 -2
- xenfra_sdk/blueprints/base.py +150 -0
- xenfra_sdk/blueprints/factory.py +99 -0
- xenfra_sdk/blueprints/node.py +219 -0
- xenfra_sdk/blueprints/python.py +57 -0
- xenfra_sdk/blueprints/railpack.py +99 -0
- xenfra_sdk/blueprints/schema.py +70 -0
- xenfra_sdk/cli/main.py +175 -49
- xenfra_sdk/client.py +6 -2
- xenfra_sdk/constants.py +26 -0
- xenfra_sdk/db/session.py +8 -3
- xenfra_sdk/detection.py +262 -191
- xenfra_sdk/dockerizer.py +76 -120
- xenfra_sdk/engine.py +767 -172
- xenfra_sdk/events.py +254 -0
- xenfra_sdk/exceptions.py +9 -0
- xenfra_sdk/governance.py +150 -0
- xenfra_sdk/manifest.py +93 -138
- xenfra_sdk/mcp_client.py +7 -5
- xenfra_sdk/{models.py → models/__init__.py} +17 -1
- xenfra_sdk/models/context.py +61 -0
- xenfra_sdk/orchestrator.py +223 -99
- xenfra_sdk/privacy.py +11 -0
- xenfra_sdk/protocol.py +38 -0
- xenfra_sdk/railpack_adapter.py +357 -0
- xenfra_sdk/railpack_detector.py +587 -0
- xenfra_sdk/railpack_manager.py +312 -0
- xenfra_sdk/recipes.py +152 -19
- xenfra_sdk/resources/activity.py +45 -0
- xenfra_sdk/resources/build.py +157 -0
- xenfra_sdk/resources/deployments.py +22 -2
- xenfra_sdk/resources/intelligence.py +25 -0
- xenfra_sdk-0.2.7.dist-info/METADATA +118 -0
- xenfra_sdk-0.2.7.dist-info/RECORD +49 -0
- {xenfra_sdk-0.2.5.dist-info → xenfra_sdk-0.2.7.dist-info}/WHEEL +1 -1
- xenfra_sdk/templates/Caddyfile.j2 +0 -14
- xenfra_sdk/templates/Dockerfile.j2 +0 -41
- xenfra_sdk/templates/cloud-init.sh.j2 +0 -90
- xenfra_sdk/templates/docker-compose-multi.yml.j2 +0 -29
- xenfra_sdk/templates/docker-compose.yml.j2 +0 -30
- xenfra_sdk-0.2.5.dist-info/METADATA +0 -116
- xenfra_sdk-0.2.5.dist-info/RECORD +0 -38
xenfra_sdk/detection.py
CHANGED
|
@@ -16,20 +16,26 @@ class DetectionResult:
|
|
|
16
16
|
tier: str, # "backend", "frontend", "agent"
|
|
17
17
|
framework: str,
|
|
18
18
|
entrypoint: Optional[str] = None,
|
|
19
|
+
command: Optional[str] = None,
|
|
19
20
|
port: int = 8000,
|
|
20
21
|
package_manager: str = "pip",
|
|
21
22
|
dependency_file: str = "requirements.txt",
|
|
22
|
-
missing_deps: List[str] = None
|
|
23
|
+
missing_deps: List[str] = None,
|
|
24
|
+
required_secrets: List[str] = None,
|
|
25
|
+
is_infrastructure: bool = False
|
|
23
26
|
):
|
|
24
27
|
self.name = name
|
|
25
28
|
self.path = path
|
|
26
29
|
self.tier = tier
|
|
27
30
|
self.framework = framework
|
|
28
31
|
self.entrypoint = entrypoint
|
|
32
|
+
self.command = command
|
|
29
33
|
self.port = port
|
|
30
34
|
self.package_manager = package_manager
|
|
31
35
|
self.dependency_file = dependency_file
|
|
32
36
|
self.missing_deps = missing_deps or []
|
|
37
|
+
self.required_secrets = required_secrets or []
|
|
38
|
+
self.is_infrastructure = is_infrastructure
|
|
33
39
|
|
|
34
40
|
def to_dict(self) -> dict:
|
|
35
41
|
return {
|
|
@@ -38,240 +44,296 @@ class DetectionResult:
|
|
|
38
44
|
"tier": self.tier,
|
|
39
45
|
"framework": self.framework,
|
|
40
46
|
"entrypoint": self.entrypoint,
|
|
47
|
+
"command": self.command,
|
|
41
48
|
"port": self.port,
|
|
42
49
|
"package_manager": self.package_manager,
|
|
43
50
|
"dependency_file": self.dependency_file,
|
|
44
51
|
"missing_deps": self.missing_deps,
|
|
52
|
+
"required_secrets": self.required_secrets,
|
|
53
|
+
"is_infrastructure": self.is_infrastructure,
|
|
45
54
|
}
|
|
46
55
|
|
|
47
56
|
|
|
48
|
-
class
|
|
49
|
-
"""
|
|
57
|
+
class DetectionRegistry:
|
|
58
|
+
"""Registry of prioritized probes for service detection."""
|
|
59
|
+
_service_probes: List[tuple] = []
|
|
60
|
+
|
|
61
|
+
# Infrastructure indicators (Ported from discovery.py)
|
|
62
|
+
INFRA_INDICATORS = {
|
|
63
|
+
"kafka": [r"kafka-python", r"confluent-kafka", r"aiokafka"],
|
|
64
|
+
"redis": [r"redis", r"aioredis"],
|
|
65
|
+
"postgres": [r"psycopg2", r"asyncpg", r"sqlalchemy", r"sqlmodel"],
|
|
66
|
+
"mongodb": [r"pymongo", r"motor"],
|
|
67
|
+
}
|
|
50
68
|
|
|
51
|
-
@
|
|
69
|
+
@classmethod
|
|
70
|
+
def infer_infrastructure_from_services(cls, services: List[DetectionResult]) -> List[DetectionResult]:
|
|
71
|
+
"""Scans service dependencies to suggest infrastructure components."""
|
|
72
|
+
found_infra_types = set()
|
|
73
|
+
for svc in services:
|
|
74
|
+
svc_path = Path(svc.path)
|
|
75
|
+
for dep_file in ["requirements.txt", "package.json", "go.mod", "Cargo.toml", "pyproject.toml"]:
|
|
76
|
+
fpath = svc_path / dep_file
|
|
77
|
+
if not fpath.exists(): continue
|
|
78
|
+
content = fpath.read_text(errors="ignore")
|
|
79
|
+
for infra_type, patterns in cls.INFRA_INDICATORS.items():
|
|
80
|
+
for pattern in patterns:
|
|
81
|
+
if re.search(pattern, content, re.IGNORECASE):
|
|
82
|
+
found_infra_types.add(infra_type)
|
|
83
|
+
|
|
84
|
+
infra_results = []
|
|
85
|
+
for itype in found_infra_types:
|
|
86
|
+
infra_results.append(DetectionResult(
|
|
87
|
+
name=f"{itype}-cluster",
|
|
88
|
+
path="",
|
|
89
|
+
tier="infrastructure",
|
|
90
|
+
framework=itype,
|
|
91
|
+
is_infrastructure=True
|
|
92
|
+
))
|
|
93
|
+
return infra_results
|
|
94
|
+
|
|
95
|
+
@classmethod
|
|
96
|
+
def register_service_probe(cls, priority: int):
|
|
97
|
+
"""Decorator to register a detail probe (e.g. Python, Railpack)."""
|
|
98
|
+
def decorator(probe_cls):
|
|
99
|
+
cls._service_probes.append((priority, probe_cls()))
|
|
100
|
+
cls._service_probes.sort(key=lambda x: x[0])
|
|
101
|
+
return probe_cls
|
|
102
|
+
return decorator
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def detect_service_details(cls, service_dir: Path, name: str) -> Optional[DetectionResult]:
|
|
106
|
+
"""Run all registered service detail probes in priority order."""
|
|
107
|
+
for priority, probe in cls._service_probes:
|
|
108
|
+
result = probe.detect(service_dir, name)
|
|
109
|
+
if result:
|
|
110
|
+
return result
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@DetectionRegistry.register_service_probe(priority=0)
|
|
115
|
+
class RailpackProbe:
|
|
116
|
+
"""Uses Railway's Railpack for high-fidelity service detection."""
|
|
52
117
|
def detect(self, service_dir: Path, name: str) -> Optional[DetectionResult]:
|
|
53
|
-
|
|
118
|
+
try:
|
|
119
|
+
from xenfra_sdk.railpack_detector import get_railpack_detector
|
|
120
|
+
detector = get_railpack_detector()
|
|
121
|
+
# Railpack's fallback logic is already decent, but we use it here
|
|
122
|
+
rp_result = detector.detect_from_path(str(service_dir))
|
|
123
|
+
|
|
124
|
+
if not rp_result or rp_result.framework == "unknown":
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
return DetectionResult(
|
|
128
|
+
name=name,
|
|
129
|
+
path=str(service_dir),
|
|
130
|
+
tier="frontend" if rp_result.runtime_name == "node" and rp_result.framework in ["nextjs", "react"] else "backend",
|
|
131
|
+
framework=rp_result.framework,
|
|
132
|
+
command=rp_result.start_command,
|
|
133
|
+
port=rp_result.detected_port or 8000,
|
|
134
|
+
package_manager=rp_result.package_manager or "pip",
|
|
135
|
+
dependency_file=rp_result.detected_from,
|
|
136
|
+
missing_deps=_scan_proactive_dependencies(service_dir)
|
|
137
|
+
)
|
|
138
|
+
except Exception:
|
|
139
|
+
return None
|
|
54
140
|
|
|
55
141
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
142
|
+
@DetectionRegistry.register_service_probe(priority=1)
|
|
143
|
+
class FrameworkProbe:
|
|
144
|
+
"""Static file-based detection for common frameworks."""
|
|
59
145
|
def detect(self, service_dir: Path, name: str) -> Optional[DetectionResult]:
|
|
146
|
+
# Python Check
|
|
60
147
|
has_pyproject = (service_dir / "pyproject.toml").exists()
|
|
61
148
|
has_requirements = (service_dir / "requirements.txt").exists()
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
149
|
+
if has_pyproject or has_requirements:
|
|
150
|
+
return self._detect_python(service_dir, name, has_pyproject)
|
|
151
|
+
|
|
152
|
+
# Node Check
|
|
153
|
+
if (service_dir / "package.json").exists():
|
|
154
|
+
return self._detect_node(service_dir, name)
|
|
155
|
+
|
|
156
|
+
# Go Check
|
|
157
|
+
if (service_dir / "go.mod").exists():
|
|
158
|
+
return self._detect_go(service_dir, name)
|
|
159
|
+
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def _detect_python(self, service_dir, name, has_pyproject):
|
|
66
163
|
content = ""
|
|
67
164
|
if has_pyproject:
|
|
68
165
|
content = (service_dir / "pyproject.toml").read_text(errors="ignore").lower()
|
|
69
|
-
|
|
166
|
+
else:
|
|
70
167
|
content = (service_dir / "requirements.txt").read_text(errors="ignore").lower()
|
|
71
168
|
|
|
72
|
-
# Determine Tier & Framework
|
|
73
169
|
tier = "backend"
|
|
74
|
-
framework = "fastapi"
|
|
75
|
-
|
|
76
|
-
# Check for Agents
|
|
170
|
+
framework = "fastapi"
|
|
77
171
|
if any(lib in content for lib in ["mcp", "langgraph", "crewai", "langchain"]):
|
|
78
172
|
tier = "agent"
|
|
79
173
|
framework = "mcp" if "mcp" in content else "langgraph"
|
|
80
|
-
elif "django" in content
|
|
81
|
-
|
|
82
|
-
elif "flask" in content:
|
|
83
|
-
framework = "flask"
|
|
84
|
-
|
|
85
|
-
entrypoint = _detect_python_entrypoint(service_dir, name)
|
|
86
|
-
|
|
87
|
-
# Proactive Dependency Scanning (Zen Mode Auto-Healing)
|
|
88
|
-
missing_deps = _scan_proactive_dependencies(service_dir)
|
|
174
|
+
elif "django" in content: framework = "django"
|
|
175
|
+
elif "flask" in content: framework = "flask"
|
|
89
176
|
|
|
90
177
|
return DetectionResult(
|
|
91
178
|
name=name,
|
|
92
179
|
path=str(service_dir),
|
|
93
180
|
tier=tier,
|
|
94
181
|
framework=framework,
|
|
95
|
-
entrypoint=
|
|
182
|
+
entrypoint=_detect_python_entrypoint(service_dir, name),
|
|
96
183
|
package_manager="uv" if has_pyproject else "pip",
|
|
97
184
|
dependency_file="pyproject.toml" if has_pyproject else "requirements.txt",
|
|
98
|
-
missing_deps=
|
|
185
|
+
missing_deps=_scan_proactive_dependencies(service_dir)
|
|
99
186
|
)
|
|
100
187
|
|
|
101
|
-
|
|
102
|
-
class NodeProbe(BaseProbe):
|
|
103
|
-
"""Probe for Node.js based frontends and backends."""
|
|
104
|
-
|
|
105
|
-
def detect(self, service_dir: Path, name: str) -> Optional[DetectionResult]:
|
|
106
|
-
package_json = service_dir / "package.json"
|
|
107
|
-
if not package_json.exists():
|
|
108
|
-
return None
|
|
109
|
-
|
|
188
|
+
def _detect_node(self, service_dir, name):
|
|
110
189
|
try:
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
elif any(lib in deps for lib in ["express", "fastify", "nest", "hono"]):
|
|
126
|
-
tier = "backend"
|
|
127
|
-
framework = "express" if "express" in deps else "node-backend"
|
|
128
|
-
elif "@modelcontextprotocol/sdk" in deps:
|
|
129
|
-
tier = "agent"
|
|
130
|
-
framework = "mcp"
|
|
131
|
-
|
|
190
|
+
config = json.loads((service_dir / "package.json").read_text())
|
|
191
|
+
deps = {**config.get("dependencies", {}), **config.get("devDependencies", {})}
|
|
192
|
+
tier = "frontend" if any(l in deps for l in ["next", "react", "vue"]) else "backend"
|
|
193
|
+
framework = "nextjs" if "next" in deps else ("express" if "express" in deps else "node")
|
|
194
|
+
return DetectionResult(
|
|
195
|
+
name=name, path=str(service_dir), tier=tier, framework=framework,
|
|
196
|
+
package_manager="npm", dependency_file="package.json",
|
|
197
|
+
port=3000 if tier == "frontend" else 8000
|
|
198
|
+
)
|
|
199
|
+
except Exception: return None
|
|
200
|
+
|
|
201
|
+
def _detect_go(self, service_dir, name):
|
|
202
|
+
content = (service_dir / "go.mod").read_text(errors="ignore").lower()
|
|
203
|
+
framework = "gin" if "gin-gonic" in content else ("echo" if "echo" in content else "go-std")
|
|
132
204
|
return DetectionResult(
|
|
133
|
-
name=name,
|
|
134
|
-
|
|
135
|
-
tier=tier,
|
|
136
|
-
framework=framework,
|
|
137
|
-
package_manager="npm",
|
|
138
|
-
dependency_file="package.json",
|
|
139
|
-
port=3000 if tier == "frontend" else 8000
|
|
205
|
+
name=name, path=str(service_dir), tier="backend", framework=framework,
|
|
206
|
+
package_manager="go", dependency_file="go.mod"
|
|
140
207
|
)
|
|
141
208
|
|
|
142
209
|
|
|
143
|
-
class
|
|
144
|
-
"""
|
|
210
|
+
class TopLevelRegistry:
|
|
211
|
+
"""Registry of high-level probes (e.g. Docker Compose, Recursive Search)."""
|
|
212
|
+
_top_probes: List[tuple] = []
|
|
145
213
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
214
|
+
@classmethod
|
|
215
|
+
def register(cls, priority: int):
|
|
216
|
+
def decorator(probe_cls):
|
|
217
|
+
cls._top_probes.append((priority, probe_cls()))
|
|
218
|
+
cls._top_probes.sort(key=lambda x: x[0])
|
|
219
|
+
return probe_cls
|
|
220
|
+
return decorator
|
|
221
|
+
|
|
222
|
+
@classmethod
|
|
223
|
+
def auto_detect(cls, project_path: Path) -> List[DetectionResult]:
|
|
224
|
+
"""Execute top-level probes in order and return results from the first one that succeeds."""
|
|
225
|
+
for priority, probe in cls._top_probes:
|
|
226
|
+
results = probe.scan(project_path)
|
|
227
|
+
if results:
|
|
228
|
+
return results
|
|
229
|
+
return []
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@TopLevelRegistry.register(priority=0)
|
|
233
|
+
class DockerComposeProbe:
|
|
234
|
+
"""Detects services from docker-compose.yml files."""
|
|
235
|
+
def scan(self, project_path: Path) -> List[DetectionResult]:
|
|
236
|
+
candidates = ["docker-compose.prod.yml", "docker-compose.prod.yaml", "docker-compose.yml", "docker-compose.yaml"]
|
|
237
|
+
compose_path = next((project_path / c for c in candidates if (project_path / c).exists()), None)
|
|
238
|
+
if not compose_path: return []
|
|
239
|
+
|
|
240
|
+
try:
|
|
241
|
+
with open(compose_path, "r", encoding="utf-8") as f:
|
|
242
|
+
compose = yaml.safe_load(f)
|
|
243
|
+
if not compose or "services" not in compose: return []
|
|
244
|
+
except Exception: return []
|
|
245
|
+
|
|
246
|
+
results = []
|
|
247
|
+
for name, config in compose.get("services", {}).items():
|
|
248
|
+
if _is_infrastructure_service(name, config): continue
|
|
150
249
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
framework = "gin"
|
|
155
|
-
elif "labstack/echo" in content:
|
|
156
|
-
framework = "echo"
|
|
250
|
+
build_config = config.get("build", ".")
|
|
251
|
+
build_path = build_config.get("context", ".") if isinstance(build_config, dict) else str(build_config)
|
|
252
|
+
src_path = (project_path / build_path).resolve()
|
|
157
253
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
254
|
+
# Use detail probes to identify what's inside the build context
|
|
255
|
+
res = DetectionRegistry.detect_service_details(src_path, name)
|
|
256
|
+
if res:
|
|
257
|
+
res.path = build_path # Keep relative path from compose
|
|
258
|
+
res.port = _extract_port(config.get("ports", []), config.get("expose", []), res.port)
|
|
259
|
+
results.append(res)
|
|
260
|
+
else:
|
|
261
|
+
# Fallback for unrecognized services
|
|
262
|
+
results.append(DetectionResult(
|
|
263
|
+
name=name, path=build_path, tier="backend", framework="other",
|
|
264
|
+
port=_extract_port(config.get("ports", []), config.get("expose", []), 8000)
|
|
265
|
+
))
|
|
266
|
+
return results
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
@TopLevelRegistry.register(priority=1)
|
|
270
|
+
class RecursiveScannerProbe:
|
|
271
|
+
"""Consolidated recursive scanner (ports logic from discovery.py)."""
|
|
272
|
+
def scan(self, project_path: Path) -> List[DetectionResult]:
|
|
273
|
+
results = []
|
|
274
|
+
project_root = project_path.resolve()
|
|
177
275
|
|
|
178
|
-
|
|
179
|
-
for
|
|
180
|
-
|
|
181
|
-
if
|
|
182
|
-
|
|
183
|
-
|
|
276
|
+
# Scan standard directories first
|
|
277
|
+
for dir_name in ["services", "apps"]:
|
|
278
|
+
s_dir = project_root / dir_name
|
|
279
|
+
if not s_dir.exists(): continue
|
|
280
|
+
for subdir in [d for d in s_dir.iterdir() if d.is_dir()]:
|
|
281
|
+
if subdir.name.startswith((".", "_")) or subdir.name in {"common", "lib", "assets"}:
|
|
282
|
+
continue
|
|
283
|
+
res = DetectionRegistry.detect_service_details(subdir, subdir.name)
|
|
284
|
+
if res:
|
|
285
|
+
res.path = f"./{dir_name}/{subdir.name}"
|
|
286
|
+
results.append(res)
|
|
287
|
+
|
|
288
|
+
# If still nothing, do a deeper recursive scan (Option: discovery.py logic)
|
|
289
|
+
if not results:
|
|
290
|
+
results = self._deep_scan(project_root, project_root)
|
|
291
|
+
|
|
292
|
+
return results
|
|
184
293
|
|
|
294
|
+
def _deep_scan(self, path: Path, root: Path) -> List[DetectionResult]:
|
|
295
|
+
# Implementation similar to discovery.py but using Detail Probes
|
|
296
|
+
if path.name.startswith(".") or path.name in ["venv", "node_modules", "target", "build", "dist"]:
|
|
297
|
+
return []
|
|
298
|
+
|
|
299
|
+
res = DetectionRegistry.detect_service_details(path, path.name)
|
|
300
|
+
if res and path != root:
|
|
301
|
+
res.path = str(path.relative_to(root))
|
|
302
|
+
return [res]
|
|
303
|
+
|
|
304
|
+
results = []
|
|
305
|
+
if path.is_dir():
|
|
306
|
+
for item in path.iterdir():
|
|
307
|
+
if item.is_dir():
|
|
308
|
+
results.extend(self._deep_scan(item, root))
|
|
309
|
+
return results
|
|
185
310
|
|
|
186
|
-
# --- Singleton for discovery ---
|
|
187
|
-
_registry = DetectionRegistry()
|
|
188
311
|
|
|
312
|
+
def auto_detect_services(project_path: str = ".") -> Optional[List[dict]]:
|
|
313
|
+
"""Try all detection methods in priority order using the Unified Brain."""
|
|
314
|
+
path = Path(project_path)
|
|
315
|
+
results = TopLevelRegistry.auto_detect(path)
|
|
316
|
+
if results:
|
|
317
|
+
infra = DetectionRegistry.infer_infrastructure_from_services(results)
|
|
318
|
+
results.extend(infra)
|
|
319
|
+
return [r.to_dict() for r in results]
|
|
320
|
+
return None
|
|
189
321
|
|
|
190
|
-
def detect_docker_compose_services(project_path: str = ".") -> Optional[List[dict]]:
|
|
191
|
-
"""Parse docker-compose.yml to detect services."""
|
|
192
|
-
compose_path = Path(project_path) / "docker-compose.yml"
|
|
193
|
-
if not compose_path.exists():
|
|
194
|
-
compose_path = Path(project_path) / "docker-compose.yaml"
|
|
195
|
-
|
|
196
|
-
if not compose_path.exists():
|
|
197
|
-
return None
|
|
198
|
-
|
|
199
|
-
try:
|
|
200
|
-
with open(compose_path, "r", encoding="utf-8") as f:
|
|
201
|
-
compose = yaml.safe_load(f)
|
|
202
|
-
except yaml.YAMLError:
|
|
203
|
-
return None
|
|
204
|
-
|
|
205
|
-
if not compose or "services" not in compose:
|
|
206
|
-
return None
|
|
207
|
-
|
|
208
|
-
services = []
|
|
209
|
-
base_port = 8000
|
|
210
|
-
|
|
211
|
-
for name, config in compose.get("services", {}).items():
|
|
212
|
-
if _is_infrastructure_service(name, config):
|
|
213
|
-
continue
|
|
214
|
-
|
|
215
|
-
build_config = config.get("build", ".")
|
|
216
|
-
build_path = build_config.get("context", ".") if isinstance(build_config, dict) else str(build_config)
|
|
217
|
-
|
|
218
|
-
src_path = (Path(project_path) / build_path).resolve()
|
|
219
|
-
|
|
220
|
-
# Use our new registry for deep detection
|
|
221
|
-
result = _registry.detect_all(src_path, name)
|
|
222
|
-
|
|
223
|
-
if result:
|
|
224
|
-
svc_dict = result.to_dict()
|
|
225
|
-
svc_dict["path"] = build_path # Use relative path from compose
|
|
226
|
-
# Override port if explicitly set in compose
|
|
227
|
-
svc_dict["port"] = _extract_port(config.get("ports", []), svc_dict["port"])
|
|
228
|
-
services.append(svc_dict)
|
|
229
|
-
else:
|
|
230
|
-
# Fallback for unrecognized languages
|
|
231
|
-
services.append({
|
|
232
|
-
"name": name,
|
|
233
|
-
"path": build_path,
|
|
234
|
-
"port": _extract_port(config.get("ports", []), base_port + len(services)),
|
|
235
|
-
"framework": "other",
|
|
236
|
-
"tier": "backend"
|
|
237
|
-
})
|
|
238
|
-
|
|
239
|
-
return services if services else None
|
|
240
322
|
|
|
323
|
+
# --- Legacy Wrappers (Backward Compatibility) ---
|
|
241
324
|
|
|
242
|
-
def
|
|
243
|
-
"""
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
services_dir = project_root / "services"
|
|
248
|
-
if not services_dir.exists():
|
|
249
|
-
return None
|
|
250
|
-
|
|
251
|
-
for service_dir in sorted(services_dir.iterdir()):
|
|
252
|
-
if not service_dir.is_dir():
|
|
253
|
-
continue
|
|
254
|
-
|
|
255
|
-
result = _registry.detect_all(service_dir, service_dir.name)
|
|
256
|
-
if result:
|
|
257
|
-
svc_dict = result.to_dict()
|
|
258
|
-
svc_dict["path"] = f"./services/{service_dir.name}"
|
|
259
|
-
services.append(svc_dict)
|
|
260
|
-
|
|
261
|
-
return services if len(services) > 1 else None
|
|
325
|
+
def detect_docker_compose_services(project_path: str = ".") -> List[dict]:
|
|
326
|
+
"""Wrapper for DockerComposeProbe."""
|
|
327
|
+
probe = DockerComposeProbe()
|
|
328
|
+
results = probe.scan(Path(project_path))
|
|
329
|
+
return [r.to_dict() for r in results]
|
|
262
330
|
|
|
263
331
|
|
|
264
|
-
def
|
|
265
|
-
"""
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
services = detect_pyproject_services(project_path)
|
|
271
|
-
if services:
|
|
272
|
-
return services
|
|
273
|
-
|
|
274
|
-
return None
|
|
332
|
+
def detect_pyproject_services(project_path: str = ".") -> List[dict]:
|
|
333
|
+
"""Wrapper for RecursiveScannerProbe."""
|
|
334
|
+
probe = RecursiveScannerProbe()
|
|
335
|
+
results = probe.scan(Path(project_path))
|
|
336
|
+
return [r.to_dict() for r in results]
|
|
275
337
|
|
|
276
338
|
|
|
277
339
|
# --- Helper Functions (Internal) ---
|
|
@@ -284,12 +346,21 @@ def _is_infrastructure_service(name: str, config: dict) -> bool:
|
|
|
284
346
|
return any(infra in image.lower() for infra in infra_names)
|
|
285
347
|
|
|
286
348
|
|
|
287
|
-
def _extract_port(ports: list, default: int) -> int:
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
349
|
+
def _extract_port(ports: list, expose: list, default: int) -> int:
|
|
350
|
+
# 1. Check 'ports' (published ports)
|
|
351
|
+
if ports:
|
|
352
|
+
port_str = str(ports[0])
|
|
353
|
+
if isinstance(ports[0], dict): return int(ports[0].get("published", default))
|
|
354
|
+
match = re.match(r"(\d+):", port_str)
|
|
355
|
+
if match: return int(match.group(1))
|
|
356
|
+
if port_str.isdigit(): return int(port_str)
|
|
357
|
+
|
|
358
|
+
# 2. Check 'expose' (internal only)
|
|
359
|
+
if expose:
|
|
360
|
+
port_str = str(expose[0])
|
|
361
|
+
if port_str.isdigit(): return int(port_str)
|
|
362
|
+
|
|
363
|
+
return default
|
|
293
364
|
|
|
294
365
|
|
|
295
366
|
def _detect_python_entrypoint(service_dir: Path, service_name: str = None) -> Optional[str]:
|