clonebox 1.1.18__py3-none-any.whl → 1.1.19__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/backends/libvirt_backend.py +3 -1
- clonebox/cli.py +591 -556
- clonebox/cloner.py +465 -412
- clonebox/health/probes.py +14 -0
- clonebox/policies/__init__.py +13 -0
- clonebox/policies/engine.py +112 -0
- clonebox/policies/models.py +55 -0
- clonebox/policies/validators.py +26 -0
- clonebox/validator.py +70 -28
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/METADATA +1 -1
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/RECORD +15 -11
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/WHEEL +0 -0
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.18.dist-info → clonebox-1.1.19.dist-info}/top_level.txt +0 -0
clonebox/cli.py
CHANGED
|
@@ -7,8 +7,10 @@ import argparse
|
|
|
7
7
|
import json
|
|
8
8
|
import os
|
|
9
9
|
import re
|
|
10
|
+
import secrets
|
|
10
11
|
import sys
|
|
11
12
|
import time
|
|
13
|
+
from dataclasses import asdict
|
|
12
14
|
from typing import Any, Dict, Optional, Tuple
|
|
13
15
|
from datetime import datetime
|
|
14
16
|
from pathlib import Path
|
|
@@ -37,6 +39,7 @@ from clonebox.health import HealthCheckManager, ProbeConfig, ProbeType
|
|
|
37
39
|
from clonebox.audit import get_audit_logger, AuditQuery, AuditEventType, AuditOutcome
|
|
38
40
|
from clonebox.orchestrator import Orchestrator, OrchestrationResult
|
|
39
41
|
from clonebox.plugins import get_plugin_manager, PluginHook, PluginContext
|
|
42
|
+
from clonebox.policies import PolicyEngine, PolicyValidationError, PolicyViolationError
|
|
40
43
|
from clonebox.remote import RemoteCloner, RemoteConnection
|
|
41
44
|
|
|
42
45
|
# Custom questionary style
|
|
@@ -263,8 +266,6 @@ def run_vm_diagnostics(
|
|
|
263
266
|
console.print(f"[dim]{domifaddr.stdout.strip()}[/]")
|
|
264
267
|
else:
|
|
265
268
|
console.print("[yellow]⚠️ No interface address detected via virsh domifaddr[/]")
|
|
266
|
-
if verbose and domifaddr.stderr.strip():
|
|
267
|
-
console.print(f"[dim]{domifaddr.stderr.strip()}[/]")
|
|
268
269
|
# Fallback: try to get IP via QEMU Guest Agent (useful for slirp/user networking)
|
|
269
270
|
if guest_agent_ready:
|
|
270
271
|
try:
|
|
@@ -349,7 +350,7 @@ def run_vm_diagnostics(
|
|
|
349
350
|
if not guest_agent_ready:
|
|
350
351
|
result["cloud_init"] = {"status": "unknown", "reason": "qga_not_ready"}
|
|
351
352
|
console.print(
|
|
352
|
-
"[yellow]⏳ Cloud-init status: Unknown (QEMU
|
|
353
|
+
"[yellow]⏳ Cloud-init status: Unknown (QEMU Guest Agent not connected yet)[/]"
|
|
353
354
|
)
|
|
354
355
|
else:
|
|
355
356
|
ready_msg = _qga_exec(
|
|
@@ -453,7 +454,7 @@ def run_vm_diagnostics(
|
|
|
453
454
|
console.print("\n[bold]🏥 Health Check Status...[/]")
|
|
454
455
|
if not guest_agent_ready:
|
|
455
456
|
result["health"]["status"] = "unknown"
|
|
456
|
-
console.print("[dim]Health status: Not available yet (QEMU
|
|
457
|
+
console.print("[dim]Health status: Not available yet (QEMU Guest Agent not ready)[/]")
|
|
457
458
|
else:
|
|
458
459
|
health_status = _qga_exec(
|
|
459
460
|
vm_name, conn_uri, "cat /var/log/clonebox-health-status 2>/dev/null || true", timeout=10
|
|
@@ -609,7 +610,6 @@ def cmd_logs(args):
|
|
|
609
610
|
|
|
610
611
|
name = args.name
|
|
611
612
|
user_session = getattr(args, "user", False)
|
|
612
|
-
show_all = getattr(args, "all", False)
|
|
613
613
|
|
|
614
614
|
try:
|
|
615
615
|
vm_name, _ = _resolve_vm_name_and_config_file(name)
|
|
@@ -629,7 +629,7 @@ def cmd_logs(args):
|
|
|
629
629
|
try:
|
|
630
630
|
console.print(f"[cyan]📋 Opening logs for VM: {vm_name}[/]")
|
|
631
631
|
subprocess.run(
|
|
632
|
-
[str(logs_script), vm_name, "true" if user_session else "false", "true" if
|
|
632
|
+
[str(logs_script), vm_name, "true" if user_session else "false", "true" if getattr(args, "all", False) else "false"],
|
|
633
633
|
check=True
|
|
634
634
|
)
|
|
635
635
|
except subprocess.CalledProcessError as e:
|
|
@@ -942,7 +942,7 @@ def interactive_mode():
|
|
|
942
942
|
)
|
|
943
943
|
|
|
944
944
|
try:
|
|
945
|
-
cloner = SelectiveVMCloner(user_session=
|
|
945
|
+
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
946
946
|
|
|
947
947
|
# Check prerequisites
|
|
948
948
|
checks = cloner.check_prerequisites()
|
|
@@ -1192,13 +1192,42 @@ def cmd_delete(args):
|
|
|
1192
1192
|
# If name is a path, load config
|
|
1193
1193
|
if name and (name.startswith(".") or name.startswith("/") or name.startswith("~")):
|
|
1194
1194
|
target_path = Path(name).expanduser().resolve()
|
|
1195
|
-
|
|
1195
|
+
|
|
1196
|
+
if target_path.is_dir():
|
|
1197
|
+
config_file = target_path / CLONEBOX_CONFIG_FILE
|
|
1198
|
+
else:
|
|
1199
|
+
config_file = target_path
|
|
1200
|
+
|
|
1196
1201
|
if config_file.exists():
|
|
1197
1202
|
config = load_clonebox_config(config_file)
|
|
1198
1203
|
name = config["vm"]["name"]
|
|
1199
1204
|
else:
|
|
1200
1205
|
console.print(f"[red]❌ Config not found: {config_file}[/]")
|
|
1201
1206
|
return
|
|
1207
|
+
elif not name or name == ".":
|
|
1208
|
+
config_file = Path.cwd() / ".clonebox.yaml"
|
|
1209
|
+
if config_file.exists():
|
|
1210
|
+
config = load_clonebox_config(config_file)
|
|
1211
|
+
name = config["vm"]["name"]
|
|
1212
|
+
else:
|
|
1213
|
+
console.print("[red]❌ No .clonebox.yaml found in current directory[/]")
|
|
1214
|
+
console.print("[dim]Usage: clonebox delete . or clonebox delete <vm-name>[/]")
|
|
1215
|
+
return
|
|
1216
|
+
|
|
1217
|
+
policy_start = None
|
|
1218
|
+
if name and (name.startswith(".") or name.startswith("/") or name.startswith("~")):
|
|
1219
|
+
policy_start = Path(name).expanduser().resolve()
|
|
1220
|
+
|
|
1221
|
+
policy = PolicyEngine.load_effective(start=policy_start)
|
|
1222
|
+
if policy is not None:
|
|
1223
|
+
try:
|
|
1224
|
+
policy.assert_operation_approved(
|
|
1225
|
+
AuditEventType.VM_DELETE.value,
|
|
1226
|
+
approved=getattr(args, "approve", False),
|
|
1227
|
+
)
|
|
1228
|
+
except PolicyViolationError as e:
|
|
1229
|
+
console.print(f"[red]❌ {e}[/]")
|
|
1230
|
+
sys.exit(1)
|
|
1202
1231
|
|
|
1203
1232
|
if not args.yes:
|
|
1204
1233
|
if not questionary.confirm(
|
|
@@ -1207,6 +1236,19 @@ def cmd_delete(args):
|
|
|
1207
1236
|
console.print("[yellow]Cancelled.[/]")
|
|
1208
1237
|
return
|
|
1209
1238
|
|
|
1239
|
+
cloner = SelectiveVMCloner(user_session=getattr(args, "user", False))
|
|
1240
|
+
delete_storage = not getattr(args, "keep_storage", False)
|
|
1241
|
+
console.print(f"[cyan]🗑️ Deleting VM: {name}[/]")
|
|
1242
|
+
try:
|
|
1243
|
+
ok = cloner.delete_vm(
|
|
1244
|
+
name, delete_storage=delete_storage, console=console, approved=getattr(args, "approve", False)
|
|
1245
|
+
)
|
|
1246
|
+
if not ok:
|
|
1247
|
+
sys.exit(1)
|
|
1248
|
+
except Exception as e:
|
|
1249
|
+
console.print(f"[red]❌ Failed to delete VM: {e}[/]")
|
|
1250
|
+
sys.exit(1)
|
|
1251
|
+
|
|
1210
1252
|
|
|
1211
1253
|
def cmd_list(args):
|
|
1212
1254
|
"""List all VMs."""
|
|
@@ -1418,7 +1460,12 @@ def cmd_export(args):
|
|
|
1418
1460
|
# If name is a path, load config
|
|
1419
1461
|
if name and (name.startswith(".") or name.startswith("/") or name.startswith("~")):
|
|
1420
1462
|
target_path = Path(name).expanduser().resolve()
|
|
1421
|
-
|
|
1463
|
+
|
|
1464
|
+
if target_path.is_dir():
|
|
1465
|
+
config_file = target_path / CLONEBOX_CONFIG_FILE
|
|
1466
|
+
else:
|
|
1467
|
+
config_file = target_path
|
|
1468
|
+
|
|
1422
1469
|
if config_file.exists():
|
|
1423
1470
|
config = load_clonebox_config(config_file)
|
|
1424
1471
|
name = config["vm"]["name"]
|
|
@@ -1649,6 +1696,16 @@ def cmd_import(args):
|
|
|
1649
1696
|
f"[red]❌ VM '{vm_name}' already exists. Use --replace to overwrite.[/]"
|
|
1650
1697
|
)
|
|
1651
1698
|
return
|
|
1699
|
+
policy = PolicyEngine.load_effective(start=vm_storage)
|
|
1700
|
+
if policy is not None:
|
|
1701
|
+
try:
|
|
1702
|
+
policy.assert_operation_approved(
|
|
1703
|
+
AuditEventType.VM_DELETE.value,
|
|
1704
|
+
approved=getattr(args, "approve", False),
|
|
1705
|
+
)
|
|
1706
|
+
except PolicyViolationError as e:
|
|
1707
|
+
console.print(f"[red]❌ {e}[/]")
|
|
1708
|
+
sys.exit(1)
|
|
1652
1709
|
shutil.rmtree(vm_storage)
|
|
1653
1710
|
|
|
1654
1711
|
vm_storage.mkdir(parents=True)
|
|
@@ -1811,7 +1868,7 @@ def cmd_test(args):
|
|
|
1811
1868
|
console.print()
|
|
1812
1869
|
|
|
1813
1870
|
# Test 2: Check VM state
|
|
1814
|
-
|
|
1871
|
+
cloud_init_running = False
|
|
1815
1872
|
try:
|
|
1816
1873
|
result = subprocess.run(
|
|
1817
1874
|
["virsh", "--connect", conn_uri, "domstate", vm_name],
|
|
@@ -1820,6 +1877,7 @@ def cmd_test(args):
|
|
|
1820
1877
|
timeout=10,
|
|
1821
1878
|
)
|
|
1822
1879
|
state = result.stdout.strip()
|
|
1880
|
+
|
|
1823
1881
|
if state == "running":
|
|
1824
1882
|
console.print("[green]✅ VM is running[/]")
|
|
1825
1883
|
|
|
@@ -1831,6 +1889,15 @@ def cmd_test(args):
|
|
|
1831
1889
|
qga_ready = _qga_ping(vm_name, conn_uri)
|
|
1832
1890
|
if qga_ready:
|
|
1833
1891
|
break
|
|
1892
|
+
|
|
1893
|
+
# Check cloud-init status immediately if QGA is ready
|
|
1894
|
+
if qga_ready:
|
|
1895
|
+
status = _qga_exec(
|
|
1896
|
+
vm_name, conn_uri, "cloud-init status 2>/dev/null || true", timeout=15
|
|
1897
|
+
)
|
|
1898
|
+
if status and "running" in status.lower():
|
|
1899
|
+
cloud_init_running = True
|
|
1900
|
+
console.print("[yellow]⏳ Setup in progress (cloud-init is running)[/]")
|
|
1834
1901
|
|
|
1835
1902
|
# Test network if running
|
|
1836
1903
|
console.print("\n Checking network...")
|
|
@@ -1859,8 +1926,9 @@ def cmd_test(args):
|
|
|
1859
1926
|
timeout=5,
|
|
1860
1927
|
)
|
|
1861
1928
|
if ip_out and ip_out.strip():
|
|
1929
|
+
ip_clean = ip_out.strip().replace("\n", ", ")
|
|
1862
1930
|
console.print(
|
|
1863
|
-
f"[green]✅ VM has network access (IP via QGA: {
|
|
1931
|
+
f"[green]✅ VM has network access (IP via QGA: {ip_clean})[/]"
|
|
1864
1932
|
)
|
|
1865
1933
|
else:
|
|
1866
1934
|
console.print("[yellow]⚠️ IP not available via QGA[/]")
|
|
@@ -1880,14 +1948,15 @@ def cmd_test(args):
|
|
|
1880
1948
|
|
|
1881
1949
|
# Test 3: Check cloud-init status (if running)
|
|
1882
1950
|
cloud_init_complete: Optional[bool] = None
|
|
1883
|
-
cloud_init_running: bool = False
|
|
1884
1951
|
if not quick and state == "running":
|
|
1885
1952
|
console.print("[bold]3. Cloud-init Status[/]")
|
|
1886
1953
|
try:
|
|
1887
1954
|
if not qga_ready:
|
|
1888
1955
|
console.print("[yellow]⚠️ Cloud-init status unknown (QEMU Guest Agent not connected)[/]")
|
|
1889
1956
|
else:
|
|
1890
|
-
status = _qga_exec(
|
|
1957
|
+
status = _qga_exec(
|
|
1958
|
+
vm_name, conn_uri, "cloud-init status 2>/dev/null || true", timeout=15
|
|
1959
|
+
)
|
|
1891
1960
|
if status is None:
|
|
1892
1961
|
console.print("[yellow]⚠️ Could not check cloud-init (QGA command failed)[/]")
|
|
1893
1962
|
cloud_init_complete = None
|
|
@@ -1914,9 +1983,11 @@ def cmd_test(args):
|
|
|
1914
1983
|
if not quick and state == "running":
|
|
1915
1984
|
console.print("[bold]4. Mount Points Check[/]")
|
|
1916
1985
|
paths = config.get("paths", {})
|
|
1917
|
-
|
|
1986
|
+
copy_paths = config.get("copy_paths", None)
|
|
1987
|
+
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
1988
|
+
copy_paths = config.get("app_data_paths", {})
|
|
1918
1989
|
|
|
1919
|
-
if paths or
|
|
1990
|
+
if paths or copy_paths:
|
|
1920
1991
|
if not _qga_ping(vm_name, conn_uri):
|
|
1921
1992
|
console.print("[yellow]⚠️ QEMU guest agent not connected - cannot verify mounts[/]")
|
|
1922
1993
|
else:
|
|
@@ -1938,7 +2009,7 @@ def cmd_test(args):
|
|
|
1938
2009
|
console.print(f"[yellow]⚠️ {guest_path} (could not check)[/]")
|
|
1939
2010
|
|
|
1940
2011
|
# Check copied paths
|
|
1941
|
-
for idx, (host_path, guest_path) in enumerate(
|
|
2012
|
+
for idx, (host_path, guest_path) in enumerate(copy_paths.items()):
|
|
1942
2013
|
try:
|
|
1943
2014
|
is_accessible = _qga_exec(
|
|
1944
2015
|
vm_name, conn_uri, f"test -d {guest_path} && echo yes || echo no", timeout=5
|
|
@@ -1965,23 +2036,14 @@ def cmd_test(args):
|
|
|
1965
2036
|
console.print("[yellow]⚠️ QEMU Guest Agent not connected - cannot run health check[/]")
|
|
1966
2037
|
else:
|
|
1967
2038
|
exists = _qga_exec(
|
|
1968
|
-
vm_name,
|
|
1969
|
-
conn_uri,
|
|
1970
|
-
"test -x /usr/local/bin/clonebox-health && echo yes || echo no",
|
|
1971
|
-
timeout=10,
|
|
2039
|
+
vm_name, conn_uri, "test -x /usr/local/bin/clonebox-health && echo yes || echo no", timeout=10
|
|
1972
2040
|
)
|
|
1973
2041
|
if exists and exists.strip() == "yes":
|
|
1974
2042
|
_qga_exec(
|
|
1975
|
-
vm_name,
|
|
1976
|
-
conn_uri,
|
|
1977
|
-
"/usr/local/bin/clonebox-health >/dev/null 2>&1 || true",
|
|
1978
|
-
timeout=60,
|
|
2043
|
+
vm_name, conn_uri, "/usr/local/bin/clonebox-health >/dev/null 2>&1 || true", timeout=60
|
|
1979
2044
|
)
|
|
1980
2045
|
health_status = _qga_exec(
|
|
1981
|
-
vm_name,
|
|
1982
|
-
conn_uri,
|
|
1983
|
-
"cat /var/log/clonebox-health-status 2>/dev/null || true",
|
|
1984
|
-
timeout=10,
|
|
2046
|
+
vm_name, conn_uri, "cat /var/log/clonebox-health-status 2>/dev/null || true", timeout=10
|
|
1985
2047
|
)
|
|
1986
2048
|
if health_status and "HEALTH_STATUS=OK" in health_status:
|
|
1987
2049
|
console.print("[green]✅ Health check passed[/]")
|
|
@@ -1999,9 +2061,6 @@ def cmd_test(args):
|
|
|
1999
2061
|
else:
|
|
2000
2062
|
console.print("[yellow]⚠️ Health check status not available yet[/]")
|
|
2001
2063
|
console.print(" View logs in VM: cat /var/log/clonebox-health.log")
|
|
2002
|
-
else:
|
|
2003
|
-
console.print("[yellow]⚠️ Health check script not found[/]")
|
|
2004
|
-
console.print(" This is expected until cloud-init completes")
|
|
2005
2064
|
except Exception as e:
|
|
2006
2065
|
console.print(f"[yellow]⚠️ Could not run health check: {e}[/]")
|
|
2007
2066
|
|
|
@@ -2147,11 +2206,11 @@ def generate_clonebox_yaml(
|
|
|
2147
2206
|
paths_by_type = {"project": [], "config": [], "data": []}
|
|
2148
2207
|
for p in snapshot.paths:
|
|
2149
2208
|
if p.type in paths_by_type:
|
|
2150
|
-
paths_by_type[p.type].append(p
|
|
2209
|
+
paths_by_type[p.type].append(p)
|
|
2151
2210
|
|
|
2152
2211
|
if deduplicate:
|
|
2153
2212
|
for ptype in paths_by_type:
|
|
2154
|
-
paths_by_type[ptype] = deduplicate_list(paths_by_type[ptype])
|
|
2213
|
+
paths_by_type[ptype] = deduplicate_list(paths_by_type[ptype], key=lambda x: x.path)
|
|
2155
2214
|
|
|
2156
2215
|
# Collect working directories from running apps
|
|
2157
2216
|
working_dirs = []
|
|
@@ -2172,7 +2231,8 @@ def generate_clonebox_yaml(
|
|
|
2172
2231
|
# Build paths mapping
|
|
2173
2232
|
paths_mapping = {}
|
|
2174
2233
|
idx = 0
|
|
2175
|
-
for
|
|
2234
|
+
for host_path_obj in paths_by_type["project"][:5]: # Limit projects
|
|
2235
|
+
host_path = host_path_obj.path if hasattr(host_path_obj, 'path') else host_path_obj
|
|
2176
2236
|
paths_mapping[host_path] = f"/mnt/project{idx}"
|
|
2177
2237
|
idx += 1
|
|
2178
2238
|
|
|
@@ -2254,10 +2314,6 @@ def generate_clonebox_yaml(
|
|
|
2254
2314
|
if deduplicate:
|
|
2255
2315
|
all_snap_packages = deduplicate_list(all_snap_packages)
|
|
2256
2316
|
|
|
2257
|
-
if chrome_profile.exists() and "google-chrome" not in [d.get("app", "") for d in app_data_dirs]:
|
|
2258
|
-
if "chromium" not in all_snap_packages:
|
|
2259
|
-
all_snap_packages.append("chromium")
|
|
2260
|
-
|
|
2261
2317
|
if "pycharm-community" in all_snap_packages:
|
|
2262
2318
|
remapped = {}
|
|
2263
2319
|
for host_path, guest_path in app_data_mapping.items():
|
|
@@ -2315,9 +2371,9 @@ def generate_clonebox_yaml(
|
|
|
2315
2371
|
for d in app_data_dirs[:15]
|
|
2316
2372
|
],
|
|
2317
2373
|
"all_paths": {
|
|
2318
|
-
"projects":
|
|
2319
|
-
"configs":
|
|
2320
|
-
"data":
|
|
2374
|
+
"projects": [{"path": p.path if hasattr(p, 'path') else p, "type": p.type if hasattr(p, 'type') else 'project', "size_mb": p.size_mb if hasattr(p, 'size_mb') else 0} for p in paths_by_type["project"]],
|
|
2375
|
+
"configs": [{"path": p.path, "type": p.type, "size_mb": p.size_mb} for p in paths_by_type["config"][:5]],
|
|
2376
|
+
"data": [{"path": p.path, "type": p.type, "size_mb": p.size_mb} for p in paths_by_type["data"][:5]],
|
|
2321
2377
|
},
|
|
2322
2378
|
},
|
|
2323
2379
|
}
|
|
@@ -2417,7 +2473,7 @@ def _exec_in_vm_qga(vm_name: str, conn_uri: str, command: str) -> Optional[str]:
|
|
|
2417
2473
|
return None
|
|
2418
2474
|
|
|
2419
2475
|
|
|
2420
|
-
def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout: int =
|
|
2476
|
+
def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout: int = 1800):
|
|
2421
2477
|
"""Monitor cloud-init status in VM and show progress."""
|
|
2422
2478
|
import subprocess
|
|
2423
2479
|
import time
|
|
@@ -2429,582 +2485,454 @@ def monitor_cloud_init_status(vm_name: str, user_session: bool = False, timeout:
|
|
|
2429
2485
|
last_phases = []
|
|
2430
2486
|
seen_lines = set()
|
|
2431
2487
|
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
console=console,
|
|
2436
|
-
) as progress:
|
|
2437
|
-
task = progress.add_task("[cyan]Starting VM and initializing...", total=None)
|
|
2438
|
-
|
|
2439
|
-
while time.time() - start_time < timeout:
|
|
2440
|
-
try:
|
|
2441
|
-
elapsed = int(time.time() - start_time)
|
|
2442
|
-
minutes = elapsed // 60
|
|
2443
|
-
seconds = elapsed % 60
|
|
2444
|
-
|
|
2445
|
-
# Check VM state
|
|
2446
|
-
result = subprocess.run(
|
|
2447
|
-
["virsh", "--connect", conn_uri, "domstate", vm_name],
|
|
2448
|
-
capture_output=True,
|
|
2449
|
-
text=True,
|
|
2450
|
-
timeout=5,
|
|
2451
|
-
)
|
|
2488
|
+
refresh = 1.0
|
|
2489
|
+
once = False
|
|
2490
|
+
monitor = ResourceMonitor(conn_uri=conn_uri)
|
|
2452
2491
|
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2492
|
+
try:
|
|
2493
|
+
with Progress(
|
|
2494
|
+
SpinnerColumn(),
|
|
2495
|
+
TextColumn("[progress.description]{task.description}"),
|
|
2496
|
+
console=console,
|
|
2497
|
+
) as progress:
|
|
2498
|
+
task = progress.add_task("[cyan]Starting VM and initializing...", total=None)
|
|
2499
|
+
|
|
2500
|
+
while time.time() - start_time < timeout:
|
|
2501
|
+
# Clear screen for live update
|
|
2502
|
+
if not progress.finished:
|
|
2503
|
+
console.clear()
|
|
2504
|
+
|
|
2505
|
+
console.print("[bold cyan]📊 CloneBox Resource Monitor[/]")
|
|
2506
|
+
console.print()
|
|
2507
|
+
|
|
2508
|
+
# VM Stats
|
|
2509
|
+
vm_stats = monitor.get_all_vm_stats()
|
|
2510
|
+
if vm_stats:
|
|
2511
|
+
table = Table(title="🖥️ Virtual Machines", border_style="cyan")
|
|
2512
|
+
table.add_column("Name", style="bold")
|
|
2513
|
+
table.add_column("State")
|
|
2514
|
+
table.add_column("CPU %")
|
|
2515
|
+
table.add_column("Memory")
|
|
2516
|
+
table.add_column("Disk")
|
|
2517
|
+
table.add_column("Network I/O")
|
|
2518
|
+
|
|
2519
|
+
for vm in vm_stats:
|
|
2520
|
+
state_color = "green" if vm.state == "running" else "yellow"
|
|
2521
|
+
cpu_color = "red" if vm.cpu_percent > 80 else "green"
|
|
2522
|
+
mem_pct = (
|
|
2523
|
+
(vm.memory_used_mb / vm.memory_total_mb * 100)
|
|
2524
|
+
if vm.memory_total_mb > 0
|
|
2525
|
+
else 0
|
|
2464
2526
|
)
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
task, description=f"[green]✓ VM ready! Total time: {minutes}m {seconds}s"
|
|
2477
|
-
)
|
|
2478
|
-
time.sleep(2)
|
|
2479
|
-
break
|
|
2480
|
-
|
|
2481
|
-
# Estimate remaining time (total ~12-15 minutes for full desktop install)
|
|
2482
|
-
if elapsed < 60:
|
|
2483
|
-
remaining = "~12-15 minutes"
|
|
2484
|
-
elif elapsed < 300:
|
|
2485
|
-
remaining = f"~{12 - minutes} minutes"
|
|
2486
|
-
elif elapsed < 600:
|
|
2487
|
-
remaining = f"~{10 - minutes} minutes"
|
|
2488
|
-
elif elapsed < 800:
|
|
2489
|
-
remaining = "finishing soon..."
|
|
2527
|
+
mem_color = "red" if mem_pct > 80 else "green"
|
|
2528
|
+
|
|
2529
|
+
table.add_row(
|
|
2530
|
+
vm.name,
|
|
2531
|
+
f"[{state_color}]{vm.state}[/]",
|
|
2532
|
+
f"[{cpu_color}]{vm.cpu_percent:.1f}%[/]",
|
|
2533
|
+
f"[{mem_color}]{vm.memory_used_mb}/{vm.memory_total_mb} MB[/]",
|
|
2534
|
+
f"{vm.disk_used_gb:.1f}/{vm.disk_total_gb:.1f} GB",
|
|
2535
|
+
f"↓{format_bytes(vm.network_rx_bytes)} ↑{format_bytes(vm.network_tx_bytes)}",
|
|
2536
|
+
)
|
|
2537
|
+
console.print(table)
|
|
2490
2538
|
else:
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
2494
|
-
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
|
|
2498
|
-
|
|
2499
|
-
|
|
2500
|
-
|
|
2501
|
-
|
|
2502
|
-
|
|
2503
|
-
|
|
2504
|
-
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
if len(last_phases) > 2:
|
|
2525
|
-
last_phases = last_phases[-2:]
|
|
2526
|
-
|
|
2527
|
-
if restart_detected:
|
|
2528
|
-
progress.update(
|
|
2529
|
-
task,
|
|
2530
|
-
description=f"[cyan]Finalizing setup... ({minutes}m {seconds}s, {remaining})",
|
|
2531
|
-
)
|
|
2532
|
-
elif last_phases:
|
|
2533
|
-
# Show the actual phase from logs
|
|
2534
|
-
current_status = last_phases[-1]
|
|
2535
|
-
progress.update(
|
|
2536
|
-
task,
|
|
2537
|
-
description=f"[cyan]{current_status} ({minutes}m {seconds}s, {remaining})",
|
|
2538
|
-
)
|
|
2539
|
+
console.print("[dim]No VMs found.[/]")
|
|
2540
|
+
|
|
2541
|
+
console.print()
|
|
2542
|
+
|
|
2543
|
+
# Container Stats
|
|
2544
|
+
container_stats = monitor.get_container_stats()
|
|
2545
|
+
if container_stats:
|
|
2546
|
+
table = Table(title="🐳 Containers", border_style="blue")
|
|
2547
|
+
table.add_column("Name", style="bold")
|
|
2548
|
+
table.add_column("State")
|
|
2549
|
+
table.add_column("CPU %")
|
|
2550
|
+
table.add_column("Memory")
|
|
2551
|
+
table.add_column("Network I/O")
|
|
2552
|
+
table.add_column("PIDs")
|
|
2553
|
+
|
|
2554
|
+
for c in container_stats:
|
|
2555
|
+
cpu_color = "red" if c.cpu_percent > 80 else "green"
|
|
2556
|
+
mem_pct = (
|
|
2557
|
+
(c.memory_used_mb / c.memory_limit_mb * 100)
|
|
2558
|
+
if c.memory_limit_mb > 0
|
|
2559
|
+
else 0
|
|
2560
|
+
)
|
|
2561
|
+
mem_color = "red" if mem_pct > 80 else "green"
|
|
2562
|
+
|
|
2563
|
+
table.add_row(
|
|
2564
|
+
c.name,
|
|
2565
|
+
f"[green]{c.state}[/]",
|
|
2566
|
+
f"[{cpu_color}]{c.cpu_percent:.1f}%[/]",
|
|
2567
|
+
f"[{mem_color}]{c.memory_used_mb}/{c.memory_limit_mb} MB[/]",
|
|
2568
|
+
f"↓{format_bytes(c.network_rx_bytes)} ↑{format_bytes(c.network_tx_bytes)}",
|
|
2569
|
+
str(c.pids),
|
|
2570
|
+
)
|
|
2571
|
+
console.print(table)
|
|
2539
2572
|
else:
|
|
2540
|
-
|
|
2541
|
-
task,
|
|
2542
|
-
description=f"[cyan]Installing packages... ({minutes}m {seconds}s, {remaining})",
|
|
2543
|
-
)
|
|
2573
|
+
console.print("[dim]No containers running.[/]")
|
|
2544
2574
|
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
minutes = elapsed // 60
|
|
2548
|
-
seconds = elapsed % 60
|
|
2549
|
-
progress.update(
|
|
2550
|
-
task, description=f"[cyan]Configuring VM... ({minutes}m {seconds}s)"
|
|
2551
|
-
)
|
|
2575
|
+
if once:
|
|
2576
|
+
break
|
|
2552
2577
|
|
|
2553
|
-
|
|
2578
|
+
console.print(f"\n[dim]Refreshing every {refresh}s. Press Ctrl+C to exit.[/]")
|
|
2579
|
+
time.sleep(refresh)
|
|
2554
2580
|
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
2559
|
-
)
|
|
2581
|
+
except KeyboardInterrupt:
|
|
2582
|
+
console.print("\n[yellow]Monitoring stopped.[/]")
|
|
2583
|
+
finally:
|
|
2584
|
+
monitor.close()
|
|
2560
2585
|
|
|
2561
2586
|
|
|
2562
|
-
def create_vm_from_config(
|
|
2563
|
-
|
|
2564
|
-
|
|
2565
|
-
user_session: bool = False,
|
|
2566
|
-
replace: bool = False,
|
|
2567
|
-
) -> str:
|
|
2568
|
-
"""Create VM from YAML config dict."""
|
|
2569
|
-
paths = config.get("paths", {})
|
|
2570
|
-
# Backwards compatible: v1 uses app_data_paths, newer configs may use copy_paths
|
|
2571
|
-
copy_paths = config.get("copy_paths", None)
|
|
2572
|
-
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
2573
|
-
copy_paths = config.get("app_data_paths", {})
|
|
2574
|
-
|
|
2575
|
-
vm_section = config.get("vm") or {}
|
|
2576
|
-
|
|
2577
|
-
# Support both v1 (auth_method) and v2 (auth.method) config formats
|
|
2578
|
-
auth_section = vm_section.get("auth") or {}
|
|
2579
|
-
auth_method = auth_section.get("method") or vm_section.get("auth_method") or "ssh_key"
|
|
2587
|
+
def create_vm_from_config(config, start=False, user_session=False, replace=False, approved=False):
|
|
2588
|
+
"""Create VM from configuration dict."""
|
|
2589
|
+
vm_config_dict = config.get("vm", {})
|
|
2580
2590
|
|
|
2581
|
-
#
|
|
2582
|
-
secrets_section = config.get("secrets") or {}
|
|
2583
|
-
secrets_provider = secrets_section.get("provider", "auto")
|
|
2584
|
-
|
|
2585
|
-
# v2 config: resource limits
|
|
2586
|
-
limits_section = config.get("limits") or {}
|
|
2587
|
-
resources = {
|
|
2588
|
-
"memory_limit": limits_section.get("memory_limit"),
|
|
2589
|
-
"cpu_shares": limits_section.get("cpu_shares"),
|
|
2590
|
-
"disk_limit": limits_section.get("disk_limit"),
|
|
2591
|
-
"network_limit": limits_section.get("network_limit"),
|
|
2592
|
-
}
|
|
2593
|
-
# Remove None values
|
|
2594
|
-
resources = {k: v for k, v in resources.items() if v is not None}
|
|
2595
|
-
|
|
2591
|
+
# Create VMConfig object
|
|
2596
2592
|
vm_config = VMConfig(
|
|
2597
|
-
name=
|
|
2598
|
-
ram_mb=
|
|
2599
|
-
vcpus=
|
|
2600
|
-
disk_size_gb=
|
|
2601
|
-
gui=
|
|
2602
|
-
base_image=
|
|
2603
|
-
|
|
2604
|
-
|
|
2593
|
+
name=vm_config_dict.get("name", "clonebox-vm"),
|
|
2594
|
+
ram_mb=vm_config_dict.get("ram_mb", 8192),
|
|
2595
|
+
vcpus=vm_config_dict.get("vcpus", 4),
|
|
2596
|
+
disk_size_gb=vm_config_dict.get("disk_size_gb", 20),
|
|
2597
|
+
gui=vm_config_dict.get("gui", True),
|
|
2598
|
+
base_image=vm_config_dict.get("base_image"),
|
|
2599
|
+
network_mode=vm_config_dict.get("network_mode", "auto"),
|
|
2600
|
+
username=vm_config_dict.get("username", "ubuntu"),
|
|
2601
|
+
password=vm_config_dict.get("password", "ubuntu"),
|
|
2602
|
+
user_session=user_session,
|
|
2603
|
+
paths=config.get("paths", {}),
|
|
2605
2604
|
packages=config.get("packages", []),
|
|
2606
2605
|
snap_packages=config.get("snap_packages", []),
|
|
2607
2606
|
services=config.get("services", []),
|
|
2608
2607
|
post_commands=config.get("post_commands", []),
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
username=config["vm"].get("username", "ubuntu"),
|
|
2612
|
-
password=config["vm"].get("password", "ubuntu"),
|
|
2613
|
-
auth_method=auth_method,
|
|
2614
|
-
ssh_public_key=vm_section.get("ssh_public_key") or auth_section.get("ssh_public_key"),
|
|
2615
|
-
resources=resources if resources else config["vm"].get("resources", {}),
|
|
2608
|
+
copy_paths=(config.get("copy_paths") or config.get("app_data_paths") or {}),
|
|
2609
|
+
resources=config.get("resources", {}),
|
|
2616
2610
|
)
|
|
2617
|
-
|
|
2611
|
+
|
|
2618
2612
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
2619
|
-
|
|
2620
|
-
# Check prerequisites
|
|
2613
|
+
|
|
2614
|
+
# Check prerequisites
|
|
2621
2615
|
checks = cloner.check_prerequisites()
|
|
2622
|
-
|
|
2623
|
-
|
|
2624
|
-
|
|
2625
|
-
|
|
2626
|
-
console.print(f"
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2616
|
+
if not all(checks.values()):
|
|
2617
|
+
console.print("[yellow]⚠️ Prerequisites check:[/]")
|
|
2618
|
+
for check, passed in checks.items():
|
|
2619
|
+
icon = "✅" if passed else "❌"
|
|
2620
|
+
console.print(f" {icon} {check}")
|
|
2621
|
+
|
|
2622
|
+
# Create VM
|
|
2623
|
+
vm_uuid = cloner.create_vm(
|
|
2624
|
+
vm_config,
|
|
2625
|
+
replace=replace,
|
|
2626
|
+
console=console,
|
|
2627
|
+
approved=approved,
|
|
2628
|
+
)
|
|
2629
|
+
|
|
2633
2630
|
if start:
|
|
2634
|
-
cloner.start_vm(vm_config.name, open_viewer=
|
|
2635
|
-
|
|
2636
|
-
# Monitor cloud-init progress if GUI is enabled
|
|
2637
|
-
if vm_config.gui:
|
|
2638
|
-
console.print("\n[bold cyan]📊 Monitoring setup progress...[/]")
|
|
2639
|
-
try:
|
|
2640
|
-
monitor_cloud_init_status(vm_config.name, user_session=user_session)
|
|
2641
|
-
except KeyboardInterrupt:
|
|
2642
|
-
console.print("\n[yellow]Monitoring stopped. VM continues setup in background.[/]")
|
|
2643
|
-
except Exception as e:
|
|
2644
|
-
console.print(
|
|
2645
|
-
f"\n[dim]Note: Could not monitor status ({e}). VM continues setup in background.[/]"
|
|
2646
|
-
)
|
|
2647
|
-
|
|
2631
|
+
cloner.start_vm(vm_config.name, open_viewer=True, console=console)
|
|
2632
|
+
|
|
2648
2633
|
return vm_uuid
|
|
2649
2634
|
|
|
2650
2635
|
|
|
2651
|
-
def cmd_clone(args):
|
|
2636
|
+
def cmd_clone(args) -> None:
|
|
2652
2637
|
"""Generate clone config from path and optionally create VM."""
|
|
2653
|
-
|
|
2654
|
-
|
|
2655
|
-
|
|
2638
|
+
from clonebox.detector import SystemDetector
|
|
2639
|
+
|
|
2640
|
+
target_path = Path(args.path).expanduser().resolve() if args.path else Path.cwd()
|
|
2641
|
+
|
|
2656
2642
|
if not target_path.exists():
|
|
2657
2643
|
console.print(f"[red]❌ Path does not exist: {target_path}[/]")
|
|
2658
2644
|
return
|
|
2659
|
-
|
|
2660
|
-
|
|
2661
|
-
|
|
2662
|
-
else:
|
|
2663
|
-
console.print(f"[bold cyan]📦 Generating clone config for: {target_path}[/]\n")
|
|
2664
|
-
|
|
2645
|
+
|
|
2646
|
+
console.print(f"[cyan]🔍 Analyzing system for cloning...[/]")
|
|
2647
|
+
|
|
2665
2648
|
# Detect system state
|
|
2649
|
+
detector = SystemDetector()
|
|
2650
|
+
|
|
2666
2651
|
with Progress(
|
|
2667
2652
|
SpinnerColumn(),
|
|
2668
2653
|
TextColumn("[progress.description]{task.description}"),
|
|
2669
2654
|
console=console,
|
|
2670
|
-
transient=True,
|
|
2671
2655
|
) as progress:
|
|
2672
|
-
progress.add_task("Scanning system...", total=None)
|
|
2673
|
-
|
|
2656
|
+
task = progress.add_task("Scanning system...", total=None)
|
|
2657
|
+
|
|
2658
|
+
# Take snapshot
|
|
2674
2659
|
snapshot = detector.detect_all()
|
|
2675
|
-
|
|
2660
|
+
|
|
2661
|
+
# Detect Docker containers
|
|
2662
|
+
containers = detector.detect_docker_containers()
|
|
2663
|
+
|
|
2664
|
+
progress.update(task, description="Finalizing...")
|
|
2665
|
+
|
|
2676
2666
|
# Generate config
|
|
2677
|
-
vm_name = args.name or f"clone-{target_path.name}"
|
|
2678
2667
|
yaml_content = generate_clonebox_yaml(
|
|
2679
2668
|
snapshot,
|
|
2680
2669
|
detector,
|
|
2681
2670
|
deduplicate=args.dedupe,
|
|
2682
|
-
target_path=str(target_path),
|
|
2683
|
-
vm_name=
|
|
2671
|
+
target_path=str(target_path) if args.path else None,
|
|
2672
|
+
vm_name=args.name,
|
|
2684
2673
|
network_mode=args.network,
|
|
2685
|
-
base_image=
|
|
2686
|
-
disk_size_gb=
|
|
2674
|
+
base_image=args.base_image,
|
|
2675
|
+
disk_size_gb=args.disk_size_gb,
|
|
2687
2676
|
)
|
|
2688
|
-
|
|
2689
|
-
profile_name = getattr(args, "profile", None)
|
|
2690
|
-
if profile_name:
|
|
2691
|
-
merged_config = merge_with_profile(yaml.safe_load(yaml_content), profile_name)
|
|
2692
|
-
if isinstance(merged_config, dict):
|
|
2693
|
-
vm_section = merged_config.get("vm")
|
|
2694
|
-
if isinstance(vm_section, dict):
|
|
2695
|
-
vm_packages = vm_section.pop("packages", None)
|
|
2696
|
-
if isinstance(vm_packages, list):
|
|
2697
|
-
packages = merged_config.get("packages")
|
|
2698
|
-
if not isinstance(packages, list):
|
|
2699
|
-
packages = []
|
|
2700
|
-
for p in vm_packages:
|
|
2701
|
-
if p not in packages:
|
|
2702
|
-
packages.append(p)
|
|
2703
|
-
merged_config["packages"] = packages
|
|
2704
|
-
|
|
2705
|
-
if "container" in merged_config:
|
|
2706
|
-
merged_config.pop("container", None)
|
|
2707
|
-
|
|
2708
|
-
yaml_content = yaml.dump(
|
|
2709
|
-
merged_config,
|
|
2710
|
-
default_flow_style=False,
|
|
2711
|
-
allow_unicode=True,
|
|
2712
|
-
sort_keys=False,
|
|
2713
|
-
)
|
|
2714
|
-
|
|
2715
|
-
# Dry run - show what would be created and exit
|
|
2716
|
-
if dry_run:
|
|
2717
|
-
config = yaml.safe_load(yaml_content)
|
|
2718
|
-
console.print(
|
|
2719
|
-
Panel(
|
|
2720
|
-
f"[bold]VM Name:[/] {config['vm']['name']}\n"
|
|
2721
|
-
f"[bold]RAM:[/] {config['vm'].get('ram_mb', 4096)} MB\n"
|
|
2722
|
-
f"[bold]vCPUs:[/] {config['vm'].get('vcpus', 4)}\n"
|
|
2723
|
-
f"[bold]Network:[/] {config['vm'].get('network_mode', 'auto')}\n"
|
|
2724
|
-
f"[bold]Paths:[/] {len(config.get('paths', {}))} mounts\n"
|
|
2725
|
-
f"[bold]Packages:[/] {len(config.get('packages', []))} packages\n"
|
|
2726
|
-
f"[bold]Services:[/] {len(config.get('services', []))} services",
|
|
2727
|
-
title="[bold cyan]Would create VM[/]",
|
|
2728
|
-
border_style="cyan",
|
|
2729
|
-
)
|
|
2730
|
-
)
|
|
2731
|
-
console.print("\n[dim]Config preview:[/]")
|
|
2732
|
-
console.print(Panel(yaml_content, title="[bold].clonebox.yaml[/]", border_style="dim"))
|
|
2733
|
-
console.print("\n[yellow]ℹ️ Dry run complete. No changes made.[/]")
|
|
2734
|
-
return
|
|
2735
|
-
|
|
2677
|
+
|
|
2736
2678
|
# Save config file
|
|
2737
|
-
config_file =
|
|
2738
|
-
|
|
2739
|
-
|
|
2740
|
-
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
|
|
2745
|
-
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2679
|
+
config_file = target_path / CLONEBOX_CONFIG_FILE
|
|
2680
|
+
|
|
2681
|
+
if config_file.exists() and not args.replace:
|
|
2682
|
+
console.print(f"[yellow]⚠️ Config file already exists: {config_file}[/]")
|
|
2683
|
+
if not questionary.confirm(
|
|
2684
|
+
"Overwrite existing config?", default=False, style=custom_style
|
|
2685
|
+
).ask():
|
|
2686
|
+
console.print("[dim]Cancelled.[/]")
|
|
2687
|
+
return
|
|
2688
|
+
|
|
2689
|
+
with open(config_file, "w") as f:
|
|
2690
|
+
f.write(yaml_content)
|
|
2691
|
+
|
|
2692
|
+
console.print(f"[green]✅ Config saved to: {config_file}[/]")
|
|
2693
|
+
|
|
2694
|
+
# Edit if requested
|
|
2749
2695
|
if args.edit:
|
|
2750
2696
|
editor = os.environ.get("EDITOR", "nano")
|
|
2751
|
-
console.print(f"[cyan]Opening {editor}...[/]")
|
|
2752
2697
|
os.system(f"{editor} {config_file}")
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
# Ask to create VM
|
|
2698
|
+
|
|
2699
|
+
# Run VM if requested
|
|
2757
2700
|
if args.run:
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
2761
|
-
|
|
2762
|
-
)
|
|
2763
|
-
|
|
2764
|
-
if create_now:
|
|
2765
|
-
# Load config with environment variable expansion
|
|
2766
|
-
config = load_clonebox_config(config_file.parent)
|
|
2767
|
-
user_session = getattr(args, "user", False)
|
|
2768
|
-
|
|
2769
|
-
console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
|
|
2770
|
-
if user_session:
|
|
2771
|
-
console.print("[cyan]Using user session (qemu:///session) - no root required[/]")
|
|
2772
|
-
|
|
2773
|
-
try:
|
|
2774
|
-
vm_uuid = create_vm_from_config(
|
|
2775
|
-
config,
|
|
2776
|
-
start=True,
|
|
2777
|
-
user_session=user_session,
|
|
2778
|
-
replace=getattr(args, "replace", False),
|
|
2779
|
-
)
|
|
2780
|
-
console.print(f"\n[bold green]🎉 VM '{config['vm']['name']}' is running![/]")
|
|
2781
|
-
console.print(f"[dim]UUID: {vm_uuid}[/]")
|
|
2782
|
-
|
|
2783
|
-
# Show GUI startup info if GUI is enabled
|
|
2784
|
-
if config.get("vm", {}).get("gui", False):
|
|
2785
|
-
username = config["vm"].get("username", "ubuntu")
|
|
2786
|
-
password = config["vm"].get("password", "ubuntu")
|
|
2787
|
-
console.print("\n[bold yellow]⏰ GUI Setup Process:[/]")
|
|
2788
|
-
console.print(" [yellow]•[/] Installing desktop environment (~5-10 minutes)")
|
|
2789
|
-
console.print(" [yellow]•[/] Running health checks on all components")
|
|
2790
|
-
console.print(" [yellow]•[/] Automatic restart after installation")
|
|
2791
|
-
console.print(" [yellow]•[/] GUI login screen will appear")
|
|
2792
|
-
console.print(
|
|
2793
|
-
f" [yellow]•[/] Login: [cyan]{username}[/] / [cyan]{'*' * len(password)}[/] (from .env)"
|
|
2794
|
-
)
|
|
2795
|
-
console.print("\n[dim]💡 Progress will be monitored automatically below[/]")
|
|
2796
|
-
|
|
2797
|
-
# Show health check info
|
|
2798
|
-
console.print("\n[bold]📊 Health Check (inside VM):[/]")
|
|
2799
|
-
console.print(" [cyan]cat /var/log/clonebox-health.log[/] # View full report")
|
|
2800
|
-
console.print(" [cyan]cat /var/log/clonebox-health-status[/] # Quick status")
|
|
2801
|
-
console.print(" [cyan]clonebox-health[/] # Re-run health check")
|
|
2802
|
-
|
|
2803
|
-
# Show mount instructions
|
|
2804
|
-
paths = config.get("paths", {})
|
|
2805
|
-
app_data_paths = config.get("app_data_paths", {})
|
|
2806
|
-
|
|
2807
|
-
if paths:
|
|
2808
|
-
console.print("\n[bold]📁 Mounted paths (shared live):[/]")
|
|
2809
|
-
for idx, (host, guest) in enumerate(list(paths.items())[:5]):
|
|
2810
|
-
console.print(f" [dim]{host}[/] → [cyan]{guest}[/]")
|
|
2811
|
-
if len(paths) > 5:
|
|
2812
|
-
console.print(f" [dim]... and {len(paths) - 5} more paths[/]")
|
|
2813
|
-
|
|
2814
|
-
if app_data_paths:
|
|
2815
|
-
console.print("\n[bold]📥 Copied paths (one-time import):[/]")
|
|
2816
|
-
for idx, (host, guest) in enumerate(list(app_data_paths.items())[:5]):
|
|
2817
|
-
console.print(f" [dim]{host}[/] → [cyan]{guest}[/]")
|
|
2818
|
-
if len(app_data_paths) > 5:
|
|
2819
|
-
console.print(f" [dim]... and {len(app_data_paths) - 5} more paths[/]")
|
|
2820
|
-
except PermissionError as e:
|
|
2821
|
-
console.print(f"[red]❌ Permission Error:[/]\n{e}")
|
|
2822
|
-
console.print("\n[yellow]💡 Try running with --user flag:[/]")
|
|
2823
|
-
console.print(f" [cyan]clonebox clone {target_path} --user[/]")
|
|
2824
|
-
except Exception as e:
|
|
2825
|
-
console.print(f"[red]❌ Error: {e}[/]")
|
|
2826
|
-
else:
|
|
2827
|
-
console.print("\n[dim]To create VM later, run:[/]")
|
|
2828
|
-
console.print(f" [cyan]clonebox start {target_path}[/]")
|
|
2701
|
+
console.print("[cyan]🚀 Creating VM from config...[/]")
|
|
2702
|
+
config = load_clonebox_config(config_file)
|
|
2703
|
+
vm_uuid = create_vm_from_config(
|
|
2704
|
+
config, start=True, user_session=args.user, replace=args.replace, approved=args.approve
|
|
2705
|
+
)
|
|
2706
|
+
console.print(f"[green]✅ VM created: {vm_uuid}[/]")
|
|
2829
2707
|
|
|
2830
2708
|
|
|
2831
|
-
def cmd_detect(args):
|
|
2709
|
+
def cmd_detect(args) -> None:
|
|
2832
2710
|
"""Detect and show system state."""
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2711
|
+
from clonebox.detector import SystemDetector
|
|
2712
|
+
|
|
2713
|
+
console.print("[cyan]🔍 Detecting system state...[/]")
|
|
2714
|
+
|
|
2715
|
+
try:
|
|
2716
|
+
detector = SystemDetector()
|
|
2717
|
+
|
|
2718
|
+
# Detect system info
|
|
2719
|
+
sys_info = detector.get_system_info()
|
|
2720
|
+
|
|
2721
|
+
# Detect all services, apps, and paths
|
|
2722
|
+
snapshot = detector.detect_all()
|
|
2723
|
+
|
|
2724
|
+
# Detect Docker containers
|
|
2725
|
+
containers = detector.detect_docker_containers()
|
|
2726
|
+
|
|
2727
|
+
# Prepare output
|
|
2728
|
+
output = {
|
|
2729
|
+
"system": sys_info,
|
|
2730
|
+
"services": [
|
|
2731
|
+
{
|
|
2732
|
+
"name": s.name,
|
|
2733
|
+
"status": s.status,
|
|
2734
|
+
"enabled": s.enabled,
|
|
2735
|
+
"description": s.description,
|
|
2736
|
+
}
|
|
2737
|
+
for s in snapshot.running_services
|
|
2738
|
+
],
|
|
2842
2739
|
"applications": [
|
|
2843
|
-
{
|
|
2740
|
+
{
|
|
2741
|
+
"name": a.name,
|
|
2742
|
+
"pid": a.pid,
|
|
2743
|
+
"memory_mb": round(a.memory_mb, 2),
|
|
2744
|
+
"working_dir": a.working_dir or "",
|
|
2745
|
+
}
|
|
2746
|
+
for a in snapshot.applications
|
|
2844
2747
|
],
|
|
2845
2748
|
"paths": [
|
|
2846
|
-
{"path": p.path, "type": p.type, "size_mb": p.size_mb}
|
|
2749
|
+
{"path": p.path, "type": p.type, "size_mb": p.size_mb}
|
|
2750
|
+
for p in snapshot.paths
|
|
2751
|
+
],
|
|
2752
|
+
"docker_containers": [
|
|
2753
|
+
{
|
|
2754
|
+
"name": c["name"],
|
|
2755
|
+
"status": c["status"],
|
|
2756
|
+
"image": c["image"],
|
|
2757
|
+
"ports": c.get("ports", ""),
|
|
2758
|
+
}
|
|
2759
|
+
for c in containers
|
|
2847
2760
|
],
|
|
2848
2761
|
}
|
|
2849
|
-
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2762
|
+
|
|
2763
|
+
# Apply deduplication if requested
|
|
2764
|
+
if args.dedupe:
|
|
2765
|
+
output["services"] = deduplicate_list(output["services"], key=lambda x: x["name"])
|
|
2766
|
+
output["applications"] = deduplicate_list(output["applications"], key=lambda x: (x["name"], x["pid"]))
|
|
2767
|
+
output["paths"] = deduplicate_list(output["paths"], key=lambda x: x["path"])
|
|
2768
|
+
|
|
2769
|
+
# Format output
|
|
2770
|
+
if args.json:
|
|
2771
|
+
content = json.dumps(output, indent=2)
|
|
2772
|
+
elif args.yaml:
|
|
2773
|
+
content = yaml.dump(output, default_flow_style=False, allow_unicode=True)
|
|
2774
|
+
else:
|
|
2775
|
+
# Pretty print
|
|
2776
|
+
content = format_detection_output(output, sys_info)
|
|
2777
|
+
|
|
2778
|
+
# Save to file or print
|
|
2856
2779
|
if args.output:
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
console.print(f"[green]✅
|
|
2780
|
+
with open(args.output, "w") as f:
|
|
2781
|
+
f.write(content)
|
|
2782
|
+
console.print(f"[green]✅ Output saved to: {args.output}[/]")
|
|
2860
2783
|
else:
|
|
2861
|
-
print(
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
if running:
|
|
2869
|
-
table = Table(title="Running Services", border_style="green")
|
|
2870
|
-
table.add_column("Service")
|
|
2871
|
-
table.add_column("Status")
|
|
2872
|
-
table.add_column("Enabled")
|
|
2873
|
-
|
|
2874
|
-
for svc in running:
|
|
2875
|
-
table.add_row(svc.name, f"[green]{svc.status}[/]", "✓" if svc.enabled else "")
|
|
2876
|
-
|
|
2877
|
-
console.print(table)
|
|
2878
|
-
|
|
2879
|
-
# Applications
|
|
2880
|
-
apps = detector.detect_applications()
|
|
2881
|
-
|
|
2882
|
-
if apps:
|
|
2883
|
-
console.print()
|
|
2884
|
-
table = Table(title="Running Applications", border_style="blue")
|
|
2885
|
-
table.add_column("Name")
|
|
2886
|
-
table.add_column("PID")
|
|
2887
|
-
table.add_column("Memory")
|
|
2888
|
-
table.add_column("Working Dir")
|
|
2889
|
-
|
|
2890
|
-
for app in apps[:15]:
|
|
2891
|
-
table.add_row(
|
|
2892
|
-
app.name,
|
|
2893
|
-
str(app.pid),
|
|
2894
|
-
f"{app.memory_mb:.0f} MB",
|
|
2895
|
-
app.working_dir[:40] if app.working_dir else "",
|
|
2896
|
-
)
|
|
2897
|
-
|
|
2898
|
-
console.print(table)
|
|
2899
|
-
|
|
2900
|
-
# Paths
|
|
2901
|
-
paths = detector.detect_paths()
|
|
2902
|
-
|
|
2903
|
-
if paths:
|
|
2904
|
-
console.print()
|
|
2905
|
-
table = Table(title="Detected Paths", border_style="yellow")
|
|
2906
|
-
table.add_column("Type")
|
|
2907
|
-
table.add_column("Path")
|
|
2908
|
-
table.add_column("Size")
|
|
2784
|
+
console.print(content)
|
|
2785
|
+
|
|
2786
|
+
except Exception as e:
|
|
2787
|
+
console.print(f"[red]Error: {e}[/]")
|
|
2788
|
+
import traceback
|
|
2789
|
+
traceback.print_exc()
|
|
2909
2790
|
|
|
2910
|
-
for p in paths[:20]:
|
|
2911
|
-
table.add_row(
|
|
2912
|
-
f"[cyan]{p.type}[/]", p.path, f"{p.size_mb:.0f} MB" if p.size_mb > 0 else "-"
|
|
2913
|
-
)
|
|
2914
2791
|
|
|
2915
|
-
|
|
2792
|
+
def format_detection_output(output, sys_info):
|
|
2793
|
+
"""Format detection output for console display."""
|
|
2794
|
+
from rich.table import Table
|
|
2795
|
+
from rich.text import Text
|
|
2796
|
+
|
|
2797
|
+
# System info
|
|
2798
|
+
system_text = Text()
|
|
2799
|
+
system_text.append(f"Hostname: {sys_info['hostname']}\n", style="bold")
|
|
2800
|
+
system_text.append(f"User: {sys_info['user']}\n")
|
|
2801
|
+
system_text.append(f"CPU: {sys_info['cpu_count']} cores\n")
|
|
2802
|
+
system_text.append(
|
|
2803
|
+
f"Memory: {sys_info['memory_total_gb']:.1f} GB total, {sys_info['memory_available_gb']:.1f} GB available\n"
|
|
2804
|
+
)
|
|
2805
|
+
system_text.append(
|
|
2806
|
+
f"Disk: {sys_info['disk_total_gb']:.1f} GB total, {sys_info['disk_free_gb']:.1f} GB free"
|
|
2807
|
+
)
|
|
2808
|
+
|
|
2809
|
+
# Services table
|
|
2810
|
+
services_table = Table(title="Services", show_header=True, header_style="bold magenta")
|
|
2811
|
+
services_table.add_column("Name", style="cyan")
|
|
2812
|
+
services_table.add_column("Status", style="green")
|
|
2813
|
+
services_table.add_column("Enabled", style="yellow")
|
|
2814
|
+
services_table.add_column("Description", style="dim")
|
|
2815
|
+
|
|
2816
|
+
for svc in output["services"]:
|
|
2817
|
+
status_style = "green" if svc["status"] == "running" else "red"
|
|
2818
|
+
enabled_text = "✓" if svc["enabled"] else "✗"
|
|
2819
|
+
services_table.add_row(
|
|
2820
|
+
svc["name"],
|
|
2821
|
+
Text(svc["status"], style=status_style),
|
|
2822
|
+
enabled_text,
|
|
2823
|
+
svc["description"] or "-",
|
|
2824
|
+
)
|
|
2825
|
+
|
|
2826
|
+
# Applications table
|
|
2827
|
+
apps_table = Table(title="Applications", show_header=True, header_style="bold magenta")
|
|
2828
|
+
apps_table.add_column("Name", style="cyan")
|
|
2829
|
+
apps_table.add_column("PID", justify="right")
|
|
2830
|
+
apps_table.add_column("Memory (MB)", justify="right")
|
|
2831
|
+
apps_table.add_column("Working Dir", style="dim")
|
|
2832
|
+
|
|
2833
|
+
for app in output["applications"]:
|
|
2834
|
+
apps_table.add_row(
|
|
2835
|
+
app["name"],
|
|
2836
|
+
str(app["pid"]),
|
|
2837
|
+
f"{app['memory_mb']:.1f}",
|
|
2838
|
+
app["working_dir"] or "-",
|
|
2839
|
+
)
|
|
2840
|
+
|
|
2841
|
+
# Combine output
|
|
2842
|
+
result = Panel(system_text, title="System Information", border_style="blue")
|
|
2843
|
+
result += "\n\n"
|
|
2844
|
+
result += services_table
|
|
2845
|
+
result += "\n\n"
|
|
2846
|
+
result += apps_table
|
|
2847
|
+
|
|
2848
|
+
return result
|
|
2916
2849
|
|
|
2917
2850
|
|
|
2918
2851
|
def cmd_monitor(args) -> None:
|
|
2919
|
-
"""Real-time resource monitoring
|
|
2920
|
-
|
|
2852
|
+
"""Real-time resource monitoring."""
|
|
2853
|
+
from clonebox.cloner import SelectiveVMCloner
|
|
2854
|
+
|
|
2855
|
+
user_session = getattr(args, "user", False)
|
|
2921
2856
|
refresh = getattr(args, "refresh", 2.0)
|
|
2922
2857
|
once = getattr(args, "once", False)
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2858
|
+
|
|
2859
|
+
cloner = SelectiveVMCloner(user_session=user_session)
|
|
2860
|
+
|
|
2926
2861
|
try:
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
cpu_color = "red" if vm.cpu_percent > 80 else "green"
|
|
2949
|
-
mem_pct = (
|
|
2950
|
-
(vm.memory_used_mb / vm.memory_total_mb * 100)
|
|
2951
|
-
if vm.memory_total_mb > 0
|
|
2952
|
-
else 0
|
|
2953
|
-
)
|
|
2954
|
-
mem_color = "red" if mem_pct > 80 else "green"
|
|
2955
|
-
|
|
2862
|
+
vms = cloner.list_vms()
|
|
2863
|
+
|
|
2864
|
+
if not vms:
|
|
2865
|
+
console.print("[dim]No VMs found.[/]")
|
|
2866
|
+
return
|
|
2867
|
+
|
|
2868
|
+
# Create monitor
|
|
2869
|
+
monitor = ResourceMonitor(conn_uri="qemu:///session" if user_session else "qemu:///system")
|
|
2870
|
+
|
|
2871
|
+
if once:
|
|
2872
|
+
# Show stats once
|
|
2873
|
+
table = Table(title="VM Resource Usage", show_header=True, header_style="bold magenta")
|
|
2874
|
+
table.add_column("VM Name", style="cyan")
|
|
2875
|
+
table.add_column("CPU %", justify="right")
|
|
2876
|
+
table.add_column("Memory", justify="right")
|
|
2877
|
+
table.add_column("Disk I/O", justify="right")
|
|
2878
|
+
table.add_column("Network I/O", justify="right")
|
|
2879
|
+
|
|
2880
|
+
for vm in vms:
|
|
2881
|
+
if vm["state"] == "running":
|
|
2882
|
+
stats = monitor.get_vm_stats(vm["name"])
|
|
2956
2883
|
table.add_row(
|
|
2957
|
-
vm
|
|
2958
|
-
f"
|
|
2959
|
-
|
|
2960
|
-
f"
|
|
2961
|
-
f"{
|
|
2962
|
-
f"↓{format_bytes(vm.network_rx_bytes)} ↑{format_bytes(vm.network_tx_bytes)}",
|
|
2884
|
+
vm["name"],
|
|
2885
|
+
f"{stats.get('cpu_percent', 0):.1f}%",
|
|
2886
|
+
format_bytes(stats.get("memory_usage", 0)),
|
|
2887
|
+
f"{stats.get('disk_read', 0)}/{stats.get('disk_write', 0)} MB/s",
|
|
2888
|
+
f"{stats.get('net_rx', 0)}/{stats.get('net_tx', 0)} MB/s",
|
|
2963
2889
|
)
|
|
2964
|
-
|
|
2965
|
-
|
|
2966
|
-
|
|
2967
|
-
|
|
2968
|
-
|
|
2969
|
-
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
cpu_color = "red" if c.cpu_percent > 80 else "green"
|
|
2983
|
-
mem_pct = (
|
|
2984
|
-
(c.memory_used_mb / c.memory_limit_mb * 100) if c.memory_limit_mb > 0 else 0
|
|
2985
|
-
)
|
|
2986
|
-
mem_color = "red" if mem_pct > 80 else "green"
|
|
2987
|
-
|
|
2988
|
-
table.add_row(
|
|
2989
|
-
c.name,
|
|
2990
|
-
f"[green]{c.state}[/]",
|
|
2991
|
-
f"[{cpu_color}]{c.cpu_percent:.1f}%[/]",
|
|
2992
|
-
f"[{mem_color}]{c.memory_used_mb}/{c.memory_limit_mb} MB[/]",
|
|
2993
|
-
f"↓{format_bytes(c.network_rx_bytes)} ↑{format_bytes(c.network_tx_bytes)}",
|
|
2994
|
-
str(c.pids),
|
|
2890
|
+
else:
|
|
2891
|
+
table.add_row(vm["name"], "[dim]not running[/]", "-", "-", "-")
|
|
2892
|
+
|
|
2893
|
+
console.print(table)
|
|
2894
|
+
else:
|
|
2895
|
+
# Continuous monitoring
|
|
2896
|
+
console.print(f"[cyan]Monitoring VMs (refresh every {refresh}s). Press Ctrl+C to exit.[/]\n")
|
|
2897
|
+
|
|
2898
|
+
try:
|
|
2899
|
+
while True:
|
|
2900
|
+
# Clear screen
|
|
2901
|
+
console.clear()
|
|
2902
|
+
|
|
2903
|
+
# Create table
|
|
2904
|
+
table = Table(
|
|
2905
|
+
title=f"VM Resource Usage - {datetime.now().strftime('%H:%M:%S')}",
|
|
2906
|
+
show_header=True,
|
|
2907
|
+
header_style="bold magenta",
|
|
2995
2908
|
)
|
|
2996
|
-
|
|
2997
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
|
|
3003
|
-
|
|
3004
|
-
|
|
3005
|
-
|
|
3006
|
-
|
|
3007
|
-
|
|
2909
|
+
table.add_column("VM Name", style="cyan")
|
|
2910
|
+
table.add_column("State", style="green")
|
|
2911
|
+
table.add_column("CPU %", justify="right")
|
|
2912
|
+
table.add_column("Memory", justify="right")
|
|
2913
|
+
table.add_column("Disk I/O", justify="right")
|
|
2914
|
+
table.add_column("Network I/O", justify="right")
|
|
2915
|
+
|
|
2916
|
+
for vm in vms:
|
|
2917
|
+
if vm["state"] == "running":
|
|
2918
|
+
stats = monitor.get_vm_stats(vm["name"])
|
|
2919
|
+
table.add_row(
|
|
2920
|
+
vm["name"],
|
|
2921
|
+
vm["state"],
|
|
2922
|
+
f"{stats.get('cpu_percent', 0):.1f}%",
|
|
2923
|
+
format_bytes(stats.get("memory_usage", 0)),
|
|
2924
|
+
f"{stats.get('disk_read', 0):.1f}/{stats.get('disk_write', 0):.1f} MB/s",
|
|
2925
|
+
f"{stats.get('net_rx', 0):.1f}/{stats.get('net_tx', 0):.1f} MB/s",
|
|
2926
|
+
)
|
|
2927
|
+
else:
|
|
2928
|
+
table.add_row(vm["name"], f"[dim]{vm['state']}[/]", "-", "-", "-", "-")
|
|
2929
|
+
|
|
2930
|
+
console.print(table)
|
|
2931
|
+
time.sleep(refresh)
|
|
2932
|
+
|
|
2933
|
+
except KeyboardInterrupt:
|
|
2934
|
+
console.print("\n[yellow]Monitoring stopped.[/]")
|
|
2935
|
+
|
|
3008
2936
|
finally:
|
|
3009
2937
|
monitor.close()
|
|
3010
2938
|
|
|
@@ -3159,6 +3087,17 @@ def cmd_snapshot_restore(args) -> None:
|
|
|
3159
3087
|
vm_name, config_file = _resolve_vm_name_and_config_file(args.vm_name)
|
|
3160
3088
|
conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
|
|
3161
3089
|
|
|
3090
|
+
policy = PolicyEngine.load_effective(start=config_file)
|
|
3091
|
+
if policy is not None:
|
|
3092
|
+
try:
|
|
3093
|
+
policy.assert_operation_approved(
|
|
3094
|
+
AuditEventType.VM_SNAPSHOT_RESTORE.value,
|
|
3095
|
+
approved=getattr(args, "approve", False),
|
|
3096
|
+
)
|
|
3097
|
+
except PolicyViolationError as e:
|
|
3098
|
+
console.print(f"[red]❌ {e}[/]")
|
|
3099
|
+
sys.exit(1)
|
|
3100
|
+
|
|
3162
3101
|
console.print(f"[cyan]🔄 Restoring snapshot: {args.name}[/]")
|
|
3163
3102
|
|
|
3164
3103
|
try:
|
|
@@ -3364,6 +3303,46 @@ def cmd_list_remote(args) -> None:
|
|
|
3364
3303
|
console.print("[yellow]No VMs found on remote host.[/]")
|
|
3365
3304
|
|
|
3366
3305
|
|
|
3306
|
+
def cmd_policy_validate(args) -> None:
|
|
3307
|
+
"""Validate a policy file."""
|
|
3308
|
+
try:
|
|
3309
|
+
file_arg = getattr(args, "file", None)
|
|
3310
|
+
if file_arg:
|
|
3311
|
+
policy_path = Path(file_arg).expanduser().resolve()
|
|
3312
|
+
else:
|
|
3313
|
+
policy_path = PolicyEngine.find_policy_file()
|
|
3314
|
+
|
|
3315
|
+
if not policy_path:
|
|
3316
|
+
console.print("[red]❌ Policy file not found[/]")
|
|
3317
|
+
sys.exit(1)
|
|
3318
|
+
|
|
3319
|
+
PolicyEngine.load(policy_path)
|
|
3320
|
+
console.print(f"[green]✅ Policy valid: {policy_path}[/]")
|
|
3321
|
+
except (PolicyValidationError, FileNotFoundError) as e:
|
|
3322
|
+
console.print(f"[red]❌ Policy invalid: {e}[/]")
|
|
3323
|
+
sys.exit(1)
|
|
3324
|
+
|
|
3325
|
+
|
|
3326
|
+
def cmd_policy_apply(args) -> None:
|
|
3327
|
+
"""Apply a policy file as project or global policy."""
|
|
3328
|
+
try:
|
|
3329
|
+
src = Path(args.file).expanduser().resolve()
|
|
3330
|
+
PolicyEngine.load(src)
|
|
3331
|
+
|
|
3332
|
+
scope = getattr(args, "scope", "project")
|
|
3333
|
+
if scope == "global":
|
|
3334
|
+
dest = Path.home() / ".clonebox.d" / "policy.yaml"
|
|
3335
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
3336
|
+
else:
|
|
3337
|
+
dest = Path.cwd() / ".clonebox-policy.yaml"
|
|
3338
|
+
|
|
3339
|
+
dest.write_text(src.read_text())
|
|
3340
|
+
console.print(f"[green]✅ Policy applied: {dest}[/]")
|
|
3341
|
+
except (PolicyValidationError, FileNotFoundError) as e:
|
|
3342
|
+
console.print(f"[red]❌ Failed to apply policy: {e}[/]")
|
|
3343
|
+
sys.exit(1)
|
|
3344
|
+
|
|
3345
|
+
|
|
3367
3346
|
# === Audit Commands ===
|
|
3368
3347
|
|
|
3369
3348
|
|
|
@@ -3930,6 +3909,17 @@ def cmd_remote_delete(args) -> None:
|
|
|
3930
3909
|
user_session = getattr(args, "user", False)
|
|
3931
3910
|
keep_storage = getattr(args, "keep_storage", False)
|
|
3932
3911
|
|
|
3912
|
+
policy = PolicyEngine.load_effective()
|
|
3913
|
+
if policy is not None:
|
|
3914
|
+
try:
|
|
3915
|
+
policy.assert_operation_approved(
|
|
3916
|
+
AuditEventType.VM_DELETE.value,
|
|
3917
|
+
approved=getattr(args, "approve", False),
|
|
3918
|
+
)
|
|
3919
|
+
except PolicyViolationError as e:
|
|
3920
|
+
console.print(f"[red]❌ {e}[/]")
|
|
3921
|
+
sys.exit(1)
|
|
3922
|
+
|
|
3933
3923
|
if not getattr(args, "yes", False):
|
|
3934
3924
|
confirm = questionary.confirm(
|
|
3935
3925
|
f"Delete VM '{vm_name}' on {host}?",
|
|
@@ -4105,6 +4095,11 @@ def main():
|
|
|
4105
4095
|
action="store_true",
|
|
4106
4096
|
help="Use user session (qemu:///session) - no root required",
|
|
4107
4097
|
)
|
|
4098
|
+
delete_parser.add_argument(
|
|
4099
|
+
"--approve",
|
|
4100
|
+
action="store_true",
|
|
4101
|
+
help="Approve policy-gated operation",
|
|
4102
|
+
)
|
|
4108
4103
|
delete_parser.set_defaults(func=cmd_delete)
|
|
4109
4104
|
|
|
4110
4105
|
# List command
|
|
@@ -4274,6 +4269,11 @@ def main():
|
|
|
4274
4269
|
action="store_true",
|
|
4275
4270
|
help="If VM already exists, stop+undefine it and recreate (also deletes its storage)",
|
|
4276
4271
|
)
|
|
4272
|
+
clone_parser.add_argument(
|
|
4273
|
+
"--approve",
|
|
4274
|
+
action="store_true",
|
|
4275
|
+
help="Approve policy-gated operation (required for --replace if policy demands)",
|
|
4276
|
+
)
|
|
4277
4277
|
clone_parser.add_argument(
|
|
4278
4278
|
"--dry-run",
|
|
4279
4279
|
action="store_true",
|
|
@@ -4413,14 +4413,12 @@ def main():
|
|
|
4413
4413
|
export_parser.add_argument(
|
|
4414
4414
|
"-u", "--user", action="store_true", help="Use user session (qemu:///session)"
|
|
4415
4415
|
)
|
|
4416
|
-
export_parser.add_argument(
|
|
4417
|
-
"-o", "--output", help="Output archive filename (default: <vmname>-export.tar.gz)"
|
|
4418
|
-
)
|
|
4416
|
+
export_parser.add_argument("-o", "--output", help="Output archive filename (default: <vmname>-export.tar.gz)")
|
|
4419
4417
|
export_parser.add_argument(
|
|
4420
4418
|
"--include-data",
|
|
4421
4419
|
"-d",
|
|
4422
4420
|
action="store_true",
|
|
4423
|
-
help="Include shared data (browser profiles, configs)
|
|
4421
|
+
help="Include shared data (browser profiles, configs)",
|
|
4424
4422
|
)
|
|
4425
4423
|
export_parser.set_defaults(func=cmd_export)
|
|
4426
4424
|
|
|
@@ -4433,6 +4431,11 @@ def main():
|
|
|
4433
4431
|
import_parser.add_argument(
|
|
4434
4432
|
"--replace", action="store_true", help="Replace existing VM if exists"
|
|
4435
4433
|
)
|
|
4434
|
+
import_parser.add_argument(
|
|
4435
|
+
"--approve",
|
|
4436
|
+
action="store_true",
|
|
4437
|
+
help="Approve policy-gated operation (required for --replace if policy demands)",
|
|
4438
|
+
)
|
|
4436
4439
|
import_parser.set_defaults(func=cmd_import)
|
|
4437
4440
|
|
|
4438
4441
|
# Test command - validate VM configuration
|
|
@@ -4512,6 +4515,11 @@ def main():
|
|
|
4512
4515
|
snap_restore.add_argument(
|
|
4513
4516
|
"-f", "--force", action="store_true", help="Force restore even if running"
|
|
4514
4517
|
)
|
|
4518
|
+
snap_restore.add_argument(
|
|
4519
|
+
"--approve",
|
|
4520
|
+
action="store_true",
|
|
4521
|
+
help="Approve policy-gated operation",
|
|
4522
|
+
)
|
|
4515
4523
|
snap_restore.set_defaults(func=cmd_snapshot_restore)
|
|
4516
4524
|
|
|
4517
4525
|
snap_delete = snapshot_sub.add_parser("delete", aliases=["rm"], help="Delete snapshot")
|
|
@@ -4700,6 +4708,28 @@ def main():
|
|
|
4700
4708
|
plugin_uninstall.add_argument("name", help="Plugin name")
|
|
4701
4709
|
plugin_uninstall.set_defaults(func=cmd_plugin_uninstall)
|
|
4702
4710
|
|
|
4711
|
+
policy_parser = subparsers.add_parser("policy", help="Manage security policies")
|
|
4712
|
+
policy_parser.set_defaults(func=lambda args, p=policy_parser: p.print_help())
|
|
4713
|
+
policy_sub = policy_parser.add_subparsers(dest="policy_command", help="Policy commands")
|
|
4714
|
+
|
|
4715
|
+
policy_validate = policy_sub.add_parser("validate", help="Validate policy file")
|
|
4716
|
+
policy_validate.add_argument(
|
|
4717
|
+
"--file",
|
|
4718
|
+
"-f",
|
|
4719
|
+
help="Policy file (default: auto-detect .clonebox-policy.yaml/.yml or ~/.clonebox.d/policy.yaml)",
|
|
4720
|
+
)
|
|
4721
|
+
policy_validate.set_defaults(func=cmd_policy_validate)
|
|
4722
|
+
|
|
4723
|
+
policy_apply = policy_sub.add_parser("apply", help="Apply policy file")
|
|
4724
|
+
policy_apply.add_argument("--file", "-f", required=True, help="Policy file to apply")
|
|
4725
|
+
policy_apply.add_argument(
|
|
4726
|
+
"--scope",
|
|
4727
|
+
choices=["project", "global"],
|
|
4728
|
+
default="project",
|
|
4729
|
+
help="Apply scope: project writes .clonebox-policy.yaml in CWD, global writes ~/.clonebox.d/policy.yaml",
|
|
4730
|
+
)
|
|
4731
|
+
policy_apply.set_defaults(func=cmd_policy_apply)
|
|
4732
|
+
|
|
4703
4733
|
# === Remote Management Commands ===
|
|
4704
4734
|
remote_parser = subparsers.add_parser("remote", help="Manage VMs on remote hosts")
|
|
4705
4735
|
remote_sub = remote_parser.add_subparsers(dest="remote_command", help="Remote commands")
|
|
@@ -4735,6 +4765,11 @@ def main():
|
|
|
4735
4765
|
remote_delete.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4736
4766
|
remote_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
|
|
4737
4767
|
remote_delete.add_argument("--keep-storage", action="store_true", help="Keep disk images")
|
|
4768
|
+
remote_delete.add_argument(
|
|
4769
|
+
"--approve",
|
|
4770
|
+
action="store_true",
|
|
4771
|
+
help="Approve policy-gated operation",
|
|
4772
|
+
)
|
|
4738
4773
|
remote_delete.set_defaults(func=cmd_remote_delete)
|
|
4739
4774
|
|
|
4740
4775
|
remote_exec = remote_sub.add_parser("exec", help="Execute command in VM on remote host")
|