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,533 @@
1
+ """FastAPI endpoints for Quadlet deployment management.
2
+
3
+ Provides a REST API for generating and deploying Markdown services
4
+ using Podman Quadlet on VPS infrastructure.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from pathlib import Path
11
+ from typing import Optional
12
+
13
+ from fastapi import BackgroundTasks, FastAPI, HTTPException
14
+ from fastapi.responses import PlainTextResponse
15
+ from pydantic import BaseModel, Field
16
+
17
+ from .base import DeploymentConfig
18
+ from .quadlet import (
19
+ QuadletBackend,
20
+ QuadletConfig,
21
+ QuadletTemplates,
22
+ generate_markdown_service_quadlet,
23
+ generate_traefik_quadlet,
24
+ )
25
+
26
+
27
+ # Pydantic models for API
28
+ class DeploymentRequest(BaseModel):
29
+ """Request to deploy a Markdown service."""
30
+
31
+ markdown_content: str = Field(..., description="Markdown content to deploy")
32
+ name: Optional[str] = Field(None, description="Service name (auto-generated from content if not provided)")
33
+ tenant_id: str = Field("default", description="Tenant identifier")
34
+ subdomain: Optional[str] = Field(None, description="Subdomain for the service")
35
+ domain: str = Field("localhost", description="Base domain")
36
+ tls_enabled: bool = Field(False, description="Enable TLS/HTTPS")
37
+
38
+ # Resource limits
39
+ cpus: str = Field("0.5", description="CPU limit")
40
+ memory: str = Field("256M", description="Memory limit")
41
+
42
+ # Image
43
+ image: str = Field(
44
+ "ghcr.io/pactown/markdown-server:latest",
45
+ description="Container image for Markdown server"
46
+ )
47
+
48
+
49
+ class ContainerRequest(BaseModel):
50
+ """Request to generate a container Quadlet file."""
51
+
52
+ name: str = Field(..., description="Container name")
53
+ image: str = Field(..., description="Container image")
54
+ port: int = Field(..., description="Container port")
55
+ tenant_id: str = Field("default", description="Tenant identifier")
56
+ subdomain: Optional[str] = Field(None, description="Subdomain")
57
+ domain: str = Field("localhost", description="Base domain")
58
+ tls_enabled: bool = Field(False, description="Enable TLS")
59
+
60
+ # Environment variables
61
+ env: dict[str, str] = Field(default_factory=dict, description="Environment variables")
62
+
63
+ # Volumes
64
+ volumes: list[str] = Field(default_factory=list, description="Volume mounts")
65
+
66
+ # Health check
67
+ health_check: Optional[str] = Field(None, description="Health check endpoint")
68
+
69
+ # Resource limits
70
+ cpus: str = Field("0.5", description="CPU limit")
71
+ memory: str = Field("256M", description="Memory limit")
72
+
73
+
74
+ class TraefikRequest(BaseModel):
75
+ """Request to generate Traefik Quadlet files."""
76
+
77
+ domain: str = Field(..., description="Base domain for Traefik")
78
+ email: Optional[str] = Field(None, description="Email for Let's Encrypt")
79
+
80
+
81
+ class DeploymentResponse(BaseModel):
82
+ """Response from deployment operation."""
83
+
84
+ success: bool
85
+ service_name: str
86
+ message: str
87
+ url: Optional[str] = None
88
+ unit_files: list[str] = Field(default_factory=list)
89
+ error: Optional[str] = None
90
+
91
+
92
+ class QuadletFileResponse(BaseModel):
93
+ """Response containing generated Quadlet files."""
94
+
95
+ files: list[dict[str, str]] # [{filename: content}]
96
+ tenant_path: str
97
+ instructions: str
98
+
99
+
100
+ class ServiceStatus(BaseModel):
101
+ """Service status information."""
102
+
103
+ name: str
104
+ running: bool
105
+ state: str
106
+ pid: Optional[str] = None
107
+ tenant: str
108
+ url: Optional[str] = None
109
+
110
+
111
+ class ListServicesResponse(BaseModel):
112
+ """Response listing all services."""
113
+
114
+ tenant_id: str
115
+ services: list[ServiceStatus]
116
+ total: int
117
+
118
+
119
+ # Create FastAPI app
120
+ def create_quadlet_api(
121
+ default_domain: str = "localhost",
122
+ default_tenant: str = "default",
123
+ user_mode: bool = True,
124
+ ) -> FastAPI:
125
+ """Create FastAPI application for Quadlet management."""
126
+
127
+ app = FastAPI(
128
+ title="Pactown Quadlet API",
129
+ description="Deploy Markdown services on VPS using Podman Quadlet",
130
+ version="1.0.0",
131
+ docs_url="/docs",
132
+ redoc_url="/redoc",
133
+ )
134
+
135
+ # Dependency to get backend
136
+ def get_backend(
137
+ tenant_id: str = default_tenant,
138
+ domain: str = default_domain,
139
+ ) -> QuadletBackend:
140
+ quadlet_config = QuadletConfig(
141
+ tenant_id=tenant_id,
142
+ domain=domain,
143
+ user_mode=user_mode,
144
+ )
145
+ deploy_config = DeploymentConfig.for_production()
146
+ return QuadletBackend(deploy_config, quadlet_config)
147
+
148
+ @app.get("/health")
149
+ async def health():
150
+ """Health check endpoint."""
151
+ return {"status": "healthy", "service": "quadlet-api"}
152
+
153
+ @app.get("/version")
154
+ async def version():
155
+ """Get Podman/Quadlet version."""
156
+ backend = get_backend()
157
+ podman_version = backend.get_quadlet_version()
158
+ return {
159
+ "api_version": "1.0.0",
160
+ "podman_version": podman_version,
161
+ "quadlet_available": backend.is_available(),
162
+ }
163
+
164
+ @app.post("/generate/markdown", response_model=QuadletFileResponse)
165
+ async def generate_markdown_quadlet(request: DeploymentRequest):
166
+ """Generate Quadlet files for a Markdown service.
167
+
168
+ This endpoint generates Quadlet unit files that can be used
169
+ to deploy a Markdown file as a web service.
170
+ """
171
+ # Create config
172
+ quadlet_config = QuadletConfig(
173
+ tenant_id=request.tenant_id,
174
+ domain=request.domain,
175
+ subdomain=request.subdomain,
176
+ tls_enabled=request.tls_enabled,
177
+ cpus=request.cpus,
178
+ memory=request.memory,
179
+ )
180
+
181
+ # Generate service name from content if not provided
182
+ name = request.name
183
+ if not name:
184
+ # Generate name from first heading or hash
185
+ lines = request.markdown_content.strip().split("\n")
186
+ for line in lines:
187
+ if line.startswith("# "):
188
+ name = line[2:].strip().lower().replace(" ", "-").replace("_", "-")[:32]
189
+ break
190
+ if not name:
191
+ name = f"md-{hashlib.sha256(request.markdown_content.encode()).hexdigest()[:8]}"
192
+
193
+ # Create temporary markdown file
194
+ content_hash = hashlib.sha256(request.markdown_content.encode()).hexdigest()[:12]
195
+ markdown_path = Path(f"/tmp/pactown-markdown-{content_hash}.md")
196
+ markdown_path.write_text(request.markdown_content)
197
+
198
+ # Generate units
199
+ units = generate_markdown_service_quadlet(
200
+ markdown_path=markdown_path,
201
+ config=quadlet_config,
202
+ image=request.image,
203
+ )
204
+
205
+ files = []
206
+ for unit in units:
207
+ files.append({
208
+ "filename": unit.filename,
209
+ "content": unit.content,
210
+ })
211
+
212
+ return QuadletFileResponse(
213
+ files=files,
214
+ tenant_path=str(quadlet_config.tenant_path),
215
+ instructions=f"""
216
+ To deploy this service:
217
+
218
+ 1. Save the unit file(s) to: {quadlet_config.tenant_path}/
219
+ 2. Run: systemctl --user daemon-reload
220
+ 3. Run: systemctl --user enable --now {name}.service
221
+ 4. Access at: {"https" if request.tls_enabled else "http"}://{quadlet_config.full_domain}
222
+
223
+ Or use the /deploy/markdown endpoint to deploy automatically.
224
+ """.strip(),
225
+ )
226
+
227
+ @app.post("/generate/container", response_model=QuadletFileResponse)
228
+ async def generate_container_quadlet(request: ContainerRequest):
229
+ """Generate Quadlet files for a custom container."""
230
+ quadlet_config = QuadletConfig(
231
+ tenant_id=request.tenant_id,
232
+ domain=request.domain,
233
+ subdomain=request.subdomain,
234
+ tls_enabled=request.tls_enabled,
235
+ cpus=request.cpus,
236
+ memory=request.memory,
237
+ )
238
+
239
+ unit = QuadletTemplates.container(
240
+ name=request.name,
241
+ image=request.image,
242
+ port=request.port,
243
+ config=quadlet_config,
244
+ env=request.env,
245
+ health_check=request.health_check,
246
+ volumes=request.volumes,
247
+ )
248
+
249
+ return QuadletFileResponse(
250
+ files=[{
251
+ "filename": unit.filename,
252
+ "content": unit.content,
253
+ }],
254
+ tenant_path=str(quadlet_config.tenant_path),
255
+ instructions=f"""
256
+ To deploy this container:
257
+
258
+ 1. Save the unit file to: {quadlet_config.tenant_path}/{unit.filename}
259
+ 2. Run: systemctl --user daemon-reload
260
+ 3. Run: systemctl --user enable --now {request.name}.service
261
+ 4. Access at: {"https" if request.tls_enabled else "http"}://{quadlet_config.full_domain}
262
+ """.strip(),
263
+ )
264
+
265
+ @app.post("/generate/traefik", response_model=QuadletFileResponse)
266
+ async def generate_traefik_files(request: TraefikRequest):
267
+ """Generate Traefik reverse proxy Quadlet files."""
268
+ quadlet_config = QuadletConfig(
269
+ domain=request.domain,
270
+ )
271
+
272
+ units = generate_traefik_quadlet(quadlet_config)
273
+
274
+ files = []
275
+ for unit in units:
276
+ content = unit.content
277
+ if request.email:
278
+ content = content.replace(
279
+ f"admin@{request.domain}",
280
+ request.email
281
+ )
282
+ files.append({
283
+ "filename": unit.filename,
284
+ "content": content,
285
+ })
286
+
287
+ return QuadletFileResponse(
288
+ files=files,
289
+ tenant_path=str(quadlet_config.systemd_path),
290
+ instructions=f"""
291
+ To deploy Traefik:
292
+
293
+ 1. Save the unit files to: {quadlet_config.systemd_path}/
294
+ 2. Run: systemctl --user daemon-reload
295
+ 3. Run: systemctl --user enable --now traefik.service
296
+
297
+ Traefik will automatically:
298
+ - Handle HTTP to HTTPS redirect
299
+ - Provision Let's Encrypt certificates
300
+ - Route traffic to your services based on Host labels
301
+ """.strip(),
302
+ )
303
+
304
+ @app.post("/deploy/markdown", response_model=DeploymentResponse)
305
+ async def deploy_markdown(request: DeploymentRequest, background_tasks: BackgroundTasks):
306
+ """Deploy a Markdown file as a web service.
307
+
308
+ This endpoint generates Quadlet files and starts the service.
309
+ """
310
+ quadlet_config = QuadletConfig(
311
+ tenant_id=request.tenant_id,
312
+ domain=request.domain,
313
+ subdomain=request.subdomain,
314
+ tls_enabled=request.tls_enabled,
315
+ cpus=request.cpus,
316
+ memory=request.memory,
317
+ user_mode=user_mode,
318
+ )
319
+
320
+ deploy_config = DeploymentConfig.for_production()
321
+ backend = QuadletBackend(deploy_config, quadlet_config)
322
+
323
+ if not backend.is_available():
324
+ raise HTTPException(
325
+ status_code=503,
326
+ detail="Podman with Quadlet support not available"
327
+ )
328
+
329
+ # Generate name
330
+ name = request.name
331
+ if not name:
332
+ lines = request.markdown_content.strip().split("\n")
333
+ for line in lines:
334
+ if line.startswith("# "):
335
+ name = line[2:].strip().lower().replace(" ", "-").replace("_", "-")[:32]
336
+ break
337
+ if not name:
338
+ name = f"md-{hashlib.sha256(request.markdown_content.encode()).hexdigest()[:8]}"
339
+
340
+ # Save markdown content
341
+ content_dir = quadlet_config.tenant_path / "content"
342
+ content_dir.mkdir(parents=True, exist_ok=True)
343
+ markdown_path = content_dir / f"{name}.md"
344
+ markdown_path.write_text(request.markdown_content)
345
+
346
+ # Generate and save units
347
+ units = generate_markdown_service_quadlet(
348
+ markdown_path=markdown_path,
349
+ config=quadlet_config,
350
+ image=request.image,
351
+ )
352
+
353
+ unit_files = []
354
+ for unit in units:
355
+ path = unit.save(quadlet_config.tenant_path)
356
+ unit_files.append(str(path))
357
+
358
+ # Reload and start
359
+ backend._systemctl("daemon-reload")
360
+ backend._systemctl("enable", f"{name}.service")
361
+ result = backend._systemctl("start", f"{name}.service")
362
+
363
+ url = f"{'https' if request.tls_enabled else 'http'}://{quadlet_config.full_domain}"
364
+
365
+ if result.returncode == 0:
366
+ return DeploymentResponse(
367
+ success=True,
368
+ service_name=name,
369
+ message=f"Successfully deployed {name}",
370
+ url=url,
371
+ unit_files=unit_files,
372
+ )
373
+ else:
374
+ return DeploymentResponse(
375
+ success=False,
376
+ service_name=name,
377
+ message="Deployment failed",
378
+ error=result.stderr,
379
+ unit_files=unit_files,
380
+ )
381
+
382
+ @app.delete("/deploy/{service_name}", response_model=DeploymentResponse)
383
+ async def undeploy_service(
384
+ service_name: str,
385
+ tenant_id: str = default_tenant,
386
+ ):
387
+ """Remove a deployed service."""
388
+ backend = get_backend(tenant_id=tenant_id)
389
+ result = backend.stop(service_name)
390
+
391
+ return DeploymentResponse(
392
+ success=result.success,
393
+ service_name=service_name,
394
+ message="Service removed" if result.success else "Failed to remove service",
395
+ error=result.error,
396
+ )
397
+
398
+ @app.get("/services", response_model=ListServicesResponse)
399
+ async def list_services(tenant_id: str = default_tenant):
400
+ """List all services for a tenant."""
401
+ backend = get_backend(tenant_id=tenant_id)
402
+ services = backend.list_services()
403
+
404
+ service_statuses = []
405
+ for svc in services:
406
+ status = svc["status"]
407
+ service_statuses.append(ServiceStatus(
408
+ name=svc["name"],
409
+ running=status.get("running", False),
410
+ state=status.get("state", "unknown"),
411
+ pid=status.get("pid"),
412
+ tenant=tenant_id,
413
+ ))
414
+
415
+ return ListServicesResponse(
416
+ tenant_id=tenant_id,
417
+ services=service_statuses,
418
+ total=len(service_statuses),
419
+ )
420
+
421
+ @app.get("/services/{service_name}", response_model=ServiceStatus)
422
+ async def get_service_status(
423
+ service_name: str,
424
+ tenant_id: str = default_tenant,
425
+ ):
426
+ """Get status of a specific service."""
427
+ backend = get_backend(tenant_id=tenant_id)
428
+ status = backend.status(service_name)
429
+
430
+ return ServiceStatus(
431
+ name=service_name,
432
+ running=status.get("running", False),
433
+ state=status.get("state", "unknown"),
434
+ pid=status.get("pid"),
435
+ tenant=tenant_id,
436
+ )
437
+
438
+ @app.post("/services/{service_name}/start")
439
+ async def start_service(
440
+ service_name: str,
441
+ tenant_id: str = default_tenant,
442
+ ):
443
+ """Start a service."""
444
+ backend = get_backend(tenant_id=tenant_id)
445
+ backend._systemctl("daemon-reload")
446
+ result = backend._systemctl("start", f"{service_name}.service")
447
+
448
+ if result.returncode == 0:
449
+ return {"success": True, "message": f"Started {service_name}"}
450
+ else:
451
+ raise HTTPException(status_code=500, detail=result.stderr)
452
+
453
+ @app.post("/services/{service_name}/stop")
454
+ async def stop_service(
455
+ service_name: str,
456
+ tenant_id: str = default_tenant,
457
+ ):
458
+ """Stop a service."""
459
+ backend = get_backend(tenant_id=tenant_id)
460
+ result = backend._systemctl("stop", f"{service_name}.service")
461
+
462
+ if result.returncode == 0:
463
+ return {"success": True, "message": f"Stopped {service_name}"}
464
+ else:
465
+ raise HTTPException(status_code=500, detail=result.stderr)
466
+
467
+ @app.post("/services/{service_name}/restart")
468
+ async def restart_service(
469
+ service_name: str,
470
+ tenant_id: str = default_tenant,
471
+ ):
472
+ """Restart a service."""
473
+ backend = get_backend(tenant_id=tenant_id)
474
+ result = backend._systemctl("restart", f"{service_name}.service")
475
+
476
+ if result.returncode == 0:
477
+ return {"success": True, "message": f"Restarted {service_name}"}
478
+ else:
479
+ raise HTTPException(status_code=500, detail=result.stderr)
480
+
481
+ @app.get("/services/{service_name}/logs", response_class=PlainTextResponse)
482
+ async def get_service_logs(
483
+ service_name: str,
484
+ tenant_id: str = default_tenant,
485
+ lines: int = 100,
486
+ ):
487
+ """Get logs for a service."""
488
+ backend = get_backend(tenant_id=tenant_id)
489
+ logs = backend.logs(service_name, tail=lines)
490
+ return logs
491
+
492
+ @app.get("/unit/{service_name}", response_class=PlainTextResponse)
493
+ async def get_unit_file(
494
+ service_name: str,
495
+ tenant_id: str = default_tenant,
496
+ ):
497
+ """Get the Quadlet unit file content for a service."""
498
+ quadlet_config = QuadletConfig(tenant_id=tenant_id, user_mode=user_mode)
499
+
500
+ for ext in ["container", "pod", "network", "volume", "kube"]:
501
+ unit_path = quadlet_config.tenant_path / f"{service_name}.{ext}"
502
+ if unit_path.exists():
503
+ return unit_path.read_text()
504
+
505
+ raise HTTPException(status_code=404, detail="Unit file not found")
506
+
507
+ return app
508
+
509
+
510
+ # Default app instance
511
+ app = create_quadlet_api()
512
+
513
+
514
+ def run_api(
515
+ host: str = "0.0.0.0",
516
+ port: int = 8800,
517
+ domain: str = "localhost",
518
+ tenant: str = "default",
519
+ ):
520
+ """Run the Quadlet API server."""
521
+ import uvicorn
522
+
523
+ global app
524
+ app = create_quadlet_api(
525
+ default_domain=domain,
526
+ default_tenant=tenant,
527
+ )
528
+
529
+ uvicorn.run(app, host=host, port=port)
530
+
531
+
532
+ if __name__ == "__main__":
533
+ run_api()