clonebox 1.1.17__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 -544
- clonebox/cloner.py +498 -422
- 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 +228 -51
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/METADATA +1 -1
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/RECORD +15 -11
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/WHEEL +0 -0
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.17.dist-info → clonebox-1.1.19.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.17.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,570 +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
|
|
2488
|
+
refresh = 1.0
|
|
2489
|
+
once = False
|
|
2490
|
+
monitor = ResourceMonitor(conn_uri=conn_uri)
|
|
2444
2491
|
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
if
|
|
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
|
-
task,
|
|
2525
|
-
description=f"[cyan]{current_status} ({minutes}m {seconds}s, {remaining})",
|
|
2526
|
-
)
|
|
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)
|
|
2527
2572
|
else:
|
|
2528
|
-
|
|
2529
|
-
task,
|
|
2530
|
-
description=f"[cyan]Installing packages... ({minutes}m {seconds}s, {remaining})",
|
|
2531
|
-
)
|
|
2573
|
+
console.print("[dim]No containers running.[/]")
|
|
2532
2574
|
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
minutes = elapsed // 60
|
|
2536
|
-
seconds = elapsed % 60
|
|
2537
|
-
progress.update(
|
|
2538
|
-
task, description=f"[cyan]Configuring VM... ({minutes}m {seconds}s)"
|
|
2539
|
-
)
|
|
2575
|
+
if once:
|
|
2576
|
+
break
|
|
2540
2577
|
|
|
2541
|
-
|
|
2578
|
+
console.print(f"\n[dim]Refreshing every {refresh}s. Press Ctrl+C to exit.[/]")
|
|
2579
|
+
time.sleep(refresh)
|
|
2542
2580
|
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
)
|
|
2581
|
+
except KeyboardInterrupt:
|
|
2582
|
+
console.print("\n[yellow]Monitoring stopped.[/]")
|
|
2583
|
+
finally:
|
|
2584
|
+
monitor.close()
|
|
2548
2585
|
|
|
2549
2586
|
|
|
2550
|
-
def create_vm_from_config(
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
user_session: bool = False,
|
|
2554
|
-
replace: bool = False,
|
|
2555
|
-
) -> str:
|
|
2556
|
-
"""Create VM from YAML config dict."""
|
|
2557
|
-
paths = config.get("paths", {})
|
|
2558
|
-
# Backwards compatible: v1 uses app_data_paths, newer configs may use copy_paths
|
|
2559
|
-
copy_paths = config.get("copy_paths", None)
|
|
2560
|
-
if not isinstance(copy_paths, dict) or not copy_paths:
|
|
2561
|
-
copy_paths = config.get("app_data_paths", {})
|
|
2562
|
-
|
|
2563
|
-
vm_section = config.get("vm") or {}
|
|
2564
|
-
|
|
2565
|
-
# Support both v1 (auth_method) and v2 (auth.method) config formats
|
|
2566
|
-
auth_section = vm_section.get("auth") or {}
|
|
2567
|
-
auth_method = auth_section.get("method") or vm_section.get("auth_method") or "ssh_key"
|
|
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", {})
|
|
2568
2590
|
|
|
2569
|
-
#
|
|
2570
|
-
secrets_section = config.get("secrets") or {}
|
|
2571
|
-
secrets_provider = secrets_section.get("provider", "auto")
|
|
2572
|
-
|
|
2573
|
-
# v2 config: resource limits
|
|
2574
|
-
limits_section = config.get("limits") or {}
|
|
2575
|
-
resources = {
|
|
2576
|
-
"memory_limit": limits_section.get("memory_limit"),
|
|
2577
|
-
"cpu_shares": limits_section.get("cpu_shares"),
|
|
2578
|
-
"disk_limit": limits_section.get("disk_limit"),
|
|
2579
|
-
"network_limit": limits_section.get("network_limit"),
|
|
2580
|
-
}
|
|
2581
|
-
# Remove None values
|
|
2582
|
-
resources = {k: v for k, v in resources.items() if v is not None}
|
|
2583
|
-
|
|
2591
|
+
# Create VMConfig object
|
|
2584
2592
|
vm_config = VMConfig(
|
|
2585
|
-
name=
|
|
2586
|
-
ram_mb=
|
|
2587
|
-
vcpus=
|
|
2588
|
-
disk_size_gb=
|
|
2589
|
-
gui=
|
|
2590
|
-
base_image=
|
|
2591
|
-
|
|
2592
|
-
|
|
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", {}),
|
|
2593
2604
|
packages=config.get("packages", []),
|
|
2594
2605
|
snap_packages=config.get("snap_packages", []),
|
|
2595
2606
|
services=config.get("services", []),
|
|
2596
2607
|
post_commands=config.get("post_commands", []),
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
username=config["vm"].get("username", "ubuntu"),
|
|
2600
|
-
password=config["vm"].get("password", "ubuntu"),
|
|
2601
|
-
auth_method=auth_method,
|
|
2602
|
-
ssh_public_key=vm_section.get("ssh_public_key") or auth_section.get("ssh_public_key"),
|
|
2603
|
-
resources=resources if resources else config["vm"].get("resources", {}),
|
|
2608
|
+
copy_paths=(config.get("copy_paths") or config.get("app_data_paths") or {}),
|
|
2609
|
+
resources=config.get("resources", {}),
|
|
2604
2610
|
)
|
|
2605
|
-
|
|
2611
|
+
|
|
2606
2612
|
cloner = SelectiveVMCloner(user_session=user_session)
|
|
2607
|
-
|
|
2608
|
-
# Check prerequisites
|
|
2613
|
+
|
|
2614
|
+
# Check prerequisites
|
|
2609
2615
|
checks = cloner.check_prerequisites()
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
console.print(f"
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
|
|
2620
|
-
|
|
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
|
+
|
|
2621
2630
|
if start:
|
|
2622
|
-
cloner.start_vm(vm_config.name, open_viewer=
|
|
2623
|
-
|
|
2624
|
-
# Monitor cloud-init progress if GUI is enabled
|
|
2625
|
-
if vm_config.gui:
|
|
2626
|
-
console.print("\n[bold cyan]📊 Monitoring setup progress...[/]")
|
|
2627
|
-
try:
|
|
2628
|
-
monitor_cloud_init_status(vm_config.name, user_session=user_session)
|
|
2629
|
-
except KeyboardInterrupt:
|
|
2630
|
-
console.print("\n[yellow]Monitoring stopped. VM continues setup in background.[/]")
|
|
2631
|
-
except Exception as e:
|
|
2632
|
-
console.print(
|
|
2633
|
-
f"\n[dim]Note: Could not monitor status ({e}). VM continues setup in background.[/]"
|
|
2634
|
-
)
|
|
2635
|
-
|
|
2631
|
+
cloner.start_vm(vm_config.name, open_viewer=True, console=console)
|
|
2632
|
+
|
|
2636
2633
|
return vm_uuid
|
|
2637
2634
|
|
|
2638
2635
|
|
|
2639
|
-
def cmd_clone(args):
|
|
2636
|
+
def cmd_clone(args) -> None:
|
|
2640
2637
|
"""Generate clone config from path and optionally create VM."""
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2638
|
+
from clonebox.detector import SystemDetector
|
|
2639
|
+
|
|
2640
|
+
target_path = Path(args.path).expanduser().resolve() if args.path else Path.cwd()
|
|
2641
|
+
|
|
2644
2642
|
if not target_path.exists():
|
|
2645
2643
|
console.print(f"[red]❌ Path does not exist: {target_path}[/]")
|
|
2646
2644
|
return
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2650
|
-
else:
|
|
2651
|
-
console.print(f"[bold cyan]📦 Generating clone config for: {target_path}[/]\n")
|
|
2652
|
-
|
|
2645
|
+
|
|
2646
|
+
console.print(f"[cyan]🔍 Analyzing system for cloning...[/]")
|
|
2647
|
+
|
|
2653
2648
|
# Detect system state
|
|
2649
|
+
detector = SystemDetector()
|
|
2650
|
+
|
|
2654
2651
|
with Progress(
|
|
2655
2652
|
SpinnerColumn(),
|
|
2656
2653
|
TextColumn("[progress.description]{task.description}"),
|
|
2657
2654
|
console=console,
|
|
2658
|
-
transient=True,
|
|
2659
2655
|
) as progress:
|
|
2660
|
-
progress.add_task("Scanning system...", total=None)
|
|
2661
|
-
|
|
2656
|
+
task = progress.add_task("Scanning system...", total=None)
|
|
2657
|
+
|
|
2658
|
+
# Take snapshot
|
|
2662
2659
|
snapshot = detector.detect_all()
|
|
2663
|
-
|
|
2660
|
+
|
|
2661
|
+
# Detect Docker containers
|
|
2662
|
+
containers = detector.detect_docker_containers()
|
|
2663
|
+
|
|
2664
|
+
progress.update(task, description="Finalizing...")
|
|
2665
|
+
|
|
2664
2666
|
# Generate config
|
|
2665
|
-
vm_name = args.name or f"clone-{target_path.name}"
|
|
2666
2667
|
yaml_content = generate_clonebox_yaml(
|
|
2667
2668
|
snapshot,
|
|
2668
2669
|
detector,
|
|
2669
2670
|
deduplicate=args.dedupe,
|
|
2670
|
-
target_path=str(target_path),
|
|
2671
|
-
vm_name=
|
|
2671
|
+
target_path=str(target_path) if args.path else None,
|
|
2672
|
+
vm_name=args.name,
|
|
2672
2673
|
network_mode=args.network,
|
|
2673
|
-
base_image=
|
|
2674
|
-
disk_size_gb=
|
|
2674
|
+
base_image=args.base_image,
|
|
2675
|
+
disk_size_gb=args.disk_size_gb,
|
|
2675
2676
|
)
|
|
2676
|
-
|
|
2677
|
-
profile_name = getattr(args, "profile", None)
|
|
2678
|
-
if profile_name:
|
|
2679
|
-
merged_config = merge_with_profile(yaml.safe_load(yaml_content), profile_name)
|
|
2680
|
-
if isinstance(merged_config, dict):
|
|
2681
|
-
vm_section = merged_config.get("vm")
|
|
2682
|
-
if isinstance(vm_section, dict):
|
|
2683
|
-
vm_packages = vm_section.pop("packages", None)
|
|
2684
|
-
if isinstance(vm_packages, list):
|
|
2685
|
-
packages = merged_config.get("packages")
|
|
2686
|
-
if not isinstance(packages, list):
|
|
2687
|
-
packages = []
|
|
2688
|
-
for p in vm_packages:
|
|
2689
|
-
if p not in packages:
|
|
2690
|
-
packages.append(p)
|
|
2691
|
-
merged_config["packages"] = packages
|
|
2692
|
-
|
|
2693
|
-
if "container" in merged_config:
|
|
2694
|
-
merged_config.pop("container", None)
|
|
2695
|
-
|
|
2696
|
-
yaml_content = yaml.dump(
|
|
2697
|
-
merged_config,
|
|
2698
|
-
default_flow_style=False,
|
|
2699
|
-
allow_unicode=True,
|
|
2700
|
-
sort_keys=False,
|
|
2701
|
-
)
|
|
2702
|
-
|
|
2703
|
-
# Dry run - show what would be created and exit
|
|
2704
|
-
if dry_run:
|
|
2705
|
-
config = yaml.safe_load(yaml_content)
|
|
2706
|
-
console.print(
|
|
2707
|
-
Panel(
|
|
2708
|
-
f"[bold]VM Name:[/] {config['vm']['name']}\n"
|
|
2709
|
-
f"[bold]RAM:[/] {config['vm'].get('ram_mb', 4096)} MB\n"
|
|
2710
|
-
f"[bold]vCPUs:[/] {config['vm'].get('vcpus', 4)}\n"
|
|
2711
|
-
f"[bold]Network:[/] {config['vm'].get('network_mode', 'auto')}\n"
|
|
2712
|
-
f"[bold]Paths:[/] {len(config.get('paths', {}))} mounts\n"
|
|
2713
|
-
f"[bold]Packages:[/] {len(config.get('packages', []))} packages\n"
|
|
2714
|
-
f"[bold]Services:[/] {len(config.get('services', []))} services",
|
|
2715
|
-
title="[bold cyan]Would create VM[/]",
|
|
2716
|
-
border_style="cyan",
|
|
2717
|
-
)
|
|
2718
|
-
)
|
|
2719
|
-
console.print("\n[dim]Config preview:[/]")
|
|
2720
|
-
console.print(Panel(yaml_content, title="[bold].clonebox.yaml[/]", border_style="dim"))
|
|
2721
|
-
console.print("\n[yellow]ℹ️ Dry run complete. No changes made.[/]")
|
|
2722
|
-
return
|
|
2723
|
-
|
|
2677
|
+
|
|
2724
2678
|
# Save config file
|
|
2725
|
-
config_file =
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
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
|
|
2737
2695
|
if args.edit:
|
|
2738
2696
|
editor = os.environ.get("EDITOR", "nano")
|
|
2739
|
-
console.print(f"[cyan]Opening {editor}...[/]")
|
|
2740
2697
|
os.system(f"{editor} {config_file}")
|
|
2741
|
-
|
|
2742
|
-
|
|
2743
|
-
|
|
2744
|
-
# Ask to create VM
|
|
2698
|
+
|
|
2699
|
+
# Run VM if requested
|
|
2745
2700
|
if args.run:
|
|
2746
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
)
|
|
2751
|
-
|
|
2752
|
-
if create_now:
|
|
2753
|
-
# Load config with environment variable expansion
|
|
2754
|
-
config = load_clonebox_config(config_file.parent)
|
|
2755
|
-
user_session = getattr(args, "user", False)
|
|
2756
|
-
|
|
2757
|
-
console.print("\n[bold cyan]🔧 Creating VM...[/]\n")
|
|
2758
|
-
if user_session:
|
|
2759
|
-
console.print("[cyan]Using user session (qemu:///session) - no root required[/]")
|
|
2760
|
-
|
|
2761
|
-
try:
|
|
2762
|
-
vm_uuid = create_vm_from_config(
|
|
2763
|
-
config,
|
|
2764
|
-
start=True,
|
|
2765
|
-
user_session=user_session,
|
|
2766
|
-
replace=getattr(args, "replace", False),
|
|
2767
|
-
)
|
|
2768
|
-
console.print(f"\n[bold green]🎉 VM '{config['vm']['name']}' is running![/]")
|
|
2769
|
-
console.print(f"[dim]UUID: {vm_uuid}[/]")
|
|
2770
|
-
|
|
2771
|
-
# Show GUI startup info if GUI is enabled
|
|
2772
|
-
if config.get("vm", {}).get("gui", False):
|
|
2773
|
-
username = config["vm"].get("username", "ubuntu")
|
|
2774
|
-
password = config["vm"].get("password", "ubuntu")
|
|
2775
|
-
console.print("\n[bold yellow]⏰ GUI Setup Process:[/]")
|
|
2776
|
-
console.print(" [yellow]•[/] Installing desktop environment (~5-10 minutes)")
|
|
2777
|
-
console.print(" [yellow]•[/] Running health checks on all components")
|
|
2778
|
-
console.print(" [yellow]•[/] Automatic restart after installation")
|
|
2779
|
-
console.print(" [yellow]•[/] GUI login screen will appear")
|
|
2780
|
-
console.print(
|
|
2781
|
-
f" [yellow]•[/] Login: [cyan]{username}[/] / [cyan]{'*' * len(password)}[/] (from .env)"
|
|
2782
|
-
)
|
|
2783
|
-
console.print("\n[dim]💡 Progress will be monitored automatically below[/]")
|
|
2784
|
-
|
|
2785
|
-
# Show health check info
|
|
2786
|
-
console.print("\n[bold]📊 Health Check (inside VM):[/]")
|
|
2787
|
-
console.print(" [cyan]cat /var/log/clonebox-health.log[/] # View full report")
|
|
2788
|
-
console.print(" [cyan]cat /var/log/clonebox-health-status[/] # Quick status")
|
|
2789
|
-
console.print(" [cyan]clonebox-health[/] # Re-run health check")
|
|
2790
|
-
|
|
2791
|
-
# Show mount instructions
|
|
2792
|
-
paths = config.get("paths", {})
|
|
2793
|
-
app_data_paths = config.get("app_data_paths", {})
|
|
2794
|
-
|
|
2795
|
-
if paths:
|
|
2796
|
-
console.print("\n[bold]📁 Mounted paths (shared live):[/]")
|
|
2797
|
-
for idx, (host, guest) in enumerate(list(paths.items())[:5]):
|
|
2798
|
-
console.print(f" [dim]{host}[/] → [cyan]{guest}[/]")
|
|
2799
|
-
if len(paths) > 5:
|
|
2800
|
-
console.print(f" [dim]... and {len(paths) - 5} more paths[/]")
|
|
2801
|
-
|
|
2802
|
-
if app_data_paths:
|
|
2803
|
-
console.print("\n[bold]📥 Copied paths (one-time import):[/]")
|
|
2804
|
-
for idx, (host, guest) in enumerate(list(app_data_paths.items())[:5]):
|
|
2805
|
-
console.print(f" [dim]{host}[/] → [cyan]{guest}[/]")
|
|
2806
|
-
if len(app_data_paths) > 5:
|
|
2807
|
-
console.print(f" [dim]... and {len(app_data_paths) - 5} more paths[/]")
|
|
2808
|
-
except PermissionError as e:
|
|
2809
|
-
console.print(f"[red]❌ Permission Error:[/]\n{e}")
|
|
2810
|
-
console.print("\n[yellow]💡 Try running with --user flag:[/]")
|
|
2811
|
-
console.print(f" [cyan]clonebox clone {target_path} --user[/]")
|
|
2812
|
-
except Exception as e:
|
|
2813
|
-
console.print(f"[red]❌ Error: {e}[/]")
|
|
2814
|
-
else:
|
|
2815
|
-
console.print("\n[dim]To create VM later, run:[/]")
|
|
2816
|
-
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}[/]")
|
|
2817
2707
|
|
|
2818
2708
|
|
|
2819
|
-
def cmd_detect(args):
|
|
2709
|
+
def cmd_detect(args) -> None:
|
|
2820
2710
|
"""Detect and show system state."""
|
|
2821
|
-
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
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
|
+
],
|
|
2830
2739
|
"applications": [
|
|
2831
|
-
{
|
|
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
|
|
2832
2747
|
],
|
|
2833
2748
|
"paths": [
|
|
2834
|
-
{"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
|
|
2835
2760
|
],
|
|
2836
2761
|
}
|
|
2837
|
-
|
|
2838
|
-
|
|
2839
|
-
|
|
2840
|
-
|
|
2841
|
-
|
|
2842
|
-
|
|
2843
|
-
|
|
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
|
|
2844
2779
|
if args.output:
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
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}[/]")
|
|
2848
2783
|
else:
|
|
2849
|
-
print(
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
if running:
|
|
2857
|
-
table = Table(title="Running Services", border_style="green")
|
|
2858
|
-
table.add_column("Service")
|
|
2859
|
-
table.add_column("Status")
|
|
2860
|
-
table.add_column("Enabled")
|
|
2861
|
-
|
|
2862
|
-
for svc in running:
|
|
2863
|
-
table.add_row(svc.name, f"[green]{svc.status}[/]", "✓" if svc.enabled else "")
|
|
2864
|
-
|
|
2865
|
-
console.print(table)
|
|
2866
|
-
|
|
2867
|
-
# Applications
|
|
2868
|
-
apps = detector.detect_applications()
|
|
2869
|
-
|
|
2870
|
-
if apps:
|
|
2871
|
-
console.print()
|
|
2872
|
-
table = Table(title="Running Applications", border_style="blue")
|
|
2873
|
-
table.add_column("Name")
|
|
2874
|
-
table.add_column("PID")
|
|
2875
|
-
table.add_column("Memory")
|
|
2876
|
-
table.add_column("Working Dir")
|
|
2877
|
-
|
|
2878
|
-
for app in apps[:15]:
|
|
2879
|
-
table.add_row(
|
|
2880
|
-
app.name,
|
|
2881
|
-
str(app.pid),
|
|
2882
|
-
f"{app.memory_mb:.0f} MB",
|
|
2883
|
-
app.working_dir[:40] if app.working_dir else "",
|
|
2884
|
-
)
|
|
2885
|
-
|
|
2886
|
-
console.print(table)
|
|
2887
|
-
|
|
2888
|
-
# Paths
|
|
2889
|
-
paths = detector.detect_paths()
|
|
2890
|
-
|
|
2891
|
-
if paths:
|
|
2892
|
-
console.print()
|
|
2893
|
-
table = Table(title="Detected Paths", border_style="yellow")
|
|
2894
|
-
table.add_column("Type")
|
|
2895
|
-
table.add_column("Path")
|
|
2896
|
-
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()
|
|
2897
2790
|
|
|
2898
|
-
for p in paths[:20]:
|
|
2899
|
-
table.add_row(
|
|
2900
|
-
f"[cyan]{p.type}[/]", p.path, f"{p.size_mb:.0f} MB" if p.size_mb > 0 else "-"
|
|
2901
|
-
)
|
|
2902
2791
|
|
|
2903
|
-
|
|
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
|
|
2904
2849
|
|
|
2905
2850
|
|
|
2906
2851
|
def cmd_monitor(args) -> None:
|
|
2907
|
-
"""Real-time resource monitoring
|
|
2908
|
-
|
|
2852
|
+
"""Real-time resource monitoring."""
|
|
2853
|
+
from clonebox.cloner import SelectiveVMCloner
|
|
2854
|
+
|
|
2855
|
+
user_session = getattr(args, "user", False)
|
|
2909
2856
|
refresh = getattr(args, "refresh", 2.0)
|
|
2910
2857
|
once = getattr(args, "once", False)
|
|
2911
|
-
|
|
2912
|
-
|
|
2913
|
-
|
|
2858
|
+
|
|
2859
|
+
cloner = SelectiveVMCloner(user_session=user_session)
|
|
2860
|
+
|
|
2914
2861
|
try:
|
|
2915
|
-
|
|
2916
|
-
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
|
|
2924
|
-
|
|
2925
|
-
|
|
2926
|
-
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
2936
|
-
cpu_color = "red" if vm.cpu_percent > 80 else "green"
|
|
2937
|
-
mem_pct = (
|
|
2938
|
-
(vm.memory_used_mb / vm.memory_total_mb * 100)
|
|
2939
|
-
if vm.memory_total_mb > 0
|
|
2940
|
-
else 0
|
|
2941
|
-
)
|
|
2942
|
-
mem_color = "red" if mem_pct > 80 else "green"
|
|
2943
|
-
|
|
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"])
|
|
2944
2883
|
table.add_row(
|
|
2945
|
-
vm
|
|
2946
|
-
f"
|
|
2947
|
-
|
|
2948
|
-
f"
|
|
2949
|
-
f"{
|
|
2950
|
-
f"↓{format_bytes(vm.network_rx_bytes)} ↑{format_bytes(vm.network_tx_bytes)}",
|
|
2951
|
-
)
|
|
2952
|
-
console.print(table)
|
|
2953
|
-
else:
|
|
2954
|
-
console.print("[dim]No VMs found.[/]")
|
|
2955
|
-
|
|
2956
|
-
console.print()
|
|
2957
|
-
|
|
2958
|
-
# Container Stats
|
|
2959
|
-
container_stats = monitor.get_container_stats()
|
|
2960
|
-
if container_stats:
|
|
2961
|
-
table = Table(title="🐳 Containers", border_style="blue")
|
|
2962
|
-
table.add_column("Name", style="bold")
|
|
2963
|
-
table.add_column("State")
|
|
2964
|
-
table.add_column("CPU %")
|
|
2965
|
-
table.add_column("Memory")
|
|
2966
|
-
table.add_column("Network I/O")
|
|
2967
|
-
table.add_column("PIDs")
|
|
2968
|
-
|
|
2969
|
-
for c in container_stats:
|
|
2970
|
-
cpu_color = "red" if c.cpu_percent > 80 else "green"
|
|
2971
|
-
mem_pct = (
|
|
2972
|
-
(c.memory_used_mb / c.memory_limit_mb * 100) if c.memory_limit_mb > 0 else 0
|
|
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",
|
|
2973
2889
|
)
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
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",
|
|
2983
2908
|
)
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
2989
|
-
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
2994
|
-
|
|
2995
|
-
|
|
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
|
+
|
|
2996
2936
|
finally:
|
|
2997
2937
|
monitor.close()
|
|
2998
2938
|
|
|
@@ -3147,6 +3087,17 @@ def cmd_snapshot_restore(args) -> None:
|
|
|
3147
3087
|
vm_name, config_file = _resolve_vm_name_and_config_file(args.vm_name)
|
|
3148
3088
|
conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
|
|
3149
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
|
+
|
|
3150
3101
|
console.print(f"[cyan]🔄 Restoring snapshot: {args.name}[/]")
|
|
3151
3102
|
|
|
3152
3103
|
try:
|
|
@@ -3352,6 +3303,46 @@ def cmd_list_remote(args) -> None:
|
|
|
3352
3303
|
console.print("[yellow]No VMs found on remote host.[/]")
|
|
3353
3304
|
|
|
3354
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
|
+
|
|
3355
3346
|
# === Audit Commands ===
|
|
3356
3347
|
|
|
3357
3348
|
|
|
@@ -3918,6 +3909,17 @@ def cmd_remote_delete(args) -> None:
|
|
|
3918
3909
|
user_session = getattr(args, "user", False)
|
|
3919
3910
|
keep_storage = getattr(args, "keep_storage", False)
|
|
3920
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
|
+
|
|
3921
3923
|
if not getattr(args, "yes", False):
|
|
3922
3924
|
confirm = questionary.confirm(
|
|
3923
3925
|
f"Delete VM '{vm_name}' on {host}?",
|
|
@@ -4093,6 +4095,11 @@ def main():
|
|
|
4093
4095
|
action="store_true",
|
|
4094
4096
|
help="Use user session (qemu:///session) - no root required",
|
|
4095
4097
|
)
|
|
4098
|
+
delete_parser.add_argument(
|
|
4099
|
+
"--approve",
|
|
4100
|
+
action="store_true",
|
|
4101
|
+
help="Approve policy-gated operation",
|
|
4102
|
+
)
|
|
4096
4103
|
delete_parser.set_defaults(func=cmd_delete)
|
|
4097
4104
|
|
|
4098
4105
|
# List command
|
|
@@ -4262,6 +4269,11 @@ def main():
|
|
|
4262
4269
|
action="store_true",
|
|
4263
4270
|
help="If VM already exists, stop+undefine it and recreate (also deletes its storage)",
|
|
4264
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
|
+
)
|
|
4265
4277
|
clone_parser.add_argument(
|
|
4266
4278
|
"--dry-run",
|
|
4267
4279
|
action="store_true",
|
|
@@ -4401,14 +4413,12 @@ def main():
|
|
|
4401
4413
|
export_parser.add_argument(
|
|
4402
4414
|
"-u", "--user", action="store_true", help="Use user session (qemu:///session)"
|
|
4403
4415
|
)
|
|
4404
|
-
export_parser.add_argument(
|
|
4405
|
-
"-o", "--output", help="Output archive filename (default: <vmname>-export.tar.gz)"
|
|
4406
|
-
)
|
|
4416
|
+
export_parser.add_argument("-o", "--output", help="Output archive filename (default: <vmname>-export.tar.gz)")
|
|
4407
4417
|
export_parser.add_argument(
|
|
4408
4418
|
"--include-data",
|
|
4409
4419
|
"-d",
|
|
4410
4420
|
action="store_true",
|
|
4411
|
-
help="Include shared data (browser profiles, configs)
|
|
4421
|
+
help="Include shared data (browser profiles, configs)",
|
|
4412
4422
|
)
|
|
4413
4423
|
export_parser.set_defaults(func=cmd_export)
|
|
4414
4424
|
|
|
@@ -4421,6 +4431,11 @@ def main():
|
|
|
4421
4431
|
import_parser.add_argument(
|
|
4422
4432
|
"--replace", action="store_true", help="Replace existing VM if exists"
|
|
4423
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
|
+
)
|
|
4424
4439
|
import_parser.set_defaults(func=cmd_import)
|
|
4425
4440
|
|
|
4426
4441
|
# Test command - validate VM configuration
|
|
@@ -4500,6 +4515,11 @@ def main():
|
|
|
4500
4515
|
snap_restore.add_argument(
|
|
4501
4516
|
"-f", "--force", action="store_true", help="Force restore even if running"
|
|
4502
4517
|
)
|
|
4518
|
+
snap_restore.add_argument(
|
|
4519
|
+
"--approve",
|
|
4520
|
+
action="store_true",
|
|
4521
|
+
help="Approve policy-gated operation",
|
|
4522
|
+
)
|
|
4503
4523
|
snap_restore.set_defaults(func=cmd_snapshot_restore)
|
|
4504
4524
|
|
|
4505
4525
|
snap_delete = snapshot_sub.add_parser("delete", aliases=["rm"], help="Delete snapshot")
|
|
@@ -4688,6 +4708,28 @@ def main():
|
|
|
4688
4708
|
plugin_uninstall.add_argument("name", help="Plugin name")
|
|
4689
4709
|
plugin_uninstall.set_defaults(func=cmd_plugin_uninstall)
|
|
4690
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
|
+
|
|
4691
4733
|
# === Remote Management Commands ===
|
|
4692
4734
|
remote_parser = subparsers.add_parser("remote", help="Manage VMs on remote hosts")
|
|
4693
4735
|
remote_sub = remote_parser.add_subparsers(dest="remote_command", help="Remote commands")
|
|
@@ -4723,6 +4765,11 @@ def main():
|
|
|
4723
4765
|
remote_delete.add_argument("-u", "--user", action="store_true", help="Use user session on remote")
|
|
4724
4766
|
remote_delete.add_argument("-y", "--yes", action="store_true", help="Skip confirmation")
|
|
4725
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
|
+
)
|
|
4726
4773
|
remote_delete.set_defaults(func=cmd_remote_delete)
|
|
4727
4774
|
|
|
4728
4775
|
remote_exec = remote_sub.add_parser("exec", help="Execute command in VM on remote host")
|