clonebox 1.1.14__py3-none-any.whl → 1.1.16__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.
Potentially problematic release.
This version of clonebox might be problematic. Click here for more details.
- clonebox/audit.py +5 -1
- clonebox/cli.py +673 -11
- clonebox/cloner.py +358 -179
- clonebox/plugins/manager.py +85 -0
- clonebox/remote.py +511 -0
- clonebox/secrets.py +9 -6
- clonebox/validator.py +140 -53
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/METADATA +5 -1
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/RECORD +13 -12
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/WHEEL +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/top_level.txt +0 -0
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()
|
|
@@ -1857,7 +1930,10 @@ def cmd_test(args):
|
|
|
1857
1930
|
if is_accessible == "yes":
|
|
1858
1931
|
console.print(f"[green]✅ {guest_path} (mount)[/]")
|
|
1859
1932
|
else:
|
|
1860
|
-
|
|
1933
|
+
if cloud_init_running:
|
|
1934
|
+
console.print(f"[yellow]⏳ {guest_path} (mount pending)[/]")
|
|
1935
|
+
else:
|
|
1936
|
+
console.print(f"[red]❌ {guest_path} (mount inaccessible)[/]")
|
|
1861
1937
|
except Exception:
|
|
1862
1938
|
console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
|
|
1863
1939
|
|
|
@@ -1870,7 +1946,10 @@ def cmd_test(args):
|
|
|
1870
1946
|
if is_accessible == "yes":
|
|
1871
1947
|
console.print(f"[green]✅ {guest_path} (copied)[/]")
|
|
1872
1948
|
else:
|
|
1873
|
-
|
|
1949
|
+
if cloud_init_running:
|
|
1950
|
+
console.print(f"[yellow]⏳ {guest_path} (copy pending)[/]")
|
|
1951
|
+
else:
|
|
1952
|
+
console.print(f"[red]❌ {guest_path} (copy missing)[/]")
|
|
1874
1953
|
except Exception:
|
|
1875
1954
|
console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
|
|
1876
1955
|
else:
|
|
@@ -1972,7 +2051,7 @@ def load_env_file(env_path: Path) -> dict:
|
|
|
1972
2051
|
continue
|
|
1973
2052
|
if "=" in line:
|
|
1974
2053
|
key, value = line.split("=", 1)
|
|
1975
|
-
env_vars[key.strip()] = value.strip()
|
|
2054
|
+
env_vars[key.strip()] = value.strip().strip("'\"")
|
|
1976
2055
|
|
|
1977
2056
|
return env_vars
|
|
1978
2057
|
|
|
@@ -1993,6 +2072,22 @@ def expand_env_vars(value, env_vars: dict):
|
|
|
1993
2072
|
return value
|
|
1994
2073
|
|
|
1995
2074
|
|
|
2075
|
+
def _find_unexpanded_env_placeholders(value) -> set:
|
|
2076
|
+
if isinstance(value, str):
|
|
2077
|
+
return set(re.findall(r"\$\{([^}]+)\}", value))
|
|
2078
|
+
if isinstance(value, dict):
|
|
2079
|
+
found = set()
|
|
2080
|
+
for v in value.values():
|
|
2081
|
+
found |= _find_unexpanded_env_placeholders(v)
|
|
2082
|
+
return found
|
|
2083
|
+
if isinstance(value, list):
|
|
2084
|
+
found = set()
|
|
2085
|
+
for item in value:
|
|
2086
|
+
found |= _find_unexpanded_env_placeholders(item)
|
|
2087
|
+
return found
|
|
2088
|
+
return set()
|
|
2089
|
+
|
|
2090
|
+
|
|
1996
2091
|
def deduplicate_list(items: list, key=None) -> list:
|
|
1997
2092
|
"""Remove duplicates from list, preserving order."""
|
|
1998
2093
|
seen = set()
|
|
@@ -2249,9 +2344,79 @@ def load_clonebox_config(path: Path) -> dict:
|
|
|
2249
2344
|
# Expand environment variables in config
|
|
2250
2345
|
config = expand_env_vars(config, env_vars)
|
|
2251
2346
|
|
|
2347
|
+
unresolved = _find_unexpanded_env_placeholders(config)
|
|
2348
|
+
if unresolved:
|
|
2349
|
+
unresolved_sorted = ", ".join(sorted(unresolved))
|
|
2350
|
+
raise ValueError(
|
|
2351
|
+
f"Unresolved environment variables in config: {unresolved_sorted}. "
|
|
2352
|
+
f"Set them in {env_file} or in the process environment."
|
|
2353
|
+
)
|
|
2354
|
+
|
|
2252
2355
|
return config
|
|
2253
2356
|
|
|
2254
2357
|
|
|
2358
|
+
def _exec_in_vm_qga(vm_name: str, conn_uri: str, command: str) -> Optional[str]:
|
|
2359
|
+
"""Internal helper to execute command via QGA and get output."""
|
|
2360
|
+
import subprocess
|
|
2361
|
+
import json
|
|
2362
|
+
import base64
|
|
2363
|
+
import time
|
|
2364
|
+
|
|
2365
|
+
try:
|
|
2366
|
+
# 1. Start execution
|
|
2367
|
+
cmd_json = {
|
|
2368
|
+
"execute": "guest-exec",
|
|
2369
|
+
"arguments": {
|
|
2370
|
+
"path": "/bin/sh",
|
|
2371
|
+
"arg": ["-c", command],
|
|
2372
|
+
"capture-output": True
|
|
2373
|
+
}
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
result = subprocess.run(
|
|
2377
|
+
["virsh", "--connect", conn_uri, "qemu-agent-command", vm_name, json.dumps(cmd_json)],
|
|
2378
|
+
capture_output=True,
|
|
2379
|
+
text=True,
|
|
2380
|
+
timeout=5
|
|
2381
|
+
)
|
|
2382
|
+
|
|
2383
|
+
if result.returncode != 0:
|
|
2384
|
+
return None
|
|
2385
|
+
|
|
2386
|
+
resp = json.loads(result.stdout)
|
|
2387
|
+
pid = resp.get("return", {}).get("pid")
|
|
2388
|
+
if not pid:
|
|
2389
|
+
return None
|
|
2390
|
+
|
|
2391
|
+
# 2. Wait and get status (quick check)
|
|
2392
|
+
status_json = {"execute": "guest-exec-status", "arguments": {"pid": pid}}
|
|
2393
|
+
|
|
2394
|
+
for _ in range(3): # Try a few times
|
|
2395
|
+
status_result = subprocess.run(
|
|
2396
|
+
["virsh", "--connect", conn_uri, "qemu-agent-command", vm_name, json.dumps(status_json)],
|
|
2397
|
+
capture_output=True,
|
|
2398
|
+
text=True,
|
|
2399
|
+
timeout=5
|
|
2400
|
+
)
|
|
2401
|
+
|
|
2402
|
+
if status_result.returncode != 0:
|
|
2403
|
+
continue
|
|
2404
|
+
|
|
2405
|
+
status_resp = json.loads(status_result.stdout)
|
|
2406
|
+
ret = status_resp.get("return", {})
|
|
2407
|
+
|
|
2408
|
+
if ret.get("exited"):
|
|
2409
|
+
if "out-data" in ret:
|
|
2410
|
+
return base64.b64decode(ret["out-data"]).decode().strip()
|
|
2411
|
+
return ""
|
|
2412
|
+
|
|
2413
|
+
time.sleep(0.5)
|
|
2414
|
+
|
|
2415
|
+
return None
|
|
2416
|
+
except Exception:
|
|
2417
|
+
return None
|
|
2418
|
+
|
|
2419
|
+
|
|
2255
2420
|
def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout: int = 900):
|
|
2256
2421
|
"""Monitor cloud-init status in VM and show progress."""
|
|
2257
2422
|
import subprocess
|
|
@@ -2261,6 +2426,8 @@ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout:
|
|
|
2261
2426
|
start_time = time.time()
|
|
2262
2427
|
shutdown_count = 0 # Count consecutive shutdown detections
|
|
2263
2428
|
restart_detected = False
|
|
2429
|
+
last_phases = []
|
|
2430
|
+
seen_lines = set()
|
|
2264
2431
|
|
|
2265
2432
|
with Progress(
|
|
2266
2433
|
SpinnerColumn(),
|
|
@@ -2293,7 +2460,7 @@ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout:
|
|
|
2293
2460
|
restart_detected = True
|
|
2294
2461
|
progress.update(
|
|
2295
2462
|
task,
|
|
2296
|
-
description="[yellow]⟳ VM restarting after
|
|
2463
|
+
description="[yellow]⟳ VM restarting after installation...",
|
|
2297
2464
|
)
|
|
2298
2465
|
time.sleep(3)
|
|
2299
2466
|
continue
|
|
@@ -2306,7 +2473,7 @@ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout:
|
|
|
2306
2473
|
if restart_detected and "running" in vm_state and shutdown_count >= 3:
|
|
2307
2474
|
# VM restarted successfully - GUI should be ready
|
|
2308
2475
|
progress.update(
|
|
2309
|
-
task, description=f"[green]✓
|
|
2476
|
+
task, description=f"[green]✓ VM ready! Total time: {minutes}m {seconds}s"
|
|
2310
2477
|
)
|
|
2311
2478
|
time.sleep(2)
|
|
2312
2479
|
break
|
|
@@ -2323,15 +2490,44 @@ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout:
|
|
|
2323
2490
|
else:
|
|
2324
2491
|
remaining = "almost done"
|
|
2325
2492
|
|
|
2493
|
+
# Try to get real-time progress via QGA
|
|
2494
|
+
# Get last 3 unique progress lines
|
|
2495
|
+
raw_info = _exec_in_vm_qga(
|
|
2496
|
+
vm_name,
|
|
2497
|
+
conn_uri,
|
|
2498
|
+
"grep -E '\\[[0-9]/[0-9]\\]|→' /var/log/cloud-init-output.log 2>/dev/null | tail -n 5"
|
|
2499
|
+
)
|
|
2500
|
+
|
|
2501
|
+
if raw_info:
|
|
2502
|
+
lines = [l.strip() for l in raw_info.strip().split('\n') if l.strip()]
|
|
2503
|
+
for line in lines:
|
|
2504
|
+
if line not in seen_lines:
|
|
2505
|
+
# If it's a new phase line, we can log it to console above the progress bar
|
|
2506
|
+
if "[" in line and "/9]" in line:
|
|
2507
|
+
console.print(f"[dim] {line}[/]")
|
|
2508
|
+
seen_lines.add(line)
|
|
2509
|
+
last_phases.append(line)
|
|
2510
|
+
|
|
2511
|
+
# Keep only last 2 for the progress bar description
|
|
2512
|
+
if len(last_phases) > 2:
|
|
2513
|
+
last_phases = last_phases[-2:]
|
|
2514
|
+
|
|
2326
2515
|
if restart_detected:
|
|
2327
2516
|
progress.update(
|
|
2328
2517
|
task,
|
|
2329
|
-
description=f"[cyan]
|
|
2518
|
+
description=f"[cyan]Finalizing setup... ({minutes}m {seconds}s, {remaining})",
|
|
2519
|
+
)
|
|
2520
|
+
elif last_phases:
|
|
2521
|
+
# Show the actual phase from logs
|
|
2522
|
+
current_status = last_phases[-1]
|
|
2523
|
+
progress.update(
|
|
2524
|
+
task,
|
|
2525
|
+
description=f"[cyan]{current_status} ({minutes}m {seconds}s, {remaining})",
|
|
2330
2526
|
)
|
|
2331
2527
|
else:
|
|
2332
2528
|
progress.update(
|
|
2333
2529
|
task,
|
|
2334
|
-
description=f"[cyan]Installing
|
|
2530
|
+
description=f"[cyan]Installing packages... ({minutes}m {seconds}s, {remaining})",
|
|
2335
2531
|
)
|
|
2336
2532
|
|
|
2337
2533
|
except (subprocess.TimeoutExpired, Exception) as e:
|
|
@@ -2358,9 +2554,32 @@ def create_vm_from_config(
|
|
|
2358
2554
|
replace: bool = False,
|
|
2359
2555
|
) -> str:
|
|
2360
2556
|
"""Create VM from YAML config dict."""
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2557
|
+
paths = config.get("paths", {})
|
|
2558
|
+
# Backwards compatible: v1 uses app_data_paths, newer configs may use copy_paths
|
|
2559
|
+
copy_paths = config.get("copy_paths", None)
|
|
2560
|
+
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
2561
|
+
copy_paths = config.get("app_data_paths", {})
|
|
2562
|
+
|
|
2563
|
+
vm_section = config.get("vm") or {}
|
|
2564
|
+
|
|
2565
|
+
# Support both v1 (auth_method) and v2 (auth.method) config formats
|
|
2566
|
+
auth_section = vm_section.get("auth") or {}
|
|
2567
|
+
auth_method = auth_section.get("method") or vm_section.get("auth_method") or "ssh_key"
|
|
2568
|
+
|
|
2569
|
+
# v2 config: secrets provider
|
|
2570
|
+
secrets_section = config.get("secrets") or {}
|
|
2571
|
+
secrets_provider = secrets_section.get("provider", "auto")
|
|
2572
|
+
|
|
2573
|
+
# v2 config: resource limits
|
|
2574
|
+
limits_section = config.get("limits") or {}
|
|
2575
|
+
resources = {
|
|
2576
|
+
"memory_limit": limits_section.get("memory_limit"),
|
|
2577
|
+
"cpu_shares": limits_section.get("cpu_shares"),
|
|
2578
|
+
"disk_limit": limits_section.get("disk_limit"),
|
|
2579
|
+
"network_limit": limits_section.get("network_limit"),
|
|
2580
|
+
}
|
|
2581
|
+
# Remove None values
|
|
2582
|
+
resources = {k: v for k, v in resources.items() if v is not None}
|
|
2364
2583
|
|
|
2365
2584
|
vm_config = VMConfig(
|
|
2366
2585
|
name=config["vm"]["name"],
|
|
@@ -2369,7 +2588,8 @@ def create_vm_from_config(
|
|
|
2369
2588
|
disk_size_gb=config["vm"].get("disk_size_gb", 10),
|
|
2370
2589
|
gui=config["vm"].get("gui", True),
|
|
2371
2590
|
base_image=config["vm"].get("base_image"),
|
|
2372
|
-
paths=
|
|
2591
|
+
paths=paths,
|
|
2592
|
+
copy_paths=copy_paths,
|
|
2373
2593
|
packages=config.get("packages", []),
|
|
2374
2594
|
snap_packages=config.get("snap_packages", []),
|
|
2375
2595
|
services=config.get("services", []),
|
|
@@ -2378,6 +2598,9 @@ def create_vm_from_config(
|
|
|
2378
2598
|
network_mode=config["vm"].get("network_mode", "auto"),
|
|
2379
2599
|
username=config["vm"].get("username", "ubuntu"),
|
|
2380
2600
|
password=config["vm"].get("password", "ubuntu"),
|
|
2601
|
+
auth_method=auth_method,
|
|
2602
|
+
ssh_public_key=vm_section.get("ssh_public_key") or auth_section.get("ssh_public_key"),
|
|
2603
|
+
resources=resources if resources else config["vm"].get("resources", {}),
|
|
2381
2604
|
)
|
|
2382
2605
|
|
|
2383
2606
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
@@ -3238,6 +3461,104 @@ def cmd_audit_failures(args) -> None:
|
|
|
3238
3461
|
console.print(table)
|
|
3239
3462
|
|
|
3240
3463
|
|
|
3464
|
+
def cmd_audit_search(args) -> None:
|
|
3465
|
+
"""Search audit events."""
|
|
3466
|
+
from datetime import datetime, timedelta
|
|
3467
|
+
|
|
3468
|
+
query = AuditQuery()
|
|
3469
|
+
|
|
3470
|
+
# Parse event type
|
|
3471
|
+
event_type = None
|
|
3472
|
+
if hasattr(args, "event") and args.event:
|
|
3473
|
+
try:
|
|
3474
|
+
event_type = AuditEventType(args.event)
|
|
3475
|
+
except ValueError:
|
|
3476
|
+
console.print(f"[red]Unknown event type: {args.event}[/]")
|
|
3477
|
+
return
|
|
3478
|
+
|
|
3479
|
+
# Parse time range
|
|
3480
|
+
start_time = None
|
|
3481
|
+
if hasattr(args, "since") and args.since:
|
|
3482
|
+
since = args.since.lower()
|
|
3483
|
+
now = datetime.now()
|
|
3484
|
+
if "hour" in since:
|
|
3485
|
+
hours = int(since.split()[0]) if since[0].isdigit() else 1
|
|
3486
|
+
start_time = now - timedelta(hours=hours)
|
|
3487
|
+
elif "day" in since:
|
|
3488
|
+
days = int(since.split()[0]) if since[0].isdigit() else 1
|
|
3489
|
+
start_time = now - timedelta(days=days)
|
|
3490
|
+
elif "week" in since:
|
|
3491
|
+
weeks = int(since.split()[0]) if since[0].isdigit() else 1
|
|
3492
|
+
start_time = now - timedelta(weeks=weeks)
|
|
3493
|
+
|
|
3494
|
+
user = getattr(args, "user_filter", None)
|
|
3495
|
+
target = getattr(args, "target", None)
|
|
3496
|
+
limit = getattr(args, "limit", 100)
|
|
3497
|
+
|
|
3498
|
+
events = query.query(
|
|
3499
|
+
event_type=event_type,
|
|
3500
|
+
target_name=target,
|
|
3501
|
+
user=user,
|
|
3502
|
+
start_time=start_time,
|
|
3503
|
+
limit=limit,
|
|
3504
|
+
)
|
|
3505
|
+
|
|
3506
|
+
if not events:
|
|
3507
|
+
console.print("[yellow]No matching audit events found.[/]")
|
|
3508
|
+
return
|
|
3509
|
+
|
|
3510
|
+
console.print(f"[bold]Found {len(events)} events:[/]")
|
|
3511
|
+
|
|
3512
|
+
for event in events:
|
|
3513
|
+
outcome_color = "green" if event.outcome.value == "success" else "red"
|
|
3514
|
+
console.print(
|
|
3515
|
+
f" [{outcome_color}]{event.outcome.value}[/] "
|
|
3516
|
+
f"{event.timestamp.strftime('%Y-%m-%d %H:%M')} "
|
|
3517
|
+
f"[cyan]{event.event_type.value}[/] "
|
|
3518
|
+
f"{event.target_name or '-'}"
|
|
3519
|
+
)
|
|
3520
|
+
|
|
3521
|
+
|
|
3522
|
+
def cmd_audit_export(args) -> None:
|
|
3523
|
+
"""Export audit events to file."""
|
|
3524
|
+
query = AuditQuery()
|
|
3525
|
+
events = query.query(limit=getattr(args, "limit", 10000))
|
|
3526
|
+
|
|
3527
|
+
if not events:
|
|
3528
|
+
console.print("[yellow]No audit events to export.[/]")
|
|
3529
|
+
return
|
|
3530
|
+
|
|
3531
|
+
output_format = getattr(args, "format", "json")
|
|
3532
|
+
output_file = getattr(args, "output", None)
|
|
3533
|
+
|
|
3534
|
+
if output_format == "json":
|
|
3535
|
+
data = [e.to_dict() for e in events]
|
|
3536
|
+
content = json.dumps(data, indent=2, default=str)
|
|
3537
|
+
else:
|
|
3538
|
+
# CSV format
|
|
3539
|
+
import csv
|
|
3540
|
+
import io
|
|
3541
|
+
output = io.StringIO()
|
|
3542
|
+
writer = csv.writer(output)
|
|
3543
|
+
writer.writerow(["timestamp", "event_type", "outcome", "target", "user", "error"])
|
|
3544
|
+
for e in events:
|
|
3545
|
+
writer.writerow([
|
|
3546
|
+
e.timestamp.isoformat(),
|
|
3547
|
+
e.event_type.value,
|
|
3548
|
+
e.outcome.value,
|
|
3549
|
+
e.target_name or "",
|
|
3550
|
+
e.user,
|
|
3551
|
+
e.error_message or "",
|
|
3552
|
+
])
|
|
3553
|
+
content = output.getvalue()
|
|
3554
|
+
|
|
3555
|
+
if output_file:
|
|
3556
|
+
Path(output_file).write_text(content)
|
|
3557
|
+
console.print(f"[green]✅ Exported {len(events)} events to {output_file}[/]")
|
|
3558
|
+
else:
|
|
3559
|
+
console.print(content)
|
|
3560
|
+
|
|
3561
|
+
|
|
3241
3562
|
# === Orchestration Commands ===
|
|
3242
3563
|
|
|
3243
3564
|
|
|
@@ -3353,6 +3674,42 @@ def cmd_compose_status(args) -> None:
|
|
|
3353
3674
|
console.print(f"[red]❌ Failed to get status: {e}[/]")
|
|
3354
3675
|
|
|
3355
3676
|
|
|
3677
|
+
def cmd_compose_logs(args) -> None:
|
|
3678
|
+
"""Show aggregated logs from VMs."""
|
|
3679
|
+
compose_file = Path(args.file) if hasattr(args, "file") and args.file else Path("clonebox-compose.yaml")
|
|
3680
|
+
|
|
3681
|
+
if not compose_file.exists():
|
|
3682
|
+
console.print(f"[red]Compose file not found: {compose_file}[/]")
|
|
3683
|
+
return
|
|
3684
|
+
|
|
3685
|
+
user_session = getattr(args, "user", False)
|
|
3686
|
+
follow = getattr(args, "follow", False)
|
|
3687
|
+
lines = getattr(args, "lines", 50)
|
|
3688
|
+
service = getattr(args, "service", None)
|
|
3689
|
+
|
|
3690
|
+
try:
|
|
3691
|
+
orch = Orchestrator.from_file(compose_file, user_session=user_session)
|
|
3692
|
+
|
|
3693
|
+
if service:
|
|
3694
|
+
# Logs for specific service
|
|
3695
|
+
logs = orch.logs(service, follow=follow, lines=lines)
|
|
3696
|
+
if logs:
|
|
3697
|
+
console.print(f"[bold]Logs for {service}:[/]")
|
|
3698
|
+
console.print(logs)
|
|
3699
|
+
else:
|
|
3700
|
+
console.print(f"[yellow]No logs available for {service}[/]")
|
|
3701
|
+
else:
|
|
3702
|
+
# Logs for all services
|
|
3703
|
+
for name in orch.plan.vms.keys():
|
|
3704
|
+
logs = orch.logs(name, follow=False, lines=lines)
|
|
3705
|
+
if logs:
|
|
3706
|
+
console.print(f"\n[bold cyan]━━━ {name} ━━━[/]")
|
|
3707
|
+
console.print(logs)
|
|
3708
|
+
|
|
3709
|
+
except Exception as e:
|
|
3710
|
+
console.print(f"[red]❌ Failed to get logs: {e}[/]")
|
|
3711
|
+
|
|
3712
|
+
|
|
3356
3713
|
# === Plugin Commands ===
|
|
3357
3714
|
|
|
3358
3715
|
|
|
@@ -3430,6 +3787,199 @@ def cmd_plugin_discover(args) -> None:
|
|
|
3430
3787
|
console.print(f" • {name}")
|
|
3431
3788
|
|
|
3432
3789
|
|
|
3790
|
+
def cmd_plugin_install(args) -> None:
|
|
3791
|
+
"""Install a plugin."""
|
|
3792
|
+
manager = get_plugin_manager()
|
|
3793
|
+
source = args.source
|
|
3794
|
+
|
|
3795
|
+
console.print(f"[cyan]📦 Installing plugin from: {source}[/]")
|
|
3796
|
+
|
|
3797
|
+
if manager.install(source):
|
|
3798
|
+
console.print("[green]✅ Plugin installed successfully[/]")
|
|
3799
|
+
console.print("[dim]Run 'clonebox plugin discover' to see available plugins[/]")
|
|
3800
|
+
else:
|
|
3801
|
+
console.print(f"[red]❌ Failed to install plugin from: {source}[/]")
|
|
3802
|
+
|
|
3803
|
+
|
|
3804
|
+
def cmd_plugin_uninstall(args) -> None:
|
|
3805
|
+
"""Uninstall a plugin."""
|
|
3806
|
+
manager = get_plugin_manager()
|
|
3807
|
+
name = args.name
|
|
3808
|
+
|
|
3809
|
+
console.print(f"[cyan]🗑️ Uninstalling plugin: {name}[/]")
|
|
3810
|
+
|
|
3811
|
+
if manager.uninstall(name):
|
|
3812
|
+
console.print(f"[green]✅ Plugin '{name}' uninstalled successfully[/]")
|
|
3813
|
+
else:
|
|
3814
|
+
console.print(f"[red]❌ Failed to uninstall plugin: {name}[/]")
|
|
3815
|
+
|
|
3816
|
+
|
|
3817
|
+
# === Remote Management Commands ===
|
|
3818
|
+
|
|
3819
|
+
|
|
3820
|
+
def cmd_remote_list(args) -> None:
|
|
3821
|
+
"""List VMs on remote host."""
|
|
3822
|
+
host = args.host
|
|
3823
|
+
user_session = getattr(args, "user", False)
|
|
3824
|
+
|
|
3825
|
+
console.print(f"[cyan]🔍 Connecting to: {host}[/]")
|
|
3826
|
+
|
|
3827
|
+
try:
|
|
3828
|
+
remote = RemoteCloner(host, verify=True)
|
|
3829
|
+
|
|
3830
|
+
if not remote.is_clonebox_installed():
|
|
3831
|
+
console.print("[red]❌ CloneBox is not installed on remote host[/]")
|
|
3832
|
+
return
|
|
3833
|
+
|
|
3834
|
+
vms = remote.list_vms(user_session=user_session)
|
|
3835
|
+
|
|
3836
|
+
if not vms:
|
|
3837
|
+
console.print("[yellow]No VMs found on remote host.[/]")
|
|
3838
|
+
return
|
|
3839
|
+
|
|
3840
|
+
table = Table(title=f"VMs on {host}", border_style="cyan")
|
|
3841
|
+
table.add_column("Name")
|
|
3842
|
+
table.add_column("Status")
|
|
3843
|
+
|
|
3844
|
+
for vm in vms:
|
|
3845
|
+
name = vm.get("name", str(vm))
|
|
3846
|
+
status = vm.get("state", vm.get("status", "-"))
|
|
3847
|
+
table.add_row(name, status)
|
|
3848
|
+
|
|
3849
|
+
console.print(table)
|
|
3850
|
+
|
|
3851
|
+
except ConnectionError as e:
|
|
3852
|
+
console.print(f"[red]❌ Connection failed: {e}[/]")
|
|
3853
|
+
except Exception as e:
|
|
3854
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3855
|
+
|
|
3856
|
+
|
|
3857
|
+
def cmd_remote_status(args) -> None:
|
|
3858
|
+
"""Get VM status on remote host."""
|
|
3859
|
+
host = args.host
|
|
3860
|
+
vm_name = args.vm_name
|
|
3861
|
+
user_session = getattr(args, "user", False)
|
|
3862
|
+
|
|
3863
|
+
console.print(f"[cyan]🔍 Getting status of {vm_name} on {host}[/]")
|
|
3864
|
+
|
|
3865
|
+
try:
|
|
3866
|
+
remote = RemoteCloner(host, verify=True)
|
|
3867
|
+
status = remote.get_status(vm_name, user_session=user_session)
|
|
3868
|
+
|
|
3869
|
+
if getattr(args, "json", False):
|
|
3870
|
+
console.print_json(json.dumps(status, default=str))
|
|
3871
|
+
else:
|
|
3872
|
+
for key, value in status.items():
|
|
3873
|
+
console.print(f" [bold]{key}:[/] {value}")
|
|
3874
|
+
|
|
3875
|
+
except Exception as e:
|
|
3876
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3877
|
+
|
|
3878
|
+
|
|
3879
|
+
def cmd_remote_start(args) -> None:
|
|
3880
|
+
"""Start VM on remote host."""
|
|
3881
|
+
host = args.host
|
|
3882
|
+
vm_name = args.vm_name
|
|
3883
|
+
user_session = getattr(args, "user", False)
|
|
3884
|
+
|
|
3885
|
+
console.print(f"[cyan]🚀 Starting {vm_name} on {host}[/]")
|
|
3886
|
+
|
|
3887
|
+
try:
|
|
3888
|
+
remote = RemoteCloner(host, verify=True)
|
|
3889
|
+
remote.start_vm(vm_name, user_session=user_session)
|
|
3890
|
+
console.print(f"[green]✅ VM {vm_name} started[/]")
|
|
3891
|
+
|
|
3892
|
+
except Exception as e:
|
|
3893
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3894
|
+
|
|
3895
|
+
|
|
3896
|
+
def cmd_remote_stop(args) -> None:
|
|
3897
|
+
"""Stop VM on remote host."""
|
|
3898
|
+
host = args.host
|
|
3899
|
+
vm_name = args.vm_name
|
|
3900
|
+
user_session = getattr(args, "user", False)
|
|
3901
|
+
force = getattr(args, "force", False)
|
|
3902
|
+
|
|
3903
|
+
console.print(f"[cyan]🛑 Stopping {vm_name} on {host}[/]")
|
|
3904
|
+
|
|
3905
|
+
try:
|
|
3906
|
+
remote = RemoteCloner(host, verify=True)
|
|
3907
|
+
remote.stop_vm(vm_name, force=force, user_session=user_session)
|
|
3908
|
+
console.print(f"[green]✅ VM {vm_name} stopped[/]")
|
|
3909
|
+
|
|
3910
|
+
except Exception as e:
|
|
3911
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3912
|
+
|
|
3913
|
+
|
|
3914
|
+
def cmd_remote_delete(args) -> None:
|
|
3915
|
+
"""Delete VM on remote host."""
|
|
3916
|
+
host = args.host
|
|
3917
|
+
vm_name = args.vm_name
|
|
3918
|
+
user_session = getattr(args, "user", False)
|
|
3919
|
+
keep_storage = getattr(args, "keep_storage", False)
|
|
3920
|
+
|
|
3921
|
+
if not getattr(args, "yes", False):
|
|
3922
|
+
confirm = questionary.confirm(
|
|
3923
|
+
f"Delete VM '{vm_name}' on {host}?",
|
|
3924
|
+
default=False,
|
|
3925
|
+
style=custom_style,
|
|
3926
|
+
).ask()
|
|
3927
|
+
if not confirm:
|
|
3928
|
+
console.print("[yellow]Aborted.[/]")
|
|
3929
|
+
return
|
|
3930
|
+
|
|
3931
|
+
console.print(f"[cyan]🗑️ Deleting {vm_name} on {host}[/]")
|
|
3932
|
+
|
|
3933
|
+
try:
|
|
3934
|
+
remote = RemoteCloner(host, verify=True)
|
|
3935
|
+
remote.delete_vm(vm_name, keep_storage=keep_storage, user_session=user_session)
|
|
3936
|
+
console.print(f"[green]✅ VM {vm_name} deleted[/]")
|
|
3937
|
+
|
|
3938
|
+
except Exception as e:
|
|
3939
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3940
|
+
|
|
3941
|
+
|
|
3942
|
+
def cmd_remote_exec(args) -> None:
|
|
3943
|
+
"""Execute command in VM on remote host."""
|
|
3944
|
+
host = args.host
|
|
3945
|
+
vm_name = args.vm_name
|
|
3946
|
+
command = " ".join(args.command) if args.command else "echo ok"
|
|
3947
|
+
user_session = getattr(args, "user", False)
|
|
3948
|
+
timeout = getattr(args, "timeout", 30)
|
|
3949
|
+
|
|
3950
|
+
try:
|
|
3951
|
+
remote = RemoteCloner(host, verify=True)
|
|
3952
|
+
output = remote.exec_in_vm(vm_name, command, timeout=timeout, user_session=user_session)
|
|
3953
|
+
console.print(output)
|
|
3954
|
+
|
|
3955
|
+
except Exception as e:
|
|
3956
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3957
|
+
|
|
3958
|
+
|
|
3959
|
+
def cmd_remote_health(args) -> None:
|
|
3960
|
+
"""Run health check on remote VM."""
|
|
3961
|
+
host = args.host
|
|
3962
|
+
vm_name = args.vm_name
|
|
3963
|
+
user_session = getattr(args, "user", False)
|
|
3964
|
+
|
|
3965
|
+
console.print(f"[cyan]🏥 Running health check on {vm_name}@{host}[/]")
|
|
3966
|
+
|
|
3967
|
+
try:
|
|
3968
|
+
remote = RemoteCloner(host, verify=True)
|
|
3969
|
+
result = remote.health_check(vm_name, user_session=user_session)
|
|
3970
|
+
|
|
3971
|
+
if result["success"]:
|
|
3972
|
+
console.print("[green]✅ Health check passed[/]")
|
|
3973
|
+
else:
|
|
3974
|
+
console.print("[red]❌ Health check failed[/]")
|
|
3975
|
+
|
|
3976
|
+
if result.get("output"):
|
|
3977
|
+
console.print(result["output"])
|
|
3978
|
+
|
|
3979
|
+
except Exception as e:
|
|
3980
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
3981
|
+
|
|
3982
|
+
|
|
3433
3983
|
def main():
|
|
3434
3984
|
"""Main entry point."""
|
|
3435
3985
|
parser = argparse.ArgumentParser(
|
|
@@ -3812,6 +4362,37 @@ def main():
|
|
|
3812
4362
|
)
|
|
3813
4363
|
repair_parser.set_defaults(func=cmd_repair)
|
|
3814
4364
|
|
|
4365
|
+
# Logs command - view VM logs
|
|
4366
|
+
logs_parser = subparsers.add_parser("logs", help="View logs from VM")
|
|
4367
|
+
logs_parser.add_argument(
|
|
4368
|
+
"name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
|
|
4369
|
+
)
|
|
4370
|
+
logs_parser.add_argument(
|
|
4371
|
+
"-u",
|
|
4372
|
+
"--user",
|
|
4373
|
+
action="store_true",
|
|
4374
|
+
help="Use user session (qemu:///session)",
|
|
4375
|
+
)
|
|
4376
|
+
logs_parser.add_argument(
|
|
4377
|
+
"--all",
|
|
4378
|
+
action="store_true",
|
|
4379
|
+
help="Show all logs at once without interactive menu",
|
|
4380
|
+
)
|
|
4381
|
+
logs_parser.set_defaults(func=cmd_logs)
|
|
4382
|
+
|
|
4383
|
+
# Set-password command - set password for VM user
|
|
4384
|
+
set_password_parser = subparsers.add_parser("set-password", help="Set password for VM user")
|
|
4385
|
+
set_password_parser.add_argument(
|
|
4386
|
+
"name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
|
|
4387
|
+
)
|
|
4388
|
+
set_password_parser.add_argument(
|
|
4389
|
+
"-u",
|
|
4390
|
+
"--user",
|
|
4391
|
+
action="store_true",
|
|
4392
|
+
help="Use user session (qemu:///session)",
|
|
4393
|
+
)
|
|
4394
|
+
set_password_parser.set_defaults(func=cmd_set_password)
|
|
4395
|
+
|
|
3815
4396
|
# Export command - package VM for migration
|
|
3816
4397
|
export_parser = subparsers.add_parser("export", help="Export VM and data for migration")
|
|
3817
4398
|
export_parser.add_argument(
|
|
@@ -4036,6 +4617,20 @@ def main():
|
|
|
4036
4617
|
audit_failures.add_argument("--limit", "-n", type=int, default=20, help="Max events to show")
|
|
4037
4618
|
audit_failures.set_defaults(func=cmd_audit_failures)
|
|
4038
4619
|
|
|
4620
|
+
audit_search = audit_sub.add_parser("search", help="Search audit events")
|
|
4621
|
+
audit_search.add_argument("--event", "-e", help="Event type (e.g., vm.create)")
|
|
4622
|
+
audit_search.add_argument("--since", "-s", help="Time range (e.g., '1 hour ago', '7 days')")
|
|
4623
|
+
audit_search.add_argument("--user-filter", help="Filter by user")
|
|
4624
|
+
audit_search.add_argument("--target", help="Filter by target name")
|
|
4625
|
+
audit_search.add_argument("--limit", "-n", type=int, default=100, help="Max events to show")
|
|
4626
|
+
audit_search.set_defaults(func=cmd_audit_search)
|
|
4627
|
+
|
|
4628
|
+
audit_export = audit_sub.add_parser("export", help="Export audit events to file")
|
|
4629
|
+
audit_export.add_argument("--format", "-f", choices=["json", "csv"], default="json", help="Output format")
|
|
4630
|
+
audit_export.add_argument("--output", "-o", help="Output file (stdout if not specified)")
|
|
4631
|
+
audit_export.add_argument("--limit", "-n", type=int, default=10000, help="Max events to export")
|
|
4632
|
+
audit_export.set_defaults(func=cmd_audit_export)
|
|
4633
|
+
|
|
4039
4634
|
# === Compose/Orchestration Commands ===
|
|
4040
4635
|
compose_parser = subparsers.add_parser("compose", help="Multi-VM orchestration")
|
|
4041
4636
|
compose_sub = compose_parser.add_subparsers(dest="compose_command", help="Compose commands")
|
|
@@ -4059,6 +4654,14 @@ def main():
|
|
|
4059
4654
|
compose_status.add_argument("--json", action="store_true", help="Output as JSON")
|
|
4060
4655
|
compose_status.set_defaults(func=cmd_compose_status)
|
|
4061
4656
|
|
|
4657
|
+
compose_logs = compose_sub.add_parser("logs", help="Show aggregated logs from VMs")
|
|
4658
|
+
compose_logs.add_argument("-f", "--file", default="clonebox-compose.yaml", help="Compose file")
|
|
4659
|
+
compose_logs.add_argument("-u", "--user", action="store_true", help="Use user session")
|
|
4660
|
+
compose_logs.add_argument("--follow", action="store_true", help="Follow log output")
|
|
4661
|
+
compose_logs.add_argument("--lines", "-n", type=int, default=50, help="Number of lines to show")
|
|
4662
|
+
compose_logs.add_argument("service", nargs="?", help="Specific service to show logs for")
|
|
4663
|
+
compose_logs.set_defaults(func=cmd_compose_logs)
|
|
4664
|
+
|
|
4062
4665
|
# === Plugin Commands ===
|
|
4063
4666
|
plugin_parser = subparsers.add_parser("plugin", help="Manage plugins")
|
|
4064
4667
|
plugin_sub = plugin_parser.add_subparsers(dest="plugin_command", help="Plugin commands")
|
|
@@ -4077,6 +4680,65 @@ def main():
|
|
|
4077
4680
|
plugin_discover = plugin_sub.add_parser("discover", help="Discover available plugins")
|
|
4078
4681
|
plugin_discover.set_defaults(func=cmd_plugin_discover)
|
|
4079
4682
|
|
|
4683
|
+
plugin_install = plugin_sub.add_parser("install", help="Install a plugin")
|
|
4684
|
+
plugin_install.add_argument("source", help="Plugin source (PyPI package, git URL, or local path)")
|
|
4685
|
+
plugin_install.set_defaults(func=cmd_plugin_install)
|
|
4686
|
+
|
|
4687
|
+
plugin_uninstall = plugin_sub.add_parser("uninstall", aliases=["remove"], help="Uninstall a plugin")
|
|
4688
|
+
plugin_uninstall.add_argument("name", help="Plugin name")
|
|
4689
|
+
plugin_uninstall.set_defaults(func=cmd_plugin_uninstall)
|
|
4690
|
+
|
|
4691
|
+
# === Remote Management Commands ===
|
|
4692
|
+
remote_parser = subparsers.add_parser("remote", help="Manage VMs on remote hosts")
|
|
4693
|
+
remote_sub = remote_parser.add_subparsers(dest="remote_command", help="Remote commands")
|
|
4694
|
+
|
|
4695
|
+
remote_list = remote_sub.add_parser("list", aliases=["ls"], help="List VMs on remote host")
|
|
4696
|
+
remote_list.add_argument("host", help="Remote host (user@hostname)")
|
|
4697
|
+
remote_list.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4698
|
+
remote_list.set_defaults(func=cmd_remote_list)
|
|
4699
|
+
|
|
4700
|
+
remote_status = remote_sub.add_parser("status", help="Get VM status on remote host")
|
|
4701
|
+
remote_status.add_argument("host", help="Remote host (user@hostname)")
|
|
4702
|
+
remote_status.add_argument("vm_name", help="VM name")
|
|
4703
|
+
remote_status.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4704
|
+
remote_status.add_argument("--json", action="store_true", help="Output as JSON")
|
|
4705
|
+
remote_status.set_defaults(func=cmd_remote_status)
|
|
4706
|
+
|
|
4707
|
+
remote_start = remote_sub.add_parser("start", help="Start VM on remote host")
|
|
4708
|
+
remote_start.add_argument("host", help="Remote host (user@hostname)")
|
|
4709
|
+
remote_start.add_argument("vm_name", help="VM name")
|
|
4710
|
+
remote_start.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4711
|
+
remote_start.set_defaults(func=cmd_remote_start)
|
|
4712
|
+
|
|
4713
|
+
remote_stop = remote_sub.add_parser("stop", help="Stop VM on remote host")
|
|
4714
|
+
remote_stop.add_argument("host", help="Remote host (user@hostname)")
|
|
4715
|
+
remote_stop.add_argument("vm_name", help="VM name")
|
|
4716
|
+
remote_stop.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4717
|
+
remote_stop.add_argument("-f", "--force", action="store_true", help="Force stop")
|
|
4718
|
+
remote_stop.set_defaults(func=cmd_remote_stop)
|
|
4719
|
+
|
|
4720
|
+
remote_delete = remote_sub.add_parser("delete", aliases=["rm"], help="Delete VM on remote host")
|
|
4721
|
+
remote_delete.add_argument("host", help="Remote host (user@hostname)")
|
|
4722
|
+
remote_delete.add_argument("vm_name", help="VM name")
|
|
4723
|
+
remote_delete.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4724
|
+
remote_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
|
|
4725
|
+
remote_delete.add_argument("--keep-storage", action="store_true", help="Keep disk images")
|
|
4726
|
+
remote_delete.set_defaults(func=cmd_remote_delete)
|
|
4727
|
+
|
|
4728
|
+
remote_exec = remote_sub.add_parser("exec", help="Execute command in VM on remote host")
|
|
4729
|
+
remote_exec.add_argument("host", help="Remote host (user@hostname)")
|
|
4730
|
+
remote_exec.add_argument("vm_name", help="VM name")
|
|
4731
|
+
remote_exec.add_argument("command", nargs=argparse.REMAINDER, help="Command to execute")
|
|
4732
|
+
remote_exec.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4733
|
+
remote_exec.add_argument("-t", "--timeout", type=int, default=30, help="Command timeout")
|
|
4734
|
+
remote_exec.set_defaults(func=cmd_remote_exec)
|
|
4735
|
+
|
|
4736
|
+
remote_health = remote_sub.add_parser("health", help="Run health check on remote VM")
|
|
4737
|
+
remote_health.add_argument("host", help="Remote host (user@hostname)")
|
|
4738
|
+
remote_health.add_argument("vm_name", help="VM name")
|
|
4739
|
+
remote_health.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4740
|
+
remote_health.set_defaults(func=cmd_remote_health)
|
|
4741
|
+
|
|
4080
4742
|
args = parser.parse_args()
|
|
4081
4743
|
|
|
4082
4744
|
if hasattr(args, "func"):
|