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/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