mdify-cli 2.11.8__py3-none-any.whl → 2.11.10__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.
- mdify/__init__.py +1 -1
- mdify/cli.py +580 -4
- mdify/container.py +0 -4
- mdify/ssh/__init__.py +11 -0
- mdify/ssh/client.py +408 -0
- mdify/ssh/models.py +470 -0
- mdify/ssh/remote_container.py +237 -0
- mdify/ssh/transfer.py +297 -0
- {mdify_cli-2.11.8.dist-info → mdify_cli-2.11.10.dist-info}/METADATA +192 -4
- mdify_cli-2.11.10.dist-info/RECORD +17 -0
- mdify_cli-2.11.8.dist-info/RECORD +0 -12
- {mdify_cli-2.11.8.dist-info → mdify_cli-2.11.10.dist-info}/WHEEL +0 -0
- {mdify_cli-2.11.8.dist-info → mdify_cli-2.11.10.dist-info}/entry_points.txt +0 -0
- {mdify_cli-2.11.8.dist-info → mdify_cli-2.11.10.dist-info}/licenses/LICENSE +0 -0
- {mdify_cli-2.11.8.dist-info → mdify_cli-2.11.10.dist-info}/top_level.txt +0 -0
mdify/ssh/client.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"""AsyncSSH client implementation for mdify."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import logging
|
|
5
|
+
from typing import AsyncGenerator
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
import asyncssh
|
|
8
|
+
|
|
9
|
+
from mdify.ssh.models import SSHConfig, SSHConnectionError, SSHAuthError
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class AsyncSSHClient:
|
|
15
|
+
"""SSH client using asyncssh library."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, config: SSHConfig):
|
|
18
|
+
"""Initialize SSH client with configuration.
|
|
19
|
+
|
|
20
|
+
Parameters:
|
|
21
|
+
config: SSHConfig instance with connection parameters
|
|
22
|
+
"""
|
|
23
|
+
self.config = config
|
|
24
|
+
self.connection: asyncssh.SSHClientConnection | None = None
|
|
25
|
+
self._retries = 0
|
|
26
|
+
self._max_retries = 3
|
|
27
|
+
|
|
28
|
+
async def connect(self) -> None:
|
|
29
|
+
"""Establish SSH connection to remote host.
|
|
30
|
+
|
|
31
|
+
Uses exponential backoff for retries: 1s, 2s, 4s
|
|
32
|
+
|
|
33
|
+
Raises:
|
|
34
|
+
SSHConnectionError: Connection failed
|
|
35
|
+
SSHAuthError: Authentication failed
|
|
36
|
+
"""
|
|
37
|
+
backoff_delays = [1, 2, 4]
|
|
38
|
+
last_error = None
|
|
39
|
+
|
|
40
|
+
for attempt in range(self._max_retries):
|
|
41
|
+
try:
|
|
42
|
+
logger.debug(f"SSH: Connecting to {self.config.host}:{self.config.port} (attempt {attempt + 1}/{self._max_retries})")
|
|
43
|
+
|
|
44
|
+
# Prepare connection parameters - only include non-None values
|
|
45
|
+
connect_kwargs = {
|
|
46
|
+
"port": self.config.port,
|
|
47
|
+
"connect_timeout": self.config.timeout,
|
|
48
|
+
"known_hosts": None, # Skip host key verification for now
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
# Add username if provided
|
|
52
|
+
if self.config.username:
|
|
53
|
+
connect_kwargs["username"] = self.config.username
|
|
54
|
+
|
|
55
|
+
# Add passphrase if provided
|
|
56
|
+
if self.config.key_passphrase:
|
|
57
|
+
connect_kwargs["passphrase"] = self.config.key_passphrase
|
|
58
|
+
|
|
59
|
+
# Use key-based authentication if provided
|
|
60
|
+
if self.config.key_file:
|
|
61
|
+
key_path = Path(self.config.key_file).expanduser()
|
|
62
|
+
if not key_path.exists():
|
|
63
|
+
raise SSHConnectionError(
|
|
64
|
+
f"SSH key not found: {key_path}",
|
|
65
|
+
self.config.host,
|
|
66
|
+
self.config.port
|
|
67
|
+
)
|
|
68
|
+
connect_kwargs["client_keys"] = [str(key_path)]
|
|
69
|
+
|
|
70
|
+
# Use password if provided (backup authentication)
|
|
71
|
+
if self.config.password:
|
|
72
|
+
connect_kwargs["password"] = self.config.password
|
|
73
|
+
|
|
74
|
+
# Establish connection
|
|
75
|
+
self.connection = await asyncssh.connect(
|
|
76
|
+
self.config.host,
|
|
77
|
+
**connect_kwargs
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
logger.info(f"SSH: Connected to {self.config.host}:{self.config.port}")
|
|
81
|
+
self._retries = 0
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
except asyncssh.PermissionDenied as e:
|
|
85
|
+
logger.debug(f"SSH: Authentication failed: {e}")
|
|
86
|
+
raise SSHAuthError(
|
|
87
|
+
f"SSH authentication failed: {e}",
|
|
88
|
+
self.config.host,
|
|
89
|
+
self.config.port
|
|
90
|
+
)
|
|
91
|
+
except asyncssh.misc.DisconnectError as e:
|
|
92
|
+
last_error = e
|
|
93
|
+
logger.debug(f"SSH: Connection error (attempt {attempt + 1}): {e}")
|
|
94
|
+
|
|
95
|
+
if attempt < self._max_retries - 1:
|
|
96
|
+
delay = backoff_delays[attempt]
|
|
97
|
+
logger.debug(f"SSH: Retrying in {delay}s...")
|
|
98
|
+
await asyncio.sleep(delay)
|
|
99
|
+
|
|
100
|
+
except Exception as e:
|
|
101
|
+
logger.debug(f"SSH: Connection failed: {e}")
|
|
102
|
+
raise SSHConnectionError(
|
|
103
|
+
f"SSH connection failed: {e}",
|
|
104
|
+
self.config.host,
|
|
105
|
+
self.config.port
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# All retries exhausted
|
|
109
|
+
raise SSHConnectionError(
|
|
110
|
+
f"SSH connection failed after {self._max_retries} retries: {last_error}",
|
|
111
|
+
self.config.host,
|
|
112
|
+
self.config.port
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
async def disconnect(self) -> None:
|
|
116
|
+
"""Close SSH connection gracefully."""
|
|
117
|
+
if self.connection:
|
|
118
|
+
self.connection.close()
|
|
119
|
+
await self.connection.wait_closed()
|
|
120
|
+
self.connection = None
|
|
121
|
+
logger.info(f"SSH: Disconnected from {self.config.host}")
|
|
122
|
+
|
|
123
|
+
async def is_connected(self) -> bool:
|
|
124
|
+
"""Check if SSH connection is active.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
True if connected and authenticated, False otherwise
|
|
128
|
+
"""
|
|
129
|
+
if not self.connection:
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
# Try a simple no-op to verify connection
|
|
134
|
+
await self.run_command("echo OK", timeout=5)
|
|
135
|
+
return True
|
|
136
|
+
except Exception:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
async def run_command(
|
|
140
|
+
self,
|
|
141
|
+
command: str,
|
|
142
|
+
timeout: int | None = None
|
|
143
|
+
) -> tuple[str, str, int]:
|
|
144
|
+
"""Execute a command on remote server.
|
|
145
|
+
|
|
146
|
+
Parameters:
|
|
147
|
+
command: Shell command to execute
|
|
148
|
+
timeout: Command timeout in seconds (None = no timeout)
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Tuple of (stdout, stderr, exit_code)
|
|
152
|
+
|
|
153
|
+
Raises:
|
|
154
|
+
SSHConnectionError: Connection lost during execution
|
|
155
|
+
TimeoutError: Command exceeded timeout
|
|
156
|
+
"""
|
|
157
|
+
if not self.connection:
|
|
158
|
+
raise SSHConnectionError(
|
|
159
|
+
"Not connected to remote server",
|
|
160
|
+
self.config.host,
|
|
161
|
+
self.config.port
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
timeout_val = timeout or self.config.timeout
|
|
166
|
+
|
|
167
|
+
result = await asyncio.wait_for(
|
|
168
|
+
self.connection.run(command, check=False),
|
|
169
|
+
timeout=timeout_val
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
result.stdout if result.stdout else "",
|
|
174
|
+
result.stderr if result.stderr else "",
|
|
175
|
+
result.exit_status if result.exit_status is not None else 0
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
except asyncio.TimeoutError:
|
|
179
|
+
raise TimeoutError(f"Command timeout after {timeout} seconds: {command}")
|
|
180
|
+
except Exception as e:
|
|
181
|
+
raise SSHConnectionError(
|
|
182
|
+
f"Command execution failed: {e}",
|
|
183
|
+
self.config.host,
|
|
184
|
+
self.config.port
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
async def run_command_stream(self, command: str) -> AsyncGenerator[str, None]:
|
|
188
|
+
"""Execute command with streaming output.
|
|
189
|
+
|
|
190
|
+
Parameters:
|
|
191
|
+
command: Shell command to execute
|
|
192
|
+
|
|
193
|
+
Yields:
|
|
194
|
+
Output lines from command as they arrive
|
|
195
|
+
|
|
196
|
+
Raises:
|
|
197
|
+
SSHConnectionError: Connection lost
|
|
198
|
+
"""
|
|
199
|
+
if not self.connection:
|
|
200
|
+
raise SSHConnectionError(
|
|
201
|
+
"Not connected to remote server",
|
|
202
|
+
self.config.host,
|
|
203
|
+
self.config.port
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
async with self.connection.create_process(command) as process:
|
|
208
|
+
# Read stdout line by line
|
|
209
|
+
while True:
|
|
210
|
+
line = await process.stdout.readline()
|
|
211
|
+
if not line:
|
|
212
|
+
break
|
|
213
|
+
yield line.decode('utf-8', errors='replace').rstrip('\n')
|
|
214
|
+
|
|
215
|
+
except Exception as e:
|
|
216
|
+
raise SSHConnectionError(
|
|
217
|
+
f"Stream command failed: {e}",
|
|
218
|
+
self.config.host,
|
|
219
|
+
self.config.port
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
async def check_container_runtime(self) -> str | None:
|
|
223
|
+
"""Detect available container runtime on remote.
|
|
224
|
+
|
|
225
|
+
Returns:
|
|
226
|
+
"docker" if docker available
|
|
227
|
+
"podman" if podman available (and docker not)
|
|
228
|
+
None if neither available
|
|
229
|
+
"""
|
|
230
|
+
if not self.config.container_runtime:
|
|
231
|
+
# Auto-detect
|
|
232
|
+
# Try docker first
|
|
233
|
+
try:
|
|
234
|
+
stdout, stderr, code = await self.run_command("which docker")
|
|
235
|
+
if code == 0:
|
|
236
|
+
stdout, stderr, code = await self.run_command("docker --version")
|
|
237
|
+
if code == 0:
|
|
238
|
+
logger.info(f"SSH: Detected Docker: {stdout.strip()}")
|
|
239
|
+
return "docker"
|
|
240
|
+
except Exception as e:
|
|
241
|
+
logger.debug(f"SSH: Docker check failed: {e}")
|
|
242
|
+
|
|
243
|
+
# Try podman
|
|
244
|
+
try:
|
|
245
|
+
stdout, stderr, code = await self.run_command("which podman")
|
|
246
|
+
if code == 0:
|
|
247
|
+
stdout, stderr, code = await self.run_command("podman --version")
|
|
248
|
+
if code == 0:
|
|
249
|
+
logger.info(f"SSH: Detected Podman: {stdout.strip()}")
|
|
250
|
+
return "podman"
|
|
251
|
+
except Exception as e:
|
|
252
|
+
logger.debug(f"SSH: Podman check failed: {e}")
|
|
253
|
+
|
|
254
|
+
return None
|
|
255
|
+
|
|
256
|
+
# Verify configured runtime
|
|
257
|
+
runtime = self.config.container_runtime.lower()
|
|
258
|
+
try:
|
|
259
|
+
stdout, stderr, code = await self.run_command(f"which {runtime}")
|
|
260
|
+
if code == 0:
|
|
261
|
+
return runtime
|
|
262
|
+
except Exception as e:
|
|
263
|
+
logger.debug(f"SSH: Runtime check failed: {e}")
|
|
264
|
+
|
|
265
|
+
return None
|
|
266
|
+
|
|
267
|
+
async def get_available_memory(self) -> int:
|
|
268
|
+
"""Get available memory on remote in bytes.
|
|
269
|
+
|
|
270
|
+
Returns:
|
|
271
|
+
Available memory in bytes
|
|
272
|
+
|
|
273
|
+
Raises:
|
|
274
|
+
SSHConnectionError: Connection lost
|
|
275
|
+
"""
|
|
276
|
+
try:
|
|
277
|
+
# Check if Linux
|
|
278
|
+
stdout, stderr, code = await self.run_command("uname")
|
|
279
|
+
if code == 0 and "Linux" in stdout:
|
|
280
|
+
# Parse /proc/meminfo
|
|
281
|
+
stdout, stderr, code = await self.run_command(
|
|
282
|
+
"grep MemAvailable /proc/meminfo | awk '{print $2}'"
|
|
283
|
+
)
|
|
284
|
+
if code == 0:
|
|
285
|
+
try:
|
|
286
|
+
kb = int(stdout.strip())
|
|
287
|
+
return kb * 1024 # Convert KB to bytes
|
|
288
|
+
except ValueError:
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
# Try macOS vm_stat
|
|
292
|
+
stdout, stderr, code = await self.run_command("vm_stat")
|
|
293
|
+
if code == 0:
|
|
294
|
+
# Parse "Pages free" line
|
|
295
|
+
for line in stdout.split('\n'):
|
|
296
|
+
if "Pages free" in line:
|
|
297
|
+
try:
|
|
298
|
+
pages = int(line.split()[-1].rstrip('.'))
|
|
299
|
+
page_size = 4096 # macOS page size
|
|
300
|
+
return pages * page_size
|
|
301
|
+
except (ValueError, IndexError):
|
|
302
|
+
pass
|
|
303
|
+
|
|
304
|
+
# Fallback: return 2GB
|
|
305
|
+
logger.warning("SSH: Could not determine available memory, assuming 2GB")
|
|
306
|
+
return 2 * 1024 * 1024 * 1024
|
|
307
|
+
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.warning(f"SSH: Memory check failed: {e}")
|
|
310
|
+
return 2 * 1024 * 1024 * 1024
|
|
311
|
+
|
|
312
|
+
async def get_available_disk(self, path: str) -> int:
|
|
313
|
+
"""Get available disk space for path on remote.
|
|
314
|
+
|
|
315
|
+
Parameters:
|
|
316
|
+
path: Filesystem path to check
|
|
317
|
+
|
|
318
|
+
Returns:
|
|
319
|
+
Available space in bytes
|
|
320
|
+
|
|
321
|
+
Raises:
|
|
322
|
+
SSHConnectionError: Connection lost
|
|
323
|
+
"""
|
|
324
|
+
try:
|
|
325
|
+
# Expand path
|
|
326
|
+
expanded_path = path.replace("~", "$HOME")
|
|
327
|
+
|
|
328
|
+
# Run df -B1 (block size 1 byte for accurate output)
|
|
329
|
+
stdout, stderr, code = await self.run_command(
|
|
330
|
+
f"df -B1 {expanded_path} | tail -1 | awk '{{print $4}}'"
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if code == 0:
|
|
334
|
+
try:
|
|
335
|
+
return int(stdout.strip())
|
|
336
|
+
except ValueError:
|
|
337
|
+
pass
|
|
338
|
+
|
|
339
|
+
# Fallback: return 10GB
|
|
340
|
+
logger.warning("SSH: Could not determine disk space, assuming 10GB")
|
|
341
|
+
return 10 * 1024 * 1024 * 1024
|
|
342
|
+
|
|
343
|
+
except Exception as e:
|
|
344
|
+
logger.warning(f"SSH: Disk check failed: {e}")
|
|
345
|
+
return 10 * 1024 * 1024 * 1024
|
|
346
|
+
|
|
347
|
+
async def validate_remote_resources(self) -> dict[str, bool]:
|
|
348
|
+
"""Run comprehensive resource validation checks.
|
|
349
|
+
|
|
350
|
+
Returns:
|
|
351
|
+
Dict with validation results
|
|
352
|
+
"""
|
|
353
|
+
results = {
|
|
354
|
+
"can_connect": False,
|
|
355
|
+
"work_dir_exists": False,
|
|
356
|
+
"work_dir_writable": False,
|
|
357
|
+
"container_runtime_available": False,
|
|
358
|
+
"disk_space_min_5gb": False,
|
|
359
|
+
"memory_min_2gb": False,
|
|
360
|
+
"ssh_config_valid": False,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
try:
|
|
364
|
+
# Test connection
|
|
365
|
+
if await self.is_connected():
|
|
366
|
+
results["can_connect"] = True
|
|
367
|
+
results["ssh_config_valid"] = True
|
|
368
|
+
else:
|
|
369
|
+
return results
|
|
370
|
+
|
|
371
|
+
# Check work directory
|
|
372
|
+
work_dir = self.config.work_dir
|
|
373
|
+
stdout, stderr, code = await self.run_command(f"test -d {work_dir}")
|
|
374
|
+
if code == 0:
|
|
375
|
+
results["work_dir_exists"] = True
|
|
376
|
+
else:
|
|
377
|
+
# Try to create it
|
|
378
|
+
stdout, stderr, code = await self.run_command(f"mkdir -p {work_dir}")
|
|
379
|
+
if code == 0:
|
|
380
|
+
results["work_dir_exists"] = True
|
|
381
|
+
|
|
382
|
+
# Check write access
|
|
383
|
+
if results["work_dir_exists"]:
|
|
384
|
+
stdout, stderr, code = await self.run_command(
|
|
385
|
+
f"touch {work_dir}/.mdify_test_$$"
|
|
386
|
+
)
|
|
387
|
+
if code == 0:
|
|
388
|
+
results["work_dir_writable"] = True
|
|
389
|
+
# Clean up
|
|
390
|
+
await self.run_command(f"rm {work_dir}/.mdify_test_$$")
|
|
391
|
+
|
|
392
|
+
# Check container runtime
|
|
393
|
+
runtime = await self.check_container_runtime()
|
|
394
|
+
results["container_runtime_available"] = runtime is not None
|
|
395
|
+
|
|
396
|
+
# Check disk space
|
|
397
|
+
available_disk = await self.get_available_disk(work_dir)
|
|
398
|
+
results["disk_space_min_5gb"] = available_disk >= (5 * 1024 * 1024 * 1024)
|
|
399
|
+
|
|
400
|
+
# Check memory
|
|
401
|
+
available_mem = await self.get_available_memory()
|
|
402
|
+
results["memory_min_2gb"] = available_mem >= (2 * 1024 * 1024 * 1024)
|
|
403
|
+
|
|
404
|
+
return results
|
|
405
|
+
|
|
406
|
+
except Exception as e:
|
|
407
|
+
logger.error(f"SSH: Resource validation error: {e}")
|
|
408
|
+
return results
|