clonebox 1.1.14__py3-none-any.whl → 1.1.15__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/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
- 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("'\"")
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