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/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