clonebox 1.1.14__py3-none-any.whl → 1.1.16__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.
Potentially problematic release.
This version of clonebox might be problematic. Click here for more details.
- clonebox/audit.py +5 -1
- clonebox/cli.py +673 -11
- clonebox/cloner.py +358 -179
- clonebox/plugins/manager.py +85 -0
- clonebox/remote.py +511 -0
- clonebox/secrets.py +9 -6
- clonebox/validator.py +140 -53
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/METADATA +5 -1
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/RECORD +13 -12
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/WHEEL +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.14.dist-info → clonebox-1.1.16.dist-info}/top_level.txt +0 -0
clonebox/plugins/manager.py
CHANGED
|
@@ -388,6 +388,91 @@ class PluginManager:
|
|
|
388
388
|
self._save_config()
|
|
389
389
|
return True
|
|
390
390
|
|
|
391
|
+
def install(self, source: str) -> bool:
|
|
392
|
+
"""
|
|
393
|
+
Install a plugin from a source.
|
|
394
|
+
|
|
395
|
+
Sources:
|
|
396
|
+
- PyPI package name: "clonebox-plugin-kubernetes"
|
|
397
|
+
- Git URL: "git+https://github.com/user/plugin.git"
|
|
398
|
+
- Local path: "/path/to/plugin"
|
|
399
|
+
|
|
400
|
+
Returns True if installation succeeded.
|
|
401
|
+
"""
|
|
402
|
+
import subprocess
|
|
403
|
+
|
|
404
|
+
# Handle local path
|
|
405
|
+
if Path(source).exists():
|
|
406
|
+
target_dir = self.plugin_dirs[0] # User plugins dir
|
|
407
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
408
|
+
source_path = Path(source)
|
|
409
|
+
|
|
410
|
+
if source_path.is_file() and source_path.suffix == ".py":
|
|
411
|
+
# Single file plugin
|
|
412
|
+
import shutil
|
|
413
|
+
shutil.copy(source_path, target_dir / source_path.name)
|
|
414
|
+
return True
|
|
415
|
+
elif source_path.is_dir():
|
|
416
|
+
# Directory plugin
|
|
417
|
+
import shutil
|
|
418
|
+
target = target_dir / source_path.name
|
|
419
|
+
if target.exists():
|
|
420
|
+
shutil.rmtree(target)
|
|
421
|
+
shutil.copytree(source_path, target)
|
|
422
|
+
return True
|
|
423
|
+
|
|
424
|
+
# Handle pip installable (PyPI or git)
|
|
425
|
+
try:
|
|
426
|
+
result = subprocess.run(
|
|
427
|
+
[sys.executable, "-m", "pip", "install", "--user", source],
|
|
428
|
+
capture_output=True,
|
|
429
|
+
text=True,
|
|
430
|
+
)
|
|
431
|
+
return result.returncode == 0
|
|
432
|
+
except Exception:
|
|
433
|
+
return False
|
|
434
|
+
|
|
435
|
+
def uninstall(self, name: str) -> bool:
|
|
436
|
+
"""
|
|
437
|
+
Uninstall a plugin.
|
|
438
|
+
|
|
439
|
+
Returns True if uninstallation succeeded.
|
|
440
|
+
"""
|
|
441
|
+
import subprocess
|
|
442
|
+
|
|
443
|
+
# Check if it's a local plugin
|
|
444
|
+
for plugin_dir in self.plugin_dirs:
|
|
445
|
+
plugin_path = plugin_dir / f"{name}.py"
|
|
446
|
+
plugin_pkg = plugin_dir / name
|
|
447
|
+
|
|
448
|
+
if plugin_path.exists():
|
|
449
|
+
plugin_path.unlink()
|
|
450
|
+
return True
|
|
451
|
+
if plugin_pkg.exists() and plugin_pkg.is_dir():
|
|
452
|
+
import shutil
|
|
453
|
+
shutil.rmtree(plugin_pkg)
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
# Try pip uninstall
|
|
457
|
+
try:
|
|
458
|
+
result = subprocess.run(
|
|
459
|
+
[sys.executable, "-m", "pip", "uninstall", "-y", f"clonebox-plugin-{name}"],
|
|
460
|
+
capture_output=True,
|
|
461
|
+
text=True,
|
|
462
|
+
)
|
|
463
|
+
if result.returncode == 0:
|
|
464
|
+
return True
|
|
465
|
+
|
|
466
|
+
# Try with original name
|
|
467
|
+
result = subprocess.run(
|
|
468
|
+
[sys.executable, "-m", "pip", "uninstall", "-y", name],
|
|
469
|
+
capture_output=True,
|
|
470
|
+
text=True,
|
|
471
|
+
)
|
|
472
|
+
return result.returncode == 0
|
|
473
|
+
except Exception:
|
|
474
|
+
return False
|
|
475
|
+
|
|
391
476
|
def list_plugins(self) -> List[Dict[str, Any]]:
|
|
392
477
|
"""List all loaded plugins."""
|
|
393
478
|
return [
|
clonebox/remote.py
ADDED
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Remote VM management for CloneBox.
|
|
3
|
+
Execute CloneBox operations on remote hosts via SSH.
|
|
4
|
+
"""
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional, List, Dict, Any
|
|
8
|
+
from urllib.parse import urlparse
|
|
9
|
+
import json
|
|
10
|
+
import subprocess
|
|
11
|
+
import tempfile
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class RemoteConnection:
|
|
16
|
+
"""Remote libvirt connection configuration."""
|
|
17
|
+
uri: str
|
|
18
|
+
ssh_key: Optional[Path] = None
|
|
19
|
+
ssh_user: Optional[str] = None
|
|
20
|
+
ssh_host: Optional[str] = None
|
|
21
|
+
ssh_port: int = 22
|
|
22
|
+
|
|
23
|
+
@classmethod
|
|
24
|
+
def from_string(cls, connection_string: str) -> "RemoteConnection":
|
|
25
|
+
"""
|
|
26
|
+
Parse connection string.
|
|
27
|
+
|
|
28
|
+
Formats:
|
|
29
|
+
- qemu+ssh://user@host/system
|
|
30
|
+
- qemu+ssh://user@host:port/system
|
|
31
|
+
- user@host
|
|
32
|
+
- user@host:port
|
|
33
|
+
- ssh://user@host
|
|
34
|
+
"""
|
|
35
|
+
if connection_string.startswith("qemu"):
|
|
36
|
+
parsed = urlparse(connection_string)
|
|
37
|
+
return cls(
|
|
38
|
+
uri=connection_string,
|
|
39
|
+
ssh_user=parsed.username,
|
|
40
|
+
ssh_host=parsed.hostname,
|
|
41
|
+
ssh_port=parsed.port or 22,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
# Parse SSH-style connection
|
|
45
|
+
if "@" in connection_string:
|
|
46
|
+
user, host = connection_string.split("@", 1)
|
|
47
|
+
else:
|
|
48
|
+
user, host = None, connection_string
|
|
49
|
+
|
|
50
|
+
# Extract port if present
|
|
51
|
+
port = 22
|
|
52
|
+
if ":" in host:
|
|
53
|
+
host, port_str = host.rsplit(":", 1)
|
|
54
|
+
try:
|
|
55
|
+
port = int(port_str)
|
|
56
|
+
except ValueError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
uri = f"qemu+ssh://{user}@{host}/system" if user else f"qemu+ssh://{host}/system"
|
|
60
|
+
|
|
61
|
+
return cls(
|
|
62
|
+
uri=uri,
|
|
63
|
+
ssh_user=user,
|
|
64
|
+
ssh_host=host,
|
|
65
|
+
ssh_port=port,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def get_libvirt_uri(self) -> str:
|
|
69
|
+
"""Get the libvirt connection URI."""
|
|
70
|
+
return self.uri
|
|
71
|
+
|
|
72
|
+
def get_ssh_target(self) -> str:
|
|
73
|
+
"""Get SSH connection target (user@host)."""
|
|
74
|
+
if self.ssh_user and self.ssh_host:
|
|
75
|
+
return f"{self.ssh_user}@{self.ssh_host}"
|
|
76
|
+
elif self.ssh_host:
|
|
77
|
+
return self.ssh_host
|
|
78
|
+
else:
|
|
79
|
+
parsed = urlparse(self.uri)
|
|
80
|
+
if parsed.username and parsed.hostname:
|
|
81
|
+
return f"{parsed.username}@{parsed.hostname}"
|
|
82
|
+
return parsed.hostname or ""
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class RemoteCommandResult:
|
|
87
|
+
"""Result of a remote command execution."""
|
|
88
|
+
success: bool
|
|
89
|
+
stdout: str
|
|
90
|
+
stderr: str
|
|
91
|
+
returncode: int
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class RemoteCloner:
|
|
95
|
+
"""
|
|
96
|
+
Execute CloneBox operations on remote hosts.
|
|
97
|
+
|
|
98
|
+
Usage:
|
|
99
|
+
remote = RemoteCloner("user@server")
|
|
100
|
+
remote.list_vms()
|
|
101
|
+
remote.create_vm(config)
|
|
102
|
+
remote.start_vm("my-vm")
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
def __init__(
|
|
106
|
+
self,
|
|
107
|
+
connection: str | RemoteConnection,
|
|
108
|
+
ssh_key: Optional[Path] = None,
|
|
109
|
+
verify: bool = True,
|
|
110
|
+
):
|
|
111
|
+
if isinstance(connection, str):
|
|
112
|
+
self.connection = RemoteConnection.from_string(connection)
|
|
113
|
+
else:
|
|
114
|
+
self.connection = connection
|
|
115
|
+
|
|
116
|
+
if ssh_key:
|
|
117
|
+
self.connection.ssh_key = ssh_key
|
|
118
|
+
|
|
119
|
+
if verify:
|
|
120
|
+
self._verify_connection()
|
|
121
|
+
|
|
122
|
+
def _verify_connection(self) -> None:
|
|
123
|
+
"""Verify SSH connection to remote host."""
|
|
124
|
+
result = self._run_remote(["echo", "ok"], timeout=10)
|
|
125
|
+
|
|
126
|
+
if not result.success:
|
|
127
|
+
raise ConnectionError(
|
|
128
|
+
f"Cannot connect to {self.connection.get_ssh_target()}: {result.stderr}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
def _build_ssh_command(self) -> List[str]:
|
|
132
|
+
"""Build base SSH command with options."""
|
|
133
|
+
ssh_cmd = ["ssh"]
|
|
134
|
+
|
|
135
|
+
# Connection options
|
|
136
|
+
ssh_cmd.extend(["-o", "ConnectTimeout=10"])
|
|
137
|
+
ssh_cmd.extend(["-o", "StrictHostKeyChecking=accept-new"])
|
|
138
|
+
ssh_cmd.extend(["-o", "BatchMode=yes"])
|
|
139
|
+
|
|
140
|
+
# SSH key
|
|
141
|
+
if self.connection.ssh_key:
|
|
142
|
+
ssh_cmd.extend(["-i", str(self.connection.ssh_key)])
|
|
143
|
+
|
|
144
|
+
# Port
|
|
145
|
+
if self.connection.ssh_port != 22:
|
|
146
|
+
ssh_cmd.extend(["-p", str(self.connection.ssh_port)])
|
|
147
|
+
|
|
148
|
+
# Target
|
|
149
|
+
ssh_cmd.append(self.connection.get_ssh_target())
|
|
150
|
+
|
|
151
|
+
return ssh_cmd
|
|
152
|
+
|
|
153
|
+
def _run_remote(
|
|
154
|
+
self,
|
|
155
|
+
command: List[str],
|
|
156
|
+
timeout: Optional[int] = None,
|
|
157
|
+
) -> RemoteCommandResult:
|
|
158
|
+
"""Run a command on the remote host."""
|
|
159
|
+
ssh_cmd = self._build_ssh_command()
|
|
160
|
+
ssh_cmd.extend(command)
|
|
161
|
+
|
|
162
|
+
try:
|
|
163
|
+
result = subprocess.run(
|
|
164
|
+
ssh_cmd,
|
|
165
|
+
capture_output=True,
|
|
166
|
+
text=True,
|
|
167
|
+
timeout=timeout,
|
|
168
|
+
)
|
|
169
|
+
return RemoteCommandResult(
|
|
170
|
+
success=result.returncode == 0,
|
|
171
|
+
stdout=result.stdout,
|
|
172
|
+
stderr=result.stderr,
|
|
173
|
+
returncode=result.returncode,
|
|
174
|
+
)
|
|
175
|
+
except subprocess.TimeoutExpired:
|
|
176
|
+
return RemoteCommandResult(
|
|
177
|
+
success=False,
|
|
178
|
+
stdout="",
|
|
179
|
+
stderr="Command timed out",
|
|
180
|
+
returncode=-1,
|
|
181
|
+
)
|
|
182
|
+
except Exception as e:
|
|
183
|
+
return RemoteCommandResult(
|
|
184
|
+
success=False,
|
|
185
|
+
stdout="",
|
|
186
|
+
stderr=str(e),
|
|
187
|
+
returncode=-1,
|
|
188
|
+
)
|
|
189
|
+
|
|
190
|
+
def _run_clonebox(
|
|
191
|
+
self,
|
|
192
|
+
args: List[str],
|
|
193
|
+
timeout: Optional[int] = None,
|
|
194
|
+
) -> RemoteCommandResult:
|
|
195
|
+
"""Run a clonebox command on the remote host."""
|
|
196
|
+
return self._run_remote(["clonebox"] + args, timeout=timeout)
|
|
197
|
+
|
|
198
|
+
def is_clonebox_installed(self) -> bool:
|
|
199
|
+
"""Check if CloneBox is installed on remote host."""
|
|
200
|
+
result = self._run_remote(["which", "clonebox"], timeout=10)
|
|
201
|
+
return result.success
|
|
202
|
+
|
|
203
|
+
def get_clonebox_version(self) -> Optional[str]:
|
|
204
|
+
"""Get CloneBox version on remote host."""
|
|
205
|
+
result = self._run_clonebox(["--version"], timeout=10)
|
|
206
|
+
if result.success:
|
|
207
|
+
return result.stdout.strip()
|
|
208
|
+
return None
|
|
209
|
+
|
|
210
|
+
def list_vms(self, user_session: bool = False) -> List[Dict[str, Any]]:
|
|
211
|
+
"""List VMs on remote host."""
|
|
212
|
+
args = ["list", "--json"]
|
|
213
|
+
if user_session:
|
|
214
|
+
args.append("--user")
|
|
215
|
+
|
|
216
|
+
result = self._run_clonebox(args, timeout=30)
|
|
217
|
+
|
|
218
|
+
if not result.success:
|
|
219
|
+
raise RuntimeError(f"Failed to list VMs: {result.stderr}")
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
return json.loads(result.stdout)
|
|
223
|
+
except json.JSONDecodeError:
|
|
224
|
+
# Try to parse line by line if not JSON
|
|
225
|
+
vms = []
|
|
226
|
+
for line in result.stdout.strip().split("\n"):
|
|
227
|
+
if line.strip():
|
|
228
|
+
vms.append({"name": line.strip()})
|
|
229
|
+
return vms
|
|
230
|
+
|
|
231
|
+
def get_status(
|
|
232
|
+
self,
|
|
233
|
+
vm_name: str,
|
|
234
|
+
user_session: bool = False,
|
|
235
|
+
) -> Dict[str, Any]:
|
|
236
|
+
"""Get VM status on remote host."""
|
|
237
|
+
args = ["status", vm_name]
|
|
238
|
+
if user_session:
|
|
239
|
+
args.append("--user")
|
|
240
|
+
|
|
241
|
+
result = self._run_clonebox(args, timeout=30)
|
|
242
|
+
|
|
243
|
+
if not result.success:
|
|
244
|
+
raise RuntimeError(f"Failed to get status: {result.stderr}")
|
|
245
|
+
|
|
246
|
+
# Try to parse as JSON, otherwise return raw
|
|
247
|
+
try:
|
|
248
|
+
return json.loads(result.stdout)
|
|
249
|
+
except json.JSONDecodeError:
|
|
250
|
+
return {"raw_output": result.stdout, "status": "unknown"}
|
|
251
|
+
|
|
252
|
+
def create_vm(
|
|
253
|
+
self,
|
|
254
|
+
config: Dict[str, Any],
|
|
255
|
+
start: bool = True,
|
|
256
|
+
user_session: bool = False,
|
|
257
|
+
) -> str:
|
|
258
|
+
"""Create VM on remote host from config dict."""
|
|
259
|
+
import yaml
|
|
260
|
+
|
|
261
|
+
# Write config to temp file
|
|
262
|
+
with tempfile.NamedTemporaryFile(
|
|
263
|
+
mode="w", suffix=".yaml", delete=False
|
|
264
|
+
) as f:
|
|
265
|
+
yaml.dump(config, f)
|
|
266
|
+
local_config = Path(f.name)
|
|
267
|
+
|
|
268
|
+
try:
|
|
269
|
+
# Copy config to remote
|
|
270
|
+
remote_config = f"/tmp/clonebox-{local_config.stem}.yaml"
|
|
271
|
+
self._copy_to_remote(local_config, remote_config)
|
|
272
|
+
|
|
273
|
+
# Create VM
|
|
274
|
+
args = ["clone", remote_config]
|
|
275
|
+
if start:
|
|
276
|
+
args.append("--run")
|
|
277
|
+
if user_session:
|
|
278
|
+
args.append("--user")
|
|
279
|
+
|
|
280
|
+
result = self._run_clonebox(args, timeout=600)
|
|
281
|
+
|
|
282
|
+
if not result.success:
|
|
283
|
+
raise RuntimeError(f"Failed to create VM: {result.stderr}")
|
|
284
|
+
|
|
285
|
+
return result.stdout.strip()
|
|
286
|
+
|
|
287
|
+
finally:
|
|
288
|
+
local_config.unlink(missing_ok=True)
|
|
289
|
+
|
|
290
|
+
def create_vm_from_file(
|
|
291
|
+
self,
|
|
292
|
+
config_path: Path,
|
|
293
|
+
start: bool = True,
|
|
294
|
+
user_session: bool = False,
|
|
295
|
+
) -> str:
|
|
296
|
+
"""Create VM on remote host from local config file."""
|
|
297
|
+
# Copy config to remote
|
|
298
|
+
remote_config = f"/tmp/clonebox-{config_path.name}"
|
|
299
|
+
self._copy_to_remote(config_path, remote_config)
|
|
300
|
+
|
|
301
|
+
# Create VM
|
|
302
|
+
args = ["clone", remote_config]
|
|
303
|
+
if start:
|
|
304
|
+
args.append("--run")
|
|
305
|
+
if user_session:
|
|
306
|
+
args.append("--user")
|
|
307
|
+
|
|
308
|
+
result = self._run_clonebox(args, timeout=600)
|
|
309
|
+
|
|
310
|
+
if not result.success:
|
|
311
|
+
raise RuntimeError(f"Failed to create VM: {result.stderr}")
|
|
312
|
+
|
|
313
|
+
return result.stdout.strip()
|
|
314
|
+
|
|
315
|
+
def start_vm(
|
|
316
|
+
self,
|
|
317
|
+
vm_name: str,
|
|
318
|
+
user_session: bool = False,
|
|
319
|
+
) -> None:
|
|
320
|
+
"""Start VM on remote host."""
|
|
321
|
+
args = ["start", vm_name]
|
|
322
|
+
if user_session:
|
|
323
|
+
args.append("--user")
|
|
324
|
+
|
|
325
|
+
result = self._run_clonebox(args, timeout=60)
|
|
326
|
+
if not result.success:
|
|
327
|
+
raise RuntimeError(f"Failed to start VM: {result.stderr}")
|
|
328
|
+
|
|
329
|
+
def stop_vm(
|
|
330
|
+
self,
|
|
331
|
+
vm_name: str,
|
|
332
|
+
force: bool = False,
|
|
333
|
+
user_session: bool = False,
|
|
334
|
+
) -> None:
|
|
335
|
+
"""Stop VM on remote host."""
|
|
336
|
+
args = ["stop", vm_name]
|
|
337
|
+
if force:
|
|
338
|
+
args.append("--force")
|
|
339
|
+
if user_session:
|
|
340
|
+
args.append("--user")
|
|
341
|
+
|
|
342
|
+
result = self._run_clonebox(args, timeout=60)
|
|
343
|
+
if not result.success:
|
|
344
|
+
raise RuntimeError(f"Failed to stop VM: {result.stderr}")
|
|
345
|
+
|
|
346
|
+
def restart_vm(
|
|
347
|
+
self,
|
|
348
|
+
vm_name: str,
|
|
349
|
+
user_session: bool = False,
|
|
350
|
+
) -> None:
|
|
351
|
+
"""Restart VM on remote host."""
|
|
352
|
+
args = ["restart", vm_name]
|
|
353
|
+
if user_session:
|
|
354
|
+
args.append("--user")
|
|
355
|
+
|
|
356
|
+
result = self._run_clonebox(args, timeout=120)
|
|
357
|
+
if not result.success:
|
|
358
|
+
raise RuntimeError(f"Failed to restart VM: {result.stderr}")
|
|
359
|
+
|
|
360
|
+
def delete_vm(
|
|
361
|
+
self,
|
|
362
|
+
vm_name: str,
|
|
363
|
+
keep_storage: bool = False,
|
|
364
|
+
user_session: bool = False,
|
|
365
|
+
) -> None:
|
|
366
|
+
"""Delete VM on remote host."""
|
|
367
|
+
args = ["delete", vm_name, "--yes"]
|
|
368
|
+
if keep_storage:
|
|
369
|
+
args.append("--keep-storage")
|
|
370
|
+
if user_session:
|
|
371
|
+
args.append("--user")
|
|
372
|
+
|
|
373
|
+
result = self._run_clonebox(args, timeout=60)
|
|
374
|
+
if not result.success:
|
|
375
|
+
raise RuntimeError(f"Failed to delete VM: {result.stderr}")
|
|
376
|
+
|
|
377
|
+
def exec_in_vm(
|
|
378
|
+
self,
|
|
379
|
+
vm_name: str,
|
|
380
|
+
command: str,
|
|
381
|
+
timeout: int = 30,
|
|
382
|
+
user_session: bool = False,
|
|
383
|
+
) -> str:
|
|
384
|
+
"""Execute command in VM on remote host."""
|
|
385
|
+
args = ["exec", vm_name, "--timeout", str(timeout)]
|
|
386
|
+
if user_session:
|
|
387
|
+
args.append("--user")
|
|
388
|
+
args.extend(["--", command])
|
|
389
|
+
|
|
390
|
+
result = self._run_clonebox(args, timeout=timeout + 30)
|
|
391
|
+
if not result.success:
|
|
392
|
+
raise RuntimeError(f"Failed to exec in VM: {result.stderr}")
|
|
393
|
+
|
|
394
|
+
return result.stdout
|
|
395
|
+
|
|
396
|
+
def snapshot_create(
|
|
397
|
+
self,
|
|
398
|
+
vm_name: str,
|
|
399
|
+
snapshot_name: str,
|
|
400
|
+
description: Optional[str] = None,
|
|
401
|
+
user_session: bool = False,
|
|
402
|
+
) -> None:
|
|
403
|
+
"""Create snapshot on remote host."""
|
|
404
|
+
args = ["snapshot", "create", vm_name, "--name", snapshot_name]
|
|
405
|
+
if description:
|
|
406
|
+
args.extend(["--description", description])
|
|
407
|
+
if user_session:
|
|
408
|
+
args.append("--user")
|
|
409
|
+
|
|
410
|
+
result = self._run_clonebox(args, timeout=120)
|
|
411
|
+
if not result.success:
|
|
412
|
+
raise RuntimeError(f"Failed to create snapshot: {result.stderr}")
|
|
413
|
+
|
|
414
|
+
def snapshot_restore(
|
|
415
|
+
self,
|
|
416
|
+
vm_name: str,
|
|
417
|
+
snapshot_name: str,
|
|
418
|
+
user_session: bool = False,
|
|
419
|
+
) -> None:
|
|
420
|
+
"""Restore snapshot on remote host."""
|
|
421
|
+
args = ["snapshot", "restore", vm_name, "--name", snapshot_name]
|
|
422
|
+
if user_session:
|
|
423
|
+
args.append("--user")
|
|
424
|
+
|
|
425
|
+
result = self._run_clonebox(args, timeout=120)
|
|
426
|
+
if not result.success:
|
|
427
|
+
raise RuntimeError(f"Failed to restore snapshot: {result.stderr}")
|
|
428
|
+
|
|
429
|
+
def snapshot_list(
|
|
430
|
+
self,
|
|
431
|
+
vm_name: str,
|
|
432
|
+
user_session: bool = False,
|
|
433
|
+
) -> List[Dict[str, Any]]:
|
|
434
|
+
"""List snapshots on remote host."""
|
|
435
|
+
args = ["snapshot", "list", vm_name]
|
|
436
|
+
if user_session:
|
|
437
|
+
args.append("--user")
|
|
438
|
+
|
|
439
|
+
result = self._run_clonebox(args, timeout=30)
|
|
440
|
+
if not result.success:
|
|
441
|
+
raise RuntimeError(f"Failed to list snapshots: {result.stderr}")
|
|
442
|
+
|
|
443
|
+
# Parse output
|
|
444
|
+
snapshots = []
|
|
445
|
+
for line in result.stdout.strip().split("\n"):
|
|
446
|
+
if line.strip():
|
|
447
|
+
snapshots.append({"name": line.strip()})
|
|
448
|
+
return snapshots
|
|
449
|
+
|
|
450
|
+
def health_check(
|
|
451
|
+
self,
|
|
452
|
+
vm_name: str,
|
|
453
|
+
user_session: bool = False,
|
|
454
|
+
) -> Dict[str, Any]:
|
|
455
|
+
"""Run health check on remote host."""
|
|
456
|
+
args = ["health", vm_name]
|
|
457
|
+
if user_session:
|
|
458
|
+
args.append("--user")
|
|
459
|
+
|
|
460
|
+
result = self._run_clonebox(args, timeout=120)
|
|
461
|
+
|
|
462
|
+
return {
|
|
463
|
+
"success": result.success,
|
|
464
|
+
"output": result.stdout,
|
|
465
|
+
"errors": result.stderr if not result.success else None,
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
def _copy_to_remote(self, local_path: Path, remote_path: str) -> None:
|
|
469
|
+
"""Copy file to remote host."""
|
|
470
|
+
scp_cmd = ["scp"]
|
|
471
|
+
|
|
472
|
+
if self.connection.ssh_key:
|
|
473
|
+
scp_cmd.extend(["-i", str(self.connection.ssh_key)])
|
|
474
|
+
|
|
475
|
+
if self.connection.ssh_port != 22:
|
|
476
|
+
scp_cmd.extend(["-P", str(self.connection.ssh_port)])
|
|
477
|
+
|
|
478
|
+
scp_cmd.append(str(local_path))
|
|
479
|
+
scp_cmd.append(f"{self.connection.get_ssh_target()}:{remote_path}")
|
|
480
|
+
|
|
481
|
+
result = subprocess.run(scp_cmd, capture_output=True, text=True)
|
|
482
|
+
if result.returncode != 0:
|
|
483
|
+
raise RuntimeError(f"Failed to copy file: {result.stderr}")
|
|
484
|
+
|
|
485
|
+
def _copy_from_remote(self, remote_path: str, local_path: Path) -> None:
|
|
486
|
+
"""Copy file from remote host."""
|
|
487
|
+
scp_cmd = ["scp"]
|
|
488
|
+
|
|
489
|
+
if self.connection.ssh_key:
|
|
490
|
+
scp_cmd.extend(["-i", str(self.connection.ssh_key)])
|
|
491
|
+
|
|
492
|
+
if self.connection.ssh_port != 22:
|
|
493
|
+
scp_cmd.extend(["-P", str(self.connection.ssh_port)])
|
|
494
|
+
|
|
495
|
+
scp_cmd.append(f"{self.connection.get_ssh_target()}:{remote_path}")
|
|
496
|
+
scp_cmd.append(str(local_path))
|
|
497
|
+
|
|
498
|
+
result = subprocess.run(scp_cmd, capture_output=True, text=True)
|
|
499
|
+
if result.returncode != 0:
|
|
500
|
+
raise RuntimeError(f"Failed to copy file: {result.stderr}")
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def connect(connection_string: str, **kwargs) -> RemoteCloner:
|
|
504
|
+
"""
|
|
505
|
+
Convenience function to create a RemoteCloner.
|
|
506
|
+
|
|
507
|
+
Usage:
|
|
508
|
+
remote = connect("user@server")
|
|
509
|
+
vms = remote.list_vms()
|
|
510
|
+
"""
|
|
511
|
+
return RemoteCloner(connection_string, **kwargs)
|
clonebox/secrets.py
CHANGED
|
@@ -60,12 +60,15 @@ class EnvSecretsProvider(SecretsProvider):
|
|
|
60
60
|
|
|
61
61
|
def _load_env_file(self) -> None:
|
|
62
62
|
if self.env_file.exists():
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
line
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
63
|
+
try:
|
|
64
|
+
with open(self.env_file) as f:
|
|
65
|
+
for line in f:
|
|
66
|
+
line = line.strip()
|
|
67
|
+
if line and not line.startswith("#") and "=" in line:
|
|
68
|
+
key, _, value = line.partition("=")
|
|
69
|
+
self._cache[key.strip()] = value.strip().strip("'\"")
|
|
70
|
+
except (FileNotFoundError, OSError):
|
|
71
|
+
return
|
|
69
72
|
|
|
70
73
|
def get_secret(self, key: str) -> Optional[SecretValue]:
|
|
71
74
|
# Check environment first, then cache from file
|