clonebox 1.1.13__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/audit.py +452 -0
- clonebox/cli.py +966 -10
- clonebox/cloner.py +221 -135
- clonebox/orchestrator.py +568 -0
- clonebox/plugins/__init__.py +24 -0
- clonebox/plugins/base.py +319 -0
- clonebox/plugins/manager.py +523 -0
- clonebox/remote.py +511 -0
- clonebox/secrets.py +9 -6
- clonebox/validator.py +113 -41
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/METADATA +5 -1
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/RECORD +16 -10
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/WHEEL +0 -0
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/entry_points.txt +0 -0
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/licenses/LICENSE +0 -0
- {clonebox-1.1.13.dist-info → clonebox-1.1.15.dist-info}/top_level.txt +0 -0
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
|