clonebox 1.1.13__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 +452 -0
- clonebox/cli.py +966 -10
- clonebox/cloner.py +221 -135
- clonebox/orchestrator.py +568 -0
- clonebox/plugins/__init__.py +24 -0
- clonebox/plugins/base.py +319 -0
- clonebox/plugins/manager.py +523 -0
- clonebox/remote.py +511 -0
- clonebox/secrets.py +9 -6
- clonebox/validator.py +113 -41
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/METADATA +5 -1
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/RECORD +16 -10
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/WHEEL +0 -0
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/top_level.txt +0 -0
clonebox/cli.py
CHANGED
|
@@ -34,6 +34,10 @@ from clonebox.monitor import ResourceMonitor, format_bytes
|
|
|
34
34
|
from clonebox.p2p import P2PManager
|
|
35
35
|
from clonebox.snapshots import SnapshotManager, SnapshotType
|
|
36
36
|
from clonebox.health import HealthCheckManager, ProbeConfig, ProbeType
|
|
37
|
+
from clonebox.audit import get_audit_logger, AuditQuery, AuditEventType, AuditOutcome
|
|
38
|
+
from clonebox.orchestrator import Orchestrator, OrchestrationResult
|
|
39
|
+
from clonebox.plugins import get_plugin_manager, PluginHook, PluginContext
|
|
40
|
+
from clonebox.remote import RemoteCloner, RemoteConnection
|
|
37
41
|
|
|
38
42
|
# Custom questionary style
|
|
39
43
|
custom_style = Style(
|
|
@@ -458,6 +462,9 @@ def run_vm_diagnostics(
|
|
|
458
462
|
if health_status and "HEALTH_STATUS=OK" in health_status:
|
|
459
463
|
result["health"]["status"] = "ok"
|
|
460
464
|
console.print("[green]✅ Health: All checks passed[/]")
|
|
465
|
+
elif health_status and "HEALTH_STATUS=PENDING" in health_status:
|
|
466
|
+
result["health"]["status"] = "pending"
|
|
467
|
+
console.print("[yellow]⏳ Health: Setup in progress[/]")
|
|
461
468
|
elif health_status and "HEALTH_STATUS=FAILED" in health_status:
|
|
462
469
|
result["health"]["status"] = "failed"
|
|
463
470
|
console.print("[red]❌ Health: Some checks failed[/]")
|
|
@@ -595,6 +602,78 @@ def cmd_repair(args):
|
|
|
595
602
|
)
|
|
596
603
|
|
|
597
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
|
+
|
|
598
677
|
def interactive_mode():
|
|
599
678
|
"""Run the interactive VM creation wizard."""
|
|
600
679
|
print_banner()
|
|
@@ -1800,6 +1879,8 @@ def cmd_test(args):
|
|
|
1800
1879
|
console.print()
|
|
1801
1880
|
|
|
1802
1881
|
# Test 3: Check cloud-init status (if running)
|
|
1882
|
+
cloud_init_complete: Optional[bool] = None
|
|
1883
|
+
cloud_init_running: bool = False
|
|
1803
1884
|
if not quick and state == "running":
|
|
1804
1885
|
console.print("[bold]3. Cloud-init Status[/]")
|
|
1805
1886
|
try:
|
|
@@ -1809,16 +1890,23 @@ def cmd_test(args):
|
|
|
1809
1890
|
status = _qga_exec(vm_name, conn_uri, "cloud-init status 2>/dev/null || true", timeout=15)
|
|
1810
1891
|
if status is None:
|
|
1811
1892
|
console.print("[yellow]⚠️ Could not check cloud-init (QGA command failed)[/]")
|
|
1893
|
+
cloud_init_complete = None
|
|
1812
1894
|
elif "done" in status.lower():
|
|
1813
1895
|
console.print("[green]✅ Cloud-init completed[/]")
|
|
1896
|
+
cloud_init_complete = True
|
|
1814
1897
|
elif "running" in status.lower():
|
|
1815
1898
|
console.print("[yellow]⚠️ Cloud-init still running[/]")
|
|
1899
|
+
cloud_init_complete = False
|
|
1900
|
+
cloud_init_running = True
|
|
1816
1901
|
elif status.strip():
|
|
1817
1902
|
console.print(f"[yellow]⚠️ Cloud-init status: {status.strip()}[/]")
|
|
1903
|
+
cloud_init_complete = None
|
|
1818
1904
|
else:
|
|
1819
1905
|
console.print("[yellow]⚠️ Cloud-init status: unknown[/]")
|
|
1906
|
+
cloud_init_complete = None
|
|
1820
1907
|
except Exception:
|
|
1821
1908
|
console.print("[yellow]⚠️ Could not check cloud-init (QEMU agent may not be running)[/]")
|
|
1909
|
+
cloud_init_complete = None
|
|
1822
1910
|
|
|
1823
1911
|
console.print()
|
|
1824
1912
|
|
|
@@ -1877,17 +1965,33 @@ def cmd_test(args):
|
|
|
1877
1965
|
timeout=10,
|
|
1878
1966
|
)
|
|
1879
1967
|
if exists and exists.strip() == "yes":
|
|
1880
|
-
|
|
1968
|
+
_qga_exec(
|
|
1881
1969
|
vm_name,
|
|
1882
1970
|
conn_uri,
|
|
1883
|
-
"/usr/local/bin/clonebox-health >/dev/null 2>&1
|
|
1971
|
+
"/usr/local/bin/clonebox-health >/dev/null 2>&1 || true",
|
|
1884
1972
|
timeout=60,
|
|
1885
1973
|
)
|
|
1886
|
-
|
|
1887
|
-
|
|
1974
|
+
health_status = _qga_exec(
|
|
1975
|
+
vm_name,
|
|
1976
|
+
conn_uri,
|
|
1977
|
+
"cat /var/log/clonebox-health-status 2>/dev/null || true",
|
|
1978
|
+
timeout=10,
|
|
1979
|
+
)
|
|
1980
|
+
if health_status and "HEALTH_STATUS=OK" in health_status:
|
|
1981
|
+
console.print("[green]✅ Health check passed[/]")
|
|
1888
1982
|
console.print(" View results in VM: cat /var/log/clonebox-health.log")
|
|
1983
|
+
elif health_status and "HEALTH_STATUS=PENDING" in health_status:
|
|
1984
|
+
console.print("[yellow]⚠️ Health check pending (setup in progress)[/]")
|
|
1985
|
+
if cloud_init_running:
|
|
1986
|
+
console.print(" Cloud-init is still running; re-check after it completes")
|
|
1987
|
+
console.print(" View logs in VM: cat /var/log/clonebox-health.log")
|
|
1988
|
+
elif health_status and "HEALTH_STATUS=FAILED" in health_status:
|
|
1989
|
+
console.print("[yellow]⚠️ Health check reports failures[/]")
|
|
1990
|
+
if cloud_init_running:
|
|
1991
|
+
console.print(" Cloud-init is still running; some failures may be transient")
|
|
1992
|
+
console.print(" View logs in VM: cat /var/log/clonebox-health.log")
|
|
1889
1993
|
else:
|
|
1890
|
-
console.print("[yellow]⚠️ Health check
|
|
1994
|
+
console.print("[yellow]⚠️ Health check status not available yet[/]")
|
|
1891
1995
|
console.print(" View logs in VM: cat /var/log/clonebox-health.log")
|
|
1892
1996
|
else:
|
|
1893
1997
|
console.print("[yellow]⚠️ Health check script not found[/]")
|
|
@@ -1941,7 +2045,7 @@ def load_env_file(env_path: Path) -> dict:
|
|
|
1941
2045
|
continue
|
|
1942
2046
|
if "=" in line:
|
|
1943
2047
|
key, value = line.split("=", 1)
|
|
1944
|
-
env_vars[key.strip()] = value.strip()
|
|
2048
|
+
env_vars[key.strip()] = value.strip().strip("'\"")
|
|
1945
2049
|
|
|
1946
2050
|
return env_vars
|
|
1947
2051
|
|
|
@@ -1962,6 +2066,22 @@ def expand_env_vars(value, env_vars: dict):
|
|
|
1962
2066
|
return value
|
|
1963
2067
|
|
|
1964
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
|
+
|
|
1965
2085
|
def deduplicate_list(items: list, key=None) -> list:
|
|
1966
2086
|
"""Remove duplicates from list, preserving order."""
|
|
1967
2087
|
seen = set()
|
|
@@ -2218,6 +2338,14 @@ def load_clonebox_config(path: Path) -> dict:
|
|
|
2218
2338
|
# Expand environment variables in config
|
|
2219
2339
|
config = expand_env_vars(config, env_vars)
|
|
2220
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
|
+
|
|
2221
2349
|
return config
|
|
2222
2350
|
|
|
2223
2351
|
|
|
@@ -2327,9 +2455,32 @@ def create_vm_from_config(
|
|
|
2327
2455
|
replace: bool = False,
|
|
2328
2456
|
) -> str:
|
|
2329
2457
|
"""Create VM from YAML config dict."""
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
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}
|
|
2333
2484
|
|
|
2334
2485
|
vm_config = VMConfig(
|
|
2335
2486
|
name=config["vm"]["name"],
|
|
@@ -2338,7 +2489,8 @@ def create_vm_from_config(
|
|
|
2338
2489
|
disk_size_gb=config["vm"].get("disk_size_gb", 10),
|
|
2339
2490
|
gui=config["vm"].get("gui", True),
|
|
2340
2491
|
base_image=config["vm"].get("base_image"),
|
|
2341
|
-
paths=
|
|
2492
|
+
paths=paths,
|
|
2493
|
+
copy_paths=copy_paths,
|
|
2342
2494
|
packages=config.get("packages", []),
|
|
2343
2495
|
snap_packages=config.get("snap_packages", []),
|
|
2344
2496
|
services=config.get("services", []),
|
|
@@ -2347,6 +2499,9 @@ def create_vm_from_config(
|
|
|
2347
2499
|
network_mode=config["vm"].get("network_mode", "auto"),
|
|
2348
2500
|
username=config["vm"].get("username", "ubuntu"),
|
|
2349
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", {}),
|
|
2350
2505
|
)
|
|
2351
2506
|
|
|
2352
2507
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
@@ -3098,6 +3253,634 @@ def cmd_list_remote(args) -> None:
|
|
|
3098
3253
|
console.print("[yellow]No VMs found on remote host.[/]")
|
|
3099
3254
|
|
|
3100
3255
|
|
|
3256
|
+
# === Audit Commands ===
|
|
3257
|
+
|
|
3258
|
+
|
|
3259
|
+
def cmd_audit_list(args) -> None:
|
|
3260
|
+
"""List audit events."""
|
|
3261
|
+
query = AuditQuery()
|
|
3262
|
+
|
|
3263
|
+
# Build filters
|
|
3264
|
+
event_type = None
|
|
3265
|
+
if hasattr(args, "type") and args.type:
|
|
3266
|
+
try:
|
|
3267
|
+
event_type = AuditEventType(args.type)
|
|
3268
|
+
except ValueError:
|
|
3269
|
+
console.print(f"[red]Unknown event type: {args.type}[/]")
|
|
3270
|
+
return
|
|
3271
|
+
|
|
3272
|
+
outcome = None
|
|
3273
|
+
if hasattr(args, "outcome") and args.outcome:
|
|
3274
|
+
try:
|
|
3275
|
+
outcome = AuditOutcome(args.outcome)
|
|
3276
|
+
except ValueError:
|
|
3277
|
+
console.print(f"[red]Unknown outcome: {args.outcome}[/]")
|
|
3278
|
+
return
|
|
3279
|
+
|
|
3280
|
+
limit = getattr(args, "limit", 50)
|
|
3281
|
+
target = getattr(args, "target", None)
|
|
3282
|
+
|
|
3283
|
+
events = query.query(
|
|
3284
|
+
event_type=event_type,
|
|
3285
|
+
target_name=target,
|
|
3286
|
+
outcome=outcome,
|
|
3287
|
+
limit=limit,
|
|
3288
|
+
)
|
|
3289
|
+
|
|
3290
|
+
if not events:
|
|
3291
|
+
console.print("[yellow]No audit events found.[/]")
|
|
3292
|
+
return
|
|
3293
|
+
|
|
3294
|
+
if getattr(args, "json", False):
|
|
3295
|
+
console.print_json(json.dumps([e.to_dict() for e in events], default=str))
|
|
3296
|
+
return
|
|
3297
|
+
|
|
3298
|
+
table = Table(title="Audit Events", border_style="cyan")
|
|
3299
|
+
table.add_column("Time", style="dim")
|
|
3300
|
+
table.add_column("Event")
|
|
3301
|
+
table.add_column("Target")
|
|
3302
|
+
table.add_column("Outcome")
|
|
3303
|
+
table.add_column("User")
|
|
3304
|
+
|
|
3305
|
+
for event in reversed(events[-limit:]):
|
|
3306
|
+
outcome_style = {
|
|
3307
|
+
"success": "green",
|
|
3308
|
+
"failure": "red",
|
|
3309
|
+
"partial": "yellow",
|
|
3310
|
+
"denied": "red bold",
|
|
3311
|
+
"skipped": "dim",
|
|
3312
|
+
}.get(event.outcome.value, "white")
|
|
3313
|
+
|
|
3314
|
+
target_str = event.target_name or "-"
|
|
3315
|
+
table.add_row(
|
|
3316
|
+
event.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
|
|
3317
|
+
event.event_type.value,
|
|
3318
|
+
target_str,
|
|
3319
|
+
f"[{outcome_style}]{event.outcome.value}[/]",
|
|
3320
|
+
event.user,
|
|
3321
|
+
)
|
|
3322
|
+
|
|
3323
|
+
console.print(table)
|
|
3324
|
+
|
|
3325
|
+
|
|
3326
|
+
def cmd_audit_show(args) -> None:
|
|
3327
|
+
"""Show audit event details."""
|
|
3328
|
+
query = AuditQuery()
|
|
3329
|
+
events = query.query(limit=1000)
|
|
3330
|
+
|
|
3331
|
+
for event in events:
|
|
3332
|
+
if event.event_id == args.event_id:
|
|
3333
|
+
console.print_json(json.dumps(event.to_dict(), indent=2, default=str))
|
|
3334
|
+
return
|
|
3335
|
+
|
|
3336
|
+
console.print(f"[red]Event not found: {args.event_id}[/]")
|
|
3337
|
+
|
|
3338
|
+
|
|
3339
|
+
def cmd_audit_failures(args) -> None:
|
|
3340
|
+
"""Show recent failures."""
|
|
3341
|
+
query = AuditQuery()
|
|
3342
|
+
events = query.get_failures(limit=getattr(args, "limit", 20))
|
|
3343
|
+
|
|
3344
|
+
if not events:
|
|
3345
|
+
console.print("[green]No failures recorded.[/]")
|
|
3346
|
+
return
|
|
3347
|
+
|
|
3348
|
+
table = Table(title="Recent Failures", border_style="red")
|
|
3349
|
+
table.add_column("Time", style="dim")
|
|
3350
|
+
table.add_column("Event")
|
|
3351
|
+
table.add_column("Target")
|
|
3352
|
+
table.add_column("Error")
|
|
3353
|
+
|
|
3354
|
+
for event in reversed(events):
|
|
3355
|
+
table.add_row(
|
|
3356
|
+
event.timestamp.strftime("%Y-%m-%d %H:%M:%S"),
|
|
3357
|
+
event.event_type.value,
|
|
3358
|
+
event.target_name or "-",
|
|
3359
|
+
(event.error_message or "-")[:50],
|
|
3360
|
+
)
|
|
3361
|
+
|
|
3362
|
+
console.print(table)
|
|
3363
|
+
|
|
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
|
+
|
|
3463
|
+
# === Orchestration Commands ===
|
|
3464
|
+
|
|
3465
|
+
|
|
3466
|
+
def cmd_compose_up(args) -> None:
|
|
3467
|
+
"""Start VMs from compose file."""
|
|
3468
|
+
compose_file = Path(args.file) if hasattr(args, "file") and args.file else Path("clonebox-compose.yaml")
|
|
3469
|
+
|
|
3470
|
+
if not compose_file.exists():
|
|
3471
|
+
console.print(f"[red]Compose file not found: {compose_file}[/]")
|
|
3472
|
+
return
|
|
3473
|
+
|
|
3474
|
+
user_session = getattr(args, "user", False)
|
|
3475
|
+
services = args.services if hasattr(args, "services") and args.services else None
|
|
3476
|
+
|
|
3477
|
+
console.print(f"[cyan]🚀 Starting VMs from: {compose_file}[/]")
|
|
3478
|
+
|
|
3479
|
+
try:
|
|
3480
|
+
orch = Orchestrator.from_file(compose_file, user_session=user_session)
|
|
3481
|
+
result = orch.up(services=services, console=console)
|
|
3482
|
+
|
|
3483
|
+
if result.success:
|
|
3484
|
+
console.print("[green]✅ All VMs started successfully[/]")
|
|
3485
|
+
else:
|
|
3486
|
+
console.print("[yellow]⚠️ Some VMs failed to start:[/]")
|
|
3487
|
+
for name, error in result.errors.items():
|
|
3488
|
+
console.print(f" [red]{name}:[/] {error}")
|
|
3489
|
+
|
|
3490
|
+
console.print(f"[dim]Duration: {result.duration_seconds:.1f}s[/]")
|
|
3491
|
+
|
|
3492
|
+
except Exception as e:
|
|
3493
|
+
console.print(f"[red]❌ Orchestration failed: {e}[/]")
|
|
3494
|
+
|
|
3495
|
+
|
|
3496
|
+
def cmd_compose_down(args) -> None:
|
|
3497
|
+
"""Stop VMs from compose file."""
|
|
3498
|
+
compose_file = Path(args.file) if hasattr(args, "file") and args.file else Path("clonebox-compose.yaml")
|
|
3499
|
+
|
|
3500
|
+
if not compose_file.exists():
|
|
3501
|
+
console.print(f"[red]Compose file not found: {compose_file}[/]")
|
|
3502
|
+
return
|
|
3503
|
+
|
|
3504
|
+
user_session = getattr(args, "user", False)
|
|
3505
|
+
services = args.services if hasattr(args, "services") and args.services else None
|
|
3506
|
+
force = getattr(args, "force", False)
|
|
3507
|
+
|
|
3508
|
+
console.print(f"[cyan]🛑 Stopping VMs from: {compose_file}[/]")
|
|
3509
|
+
|
|
3510
|
+
try:
|
|
3511
|
+
orch = Orchestrator.from_file(compose_file, user_session=user_session)
|
|
3512
|
+
result = orch.down(services=services, force=force, console=console)
|
|
3513
|
+
|
|
3514
|
+
if result.success:
|
|
3515
|
+
console.print("[green]✅ All VMs stopped successfully[/]")
|
|
3516
|
+
else:
|
|
3517
|
+
console.print("[yellow]⚠️ Some VMs failed to stop:[/]")
|
|
3518
|
+
for name, error in result.errors.items():
|
|
3519
|
+
console.print(f" [red]{name}:[/] {error}")
|
|
3520
|
+
|
|
3521
|
+
except Exception as e:
|
|
3522
|
+
console.print(f"[red]❌ Stop failed: {e}[/]")
|
|
3523
|
+
|
|
3524
|
+
|
|
3525
|
+
def cmd_compose_status(args) -> None:
|
|
3526
|
+
"""Show status of VMs from compose file."""
|
|
3527
|
+
compose_file = Path(args.file) if hasattr(args, "file") and args.file else Path("clonebox-compose.yaml")
|
|
3528
|
+
|
|
3529
|
+
if not compose_file.exists():
|
|
3530
|
+
console.print(f"[red]Compose file not found: {compose_file}[/]")
|
|
3531
|
+
return
|
|
3532
|
+
|
|
3533
|
+
user_session = getattr(args, "user", False)
|
|
3534
|
+
|
|
3535
|
+
try:
|
|
3536
|
+
orch = Orchestrator.from_file(compose_file, user_session=user_session)
|
|
3537
|
+
status = orch.status()
|
|
3538
|
+
|
|
3539
|
+
if getattr(args, "json", False):
|
|
3540
|
+
console.print_json(json.dumps(status, default=str))
|
|
3541
|
+
return
|
|
3542
|
+
|
|
3543
|
+
table = Table(title=f"Compose Status: {compose_file.name}", border_style="cyan")
|
|
3544
|
+
table.add_column("VM")
|
|
3545
|
+
table.add_column("State")
|
|
3546
|
+
table.add_column("Actual")
|
|
3547
|
+
table.add_column("Health")
|
|
3548
|
+
table.add_column("Depends On")
|
|
3549
|
+
|
|
3550
|
+
for name, info in status.items():
|
|
3551
|
+
state = info["orchestration_state"]
|
|
3552
|
+
actual = info["actual_state"]
|
|
3553
|
+
health = "✅" if info["health_check_passed"] else "⏳"
|
|
3554
|
+
deps = ", ".join(info["depends_on"]) or "-"
|
|
3555
|
+
|
|
3556
|
+
state_style = {
|
|
3557
|
+
"running": "green",
|
|
3558
|
+
"healthy": "green bold",
|
|
3559
|
+
"stopped": "dim",
|
|
3560
|
+
"failed": "red",
|
|
3561
|
+
"pending": "yellow",
|
|
3562
|
+
}.get(state, "white")
|
|
3563
|
+
|
|
3564
|
+
table.add_row(
|
|
3565
|
+
name,
|
|
3566
|
+
f"[{state_style}]{state}[/]",
|
|
3567
|
+
actual,
|
|
3568
|
+
health,
|
|
3569
|
+
deps,
|
|
3570
|
+
)
|
|
3571
|
+
|
|
3572
|
+
console.print(table)
|
|
3573
|
+
|
|
3574
|
+
except Exception as e:
|
|
3575
|
+
console.print(f"[red]❌ Failed to get status: {e}[/]")
|
|
3576
|
+
|
|
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
|
+
|
|
3614
|
+
# === Plugin Commands ===
|
|
3615
|
+
|
|
3616
|
+
|
|
3617
|
+
def cmd_plugin_list(args) -> None:
|
|
3618
|
+
"""List installed plugins."""
|
|
3619
|
+
manager = get_plugin_manager()
|
|
3620
|
+
|
|
3621
|
+
# Load plugins if not already loaded
|
|
3622
|
+
if not manager.list_plugins():
|
|
3623
|
+
manager.load_all()
|
|
3624
|
+
|
|
3625
|
+
plugins = manager.list_plugins()
|
|
3626
|
+
|
|
3627
|
+
if not plugins:
|
|
3628
|
+
console.print("[yellow]No plugins installed.[/]")
|
|
3629
|
+
console.print("[dim]Plugin directories:[/]")
|
|
3630
|
+
for d in manager.plugin_dirs:
|
|
3631
|
+
console.print(f" {d}")
|
|
3632
|
+
return
|
|
3633
|
+
|
|
3634
|
+
table = Table(title="Installed Plugins", border_style="cyan")
|
|
3635
|
+
table.add_column("Name")
|
|
3636
|
+
table.add_column("Version")
|
|
3637
|
+
table.add_column("Enabled")
|
|
3638
|
+
table.add_column("Description")
|
|
3639
|
+
|
|
3640
|
+
for plugin in plugins:
|
|
3641
|
+
enabled = "[green]✅[/]" if plugin["enabled"] else "[red]❌[/]"
|
|
3642
|
+
table.add_row(
|
|
3643
|
+
plugin["name"],
|
|
3644
|
+
plugin["version"],
|
|
3645
|
+
enabled,
|
|
3646
|
+
(plugin.get("description", "") or "")[:40],
|
|
3647
|
+
)
|
|
3648
|
+
|
|
3649
|
+
console.print(table)
|
|
3650
|
+
|
|
3651
|
+
|
|
3652
|
+
def cmd_plugin_enable(args) -> None:
|
|
3653
|
+
"""Enable a plugin."""
|
|
3654
|
+
manager = get_plugin_manager()
|
|
3655
|
+
manager.load_all()
|
|
3656
|
+
|
|
3657
|
+
if manager.enable(args.name):
|
|
3658
|
+
console.print(f"[green]✅ Plugin '{args.name}' enabled[/]")
|
|
3659
|
+
else:
|
|
3660
|
+
console.print(f"[red]Plugin '{args.name}' not found[/]")
|
|
3661
|
+
|
|
3662
|
+
|
|
3663
|
+
def cmd_plugin_disable(args) -> None:
|
|
3664
|
+
"""Disable a plugin."""
|
|
3665
|
+
manager = get_plugin_manager()
|
|
3666
|
+
manager.load_all()
|
|
3667
|
+
|
|
3668
|
+
if manager.disable(args.name):
|
|
3669
|
+
console.print(f"[yellow]⚠️ Plugin '{args.name}' disabled[/]")
|
|
3670
|
+
else:
|
|
3671
|
+
console.print(f"[red]Plugin '{args.name}' not found[/]")
|
|
3672
|
+
|
|
3673
|
+
|
|
3674
|
+
def cmd_plugin_discover(args) -> None:
|
|
3675
|
+
"""Discover available plugins."""
|
|
3676
|
+
manager = get_plugin_manager()
|
|
3677
|
+
discovered = manager.discover()
|
|
3678
|
+
|
|
3679
|
+
if not discovered:
|
|
3680
|
+
console.print("[yellow]No plugins discovered.[/]")
|
|
3681
|
+
console.print("[dim]Plugin directories:[/]")
|
|
3682
|
+
for d in manager.plugin_dirs:
|
|
3683
|
+
console.print(f" {d}")
|
|
3684
|
+
return
|
|
3685
|
+
|
|
3686
|
+
console.print("[bold]Discovered plugins:[/]")
|
|
3687
|
+
for name in discovered:
|
|
3688
|
+
console.print(f" • {name}")
|
|
3689
|
+
|
|
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
|
+
|
|
3101
3884
|
def main():
|
|
3102
3885
|
"""Main entry point."""
|
|
3103
3886
|
parser = argparse.ArgumentParser(
|
|
@@ -3480,6 +4263,37 @@ def main():
|
|
|
3480
4263
|
)
|
|
3481
4264
|
repair_parser.set_defaults(func=cmd_repair)
|
|
3482
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
|
+
|
|
3483
4297
|
# Export command - package VM for migration
|
|
3484
4298
|
export_parser = subparsers.add_parser("export", help="Export VM and data for migration")
|
|
3485
4299
|
export_parser.add_argument(
|
|
@@ -3684,6 +4498,148 @@ def main():
|
|
|
3684
4498
|
list_remote_parser.add_argument("host", help="Remote host (user@hostname)")
|
|
3685
4499
|
list_remote_parser.set_defaults(func=cmd_list_remote)
|
|
3686
4500
|
|
|
4501
|
+
# === Audit Commands ===
|
|
4502
|
+
audit_parser = subparsers.add_parser("audit", help="View audit logs")
|
|
4503
|
+
audit_sub = audit_parser.add_subparsers(dest="audit_command", help="Audit commands")
|
|
4504
|
+
|
|
4505
|
+
audit_list = audit_sub.add_parser("list", aliases=["ls"], help="List audit events")
|
|
4506
|
+
audit_list.add_argument("--type", "-t", help="Filter by event type (e.g., vm.create)")
|
|
4507
|
+
audit_list.add_argument("--target", help="Filter by target name")
|
|
4508
|
+
audit_list.add_argument("--outcome", "-o", choices=["success", "failure", "partial"], help="Filter by outcome")
|
|
4509
|
+
audit_list.add_argument("--limit", "-n", type=int, default=50, help="Max events to show")
|
|
4510
|
+
audit_list.add_argument("--json", action="store_true", help="Output as JSON")
|
|
4511
|
+
audit_list.set_defaults(func=cmd_audit_list)
|
|
4512
|
+
|
|
4513
|
+
audit_show = audit_sub.add_parser("show", help="Show audit event details")
|
|
4514
|
+
audit_show.add_argument("event_id", help="Event ID to show")
|
|
4515
|
+
audit_show.set_defaults(func=cmd_audit_show)
|
|
4516
|
+
|
|
4517
|
+
audit_failures = audit_sub.add_parser("failures", help="Show recent failures")
|
|
4518
|
+
audit_failures.add_argument("--limit", "-n", type=int, default=20, help="Max events to show")
|
|
4519
|
+
audit_failures.set_defaults(func=cmd_audit_failures)
|
|
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
|
+
|
|
4535
|
+
# === Compose/Orchestration Commands ===
|
|
4536
|
+
compose_parser = subparsers.add_parser("compose", help="Multi-VM orchestration")
|
|
4537
|
+
compose_sub = compose_parser.add_subparsers(dest="compose_command", help="Compose commands")
|
|
4538
|
+
|
|
4539
|
+
compose_up = compose_sub.add_parser("up", help="Start VMs from compose file")
|
|
4540
|
+
compose_up.add_argument("-f", "--file", default="clonebox-compose.yaml", help="Compose file")
|
|
4541
|
+
compose_up.add_argument("-u", "--user", action="store_true", help="Use user session")
|
|
4542
|
+
compose_up.add_argument("services", nargs="*", help="Specific services to start")
|
|
4543
|
+
compose_up.set_defaults(func=cmd_compose_up)
|
|
4544
|
+
|
|
4545
|
+
compose_down = compose_sub.add_parser("down", help="Stop VMs from compose file")
|
|
4546
|
+
compose_down.add_argument("-f", "--file", default="clonebox-compose.yaml", help="Compose file")
|
|
4547
|
+
compose_down.add_argument("-u", "--user", action="store_true", help="Use user session")
|
|
4548
|
+
compose_down.add_argument("--force", action="store_true", help="Force stop")
|
|
4549
|
+
compose_down.add_argument("services", nargs="*", help="Specific services to stop")
|
|
4550
|
+
compose_down.set_defaults(func=cmd_compose_down)
|
|
4551
|
+
|
|
4552
|
+
compose_status = compose_sub.add_parser("status", aliases=["ps"], help="Show compose status")
|
|
4553
|
+
compose_status.add_argument("-f", "--file", default="clonebox-compose.yaml", help="Compose file")
|
|
4554
|
+
compose_status.add_argument("-u", "--user", action="store_true", help="Use user session")
|
|
4555
|
+
compose_status.add_argument("--json", action="store_true", help="Output as JSON")
|
|
4556
|
+
compose_status.set_defaults(func=cmd_compose_status)
|
|
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
|
+
|
|
4566
|
+
# === Plugin Commands ===
|
|
4567
|
+
plugin_parser = subparsers.add_parser("plugin", help="Manage plugins")
|
|
4568
|
+
plugin_sub = plugin_parser.add_subparsers(dest="plugin_command", help="Plugin commands")
|
|
4569
|
+
|
|
4570
|
+
plugin_list = plugin_sub.add_parser("list", aliases=["ls"], help="List plugins")
|
|
4571
|
+
plugin_list.set_defaults(func=cmd_plugin_list)
|
|
4572
|
+
|
|
4573
|
+
plugin_enable = plugin_sub.add_parser("enable", help="Enable a plugin")
|
|
4574
|
+
plugin_enable.add_argument("name", help="Plugin name")
|
|
4575
|
+
plugin_enable.set_defaults(func=cmd_plugin_enable)
|
|
4576
|
+
|
|
4577
|
+
plugin_disable = plugin_sub.add_parser("disable", help="Disable a plugin")
|
|
4578
|
+
plugin_disable.add_argument("name", help="Plugin name")
|
|
4579
|
+
plugin_disable.set_defaults(func=cmd_plugin_disable)
|
|
4580
|
+
|
|
4581
|
+
plugin_discover = plugin_sub.add_parser("discover", help="Discover available plugins")
|
|
4582
|
+
plugin_discover.set_defaults(func=cmd_plugin_discover)
|
|
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
|
+
|
|
3687
4643
|
args = parser.parse_args()
|
|
3688
4644
|
|
|
3689
4645
|
if hasattr(args, "func"):
|