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.
@@ -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)