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
pactown/cli.py
CHANGED
|
@@ -5,20 +5,49 @@ from pathlib import Path
|
|
|
5
5
|
from typing import Optional
|
|
6
6
|
|
|
7
7
|
import click
|
|
8
|
+
import yaml
|
|
8
9
|
from rich.console import Console
|
|
9
10
|
from rich.panel import Panel
|
|
10
|
-
import yaml
|
|
11
11
|
|
|
12
12
|
from . import __version__
|
|
13
|
-
from .config import
|
|
14
|
-
from .
|
|
13
|
+
from .config import load_config
|
|
14
|
+
from .generator import generate_config, print_scan_results
|
|
15
|
+
from .orchestrator import Orchestrator
|
|
15
16
|
from .resolver import DependencyResolver
|
|
16
|
-
from .generator import scan_folder, generate_config, print_scan_results
|
|
17
|
-
|
|
18
17
|
|
|
19
18
|
console = Console()
|
|
20
19
|
|
|
21
20
|
|
|
21
|
+
def is_lolm_available() -> bool:
|
|
22
|
+
from .llm import is_lolm_available as _is_lolm_available
|
|
23
|
+
|
|
24
|
+
return _is_lolm_available()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_llm_status() -> dict:
|
|
28
|
+
from .llm import get_llm_status as _get_llm_status
|
|
29
|
+
|
|
30
|
+
return _get_llm_status()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_llm(*, verbose: bool = False):
|
|
34
|
+
from .llm import get_llm as _get_llm
|
|
35
|
+
|
|
36
|
+
return _get_llm(verbose=verbose)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def set_llm_priority(provider: str, priority: int) -> bool:
|
|
40
|
+
from .llm import set_provider_priority as _set_provider_priority
|
|
41
|
+
|
|
42
|
+
return bool(_set_provider_priority(provider, priority))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def reset_llm_provider(provider: str) -> bool:
|
|
46
|
+
from .llm import reset_provider as _reset_provider
|
|
47
|
+
|
|
48
|
+
return bool(_reset_provider(provider))
|
|
49
|
+
|
|
50
|
+
|
|
22
51
|
@click.group()
|
|
23
52
|
@click.version_option(version=__version__, prog_name="pactown")
|
|
24
53
|
def cli():
|
|
@@ -38,12 +67,12 @@ def up(config_path: str, dry_run: bool, no_health: bool, quiet: bool, sequential
|
|
|
38
67
|
try:
|
|
39
68
|
config = load_config(config_path)
|
|
40
69
|
orch = Orchestrator(config, base_path=Path(config_path).parent, verbose=not quiet)
|
|
41
|
-
|
|
70
|
+
|
|
42
71
|
if dry_run:
|
|
43
72
|
console.print(f"[bold]Dry run: {config.name}[/bold]\n")
|
|
44
73
|
resolver = DependencyResolver(config)
|
|
45
74
|
order = resolver.get_startup_order()
|
|
46
|
-
|
|
75
|
+
|
|
47
76
|
console.print("Would start services in order:")
|
|
48
77
|
for i, name in enumerate(order, 1):
|
|
49
78
|
svc = config.services[name]
|
|
@@ -51,16 +80,16 @@ def up(config_path: str, dry_run: bool, no_health: bool, quiet: bool, sequential
|
|
|
51
80
|
deps_str = f" (deps: {', '.join(deps)})" if deps else ""
|
|
52
81
|
console.print(f" {i}. {name}:{svc.port}{deps_str}")
|
|
53
82
|
return
|
|
54
|
-
|
|
83
|
+
|
|
55
84
|
if not orch.validate():
|
|
56
85
|
sys.exit(1)
|
|
57
|
-
|
|
86
|
+
|
|
58
87
|
orch.start_all(
|
|
59
88
|
wait_for_health=not no_health,
|
|
60
89
|
parallel=not sequential,
|
|
61
90
|
max_workers=workers,
|
|
62
91
|
)
|
|
63
|
-
|
|
92
|
+
|
|
64
93
|
console.print("\n[dim]Press Ctrl+C to stop all services[/dim]\n")
|
|
65
94
|
try:
|
|
66
95
|
while True:
|
|
@@ -69,7 +98,7 @@ def up(config_path: str, dry_run: bool, no_health: bool, quiet: bool, sequential
|
|
|
69
98
|
except KeyboardInterrupt:
|
|
70
99
|
console.print("\n[yellow]Shutting down...[/yellow]")
|
|
71
100
|
orch.stop_all()
|
|
72
|
-
|
|
101
|
+
|
|
73
102
|
except Exception as e:
|
|
74
103
|
console.print(f"[red]Error: {e}[/red]")
|
|
75
104
|
sys.exit(1)
|
|
@@ -109,7 +138,7 @@ def validate(config_path: str):
|
|
|
109
138
|
try:
|
|
110
139
|
config = load_config(config_path)
|
|
111
140
|
orch = Orchestrator(config, base_path=Path(config_path).parent)
|
|
112
|
-
|
|
141
|
+
|
|
113
142
|
if orch.validate():
|
|
114
143
|
sys.exit(0)
|
|
115
144
|
else:
|
|
@@ -165,11 +194,11 @@ def init(name: str, output: str):
|
|
|
165
194
|
},
|
|
166
195
|
},
|
|
167
196
|
}
|
|
168
|
-
|
|
197
|
+
|
|
169
198
|
output_path = Path(output)
|
|
170
199
|
with open(output_path, "w") as f:
|
|
171
200
|
yaml.dump(config, f, default_flow_style=False, sort_keys=False)
|
|
172
|
-
|
|
201
|
+
|
|
173
202
|
console.print(f"[green]Created {output_path}[/green]")
|
|
174
203
|
console.print("\nNext steps:")
|
|
175
204
|
console.print(" 1. Create service README.md files")
|
|
@@ -184,10 +213,16 @@ def publish(config_path: str, registry: str):
|
|
|
184
213
|
"""Publish all modules to registry."""
|
|
185
214
|
try:
|
|
186
215
|
from .registry.client import RegistryClient
|
|
187
|
-
|
|
216
|
+
|
|
188
217
|
config = load_config(config_path)
|
|
189
218
|
client = RegistryClient(registry)
|
|
190
|
-
|
|
219
|
+
|
|
220
|
+
if not client.health():
|
|
221
|
+
console.print(
|
|
222
|
+
f"[red]Error: Registry not reachable at {registry}. Start it with: make registry[/red]"
|
|
223
|
+
)
|
|
224
|
+
sys.exit(1)
|
|
225
|
+
|
|
191
226
|
for name, service in config.services.items():
|
|
192
227
|
readme_path = Path(config_path).parent / service.readme
|
|
193
228
|
if readme_path.exists():
|
|
@@ -213,10 +248,16 @@ def pull(config_path: str, registry: str):
|
|
|
213
248
|
"""Pull dependencies from registry."""
|
|
214
249
|
try:
|
|
215
250
|
from .registry.client import RegistryClient
|
|
216
|
-
|
|
251
|
+
|
|
217
252
|
config = load_config(config_path)
|
|
218
253
|
client = RegistryClient(registry)
|
|
219
|
-
|
|
254
|
+
|
|
255
|
+
if not client.health():
|
|
256
|
+
console.print(
|
|
257
|
+
f"[red]Error: Registry not reachable at {registry}. Start it with: make registry[/red]"
|
|
258
|
+
)
|
|
259
|
+
sys.exit(1)
|
|
260
|
+
|
|
220
261
|
for name, service in config.services.items():
|
|
221
262
|
for dep in service.depends_on:
|
|
222
263
|
if dep.name not in config.services:
|
|
@@ -244,31 +285,32 @@ def scan(folder: str):
|
|
|
244
285
|
@click.option("--base-port", "-p", default=8000, type=int, help="Starting port")
|
|
245
286
|
def generate(folder: str, name: Optional[str], output: str, base_port: int):
|
|
246
287
|
"""Generate pactown config from a folder of README.md files.
|
|
247
|
-
|
|
288
|
+
|
|
248
289
|
Example:
|
|
249
290
|
pactown generate ./examples -o my-ecosystem.pactown.yaml
|
|
250
291
|
"""
|
|
251
292
|
try:
|
|
252
293
|
folder_path = Path(folder)
|
|
253
294
|
output_path = Path(output)
|
|
254
|
-
|
|
295
|
+
|
|
255
296
|
console.print(f"[bold]Scanning {folder_path}...[/bold]\n")
|
|
256
297
|
print_scan_results(folder_path)
|
|
257
|
-
|
|
298
|
+
|
|
258
299
|
console.print()
|
|
300
|
+
|
|
259
301
|
config = generate_config(
|
|
260
302
|
folder=folder_path,
|
|
261
303
|
name=name,
|
|
262
304
|
base_port=base_port,
|
|
263
305
|
output=output_path,
|
|
264
306
|
)
|
|
265
|
-
|
|
307
|
+
|
|
266
308
|
console.print(f"\n[green]✓ Generated {output_path}[/green]")
|
|
267
309
|
console.print(f" Services: {len(config['services'])}")
|
|
268
|
-
console.print(
|
|
310
|
+
console.print("\nNext steps:")
|
|
269
311
|
console.print(f" pactown validate {output}")
|
|
270
312
|
console.print(f" pactown up {output}")
|
|
271
|
-
|
|
313
|
+
|
|
272
314
|
except Exception as e:
|
|
273
315
|
console.print(f"[red]Error: {e}[/red]")
|
|
274
316
|
sys.exit(1)
|
|
@@ -282,24 +324,24 @@ def generate(folder: str, name: Optional[str], output: str, base_port: int):
|
|
|
282
324
|
def deploy(config_path: str, output: str, production: bool, kubernetes: bool):
|
|
283
325
|
"""Generate deployment files (Docker Compose, Kubernetes)."""
|
|
284
326
|
try:
|
|
327
|
+
from .deploy.base import DeploymentConfig
|
|
285
328
|
from .deploy.compose import generate_compose_from_config
|
|
286
329
|
from .deploy.kubernetes import KubernetesBackend
|
|
287
|
-
|
|
288
|
-
|
|
330
|
+
|
|
289
331
|
config_path = Path(config_path)
|
|
290
332
|
output_dir = Path(output)
|
|
291
333
|
output_dir.mkdir(parents=True, exist_ok=True)
|
|
292
|
-
|
|
334
|
+
|
|
293
335
|
if kubernetes:
|
|
294
336
|
# Generate Kubernetes manifests
|
|
295
337
|
from .config import load_config
|
|
296
338
|
ecosystem = load_config(config_path)
|
|
297
339
|
deploy_config = DeploymentConfig.for_production() if production else DeploymentConfig.for_development()
|
|
298
340
|
k8s = KubernetesBackend(deploy_config)
|
|
299
|
-
|
|
341
|
+
|
|
300
342
|
k8s_dir = output_dir / "kubernetes"
|
|
301
343
|
k8s_dir.mkdir(exist_ok=True)
|
|
302
|
-
|
|
344
|
+
|
|
303
345
|
for name, service in ecosystem.services.items():
|
|
304
346
|
image = f"{deploy_config.image_prefix}/{name}:latest"
|
|
305
347
|
manifests = k8s.generate_manifests(
|
|
@@ -311,9 +353,9 @@ def deploy(config_path: str, output: str, production: bool, kubernetes: bool):
|
|
|
311
353
|
)
|
|
312
354
|
k8s.save_manifests(name, manifests, k8s_dir)
|
|
313
355
|
console.print(f" [green]✓[/green] {k8s_dir}/{name}.yaml")
|
|
314
|
-
|
|
356
|
+
|
|
315
357
|
console.print(f"\n[green]Generated Kubernetes manifests in {k8s_dir}[/green]")
|
|
316
|
-
console.print(
|
|
358
|
+
console.print("\nDeploy with:")
|
|
317
359
|
console.print(f" kubectl apply -f {k8s_dir}/")
|
|
318
360
|
else:
|
|
319
361
|
# Generate Docker Compose
|
|
@@ -322,15 +364,15 @@ def deploy(config_path: str, output: str, production: bool, kubernetes: bool):
|
|
|
322
364
|
output_dir=output_dir,
|
|
323
365
|
production=production,
|
|
324
366
|
)
|
|
325
|
-
|
|
367
|
+
|
|
326
368
|
console.print(f"\n[green]Generated Docker Compose files in {output_dir}[/green]")
|
|
327
|
-
console.print(
|
|
369
|
+
console.print("\nRun with:")
|
|
328
370
|
if production:
|
|
329
|
-
console.print(
|
|
371
|
+
console.print(" docker compose -f docker-compose.yaml -f docker-compose.prod.yaml up -d")
|
|
330
372
|
else:
|
|
331
|
-
console.print(
|
|
332
|
-
console.print(
|
|
333
|
-
|
|
373
|
+
console.print(" docker compose up -d")
|
|
374
|
+
console.print(" # or: podman-compose up -d")
|
|
375
|
+
|
|
334
376
|
except Exception as e:
|
|
335
377
|
console.print(f"[red]Error: {e}[/red]")
|
|
336
378
|
import traceback
|
|
@@ -338,6 +380,466 @@ def deploy(config_path: str, output: str, production: bool, kubernetes: bool):
|
|
|
338
380
|
sys.exit(1)
|
|
339
381
|
|
|
340
382
|
|
|
383
|
+
@cli.group()
|
|
384
|
+
def quadlet():
|
|
385
|
+
"""Podman Quadlet deployment commands for VPS production."""
|
|
386
|
+
pass
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@quadlet.command("shell")
|
|
390
|
+
@click.option("--tenant", "-t", default="default", help="Tenant ID")
|
|
391
|
+
@click.option("--domain", "-d", default="localhost", help="Base domain")
|
|
392
|
+
@click.option("--system", is_flag=True, help="Use system-wide systemd (requires root)")
|
|
393
|
+
def quadlet_shell(tenant: str, domain: str, system: bool):
|
|
394
|
+
"""Start interactive Quadlet deployment shell.
|
|
395
|
+
|
|
396
|
+
Example:
|
|
397
|
+
pactown quadlet shell --domain pactown.com --tenant user01
|
|
398
|
+
"""
|
|
399
|
+
from .deploy.quadlet_shell import run_shell
|
|
400
|
+
run_shell(tenant_id=tenant, domain=domain, user_mode=not system)
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
@quadlet.command("api")
|
|
404
|
+
@click.option("--host", "-h", default="0.0.0.0", help="API host")
|
|
405
|
+
@click.option("--port", "-p", default=8800, type=int, help="API port")
|
|
406
|
+
@click.option("--domain", "-d", default="localhost", help="Default domain")
|
|
407
|
+
@click.option("--tenant", "-t", default="default", help="Default tenant")
|
|
408
|
+
def quadlet_api(host: str, port: int, domain: str, tenant: str):
|
|
409
|
+
"""Start Quadlet API server for programmatic deployments.
|
|
410
|
+
|
|
411
|
+
Example:
|
|
412
|
+
pactown quadlet api --port 8800 --domain pactown.com
|
|
413
|
+
"""
|
|
414
|
+
from .deploy.quadlet_api import run_api
|
|
415
|
+
console.print("[bold]Starting Quadlet API server...[/bold]")
|
|
416
|
+
console.print(f" Host: {host}:{port}")
|
|
417
|
+
console.print(f" Domain: {domain}")
|
|
418
|
+
console.print(f" Docs: http://{host}:{port}/docs")
|
|
419
|
+
run_api(host=host, port=port, domain=domain, tenant=tenant)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
@quadlet.command("generate")
|
|
423
|
+
@click.argument("markdown_path", type=click.Path(exists=True))
|
|
424
|
+
@click.option("--output", "-o", default=".", help="Output directory")
|
|
425
|
+
@click.option("--domain", "-d", default="localhost", help="Domain")
|
|
426
|
+
@click.option("--subdomain", "-s", help="Subdomain")
|
|
427
|
+
@click.option("--tenant", "-t", default="default", help="Tenant ID")
|
|
428
|
+
@click.option("--tls/--no-tls", default=False, help="Enable TLS")
|
|
429
|
+
def quadlet_generate(markdown_path: str, output: str, domain: str, subdomain: str, tenant: str, tls: bool):
|
|
430
|
+
"""Generate Quadlet files for a Markdown service.
|
|
431
|
+
|
|
432
|
+
Example:
|
|
433
|
+
pactown quadlet generate ./README.md --domain pactown.com --subdomain docs
|
|
434
|
+
"""
|
|
435
|
+
from .deploy.quadlet import QuadletConfig, generate_markdown_service_quadlet
|
|
436
|
+
|
|
437
|
+
config = QuadletConfig(
|
|
438
|
+
tenant_id=tenant,
|
|
439
|
+
domain=domain,
|
|
440
|
+
subdomain=subdomain,
|
|
441
|
+
tls_enabled=tls,
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
units = generate_markdown_service_quadlet(
|
|
445
|
+
markdown_path=Path(markdown_path).resolve(),
|
|
446
|
+
config=config,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
output_dir = Path(output)
|
|
450
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
451
|
+
|
|
452
|
+
for unit in units:
|
|
453
|
+
path = output_dir / unit.filename
|
|
454
|
+
path.write_text(unit.content)
|
|
455
|
+
console.print(f"[green]✓ Generated: {path}[/green]")
|
|
456
|
+
|
|
457
|
+
console.print("\n[bold]Deploy with:[/bold]")
|
|
458
|
+
console.print(f" cp {output}/*.container ~/.config/containers/systemd/tenant-{tenant}/")
|
|
459
|
+
console.print(" systemctl --user daemon-reload")
|
|
460
|
+
console.print(f" systemctl --user enable --now {units[0].name}.service")
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@quadlet.command("init")
|
|
464
|
+
@click.option("--domain", "-d", required=True, help="Domain for Traefik")
|
|
465
|
+
@click.option("--email", "-e", help="Email for Let's Encrypt")
|
|
466
|
+
@click.option("--system", is_flag=True, help="Use system-wide systemd")
|
|
467
|
+
def quadlet_init(domain: str, email: str, system: bool):
|
|
468
|
+
"""Initialize Quadlet environment with Traefik.
|
|
469
|
+
|
|
470
|
+
Example:
|
|
471
|
+
pactown quadlet init --domain pactown.com --email admin@pactown.com
|
|
472
|
+
"""
|
|
473
|
+
from .deploy.quadlet import QuadletConfig, generate_traefik_quadlet
|
|
474
|
+
|
|
475
|
+
config = QuadletConfig(domain=domain, user_mode=not system)
|
|
476
|
+
|
|
477
|
+
# Create directories
|
|
478
|
+
config.systemd_path.mkdir(parents=True, exist_ok=True)
|
|
479
|
+
console.print(f"[green]✓ Created: {config.systemd_path}[/green]")
|
|
480
|
+
|
|
481
|
+
# Generate Traefik
|
|
482
|
+
units = generate_traefik_quadlet(config)
|
|
483
|
+
|
|
484
|
+
for unit in units:
|
|
485
|
+
content = unit.content
|
|
486
|
+
if email:
|
|
487
|
+
content = content.replace(f"admin@{domain}", email)
|
|
488
|
+
|
|
489
|
+
path = config.systemd_path / unit.filename
|
|
490
|
+
path.write_text(content)
|
|
491
|
+
console.print(f"[green]✓ Generated: {path}[/green]")
|
|
492
|
+
|
|
493
|
+
console.print("\n[bold]Start Traefik:[/bold]")
|
|
494
|
+
mode = "" if system else " --user"
|
|
495
|
+
console.print(f" systemctl{mode} daemon-reload")
|
|
496
|
+
console.print(f" systemctl{mode} enable --now traefik.service")
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@quadlet.command("deploy")
|
|
500
|
+
@click.argument("markdown_path", type=click.Path(exists=True))
|
|
501
|
+
@click.option("--domain", "-d", required=True, help="Domain")
|
|
502
|
+
@click.option("--subdomain", "-s", help="Subdomain")
|
|
503
|
+
@click.option("--tenant", "-t", default="default", help="Tenant ID")
|
|
504
|
+
@click.option("--tls/--no-tls", default=True, help="Enable TLS")
|
|
505
|
+
@click.option("--image", default="ghcr.io/pactown/markdown-server:latest", help="Container image")
|
|
506
|
+
def quadlet_deploy(markdown_path: str, domain: str, subdomain: str, tenant: str, tls: bool, image: str):
|
|
507
|
+
"""Deploy a Markdown file to VPS using Quadlet.
|
|
508
|
+
|
|
509
|
+
Example:
|
|
510
|
+
pactown quadlet deploy ./README.md --domain pactown.com --subdomain docs --tls
|
|
511
|
+
"""
|
|
512
|
+
from .deploy.base import DeploymentConfig
|
|
513
|
+
from .deploy.quadlet import QuadletBackend, QuadletConfig, generate_markdown_service_quadlet
|
|
514
|
+
|
|
515
|
+
config = QuadletConfig(
|
|
516
|
+
tenant_id=tenant,
|
|
517
|
+
domain=domain,
|
|
518
|
+
subdomain=subdomain,
|
|
519
|
+
tls_enabled=tls,
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
deploy_config = DeploymentConfig.for_production()
|
|
523
|
+
backend = QuadletBackend(deploy_config, config)
|
|
524
|
+
|
|
525
|
+
if not backend.is_available():
|
|
526
|
+
console.print("[red]✗ Podman 4.4+ with Quadlet support not available[/red]")
|
|
527
|
+
sys.exit(1)
|
|
528
|
+
|
|
529
|
+
md_path = Path(markdown_path).resolve()
|
|
530
|
+
console.print(f"[bold]Deploying: {md_path.name}[/bold]")
|
|
531
|
+
console.print(f" Domain: {config.full_domain}")
|
|
532
|
+
console.print(f" Tenant: {tenant}")
|
|
533
|
+
console.print(f" TLS: {tls}")
|
|
534
|
+
|
|
535
|
+
# Generate units
|
|
536
|
+
units = generate_markdown_service_quadlet(md_path, config, image)
|
|
537
|
+
|
|
538
|
+
# Save to tenant path
|
|
539
|
+
config.tenant_path.mkdir(parents=True, exist_ok=True)
|
|
540
|
+
for unit in units:
|
|
541
|
+
unit.save(config.tenant_path)
|
|
542
|
+
console.print(f"[dim]Created: {unit.filename}[/dim]")
|
|
543
|
+
|
|
544
|
+
# Reload and start
|
|
545
|
+
backend._systemctl("daemon-reload")
|
|
546
|
+
service = f"{units[0].name}.service"
|
|
547
|
+
backend._systemctl("enable", service)
|
|
548
|
+
result = backend._systemctl("start", service)
|
|
549
|
+
|
|
550
|
+
if result.returncode == 0:
|
|
551
|
+
url = f"https://{config.full_domain}" if tls else f"http://{config.full_domain}"
|
|
552
|
+
console.print("\n[green]✓ Deployed successfully![/green]")
|
|
553
|
+
console.print(f" URL: {url}")
|
|
554
|
+
else:
|
|
555
|
+
console.print(f"\n[red]✗ Deployment failed: {result.stderr}[/red]")
|
|
556
|
+
sys.exit(1)
|
|
557
|
+
|
|
558
|
+
|
|
559
|
+
@quadlet.command("list")
|
|
560
|
+
@click.option("--tenant", "-t", default="default", help="Tenant ID")
|
|
561
|
+
def quadlet_list(tenant: str):
|
|
562
|
+
"""List all Quadlet services for a tenant.
|
|
563
|
+
|
|
564
|
+
Example:
|
|
565
|
+
pactown quadlet list --tenant user01
|
|
566
|
+
"""
|
|
567
|
+
from rich.table import Table
|
|
568
|
+
|
|
569
|
+
from .deploy.base import DeploymentConfig
|
|
570
|
+
from .deploy.quadlet import QuadletBackend, QuadletConfig
|
|
571
|
+
|
|
572
|
+
config = QuadletConfig(tenant_id=tenant)
|
|
573
|
+
backend = QuadletBackend(DeploymentConfig.for_production(), config)
|
|
574
|
+
|
|
575
|
+
services = backend.list_services()
|
|
576
|
+
|
|
577
|
+
if not services:
|
|
578
|
+
console.print(f"[yellow]No services found for tenant: {tenant}[/yellow]")
|
|
579
|
+
return
|
|
580
|
+
|
|
581
|
+
table = Table(title=f"Services (tenant: {tenant})")
|
|
582
|
+
table.add_column("Name", style="cyan")
|
|
583
|
+
table.add_column("Status", style="green")
|
|
584
|
+
table.add_column("State")
|
|
585
|
+
|
|
586
|
+
for svc in services:
|
|
587
|
+
status = svc["status"]
|
|
588
|
+
running = "🟢 running" if status.get("running") else "🔴 stopped"
|
|
589
|
+
table.add_row(svc["name"], running, status.get("state", "-"))
|
|
590
|
+
|
|
591
|
+
console.print(table)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@quadlet.command("logs")
|
|
595
|
+
@click.argument("service_name")
|
|
596
|
+
@click.option("--tenant", "-t", default="default", help="Tenant ID")
|
|
597
|
+
@click.option("--lines", "-n", default=50, type=int, help="Number of lines")
|
|
598
|
+
def quadlet_logs(service_name: str, tenant: str, lines: int):
|
|
599
|
+
"""Show logs for a Quadlet service.
|
|
600
|
+
|
|
601
|
+
Example:
|
|
602
|
+
pactown quadlet logs my-service --lines 100
|
|
603
|
+
"""
|
|
604
|
+
from .deploy.base import DeploymentConfig
|
|
605
|
+
from .deploy.quadlet import QuadletBackend, QuadletConfig
|
|
606
|
+
|
|
607
|
+
config = QuadletConfig(tenant_id=tenant)
|
|
608
|
+
backend = QuadletBackend(DeploymentConfig.for_production(), config)
|
|
609
|
+
|
|
610
|
+
output = backend.logs(service_name, tail=lines)
|
|
611
|
+
console.print(output or "[dim]No logs available[/dim]")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@cli.group()
|
|
615
|
+
def llm():
|
|
616
|
+
"""LLM provider management with rotation and fallback."""
|
|
617
|
+
pass
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
@llm.command("status")
|
|
621
|
+
def llm_status():
|
|
622
|
+
"""Show status of all LLM providers.
|
|
623
|
+
|
|
624
|
+
Example:
|
|
625
|
+
pactown llm status
|
|
626
|
+
"""
|
|
627
|
+
import sys
|
|
628
|
+
|
|
629
|
+
status = get_llm_status()
|
|
630
|
+
|
|
631
|
+
if not status.get("lolm_installed", False):
|
|
632
|
+
console.print("[yellow]lolm library not available[/yellow]")
|
|
633
|
+
if status.get("lolm_import_error"):
|
|
634
|
+
console.print(f"Import error: {status['lolm_import_error']}")
|
|
635
|
+
console.print("Install/upgrade (same interpreter as pactown):")
|
|
636
|
+
console.print(f" {sys.executable} -m pip install -U pactown[llm]\n", markup=False)
|
|
637
|
+
console.print("Or install directly:")
|
|
638
|
+
console.print(f" {sys.executable} -m pip install -U lolm")
|
|
639
|
+
return
|
|
640
|
+
|
|
641
|
+
if not status.get('is_available'):
|
|
642
|
+
console.print("[yellow]No LLM providers available[/yellow]")
|
|
643
|
+
if status.get("lolm_version"):
|
|
644
|
+
console.print(f"lolm version: {status['lolm_version']}")
|
|
645
|
+
if status.get("rotation_available") is False and status.get("rotation_import_error"):
|
|
646
|
+
console.print(f"Rotation not available: {status['rotation_import_error']}")
|
|
647
|
+
if 'error' in status:
|
|
648
|
+
console.print(f"Error: {status['error']}")
|
|
649
|
+
console.print("\n[dim]Tip: run `pactown llm doctor` to check Python/pip mismatch[/dim]")
|
|
650
|
+
return
|
|
651
|
+
|
|
652
|
+
console.print("[bold]LLM Provider Status[/bold]\n")
|
|
653
|
+
|
|
654
|
+
if status.get("lolm_version"):
|
|
655
|
+
console.print(f"[dim]lolm version: {status['lolm_version']}[/dim]")
|
|
656
|
+
if status.get("rotation_available") is False:
|
|
657
|
+
console.print("[dim]rotation: not available (fallback only)[/dim]")
|
|
658
|
+
elif status.get("rotation_available") is True:
|
|
659
|
+
console.print("[dim]rotation: enabled[/dim]")
|
|
660
|
+
console.print()
|
|
661
|
+
|
|
662
|
+
providers = status.get('providers', {})
|
|
663
|
+
for name, info in providers.items():
|
|
664
|
+
state = info.get('status', 'unknown')
|
|
665
|
+
model = info.get('model', '')
|
|
666
|
+
priority = info.get('priority', 100)
|
|
667
|
+
|
|
668
|
+
if state == 'available':
|
|
669
|
+
state_icon = "[green]●[/green]"
|
|
670
|
+
elif state == 'unavailable':
|
|
671
|
+
state_icon = "[red]○[/red]"
|
|
672
|
+
else:
|
|
673
|
+
state_icon = "[yellow]◐[/yellow]"
|
|
674
|
+
|
|
675
|
+
console.print(f" {state_icon} [bold]{name}[/bold] ({model})")
|
|
676
|
+
console.print(f" Priority: {priority}")
|
|
677
|
+
|
|
678
|
+
health = info.get('health', {})
|
|
679
|
+
if health:
|
|
680
|
+
success_rate = health.get('success_rate', 1.0)
|
|
681
|
+
total = health.get('total_requests', 0)
|
|
682
|
+
rate_limits = health.get('rate_limit_hits', 0)
|
|
683
|
+
console.print(f" Requests: {total} (success: {success_rate:.1%})")
|
|
684
|
+
if rate_limits > 0:
|
|
685
|
+
console.print(f" [yellow]Rate limits: {rate_limits}[/yellow]")
|
|
686
|
+
|
|
687
|
+
if info.get('error'):
|
|
688
|
+
console.print(f" [red]Error: {info['error']}[/red]")
|
|
689
|
+
|
|
690
|
+
console.print()
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
@llm.command("doctor")
|
|
694
|
+
def llm_doctor():
|
|
695
|
+
"""Diagnose LLM installation and environment issues.
|
|
696
|
+
|
|
697
|
+
Helps detect situations where `pactown` is executed with a different
|
|
698
|
+
Python interpreter than the one where you installed `lolm`.
|
|
699
|
+
|
|
700
|
+
Example:
|
|
701
|
+
pactown llm doctor
|
|
702
|
+
"""
|
|
703
|
+
import importlib.util
|
|
704
|
+
import platform
|
|
705
|
+
import subprocess
|
|
706
|
+
import shutil
|
|
707
|
+
import sys
|
|
708
|
+
|
|
709
|
+
from . import __version__
|
|
710
|
+
from . import llm as llm_mod
|
|
711
|
+
|
|
712
|
+
console.print("[bold]LLM Doctor[/bold]\n")
|
|
713
|
+
|
|
714
|
+
console.print("[bold]Runtime[/bold]")
|
|
715
|
+
console.print(f" Python: {sys.executable}")
|
|
716
|
+
console.print(f" Python version: {platform.python_version()}")
|
|
717
|
+
console.print(f" pactown version: {__version__}")
|
|
718
|
+
|
|
719
|
+
pactown_spec = importlib.util.find_spec("pactown")
|
|
720
|
+
if pactown_spec and pactown_spec.origin:
|
|
721
|
+
console.print(f" pactown module: {pactown_spec.origin}")
|
|
722
|
+
|
|
723
|
+
try:
|
|
724
|
+
pip_v = subprocess.check_output(
|
|
725
|
+
[sys.executable, "-m", "pip", "-V"],
|
|
726
|
+
stderr=subprocess.STDOUT,
|
|
727
|
+
text=True,
|
|
728
|
+
).strip()
|
|
729
|
+
console.print(f" pip: {pip_v}")
|
|
730
|
+
except Exception as e:
|
|
731
|
+
console.print(f" pip: [red]error[/red] ({e})")
|
|
732
|
+
|
|
733
|
+
pip_on_path = shutil.which("pip")
|
|
734
|
+
if pip_on_path:
|
|
735
|
+
console.print(f" pip (PATH): {pip_on_path}")
|
|
736
|
+
else:
|
|
737
|
+
console.print(" pip (PATH): [dim]not found[/dim]")
|
|
738
|
+
|
|
739
|
+
console.print("\n[bold]lolm[/bold]")
|
|
740
|
+
info = llm_mod.get_lolm_info()
|
|
741
|
+
console.print(f" installed: {info.get('lolm_installed')}")
|
|
742
|
+
if info.get("lolm_version"):
|
|
743
|
+
console.print(f" version: {info['lolm_version']}")
|
|
744
|
+
|
|
745
|
+
lolm_spec = importlib.util.find_spec("lolm")
|
|
746
|
+
if lolm_spec and lolm_spec.origin:
|
|
747
|
+
console.print(f" module: {lolm_spec.origin}")
|
|
748
|
+
|
|
749
|
+
if info.get("lolm_import_error"):
|
|
750
|
+
console.print(f" import error: {info['lolm_import_error']}")
|
|
751
|
+
|
|
752
|
+
console.print("\n[bold]Rotation[/bold]")
|
|
753
|
+
console.print(f" available: {info.get('rotation_available')}")
|
|
754
|
+
if info.get("rotation_import_error"):
|
|
755
|
+
console.print(f" error: {info['rotation_import_error']}")
|
|
756
|
+
|
|
757
|
+
console.print("\n[bold]Suggested fix[/bold]")
|
|
758
|
+
if not info.get("lolm_installed"):
|
|
759
|
+
console.print(" python -m pip install -U 'pactown[llm]'", markup=False)
|
|
760
|
+
console.print(f" {sys.executable} -m pip install -U 'pactown[llm]'", markup=False)
|
|
761
|
+
console.print(f" {sys.executable} -m pip install -U lolm")
|
|
762
|
+
elif not info.get("rotation_available"):
|
|
763
|
+
console.print(f" {sys.executable} -m pip install -U lolm")
|
|
764
|
+
console.print(" # rotation will be enabled automatically when supported")
|
|
765
|
+
else:
|
|
766
|
+
console.print(" OK")
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
@llm.command("priority")
|
|
770
|
+
@click.argument("provider")
|
|
771
|
+
@click.argument("priority", type=int)
|
|
772
|
+
def llm_priority(provider: str, priority: int):
|
|
773
|
+
"""Set priority for an LLM provider (lower = higher priority).
|
|
774
|
+
|
|
775
|
+
Example:
|
|
776
|
+
pactown llm priority openrouter 10
|
|
777
|
+
pactown llm priority groq 20
|
|
778
|
+
"""
|
|
779
|
+
if not is_lolm_available():
|
|
780
|
+
console.print("[yellow]lolm library not installed[/yellow]")
|
|
781
|
+
return
|
|
782
|
+
|
|
783
|
+
if set_llm_priority(provider, priority):
|
|
784
|
+
console.print(f"[green]✓ Set {provider} priority to {priority}[/green]")
|
|
785
|
+
else:
|
|
786
|
+
console.print(f"[red]Failed to set priority for {provider}[/red]")
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
@llm.command("reset")
|
|
790
|
+
@click.argument("provider")
|
|
791
|
+
def llm_reset(provider: str):
|
|
792
|
+
"""Reset an LLM provider's health metrics.
|
|
793
|
+
|
|
794
|
+
Clears failure counts, rate limit history, and cooldowns.
|
|
795
|
+
|
|
796
|
+
Example:
|
|
797
|
+
pactown llm reset groq
|
|
798
|
+
"""
|
|
799
|
+
if not is_lolm_available():
|
|
800
|
+
console.print("[yellow]lolm library not installed[/yellow]")
|
|
801
|
+
return
|
|
802
|
+
|
|
803
|
+
if reset_llm_provider(provider):
|
|
804
|
+
console.print(f"[green]✓ Reset {provider} health metrics[/green]")
|
|
805
|
+
else:
|
|
806
|
+
console.print(f"[red]Failed to reset {provider}[/red]")
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
@llm.command("test")
|
|
810
|
+
@click.option("--provider", "-p", help="Specific provider to test")
|
|
811
|
+
@click.option("--rotation", "-r", is_flag=True, help="Test with rotation")
|
|
812
|
+
def llm_test(provider: str, rotation: bool):
|
|
813
|
+
"""Test LLM generation with a simple prompt.
|
|
814
|
+
|
|
815
|
+
Example:
|
|
816
|
+
pactown llm test
|
|
817
|
+
pactown llm test --provider openrouter
|
|
818
|
+
pactown llm test --rotation
|
|
819
|
+
"""
|
|
820
|
+
if not is_lolm_available():
|
|
821
|
+
console.print("[yellow]lolm library not installed[/yellow]")
|
|
822
|
+
return
|
|
823
|
+
|
|
824
|
+
try:
|
|
825
|
+
llm = get_llm()
|
|
826
|
+
prompt = "Say 'Hello from Pactown!' in one short sentence."
|
|
827
|
+
|
|
828
|
+
console.print("[dim]Testing LLM generation...[/dim]")
|
|
829
|
+
|
|
830
|
+
if rotation:
|
|
831
|
+
response = llm.generate_with_rotation(prompt, max_tokens=50)
|
|
832
|
+
elif provider:
|
|
833
|
+
response = llm.generate(prompt, provider=provider, max_tokens=50)
|
|
834
|
+
else:
|
|
835
|
+
response = llm.generate(prompt, max_tokens=50)
|
|
836
|
+
|
|
837
|
+
console.print(f"[green]✓ Response:[/green] {response}")
|
|
838
|
+
|
|
839
|
+
except Exception as e:
|
|
840
|
+
console.print(f"[red]Error: {e}[/red]")
|
|
841
|
+
|
|
842
|
+
|
|
341
843
|
def main(argv=None):
|
|
342
844
|
"""Main entry point."""
|
|
343
845
|
cli(argv)
|