clonebox 1.1.3__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 +343 -22
- clonebox/cloner.py +327 -189
- clonebox/di.py +176 -0
- clonebox/health/__init__.py +17 -0
- clonebox/health/manager.py +328 -0
- clonebox/health/models.py +194 -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 +267 -0
- 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/__init__.py +12 -0
- clonebox/snapshots/manager.py +349 -0
- clonebox/snapshots/models.py +183 -0
- {clonebox-1.1.3.dist-info → clonebox-1.1.5.dist-info}/METADATA +51 -2
- clonebox-1.1.5.dist-info/RECORD +42 -0
- clonebox-1.1.3.dist-info/RECORD +0 -21
- {clonebox-1.1.3.dist-info → clonebox-1.1.5.dist-info}/WHEEL +0 -0
- {clonebox-1.1.3.dist-info → clonebox-1.1.5.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.3.dist-info → clonebox-1.1.5.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.3.dist-info → clonebox-1.1.5.dist-info}/top_level.txt +0 -0
clonebox/secrets.py
ADDED
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Secrets management for CloneBox.
|
|
3
|
+
Supports multiple backends: env, vault, sops, age.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import secrets
|
|
9
|
+
import string
|
|
10
|
+
import subprocess
|
|
11
|
+
from abc import ABC, abstractmethod
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class SecretValue:
|
|
19
|
+
"""Represents a secret value with metadata."""
|
|
20
|
+
|
|
21
|
+
key: str
|
|
22
|
+
value: str
|
|
23
|
+
source: str # 'env', 'vault', 'sops', 'generated'
|
|
24
|
+
expires_at: Optional[str] = None
|
|
25
|
+
|
|
26
|
+
def __repr__(self) -> str:
|
|
27
|
+
return f"SecretValue(key={self.key}, source={self.source}, value=***)"
|
|
28
|
+
|
|
29
|
+
def redacted(self) -> str:
|
|
30
|
+
"""Return redacted version for logging."""
|
|
31
|
+
return f"{self.value[:2]}***{self.value[-2:]}" if len(self.value) > 4 else "***"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SecretsProvider(ABC):
|
|
35
|
+
"""Abstract base class for secrets providers."""
|
|
36
|
+
|
|
37
|
+
@abstractmethod
|
|
38
|
+
def get_secret(self, key: str) -> Optional[SecretValue]:
|
|
39
|
+
"""Retrieve a secret by key."""
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
@abstractmethod
|
|
43
|
+
def list_secrets(self) -> List[str]:
|
|
44
|
+
"""List available secret keys."""
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
@abstractmethod
|
|
48
|
+
def is_available(self) -> bool:
|
|
49
|
+
"""Check if this provider is configured and available."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class EnvSecretsProvider(SecretsProvider):
|
|
54
|
+
"""Load secrets from environment variables and .env files."""
|
|
55
|
+
|
|
56
|
+
def __init__(self, env_file: Optional[Path] = None):
|
|
57
|
+
self.env_file = env_file or Path(".env")
|
|
58
|
+
self._cache: Dict[str, str] = {}
|
|
59
|
+
self._load_env_file()
|
|
60
|
+
|
|
61
|
+
def _load_env_file(self) -> None:
|
|
62
|
+
if self.env_file.exists():
|
|
63
|
+
with open(self.env_file) as f:
|
|
64
|
+
for line in f:
|
|
65
|
+
line = line.strip()
|
|
66
|
+
if line and not line.startswith("#") and "=" in line:
|
|
67
|
+
key, _, value = line.partition("=")
|
|
68
|
+
self._cache[key.strip()] = value.strip().strip("'\"")
|
|
69
|
+
|
|
70
|
+
def get_secret(self, key: str) -> Optional[SecretValue]:
|
|
71
|
+
# Check environment first, then cache from file
|
|
72
|
+
value = os.environ.get(key) or self._cache.get(key)
|
|
73
|
+
if value:
|
|
74
|
+
return SecretValue(key=key, value=value, source="env")
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
def list_secrets(self) -> List[str]:
|
|
78
|
+
return list(
|
|
79
|
+
set(
|
|
80
|
+
list(self._cache.keys())
|
|
81
|
+
+ [
|
|
82
|
+
k
|
|
83
|
+
for k in os.environ.keys()
|
|
84
|
+
if k.startswith("VM_") or k.startswith("CLONEBOX_")
|
|
85
|
+
]
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
def is_available(self) -> bool:
|
|
90
|
+
return True
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class VaultSecretsProvider(SecretsProvider):
|
|
94
|
+
"""Load secrets from HashiCorp Vault."""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
addr: Optional[str] = None,
|
|
99
|
+
token: Optional[str] = None,
|
|
100
|
+
path_prefix: str = "secret/clonebox",
|
|
101
|
+
):
|
|
102
|
+
self.addr = addr or os.environ.get("VAULT_ADDR", "http://127.0.0.1:8200")
|
|
103
|
+
self.token = token or os.environ.get("VAULT_TOKEN")
|
|
104
|
+
self.path_prefix = path_prefix
|
|
105
|
+
self._client = None
|
|
106
|
+
|
|
107
|
+
def _get_client(self):
|
|
108
|
+
if self._client is None:
|
|
109
|
+
try:
|
|
110
|
+
import hvac
|
|
111
|
+
|
|
112
|
+
self._client = hvac.Client(url=self.addr, token=self.token)
|
|
113
|
+
except ImportError:
|
|
114
|
+
raise RuntimeError(
|
|
115
|
+
"hvac package required for Vault support: pip install hvac"
|
|
116
|
+
)
|
|
117
|
+
return self._client
|
|
118
|
+
|
|
119
|
+
def get_secret(self, key: str) -> Optional[SecretValue]:
|
|
120
|
+
try:
|
|
121
|
+
client = self._get_client()
|
|
122
|
+
path = f"{self.path_prefix}/{key}"
|
|
123
|
+
response = client.secrets.kv.v2.read_secret_version(path=path)
|
|
124
|
+
value = response["data"]["data"].get("value")
|
|
125
|
+
if value:
|
|
126
|
+
return SecretValue(key=key, value=value, source="vault")
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
129
|
+
return None
|
|
130
|
+
|
|
131
|
+
def list_secrets(self) -> List[str]:
|
|
132
|
+
try:
|
|
133
|
+
client = self._get_client()
|
|
134
|
+
response = client.secrets.kv.v2.list_secrets(path=self.path_prefix)
|
|
135
|
+
return response["data"]["keys"]
|
|
136
|
+
except Exception:
|
|
137
|
+
return []
|
|
138
|
+
|
|
139
|
+
def is_available(self) -> bool:
|
|
140
|
+
try:
|
|
141
|
+
client = self._get_client()
|
|
142
|
+
return client.is_authenticated()
|
|
143
|
+
except Exception:
|
|
144
|
+
return False
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class SOPSSecretsProvider(SecretsProvider):
|
|
148
|
+
"""Load secrets from SOPS-encrypted files."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, secrets_file: Optional[Path] = None):
|
|
151
|
+
self.secrets_file = secrets_file or Path(".clonebox.secrets.yaml")
|
|
152
|
+
self._cache: Optional[Dict[str, Any]] = None
|
|
153
|
+
|
|
154
|
+
def _decrypt(self) -> Dict[str, Any]:
|
|
155
|
+
if self._cache is not None:
|
|
156
|
+
return self._cache
|
|
157
|
+
|
|
158
|
+
if not self.secrets_file.exists():
|
|
159
|
+
self._cache = {}
|
|
160
|
+
return self._cache
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = subprocess.run(
|
|
164
|
+
["sops", "-d", str(self.secrets_file)],
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
check=True,
|
|
168
|
+
)
|
|
169
|
+
import yaml
|
|
170
|
+
|
|
171
|
+
self._cache = yaml.safe_load(result.stdout) or {}
|
|
172
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
173
|
+
self._cache = {}
|
|
174
|
+
|
|
175
|
+
return self._cache
|
|
176
|
+
|
|
177
|
+
def get_secret(self, key: str) -> Optional[SecretValue]:
|
|
178
|
+
data = self._decrypt()
|
|
179
|
+
# Support nested keys: "vm.password" -> data['vm']['password']
|
|
180
|
+
parts = key.split(".")
|
|
181
|
+
value = data
|
|
182
|
+
for part in parts:
|
|
183
|
+
if isinstance(value, dict) and part in value:
|
|
184
|
+
value = value[part]
|
|
185
|
+
else:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
if isinstance(value, str):
|
|
189
|
+
return SecretValue(key=key, value=value, source="sops")
|
|
190
|
+
return None
|
|
191
|
+
|
|
192
|
+
def list_secrets(self) -> List[str]:
|
|
193
|
+
data = self._decrypt()
|
|
194
|
+
return list(data.keys())
|
|
195
|
+
|
|
196
|
+
def is_available(self) -> bool:
|
|
197
|
+
try:
|
|
198
|
+
subprocess.run(["sops", "--version"], capture_output=True, check=True)
|
|
199
|
+
return self.secrets_file.exists()
|
|
200
|
+
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class SecretsManager:
|
|
205
|
+
"""
|
|
206
|
+
Unified secrets management with multiple provider fallback.
|
|
207
|
+
|
|
208
|
+
Usage:
|
|
209
|
+
secrets = SecretsManager()
|
|
210
|
+
password = secrets.get('VM_PASSWORD')
|
|
211
|
+
|
|
212
|
+
# Or with explicit provider
|
|
213
|
+
secrets = SecretsManager(provider='vault')
|
|
214
|
+
"""
|
|
215
|
+
|
|
216
|
+
PROVIDERS = {
|
|
217
|
+
"env": EnvSecretsProvider,
|
|
218
|
+
"vault": VaultSecretsProvider,
|
|
219
|
+
"sops": SOPSSecretsProvider,
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
def __init__(self, provider: Optional[str] = None, **kwargs):
|
|
223
|
+
self._providers: List[SecretsProvider] = []
|
|
224
|
+
|
|
225
|
+
if provider:
|
|
226
|
+
# Use specific provider
|
|
227
|
+
if provider not in self.PROVIDERS:
|
|
228
|
+
raise ValueError(
|
|
229
|
+
f"Unknown provider: {provider}. Available: {list(self.PROVIDERS.keys())}"
|
|
230
|
+
)
|
|
231
|
+
self._providers = [self.PROVIDERS[provider](**kwargs)]
|
|
232
|
+
else:
|
|
233
|
+
# Auto-detect and use all available providers
|
|
234
|
+
for name, cls in self.PROVIDERS.items():
|
|
235
|
+
try:
|
|
236
|
+
instance = cls(**kwargs)
|
|
237
|
+
if instance.is_available():
|
|
238
|
+
self._providers.append(instance)
|
|
239
|
+
except Exception:
|
|
240
|
+
pass
|
|
241
|
+
|
|
242
|
+
# Always have env as fallback
|
|
243
|
+
if not any(isinstance(p, EnvSecretsProvider) for p in self._providers):
|
|
244
|
+
self._providers.append(EnvSecretsProvider())
|
|
245
|
+
|
|
246
|
+
def get(self, key: str, default: Optional[str] = None) -> Optional[str]:
|
|
247
|
+
"""Get a secret value, trying each provider in order."""
|
|
248
|
+
for provider in self._providers:
|
|
249
|
+
secret = provider.get_secret(key)
|
|
250
|
+
if secret:
|
|
251
|
+
return secret.value
|
|
252
|
+
return default
|
|
253
|
+
|
|
254
|
+
def get_secret(self, key: str) -> Optional[SecretValue]:
|
|
255
|
+
"""Get a SecretValue with metadata."""
|
|
256
|
+
for provider in self._providers:
|
|
257
|
+
secret = provider.get_secret(key)
|
|
258
|
+
if secret:
|
|
259
|
+
return secret
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
def require(self, key: str) -> str:
|
|
263
|
+
"""Get a secret or raise an error if not found."""
|
|
264
|
+
value = self.get(key)
|
|
265
|
+
if value is None:
|
|
266
|
+
raise ValueError(f"Required secret '{key}' not found in any provider")
|
|
267
|
+
return value
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def generate_password(length: int = 24) -> str:
|
|
271
|
+
"""Generate a secure random password."""
|
|
272
|
+
alphabet = string.ascii_letters + string.digits + "!@#$%^&*"
|
|
273
|
+
return "".join(secrets.choice(alphabet) for _ in range(length))
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def generate_one_time_password() -> Tuple[str, str]:
|
|
277
|
+
"""
|
|
278
|
+
Generate a one-time password that expires on first login.
|
|
279
|
+
Returns (password, cloud-init chpasswd config)
|
|
280
|
+
"""
|
|
281
|
+
password = SecretsManager.generate_password(16)
|
|
282
|
+
# Force password change on first login
|
|
283
|
+
chpasswd_config = f"""
|
|
284
|
+
chpasswd:
|
|
285
|
+
expire: true
|
|
286
|
+
users:
|
|
287
|
+
- name: ubuntu
|
|
288
|
+
password: {password}
|
|
289
|
+
type: text
|
|
290
|
+
"""
|
|
291
|
+
return password, chpasswd_config
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# SSH Key Management
|
|
295
|
+
@dataclass
|
|
296
|
+
class SSHKeyPair:
|
|
297
|
+
"""SSH key pair for VM authentication."""
|
|
298
|
+
|
|
299
|
+
private_key: str
|
|
300
|
+
public_key: str
|
|
301
|
+
key_type: str = "ed25519"
|
|
302
|
+
|
|
303
|
+
@classmethod
|
|
304
|
+
def generate(cls, key_type: str = "ed25519") -> "SSHKeyPair":
|
|
305
|
+
"""Generate a new SSH key pair."""
|
|
306
|
+
import tempfile
|
|
307
|
+
|
|
308
|
+
with tempfile.TemporaryDirectory() as tmpdir:
|
|
309
|
+
key_path = Path(tmpdir) / "key"
|
|
310
|
+
subprocess.run(
|
|
311
|
+
["ssh-keygen", "-t", key_type, "-N", "", "-f", str(key_path), "-q"],
|
|
312
|
+
check=True,
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
private_key = key_path.read_text()
|
|
316
|
+
public_key = key_path.with_suffix(".pub").read_text()
|
|
317
|
+
|
|
318
|
+
return cls(private_key=private_key, public_key=public_key, key_type=key_type)
|
|
319
|
+
|
|
320
|
+
@classmethod
|
|
321
|
+
def from_file(cls, private_key_path: Path) -> "SSHKeyPair":
|
|
322
|
+
"""Load existing SSH key pair."""
|
|
323
|
+
private_key = private_key_path.read_text()
|
|
324
|
+
public_key = private_key_path.with_suffix(".pub").read_text()
|
|
325
|
+
return cls(private_key=private_key, public_key=public_key)
|
|
326
|
+
|
|
327
|
+
def save(self, private_key_path: Path) -> None:
|
|
328
|
+
"""Save key pair to files."""
|
|
329
|
+
private_key_path.write_text(self.private_key)
|
|
330
|
+
private_key_path.chmod(0o600)
|
|
331
|
+
private_key_path.with_suffix(".pub").write_text(self.public_key)
|
|
@@ -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,349 @@
|
|
|
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) if expires_in_days else None
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
# Save metadata
|
|
94
|
+
self._save_snapshot_metadata(snapshot)
|
|
95
|
+
|
|
96
|
+
return snapshot
|
|
97
|
+
|
|
98
|
+
def restore(
|
|
99
|
+
self,
|
|
100
|
+
vm_name: str,
|
|
101
|
+
name: str,
|
|
102
|
+
force: bool = False,
|
|
103
|
+
) -> bool:
|
|
104
|
+
"""Restore VM to a snapshot.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
vm_name: Name of VM
|
|
108
|
+
name: Snapshot name to restore
|
|
109
|
+
force: Force restore even if VM is running
|
|
110
|
+
"""
|
|
111
|
+
domain = self.conn.lookupByName(vm_name)
|
|
112
|
+
|
|
113
|
+
# Check if VM is running
|
|
114
|
+
if domain.isActive() and not force:
|
|
115
|
+
raise RuntimeError(f"VM '{vm_name}' is running. Stop it first or use --force")
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
snap = domain.snapshotLookupByName(name)
|
|
119
|
+
except libvirt.libvirtError:
|
|
120
|
+
raise RuntimeError(f"Snapshot '{name}' not found for VM '{vm_name}'")
|
|
121
|
+
|
|
122
|
+
# Revert to snapshot
|
|
123
|
+
flags = libvirt.VIR_DOMAIN_SNAPSHOT_REVERT_FORCE if force else 0
|
|
124
|
+
try:
|
|
125
|
+
domain.revertToSnapshot(snap, flags)
|
|
126
|
+
except libvirt.libvirtError as e:
|
|
127
|
+
raise RuntimeError(f"Failed to restore snapshot: {e}")
|
|
128
|
+
|
|
129
|
+
return True
|
|
130
|
+
|
|
131
|
+
def delete(
|
|
132
|
+
self,
|
|
133
|
+
vm_name: str,
|
|
134
|
+
name: str,
|
|
135
|
+
delete_children: bool = False,
|
|
136
|
+
) -> bool:
|
|
137
|
+
"""Delete a snapshot.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
vm_name: Name of VM
|
|
141
|
+
name: Snapshot name to delete
|
|
142
|
+
delete_children: Also delete child snapshots
|
|
143
|
+
"""
|
|
144
|
+
domain = self.conn.lookupByName(vm_name)
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
snap = domain.snapshotLookupByName(name)
|
|
148
|
+
except libvirt.libvirtError:
|
|
149
|
+
raise RuntimeError(f"Snapshot '{name}' not found for VM '{vm_name}'")
|
|
150
|
+
|
|
151
|
+
flags = 0
|
|
152
|
+
if delete_children:
|
|
153
|
+
flags = libvirt.VIR_DOMAIN_SNAPSHOT_DELETE_CHILDREN
|
|
154
|
+
|
|
155
|
+
try:
|
|
156
|
+
snap.delete(flags)
|
|
157
|
+
except libvirt.libvirtError as e:
|
|
158
|
+
raise RuntimeError(f"Failed to delete snapshot: {e}")
|
|
159
|
+
|
|
160
|
+
# Remove metadata
|
|
161
|
+
self._delete_snapshot_metadata(vm_name, name)
|
|
162
|
+
|
|
163
|
+
return True
|
|
164
|
+
|
|
165
|
+
def list(self, vm_name: str) -> List[Snapshot]:
|
|
166
|
+
"""List all snapshots for a VM."""
|
|
167
|
+
domain = self.conn.lookupByName(vm_name)
|
|
168
|
+
snapshots = []
|
|
169
|
+
|
|
170
|
+
try:
|
|
171
|
+
snap_names = domain.snapshotListNames()
|
|
172
|
+
except libvirt.libvirtError:
|
|
173
|
+
return []
|
|
174
|
+
|
|
175
|
+
for snap_name in snap_names:
|
|
176
|
+
try:
|
|
177
|
+
snap = domain.snapshotLookupByName(snap_name)
|
|
178
|
+
snap_xml = snap.getXMLDesc()
|
|
179
|
+
|
|
180
|
+
# Parse XML for details
|
|
181
|
+
import xml.etree.ElementTree as ET
|
|
182
|
+
|
|
183
|
+
root = ET.fromstring(snap_xml)
|
|
184
|
+
|
|
185
|
+
name = root.findtext("name", snap_name)
|
|
186
|
+
description = root.findtext("description", "")
|
|
187
|
+
creation_time = root.findtext("creationTime", "0")
|
|
188
|
+
|
|
189
|
+
# Check for saved metadata
|
|
190
|
+
metadata = self._load_snapshot_metadata(vm_name, name)
|
|
191
|
+
|
|
192
|
+
snapshot = Snapshot(
|
|
193
|
+
name=name,
|
|
194
|
+
vm_name=vm_name,
|
|
195
|
+
snapshot_type=SnapshotType(
|
|
196
|
+
metadata.get("type", "disk") if metadata else "disk"
|
|
197
|
+
),
|
|
198
|
+
state=SnapshotState.READY,
|
|
199
|
+
created_at=(
|
|
200
|
+
datetime.fromtimestamp(int(creation_time))
|
|
201
|
+
if creation_time != "0"
|
|
202
|
+
else datetime.now()
|
|
203
|
+
),
|
|
204
|
+
description=description or None,
|
|
205
|
+
tags=metadata.get("tags", []) if metadata else [],
|
|
206
|
+
auto_created=metadata.get("auto_created", False) if metadata else False,
|
|
207
|
+
auto_policy=metadata.get("auto_policy") if metadata else None,
|
|
208
|
+
expires_at=(
|
|
209
|
+
datetime.fromisoformat(metadata["expires_at"])
|
|
210
|
+
if metadata and metadata.get("expires_at")
|
|
211
|
+
else None
|
|
212
|
+
),
|
|
213
|
+
)
|
|
214
|
+
snapshots.append(snapshot)
|
|
215
|
+
|
|
216
|
+
except Exception:
|
|
217
|
+
continue
|
|
218
|
+
|
|
219
|
+
return sorted(snapshots, key=lambda s: s.created_at, reverse=True)
|
|
220
|
+
|
|
221
|
+
def get(self, vm_name: str, name: str) -> Optional[Snapshot]:
|
|
222
|
+
"""Get a specific snapshot."""
|
|
223
|
+
snapshots = self.list(vm_name)
|
|
224
|
+
for snap in snapshots:
|
|
225
|
+
if snap.name == name:
|
|
226
|
+
return snap
|
|
227
|
+
return None
|
|
228
|
+
|
|
229
|
+
def cleanup_expired(self, vm_name: str) -> List[str]:
|
|
230
|
+
"""Delete expired snapshots for a VM."""
|
|
231
|
+
deleted = []
|
|
232
|
+
for snapshot in self.list(vm_name):
|
|
233
|
+
if snapshot.is_expired:
|
|
234
|
+
try:
|
|
235
|
+
self.delete(vm_name, snapshot.name)
|
|
236
|
+
deleted.append(snapshot.name)
|
|
237
|
+
except Exception:
|
|
238
|
+
pass
|
|
239
|
+
return deleted
|
|
240
|
+
|
|
241
|
+
def apply_policy(self, vm_name: str, policy: SnapshotPolicy) -> List[str]:
|
|
242
|
+
"""Apply retention policy to VM snapshots."""
|
|
243
|
+
if not policy.auto_cleanup:
|
|
244
|
+
return []
|
|
245
|
+
|
|
246
|
+
snapshots = self.list(vm_name)
|
|
247
|
+
auto_snapshots = [s for s in snapshots if s.auto_policy == policy.name]
|
|
248
|
+
|
|
249
|
+
deleted = []
|
|
250
|
+
|
|
251
|
+
# Sort by age (oldest first)
|
|
252
|
+
auto_snapshots.sort(key=lambda s: s.created_at)
|
|
253
|
+
|
|
254
|
+
# Delete if over max count
|
|
255
|
+
while len(auto_snapshots) > policy.max_snapshots:
|
|
256
|
+
if len(auto_snapshots) <= policy.min_snapshots:
|
|
257
|
+
break
|
|
258
|
+
oldest = auto_snapshots.pop(0)
|
|
259
|
+
try:
|
|
260
|
+
self.delete(vm_name, oldest.name)
|
|
261
|
+
deleted.append(oldest.name)
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
# Delete if over max age
|
|
266
|
+
max_age = timedelta(days=policy.max_age_days)
|
|
267
|
+
for snap in auto_snapshots[:]:
|
|
268
|
+
if snap.age > max_age:
|
|
269
|
+
if len(auto_snapshots) <= policy.min_snapshots:
|
|
270
|
+
break
|
|
271
|
+
try:
|
|
272
|
+
self.delete(vm_name, snap.name)
|
|
273
|
+
deleted.append(snap.name)
|
|
274
|
+
auto_snapshots.remove(snap)
|
|
275
|
+
except Exception:
|
|
276
|
+
pass
|
|
277
|
+
|
|
278
|
+
return deleted
|
|
279
|
+
|
|
280
|
+
def create_auto_snapshot(
|
|
281
|
+
self,
|
|
282
|
+
vm_name: str,
|
|
283
|
+
operation: str,
|
|
284
|
+
policy: Optional[SnapshotPolicy] = None,
|
|
285
|
+
) -> Snapshot:
|
|
286
|
+
"""Create automatic snapshot before operation."""
|
|
287
|
+
policy = policy or SnapshotPolicy(name="default")
|
|
288
|
+
|
|
289
|
+
name = policy.generate_snapshot_name(operation)
|
|
290
|
+
|
|
291
|
+
return self.create(
|
|
292
|
+
vm_name=vm_name,
|
|
293
|
+
name=name,
|
|
294
|
+
description=f"Auto-snapshot before {operation}",
|
|
295
|
+
snapshot_type=SnapshotType.DISK_ONLY,
|
|
296
|
+
auto_policy=policy.name,
|
|
297
|
+
expires_in_days=policy.max_age_days,
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def _generate_snapshot_xml(
|
|
301
|
+
self,
|
|
302
|
+
name: str,
|
|
303
|
+
description: Optional[str],
|
|
304
|
+
snapshot_type: SnapshotType,
|
|
305
|
+
) -> str:
|
|
306
|
+
"""Generate libvirt snapshot XML."""
|
|
307
|
+
desc_xml = f"<description>{description}</description>" if description else ""
|
|
308
|
+
|
|
309
|
+
if snapshot_type == SnapshotType.DISK_ONLY:
|
|
310
|
+
disks_xml = "<disks><disk name='vda' snapshot='internal'/></disks>"
|
|
311
|
+
else:
|
|
312
|
+
disks_xml = ""
|
|
313
|
+
|
|
314
|
+
return f"""
|
|
315
|
+
<domainsnapshot>
|
|
316
|
+
<name>{name}</name>
|
|
317
|
+
{desc_xml}
|
|
318
|
+
{disks_xml}
|
|
319
|
+
</domainsnapshot>
|
|
320
|
+
"""
|
|
321
|
+
|
|
322
|
+
def _save_snapshot_metadata(self, snapshot: Snapshot) -> None:
|
|
323
|
+
"""Save snapshot metadata to disk."""
|
|
324
|
+
vm_dir = self._snapshots_dir / snapshot.vm_name
|
|
325
|
+
vm_dir.mkdir(parents=True, exist_ok=True)
|
|
326
|
+
|
|
327
|
+
meta_file = vm_dir / f"{snapshot.name}.json"
|
|
328
|
+
meta_file.write_text(json.dumps(snapshot.to_dict(), indent=2))
|
|
329
|
+
|
|
330
|
+
def _load_snapshot_metadata(self, vm_name: str, name: str) -> Optional[Dict[str, Any]]:
|
|
331
|
+
"""Load snapshot metadata from disk."""
|
|
332
|
+
meta_file = self._snapshots_dir / vm_name / f"{name}.json"
|
|
333
|
+
if meta_file.exists():
|
|
334
|
+
try:
|
|
335
|
+
return json.loads(meta_file.read_text())
|
|
336
|
+
except Exception:
|
|
337
|
+
return None
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
def _delete_snapshot_metadata(self, vm_name: str, name: str) -> None:
|
|
341
|
+
"""Delete snapshot metadata from disk."""
|
|
342
|
+
meta_file = self._snapshots_dir / vm_name / f"{name}.json"
|
|
343
|
+
if meta_file.exists():
|
|
344
|
+
meta_file.unlink()
|
|
345
|
+
|
|
346
|
+
def close(self) -> None:
|
|
347
|
+
if self._conn is not None:
|
|
348
|
+
self._conn.close()
|
|
349
|
+
self._conn = None
|