clonebox 1.1.2__py3-none-any.whl → 1.1.4__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/cli.py +359 -0
- clonebox/exporter.py +189 -0
- clonebox/health/__init__.py +16 -0
- clonebox/health/models.py +194 -0
- clonebox/importer.py +220 -0
- clonebox/monitor.py +269 -0
- clonebox/p2p.py +184 -0
- clonebox/snapshots/__init__.py +12 -0
- clonebox/snapshots/manager.py +355 -0
- clonebox/snapshots/models.py +187 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.4.dist-info}/METADATA +28 -2
- clonebox-1.1.4.dist-info/RECORD +27 -0
- clonebox-1.1.2.dist-info/RECORD +0 -18
- {clonebox-1.1.2.dist-info → clonebox-1.1.4.dist-info}/WHEEL +0 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.4.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.4.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.2.dist-info → clonebox-1.1.4.dist-info}/top_level.txt +0 -0
clonebox/p2p.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
P2P Manager - Transfer VMs between workstations via SSH/SCP.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class P2PManager:
|
|
13
|
+
"""Manage P2P VM transfers between workstations."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, ssh_options: Optional[list] = None):
|
|
16
|
+
self.ssh_options = ssh_options or [
|
|
17
|
+
"-o", "StrictHostKeyChecking=no",
|
|
18
|
+
"-o", "UserKnownHostsFile=/dev/null",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
def _run_ssh(self, host: str, command: str) -> subprocess.CompletedProcess:
|
|
22
|
+
"""Execute command on remote host via SSH."""
|
|
23
|
+
cmd = ["ssh"] + self.ssh_options + [host, command]
|
|
24
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
|
25
|
+
|
|
26
|
+
def _run_scp(
|
|
27
|
+
self,
|
|
28
|
+
source: str,
|
|
29
|
+
destination: str,
|
|
30
|
+
recursive: bool = False,
|
|
31
|
+
) -> subprocess.CompletedProcess:
|
|
32
|
+
"""Copy files via SCP."""
|
|
33
|
+
cmd = ["scp"] + self.ssh_options
|
|
34
|
+
if recursive:
|
|
35
|
+
cmd.append("-r")
|
|
36
|
+
cmd.extend([source, destination])
|
|
37
|
+
return subprocess.run(cmd, capture_output=True, text=True)
|
|
38
|
+
|
|
39
|
+
def export_remote(
|
|
40
|
+
self,
|
|
41
|
+
host: str,
|
|
42
|
+
vm_name: str,
|
|
43
|
+
output: Path,
|
|
44
|
+
encrypted: bool = False,
|
|
45
|
+
include_user_data: bool = False,
|
|
46
|
+
include_app_data: bool = False,
|
|
47
|
+
) -> Path:
|
|
48
|
+
"""Export VM from remote host to local file.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
host: Remote host in format user@hostname
|
|
52
|
+
vm_name: Name of VM to export
|
|
53
|
+
output: Local output path
|
|
54
|
+
encrypted: Use encrypted export
|
|
55
|
+
include_user_data: Include user data
|
|
56
|
+
include_app_data: Include app data
|
|
57
|
+
"""
|
|
58
|
+
remote_tmp = f"/tmp/clonebox-{vm_name}.tar.gz"
|
|
59
|
+
if encrypted:
|
|
60
|
+
remote_tmp = f"/tmp/clonebox-{vm_name}.enc"
|
|
61
|
+
|
|
62
|
+
# Build export command
|
|
63
|
+
export_cmd = f"clonebox export {vm_name} -o {remote_tmp}"
|
|
64
|
+
if encrypted:
|
|
65
|
+
export_cmd = f"clonebox export-encrypted {vm_name} -o {remote_tmp}"
|
|
66
|
+
if include_user_data:
|
|
67
|
+
export_cmd += " --user"
|
|
68
|
+
if include_app_data:
|
|
69
|
+
export_cmd += " --include-data"
|
|
70
|
+
|
|
71
|
+
print(f"📤 Exporting {vm_name} from {host}...")
|
|
72
|
+
|
|
73
|
+
# Execute remote export
|
|
74
|
+
result = self._run_ssh(host, export_cmd)
|
|
75
|
+
if result.returncode != 0:
|
|
76
|
+
raise RuntimeError(f"Remote export failed: {result.stderr}")
|
|
77
|
+
|
|
78
|
+
# Download file
|
|
79
|
+
print(f"⬇️ Downloading to {output}...")
|
|
80
|
+
result = self._run_scp(f"{host}:{remote_tmp}", str(output))
|
|
81
|
+
if result.returncode != 0:
|
|
82
|
+
raise RuntimeError(f"SCP download failed: {result.stderr}")
|
|
83
|
+
|
|
84
|
+
# Cleanup remote temp file
|
|
85
|
+
self._run_ssh(host, f"rm -f {remote_tmp}")
|
|
86
|
+
|
|
87
|
+
print(f"✅ Downloaded: {output}")
|
|
88
|
+
return output
|
|
89
|
+
|
|
90
|
+
def import_remote(
|
|
91
|
+
self,
|
|
92
|
+
host: str,
|
|
93
|
+
archive_path: Path,
|
|
94
|
+
encrypted: bool = False,
|
|
95
|
+
import_user_data: bool = False,
|
|
96
|
+
new_name: Optional[str] = None,
|
|
97
|
+
) -> str:
|
|
98
|
+
"""Upload and import VM on remote host.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
host: Remote host in format user@hostname
|
|
102
|
+
archive_path: Local archive to upload
|
|
103
|
+
encrypted: Use decrypted import
|
|
104
|
+
import_user_data: Import user data
|
|
105
|
+
new_name: New name for VM on remote
|
|
106
|
+
"""
|
|
107
|
+
remote_tmp = f"/tmp/{archive_path.name}"
|
|
108
|
+
|
|
109
|
+
print(f"⬆️ Uploading {archive_path} to {host}...")
|
|
110
|
+
|
|
111
|
+
# Upload file
|
|
112
|
+
result = self._run_scp(str(archive_path), f"{host}:{remote_tmp}")
|
|
113
|
+
if result.returncode != 0:
|
|
114
|
+
raise RuntimeError(f"SCP upload failed: {result.stderr}")
|
|
115
|
+
|
|
116
|
+
# Build import command
|
|
117
|
+
if encrypted:
|
|
118
|
+
import_cmd = f"clonebox import-encrypted {remote_tmp}"
|
|
119
|
+
else:
|
|
120
|
+
import_cmd = f"clonebox import {remote_tmp}"
|
|
121
|
+
|
|
122
|
+
if import_user_data:
|
|
123
|
+
import_cmd += " --user"
|
|
124
|
+
if new_name:
|
|
125
|
+
import_cmd += f" --name {new_name}"
|
|
126
|
+
|
|
127
|
+
print(f"📥 Importing on {host}...")
|
|
128
|
+
|
|
129
|
+
# Execute remote import
|
|
130
|
+
result = self._run_ssh(host, import_cmd)
|
|
131
|
+
if result.returncode != 0:
|
|
132
|
+
raise RuntimeError(f"Remote import failed: {result.stderr}")
|
|
133
|
+
|
|
134
|
+
# Cleanup remote temp file
|
|
135
|
+
self._run_ssh(host, f"rm -f {remote_tmp}")
|
|
136
|
+
|
|
137
|
+
print(f"✅ Import complete on {host}")
|
|
138
|
+
return new_name or archive_path.stem
|
|
139
|
+
|
|
140
|
+
def sync_key(self, host: str) -> bool:
|
|
141
|
+
"""Sync encryption key to remote host.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
host: Remote host in format user@hostname
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
True if key was synced successfully
|
|
148
|
+
"""
|
|
149
|
+
key_path = Path.home() / ".clonebox.key"
|
|
150
|
+
if not key_path.exists():
|
|
151
|
+
raise FileNotFoundError(f"No local key found at {key_path}")
|
|
152
|
+
|
|
153
|
+
print(f"🔑 Syncing encryption key to {host}...")
|
|
154
|
+
|
|
155
|
+
result = self._run_scp(str(key_path), f"{host}:~/.clonebox.key")
|
|
156
|
+
if result.returncode != 0:
|
|
157
|
+
raise RuntimeError(f"Key sync failed: {result.stderr}")
|
|
158
|
+
|
|
159
|
+
# Set proper permissions on remote
|
|
160
|
+
self._run_ssh(host, "chmod 600 ~/.clonebox.key")
|
|
161
|
+
|
|
162
|
+
print(f"✅ Key synced to {host}")
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def list_remote_vms(self, host: str) -> list:
|
|
166
|
+
"""List VMs on remote host.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
host: Remote host in format user@hostname
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
List of VM names
|
|
173
|
+
"""
|
|
174
|
+
result = self._run_ssh(host, "virsh list --all --name")
|
|
175
|
+
if result.returncode != 0:
|
|
176
|
+
return []
|
|
177
|
+
|
|
178
|
+
vms = [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
179
|
+
return vms
|
|
180
|
+
|
|
181
|
+
def check_clonebox_installed(self, host: str) -> bool:
|
|
182
|
+
"""Check if clonebox is installed on remote host."""
|
|
183
|
+
result = self._run_ssh(host, "which clonebox || command -v clonebox")
|
|
184
|
+
return result.returncode == 0
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Snapshot management for CloneBox VMs."""
|
|
2
|
+
|
|
3
|
+
from .models import Snapshot, SnapshotType, SnapshotState, SnapshotPolicy
|
|
4
|
+
from .manager import SnapshotManager
|
|
5
|
+
|
|
6
|
+
__all__ = [
|
|
7
|
+
"Snapshot",
|
|
8
|
+
"SnapshotType",
|
|
9
|
+
"SnapshotState",
|
|
10
|
+
"SnapshotPolicy",
|
|
11
|
+
"SnapshotManager",
|
|
12
|
+
]
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Snapshot manager for CloneBox VMs."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import subprocess
|
|
6
|
+
from datetime import datetime, timedelta
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
from .models import Snapshot, SnapshotPolicy, SnapshotState, SnapshotType
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
import libvirt
|
|
14
|
+
except ImportError:
|
|
15
|
+
libvirt = None
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class SnapshotManager:
|
|
19
|
+
"""Manage VM snapshots via libvirt."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, conn_uri: str = "qemu:///session"):
|
|
22
|
+
self.conn_uri = conn_uri
|
|
23
|
+
self._conn = None
|
|
24
|
+
self._snapshots_dir = Path.home() / ".local/share/clonebox/snapshots"
|
|
25
|
+
self._snapshots_dir.mkdir(parents=True, exist_ok=True)
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def conn(self):
|
|
29
|
+
if self._conn is None:
|
|
30
|
+
if libvirt is None:
|
|
31
|
+
raise RuntimeError("libvirt-python not installed")
|
|
32
|
+
self._conn = libvirt.open(self.conn_uri)
|
|
33
|
+
return self._conn
|
|
34
|
+
|
|
35
|
+
def create(
|
|
36
|
+
self,
|
|
37
|
+
vm_name: str,
|
|
38
|
+
name: str,
|
|
39
|
+
description: Optional[str] = None,
|
|
40
|
+
snapshot_type: SnapshotType = SnapshotType.DISK_ONLY,
|
|
41
|
+
tags: Optional[List[str]] = None,
|
|
42
|
+
auto_policy: Optional[str] = None,
|
|
43
|
+
expires_in_days: Optional[int] = None,
|
|
44
|
+
) -> Snapshot:
|
|
45
|
+
"""Create a new snapshot.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
vm_name: Name of VM to snapshot
|
|
49
|
+
name: Snapshot name
|
|
50
|
+
description: Optional description
|
|
51
|
+
snapshot_type: Type of snapshot (disk, full, external)
|
|
52
|
+
tags: Optional tags for categorization
|
|
53
|
+
auto_policy: If auto-created, the policy name
|
|
54
|
+
expires_in_days: Auto-expire after N days
|
|
55
|
+
"""
|
|
56
|
+
domain = self.conn.lookupByName(vm_name)
|
|
57
|
+
|
|
58
|
+
# Generate snapshot XML
|
|
59
|
+
snapshot_xml = self._generate_snapshot_xml(
|
|
60
|
+
name=name,
|
|
61
|
+
description=description,
|
|
62
|
+
snapshot_type=snapshot_type,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Create snapshot
|
|
66
|
+
flags = 0
|
|
67
|
+
if snapshot_type == SnapshotType.DISK_ONLY:
|
|
68
|
+
flags = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_DISK_ONLY
|
|
69
|
+
elif snapshot_type == SnapshotType.FULL:
|
|
70
|
+
flags = libvirt.VIR_DOMAIN_SNAPSHOT_CREATE_ATOMIC
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
snap = domain.snapshotCreateXML(snapshot_xml, flags)
|
|
74
|
+
except libvirt.libvirtError as e:
|
|
75
|
+
raise RuntimeError(f"Failed to create snapshot: {e}")
|
|
76
|
+
|
|
77
|
+
# Build snapshot object
|
|
78
|
+
snapshot = Snapshot(
|
|
79
|
+
name=name,
|
|
80
|
+
vm_name=vm_name,
|
|
81
|
+
snapshot_type=snapshot_type,
|
|
82
|
+
state=SnapshotState.READY,
|
|
83
|
+
created_at=datetime.now(),
|
|
84
|
+
description=description,
|
|
85
|
+
tags=tags or [],
|
|
86
|
+
auto_created=auto_policy is not None,
|
|
87
|
+
auto_policy=auto_policy,
|
|
88
|
+
expires_at=(
|
|
89
|
+
datetime.now() + timedelta(days=expires_in_days)
|
|
90
|
+
if expires_in_days
|
|
91
|
+
else None
|
|
92
|
+
),
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Save metadata
|
|
96
|
+
self._save_snapshot_metadata(snapshot)
|
|
97
|
+
|
|
98
|
+
return snapshot
|
|
99
|
+
|
|
100
|
+
def restore(
|
|
101
|
+
self,
|
|
102
|
+
vm_name: str,
|
|
103
|
+
name: str,
|
|
104
|
+
force: bool = False,
|
|
105
|
+
) -> bool:
|
|
106
|
+
"""Restore VM to a snapshot.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
vm_name: Name of VM
|
|
110
|
+
name: Snapshot name to restore
|
|
111
|
+
force: Force restore even if VM is running
|
|
112
|
+
"""
|
|
113
|
+
domain = self.conn.lookupByName(vm_name)
|
|
114
|
+
|
|
115
|
+
# Check if VM is running
|
|
116
|
+
if domain.isActive() and not force:
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"VM '{vm_name}' is running. Stop it first or use --force"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
snap = domain.snapshotLookupByName(name)
|
|
123
|
+
except libvirt.libvirtError:
|
|
124
|
+
raise RuntimeError(f"Snapshot '{name}' not found for VM '{vm_name}'")
|
|
125
|
+
|
|
126
|
+
# Revert to snapshot
|
|
127
|
+
flags = libvirt.VIR_DOMAIN_SNAPSHOT_REVERT_FORCE if force else 0
|
|
128
|
+
try:
|
|
129
|
+
domain.revertToSnapshot(snap, flags)
|
|
130
|
+
except libvirt.libvirtError as e:
|
|
131
|
+
raise RuntimeError(f"Failed to restore snapshot: {e}")
|
|
132
|
+
|
|
133
|
+
return True
|
|
134
|
+
|
|
135
|
+
def delete(
|
|
136
|
+
self,
|
|
137
|
+
vm_name: str,
|
|
138
|
+
name: str,
|
|
139
|
+
delete_children: bool = False,
|
|
140
|
+
) -> bool:
|
|
141
|
+
"""Delete a snapshot.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
vm_name: Name of VM
|
|
145
|
+
name: Snapshot name to delete
|
|
146
|
+
delete_children: Also delete child snapshots
|
|
147
|
+
"""
|
|
148
|
+
domain = self.conn.lookupByName(vm_name)
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
snap = domain.snapshotLookupByName(name)
|
|
152
|
+
except libvirt.libvirtError:
|
|
153
|
+
raise RuntimeError(f"Snapshot '{name}' not found for VM '{vm_name}'")
|
|
154
|
+
|
|
155
|
+
flags = 0
|
|
156
|
+
if delete_children:
|
|
157
|
+
flags = libvirt.VIR_DOMAIN_SNAPSHOT_DELETE_CHILDREN
|
|
158
|
+
|
|
159
|
+
try:
|
|
160
|
+
snap.delete(flags)
|
|
161
|
+
except libvirt.libvirtError as e:
|
|
162
|
+
raise RuntimeError(f"Failed to delete snapshot: {e}")
|
|
163
|
+
|
|
164
|
+
# Remove metadata
|
|
165
|
+
self._delete_snapshot_metadata(vm_name, name)
|
|
166
|
+
|
|
167
|
+
return True
|
|
168
|
+
|
|
169
|
+
def list(self, vm_name: str) -> List[Snapshot]:
|
|
170
|
+
"""List all snapshots for a VM."""
|
|
171
|
+
domain = self.conn.lookupByName(vm_name)
|
|
172
|
+
snapshots = []
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
snap_names = domain.snapshotListNames()
|
|
176
|
+
except libvirt.libvirtError:
|
|
177
|
+
return []
|
|
178
|
+
|
|
179
|
+
for snap_name in snap_names:
|
|
180
|
+
try:
|
|
181
|
+
snap = domain.snapshotLookupByName(snap_name)
|
|
182
|
+
snap_xml = snap.getXMLDesc()
|
|
183
|
+
|
|
184
|
+
# Parse XML for details
|
|
185
|
+
import xml.etree.ElementTree as ET
|
|
186
|
+
|
|
187
|
+
root = ET.fromstring(snap_xml)
|
|
188
|
+
|
|
189
|
+
name = root.findtext("name", snap_name)
|
|
190
|
+
description = root.findtext("description", "")
|
|
191
|
+
creation_time = root.findtext("creationTime", "0")
|
|
192
|
+
|
|
193
|
+
# Check for saved metadata
|
|
194
|
+
metadata = self._load_snapshot_metadata(vm_name, name)
|
|
195
|
+
|
|
196
|
+
snapshot = Snapshot(
|
|
197
|
+
name=name,
|
|
198
|
+
vm_name=vm_name,
|
|
199
|
+
snapshot_type=SnapshotType(
|
|
200
|
+
metadata.get("type", "disk") if metadata else "disk"
|
|
201
|
+
),
|
|
202
|
+
state=SnapshotState.READY,
|
|
203
|
+
created_at=(
|
|
204
|
+
datetime.fromtimestamp(int(creation_time))
|
|
205
|
+
if creation_time != "0"
|
|
206
|
+
else datetime.now()
|
|
207
|
+
),
|
|
208
|
+
description=description or None,
|
|
209
|
+
tags=metadata.get("tags", []) if metadata else [],
|
|
210
|
+
auto_created=metadata.get("auto_created", False) if metadata else False,
|
|
211
|
+
auto_policy=metadata.get("auto_policy") if metadata else None,
|
|
212
|
+
expires_at=(
|
|
213
|
+
datetime.fromisoformat(metadata["expires_at"])
|
|
214
|
+
if metadata and metadata.get("expires_at")
|
|
215
|
+
else None
|
|
216
|
+
),
|
|
217
|
+
)
|
|
218
|
+
snapshots.append(snapshot)
|
|
219
|
+
|
|
220
|
+
except Exception:
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
return sorted(snapshots, key=lambda s: s.created_at, reverse=True)
|
|
224
|
+
|
|
225
|
+
def get(self, vm_name: str, name: str) -> Optional[Snapshot]:
|
|
226
|
+
"""Get a specific snapshot."""
|
|
227
|
+
snapshots = self.list(vm_name)
|
|
228
|
+
for snap in snapshots:
|
|
229
|
+
if snap.name == name:
|
|
230
|
+
return snap
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def cleanup_expired(self, vm_name: str) -> List[str]:
|
|
234
|
+
"""Delete expired snapshots for a VM."""
|
|
235
|
+
deleted = []
|
|
236
|
+
for snapshot in self.list(vm_name):
|
|
237
|
+
if snapshot.is_expired:
|
|
238
|
+
try:
|
|
239
|
+
self.delete(vm_name, snapshot.name)
|
|
240
|
+
deleted.append(snapshot.name)
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
return deleted
|
|
244
|
+
|
|
245
|
+
def apply_policy(self, vm_name: str, policy: SnapshotPolicy) -> List[str]:
|
|
246
|
+
"""Apply retention policy to VM snapshots."""
|
|
247
|
+
if not policy.auto_cleanup:
|
|
248
|
+
return []
|
|
249
|
+
|
|
250
|
+
snapshots = self.list(vm_name)
|
|
251
|
+
auto_snapshots = [s for s in snapshots if s.auto_policy == policy.name]
|
|
252
|
+
|
|
253
|
+
deleted = []
|
|
254
|
+
|
|
255
|
+
# Sort by age (oldest first)
|
|
256
|
+
auto_snapshots.sort(key=lambda s: s.created_at)
|
|
257
|
+
|
|
258
|
+
# Delete if over max count
|
|
259
|
+
while len(auto_snapshots) > policy.max_snapshots:
|
|
260
|
+
if len(auto_snapshots) <= policy.min_snapshots:
|
|
261
|
+
break
|
|
262
|
+
oldest = auto_snapshots.pop(0)
|
|
263
|
+
try:
|
|
264
|
+
self.delete(vm_name, oldest.name)
|
|
265
|
+
deleted.append(oldest.name)
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
# Delete if over max age
|
|
270
|
+
max_age = timedelta(days=policy.max_age_days)
|
|
271
|
+
for snap in auto_snapshots[:]:
|
|
272
|
+
if snap.age > max_age:
|
|
273
|
+
if len(auto_snapshots) <= policy.min_snapshots:
|
|
274
|
+
break
|
|
275
|
+
try:
|
|
276
|
+
self.delete(vm_name, snap.name)
|
|
277
|
+
deleted.append(snap.name)
|
|
278
|
+
auto_snapshots.remove(snap)
|
|
279
|
+
except Exception:
|
|
280
|
+
pass
|
|
281
|
+
|
|
282
|
+
return deleted
|
|
283
|
+
|
|
284
|
+
def create_auto_snapshot(
|
|
285
|
+
self,
|
|
286
|
+
vm_name: str,
|
|
287
|
+
operation: str,
|
|
288
|
+
policy: Optional[SnapshotPolicy] = None,
|
|
289
|
+
) -> Snapshot:
|
|
290
|
+
"""Create automatic snapshot before operation."""
|
|
291
|
+
policy = policy or SnapshotPolicy(name="default")
|
|
292
|
+
|
|
293
|
+
name = policy.generate_snapshot_name(operation)
|
|
294
|
+
|
|
295
|
+
return self.create(
|
|
296
|
+
vm_name=vm_name,
|
|
297
|
+
name=name,
|
|
298
|
+
description=f"Auto-snapshot before {operation}",
|
|
299
|
+
snapshot_type=SnapshotType.DISK_ONLY,
|
|
300
|
+
auto_policy=policy.name,
|
|
301
|
+
expires_in_days=policy.max_age_days,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def _generate_snapshot_xml(
|
|
305
|
+
self,
|
|
306
|
+
name: str,
|
|
307
|
+
description: Optional[str],
|
|
308
|
+
snapshot_type: SnapshotType,
|
|
309
|
+
) -> str:
|
|
310
|
+
"""Generate libvirt snapshot XML."""
|
|
311
|
+
desc_xml = f"<description>{description}</description>" if description else ""
|
|
312
|
+
|
|
313
|
+
if snapshot_type == SnapshotType.DISK_ONLY:
|
|
314
|
+
disks_xml = "<disks><disk name='vda' snapshot='internal'/></disks>"
|
|
315
|
+
else:
|
|
316
|
+
disks_xml = ""
|
|
317
|
+
|
|
318
|
+
return f"""
|
|
319
|
+
<domainsnapshot>
|
|
320
|
+
<name>{name}</name>
|
|
321
|
+
{desc_xml}
|
|
322
|
+
{disks_xml}
|
|
323
|
+
</domainsnapshot>
|
|
324
|
+
"""
|
|
325
|
+
|
|
326
|
+
def _save_snapshot_metadata(self, snapshot: Snapshot) -> None:
|
|
327
|
+
"""Save snapshot metadata to disk."""
|
|
328
|
+
vm_dir = self._snapshots_dir / snapshot.vm_name
|
|
329
|
+
vm_dir.mkdir(parents=True, exist_ok=True)
|
|
330
|
+
|
|
331
|
+
meta_file = vm_dir / f"{snapshot.name}.json"
|
|
332
|
+
meta_file.write_text(json.dumps(snapshot.to_dict(), indent=2))
|
|
333
|
+
|
|
334
|
+
def _load_snapshot_metadata(
|
|
335
|
+
self, vm_name: str, name: str
|
|
336
|
+
) -> Optional[Dict[str, Any]]:
|
|
337
|
+
"""Load snapshot metadata from disk."""
|
|
338
|
+
meta_file = self._snapshots_dir / vm_name / f"{name}.json"
|
|
339
|
+
if meta_file.exists():
|
|
340
|
+
try:
|
|
341
|
+
return json.loads(meta_file.read_text())
|
|
342
|
+
except Exception:
|
|
343
|
+
return None
|
|
344
|
+
return None
|
|
345
|
+
|
|
346
|
+
def _delete_snapshot_metadata(self, vm_name: str, name: str) -> None:
|
|
347
|
+
"""Delete snapshot metadata from disk."""
|
|
348
|
+
meta_file = self._snapshots_dir / vm_name / f"{name}.json"
|
|
349
|
+
if meta_file.exists():
|
|
350
|
+
meta_file.unlink()
|
|
351
|
+
|
|
352
|
+
def close(self) -> None:
|
|
353
|
+
if self._conn is not None:
|
|
354
|
+
self._conn.close()
|
|
355
|
+
self._conn = None
|