clonebox 1.1.14__py3-none-any.whl → 1.1.15__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.
- clonebox/audit.py +5 -1
- clonebox/cli.py +568 -5
- clonebox/cloner.py +181 -123
- clonebox/plugins/manager.py +85 -0
- clonebox/remote.py +511 -0
- clonebox/secrets.py +9 -6
- clonebox/validator.py +113 -41
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/METADATA +5 -1
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/RECORD +13 -12
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/WHEEL +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.15.dist-info}/top_level.txt +0 -0
clonebox/audit.py
CHANGED
|
@@ -228,7 +228,11 @@ class AuditLogger:
|
|
|
228
228
|
|
|
229
229
|
# Ensure log directory exists
|
|
230
230
|
if self.enabled:
|
|
231
|
-
|
|
231
|
+
try:
|
|
232
|
+
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
except (PermissionError, OSError):
|
|
234
|
+
# If we can't create log directory, disable audit logging
|
|
235
|
+
self.enabled = False
|
|
232
236
|
|
|
233
237
|
def log(
|
|
234
238
|
self,
|
clonebox/cli.py
CHANGED
|
@@ -37,6 +37,7 @@ from clonebox.health import HealthCheckManager, ProbeConfig, ProbeType
|
|
|
37
37
|
from clonebox.audit import get_audit_logger, AuditQuery, AuditEventType, AuditOutcome
|
|
38
38
|
from clonebox.orchestrator import Orchestrator, OrchestrationResult
|
|
39
39
|
from clonebox.plugins import get_plugin_manager, PluginHook, PluginContext
|
|
40
|
+
from clonebox.remote import RemoteCloner, RemoteConnection
|
|
40
41
|
|
|
41
42
|
# Custom questionary style
|
|
42
43
|
custom_style = Style(
|
|
@@ -601,6 +602,78 @@ def cmd_repair(args):
|
|
|
601
602
|
)
|
|
602
603
|
|
|
603
604
|
|
|
605
|
+
def cmd_logs(args):
|
|
606
|
+
"""View logs from VM."""
|
|
607
|
+
import subprocess
|
|
608
|
+
import sys
|
|
609
|
+
|
|
610
|
+
name = args.name
|
|
611
|
+
user_session = getattr(args, "user", False)
|
|
612
|
+
show_all = getattr(args, "all", False)
|
|
613
|
+
|
|
614
|
+
try:
|
|
615
|
+
vm_name, _ = _resolve_vm_name_and_config_file(name)
|
|
616
|
+
except FileNotFoundError as e:
|
|
617
|
+
console.print(f"[red]❌ {e}[/]")
|
|
618
|
+
return
|
|
619
|
+
|
|
620
|
+
# Path to the logs script
|
|
621
|
+
script_dir = Path(__file__).parent.parent.parent / "scripts"
|
|
622
|
+
logs_script = script_dir / "clonebox-logs.sh"
|
|
623
|
+
|
|
624
|
+
if not logs_script.exists():
|
|
625
|
+
console.print(f"[red]❌ Logs script not found: {logs_script}[/]")
|
|
626
|
+
return
|
|
627
|
+
|
|
628
|
+
# Run the logs script
|
|
629
|
+
try:
|
|
630
|
+
console.print(f"[cyan]📋 Opening logs for VM: {vm_name}[/]")
|
|
631
|
+
subprocess.run(
|
|
632
|
+
[str(logs_script), vm_name, "true" if user_session else "false", "true" if show_all else "false"],
|
|
633
|
+
check=True
|
|
634
|
+
)
|
|
635
|
+
except subprocess.CalledProcessError as e:
|
|
636
|
+
console.print(f"[red]❌ Failed to view logs: {e}[/]")
|
|
637
|
+
sys.exit(1)
|
|
638
|
+
except KeyboardInterrupt:
|
|
639
|
+
console.print("\n[yellow]Interrupted.[/]")
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def cmd_set_password(args):
|
|
643
|
+
"""Set password for VM user."""
|
|
644
|
+
import subprocess
|
|
645
|
+
import sys
|
|
646
|
+
|
|
647
|
+
name = args.name
|
|
648
|
+
user_session = getattr(args, "user", False)
|
|
649
|
+
|
|
650
|
+
try:
|
|
651
|
+
vm_name, _ = _resolve_vm_name_and_config_file(name)
|
|
652
|
+
except FileNotFoundError as e:
|
|
653
|
+
console.print(f"[red]❌ {e}[/]")
|
|
654
|
+
return
|
|
655
|
+
|
|
656
|
+
# Path to the set-password script
|
|
657
|
+
script_dir = Path(__file__).parent.parent.parent / "scripts"
|
|
658
|
+
set_password_script = script_dir / "set-vm-password.sh"
|
|
659
|
+
|
|
660
|
+
if not set_password_script.exists():
|
|
661
|
+
console.print(f"[red]❌ Set password script not found: {set_password_script}[/]")
|
|
662
|
+
return
|
|
663
|
+
|
|
664
|
+
# Run the set-password script interactively
|
|
665
|
+
try:
|
|
666
|
+
console.print(f"[cyan]🔐 Setting password for VM: {vm_name}[/]")
|
|
667
|
+
subprocess.run(
|
|
668
|
+
[str(set_password_script), vm_name, "true" if user_session else "false"]
|
|
669
|
+
)
|
|
670
|
+
except subprocess.CalledProcessError as e:
|
|
671
|
+
console.print(f"[red]❌ Failed to set password: {e}[/]")
|
|
672
|
+
sys.exit(1)
|
|
673
|
+
except KeyboardInterrupt:
|
|
674
|
+
console.print("\n[yellow]Interrupted.[/]")
|
|
675
|
+
|
|
676
|
+
|
|
604
677
|
def interactive_mode():
|
|
605
678
|
"""Run the interactive VM creation wizard."""
|
|
606
679
|
print_banner()
|
|
@@ -1972,7 +2045,7 @@ def load_env_file(env_path: Path) -> dict:
|
|
|
1972
2045
|
continue
|
|
1973
2046
|
if "=" in line:
|
|
1974
2047
|
key, value = line.split("=", 1)
|
|
1975
|
-
env_vars[key.strip()] = value.strip()
|
|
2048
|
+
env_vars[key.strip()] = value.strip().strip("'\"")
|
|
1976
2049
|
|
|
1977
2050
|
return env_vars
|
|
1978
2051
|
|
|
@@ -1993,6 +2066,22 @@ def expand_env_vars(value, env_vars: dict):
|
|
|
1993
2066
|
return value
|
|
1994
2067
|
|
|
1995
2068
|
|
|
2069
|
+
def _find_unexpanded_env_placeholders(value) -> set:
|
|
2070
|
+
if isinstance(value, str):
|
|
2071
|
+
return set(re.findall(r"\$\{([^}]+)\}", value))
|
|
2072
|
+
if isinstance(value, dict):
|
|
2073
|
+
found = set()
|
|
2074
|
+
for v in value.values():
|
|
2075
|
+
found |= _find_unexpanded_env_placeholders(v)
|
|
2076
|
+
return found
|
|
2077
|
+
if isinstance(value, list):
|
|
2078
|
+
found = set()
|
|
2079
|
+
for item in value:
|
|
2080
|
+
found |= _find_unexpanded_env_placeholders(item)
|
|
2081
|
+
return found
|
|
2082
|
+
return set()
|
|
2083
|
+
|
|
2084
|
+
|
|
1996
2085
|
def deduplicate_list(items: list, key=None) -> list:
|
|
1997
2086
|
"""Remove duplicates from list, preserving order."""
|
|
1998
2087
|
seen = set()
|
|
@@ -2249,6 +2338,14 @@ def load_clonebox_config(path: Path) -> dict:
|
|
|
2249
2338
|
# Expand environment variables in config
|
|
2250
2339
|
config = expand_env_vars(config, env_vars)
|
|
2251
2340
|
|
|
2341
|
+
unresolved = _find_unexpanded_env_placeholders(config)
|
|
2342
|
+
if unresolved:
|
|
2343
|
+
unresolved_sorted = ", ".join(sorted(unresolved))
|
|
2344
|
+
raise ValueError(
|
|
2345
|
+
f"Unresolved environment variables in config: {unresolved_sorted}. "
|
|
2346
|
+
f"Set them in {env_file} or in the process environment."
|
|
2347
|
+
)
|
|
2348
|
+
|
|
2252
2349
|
return config
|
|
2253
2350
|
|
|
2254
2351
|
|
|
@@ -2358,9 +2455,32 @@ def create_vm_from_config(
|
|
|
2358
2455
|
replace: bool = False,
|
|
2359
2456
|
) -> str:
|
|
2360
2457
|
"""Create VM from YAML config dict."""
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2458
|
+
paths = config.get("paths", {})
|
|
2459
|
+
# Backwards compatible: v1 uses app_data_paths, newer configs may use copy_paths
|
|
2460
|
+
copy_paths = config.get("copy_paths", None)
|
|
2461
|
+
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
2462
|
+
copy_paths = config.get("app_data_paths", {})
|
|
2463
|
+
|
|
2464
|
+
vm_section = config.get("vm") or {}
|
|
2465
|
+
|
|
2466
|
+
# Support both v1 (auth_method) and v2 (auth.method) config formats
|
|
2467
|
+
auth_section = vm_section.get("auth") or {}
|
|
2468
|
+
auth_method = auth_section.get("method") or vm_section.get("auth_method") or "ssh_key"
|
|
2469
|
+
|
|
2470
|
+
# v2 config: secrets provider
|
|
2471
|
+
secrets_section = config.get("secrets") or {}
|
|
2472
|
+
secrets_provider = secrets_section.get("provider", "auto")
|
|
2473
|
+
|
|
2474
|
+
# v2 config: resource limits
|
|
2475
|
+
limits_section = config.get("limits") or {}
|
|
2476
|
+
resources = {
|
|
2477
|
+
"memory_limit": limits_section.get("memory_limit"),
|
|
2478
|
+
"cpu_shares": limits_section.get("cpu_shares"),
|
|
2479
|
+
"disk_limit": limits_section.get("disk_limit"),
|
|
2480
|
+
"network_limit": limits_section.get("network_limit"),
|
|
2481
|
+
}
|
|
2482
|
+
# Remove None values
|
|
2483
|
+
resources = {k: v for k, v in resources.items() if v is not None}
|
|
2364
2484
|
|
|
2365
2485
|
vm_config = VMConfig(
|
|
2366
2486
|
name=config["vm"]["name"],
|
|
@@ -2369,7 +2489,8 @@ def create_vm_from_config(
|
|
|
2369
2489
|
disk_size_gb=config["vm"].get("disk_size_gb", 10),
|
|
2370
2490
|
gui=config["vm"].get("gui", True),
|
|
2371
2491
|
base_image=config["vm"].get("base_image"),
|
|
2372
|
-
paths=
|
|
2492
|
+
paths=paths,
|
|
2493
|
+
copy_paths=copy_paths,
|
|
2373
2494
|
packages=config.get("packages", []),
|
|
2374
2495
|
snap_packages=config.get("snap_packages", []),
|
|
2375
2496
|
services=config.get("services", []),
|
|
@@ -2378,6 +2499,9 @@ def create_vm_from_config(
|
|
|
2378
2499
|
network_mode=config["vm"].get("network_mode", "auto"),
|
|
2379
2500
|
username=config["vm"].get("username", "ubuntu"),
|
|
2380
2501
|
password=config["vm"].get("password", "ubuntu"),
|
|
2502
|
+
auth_method=auth_method,
|
|
2503
|
+
ssh_public_key=vm_section.get("ssh_public_key") or auth_section.get("ssh_public_key"),
|
|
2504
|
+
resources=resources if resources else config["vm"].get("resources", {}),
|
|
2381
2505
|
)
|
|
2382
2506
|
|
|
2383
2507
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
@@ -3238,6 +3362,104 @@ def cmd_audit_failures(args) -> None:
|
|
|
3238
3362
|
console.print(table)
|
|
3239
3363
|
|
|
3240
3364
|
|
|
3365
|
+
def cmd_audit_search(args) -> None:
|
|
3366
|
+
"""Search audit events."""
|
|
3367
|
+
from datetime import datetime, timedelta
|
|
3368
|
+
|
|
3369
|
+
query = AuditQuery()
|
|
3370
|
+
|
|
3371
|
+
# Parse event type
|
|
3372
|
+
event_type = None
|
|
3373
|
+
if hasattr(args, "event") and args.event:
|
|
3374
|
+
try:
|
|
3375
|
+
event_type = AuditEventType(args.event)
|
|
3376
|
+
except ValueError:
|
|
3377
|
+
console.print(f"[red]Unknown event type: {args.event}[/]")
|
|
3378
|
+
return
|
|
3379
|
+
|
|
3380
|
+
# Parse time range
|
|
3381
|
+
start_time = None
|
|
3382
|
+
if hasattr(args, "since") and args.since:
|
|
3383
|
+
since = args.since.lower()
|
|
3384
|
+
now = datetime.now()
|
|
3385
|
+
if "hour" in since:
|
|
3386
|
+
hours = int(since.split()[0]) if since[0].isdigit() else 1
|
|
3387
|
+
start_time = now - timedelta(hours=hours)
|
|
3388
|
+
elif "day" in since:
|
|
3389
|
+
days = int(since.split()[0]) if since[0].isdigit() else 1
|
|
3390
|
+
start_time = now - timedelta(days=days)
|
|
3391
|
+
elif "week" in since:
|
|
3392
|
+
weeks = int(since.split()[0]) if since[0].isdigit() else 1
|
|
3393
|
+
start_time = now - timedelta(weeks=weeks)
|
|
3394
|
+
|
|
3395
|
+
user = getattr(args, "user_filter", None)
|
|
3396
|
+
target = getattr(args, "target", None)
|
|
3397
|
+
limit = getattr(args, "limit", 100)
|
|
3398
|
+
|
|
3399
|
+
events = query.query(
|
|
3400
|
+
event_type=event_type,
|
|
3401
|
+
target_name=target,
|
|
3402
|
+
user=user,
|
|
3403
|
+
start_time=start_time,
|
|
3404
|
+
limit=limit,
|
|
3405
|
+
)
|
|
3406
|
+
|
|
3407
|
+
if not events:
|
|
3408
|
+
console.print("[yellow]No matching audit events found.[/]")
|
|
3409
|
+
return
|
|
3410
|
+
|
|
3411
|
+
console.print(f"[bold]Found {len(events)} events:[/]")
|
|
3412
|
+
|
|
3413
|
+
for event in events:
|
|
3414
|
+
outcome_color = "green" if event.outcome.value == "success" else "red"
|
|
3415
|
+
console.print(
|
|
3416
|
+
f" [{outcome_color}]{event.outcome.value}[/] "
|
|
3417
|
+
f"{event.timestamp.strftime('%Y-%m-%d %H:%M')} "
|
|
3418
|
+
f"[cyan]{event.event_type.value}[/] "
|
|
3419
|
+
f"{event.target_name or '-'}"
|
|
3420
|
+
)
|
|
3421
|
+
|
|
3422
|
+
|
|
3423
|
+
def cmd_audit_export(args) -> None:
|
|
3424
|
+
"""Export audit events to file."""
|
|
3425
|
+
query = AuditQuery()
|
|
3426
|
+
events = query.query(limit=getattr(args, "limit", 10000))
|
|
3427
|
+
|
|
3428
|
+
if not events:
|
|
3429
|
+
console.print("[yellow]No audit events to export.[/]")
|
|
3430
|
+
return
|
|
3431
|
+
|
|
3432
|
+
output_format = getattr(args, "format", "json")
|
|
3433
|
+
output_file = getattr(args, "output", None)
|
|
3434
|
+
|
|
3435
|
+
if output_format == "json":
|
|
3436
|
+
data = [e.to_dict() for e in events]
|
|
3437
|
+
content = json.dumps(data, indent=2, default=str)
|
|
3438
|
+
else:
|
|
3439
|
+
# CSV format
|
|
3440
|
+
import csv
|
|
3441
|
+
import io
|
|
3442
|
+
output = io.StringIO()
|
|
3443
|
+
writer = csv.writer(output)
|
|
3444
|
+
writer.writerow(["timestamp", "event_type", "outcome", "target", "user", "error"])
|
|
3445
|
+
for e in events:
|
|
3446
|
+
writer.writerow([
|
|
3447
|
+
e.timestamp.isoformat(),
|
|
3448
|
+
e.event_type.value,
|
|
3449
|
+
e.outcome.value,
|
|
3450
|
+
e.target_name or "",
|
|
3451
|
+
e.user,
|
|
3452
|
+
e.error_message or "",
|
|
3453
|
+
])
|
|
3454
|
+
content = output.getvalue()
|
|
3455
|
+
|
|
3456
|
+
if output_file:
|
|
3457
|
+
Path(output_file).write_text(content)
|
|
3458
|
+
console.print(f"[green]✅ Exported {len(events)} events to {output_file}[/]")
|
|
3459
|
+
else:
|
|
3460
|
+
console.print(content)
|
|
3461
|
+
|
|
3462
|
+
|
|
3241
3463
|
# === Orchestration Commands ===
|
|
3242
3464
|
|
|
3243
3465
|
|
|
@@ -3353,6 +3575,42 @@ def cmd_compose_status(args) -> None:
|
|
|
3353
3575
|
console.print(f"[red]❌ Failed to get status: {e}[/]")
|
|
3354
3576
|
|
|
3355
3577
|
|
|
3578
|
+
def cmd_compose_logs(args) -> None:
|
|
3579
|
+
"""Show aggregated logs from VMs."""
|
|
3580
|
+
compose_file = Path(args.file) if hasattr(args, "file") and args.file else Path("clonebox-compose.yaml")
|
|
3581
|
+
|
|
3582
|
+
if not compose_file.exists():
|
|
3583
|
+
console.print(f"[red]Compose file not found: {compose_file}[/]")
|
|
3584
|
+
return
|
|
3585
|
+
|
|
3586
|
+
user_session = getattr(args, "user", False)
|
|
3587
|
+
follow = getattr(args, "follow", False)
|
|
3588
|
+
lines = getattr(args, "lines", 50)
|
|
3589
|
+
service = getattr(args, "service", None)
|
|
3590
|
+
|
|
3591
|
+
try:
|
|
3592
|
+
orch = Orchestrator.from_file(compose_file, user_session=user_session)
|
|
3593
|
+
|
|
3594
|
+
if service:
|
|
3595
|
+
# Logs for specific service
|
|
3596
|
+
logs = orch.logs(service, follow=follow, lines=lines)
|
|
3597
|
+
if logs:
|
|
3598
|
+
console.print(f"[bold]Logs for {service}:[/]")
|
|
3599
|
+
console.print(logs)
|
|
3600
|
+
else:
|
|
3601
|
+
console.print(f"[yellow]No logs available for {service}[/]")
|
|
3602
|
+
else:
|
|
3603
|
+
# Logs for all services
|
|
3604
|
+
for name in orch.plan.vms.keys():
|
|
3605
|
+
logs = orch.logs(name, follow=False, lines=lines)
|
|
3606
|
+
if logs:
|
|
3607
|
+
console.print(f"\n[bold cyan]━━━ {name} ━━━[/]")
|
|
3608
|
+
console.print(logs)
|
|
3609
|
+
|
|
3610
|
+
except Exception as e:
|
|
3611
|
+
console.print(f"[red]❌ Failed to get logs: {e}[/]")
|
|
3612
|
+
|
|
3613
|
+
|
|
3356
3614
|
# === Plugin Commands ===
|
|
3357
3615
|
|
|
3358
3616
|
|
|
@@ -3430,6 +3688,199 @@ def cmd_plugin_discover(args) -> None:
|
|
|
3430
3688
|
console.print(f" • {name}")
|
|
3431
3689
|
|
|
3432
3690
|
|
|
3691
|
+
def cmd_plugin_install(args) -> None:
|
|
3692
|
+
"""Install a plugin."""
|
|
3693
|
+
manager = get_plugin_manager()
|
|
3694
|
+
source = args.source
|
|
3695
|
+
|
|
3696
|
+
console.print(f"[cyan]📦 Installing plugin from: {source}[/]")
|
|
3697
|
+
|
|
3698
|
+
if manager.install(source):
|
|
3699
|
+
console.print("[green]✅ Plugin installed successfully[/]")
|
|
3700
|
+
console.print("[dim]Run 'clonebox plugin discover' to see available plugins[/]")
|
|
3701
|
+
else:
|
|
3702
|
+
console.print(f"[red]❌ Failed to install plugin from: {source}[/]")
|
|
3703
|
+
|
|
3704
|
+
|
|
3705
|
+
def cmd_plugin_uninstall(args) -> None:
|
|
3706
|
+
"""Uninstall a plugin."""
|
|
3707
|
+
manager = get_plugin_manager()
|
|
3708
|
+
name = args.name
|
|
3709
|
+
|
|
3710
|
+
console.print(f"[cyan]🗑️ Uninstalling plugin: {name}[/]")
|
|
3711
|
+
|
|
3712
|
+
if manager.uninstall(name):
|
|
3713
|
+
console.print(f"[green]✅ Plugin '{name}' uninstalled successfully[/]")
|
|
3714
|
+
else:
|
|
3715
|
+
console.print(f"[red]❌ Failed to uninstall plugin: {name}[/]")
|
|
3716
|
+
|
|
3717
|
+
|
|
3718
|
+
# === Remote Management Commands ===
|
|
3719
|
+
|
|
3720
|
+
|
|
3721
|
+
def cmd_remote_list(args) -> None:
|
|
3722
|
+
"""List VMs on remote host."""
|
|
3723
|
+
host = args.host
|
|
3724
|
+
user_session = getattr(args, "user", False)
|
|
3725
|
+
|
|
3726
|
+
console.print(f"[cyan]🔍 Connecting to: {host}[/]")
|
|
3727
|
+
|
|
3728
|
+
try:
|
|
3729
|
+
remote = RemoteCloner(host, verify=True)
|
|
3730
|
+
|
|
3731
|
+
if not remote.is_clonebox_installed():
|
|
3732
|
+
console.print("[red]❌ CloneBox is not installed on remote host[/]")
|
|
3733
|
+
return
|
|
3734
|
+
|
|
3735
|
+
vms = remote.list_vms(user_session=user_session)
|
|
3736
|
+
|
|
3737
|
+
if not vms:
|
|
3738
|
+
console.print("[yellow]No VMs found on remote host.[/]")
|
|
3739
|
+
return
|
|
3740
|
+
|
|
3741
|
+
table = Table(title=f"VMs on {host}", border_style="cyan")
|
|
3742
|
+
table.add_column("Name")
|
|
3743
|
+
table.add_column("Status")
|
|
3744
|
+
|
|
3745
|
+
for vm in vms:
|
|
3746
|
+
name = vm.get("name", str(vm))
|
|
3747
|
+
status = vm.get("state", vm.get("status", "-"))
|
|
3748
|
+
table.add_row(name, status)
|
|
3749
|
+
|
|
3750
|
+
console.print(table)
|
|
3751
|
+
|
|
3752
|
+
except ConnectionError as e:
|
|
3753
|
+
console.print(f"[red]❌ Connection failed: {e}[/]")
|
|
3754
|
+
except Exception as e:
|
|
3755
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3756
|
+
|
|
3757
|
+
|
|
3758
|
+
def cmd_remote_status(args) -> None:
|
|
3759
|
+
"""Get VM status on remote host."""
|
|
3760
|
+
host = args.host
|
|
3761
|
+
vm_name = args.vm_name
|
|
3762
|
+
user_session = getattr(args, "user", False)
|
|
3763
|
+
|
|
3764
|
+
console.print(f"[cyan]🔍 Getting status of {vm_name} on {host}[/]")
|
|
3765
|
+
|
|
3766
|
+
try:
|
|
3767
|
+
remote = RemoteCloner(host, verify=True)
|
|
3768
|
+
status = remote.get_status(vm_name, user_session=user_session)
|
|
3769
|
+
|
|
3770
|
+
if getattr(args, "json", False):
|
|
3771
|
+
console.print_json(json.dumps(status, default=str))
|
|
3772
|
+
else:
|
|
3773
|
+
for key, value in status.items():
|
|
3774
|
+
console.print(f" [bold]{key}:[/] {value}")
|
|
3775
|
+
|
|
3776
|
+
except Exception as e:
|
|
3777
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3778
|
+
|
|
3779
|
+
|
|
3780
|
+
def cmd_remote_start(args) -> None:
|
|
3781
|
+
"""Start VM on remote host."""
|
|
3782
|
+
host = args.host
|
|
3783
|
+
vm_name = args.vm_name
|
|
3784
|
+
user_session = getattr(args, "user", False)
|
|
3785
|
+
|
|
3786
|
+
console.print(f"[cyan]🚀 Starting {vm_name} on {host}[/]")
|
|
3787
|
+
|
|
3788
|
+
try:
|
|
3789
|
+
remote = RemoteCloner(host, verify=True)
|
|
3790
|
+
remote.start_vm(vm_name, user_session=user_session)
|
|
3791
|
+
console.print(f"[green]✅ VM {vm_name} started[/]")
|
|
3792
|
+
|
|
3793
|
+
except Exception as e:
|
|
3794
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3795
|
+
|
|
3796
|
+
|
|
3797
|
+
def cmd_remote_stop(args) -> None:
|
|
3798
|
+
"""Stop VM on remote host."""
|
|
3799
|
+
host = args.host
|
|
3800
|
+
vm_name = args.vm_name
|
|
3801
|
+
user_session = getattr(args, "user", False)
|
|
3802
|
+
force = getattr(args, "force", False)
|
|
3803
|
+
|
|
3804
|
+
console.print(f"[cyan]🛑 Stopping {vm_name} on {host}[/]")
|
|
3805
|
+
|
|
3806
|
+
try:
|
|
3807
|
+
remote = RemoteCloner(host, verify=True)
|
|
3808
|
+
remote.stop_vm(vm_name, force=force, user_session=user_session)
|
|
3809
|
+
console.print(f"[green]✅ VM {vm_name} stopped[/]")
|
|
3810
|
+
|
|
3811
|
+
except Exception as e:
|
|
3812
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3813
|
+
|
|
3814
|
+
|
|
3815
|
+
def cmd_remote_delete(args) -> None:
|
|
3816
|
+
"""Delete VM on remote host."""
|
|
3817
|
+
host = args.host
|
|
3818
|
+
vm_name = args.vm_name
|
|
3819
|
+
user_session = getattr(args, "user", False)
|
|
3820
|
+
keep_storage = getattr(args, "keep_storage", False)
|
|
3821
|
+
|
|
3822
|
+
if not getattr(args, "yes", False):
|
|
3823
|
+
confirm = questionary.confirm(
|
|
3824
|
+
f"Delete VM '{vm_name}' on {host}?",
|
|
3825
|
+
default=False,
|
|
3826
|
+
style=custom_style,
|
|
3827
|
+
).ask()
|
|
3828
|
+
if not confirm:
|
|
3829
|
+
console.print("[yellow]Aborted.[/]")
|
|
3830
|
+
return
|
|
3831
|
+
|
|
3832
|
+
console.print(f"[cyan]🗑️ Deleting {vm_name} on {host}[/]")
|
|
3833
|
+
|
|
3834
|
+
try:
|
|
3835
|
+
remote = RemoteCloner(host, verify=True)
|
|
3836
|
+
remote.delete_vm(vm_name, keep_storage=keep_storage, user_session=user_session)
|
|
3837
|
+
console.print(f"[green]✅ VM {vm_name} deleted[/]")
|
|
3838
|
+
|
|
3839
|
+
except Exception as e:
|
|
3840
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3841
|
+
|
|
3842
|
+
|
|
3843
|
+
def cmd_remote_exec(args) -> None:
|
|
3844
|
+
"""Execute command in VM on remote host."""
|
|
3845
|
+
host = args.host
|
|
3846
|
+
vm_name = args.vm_name
|
|
3847
|
+
command = " ".join(args.command) if args.command else "echo ok"
|
|
3848
|
+
user_session = getattr(args, "user", False)
|
|
3849
|
+
timeout = getattr(args, "timeout", 30)
|
|
3850
|
+
|
|
3851
|
+
try:
|
|
3852
|
+
remote = RemoteCloner(host, verify=True)
|
|
3853
|
+
output = remote.exec_in_vm(vm_name, command, timeout=timeout, user_session=user_session)
|
|
3854
|
+
console.print(output)
|
|
3855
|
+
|
|
3856
|
+
except Exception as e:
|
|
3857
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3858
|
+
|
|
3859
|
+
|
|
3860
|
+
def cmd_remote_health(args) -> None:
|
|
3861
|
+
"""Run health check on remote VM."""
|
|
3862
|
+
host = args.host
|
|
3863
|
+
vm_name = args.vm_name
|
|
3864
|
+
user_session = getattr(args, "user", False)
|
|
3865
|
+
|
|
3866
|
+
console.print(f"[cyan]🏥 Running health check on {vm_name}@{host}[/]")
|
|
3867
|
+
|
|
3868
|
+
try:
|
|
3869
|
+
remote = RemoteCloner(host, verify=True)
|
|
3870
|
+
result = remote.health_check(vm_name, user_session=user_session)
|
|
3871
|
+
|
|
3872
|
+
if result["success"]:
|
|
3873
|
+
console.print("[green]✅ Health check passed[/]")
|
|
3874
|
+
else:
|
|
3875
|
+
console.print("[red]❌ Health check failed[/]")
|
|
3876
|
+
|
|
3877
|
+
if result.get("output"):
|
|
3878
|
+
console.print(result["output"])
|
|
3879
|
+
|
|
3880
|
+
except Exception as e:
|
|
3881
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3882
|
+
|
|
3883
|
+
|
|
3433
3884
|
def main():
|
|
3434
3885
|
"""Main entry point."""
|
|
3435
3886
|
parser = argparse.ArgumentParser(
|
|
@@ -3812,6 +4263,37 @@ def main():
|
|
|
3812
4263
|
)
|
|
3813
4264
|
repair_parser.set_defaults(func=cmd_repair)
|
|
3814
4265
|
|
|
4266
|
+
# Logs command - view VM logs
|
|
4267
|
+
logs_parser = subparsers.add_parser("logs", help="View logs from VM")
|
|
4268
|
+
logs_parser.add_argument(
|
|
4269
|
+
"name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
|
|
4270
|
+
)
|
|
4271
|
+
logs_parser.add_argument(
|
|
4272
|
+
"-u",
|
|
4273
|
+
"--user",
|
|
4274
|
+
action="store_true",
|
|
4275
|
+
help="Use user session (qemu:///session)",
|
|
4276
|
+
)
|
|
4277
|
+
logs_parser.add_argument(
|
|
4278
|
+
"--all",
|
|
4279
|
+
action="store_true",
|
|
4280
|
+
help="Show all logs at once without interactive menu",
|
|
4281
|
+
)
|
|
4282
|
+
logs_parser.set_defaults(func=cmd_logs)
|
|
4283
|
+
|
|
4284
|
+
# Set-password command - set password for VM user
|
|
4285
|
+
set_password_parser = subparsers.add_parser("set-password", help="Set password for VM user")
|
|
4286
|
+
set_password_parser.add_argument(
|
|
4287
|
+
"name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
|
|
4288
|
+
)
|
|
4289
|
+
set_password_parser.add_argument(
|
|
4290
|
+
"-u",
|
|
4291
|
+
"--user",
|
|
4292
|
+
action="store_true",
|
|
4293
|
+
help="Use user session (qemu:///session)",
|
|
4294
|
+
)
|
|
4295
|
+
set_password_parser.set_defaults(func=cmd_set_password)
|
|
4296
|
+
|
|
3815
4297
|
# Export command - package VM for migration
|
|
3816
4298
|
export_parser = subparsers.add_parser("export", help="Export VM and data for migration")
|
|
3817
4299
|
export_parser.add_argument(
|
|
@@ -4036,6 +4518,20 @@ def main():
|
|
|
4036
4518
|
audit_failures.add_argument("--limit", "-n", type=int, default=20, help="Max events to show")
|
|
4037
4519
|
audit_failures.set_defaults(func=cmd_audit_failures)
|
|
4038
4520
|
|
|
4521
|
+
audit_search = audit_sub.add_parser("search", help="Search audit events")
|
|
4522
|
+
audit_search.add_argument("--event", "-e", help="Event type (e.g., vm.create)")
|
|
4523
|
+
audit_search.add_argument("--since", "-s", help="Time range (e.g., '1 hour ago', '7 days')")
|
|
4524
|
+
audit_search.add_argument("--user-filter", help="Filter by user")
|
|
4525
|
+
audit_search.add_argument("--target", help="Filter by target name")
|
|
4526
|
+
audit_search.add_argument("--limit", "-n", type=int, default=100, help="Max events to show")
|
|
4527
|
+
audit_search.set_defaults(func=cmd_audit_search)
|
|
4528
|
+
|
|
4529
|
+
audit_export = audit_sub.add_parser("export", help="Export audit events to file")
|
|
4530
|
+
audit_export.add_argument("--format", "-f", choices=["json", "csv"], default="json", help="Output format")
|
|
4531
|
+
audit_export.add_argument("--output", "-o", help="Output file (stdout if not specified)")
|
|
4532
|
+
audit_export.add_argument("--limit", "-n", type=int, default=10000, help="Max events to export")
|
|
4533
|
+
audit_export.set_defaults(func=cmd_audit_export)
|
|
4534
|
+
|
|
4039
4535
|
# === Compose/Orchestration Commands ===
|
|
4040
4536
|
compose_parser = subparsers.add_parser("compose", help="Multi-VM orchestration")
|
|
4041
4537
|
compose_sub = compose_parser.add_subparsers(dest="compose_command", help="Compose commands")
|
|
@@ -4059,6 +4555,14 @@ def main():
|
|
|
4059
4555
|
compose_status.add_argument("--json", action="store_true", help="Output as JSON")
|
|
4060
4556
|
compose_status.set_defaults(func=cmd_compose_status)
|
|
4061
4557
|
|
|
4558
|
+
compose_logs = compose_sub.add_parser("logs", help="Show aggregated logs from VMs")
|
|
4559
|
+
compose_logs.add_argument("-f", "--file", default="clonebox-compose.yaml", help="Compose file")
|
|
4560
|
+
compose_logs.add_argument("-u", "--user", action="store_true", help="Use user session")
|
|
4561
|
+
compose_logs.add_argument("--follow", action="store_true", help="Follow log output")
|
|
4562
|
+
compose_logs.add_argument("--lines", "-n", type=int, default=50, help="Number of lines to show")
|
|
4563
|
+
compose_logs.add_argument("service", nargs="?", help="Specific service to show logs for")
|
|
4564
|
+
compose_logs.set_defaults(func=cmd_compose_logs)
|
|
4565
|
+
|
|
4062
4566
|
# === Plugin Commands ===
|
|
4063
4567
|
plugin_parser = subparsers.add_parser("plugin", help="Manage plugins")
|
|
4064
4568
|
plugin_sub = plugin_parser.add_subparsers(dest="plugin_command", help="Plugin commands")
|
|
@@ -4077,6 +4581,65 @@ def main():
|
|
|
4077
4581
|
plugin_discover = plugin_sub.add_parser("discover", help="Discover available plugins")
|
|
4078
4582
|
plugin_discover.set_defaults(func=cmd_plugin_discover)
|
|
4079
4583
|
|
|
4584
|
+
plugin_install = plugin_sub.add_parser("install", help="Install a plugin")
|
|
4585
|
+
plugin_install.add_argument("source", help="Plugin source (PyPI package, git URL, or local path)")
|
|
4586
|
+
plugin_install.set_defaults(func=cmd_plugin_install)
|
|
4587
|
+
|
|
4588
|
+
plugin_uninstall = plugin_sub.add_parser("uninstall", aliases=["remove"], help="Uninstall a plugin")
|
|
4589
|
+
plugin_uninstall.add_argument("name", help="Plugin name")
|
|
4590
|
+
plugin_uninstall.set_defaults(func=cmd_plugin_uninstall)
|
|
4591
|
+
|
|
4592
|
+
# === Remote Management Commands ===
|
|
4593
|
+
remote_parser = subparsers.add_parser("remote", help="Manage VMs on remote hosts")
|
|
4594
|
+
remote_sub = remote_parser.add_subparsers(dest="remote_command", help="Remote commands")
|
|
4595
|
+
|
|
4596
|
+
remote_list = remote_sub.add_parser("list", aliases=["ls"], help="List VMs on remote host")
|
|
4597
|
+
remote_list.add_argument("host", help="Remote host (user@hostname)")
|
|
4598
|
+
remote_list.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4599
|
+
remote_list.set_defaults(func=cmd_remote_list)
|
|
4600
|
+
|
|
4601
|
+
remote_status = remote_sub.add_parser("status", help="Get VM status on remote host")
|
|
4602
|
+
remote_status.add_argument("host", help="Remote host (user@hostname)")
|
|
4603
|
+
remote_status.add_argument("vm_name", help="VM name")
|
|
4604
|
+
remote_status.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4605
|
+
remote_status.add_argument("--json", action="store_true", help="Output as JSON")
|
|
4606
|
+
remote_status.set_defaults(func=cmd_remote_status)
|
|
4607
|
+
|
|
4608
|
+
remote_start = remote_sub.add_parser("start", help="Start VM on remote host")
|
|
4609
|
+
remote_start.add_argument("host", help="Remote host (user@hostname)")
|
|
4610
|
+
remote_start.add_argument("vm_name", help="VM name")
|
|
4611
|
+
remote_start.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4612
|
+
remote_start.set_defaults(func=cmd_remote_start)
|
|
4613
|
+
|
|
4614
|
+
remote_stop = remote_sub.add_parser("stop", help="Stop VM on remote host")
|
|
4615
|
+
remote_stop.add_argument("host", help="Remote host (user@hostname)")
|
|
4616
|
+
remote_stop.add_argument("vm_name", help="VM name")
|
|
4617
|
+
remote_stop.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4618
|
+
remote_stop.add_argument("-f", "--force", action="store_true", help="Force stop")
|
|
4619
|
+
remote_stop.set_defaults(func=cmd_remote_stop)
|
|
4620
|
+
|
|
4621
|
+
remote_delete = remote_sub.add_parser("delete", aliases=["rm"], help="Delete VM on remote host")
|
|
4622
|
+
remote_delete.add_argument("host", help="Remote host (user@hostname)")
|
|
4623
|
+
remote_delete.add_argument("vm_name", help="VM name")
|
|
4624
|
+
remote_delete.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4625
|
+
remote_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
|
|
4626
|
+
remote_delete.add_argument("--keep-storage", action="store_true", help="Keep disk images")
|
|
4627
|
+
remote_delete.set_defaults(func=cmd_remote_delete)
|
|
4628
|
+
|
|
4629
|
+
remote_exec = remote_sub.add_parser("exec", help="Execute command in VM on remote host")
|
|
4630
|
+
remote_exec.add_argument("host", help="Remote host (user@hostname)")
|
|
4631
|
+
remote_exec.add_argument("vm_name", help="VM name")
|
|
4632
|
+
remote_exec.add_argument("command", nargs=argparse.REMAINDER, help="Command to execute")
|
|
4633
|
+
remote_exec.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4634
|
+
remote_exec.add_argument("-t", "--timeout", type=int, default=30, help="Command timeout")
|
|
4635
|
+
remote_exec.set_defaults(func=cmd_remote_exec)
|
|
4636
|
+
|
|
4637
|
+
remote_health = remote_sub.add_parser("health", help="Run health check on remote VM")
|
|
4638
|
+
remote_health.add_argument("host", help="Remote host (user@hostname)")
|
|
4639
|
+
remote_health.add_argument("vm_name", help="VM name")
|
|
4640
|
+
remote_health.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4641
|
+
remote_health.set_defaults(func=cmd_remote_health)
|
|
4642
|
+
|
|
4080
4643
|
args = parser.parse_args()
|
|
4081
4644
|
|
|
4082
4645
|
if hasattr(args, "func"):
|