clonebox 1.1.4__py3-none-any.whl → 1.1.5__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 +217 -0
- clonebox/backends/qemu_disk.py +52 -0
- clonebox/backends/subprocess_runner.py +56 -0
- clonebox/cli.py +209 -30
- clonebox/cloner.py +327 -189
- clonebox/di.py +176 -0
- clonebox/health/__init__.py +2 -1
- clonebox/health/manager.py +328 -0
- clonebox/health/probes.py +337 -0
- clonebox/interfaces/disk.py +40 -0
- clonebox/interfaces/hypervisor.py +89 -0
- clonebox/interfaces/network.py +33 -0
- clonebox/interfaces/process.py +46 -0
- clonebox/logging.py +125 -0
- clonebox/monitor.py +1 -3
- clonebox/p2p.py +4 -2
- clonebox/resource_monitor.py +162 -0
- clonebox/resources.py +222 -0
- clonebox/rollback.py +172 -0
- clonebox/secrets.py +331 -0
- clonebox/snapshots/manager.py +3 -9
- clonebox/snapshots/models.py +2 -6
- {clonebox-1.1.4.dist-info → clonebox-1.1.5.dist-info}/METADATA +51 -2
- clonebox-1.1.5.dist-info/RECORD +42 -0
- clonebox-1.1.4.dist-info/RECORD +0 -27
- {clonebox-1.1.4.dist-info → clonebox-1.1.5.dist-info}/WHEEL +0 -0
- {clonebox-1.1.4.dist-info → clonebox-1.1.5.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.4.dist-info → clonebox-1.1.5.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.4.dist-info → clonebox-1.1.5.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""libvirt hypervisor backend implementation."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import json
|
|
5
|
+
import time
|
|
6
|
+
from typing import Any, Dict, List, Optional
|
|
7
|
+
|
|
8
|
+
try:
|
|
9
|
+
import libvirt
|
|
10
|
+
except ImportError:
|
|
11
|
+
libvirt = None
|
|
12
|
+
|
|
13
|
+
from ..interfaces.hypervisor import HypervisorBackend, VMInfo
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LibvirtBackend(HypervisorBackend):
|
|
17
|
+
"""libvirt hypervisor backend."""
|
|
18
|
+
|
|
19
|
+
name = "libvirt"
|
|
20
|
+
|
|
21
|
+
def __init__(
|
|
22
|
+
self,
|
|
23
|
+
uri: Optional[str] = None,
|
|
24
|
+
user_session: bool = False,
|
|
25
|
+
):
|
|
26
|
+
self.uri = uri or ("qemu:///session" if user_session else "qemu:///system")
|
|
27
|
+
self._conn: Optional[libvirt.virConnect] = None
|
|
28
|
+
|
|
29
|
+
def connect(self) -> None:
|
|
30
|
+
"""Establish connection to libvirt."""
|
|
31
|
+
if self._conn is not None:
|
|
32
|
+
try:
|
|
33
|
+
if self._conn.isAlive():
|
|
34
|
+
return
|
|
35
|
+
except libvirt.libvirtError:
|
|
36
|
+
pass
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
self._conn = libvirt.open(self.uri)
|
|
40
|
+
except libvirt.libvirtError as e:
|
|
41
|
+
raise ConnectionError(f"Failed to connect to libvirt at {self.uri}: {e}")
|
|
42
|
+
|
|
43
|
+
def disconnect(self) -> None:
|
|
44
|
+
"""Close connection."""
|
|
45
|
+
if self._conn:
|
|
46
|
+
try:
|
|
47
|
+
self._conn.close()
|
|
48
|
+
except libvirt.libvirtError:
|
|
49
|
+
pass
|
|
50
|
+
self._conn = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def conn(self) -> libvirt.virConnect:
|
|
54
|
+
"""Get active libvirt connection."""
|
|
55
|
+
if self._conn is None:
|
|
56
|
+
self.connect()
|
|
57
|
+
return self._conn
|
|
58
|
+
|
|
59
|
+
def define_vm(self, config: Any) -> str:
|
|
60
|
+
"""Define a new VM. Returns VM name."""
|
|
61
|
+
# This normally takes XML, but for DI we might want to pass config
|
|
62
|
+
# and let the backend handle XML generation if it's backend-specific.
|
|
63
|
+
# For now, we assume the caller provides the XML via config.xml or similar
|
|
64
|
+
# if they want to use this generic interface, or we refactor cloner to use backend.
|
|
65
|
+
if hasattr(config, "xml"):
|
|
66
|
+
domain = self.conn.defineXML(config.xml)
|
|
67
|
+
return domain.name()
|
|
68
|
+
raise ValueError("Config must provide XML for libvirt backend")
|
|
69
|
+
|
|
70
|
+
def undefine_vm(self, name: str) -> None:
|
|
71
|
+
"""Remove VM definition."""
|
|
72
|
+
try:
|
|
73
|
+
domain = self.conn.lookupByName(name)
|
|
74
|
+
if domain.isActive():
|
|
75
|
+
domain.destroy()
|
|
76
|
+
domain.undefine()
|
|
77
|
+
except libvirt.libvirtError as e:
|
|
78
|
+
if "not found" not in str(e).lower():
|
|
79
|
+
raise
|
|
80
|
+
|
|
81
|
+
def start_vm(self, name: str) -> None:
|
|
82
|
+
"""Start a VM."""
|
|
83
|
+
domain = self.conn.lookupByName(name)
|
|
84
|
+
if not domain.isActive():
|
|
85
|
+
domain.create()
|
|
86
|
+
|
|
87
|
+
def stop_vm(self, name: str, force: bool = False) -> None:
|
|
88
|
+
"""Stop a VM."""
|
|
89
|
+
domain = self.conn.lookupByName(name)
|
|
90
|
+
if domain.isActive():
|
|
91
|
+
if force:
|
|
92
|
+
domain.destroy()
|
|
93
|
+
else:
|
|
94
|
+
domain.shutdown()
|
|
95
|
+
|
|
96
|
+
def get_vm_info(self, name: str) -> Optional[VMInfo]:
|
|
97
|
+
"""Get VM information."""
|
|
98
|
+
try:
|
|
99
|
+
domain = self.conn.lookupByName(name)
|
|
100
|
+
except libvirt.libvirtError:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
info = domain.info()
|
|
104
|
+
state_map = {
|
|
105
|
+
libvirt.VIR_DOMAIN_RUNNING: "running",
|
|
106
|
+
libvirt.VIR_DOMAIN_BLOCKED: "blocked",
|
|
107
|
+
libvirt.VIR_DOMAIN_PAUSED: "paused",
|
|
108
|
+
libvirt.VIR_DOMAIN_SHUTDOWN: "shutdown",
|
|
109
|
+
libvirt.VIR_DOMAIN_SHUTOFF: "shutoff",
|
|
110
|
+
libvirt.VIR_DOMAIN_CRASHED: "crashed",
|
|
111
|
+
libvirt.VIR_DOMAIN_PMSUSPENDED: "pmsuspended",
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return VMInfo(
|
|
115
|
+
name=domain.name(),
|
|
116
|
+
state=state_map.get(info[0], "unknown"),
|
|
117
|
+
uuid=domain.UUIDString(),
|
|
118
|
+
memory_mb=info[1] // 1024,
|
|
119
|
+
vcpus=info[3],
|
|
120
|
+
ip_addresses=self._get_ip_addresses(domain),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def list_vms(self) -> List[VMInfo]:
|
|
124
|
+
"""List all VMs."""
|
|
125
|
+
vms = []
|
|
126
|
+
try:
|
|
127
|
+
# List all domains (running and defined)
|
|
128
|
+
for domain in self.conn.listAllDomains():
|
|
129
|
+
info = self.get_vm_info(domain.name())
|
|
130
|
+
if info:
|
|
131
|
+
vms.append(info)
|
|
132
|
+
except libvirt.libvirtError:
|
|
133
|
+
pass
|
|
134
|
+
return vms
|
|
135
|
+
|
|
136
|
+
def vm_exists(self, name: str) -> bool:
|
|
137
|
+
"""Check if VM exists."""
|
|
138
|
+
try:
|
|
139
|
+
self.conn.lookupByName(name)
|
|
140
|
+
return True
|
|
141
|
+
except libvirt.libvirtError:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
def is_running(self, name: str) -> bool:
|
|
145
|
+
"""Check if VM is running."""
|
|
146
|
+
try:
|
|
147
|
+
domain = self.conn.lookupByName(name)
|
|
148
|
+
return domain.isActive() == 1
|
|
149
|
+
except libvirt.libvirtError:
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
def execute_command(
|
|
153
|
+
self,
|
|
154
|
+
name: str,
|
|
155
|
+
command: str,
|
|
156
|
+
timeout: int = 30,
|
|
157
|
+
) -> Optional[str]:
|
|
158
|
+
"""Execute command in VM via QEMU Guest Agent."""
|
|
159
|
+
domain = self.conn.lookupByName(name)
|
|
160
|
+
|
|
161
|
+
# Build QGA guest-exec command
|
|
162
|
+
qga_cmd = {
|
|
163
|
+
"execute": "guest-exec",
|
|
164
|
+
"arguments": {
|
|
165
|
+
"path": "/bin/bash",
|
|
166
|
+
"arg": ["-c", command],
|
|
167
|
+
"capture-output": True,
|
|
168
|
+
},
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
try:
|
|
172
|
+
result_json = domain.qemuAgentCommand(json.dumps(qga_cmd), timeout)
|
|
173
|
+
result_data = json.loads(result_json)
|
|
174
|
+
pid = result_data["return"]["pid"]
|
|
175
|
+
|
|
176
|
+
# Poll for completion
|
|
177
|
+
status_cmd = {"execute": "guest-exec-status", "arguments": {"pid": pid}}
|
|
178
|
+
start_time = time.time()
|
|
179
|
+
while time.time() - start_time < timeout:
|
|
180
|
+
status_json = domain.qemuAgentCommand(json.dumps(status_cmd), timeout)
|
|
181
|
+
status_data = json.loads(status_json)
|
|
182
|
+
|
|
183
|
+
if status_data["return"]["exited"]:
|
|
184
|
+
if "out-data" in status_data["return"]:
|
|
185
|
+
return base64.b64decode(status_data["return"]["out-data"]).decode(
|
|
186
|
+
"utf-8", errors="replace"
|
|
187
|
+
)
|
|
188
|
+
return ""
|
|
189
|
+
|
|
190
|
+
time.sleep(0.5)
|
|
191
|
+
|
|
192
|
+
return None # Timeout
|
|
193
|
+
|
|
194
|
+
except Exception:
|
|
195
|
+
return None
|
|
196
|
+
|
|
197
|
+
def _get_ip_addresses(self, domain) -> List[str]:
|
|
198
|
+
"""Get IP addresses from domain via guest agent or lease."""
|
|
199
|
+
ips = []
|
|
200
|
+
try:
|
|
201
|
+
# Try guest agent first (more accurate)
|
|
202
|
+
ifaces = domain.interfaceAddresses(libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_AGENT)
|
|
203
|
+
for iface_data in ifaces.values():
|
|
204
|
+
for addr in iface_data.get("addrs", []):
|
|
205
|
+
if addr["type"] == 0: # IPv4
|
|
206
|
+
ips.append(addr["addr"])
|
|
207
|
+
except libvirt.libvirtError:
|
|
208
|
+
# Fallback to network leases
|
|
209
|
+
try:
|
|
210
|
+
ifaces = domain.interfaceAddresses(libvirt.VIR_DOMAIN_INTERFACE_ADDRESSES_SRC_LEASE)
|
|
211
|
+
for iface_data in ifaces.values():
|
|
212
|
+
for addr in iface_data.get("addrs", []):
|
|
213
|
+
if addr["type"] == 0:
|
|
214
|
+
ips.append(addr["addr"])
|
|
215
|
+
except libvirt.libvirtError:
|
|
216
|
+
pass
|
|
217
|
+
return ips
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""QEMU disk manager implementation."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Dict, Optional
|
|
6
|
+
|
|
7
|
+
from ..interfaces.disk import DiskManager
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class QemuDiskManager(DiskManager):
|
|
11
|
+
"""Manage VM disks using qemu-img."""
|
|
12
|
+
|
|
13
|
+
def create_disk(
|
|
14
|
+
self,
|
|
15
|
+
path: Path,
|
|
16
|
+
size_gb: int,
|
|
17
|
+
format: str = "qcow2",
|
|
18
|
+
backing_file: Optional[Path] = None,
|
|
19
|
+
) -> Path:
|
|
20
|
+
"""Create a disk image."""
|
|
21
|
+
cmd = ["qemu-img", "create", "-f", format]
|
|
22
|
+
|
|
23
|
+
if backing_file:
|
|
24
|
+
cmd.extend(["-b", str(backing_file), "-F", format])
|
|
25
|
+
|
|
26
|
+
cmd.extend([str(path), f"{size_gb}G"])
|
|
27
|
+
|
|
28
|
+
subprocess.run(cmd, check=True, capture_output=True)
|
|
29
|
+
return path
|
|
30
|
+
|
|
31
|
+
def resize_disk(self, path: Path, new_size_gb: int) -> None:
|
|
32
|
+
"""Resize a disk image."""
|
|
33
|
+
cmd = ["qemu-img", "resize", str(path), f"{new_size_gb}G"]
|
|
34
|
+
subprocess.run(cmd, check=True, capture_output=True)
|
|
35
|
+
|
|
36
|
+
def get_disk_info(self, path: Path) -> Dict[str, Any]:
|
|
37
|
+
"""Get disk image information."""
|
|
38
|
+
import json
|
|
39
|
+
cmd = ["qemu-img", "info", "--output=json", str(path)]
|
|
40
|
+
result = subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
41
|
+
return json.loads(result.stdout)
|
|
42
|
+
|
|
43
|
+
def create_snapshot(self, path: Path, snapshot_name: str) -> Path:
|
|
44
|
+
"""Create internal disk snapshot."""
|
|
45
|
+
cmd = ["qemu-img", "snapshot", "-c", snapshot_name, str(path)]
|
|
46
|
+
subprocess.run(cmd, check=True, capture_output=True)
|
|
47
|
+
return path
|
|
48
|
+
|
|
49
|
+
def delete_disk(self, path: Path) -> None:
|
|
50
|
+
"""Delete disk image."""
|
|
51
|
+
if path.exists():
|
|
52
|
+
path.unlink()
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Subprocess process runner implementation."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Dict, List, Optional
|
|
6
|
+
|
|
7
|
+
from ..interfaces.process import ProcessResult, ProcessRunner
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class SubprocessRunner(ProcessRunner):
|
|
11
|
+
"""Run processes using the subprocess module."""
|
|
12
|
+
|
|
13
|
+
def run(
|
|
14
|
+
self,
|
|
15
|
+
command: List[str],
|
|
16
|
+
capture_output: bool = True,
|
|
17
|
+
timeout: Optional[int] = None,
|
|
18
|
+
check: bool = True,
|
|
19
|
+
cwd: Optional[Path] = None,
|
|
20
|
+
env: Optional[Dict[str, str]] = None,
|
|
21
|
+
) -> ProcessResult:
|
|
22
|
+
"""Run a command."""
|
|
23
|
+
result = subprocess.run(
|
|
24
|
+
command,
|
|
25
|
+
capture_output=capture_output,
|
|
26
|
+
timeout=timeout,
|
|
27
|
+
check=check,
|
|
28
|
+
cwd=str(cwd) if cwd else None,
|
|
29
|
+
env=env,
|
|
30
|
+
text=True,
|
|
31
|
+
)
|
|
32
|
+
return ProcessResult(
|
|
33
|
+
returncode=result.returncode,
|
|
34
|
+
stdout=result.stdout,
|
|
35
|
+
stderr=result.stderr,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def run_shell(
|
|
39
|
+
self,
|
|
40
|
+
command: str,
|
|
41
|
+
capture_output: bool = True,
|
|
42
|
+
timeout: Optional[int] = None,
|
|
43
|
+
) -> ProcessResult:
|
|
44
|
+
"""Run a shell command."""
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
command,
|
|
47
|
+
shell=True,
|
|
48
|
+
capture_output=capture_output,
|
|
49
|
+
timeout=timeout,
|
|
50
|
+
text=True,
|
|
51
|
+
)
|
|
52
|
+
return ProcessResult(
|
|
53
|
+
returncode=result.returncode,
|
|
54
|
+
stdout=result.stdout,
|
|
55
|
+
stderr=result.stderr,
|
|
56
|
+
)
|
clonebox/cli.py
CHANGED
|
@@ -32,6 +32,8 @@ from clonebox.exporter import SecureExporter, VMExporter
|
|
|
32
32
|
from clonebox.importer import SecureImporter, VMImporter
|
|
33
33
|
from clonebox.monitor import ResourceMonitor, format_bytes
|
|
34
34
|
from clonebox.p2p import P2PManager
|
|
35
|
+
from clonebox.snapshots import SnapshotManager, SnapshotType
|
|
36
|
+
from clonebox.health import HealthCheckManager, ProbeConfig, ProbeType
|
|
35
37
|
|
|
36
38
|
# Custom questionary style
|
|
37
39
|
custom_style = Style(
|
|
@@ -2670,7 +2672,11 @@ def cmd_monitor(args) -> None:
|
|
|
2670
2672
|
for vm in vm_stats:
|
|
2671
2673
|
state_color = "green" if vm.state == "running" else "yellow"
|
|
2672
2674
|
cpu_color = "red" if vm.cpu_percent > 80 else "green"
|
|
2673
|
-
mem_pct = (
|
|
2675
|
+
mem_pct = (
|
|
2676
|
+
(vm.memory_used_mb / vm.memory_total_mb * 100)
|
|
2677
|
+
if vm.memory_total_mb > 0
|
|
2678
|
+
else 0
|
|
2679
|
+
)
|
|
2674
2680
|
mem_color = "red" if mem_pct > 80 else "green"
|
|
2675
2681
|
|
|
2676
2682
|
table.add_row(
|
|
@@ -2700,7 +2706,9 @@ def cmd_monitor(args) -> None:
|
|
|
2700
2706
|
|
|
2701
2707
|
for c in container_stats:
|
|
2702
2708
|
cpu_color = "red" if c.cpu_percent > 80 else "green"
|
|
2703
|
-
mem_pct = (
|
|
2709
|
+
mem_pct = (
|
|
2710
|
+
(c.memory_used_mb / c.memory_limit_mb * 100) if c.memory_limit_mb > 0 else 0
|
|
2711
|
+
)
|
|
2704
2712
|
mem_color = "red" if mem_pct > 80 else "green"
|
|
2705
2713
|
|
|
2706
2714
|
table.add_row(
|
|
@@ -2751,6 +2759,154 @@ def cmd_exec(args) -> None:
|
|
|
2751
2759
|
console.print(result)
|
|
2752
2760
|
|
|
2753
2761
|
|
|
2762
|
+
def cmd_snapshot_create(args) -> None:
|
|
2763
|
+
"""Create VM snapshot."""
|
|
2764
|
+
vm_name, config_file = _resolve_vm_name_and_config_file(args.vm_name)
|
|
2765
|
+
conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
|
|
2766
|
+
|
|
2767
|
+
snap_name = args.name or f"snap-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
|
2768
|
+
description = getattr(args, "description", None)
|
|
2769
|
+
|
|
2770
|
+
console.print(f"[cyan]📸 Creating snapshot: {snap_name}[/]")
|
|
2771
|
+
|
|
2772
|
+
try:
|
|
2773
|
+
manager = SnapshotManager(conn_uri)
|
|
2774
|
+
snapshot = manager.create(
|
|
2775
|
+
vm_name=vm_name,
|
|
2776
|
+
name=snap_name,
|
|
2777
|
+
description=description,
|
|
2778
|
+
snapshot_type=SnapshotType.DISK_ONLY,
|
|
2779
|
+
)
|
|
2780
|
+
console.print(f"[green]✅ Snapshot created: {snapshot.name}[/]")
|
|
2781
|
+
except Exception as e:
|
|
2782
|
+
console.print(f"[red]❌ Failed to create snapshot: {e}[/]")
|
|
2783
|
+
finally:
|
|
2784
|
+
manager.close()
|
|
2785
|
+
|
|
2786
|
+
|
|
2787
|
+
def cmd_snapshot_list(args) -> None:
|
|
2788
|
+
"""List VM snapshots."""
|
|
2789
|
+
vm_name, config_file = _resolve_vm_name_and_config_file(args.vm_name)
|
|
2790
|
+
conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
|
|
2791
|
+
|
|
2792
|
+
try:
|
|
2793
|
+
manager = SnapshotManager(conn_uri)
|
|
2794
|
+
snapshots = manager.list(vm_name)
|
|
2795
|
+
|
|
2796
|
+
if not snapshots:
|
|
2797
|
+
console.print("[dim]No snapshots found.[/]")
|
|
2798
|
+
return
|
|
2799
|
+
|
|
2800
|
+
table = Table(title=f"📸 Snapshots for {vm_name}", border_style="cyan")
|
|
2801
|
+
table.add_column("Name", style="bold")
|
|
2802
|
+
table.add_column("Created")
|
|
2803
|
+
table.add_column("Type")
|
|
2804
|
+
table.add_column("Description")
|
|
2805
|
+
|
|
2806
|
+
for snap in snapshots:
|
|
2807
|
+
table.add_row(
|
|
2808
|
+
snap.name,
|
|
2809
|
+
snap.created_at.strftime("%Y-%m-%d %H:%M"),
|
|
2810
|
+
snap.snapshot_type.value,
|
|
2811
|
+
snap.description or "-",
|
|
2812
|
+
)
|
|
2813
|
+
|
|
2814
|
+
console.print(table)
|
|
2815
|
+
except Exception as e:
|
|
2816
|
+
console.print(f"[red]❌ Error: {e}[/]")
|
|
2817
|
+
finally:
|
|
2818
|
+
manager.close()
|
|
2819
|
+
|
|
2820
|
+
|
|
2821
|
+
def cmd_snapshot_restore(args) -> None:
|
|
2822
|
+
"""Restore VM to snapshot."""
|
|
2823
|
+
vm_name, config_file = _resolve_vm_name_and_config_file(args.vm_name)
|
|
2824
|
+
conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
|
|
2825
|
+
|
|
2826
|
+
console.print(f"[cyan]🔄 Restoring snapshot: {args.name}[/]")
|
|
2827
|
+
|
|
2828
|
+
try:
|
|
2829
|
+
manager = SnapshotManager(conn_uri)
|
|
2830
|
+
manager.restore(vm_name, args.name, force=getattr(args, "force", False))
|
|
2831
|
+
console.print(f"[green]✅ Restored to snapshot: {args.name}[/]")
|
|
2832
|
+
except Exception as e:
|
|
2833
|
+
console.print(f"[red]❌ Failed to restore: {e}[/]")
|
|
2834
|
+
finally:
|
|
2835
|
+
manager.close()
|
|
2836
|
+
|
|
2837
|
+
|
|
2838
|
+
def cmd_snapshot_delete(args) -> None:
|
|
2839
|
+
"""Delete VM snapshot."""
|
|
2840
|
+
vm_name, config_file = _resolve_vm_name_and_config_file(args.vm_name)
|
|
2841
|
+
conn_uri = "qemu:///session" if getattr(args, "user", False) else "qemu:///system"
|
|
2842
|
+
|
|
2843
|
+
console.print(f"[cyan]🗑️ Deleting snapshot: {args.name}[/]")
|
|
2844
|
+
|
|
2845
|
+
try:
|
|
2846
|
+
manager = SnapshotManager(conn_uri)
|
|
2847
|
+
manager.delete(vm_name, args.name)
|
|
2848
|
+
console.print(f"[green]✅ Snapshot deleted: {args.name}[/]")
|
|
2849
|
+
except Exception as e:
|
|
2850
|
+
console.print(f"[red]❌ Failed to delete: {e}[/]")
|
|
2851
|
+
finally:
|
|
2852
|
+
manager.close()
|
|
2853
|
+
|
|
2854
|
+
|
|
2855
|
+
def cmd_health(args) -> None:
|
|
2856
|
+
"""Run health checks for VM."""
|
|
2857
|
+
vm_name, config_file = _resolve_vm_name_and_config_file(args.name)
|
|
2858
|
+
|
|
2859
|
+
console.print(f"[cyan]🏥 Running health checks for: {vm_name}[/]")
|
|
2860
|
+
|
|
2861
|
+
manager = HealthCheckManager()
|
|
2862
|
+
|
|
2863
|
+
# Load probes from config or use defaults
|
|
2864
|
+
probes = []
|
|
2865
|
+
if config_file and config_file.exists():
|
|
2866
|
+
import yaml
|
|
2867
|
+
|
|
2868
|
+
config = yaml.safe_load(config_file.read_text())
|
|
2869
|
+
health_checks = config.get("health_checks", [])
|
|
2870
|
+
for hc in health_checks:
|
|
2871
|
+
probes.append(ProbeConfig.from_dict(hc))
|
|
2872
|
+
|
|
2873
|
+
# Also create probes for services
|
|
2874
|
+
services = config.get("services", [])
|
|
2875
|
+
if services:
|
|
2876
|
+
probes.extend(manager.create_default_probes(services))
|
|
2877
|
+
|
|
2878
|
+
if not probes:
|
|
2879
|
+
console.print(
|
|
2880
|
+
"[yellow]No health checks configured. Add 'health_checks' to .clonebox.yaml[/]"
|
|
2881
|
+
)
|
|
2882
|
+
return
|
|
2883
|
+
|
|
2884
|
+
state = manager.check(vm_name, probes)
|
|
2885
|
+
|
|
2886
|
+
# Display results
|
|
2887
|
+
status_color = "green" if state.overall_status.value == "healthy" else "red"
|
|
2888
|
+
console.print(
|
|
2889
|
+
f"\n[bold]Overall Status:[/] [{status_color}]{state.overall_status.value.upper()}[/]"
|
|
2890
|
+
)
|
|
2891
|
+
|
|
2892
|
+
table = Table(title="Health Check Results", border_style="cyan")
|
|
2893
|
+
table.add_column("Probe", style="bold")
|
|
2894
|
+
table.add_column("Status")
|
|
2895
|
+
table.add_column("Duration")
|
|
2896
|
+
table.add_column("Message")
|
|
2897
|
+
|
|
2898
|
+
for result in state.check_results:
|
|
2899
|
+
status_color = "green" if result.is_healthy else "red"
|
|
2900
|
+
table.add_row(
|
|
2901
|
+
result.probe_name,
|
|
2902
|
+
f"[{status_color}]{result.status.value}[/]",
|
|
2903
|
+
f"{result.duration_ms:.0f}ms",
|
|
2904
|
+
result.message or result.error or "-",
|
|
2905
|
+
)
|
|
2906
|
+
|
|
2907
|
+
console.print(table)
|
|
2908
|
+
|
|
2909
|
+
|
|
2754
2910
|
def cmd_keygen(args) -> None:
|
|
2755
2911
|
"""Generate encryption key for secure P2P transfers."""
|
|
2756
2912
|
key_path = SecureExporter.generate_key()
|
|
@@ -3319,9 +3475,7 @@ def main():
|
|
|
3319
3475
|
monitor_parser.add_argument(
|
|
3320
3476
|
"--refresh", "-r", type=float, default=2.0, help="Refresh interval in seconds (default: 2)"
|
|
3321
3477
|
)
|
|
3322
|
-
monitor_parser.add_argument(
|
|
3323
|
-
"--once", action="store_true", help="Show stats once and exit"
|
|
3324
|
-
)
|
|
3478
|
+
monitor_parser.add_argument("--once", action="store_true", help="Show stats once and exit")
|
|
3325
3479
|
monitor_parser.set_defaults(func=cmd_monitor)
|
|
3326
3480
|
|
|
3327
3481
|
# Exec command - execute command in VM
|
|
@@ -3329,9 +3483,7 @@ def main():
|
|
|
3329
3483
|
exec_parser.add_argument(
|
|
3330
3484
|
"name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
|
|
3331
3485
|
)
|
|
3332
|
-
exec_parser.add_argument(
|
|
3333
|
-
"command", help="Command to execute in VM"
|
|
3334
|
-
)
|
|
3486
|
+
exec_parser.add_argument("command", help="Command to execute in VM")
|
|
3335
3487
|
exec_parser.add_argument(
|
|
3336
3488
|
"-u", "--user", action="store_true", help="Use user session (qemu:///session)"
|
|
3337
3489
|
)
|
|
@@ -3340,10 +3492,51 @@ def main():
|
|
|
3340
3492
|
)
|
|
3341
3493
|
exec_parser.set_defaults(func=cmd_exec)
|
|
3342
3494
|
|
|
3495
|
+
# === Snapshot Commands ===
|
|
3496
|
+
snapshot_parser = subparsers.add_parser("snapshot", help="Manage VM snapshots")
|
|
3497
|
+
snapshot_sub = snapshot_parser.add_subparsers(dest="snapshot_command", help="Snapshot commands")
|
|
3498
|
+
|
|
3499
|
+
snap_create = snapshot_sub.add_parser("create", help="Create snapshot")
|
|
3500
|
+
snap_create.add_argument("vm_name", help="VM name or '.' to use .clonebox.yaml")
|
|
3501
|
+
snap_create.add_argument("--name", "-n", help="Snapshot name (auto-generated if not provided)")
|
|
3502
|
+
snap_create.add_argument("--description", "-d", help="Snapshot description")
|
|
3503
|
+
snap_create.add_argument("-u", "--user", action="store_true", help="Use user session")
|
|
3504
|
+
snap_create.set_defaults(func=cmd_snapshot_create)
|
|
3505
|
+
|
|
3506
|
+
snap_list = snapshot_sub.add_parser("list", aliases=["ls"], help="List snapshots")
|
|
3507
|
+
snap_list.add_argument("vm_name", help="VM name or '.' to use .clonebox.yaml")
|
|
3508
|
+
snap_list.add_argument("-u", "--user", action="store_true", help="Use user session")
|
|
3509
|
+
snap_list.set_defaults(func=cmd_snapshot_list)
|
|
3510
|
+
|
|
3511
|
+
snap_restore = snapshot_sub.add_parser("restore", help="Restore to snapshot")
|
|
3512
|
+
snap_restore.add_argument("vm_name", help="VM name or '.' to use .clonebox.yaml")
|
|
3513
|
+
snap_restore.add_argument("--name", "-n", required=True, help="Snapshot name to restore")
|
|
3514
|
+
snap_restore.add_argument("-u", "--user", action="store_true", help="Use user session")
|
|
3515
|
+
snap_restore.add_argument(
|
|
3516
|
+
"-f", "--force", action="store_true", help="Force restore even if running"
|
|
3517
|
+
)
|
|
3518
|
+
snap_restore.set_defaults(func=cmd_snapshot_restore)
|
|
3519
|
+
|
|
3520
|
+
snap_delete = snapshot_sub.add_parser("delete", aliases=["rm"], help="Delete snapshot")
|
|
3521
|
+
snap_delete.add_argument("vm_name", help="VM name or '.' to use .clonebox.yaml")
|
|
3522
|
+
snap_delete.add_argument("--name", "-n", required=True, help="Snapshot name to delete")
|
|
3523
|
+
snap_delete.add_argument("-u", "--user", action="store_true", help="Use user session")
|
|
3524
|
+
snap_delete.set_defaults(func=cmd_snapshot_delete)
|
|
3525
|
+
|
|
3526
|
+
# === Health Check Commands ===
|
|
3527
|
+
health_parser = subparsers.add_parser("health", help="Run health checks for VM")
|
|
3528
|
+
health_parser.add_argument(
|
|
3529
|
+
"name", nargs="?", default=None, help="VM name or '.' to use .clonebox.yaml"
|
|
3530
|
+
)
|
|
3531
|
+
health_parser.add_argument("-u", "--user", action="store_true", help="Use user session")
|
|
3532
|
+
health_parser.set_defaults(func=cmd_health)
|
|
3533
|
+
|
|
3343
3534
|
# === P2P Secure Transfer Commands ===
|
|
3344
3535
|
|
|
3345
3536
|
# Keygen command - generate encryption key
|
|
3346
|
-
keygen_parser = subparsers.add_parser(
|
|
3537
|
+
keygen_parser = subparsers.add_parser(
|
|
3538
|
+
"keygen", help="Generate encryption key for secure transfers"
|
|
3539
|
+
)
|
|
3347
3540
|
keygen_parser.set_defaults(func=cmd_keygen)
|
|
3348
3541
|
|
|
3349
3542
|
# Export-encrypted command
|
|
@@ -3356,9 +3549,7 @@ def main():
|
|
|
3356
3549
|
export_enc_parser.add_argument(
|
|
3357
3550
|
"-u", "--user", action="store_true", help="Use user session (qemu:///session)"
|
|
3358
3551
|
)
|
|
3359
|
-
export_enc_parser.add_argument(
|
|
3360
|
-
"-o", "--output", help="Output file (default: <vmname>.enc)"
|
|
3361
|
-
)
|
|
3552
|
+
export_enc_parser.add_argument("-o", "--output", help="Output file (default: <vmname>.enc)")
|
|
3362
3553
|
export_enc_parser.add_argument(
|
|
3363
3554
|
"--user-data", action="store_true", help="Include user data (SSH keys, configs)"
|
|
3364
3555
|
)
|
|
@@ -3376,9 +3567,7 @@ def main():
|
|
|
3376
3567
|
"-u", "--user", action="store_true", help="Use user session (qemu:///session)"
|
|
3377
3568
|
)
|
|
3378
3569
|
import_enc_parser.add_argument("--name", "-n", help="New name for imported VM")
|
|
3379
|
-
import_enc_parser.add_argument(
|
|
3380
|
-
"--user-data", action="store_true", help="Import user data"
|
|
3381
|
-
)
|
|
3570
|
+
import_enc_parser.add_argument("--user-data", action="store_true", help="Import user data")
|
|
3382
3571
|
import_enc_parser.add_argument(
|
|
3383
3572
|
"--include-data", "-d", action="store_true", help="Import app data"
|
|
3384
3573
|
)
|
|
@@ -3390,15 +3579,11 @@ def main():
|
|
|
3390
3579
|
)
|
|
3391
3580
|
export_remote_parser.add_argument("host", help="Remote host (user@hostname)")
|
|
3392
3581
|
export_remote_parser.add_argument("vm_name", help="VM name on remote host")
|
|
3393
|
-
export_remote_parser.add_argument(
|
|
3394
|
-
"-o", "--output", required=True, help="Local output file"
|
|
3395
|
-
)
|
|
3582
|
+
export_remote_parser.add_argument("-o", "--output", required=True, help="Local output file")
|
|
3396
3583
|
export_remote_parser.add_argument(
|
|
3397
3584
|
"--encrypted", "-e", action="store_true", help="Use encrypted export"
|
|
3398
3585
|
)
|
|
3399
|
-
export_remote_parser.add_argument(
|
|
3400
|
-
"--user-data", action="store_true", help="Include user data"
|
|
3401
|
-
)
|
|
3586
|
+
export_remote_parser.add_argument("--user-data", action="store_true", help="Include user data")
|
|
3402
3587
|
export_remote_parser.add_argument(
|
|
3403
3588
|
"--include-data", "-d", action="store_true", help="Include app data"
|
|
3404
3589
|
)
|
|
@@ -3414,22 +3599,16 @@ def main():
|
|
|
3414
3599
|
import_remote_parser.add_argument(
|
|
3415
3600
|
"--encrypted", "-e", action="store_true", help="Use encrypted import"
|
|
3416
3601
|
)
|
|
3417
|
-
import_remote_parser.add_argument(
|
|
3418
|
-
"--user-data", action="store_true", help="Import user data"
|
|
3419
|
-
)
|
|
3602
|
+
import_remote_parser.add_argument("--user-data", action="store_true", help="Import user data")
|
|
3420
3603
|
import_remote_parser.set_defaults(func=cmd_import_remote)
|
|
3421
3604
|
|
|
3422
3605
|
# Sync-key command
|
|
3423
|
-
sync_key_parser = subparsers.add_parser(
|
|
3424
|
-
"sync-key", help="Sync encryption key to remote host"
|
|
3425
|
-
)
|
|
3606
|
+
sync_key_parser = subparsers.add_parser("sync-key", help="Sync encryption key to remote host")
|
|
3426
3607
|
sync_key_parser.add_argument("host", help="Remote host (user@hostname)")
|
|
3427
3608
|
sync_key_parser.set_defaults(func=cmd_sync_key)
|
|
3428
3609
|
|
|
3429
3610
|
# List-remote command
|
|
3430
|
-
list_remote_parser = subparsers.add_parser(
|
|
3431
|
-
"list-remote", help="List VMs on remote host"
|
|
3432
|
-
)
|
|
3611
|
+
list_remote_parser = subparsers.add_parser("list-remote", help="List VMs on remote host")
|
|
3433
3612
|
list_remote_parser.add_argument("host", help="Remote host (user@hostname)")
|
|
3434
3613
|
list_remote_parser.set_defaults(func=cmd_list_remote)
|
|
3435
3614
|
|