pactown 0.1.4__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.
- pactown/__init__.py +23 -0
- pactown/cli.py +347 -0
- pactown/config.py +158 -0
- pactown/deploy/__init__.py +17 -0
- pactown/deploy/base.py +263 -0
- pactown/deploy/compose.py +359 -0
- pactown/deploy/docker.py +299 -0
- pactown/deploy/kubernetes.py +449 -0
- pactown/deploy/podman.py +400 -0
- pactown/generator.py +212 -0
- pactown/network.py +245 -0
- pactown/orchestrator.py +455 -0
- pactown/parallel.py +268 -0
- pactown/registry/__init__.py +12 -0
- pactown/registry/client.py +253 -0
- pactown/registry/models.py +150 -0
- pactown/registry/server.py +207 -0
- pactown/resolver.py +160 -0
- pactown/sandbox_manager.py +328 -0
- pactown-0.1.4.dist-info/METADATA +308 -0
- pactown-0.1.4.dist-info/RECORD +24 -0
- pactown-0.1.4.dist-info/WHEEL +4 -0
- pactown-0.1.4.dist-info/entry_points.txt +3 -0
- pactown-0.1.4.dist-info/licenses/LICENSE +201 -0
pactown/deploy/podman.py
ADDED
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
"""Podman deployment backend - rootless containers for production."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Optional, Any
|
|
9
|
+
|
|
10
|
+
from .base import (
|
|
11
|
+
DeploymentBackend,
|
|
12
|
+
DeploymentConfig,
|
|
13
|
+
DeploymentResult,
|
|
14
|
+
RuntimeType,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PodmanBackend(DeploymentBackend):
|
|
19
|
+
"""
|
|
20
|
+
Podman container runtime backend.
|
|
21
|
+
|
|
22
|
+
Podman is a daemonless, rootless container engine that is compatible
|
|
23
|
+
with Docker but provides better security for production environments.
|
|
24
|
+
|
|
25
|
+
Key advantages:
|
|
26
|
+
- Rootless by default (no root daemon)
|
|
27
|
+
- No daemon = no single point of failure
|
|
28
|
+
- OCI-compliant
|
|
29
|
+
- Systemd integration for service management
|
|
30
|
+
- Pod support (like Kubernetes pods)
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def runtime_type(self) -> RuntimeType:
|
|
35
|
+
return RuntimeType.PODMAN
|
|
36
|
+
|
|
37
|
+
def is_available(self) -> bool:
|
|
38
|
+
"""Check if Podman is available."""
|
|
39
|
+
try:
|
|
40
|
+
result = subprocess.run(
|
|
41
|
+
["podman", "version", "--format", "{{.Version}}"],
|
|
42
|
+
capture_output=True,
|
|
43
|
+
text=True,
|
|
44
|
+
timeout=5,
|
|
45
|
+
)
|
|
46
|
+
return result.returncode == 0
|
|
47
|
+
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
48
|
+
return False
|
|
49
|
+
|
|
50
|
+
def build_image(
|
|
51
|
+
self,
|
|
52
|
+
service_name: str,
|
|
53
|
+
dockerfile_path: Path,
|
|
54
|
+
context_path: Path,
|
|
55
|
+
tag: Optional[str] = None,
|
|
56
|
+
) -> DeploymentResult:
|
|
57
|
+
"""Build container image with Podman."""
|
|
58
|
+
image_name = f"{self.config.image_prefix}/{service_name}"
|
|
59
|
+
if tag:
|
|
60
|
+
image_name = f"{image_name}:{tag}"
|
|
61
|
+
else:
|
|
62
|
+
image_name = f"{image_name}:latest"
|
|
63
|
+
|
|
64
|
+
cmd = [
|
|
65
|
+
"podman", "build",
|
|
66
|
+
"-t", image_name,
|
|
67
|
+
"-f", str(dockerfile_path),
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
# Add labels
|
|
71
|
+
for key, value in self.config.labels.items():
|
|
72
|
+
cmd.extend(["--label", f"{key}={value}"])
|
|
73
|
+
|
|
74
|
+
# Security options for build
|
|
75
|
+
if self.config.rootless:
|
|
76
|
+
cmd.extend(["--userns", "keep-id"])
|
|
77
|
+
|
|
78
|
+
cmd.append(str(context_path))
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
result = subprocess.run(
|
|
82
|
+
cmd,
|
|
83
|
+
capture_output=True,
|
|
84
|
+
text=True,
|
|
85
|
+
timeout=300,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
if result.returncode == 0:
|
|
89
|
+
return DeploymentResult(
|
|
90
|
+
success=True,
|
|
91
|
+
service_name=service_name,
|
|
92
|
+
runtime=self.runtime_type,
|
|
93
|
+
image_name=image_name,
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
return DeploymentResult(
|
|
97
|
+
success=False,
|
|
98
|
+
service_name=service_name,
|
|
99
|
+
runtime=self.runtime_type,
|
|
100
|
+
error=result.stderr,
|
|
101
|
+
)
|
|
102
|
+
except subprocess.TimeoutExpired:
|
|
103
|
+
return DeploymentResult(
|
|
104
|
+
success=False,
|
|
105
|
+
service_name=service_name,
|
|
106
|
+
runtime=self.runtime_type,
|
|
107
|
+
error="Build timed out",
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
def push_image(
|
|
111
|
+
self,
|
|
112
|
+
image_name: str,
|
|
113
|
+
registry: Optional[str] = None,
|
|
114
|
+
) -> DeploymentResult:
|
|
115
|
+
"""Push image to registry."""
|
|
116
|
+
target = image_name
|
|
117
|
+
if registry:
|
|
118
|
+
target = f"{registry}/{image_name}"
|
|
119
|
+
subprocess.run(
|
|
120
|
+
["podman", "tag", image_name, target],
|
|
121
|
+
capture_output=True,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
try:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
["podman", "push", target],
|
|
127
|
+
capture_output=True,
|
|
128
|
+
text=True,
|
|
129
|
+
timeout=300,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
return DeploymentResult(
|
|
133
|
+
success=result.returncode == 0,
|
|
134
|
+
service_name=image_name.split("/")[-1].split(":")[0],
|
|
135
|
+
runtime=self.runtime_type,
|
|
136
|
+
image_name=target,
|
|
137
|
+
error=result.stderr if result.returncode != 0 else None,
|
|
138
|
+
)
|
|
139
|
+
except subprocess.TimeoutExpired:
|
|
140
|
+
return DeploymentResult(
|
|
141
|
+
success=False,
|
|
142
|
+
service_name=image_name,
|
|
143
|
+
runtime=self.runtime_type,
|
|
144
|
+
error="Push timed out",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
def deploy(
|
|
148
|
+
self,
|
|
149
|
+
service_name: str,
|
|
150
|
+
image_name: str,
|
|
151
|
+
port: int,
|
|
152
|
+
env: dict[str, str],
|
|
153
|
+
health_check: Optional[str] = None,
|
|
154
|
+
) -> DeploymentResult:
|
|
155
|
+
"""Deploy a container with Podman."""
|
|
156
|
+
container_name = f"{self.config.namespace}-{service_name}"
|
|
157
|
+
|
|
158
|
+
# Stop existing container if running
|
|
159
|
+
subprocess.run(
|
|
160
|
+
["podman", "rm", "-f", container_name],
|
|
161
|
+
capture_output=True,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
cmd = [
|
|
165
|
+
"podman", "run",
|
|
166
|
+
"-d",
|
|
167
|
+
"--name", container_name,
|
|
168
|
+
"--network", self.config.network_name,
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
# Rootless mode with user namespace
|
|
172
|
+
if self.config.rootless:
|
|
173
|
+
cmd.extend(["--userns", "keep-id"])
|
|
174
|
+
|
|
175
|
+
# Port mapping
|
|
176
|
+
if self.config.expose_ports:
|
|
177
|
+
cmd.extend(["-p", f"{port}:{port}"])
|
|
178
|
+
|
|
179
|
+
# Environment variables
|
|
180
|
+
for key, value in env.items():
|
|
181
|
+
cmd.extend(["-e", f"{key}={value}"])
|
|
182
|
+
|
|
183
|
+
# Resource limits
|
|
184
|
+
if self.config.memory_limit:
|
|
185
|
+
cmd.extend(["--memory", self.config.memory_limit])
|
|
186
|
+
if self.config.cpu_limit:
|
|
187
|
+
cmd.extend(["--cpus", self.config.cpu_limit])
|
|
188
|
+
|
|
189
|
+
# Security options
|
|
190
|
+
if self.config.read_only_fs:
|
|
191
|
+
cmd.append("--read-only")
|
|
192
|
+
cmd.extend(["--tmpfs", "/tmp:rw,noexec,nosuid"])
|
|
193
|
+
|
|
194
|
+
if self.config.no_new_privileges:
|
|
195
|
+
cmd.append("--security-opt=no-new-privileges:true")
|
|
196
|
+
|
|
197
|
+
# SELinux context for production
|
|
198
|
+
cmd.extend(["--security-opt", "label=type:container_runtime_t"])
|
|
199
|
+
|
|
200
|
+
if self.config.drop_capabilities:
|
|
201
|
+
for cap in self.config.drop_capabilities:
|
|
202
|
+
cmd.extend(["--cap-drop", cap])
|
|
203
|
+
|
|
204
|
+
if self.config.add_capabilities:
|
|
205
|
+
for cap in self.config.add_capabilities:
|
|
206
|
+
cmd.extend(["--cap-add", cap])
|
|
207
|
+
|
|
208
|
+
# Health check
|
|
209
|
+
if health_check:
|
|
210
|
+
cmd.extend([
|
|
211
|
+
"--health-cmd", f"curl -f http://localhost:{port}{health_check} || exit 1",
|
|
212
|
+
"--health-interval", self.config.health_check_interval,
|
|
213
|
+
"--health-timeout", self.config.health_check_timeout,
|
|
214
|
+
"--health-retries", str(self.config.health_check_retries),
|
|
215
|
+
])
|
|
216
|
+
|
|
217
|
+
# Labels
|
|
218
|
+
for key, value in self.config.labels.items():
|
|
219
|
+
cmd.extend(["--label", f"{key}={value}"])
|
|
220
|
+
|
|
221
|
+
# Systemd integration label
|
|
222
|
+
cmd.extend(["--label", "io.containers.autoupdate=registry"])
|
|
223
|
+
|
|
224
|
+
cmd.append(image_name)
|
|
225
|
+
|
|
226
|
+
try:
|
|
227
|
+
# Ensure network exists
|
|
228
|
+
subprocess.run(
|
|
229
|
+
["podman", "network", "create", self.config.network_name],
|
|
230
|
+
capture_output=True,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
result = subprocess.run(
|
|
234
|
+
cmd,
|
|
235
|
+
capture_output=True,
|
|
236
|
+
text=True,
|
|
237
|
+
timeout=60,
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if result.returncode == 0:
|
|
241
|
+
container_id = result.stdout.strip()[:12]
|
|
242
|
+
endpoint = f"http://{container_name}:{port}" if self.config.use_internal_dns else f"http://localhost:{port}"
|
|
243
|
+
|
|
244
|
+
return DeploymentResult(
|
|
245
|
+
success=True,
|
|
246
|
+
service_name=service_name,
|
|
247
|
+
runtime=self.runtime_type,
|
|
248
|
+
container_id=container_id,
|
|
249
|
+
image_name=image_name,
|
|
250
|
+
endpoint=endpoint,
|
|
251
|
+
)
|
|
252
|
+
else:
|
|
253
|
+
return DeploymentResult(
|
|
254
|
+
success=False,
|
|
255
|
+
service_name=service_name,
|
|
256
|
+
runtime=self.runtime_type,
|
|
257
|
+
error=result.stderr,
|
|
258
|
+
)
|
|
259
|
+
except subprocess.TimeoutExpired:
|
|
260
|
+
return DeploymentResult(
|
|
261
|
+
success=False,
|
|
262
|
+
service_name=service_name,
|
|
263
|
+
runtime=self.runtime_type,
|
|
264
|
+
error="Deploy timed out",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
def stop(self, service_name: str) -> DeploymentResult:
|
|
268
|
+
"""Stop a container."""
|
|
269
|
+
container_name = f"{self.config.namespace}-{service_name}"
|
|
270
|
+
|
|
271
|
+
result = subprocess.run(
|
|
272
|
+
["podman", "stop", container_name],
|
|
273
|
+
capture_output=True,
|
|
274
|
+
text=True,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
subprocess.run(
|
|
278
|
+
["podman", "rm", container_name],
|
|
279
|
+
capture_output=True,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
return DeploymentResult(
|
|
283
|
+
success=result.returncode == 0,
|
|
284
|
+
service_name=service_name,
|
|
285
|
+
runtime=self.runtime_type,
|
|
286
|
+
error=result.stderr if result.returncode != 0 else None,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
def logs(self, service_name: str, tail: int = 100) -> str:
|
|
290
|
+
"""Get container logs."""
|
|
291
|
+
container_name = f"{self.config.namespace}-{service_name}"
|
|
292
|
+
|
|
293
|
+
result = subprocess.run(
|
|
294
|
+
["podman", "logs", "--tail", str(tail), container_name],
|
|
295
|
+
capture_output=True,
|
|
296
|
+
text=True,
|
|
297
|
+
)
|
|
298
|
+
|
|
299
|
+
return result.stdout + result.stderr
|
|
300
|
+
|
|
301
|
+
def status(self, service_name: str) -> dict[str, Any]:
|
|
302
|
+
"""Get container status."""
|
|
303
|
+
container_name = f"{self.config.namespace}-{service_name}"
|
|
304
|
+
|
|
305
|
+
result = subprocess.run(
|
|
306
|
+
["podman", "inspect", container_name],
|
|
307
|
+
capture_output=True,
|
|
308
|
+
text=True,
|
|
309
|
+
)
|
|
310
|
+
|
|
311
|
+
if result.returncode != 0:
|
|
312
|
+
return {"running": False, "error": "Container not found"}
|
|
313
|
+
|
|
314
|
+
try:
|
|
315
|
+
data = json.loads(result.stdout)[0]
|
|
316
|
+
return {
|
|
317
|
+
"running": data["State"]["Running"],
|
|
318
|
+
"status": data["State"]["Status"],
|
|
319
|
+
"health": data["State"].get("Health", {}).get("Status", "unknown"),
|
|
320
|
+
"started_at": data["State"]["StartedAt"],
|
|
321
|
+
"container_id": data["Id"][:12],
|
|
322
|
+
"rootless": True,
|
|
323
|
+
}
|
|
324
|
+
except (json.JSONDecodeError, KeyError, IndexError):
|
|
325
|
+
return {"running": False, "error": "Failed to parse status"}
|
|
326
|
+
|
|
327
|
+
def generate_systemd_unit(
|
|
328
|
+
self,
|
|
329
|
+
service_name: str,
|
|
330
|
+
container_name: Optional[str] = None,
|
|
331
|
+
) -> str:
|
|
332
|
+
"""
|
|
333
|
+
Generate systemd unit file for production deployment.
|
|
334
|
+
|
|
335
|
+
This allows the container to be managed as a system service
|
|
336
|
+
with automatic restart, logging, and dependency management.
|
|
337
|
+
"""
|
|
338
|
+
container_name = container_name or f"{self.config.namespace}-{service_name}"
|
|
339
|
+
|
|
340
|
+
return f"""[Unit]
|
|
341
|
+
Description=Pactown {service_name} container
|
|
342
|
+
After=network-online.target
|
|
343
|
+
Wants=network-online.target
|
|
344
|
+
|
|
345
|
+
[Service]
|
|
346
|
+
Type=simple
|
|
347
|
+
Restart=always
|
|
348
|
+
RestartSec=5
|
|
349
|
+
TimeoutStartSec=300
|
|
350
|
+
TimeoutStopSec=70
|
|
351
|
+
|
|
352
|
+
ExecStartPre=-/usr/bin/podman stop {container_name}
|
|
353
|
+
ExecStartPre=-/usr/bin/podman rm {container_name}
|
|
354
|
+
ExecStart=/usr/bin/podman start -a {container_name}
|
|
355
|
+
ExecStop=/usr/bin/podman stop -t 60 {container_name}
|
|
356
|
+
ExecStopPost=-/usr/bin/podman rm {container_name}
|
|
357
|
+
|
|
358
|
+
# Security hardening
|
|
359
|
+
NoNewPrivileges=true
|
|
360
|
+
ProtectSystem=strict
|
|
361
|
+
ProtectHome=true
|
|
362
|
+
PrivateTmp=true
|
|
363
|
+
PrivateDevices=true
|
|
364
|
+
|
|
365
|
+
[Install]
|
|
366
|
+
WantedBy=multi-user.target
|
|
367
|
+
"""
|
|
368
|
+
|
|
369
|
+
def create_pod(
|
|
370
|
+
self,
|
|
371
|
+
pod_name: str,
|
|
372
|
+
services: list[str],
|
|
373
|
+
ports: list[int],
|
|
374
|
+
) -> DeploymentResult:
|
|
375
|
+
"""
|
|
376
|
+
Create a Podman pod (similar to Kubernetes pod).
|
|
377
|
+
|
|
378
|
+
All containers in a pod share the same network namespace,
|
|
379
|
+
making inter-service communication via localhost possible.
|
|
380
|
+
"""
|
|
381
|
+
cmd = [
|
|
382
|
+
"podman", "pod", "create",
|
|
383
|
+
"--name", pod_name,
|
|
384
|
+
]
|
|
385
|
+
|
|
386
|
+
for port in ports:
|
|
387
|
+
cmd.extend(["-p", f"{port}:{port}"])
|
|
388
|
+
|
|
389
|
+
result = subprocess.run(
|
|
390
|
+
cmd,
|
|
391
|
+
capture_output=True,
|
|
392
|
+
text=True,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
return DeploymentResult(
|
|
396
|
+
success=result.returncode == 0,
|
|
397
|
+
service_name=pod_name,
|
|
398
|
+
runtime=self.runtime_type,
|
|
399
|
+
error=result.stderr if result.returncode != 0 else None,
|
|
400
|
+
)
|
pactown/generator.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Config generator for pactown - scan folders and generate saas.pactown.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
from markpact import parse_blocks
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def scan_readme(readme_path: Path) -> dict:
|
|
14
|
+
"""
|
|
15
|
+
Scan a README.md and extract service configuration.
|
|
16
|
+
|
|
17
|
+
Returns dict with:
|
|
18
|
+
- name: service name (from folder or heading)
|
|
19
|
+
- readme: relative path to README
|
|
20
|
+
- port: detected port (if any)
|
|
21
|
+
- health_check: detected health endpoint
|
|
22
|
+
- deps: list of dependencies (markpact:deps)
|
|
23
|
+
- has_run: whether it has a run block
|
|
24
|
+
"""
|
|
25
|
+
content = readme_path.read_text()
|
|
26
|
+
blocks = parse_blocks(content)
|
|
27
|
+
|
|
28
|
+
# Extract service name from folder or first heading
|
|
29
|
+
folder_name = readme_path.parent.name
|
|
30
|
+
heading_match = re.search(r'^#\s+(.+)$', content, re.MULTILINE)
|
|
31
|
+
name = folder_name
|
|
32
|
+
|
|
33
|
+
# Detect port from run command
|
|
34
|
+
port = None
|
|
35
|
+
port_patterns = [
|
|
36
|
+
r'--port\s+\$\{MARKPACT_PORT:-(\d+)\}',
|
|
37
|
+
r'--port\s+(\d+)',
|
|
38
|
+
r':(\d+)',
|
|
39
|
+
r'PORT[=:-]+(\d+)',
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Detect health check endpoint
|
|
43
|
+
health_check = None
|
|
44
|
+
has_run = False
|
|
45
|
+
deps = []
|
|
46
|
+
|
|
47
|
+
for block in blocks:
|
|
48
|
+
if block.kind == "run":
|
|
49
|
+
has_run = True
|
|
50
|
+
for pattern in port_patterns:
|
|
51
|
+
match = re.search(pattern, block.body)
|
|
52
|
+
if match:
|
|
53
|
+
port = int(match.group(1))
|
|
54
|
+
break
|
|
55
|
+
|
|
56
|
+
if block.kind == "deps":
|
|
57
|
+
deps = [d.strip() for d in block.body.strip().split('\n') if d.strip()]
|
|
58
|
+
|
|
59
|
+
if block.kind == "test":
|
|
60
|
+
# Look for health check in tests
|
|
61
|
+
if "/health" in block.body:
|
|
62
|
+
health_check = "/health"
|
|
63
|
+
elif "GET /" in block.body:
|
|
64
|
+
health_check = "/"
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"name": name,
|
|
68
|
+
"readme": str(readme_path),
|
|
69
|
+
"port": port,
|
|
70
|
+
"health_check": health_check,
|
|
71
|
+
"deps": deps,
|
|
72
|
+
"has_run": has_run,
|
|
73
|
+
"title": heading_match.group(1) if heading_match else name,
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def scan_folder(
|
|
78
|
+
folder: Path,
|
|
79
|
+
recursive: bool = True,
|
|
80
|
+
pattern: str = "README.md",
|
|
81
|
+
) -> list[dict]:
|
|
82
|
+
"""
|
|
83
|
+
Scan a folder for README.md files and extract service configs.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
folder: Root folder to scan
|
|
87
|
+
recursive: Whether to scan subdirectories
|
|
88
|
+
pattern: Filename pattern to match
|
|
89
|
+
|
|
90
|
+
Returns:
|
|
91
|
+
List of service configurations
|
|
92
|
+
"""
|
|
93
|
+
folder = Path(folder)
|
|
94
|
+
services = []
|
|
95
|
+
|
|
96
|
+
if recursive:
|
|
97
|
+
readme_files = list(folder.rglob(pattern))
|
|
98
|
+
else:
|
|
99
|
+
readme_files = list(folder.glob(pattern))
|
|
100
|
+
|
|
101
|
+
for readme_path in readme_files:
|
|
102
|
+
try:
|
|
103
|
+
config = scan_readme(readme_path)
|
|
104
|
+
if config["has_run"]: # Only include runnable services
|
|
105
|
+
services.append(config)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
print(f"Warning: Failed to parse {readme_path}: {e}")
|
|
108
|
+
|
|
109
|
+
return services
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def generate_config(
|
|
113
|
+
folder: Path,
|
|
114
|
+
name: Optional[str] = None,
|
|
115
|
+
base_port: int = 8000,
|
|
116
|
+
output: Optional[Path] = None,
|
|
117
|
+
) -> dict:
|
|
118
|
+
"""
|
|
119
|
+
Generate a pactown ecosystem configuration from a folder.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
folder: Folder to scan for services
|
|
123
|
+
name: Ecosystem name (default: folder name)
|
|
124
|
+
base_port: Starting port for auto-assignment
|
|
125
|
+
output: Optional path to write YAML file
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
Generated configuration dict
|
|
129
|
+
"""
|
|
130
|
+
folder = Path(folder)
|
|
131
|
+
services = scan_folder(folder)
|
|
132
|
+
|
|
133
|
+
if not services:
|
|
134
|
+
raise ValueError(f"No runnable services found in {folder}")
|
|
135
|
+
|
|
136
|
+
# Build config
|
|
137
|
+
config = {
|
|
138
|
+
"name": name or folder.name,
|
|
139
|
+
"version": "0.1.0",
|
|
140
|
+
"description": f"Auto-generated from {folder}",
|
|
141
|
+
"base_port": base_port,
|
|
142
|
+
"sandbox_root": "./.pactown-sandboxes",
|
|
143
|
+
"registry": {
|
|
144
|
+
"url": "http://localhost:8800",
|
|
145
|
+
"namespace": "default",
|
|
146
|
+
},
|
|
147
|
+
"services": {},
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
# Assign ports and build service configs
|
|
151
|
+
next_port = base_port
|
|
152
|
+
for svc in services:
|
|
153
|
+
port = svc["port"] or next_port
|
|
154
|
+
next_port = max(next_port, port) + 1
|
|
155
|
+
|
|
156
|
+
# Make readme path relative to output folder
|
|
157
|
+
readme_rel = svc["readme"]
|
|
158
|
+
if output:
|
|
159
|
+
try:
|
|
160
|
+
readme_rel = str(Path(svc["readme"]).relative_to(output.parent))
|
|
161
|
+
except ValueError:
|
|
162
|
+
pass
|
|
163
|
+
|
|
164
|
+
service_config = {
|
|
165
|
+
"readme": readme_rel,
|
|
166
|
+
"port": port,
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if svc["health_check"]:
|
|
170
|
+
service_config["health_check"] = svc["health_check"]
|
|
171
|
+
|
|
172
|
+
config["services"][svc["name"]] = service_config
|
|
173
|
+
|
|
174
|
+
# Write to file if output specified
|
|
175
|
+
if output:
|
|
176
|
+
output = Path(output)
|
|
177
|
+
with open(output, "w") as f:
|
|
178
|
+
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
179
|
+
print(f"Generated: {output}")
|
|
180
|
+
|
|
181
|
+
return config
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def print_scan_results(folder: Path) -> None:
|
|
185
|
+
"""Print scan results in a readable format."""
|
|
186
|
+
from rich.console import Console
|
|
187
|
+
from rich.table import Table
|
|
188
|
+
|
|
189
|
+
console = Console()
|
|
190
|
+
services = scan_folder(folder)
|
|
191
|
+
|
|
192
|
+
if not services:
|
|
193
|
+
console.print(f"[yellow]No runnable services found in {folder}[/yellow]")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
table = Table(title=f"Services found in {folder}")
|
|
197
|
+
table.add_column("Name", style="cyan")
|
|
198
|
+
table.add_column("Title")
|
|
199
|
+
table.add_column("Port", style="blue")
|
|
200
|
+
table.add_column("Health")
|
|
201
|
+
table.add_column("Deps", style="dim")
|
|
202
|
+
|
|
203
|
+
for svc in services:
|
|
204
|
+
table.add_row(
|
|
205
|
+
svc["name"],
|
|
206
|
+
svc["title"][:30],
|
|
207
|
+
str(svc["port"]) if svc["port"] else "auto",
|
|
208
|
+
svc["health_check"] or "-",
|
|
209
|
+
str(len(svc["deps"])),
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
console.print(table)
|