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,557 @@
1
+ """Interactive shell for Podman Quadlet deployment management.
2
+
3
+ Provides a REPL-style interface for managing Quadlet deployments,
4
+ generating unit files, and deploying Markdown services.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import cmd
10
+ import shutil
11
+ from pathlib import Path
12
+
13
+ from rich.console import Console
14
+ from rich.panel import Panel
15
+ from rich.prompt import Confirm
16
+ from rich.syntax import Syntax
17
+ from rich.table import Table
18
+
19
+ from .base import DeploymentConfig
20
+ from .quadlet import (
21
+ QuadletBackend,
22
+ QuadletConfig,
23
+ QuadletTemplates,
24
+ generate_markdown_service_quadlet,
25
+ generate_traefik_quadlet,
26
+ )
27
+
28
+ console = Console()
29
+
30
+
31
+ class QuadletShell(cmd.Cmd):
32
+ """Interactive shell for Quadlet deployment management."""
33
+
34
+ intro = """
35
+ ╔══════════════════════════════════════════════════════════════════╗
36
+ ║ 🚀 Pactown Quadlet Deployment Shell ║
37
+ ║ ║
38
+ ║ Deploy Markdown services on VPS with Podman Quadlet ║
39
+ ║ Type 'help' for available commands ║
40
+ ╚══════════════════════════════════════════════════════════════════╝
41
+ """
42
+ prompt = "pactown-quadlet> "
43
+
44
+ def __init__(
45
+ self,
46
+ tenant_id: str = "default",
47
+ domain: str = "localhost",
48
+ user_mode: bool = True,
49
+ ):
50
+ super().__init__()
51
+ self.quadlet_config = QuadletConfig(
52
+ tenant_id=tenant_id,
53
+ domain=domain,
54
+ user_mode=user_mode,
55
+ )
56
+ self.deploy_config = DeploymentConfig.for_production()
57
+ self.backend = QuadletBackend(self.deploy_config, self.quadlet_config)
58
+
59
+ # Check availability
60
+ if not self.backend.is_available():
61
+ console.print("[yellow]⚠ Warning: Podman 4.4+ with Quadlet support not detected[/yellow]")
62
+ console.print("[dim]Some features may not work. Install Podman 4.4+ for full functionality.[/dim]")
63
+
64
+ def do_status(self, arg: str):
65
+ """Show current configuration and status.
66
+
67
+ Usage: status
68
+ """
69
+ table = Table(title="Quadlet Configuration")
70
+ table.add_column("Setting", style="cyan")
71
+ table.add_column("Value", style="green")
72
+
73
+ table.add_row("Tenant ID", self.quadlet_config.tenant_id)
74
+ table.add_row("Domain", self.quadlet_config.domain)
75
+ table.add_row("Full Domain", self.quadlet_config.full_domain)
76
+ table.add_row("TLS Enabled", str(self.quadlet_config.tls_enabled))
77
+ table.add_row("User Mode", str(self.quadlet_config.user_mode))
78
+ table.add_row("Systemd Path", str(self.quadlet_config.systemd_path))
79
+ table.add_row("Tenant Path", str(self.quadlet_config.tenant_path))
80
+ table.add_row("Traefik Enabled", str(self.quadlet_config.traefik_enabled))
81
+ table.add_row("CPU Limit", self.quadlet_config.cpus)
82
+ table.add_row("Memory Limit", self.quadlet_config.memory)
83
+
84
+ console.print(table)
85
+
86
+ # Podman version
87
+ version = self.backend.get_quadlet_version()
88
+ if version:
89
+ console.print(f"\n[green]✓ Podman {version} available[/green]")
90
+ else:
91
+ console.print("\n[red]✗ Podman not available[/red]")
92
+
93
+ def do_config(self, arg: str):
94
+ """Configure deployment settings.
95
+
96
+ Usage: config <setting> <value>
97
+
98
+ Settings:
99
+ tenant - Tenant ID
100
+ domain - Base domain
101
+ subdomain - Subdomain for service
102
+ tls - Enable TLS (true/false)
103
+ cpus - CPU limit (e.g., 0.5)
104
+ memory - Memory limit (e.g., 256M)
105
+
106
+ Example:
107
+ config domain pactown.com
108
+ config subdomain api
109
+ config tls true
110
+ """
111
+ parts = arg.split(maxsplit=1)
112
+ if len(parts) < 2:
113
+ console.print("[yellow]Usage: config <setting> <value>[/yellow]")
114
+ return
115
+
116
+ setting, value = parts
117
+
118
+ if setting == "tenant":
119
+ self.quadlet_config.tenant_id = value
120
+ elif setting == "domain":
121
+ self.quadlet_config.domain = value
122
+ elif setting == "subdomain":
123
+ self.quadlet_config.subdomain = value if value != "none" else None
124
+ elif setting == "tls":
125
+ self.quadlet_config.tls_enabled = value.lower() in ("true", "1", "yes")
126
+ elif setting == "cpus":
127
+ self.quadlet_config.cpus = value
128
+ elif setting == "memory":
129
+ self.quadlet_config.memory = value
130
+ else:
131
+ console.print(f"[red]Unknown setting: {setting}[/red]")
132
+ return
133
+
134
+ console.print(f"[green]✓ Set {setting} = {value}[/green]")
135
+
136
+ def do_generate(self, arg: str):
137
+ """Generate Quadlet unit files for a Markdown file.
138
+
139
+ Usage: generate <markdown_path> [image]
140
+
141
+ Example:
142
+ generate ./README.md
143
+ generate ./docs/API.md ghcr.io/pactown/markdown-server:latest
144
+ """
145
+ parts = arg.split()
146
+ if not parts:
147
+ console.print("[yellow]Usage: generate <markdown_path> [image][/yellow]")
148
+ return
149
+
150
+ markdown_path = Path(parts[0]).resolve()
151
+ image = parts[1] if len(parts) > 1 else "ghcr.io/pactown/markdown-server:latest"
152
+
153
+ if not markdown_path.exists():
154
+ console.print(f"[red]File not found: {markdown_path}[/red]")
155
+ return
156
+
157
+ units = generate_markdown_service_quadlet(
158
+ markdown_path=markdown_path,
159
+ config=self.quadlet_config,
160
+ image=image,
161
+ )
162
+
163
+ console.print(f"\n[bold]Generated {len(units)} unit file(s):[/bold]\n")
164
+
165
+ for unit in units:
166
+ console.print(Panel(
167
+ Syntax(unit.content, "ini", theme="monokai"),
168
+ title=f"📄 {unit.filename}",
169
+ border_style="blue",
170
+ ))
171
+
172
+ # Ask to save
173
+ if Confirm.ask("\nSave to systemd directory?"):
174
+ tenant_path = self.quadlet_config.tenant_path
175
+ tenant_path.mkdir(parents=True, exist_ok=True)
176
+
177
+ for unit in units:
178
+ path = unit.save(tenant_path)
179
+ console.print(f"[green]✓ Saved: {path}[/green]")
180
+
181
+ console.print("\n[dim]Run 'reload' to apply changes[/dim]")
182
+
183
+ def do_generate_container(self, arg: str):
184
+ """Generate a custom container Quadlet file.
185
+
186
+ Usage: generate_container <name> <image> <port>
187
+
188
+ Example:
189
+ generate_container api nginx:latest 8080
190
+ generate_container web python:3.12-slim 5000
191
+ """
192
+ parts = arg.split()
193
+ if len(parts) < 3:
194
+ console.print("[yellow]Usage: generate_container <name> <image> <port>[/yellow]")
195
+ return
196
+
197
+ name, image, port = parts[0], parts[1], int(parts[2])
198
+
199
+ unit = QuadletTemplates.container(
200
+ name=name,
201
+ image=image,
202
+ port=port,
203
+ config=self.quadlet_config,
204
+ )
205
+
206
+ console.print(Panel(
207
+ Syntax(unit.content, "ini", theme="monokai"),
208
+ title=f"📄 {unit.filename}",
209
+ border_style="blue",
210
+ ))
211
+
212
+ if Confirm.ask("\nSave to systemd directory?"):
213
+ tenant_path = self.quadlet_config.tenant_path
214
+ path = unit.save(tenant_path)
215
+ console.print(f"[green]✓ Saved: {path}[/green]")
216
+
217
+ def do_generate_traefik(self, arg: str):
218
+ """Generate Traefik reverse proxy Quadlet files.
219
+
220
+ Usage: generate_traefik
221
+
222
+ This generates Traefik container and volume unit files
223
+ for automatic HTTPS with Let's Encrypt.
224
+ """
225
+ units = generate_traefik_quadlet(self.quadlet_config)
226
+
227
+ console.print(f"\n[bold]Generated {len(units)} unit file(s):[/bold]\n")
228
+
229
+ for unit in units:
230
+ console.print(Panel(
231
+ Syntax(unit.content, "ini", theme="monokai"),
232
+ title=f"📄 {unit.filename}",
233
+ border_style="blue",
234
+ ))
235
+
236
+ if Confirm.ask("\nSave to systemd directory?"):
237
+ systemd_path = self.quadlet_config.systemd_path
238
+ systemd_path.mkdir(parents=True, exist_ok=True)
239
+
240
+ for unit in units:
241
+ path = unit.save(systemd_path)
242
+ console.print(f"[green]✓ Saved: {path}[/green]")
243
+
244
+ def do_list(self, arg: str):
245
+ """List all Quadlet services for current tenant.
246
+
247
+ Usage: list
248
+ """
249
+ services = self.backend.list_services()
250
+
251
+ if not services:
252
+ console.print("[yellow]No services found for tenant: {self.quadlet_config.tenant_id}[/yellow]")
253
+ return
254
+
255
+ table = Table(title=f"Services (tenant: {self.quadlet_config.tenant_id})")
256
+ table.add_column("Name", style="cyan")
257
+ table.add_column("Status", style="green")
258
+ table.add_column("PID", style="dim")
259
+ table.add_column("Unit File", style="dim")
260
+
261
+ for svc in services:
262
+ status = svc["status"]
263
+ status_str = "🟢 running" if status.get("running") else "🔴 stopped"
264
+ table.add_row(
265
+ svc["name"],
266
+ status_str,
267
+ status.get("pid", "-"),
268
+ svc["unit_file"],
269
+ )
270
+
271
+ console.print(table)
272
+
273
+ def do_start(self, arg: str):
274
+ """Start a Quadlet service.
275
+
276
+ Usage: start <service_name>
277
+ """
278
+ if not arg:
279
+ console.print("[yellow]Usage: start <service_name>[/yellow]")
280
+ return
281
+
282
+ console.print(f"Starting {arg}...")
283
+ self.backend._systemctl("daemon-reload")
284
+ result = self.backend._systemctl("start", f"{arg}.service")
285
+
286
+ if result.returncode == 0:
287
+ console.print(f"[green]✓ Started {arg}[/green]")
288
+ else:
289
+ console.print(f"[red]✗ Failed to start {arg}: {result.stderr}[/red]")
290
+
291
+ def do_stop(self, arg: str):
292
+ """Stop a Quadlet service.
293
+
294
+ Usage: stop <service_name>
295
+ """
296
+ if not arg:
297
+ console.print("[yellow]Usage: stop <service_name>[/yellow]")
298
+ return
299
+
300
+ result = self.backend._systemctl("stop", f"{arg}.service")
301
+
302
+ if result.returncode == 0:
303
+ console.print(f"[green]✓ Stopped {arg}[/green]")
304
+ else:
305
+ console.print(f"[red]✗ Failed to stop {arg}: {result.stderr}[/red]")
306
+
307
+ def do_restart(self, arg: str):
308
+ """Restart a Quadlet service.
309
+
310
+ Usage: restart <service_name>
311
+ """
312
+ if not arg:
313
+ console.print("[yellow]Usage: restart <service_name>[/yellow]")
314
+ return
315
+
316
+ result = self.backend._systemctl("restart", f"{arg}.service")
317
+
318
+ if result.returncode == 0:
319
+ console.print(f"[green]✓ Restarted {arg}[/green]")
320
+ else:
321
+ console.print(f"[red]✗ Failed to restart {arg}: {result.stderr}[/red]")
322
+
323
+ def do_logs(self, arg: str):
324
+ """Show logs for a Quadlet service.
325
+
326
+ Usage: logs <service_name> [lines]
327
+
328
+ Example:
329
+ logs api
330
+ logs api 50
331
+ """
332
+ parts = arg.split()
333
+ if not parts:
334
+ console.print("[yellow]Usage: logs <service_name> [lines][/yellow]")
335
+ return
336
+
337
+ service = parts[0]
338
+ lines = int(parts[1]) if len(parts) > 1 else 50
339
+
340
+ output = self.backend.logs(service, tail=lines)
341
+ console.print(Panel(output or "[dim]No logs available[/dim]", title=f"Logs: {service}"))
342
+
343
+ def do_reload(self, arg: str):
344
+ """Reload systemd daemon to apply Quadlet changes.
345
+
346
+ Usage: reload
347
+ """
348
+ console.print("Reloading systemd daemon...")
349
+ result = self.backend._systemctl("daemon-reload")
350
+
351
+ if result.returncode == 0:
352
+ console.print("[green]✓ Daemon reloaded[/green]")
353
+ else:
354
+ console.print(f"[red]✗ Failed to reload: {result.stderr}[/red]")
355
+
356
+ def do_deploy(self, arg: str):
357
+ """Deploy a Markdown file as a web service.
358
+
359
+ Usage: deploy <markdown_path> [subdomain]
360
+
361
+ Example:
362
+ deploy ./README.md docs
363
+ deploy ./API.md api
364
+ """
365
+ parts = arg.split()
366
+ if not parts:
367
+ console.print("[yellow]Usage: deploy <markdown_path> [subdomain][/yellow]")
368
+ return
369
+
370
+ markdown_path = Path(parts[0]).resolve()
371
+ if len(parts) > 1:
372
+ self.quadlet_config.subdomain = parts[1]
373
+
374
+ if not markdown_path.exists():
375
+ console.print(f"[red]File not found: {markdown_path}[/red]")
376
+ return
377
+
378
+ console.print(f"\n[bold]Deploying: {markdown_path.name}[/bold]")
379
+ console.print(f" Domain: {self.quadlet_config.full_domain}")
380
+ console.print(f" Tenant: {self.quadlet_config.tenant_id}")
381
+ console.print()
382
+
383
+ # Generate units
384
+ units = generate_markdown_service_quadlet(
385
+ markdown_path=markdown_path,
386
+ config=self.quadlet_config,
387
+ )
388
+
389
+ # Save units
390
+ tenant_path = self.quadlet_config.tenant_path
391
+ for unit in units:
392
+ unit.save(tenant_path)
393
+ console.print(f"[dim]Generated: {unit.filename}[/dim]")
394
+
395
+ # Reload and start
396
+ self.backend._systemctl("daemon-reload")
397
+
398
+ service_name = units[0].name
399
+ self.backend._systemctl("enable", f"{service_name}.service")
400
+ result = self.backend._systemctl("start", f"{service_name}.service")
401
+
402
+ if result.returncode == 0:
403
+ url = f"https://{self.quadlet_config.full_domain}" if self.quadlet_config.tls_enabled else f"http://{self.quadlet_config.full_domain}"
404
+ console.print("\n[green]✓ Deployed successfully![/green]")
405
+ console.print(f" URL: {url}")
406
+ else:
407
+ console.print(f"\n[red]✗ Deployment failed: {result.stderr}[/red]")
408
+
409
+ def do_undeploy(self, arg: str):
410
+ """Remove a deployed service.
411
+
412
+ Usage: undeploy <service_name>
413
+ """
414
+ if not arg:
415
+ console.print("[yellow]Usage: undeploy <service_name>[/yellow]")
416
+ return
417
+
418
+ if not Confirm.ask(f"Remove service '{arg}'?"):
419
+ return
420
+
421
+ result = self.backend.stop(arg)
422
+
423
+ if result.success:
424
+ console.print(f"[green]✓ Removed {arg}[/green]")
425
+ else:
426
+ console.print(f"[red]✗ Failed to remove {arg}: {result.error}[/red]")
427
+
428
+ def do_init(self, arg: str):
429
+ """Initialize Quadlet directories and Traefik proxy.
430
+
431
+ Usage: init
432
+
433
+ This creates the systemd directories and optionally
434
+ sets up Traefik as a reverse proxy.
435
+ """
436
+ console.print("[bold]Initializing Quadlet deployment environment...[/bold]\n")
437
+
438
+ # Create directories
439
+ systemd_path = self.quadlet_config.systemd_path
440
+ tenant_path = self.quadlet_config.tenant_path
441
+
442
+ systemd_path.mkdir(parents=True, exist_ok=True)
443
+ tenant_path.mkdir(parents=True, exist_ok=True)
444
+
445
+ console.print(f"[green]✓ Created: {systemd_path}[/green]")
446
+ console.print(f"[green]✓ Created: {tenant_path}[/green]")
447
+
448
+ # Setup Traefik
449
+ if Confirm.ask("\nSetup Traefik reverse proxy?"):
450
+ units = generate_traefik_quadlet(self.quadlet_config)
451
+ for unit in units:
452
+ unit.save(systemd_path)
453
+ console.print(f"[green]✓ Created: {unit.filename}[/green]")
454
+
455
+ self.backend._systemctl("daemon-reload")
456
+ self.backend._systemctl("enable", "traefik.service")
457
+ self.backend._systemctl("start", "traefik.service")
458
+ console.print("[green]✓ Traefik started[/green]")
459
+
460
+ console.print("\n[bold green]Initialization complete![/bold green]")
461
+
462
+ def do_export(self, arg: str):
463
+ """Export all unit files to a directory.
464
+
465
+ Usage: export <output_dir>
466
+ """
467
+ if not arg:
468
+ console.print("[yellow]Usage: export <output_dir>[/yellow]")
469
+ return
470
+
471
+ output_dir = Path(arg)
472
+ output_dir.mkdir(parents=True, exist_ok=True)
473
+
474
+ tenant_path = self.quadlet_config.tenant_path
475
+ if tenant_path.exists():
476
+ for f in tenant_path.glob("*"):
477
+ if f.is_file():
478
+ shutil.copy(f, output_dir)
479
+ console.print(f"[dim]Exported: {f.name}[/dim]")
480
+
481
+ console.print(f"\n[green]✓ Exported to: {output_dir}[/green]")
482
+
483
+ def do_help(self, arg: str):
484
+ """Show help for commands."""
485
+ if arg:
486
+ super().do_help(arg)
487
+ else:
488
+ console.print(Panel("""
489
+ [bold cyan]Deployment Commands:[/bold cyan]
490
+ deploy - Deploy a Markdown file as a web service
491
+ undeploy - Remove a deployed service
492
+ start - Start a service
493
+ stop - Stop a service
494
+ restart - Restart a service
495
+
496
+ [bold cyan]Generation Commands:[/bold cyan]
497
+ generate - Generate Quadlet files for Markdown
498
+ generate_container - Generate custom container Quadlet
499
+ generate_traefik - Generate Traefik reverse proxy
500
+
501
+ [bold cyan]Management Commands:[/bold cyan]
502
+ status - Show configuration and status
503
+ config - Configure deployment settings
504
+ list - List all services
505
+ logs - Show service logs
506
+ reload - Reload systemd daemon
507
+ init - Initialize Quadlet environment
508
+ export - Export unit files
509
+
510
+ [bold cyan]Other:[/bold cyan]
511
+ help - Show this help
512
+ quit - Exit the shell
513
+ """, title="Available Commands"))
514
+
515
+ def do_quit(self, arg: str):
516
+ """Exit the shell."""
517
+ console.print("[dim]Goodbye![/dim]")
518
+ return True
519
+
520
+ def do_exit(self, arg: str):
521
+ """Exit the shell."""
522
+ return self.do_quit(arg)
523
+
524
+ def do_EOF(self, arg: str):
525
+ """Exit on Ctrl+D."""
526
+ console.print()
527
+ return self.do_quit(arg)
528
+
529
+ def default(self, line: str):
530
+ """Handle unknown commands."""
531
+ console.print(f"[red]Unknown command: {line}[/red]")
532
+ console.print("[dim]Type 'help' for available commands[/dim]")
533
+
534
+ def emptyline(self):
535
+ """Do nothing on empty line."""
536
+ pass
537
+
538
+
539
+ def run_shell(
540
+ tenant_id: str = "default",
541
+ domain: str = "localhost",
542
+ user_mode: bool = True,
543
+ ):
544
+ """Run the interactive Quadlet shell."""
545
+ shell = QuadletShell(
546
+ tenant_id=tenant_id,
547
+ domain=domain,
548
+ user_mode=user_mode,
549
+ )
550
+ try:
551
+ shell.cmdloop()
552
+ except KeyboardInterrupt:
553
+ console.print("\n[dim]Goodbye![/dim]")
554
+
555
+
556
+ if __name__ == "__main__":
557
+ run_shell()