pactown 0.1.4__py3-none-any.whl → 0.1.47__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,1021 @@
1
+ """Podman Quadlet deployment backend - systemd-native container management.
2
+
3
+ Quadlet generates systemd unit files from simple .container/.pod/.network files,
4
+ providing a lightweight alternative to Kubernetes for single-node VPS deployments.
5
+
6
+ Key benefits:
7
+ - Zero daemon overhead (unlike kubelet)
8
+ - Native systemd integration (auto-restart, logging, dependencies)
9
+ - Rootless containers by default
10
+ - Simple file-based configuration in ~/.config/containers/systemd/
11
+ - Perfect for MVP deployments on single VPS (e.g., Hetzner CX53)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ import subprocess
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+ from string import Template
21
+ from typing import Any, Optional
22
+
23
+ from .base import (
24
+ DeploymentBackend,
25
+ DeploymentConfig,
26
+ DeploymentResult,
27
+ RuntimeType,
28
+ )
29
+
30
+ # =============================================================================
31
+ # Security Sanitization Functions
32
+ # =============================================================================
33
+
34
+ # Safe characters for container/service names
35
+ SAFE_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9][a-zA-Z0-9_-]*$')
36
+
37
+ # Dangerous patterns that should never appear in unit files
38
+ DANGEROUS_PATTERNS = [
39
+ r';\s*rm\s',
40
+ r';\s*cat\s',
41
+ r'\|\s*nc\s',
42
+ r'\|\s*bash',
43
+ r'\|\s*sh\b',
44
+ r'\$\(',
45
+ r'`[^`]+`',
46
+ r'curl\s+[^|]*\|\s*bash',
47
+ r'wget\s+[^|]*\|\s*bash',
48
+ ]
49
+
50
+ # Blocked volume mounts for security
51
+ BLOCKED_VOLUME_PATHS = [
52
+ '/etc/shadow',
53
+ '/etc/passwd',
54
+ '/etc/sudoers',
55
+ '/root/.ssh',
56
+ '/proc',
57
+ '/sys',
58
+ '/dev',
59
+ '/var/run/docker.sock',
60
+ '/run/podman/podman.sock',
61
+ ]
62
+
63
+
64
+ def sanitize_name(name: str) -> str:
65
+ """Sanitize container/service name to prevent injection.
66
+
67
+ Only allows alphanumeric, underscore, and hyphen.
68
+ Removes all dangerous characters and patterns.
69
+ """
70
+ if not name:
71
+ return "unnamed"
72
+
73
+ # Remove null bytes
74
+ name = name.replace('\x00', '')
75
+
76
+ # Remove newlines and carriage returns
77
+ name = name.replace('\n', '').replace('\r', '')
78
+
79
+ # Remove shell metacharacters
80
+ for char in [';', '|', '&', '$', '`', '(', ')', '{', '}', '[', ']', '<', '>', '"', "'", '\\']:
81
+ name = name.replace(char, '')
82
+
83
+ # Remove path separators
84
+ name = name.replace('/', '-').replace('..', '')
85
+
86
+ # Only keep safe characters
87
+ name = re.sub(r'[^a-zA-Z0-9_-]', '-', name)
88
+
89
+ # Remove leading hyphens/underscores
90
+ name = name.lstrip('-_')
91
+
92
+ # Ensure it starts with alphanumeric
93
+ if not name or not name[0].isalnum():
94
+ name = 'svc-' + name
95
+
96
+ # Limit length
97
+ return name[:63]
98
+
99
+
100
+ def sanitize_env_value(value: str) -> str:
101
+ """Sanitize environment variable value.
102
+
103
+ Escapes special characters that could break INI format.
104
+ """
105
+ if not value:
106
+ return ""
107
+
108
+ # Remove null bytes
109
+ value = value.replace('\x00', '')
110
+
111
+ # Escape newlines (critical for INI injection prevention)
112
+ value = value.replace('\n', '\\n').replace('\r', '\\r')
113
+
114
+ # Don't allow section headers
115
+ value = re.sub(r'\[(\w+)\]', r'(\1)', value)
116
+
117
+ return value
118
+
119
+
120
+ def sanitize_env_key(key: str) -> str:
121
+ """Sanitize environment variable key.
122
+
123
+ Only allows alphanumeric and underscore.
124
+ """
125
+ if not key:
126
+ return "INVALID_KEY"
127
+
128
+ # Remove null bytes and newlines
129
+ key = key.replace('\x00', '').replace('\n', '').replace('\r', '')
130
+
131
+ # Only keep safe characters for env var names
132
+ key = re.sub(r'[^a-zA-Z0-9_]', '_', key)
133
+
134
+ # Must start with letter or underscore
135
+ if key and key[0].isdigit():
136
+ key = '_' + key
137
+
138
+ return key[:128]
139
+
140
+
141
+ def sanitize_path(path: str) -> str:
142
+ """Sanitize file/volume path.
143
+
144
+ Prevents path traversal attacks.
145
+ """
146
+ if not path:
147
+ return ""
148
+
149
+ # Remove null bytes
150
+ path = path.replace('\x00', '')
151
+
152
+ # Remove newlines
153
+ path = path.replace('\n', '').replace('\r', '')
154
+
155
+ # Remove shell metacharacters from path
156
+ for char in [';', '|', '&', '$', '`', '(', ')', '<', '>']:
157
+ path = path.replace(char, '')
158
+
159
+ return path
160
+
161
+
162
+ def sanitize_domain(domain: str) -> str:
163
+ """Sanitize domain name.
164
+
165
+ Only allows valid domain characters.
166
+ """
167
+ if not domain:
168
+ return "localhost"
169
+
170
+ # Remove null bytes and newlines
171
+ domain = domain.replace('\x00', '').replace('\n', '').replace('\r', '')
172
+
173
+ # Remove injection attempts
174
+ for char in ['`', ')', '(', '"', "'", '\\', ';', '|', '&', '$', '{', '}']:
175
+ domain = domain.replace(char, '')
176
+
177
+ # Only keep valid domain characters
178
+ domain = re.sub(r'[^a-zA-Z0-9.-]', '', domain)
179
+
180
+ return domain[:253]
181
+
182
+
183
+ def sanitize_image(image: str) -> str:
184
+ """Sanitize container image name.
185
+
186
+ Only allows valid image reference characters.
187
+ """
188
+ if not image:
189
+ return "nginx:latest"
190
+
191
+ # Remove null bytes and newlines
192
+ image = image.replace('\x00', '').replace('\n', '').replace('\r', '')
193
+
194
+ # Remove shell metacharacters
195
+ for char in [';', '|', '&', '$', '`', '(', ')', '<', '>', '"', "'", '\\', ' ']:
196
+ image = image.replace(char, '')
197
+
198
+ # Only keep valid image reference characters
199
+ # Format: [registry/][namespace/]name[:tag][@digest]
200
+ image = re.sub(r'[^a-zA-Z0-9._:/@-]', '', image)
201
+
202
+ return image[:255]
203
+
204
+
205
+ def sanitize_health_check(endpoint: str) -> str:
206
+ """Sanitize health check endpoint.
207
+
208
+ Only allows safe URL path characters.
209
+ """
210
+ if not endpoint:
211
+ return "/health"
212
+
213
+ # Remove null bytes and newlines
214
+ endpoint = endpoint.replace('\x00', '').replace('\n', '').replace('\r', '')
215
+
216
+ # Remove shell metacharacters
217
+ for char in [';', '|', '&', '$', '`', '(', ')', '<', '>', '"', "'", '\\']:
218
+ endpoint = endpoint.replace(char, '')
219
+
220
+ # Must start with /
221
+ if not endpoint.startswith('/'):
222
+ endpoint = '/' + endpoint
223
+
224
+ # Only keep valid URL path characters
225
+ endpoint = re.sub(r'[^a-zA-Z0-9/_.-]', '', endpoint)
226
+
227
+ return endpoint[:255]
228
+
229
+
230
+ def validate_volume(volume: str) -> tuple[bool, str]:
231
+ """Validate volume mount specification.
232
+
233
+ Returns (is_valid, sanitized_volume or error message).
234
+ """
235
+ if not volume:
236
+ return False, "Empty volume specification"
237
+
238
+ # Remove null bytes first
239
+ volume = volume.replace('\x00', '')
240
+
241
+ # CRITICAL: Check for newline injection before anything else
242
+ if '\n' in volume or '\r' in volume:
243
+ return False, "Newline injection detected"
244
+
245
+ # Sanitize path
246
+ volume = sanitize_path(volume)
247
+
248
+ # Check for blocked paths
249
+ for blocked in BLOCKED_VOLUME_PATHS:
250
+ if blocked in volume:
251
+ return False, f"Blocked path: {blocked}"
252
+
253
+ # Check for path traversal
254
+ if '..' in volume:
255
+ return False, "Path traversal detected"
256
+
257
+ return True, volume
258
+
259
+
260
+ def check_dangerous_content(content: str) -> list[str]:
261
+ """Check content for dangerous patterns.
262
+
263
+ Returns list of detected dangerous patterns.
264
+ """
265
+ found = []
266
+ for pattern in DANGEROUS_PATTERNS:
267
+ if re.search(pattern, content, re.IGNORECASE):
268
+ found.append(pattern)
269
+ return found
270
+
271
+
272
+ @dataclass
273
+ class QuadletConfig:
274
+ """Configuration for Quadlet deployment."""
275
+
276
+ # Tenant/user identification
277
+ tenant_id: str = "default"
278
+
279
+ # Domain configuration
280
+ domain: str = "localhost"
281
+ subdomain: Optional[str] = None
282
+ tls_enabled: bool = False
283
+
284
+ # Traefik labels for routing
285
+ traefik_enabled: bool = True
286
+ traefik_entrypoint: str = "websecure"
287
+ traefik_certresolver: str = "letsencrypt"
288
+
289
+ # Resource limits
290
+ cpus: str = "0.5"
291
+ memory: str = "256M"
292
+ memory_max: str = "512M"
293
+
294
+ # Networking
295
+ network_mode: str = "bridge" # bridge, host, slirp4netns
296
+ publish_ports: bool = True
297
+
298
+ # Auto-update
299
+ auto_update: str = "registry" # registry, local, or empty
300
+
301
+ # Systemd user mode
302
+ user_mode: bool = True # Use ~/.config/containers/systemd/ vs /etc/containers/systemd/
303
+
304
+ @property
305
+ def full_domain(self) -> str:
306
+ """Get full domain with subdomain (sanitized)."""
307
+ safe_domain = sanitize_domain(self.domain)
308
+ if self.subdomain:
309
+ safe_subdomain = sanitize_domain(self.subdomain)
310
+ return f"{safe_subdomain}.{safe_domain}"
311
+ return safe_domain
312
+
313
+ @property
314
+ def systemd_path(self) -> Path:
315
+ """Get systemd unit files path."""
316
+ if self.user_mode:
317
+ return Path.home() / ".config" / "containers" / "systemd"
318
+ return Path("/etc/containers/systemd")
319
+
320
+ @property
321
+ def tenant_path(self) -> Path:
322
+ """Get tenant-specific directory."""
323
+ return self.systemd_path / f"tenant-{self.tenant_id}"
324
+
325
+
326
+ @dataclass
327
+ class QuadletUnit:
328
+ """Represents a Quadlet unit file."""
329
+ name: str
330
+ unit_type: str # container, pod, network, volume, kube
331
+ content: str
332
+
333
+ @property
334
+ def filename(self) -> str:
335
+ # Sanitize filename to prevent injection
336
+ safe_name = sanitize_name(self.name)
337
+ safe_type = re.sub(r'[^a-zA-Z0-9]', '', self.unit_type)
338
+ return f"{safe_name}.{safe_type}"
339
+
340
+ def save(self, directory: Path) -> Path:
341
+ """Save unit file to directory."""
342
+ directory.mkdir(parents=True, exist_ok=True)
343
+ # Use sanitized filename
344
+ path = directory / self.filename
345
+ path.write_text(self.content)
346
+ return path
347
+
348
+
349
+ class QuadletTemplates:
350
+ """Template generator for Quadlet unit files."""
351
+
352
+ CONTAINER_TEMPLATE = Template("""[Unit]
353
+ Description=${description}
354
+ After=network-online.target
355
+ Wants=network-online.target
356
+ ${after_units}
357
+
358
+ [Container]
359
+ ContainerName=${container_name}
360
+ Image=${image}
361
+ ${environment}
362
+ ${publish_ports}
363
+ ${volumes}
364
+ ${labels}
365
+
366
+ # Resource limits
367
+ PodmanArgs=--cpus=${cpus} --memory=${memory} --memory-reservation=${memory_max}
368
+
369
+ # Security
370
+ PodmanArgs=--security-opt=no-new-privileges:true
371
+ ${rootless_args}
372
+
373
+ # Health check
374
+ ${health_check}
375
+
376
+ # Auto-update
377
+ AutoUpdate=${auto_update}
378
+
379
+ [Service]
380
+ Restart=always
381
+ RestartSec=5
382
+ TimeoutStartSec=300
383
+ TimeoutStopSec=70
384
+
385
+ [Install]
386
+ WantedBy=default.target
387
+ """)
388
+
389
+ POD_TEMPLATE = Template("""[Unit]
390
+ Description=${description}
391
+ After=network-online.target
392
+ Wants=network-online.target
393
+
394
+ [Pod]
395
+ PodName=${pod_name}
396
+ ${publish_ports}
397
+ Network=${network}
398
+
399
+ [Install]
400
+ WantedBy=default.target
401
+ """)
402
+
403
+ NETWORK_TEMPLATE = Template("""[Unit]
404
+ Description=${description}
405
+
406
+ [Network]
407
+ NetworkName=${network_name}
408
+ Driver=${driver}
409
+ ${subnet}
410
+ ${gateway}
411
+ ${labels}
412
+
413
+ [Install]
414
+ WantedBy=default.target
415
+ """)
416
+
417
+ VOLUME_TEMPLATE = Template("""[Unit]
418
+ Description=${description}
419
+
420
+ [Volume]
421
+ VolumeName=${volume_name}
422
+ ${labels}
423
+
424
+ [Install]
425
+ WantedBy=default.target
426
+ """)
427
+
428
+ KUBE_TEMPLATE = Template("""[Unit]
429
+ Description=${description}
430
+ After=network-online.target
431
+ Wants=network-online.target
432
+
433
+ [Kube]
434
+ Yaml=${yaml_path}
435
+ ${publish_ports}
436
+ Network=${network}
437
+ ${config_maps}
438
+
439
+ [Install]
440
+ WantedBy=default.target
441
+ """)
442
+
443
+ @classmethod
444
+ def container(
445
+ cls,
446
+ name: str,
447
+ image: str,
448
+ port: int,
449
+ config: QuadletConfig,
450
+ env: dict[str, str] = None,
451
+ health_check: Optional[str] = None,
452
+ volumes: list[str] = None,
453
+ depends_on: list[str] = None,
454
+ ) -> QuadletUnit:
455
+ """Generate .container unit file with security sanitization."""
456
+ env = env or {}
457
+ volumes = volumes or []
458
+ depends_on = depends_on or []
459
+
460
+ # === SECURITY: Sanitize all inputs ===
461
+ safe_name = sanitize_name(name)
462
+ safe_image = sanitize_image(image)
463
+ safe_tenant = sanitize_name(config.tenant_id)
464
+ safe_domain = sanitize_domain(config.full_domain)
465
+
466
+ # Build environment lines with sanitization
467
+ env_lines = []
468
+ for key, value in env.items():
469
+ safe_key = sanitize_env_key(key)
470
+ safe_value = sanitize_env_value(str(value))
471
+ env_lines.append(f"Environment={safe_key}={safe_value}")
472
+
473
+ # Add Traefik labels if enabled (with sanitized values)
474
+ labels = []
475
+ if config.traefik_enabled:
476
+ labels.extend([
477
+ "Label=traefik.enable=true",
478
+ f"Label=traefik.http.routers.{safe_name}.rule=Host(`{safe_domain}`)",
479
+ f"Label=traefik.http.routers.{safe_name}.entrypoints={config.traefik_entrypoint}",
480
+ f"Label=traefik.http.services.{safe_name}.loadbalancer.server.port={port}",
481
+ ])
482
+ if config.tls_enabled:
483
+ labels.extend([
484
+ f"Label=traefik.http.routers.{safe_name}.tls=true",
485
+ f"Label=traefik.http.routers.{safe_name}.tls.certresolver={config.traefik_certresolver}",
486
+ ])
487
+
488
+ # Publish ports
489
+ publish = ""
490
+ if config.publish_ports:
491
+ publish = f"PublishPort={port}:{port}"
492
+
493
+ # Volumes with validation
494
+ vol_lines = []
495
+ for v in volumes:
496
+ is_valid, result = validate_volume(v)
497
+ if is_valid:
498
+ vol_lines.append(f"Volume={result}")
499
+ # Skip invalid/dangerous volumes silently
500
+
501
+ # Dependencies (sanitized)
502
+ after_lines = []
503
+ for dep in depends_on:
504
+ safe_dep = sanitize_name(dep)
505
+ after_lines.append(f"After={safe_dep}.service")
506
+
507
+ # Health check (sanitized)
508
+ hc = ""
509
+ if health_check:
510
+ safe_hc = sanitize_health_check(health_check)
511
+ hc = (f"HealthCmd=curl -sf http://localhost:{port}{safe_hc} || exit 1\n"
512
+ "HealthInterval=30s\nHealthTimeout=10s\nHealthRetries=3")
513
+
514
+ # Rootless args
515
+ rootless = "PodmanArgs=--userns=keep-id" if config.user_mode else ""
516
+
517
+ content = cls.CONTAINER_TEMPLATE.substitute(
518
+ description=f"Pactown service: {safe_name} (tenant: {safe_tenant})",
519
+ container_name=f"{safe_tenant}-{safe_name}",
520
+ image=safe_image,
521
+ environment="\n".join(env_lines) if env_lines else "# No environment variables",
522
+ publish_ports=publish,
523
+ volumes="\n".join(vol_lines) if vol_lines else "# No volumes",
524
+ labels="\n".join(labels) if labels else "# No labels",
525
+ cpus=config.cpus,
526
+ memory=config.memory,
527
+ memory_max=config.memory_max,
528
+ rootless_args=rootless,
529
+ health_check=hc if hc else "# No health check",
530
+ auto_update=config.auto_update,
531
+ after_units="\n".join(after_lines) if after_lines else "",
532
+ )
533
+
534
+ return QuadletUnit(name=safe_name, unit_type="container", content=content)
535
+
536
+ @classmethod
537
+ def pod(
538
+ cls,
539
+ name: str,
540
+ config: QuadletConfig,
541
+ ports: list[int] = None,
542
+ network: str = "pactown-net",
543
+ ) -> QuadletUnit:
544
+ """Generate .pod unit file."""
545
+ ports = ports or []
546
+
547
+ publish = "\n".join([f"PublishPort={p}:{p}" for p in ports]) if ports else ""
548
+
549
+ content = cls.POD_TEMPLATE.substitute(
550
+ description=f"Pactown pod: {name} (tenant: {config.tenant_id})",
551
+ pod_name=f"{config.tenant_id}-{name}",
552
+ publish_ports=publish,
553
+ network=network,
554
+ )
555
+
556
+ return QuadletUnit(name=name, unit_type="pod", content=content)
557
+
558
+ @classmethod
559
+ def network(
560
+ cls,
561
+ name: str,
562
+ config: QuadletConfig,
563
+ driver: str = "bridge",
564
+ subnet: Optional[str] = None,
565
+ gateway: Optional[str] = None,
566
+ ) -> QuadletUnit:
567
+ """Generate .network unit file."""
568
+ content = cls.NETWORK_TEMPLATE.substitute(
569
+ description=f"Pactown network: {name}",
570
+ network_name=name,
571
+ driver=driver,
572
+ subnet=f"Subnet={subnet}" if subnet else "",
573
+ gateway=f"Gateway={gateway}" if gateway else "",
574
+ labels=f"Label=pactown.tenant={config.tenant_id}",
575
+ )
576
+
577
+ return QuadletUnit(name=name, unit_type="network", content=content)
578
+
579
+ @classmethod
580
+ def volume(
581
+ cls,
582
+ name: str,
583
+ config: QuadletConfig,
584
+ ) -> QuadletUnit:
585
+ """Generate .volume unit file."""
586
+ content = cls.VOLUME_TEMPLATE.substitute(
587
+ description=f"Pactown volume: {name}",
588
+ volume_name=f"{config.tenant_id}-{name}",
589
+ labels=f"Label=pactown.tenant={config.tenant_id}",
590
+ )
591
+
592
+ return QuadletUnit(name=name, unit_type="volume", content=content)
593
+
594
+
595
+ class QuadletBackend(DeploymentBackend):
596
+ """
597
+ Podman Quadlet deployment backend.
598
+
599
+ Generates systemd-native unit files for container management,
600
+ providing a lightweight alternative to Kubernetes.
601
+ """
602
+
603
+ def __init__(self, config: DeploymentConfig, quadlet_config: QuadletConfig = None):
604
+ super().__init__(config)
605
+ self.quadlet = quadlet_config or QuadletConfig()
606
+
607
+ @property
608
+ def runtime_type(self) -> RuntimeType:
609
+ return RuntimeType.PODMAN
610
+
611
+ def is_available(self) -> bool:
612
+ """Check if Podman with Quadlet support is available."""
613
+ try:
614
+ # Check podman
615
+ result = subprocess.run(
616
+ ["podman", "version", "--format", "{{.Version}}"],
617
+ capture_output=True,
618
+ text=True,
619
+ timeout=5,
620
+ )
621
+ if result.returncode != 0:
622
+ return False
623
+
624
+ # Check for Quadlet (available in Podman 4.4+)
625
+ version = result.stdout.strip()
626
+ major, minor = map(int, version.split(".")[:2])
627
+ return major > 4 or (major == 4 and minor >= 4)
628
+ except (subprocess.TimeoutExpired, FileNotFoundError, ValueError):
629
+ return False
630
+
631
+ def get_quadlet_version(self) -> Optional[str]:
632
+ """Get Quadlet/Podman version."""
633
+ try:
634
+ result = subprocess.run(
635
+ ["podman", "version", "--format", "{{.Version}}"],
636
+ capture_output=True,
637
+ text=True,
638
+ timeout=5,
639
+ )
640
+ return result.stdout.strip() if result.returncode == 0 else None
641
+ except Exception:
642
+ return None
643
+
644
+ def build_image(
645
+ self,
646
+ service_name: str,
647
+ dockerfile_path: Path,
648
+ context_path: Path,
649
+ tag: Optional[str] = None,
650
+ ) -> DeploymentResult:
651
+ """Build container image with Podman."""
652
+ image_name = f"{self.config.image_prefix}/{service_name}"
653
+ if tag:
654
+ image_name = f"{image_name}:{tag}"
655
+ else:
656
+ image_name = f"{image_name}:latest"
657
+
658
+ cmd = [
659
+ "podman", "build",
660
+ "-t", image_name,
661
+ "-f", str(dockerfile_path),
662
+ str(context_path),
663
+ ]
664
+
665
+ try:
666
+ result = subprocess.run(
667
+ cmd,
668
+ capture_output=True,
669
+ text=True,
670
+ timeout=600,
671
+ )
672
+
673
+ return DeploymentResult(
674
+ success=result.returncode == 0,
675
+ service_name=service_name,
676
+ runtime=self.runtime_type,
677
+ image_name=image_name,
678
+ error=result.stderr if result.returncode != 0 else None,
679
+ )
680
+ except subprocess.TimeoutExpired:
681
+ return DeploymentResult(
682
+ success=False,
683
+ service_name=service_name,
684
+ runtime=self.runtime_type,
685
+ error="Build timed out",
686
+ )
687
+
688
+ def push_image(
689
+ self,
690
+ image_name: str,
691
+ registry: Optional[str] = None,
692
+ ) -> DeploymentResult:
693
+ """Push image to registry."""
694
+ target = f"{registry}/{image_name}" if registry else image_name
695
+
696
+ try:
697
+ if registry:
698
+ subprocess.run(
699
+ ["podman", "tag", image_name, target],
700
+ capture_output=True,
701
+ )
702
+
703
+ result = subprocess.run(
704
+ ["podman", "push", target],
705
+ capture_output=True,
706
+ text=True,
707
+ timeout=300,
708
+ )
709
+
710
+ return DeploymentResult(
711
+ success=result.returncode == 0,
712
+ service_name=image_name.split("/")[-1].split(":")[0],
713
+ runtime=self.runtime_type,
714
+ image_name=target,
715
+ error=result.stderr if result.returncode != 0 else None,
716
+ )
717
+ except subprocess.TimeoutExpired:
718
+ return DeploymentResult(
719
+ success=False,
720
+ service_name=image_name,
721
+ runtime=self.runtime_type,
722
+ error="Push timed out",
723
+ )
724
+
725
+ def generate_quadlet_files(
726
+ self,
727
+ service_name: str,
728
+ image_name: str,
729
+ port: int,
730
+ env: dict[str, str] = None,
731
+ health_check: Optional[str] = None,
732
+ volumes: list[str] = None,
733
+ depends_on: list[str] = None,
734
+ ) -> list[QuadletUnit]:
735
+ """Generate Quadlet unit files for a service."""
736
+ units = []
737
+
738
+ # Container unit
739
+ container = QuadletTemplates.container(
740
+ name=service_name,
741
+ image=image_name,
742
+ port=port,
743
+ config=self.quadlet,
744
+ env=env,
745
+ health_check=health_check,
746
+ volumes=volumes,
747
+ depends_on=depends_on,
748
+ )
749
+ units.append(container)
750
+
751
+ return units
752
+
753
+ def deploy(
754
+ self,
755
+ service_name: str,
756
+ image_name: str,
757
+ port: int,
758
+ env: dict[str, str],
759
+ health_check: Optional[str] = None,
760
+ ) -> DeploymentResult:
761
+ """Deploy a service using Quadlet."""
762
+ try:
763
+ # Generate Quadlet files
764
+ units = self.generate_quadlet_files(
765
+ service_name=service_name,
766
+ image_name=image_name,
767
+ port=port,
768
+ env=env,
769
+ health_check=health_check,
770
+ )
771
+
772
+ # Save to tenant directory
773
+ tenant_path = self.quadlet.tenant_path
774
+ for unit in units:
775
+ unit.save(tenant_path)
776
+
777
+ # Reload systemd daemon
778
+ self._systemctl("daemon-reload")
779
+
780
+ # Start the service
781
+ service = f"{service_name}.service"
782
+ self._systemctl("start", service)
783
+ self._systemctl("enable", service)
784
+
785
+ endpoint = f"https://{self.quadlet.full_domain}" if self.quadlet.tls_enabled else f"http://{self.quadlet.full_domain}"
786
+
787
+ return DeploymentResult(
788
+ success=True,
789
+ service_name=service_name,
790
+ runtime=self.runtime_type,
791
+ image_name=image_name,
792
+ endpoint=endpoint,
793
+ )
794
+ except Exception as e:
795
+ return DeploymentResult(
796
+ success=False,
797
+ service_name=service_name,
798
+ runtime=self.runtime_type,
799
+ error=str(e),
800
+ )
801
+
802
+ def stop(self, service_name: str) -> DeploymentResult:
803
+ """Stop a Quadlet service."""
804
+ try:
805
+ service = f"{service_name}.service"
806
+ self._systemctl("stop", service)
807
+ self._systemctl("disable", service)
808
+
809
+ # Remove unit files
810
+ tenant_path = self.quadlet.tenant_path
811
+ for ext in ["container", "pod", "network", "volume"]:
812
+ unit_file = tenant_path / f"{service_name}.{ext}"
813
+ if unit_file.exists():
814
+ unit_file.unlink()
815
+
816
+ self._systemctl("daemon-reload")
817
+
818
+ return DeploymentResult(
819
+ success=True,
820
+ service_name=service_name,
821
+ runtime=self.runtime_type,
822
+ )
823
+ except Exception as e:
824
+ return DeploymentResult(
825
+ success=False,
826
+ service_name=service_name,
827
+ runtime=self.runtime_type,
828
+ error=str(e),
829
+ )
830
+
831
+ def logs(self, service_name: str, tail: int = 100) -> str:
832
+ """Get service logs via journalctl."""
833
+ try:
834
+ cmd = ["journalctl"]
835
+ if self.quadlet.user_mode:
836
+ cmd.append("--user")
837
+ cmd.extend(["-u", f"{service_name}.service", "-n", str(tail), "--no-pager"])
838
+
839
+ result = subprocess.run(cmd, capture_output=True, text=True)
840
+ return result.stdout
841
+ except Exception:
842
+ return ""
843
+
844
+ def status(self, service_name: str) -> dict[str, Any]:
845
+ """Get service status."""
846
+ try:
847
+ cmd = ["systemctl"]
848
+ if self.quadlet.user_mode:
849
+ cmd.append("--user")
850
+ cmd.extend(["show", f"{service_name}.service", "--property=ActiveState,SubState,MainPID"])
851
+
852
+ result = subprocess.run(cmd, capture_output=True, text=True)
853
+
854
+ status = {}
855
+ for line in result.stdout.strip().split("\n"):
856
+ if "=" in line:
857
+ key, value = line.split("=", 1)
858
+ status[key] = value
859
+
860
+ return {
861
+ "running": status.get("ActiveState") == "active",
862
+ "state": status.get("SubState", "unknown"),
863
+ "pid": status.get("MainPID", "0"),
864
+ "quadlet": True,
865
+ "tenant": self.quadlet.tenant_id,
866
+ }
867
+ except Exception:
868
+ return {"running": False, "error": "Failed to get status"}
869
+
870
+ def _systemctl(self, command: str, service: str = None) -> subprocess.CompletedProcess:
871
+ """Run systemctl command."""
872
+ cmd = ["systemctl"]
873
+ if self.quadlet.user_mode:
874
+ cmd.append("--user")
875
+ cmd.append(command)
876
+ if service:
877
+ cmd.append(service)
878
+
879
+ return subprocess.run(cmd, capture_output=True, text=True)
880
+
881
+ def list_services(self) -> list[dict[str, Any]]:
882
+ """List all Quadlet services for the tenant."""
883
+ services = []
884
+ tenant_path = self.quadlet.tenant_path
885
+
886
+ if tenant_path.exists():
887
+ for f in tenant_path.glob("*.container"):
888
+ name = f.stem
889
+ status = self.status(name)
890
+ services.append({
891
+ "name": name,
892
+ "status": status,
893
+ "unit_file": str(f),
894
+ })
895
+
896
+ return services
897
+
898
+
899
+ def generate_traefik_quadlet(config: QuadletConfig) -> list[QuadletUnit]:
900
+ """Generate Traefik reverse proxy Quadlet files."""
901
+ units = []
902
+
903
+ # Traefik container
904
+ traefik_content = f"""[Unit]
905
+ Description=Traefik Reverse Proxy
906
+ After=network-online.target
907
+ Wants=network-online.target
908
+
909
+ [Container]
910
+ ContainerName=traefik
911
+ Image=docker.io/traefik:v3.0
912
+
913
+ # Entrypoints
914
+ Environment=TRAEFIK_ENTRYPOINTS_WEB_ADDRESS=:80
915
+ Environment=TRAEFIK_ENTRYPOINTS_WEBSECURE_ADDRESS=:443
916
+ Environment=TRAEFIK_PROVIDERS_DOCKER=true
917
+ Environment=TRAEFIK_PROVIDERS_DOCKER_EXPOSEDBYDEFAULT=false
918
+
919
+ # Let's Encrypt
920
+ Environment=TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_EMAIL=admin@{config.domain}
921
+ Environment=TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_STORAGE=/letsencrypt/acme.json
922
+ Environment=TRAEFIK_CERTIFICATESRESOLVERS_LETSENCRYPT_ACME_HTTPCHALLENGE_ENTRYPOINT=web
923
+
924
+ # API dashboard
925
+ Environment=TRAEFIK_API_DASHBOARD=true
926
+ Environment=TRAEFIK_API_INSECURE=false
927
+
928
+ PublishPort=80:80
929
+ PublishPort=443:443
930
+
931
+ Volume=/run/podman/podman.sock:/var/run/docker.sock:ro
932
+ Volume=traefik-letsencrypt:/letsencrypt
933
+
934
+ # Security
935
+ PodmanArgs=--security-opt=no-new-privileges:true
936
+
937
+ AutoUpdate=registry
938
+
939
+ [Service]
940
+ Restart=always
941
+ RestartSec=5
942
+
943
+ [Install]
944
+ WantedBy=default.target
945
+ """
946
+
947
+ units.append(QuadletUnit(name="traefik", unit_type="container", content=traefik_content))
948
+
949
+ # Traefik volume for Let's Encrypt
950
+ volume_content = """[Unit]
951
+ Description=Traefik Let's Encrypt storage
952
+
953
+ [Volume]
954
+ VolumeName=traefik-letsencrypt
955
+
956
+ [Install]
957
+ WantedBy=default.target
958
+ """
959
+
960
+ units.append(QuadletUnit(name="traefik-letsencrypt", unit_type="volume", content=volume_content))
961
+
962
+ return units
963
+
964
+
965
+ def generate_markdown_service_quadlet(
966
+ markdown_path: Path,
967
+ config: QuadletConfig,
968
+ image: str = "ghcr.io/pactown/markdown-server:latest",
969
+ ) -> list[QuadletUnit]:
970
+ """
971
+ Generate Quadlet files for serving a Markdown file.
972
+
973
+ This creates a simple container that serves the Markdown as a web page
974
+ with live reload and syntax highlighting.
975
+ """
976
+ name = markdown_path.stem.lower().replace(" ", "-").replace("_", "-")
977
+
978
+ container_content = f"""[Unit]
979
+ Description=Markdown Service: {markdown_path.name}
980
+ After=network-online.target traefik.service
981
+ Wants=network-online.target
982
+
983
+ [Container]
984
+ ContainerName={config.tenant_id}-{name}
985
+ Image={image}
986
+
987
+ # Mount the Markdown file
988
+ Volume={markdown_path}:/app/content/README.md:ro
989
+
990
+ # Environment
991
+ Environment=MARKDOWN_TITLE={markdown_path.stem}
992
+ Environment=MARKDOWN_THEME=github
993
+ Environment=PORT=8080
994
+
995
+ # Traefik labels
996
+ Label=traefik.enable=true
997
+ Label=traefik.http.routers.{name}.rule=Host(`{config.full_domain}`)
998
+ Label=traefik.http.routers.{name}.entrypoints={config.traefik_entrypoint}
999
+ Label=traefik.http.services.{name}.loadbalancer.server.port=8080
1000
+ {f"Label=traefik.http.routers.{name}.tls=true" if config.tls_enabled else ""}
1001
+ {f"Label=traefik.http.routers.{name}.tls.certresolver={config.traefik_certresolver}" if config.tls_enabled else ""}
1002
+
1003
+ # Resource limits
1004
+ PodmanArgs=--cpus={config.cpus} --memory={config.memory}
1005
+
1006
+ # Security
1007
+ PodmanArgs=--security-opt=no-new-privileges:true
1008
+ PodmanArgs=--read-only
1009
+ PodmanArgs=--tmpfs=/tmp:rw,noexec,nosuid
1010
+
1011
+ AutoUpdate=registry
1012
+
1013
+ [Service]
1014
+ Restart=always
1015
+ RestartSec=5
1016
+
1017
+ [Install]
1018
+ WantedBy=default.target
1019
+ """
1020
+
1021
+ return [QuadletUnit(name=name, unit_type="container", content=container_content)]