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.
- pactown/__init__.py +178 -4
- pactown/cli.py +539 -37
- pactown/config.py +12 -11
- pactown/deploy/__init__.py +17 -3
- pactown/deploy/base.py +35 -33
- pactown/deploy/compose.py +59 -58
- pactown/deploy/docker.py +40 -41
- pactown/deploy/kubernetes.py +43 -42
- pactown/deploy/podman.py +55 -56
- pactown/deploy/quadlet.py +1021 -0
- pactown/deploy/quadlet_api.py +533 -0
- pactown/deploy/quadlet_shell.py +557 -0
- pactown/events.py +1066 -0
- pactown/fast_start.py +514 -0
- pactown/generator.py +31 -30
- pactown/llm.py +450 -0
- pactown/markpact_blocks.py +50 -0
- pactown/network.py +59 -38
- pactown/orchestrator.py +90 -93
- pactown/parallel.py +40 -40
- pactown/platform.py +146 -0
- pactown/registry/__init__.py +1 -1
- pactown/registry/client.py +45 -46
- pactown/registry/models.py +25 -25
- pactown/registry/server.py +24 -24
- pactown/resolver.py +30 -30
- pactown/runner_api.py +458 -0
- pactown/sandbox_manager.py +480 -79
- pactown/security.py +682 -0
- pactown/service_runner.py +1201 -0
- pactown/user_isolation.py +458 -0
- {pactown-0.1.4.dist-info → pactown-0.1.47.dist-info}/METADATA +65 -9
- pactown-0.1.47.dist-info/RECORD +36 -0
- pactown-0.1.47.dist-info/entry_points.txt +5 -0
- pactown-0.1.4.dist-info/RECORD +0 -24
- pactown-0.1.4.dist-info/entry_points.txt +0 -3
- {pactown-0.1.4.dist-info → pactown-0.1.47.dist-info}/WHEEL +0 -0
- {pactown-0.1.4.dist-info → pactown-0.1.47.dist-info}/licenses/LICENSE +0 -0
|
@@ -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)]
|