mcp-ssh-vps 0.4.1__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.
- mcp_ssh_vps-0.4.1.dist-info/METADATA +482 -0
- mcp_ssh_vps-0.4.1.dist-info/RECORD +47 -0
- mcp_ssh_vps-0.4.1.dist-info/WHEEL +5 -0
- mcp_ssh_vps-0.4.1.dist-info/entry_points.txt +4 -0
- mcp_ssh_vps-0.4.1.dist-info/licenses/LICENSE +21 -0
- mcp_ssh_vps-0.4.1.dist-info/top_level.txt +1 -0
- sshmcp/__init__.py +3 -0
- sshmcp/cli.py +473 -0
- sshmcp/config.py +155 -0
- sshmcp/core/__init__.py +5 -0
- sshmcp/core/container.py +291 -0
- sshmcp/models/__init__.py +15 -0
- sshmcp/models/command.py +69 -0
- sshmcp/models/file.py +102 -0
- sshmcp/models/machine.py +139 -0
- sshmcp/monitoring/__init__.py +0 -0
- sshmcp/monitoring/alerts.py +464 -0
- sshmcp/prompts/__init__.py +7 -0
- sshmcp/prompts/backup.py +151 -0
- sshmcp/prompts/deploy.py +115 -0
- sshmcp/prompts/monitor.py +146 -0
- sshmcp/resources/__init__.py +7 -0
- sshmcp/resources/logs.py +99 -0
- sshmcp/resources/metrics.py +204 -0
- sshmcp/resources/status.py +160 -0
- sshmcp/security/__init__.py +7 -0
- sshmcp/security/audit.py +314 -0
- sshmcp/security/rate_limiter.py +221 -0
- sshmcp/security/totp.py +392 -0
- sshmcp/security/validator.py +234 -0
- sshmcp/security/whitelist.py +169 -0
- sshmcp/server.py +632 -0
- sshmcp/ssh/__init__.py +6 -0
- sshmcp/ssh/async_client.py +247 -0
- sshmcp/ssh/client.py +464 -0
- sshmcp/ssh/executor.py +79 -0
- sshmcp/ssh/forwarding.py +368 -0
- sshmcp/ssh/pool.py +343 -0
- sshmcp/ssh/shell.py +518 -0
- sshmcp/ssh/transfer.py +461 -0
- sshmcp/tools/__init__.py +13 -0
- sshmcp/tools/commands.py +226 -0
- sshmcp/tools/files.py +220 -0
- sshmcp/tools/helpers.py +321 -0
- sshmcp/tools/history.py +372 -0
- sshmcp/tools/processes.py +214 -0
- sshmcp/tools/servers.py +484 -0
sshmcp/tools/history.py
ADDED
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
"""Command history tracking for SSH MCP."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import threading
|
|
5
|
+
from collections import deque
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
import structlog
|
|
12
|
+
|
|
13
|
+
logger = structlog.get_logger()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class HistoryEntry:
|
|
18
|
+
"""Single command history entry."""
|
|
19
|
+
|
|
20
|
+
host: str
|
|
21
|
+
command: str
|
|
22
|
+
exit_code: int
|
|
23
|
+
timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
|
|
24
|
+
duration_ms: int = 0
|
|
25
|
+
stdout_preview: str = ""
|
|
26
|
+
stderr_preview: str = ""
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict[str, Any]:
|
|
29
|
+
"""Convert to dictionary."""
|
|
30
|
+
return {
|
|
31
|
+
"host": self.host,
|
|
32
|
+
"command": self.command,
|
|
33
|
+
"exit_code": self.exit_code,
|
|
34
|
+
"timestamp": self.timestamp.isoformat(),
|
|
35
|
+
"duration_ms": self.duration_ms,
|
|
36
|
+
"stdout_preview": self.stdout_preview[:200] if self.stdout_preview else "",
|
|
37
|
+
"stderr_preview": self.stderr_preview[:200] if self.stderr_preview else "",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
@classmethod
|
|
41
|
+
def from_dict(cls, data: dict[str, Any]) -> "HistoryEntry":
|
|
42
|
+
"""Create from dictionary."""
|
|
43
|
+
return cls(
|
|
44
|
+
host=data["host"],
|
|
45
|
+
command=data["command"],
|
|
46
|
+
exit_code=data["exit_code"],
|
|
47
|
+
timestamp=datetime.fromisoformat(data["timestamp"]),
|
|
48
|
+
duration_ms=data.get("duration_ms", 0),
|
|
49
|
+
stdout_preview=data.get("stdout_preview", ""),
|
|
50
|
+
stderr_preview=data.get("stderr_preview", ""),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class CommandHistory:
|
|
55
|
+
"""
|
|
56
|
+
Command history manager.
|
|
57
|
+
|
|
58
|
+
Tracks executed commands per host with optional persistence.
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(
|
|
62
|
+
self,
|
|
63
|
+
max_entries_per_host: int = 100,
|
|
64
|
+
max_total_entries: int = 1000,
|
|
65
|
+
persistence_file: str | None = None,
|
|
66
|
+
) -> None:
|
|
67
|
+
"""
|
|
68
|
+
Initialize command history.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
max_entries_per_host: Maximum entries to keep per host.
|
|
72
|
+
max_total_entries: Maximum total entries across all hosts.
|
|
73
|
+
persistence_file: Optional file to persist history.
|
|
74
|
+
"""
|
|
75
|
+
self.max_entries_per_host = max_entries_per_host
|
|
76
|
+
self.max_total_entries = max_total_entries
|
|
77
|
+
self.persistence_file = persistence_file
|
|
78
|
+
|
|
79
|
+
self._history: dict[str, deque[HistoryEntry]] = {}
|
|
80
|
+
self._lock = threading.Lock()
|
|
81
|
+
self._total_count = 0
|
|
82
|
+
|
|
83
|
+
if persistence_file:
|
|
84
|
+
self._load()
|
|
85
|
+
|
|
86
|
+
def add(
|
|
87
|
+
self,
|
|
88
|
+
host: str,
|
|
89
|
+
command: str,
|
|
90
|
+
exit_code: int,
|
|
91
|
+
duration_ms: int = 0,
|
|
92
|
+
stdout: str = "",
|
|
93
|
+
stderr: str = "",
|
|
94
|
+
) -> HistoryEntry:
|
|
95
|
+
"""
|
|
96
|
+
Add a command to history.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
host: Host where command was executed.
|
|
100
|
+
command: The command that was executed.
|
|
101
|
+
exit_code: Command exit code.
|
|
102
|
+
duration_ms: Execution duration in milliseconds.
|
|
103
|
+
stdout: Standard output (will be truncated).
|
|
104
|
+
stderr: Standard error (will be truncated).
|
|
105
|
+
|
|
106
|
+
Returns:
|
|
107
|
+
The created history entry.
|
|
108
|
+
"""
|
|
109
|
+
entry = HistoryEntry(
|
|
110
|
+
host=host,
|
|
111
|
+
command=command,
|
|
112
|
+
exit_code=exit_code,
|
|
113
|
+
duration_ms=duration_ms,
|
|
114
|
+
stdout_preview=stdout[:200] if stdout else "",
|
|
115
|
+
stderr_preview=stderr[:200] if stderr else "",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
with self._lock:
|
|
119
|
+
if host not in self._history:
|
|
120
|
+
self._history[host] = deque(maxlen=self.max_entries_per_host)
|
|
121
|
+
|
|
122
|
+
self._history[host].append(entry)
|
|
123
|
+
self._total_count += 1
|
|
124
|
+
|
|
125
|
+
# Enforce total limit
|
|
126
|
+
while self._total_count > self.max_total_entries:
|
|
127
|
+
self._evict_oldest()
|
|
128
|
+
|
|
129
|
+
if self.persistence_file:
|
|
130
|
+
self._save()
|
|
131
|
+
|
|
132
|
+
return entry
|
|
133
|
+
|
|
134
|
+
def get_history(
|
|
135
|
+
self,
|
|
136
|
+
host: str | None = None,
|
|
137
|
+
limit: int = 50,
|
|
138
|
+
success_only: bool = False,
|
|
139
|
+
failed_only: bool = False,
|
|
140
|
+
) -> list[HistoryEntry]:
|
|
141
|
+
"""
|
|
142
|
+
Get command history.
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
host: Filter by host (None for all hosts).
|
|
146
|
+
limit: Maximum entries to return.
|
|
147
|
+
success_only: Only return successful commands.
|
|
148
|
+
failed_only: Only return failed commands.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
List of history entries, newest first.
|
|
152
|
+
"""
|
|
153
|
+
with self._lock:
|
|
154
|
+
if host:
|
|
155
|
+
entries = list(self._history.get(host, []))
|
|
156
|
+
else:
|
|
157
|
+
entries = []
|
|
158
|
+
for host_entries in self._history.values():
|
|
159
|
+
entries.extend(host_entries)
|
|
160
|
+
|
|
161
|
+
# Filter
|
|
162
|
+
if success_only:
|
|
163
|
+
entries = [e for e in entries if e.exit_code == 0]
|
|
164
|
+
elif failed_only:
|
|
165
|
+
entries = [e for e in entries if e.exit_code != 0]
|
|
166
|
+
|
|
167
|
+
# Sort by timestamp descending and limit
|
|
168
|
+
entries.sort(key=lambda e: e.timestamp, reverse=True)
|
|
169
|
+
return entries[:limit]
|
|
170
|
+
|
|
171
|
+
def search(
|
|
172
|
+
self,
|
|
173
|
+
pattern: str,
|
|
174
|
+
host: str | None = None,
|
|
175
|
+
limit: int = 50,
|
|
176
|
+
) -> list[HistoryEntry]:
|
|
177
|
+
"""
|
|
178
|
+
Search command history.
|
|
179
|
+
|
|
180
|
+
Args:
|
|
181
|
+
pattern: Pattern to search for in commands.
|
|
182
|
+
host: Filter by host.
|
|
183
|
+
limit: Maximum entries to return.
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Matching history entries.
|
|
187
|
+
"""
|
|
188
|
+
entries = self.get_history(host=host, limit=self.max_total_entries)
|
|
189
|
+
pattern_lower = pattern.lower()
|
|
190
|
+
matches = [e for e in entries if pattern_lower in e.command.lower()]
|
|
191
|
+
return matches[:limit]
|
|
192
|
+
|
|
193
|
+
def get_last_command(self, host: str) -> HistoryEntry | None:
|
|
194
|
+
"""
|
|
195
|
+
Get the last command executed on a host.
|
|
196
|
+
|
|
197
|
+
Args:
|
|
198
|
+
host: Host name.
|
|
199
|
+
|
|
200
|
+
Returns:
|
|
201
|
+
Last history entry or None.
|
|
202
|
+
"""
|
|
203
|
+
with self._lock:
|
|
204
|
+
if host in self._history and self._history[host]:
|
|
205
|
+
return self._history[host][-1]
|
|
206
|
+
return None
|
|
207
|
+
|
|
208
|
+
def get_hosts(self) -> list[str]:
|
|
209
|
+
"""Get list of hosts with history."""
|
|
210
|
+
with self._lock:
|
|
211
|
+
return list(self._history.keys())
|
|
212
|
+
|
|
213
|
+
def get_stats(self, host: str | None = None) -> dict[str, Any]:
|
|
214
|
+
"""
|
|
215
|
+
Get history statistics.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
host: Filter by host (None for all).
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
Statistics dictionary.
|
|
222
|
+
"""
|
|
223
|
+
entries = self.get_history(host=host, limit=self.max_total_entries)
|
|
224
|
+
|
|
225
|
+
if not entries:
|
|
226
|
+
return {
|
|
227
|
+
"total_commands": 0,
|
|
228
|
+
"success_count": 0,
|
|
229
|
+
"failure_count": 0,
|
|
230
|
+
"success_rate": 0.0,
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
success_count = sum(1 for e in entries if e.exit_code == 0)
|
|
234
|
+
failure_count = len(entries) - success_count
|
|
235
|
+
|
|
236
|
+
return {
|
|
237
|
+
"total_commands": len(entries),
|
|
238
|
+
"success_count": success_count,
|
|
239
|
+
"failure_count": failure_count,
|
|
240
|
+
"success_rate": round(success_count / len(entries) * 100, 1),
|
|
241
|
+
"avg_duration_ms": round(
|
|
242
|
+
sum(e.duration_ms for e in entries) / len(entries)
|
|
243
|
+
),
|
|
244
|
+
"hosts": len(set(e.host for e in entries)),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
def clear(self, host: str | None = None) -> int:
|
|
248
|
+
"""
|
|
249
|
+
Clear history.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
host: Host to clear (None for all).
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
Number of entries cleared.
|
|
256
|
+
"""
|
|
257
|
+
with self._lock:
|
|
258
|
+
if host:
|
|
259
|
+
if host in self._history:
|
|
260
|
+
count = len(self._history[host])
|
|
261
|
+
del self._history[host]
|
|
262
|
+
self._total_count -= count
|
|
263
|
+
return count
|
|
264
|
+
return 0
|
|
265
|
+
else:
|
|
266
|
+
count = self._total_count
|
|
267
|
+
self._history.clear()
|
|
268
|
+
self._total_count = 0
|
|
269
|
+
return count
|
|
270
|
+
|
|
271
|
+
def _evict_oldest(self) -> None:
|
|
272
|
+
"""Evict oldest entry across all hosts."""
|
|
273
|
+
oldest_host = None
|
|
274
|
+
oldest_time = None
|
|
275
|
+
|
|
276
|
+
for host, entries in self._history.items():
|
|
277
|
+
if entries:
|
|
278
|
+
entry_time = entries[0].timestamp
|
|
279
|
+
if oldest_time is None or entry_time < oldest_time:
|
|
280
|
+
oldest_time = entry_time
|
|
281
|
+
oldest_host = host
|
|
282
|
+
|
|
283
|
+
if oldest_host and self._history[oldest_host]:
|
|
284
|
+
self._history[oldest_host].popleft()
|
|
285
|
+
self._total_count -= 1
|
|
286
|
+
|
|
287
|
+
if not self._history[oldest_host]:
|
|
288
|
+
del self._history[oldest_host]
|
|
289
|
+
|
|
290
|
+
def _save(self) -> None:
|
|
291
|
+
"""Save history to file."""
|
|
292
|
+
if not self.persistence_file:
|
|
293
|
+
return
|
|
294
|
+
|
|
295
|
+
try:
|
|
296
|
+
path = Path(self.persistence_file)
|
|
297
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
298
|
+
|
|
299
|
+
with self._lock:
|
|
300
|
+
data = {
|
|
301
|
+
host: [e.to_dict() for e in entries]
|
|
302
|
+
for host, entries in self._history.items()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
with open(path, "w") as f:
|
|
306
|
+
json.dump(data, f, indent=2)
|
|
307
|
+
|
|
308
|
+
except Exception as e:
|
|
309
|
+
logger.error("history_save_error", error=str(e))
|
|
310
|
+
|
|
311
|
+
def _load(self) -> None:
|
|
312
|
+
"""Load history from file."""
|
|
313
|
+
if not self.persistence_file:
|
|
314
|
+
return
|
|
315
|
+
|
|
316
|
+
path = Path(self.persistence_file)
|
|
317
|
+
if not path.exists():
|
|
318
|
+
return
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
with open(path) as f:
|
|
322
|
+
data = json.load(f)
|
|
323
|
+
|
|
324
|
+
with self._lock:
|
|
325
|
+
for host, entries in data.items():
|
|
326
|
+
self._history[host] = deque(
|
|
327
|
+
[HistoryEntry.from_dict(e) for e in entries],
|
|
328
|
+
maxlen=self.max_entries_per_host,
|
|
329
|
+
)
|
|
330
|
+
self._total_count += len(self._history[host])
|
|
331
|
+
|
|
332
|
+
logger.info("history_loaded", entries=self._total_count)
|
|
333
|
+
|
|
334
|
+
except Exception as e:
|
|
335
|
+
logger.error("history_load_error", error=str(e))
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# Global history instance
|
|
339
|
+
_history: CommandHistory | None = None
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def get_history() -> CommandHistory:
|
|
343
|
+
"""Get or create the global command history."""
|
|
344
|
+
global _history
|
|
345
|
+
if _history is None:
|
|
346
|
+
_history = CommandHistory()
|
|
347
|
+
return _history
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def init_history(
|
|
351
|
+
max_entries_per_host: int = 100,
|
|
352
|
+
max_total_entries: int = 1000,
|
|
353
|
+
persistence_file: str | None = None,
|
|
354
|
+
) -> CommandHistory:
|
|
355
|
+
"""
|
|
356
|
+
Initialize the global command history.
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
max_entries_per_host: Maximum entries per host.
|
|
360
|
+
max_total_entries: Maximum total entries.
|
|
361
|
+
persistence_file: Optional persistence file path.
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
Initialized CommandHistory.
|
|
365
|
+
"""
|
|
366
|
+
global _history
|
|
367
|
+
_history = CommandHistory(
|
|
368
|
+
max_entries_per_host=max_entries_per_host,
|
|
369
|
+
max_total_entries=max_total_entries,
|
|
370
|
+
persistence_file=persistence_file,
|
|
371
|
+
)
|
|
372
|
+
return _history
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
"""MCP Tool for process management."""
|
|
2
|
+
|
|
3
|
+
from typing import Any, Literal
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
|
|
7
|
+
from sshmcp.config import get_machine
|
|
8
|
+
from sshmcp.security.audit import get_audit_logger
|
|
9
|
+
from sshmcp.ssh.client import SSHExecutionError
|
|
10
|
+
from sshmcp.ssh.pool import get_pool
|
|
11
|
+
|
|
12
|
+
logger = structlog.get_logger()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
ServiceManager = Literal["systemd", "pm2", "supervisor", "auto"]
|
|
16
|
+
ProcessAction = Literal["start", "stop", "restart", "status"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def manage_process(
|
|
20
|
+
host: str,
|
|
21
|
+
action: ProcessAction,
|
|
22
|
+
process_name: str,
|
|
23
|
+
service_manager: ServiceManager = "auto",
|
|
24
|
+
) -> dict[str, Any]:
|
|
25
|
+
"""
|
|
26
|
+
Manage processes on remote VPS server.
|
|
27
|
+
|
|
28
|
+
Supports systemd, pm2, and supervisor service managers.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
host: Name of the host from machines.json configuration.
|
|
32
|
+
action: Action to perform (start, stop, restart, status).
|
|
33
|
+
process_name: Name of the process or service.
|
|
34
|
+
service_manager: Service manager to use (systemd, pm2, supervisor, auto).
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
Dictionary with:
|
|
38
|
+
- success: Whether action was successful
|
|
39
|
+
- action: Action that was performed
|
|
40
|
+
- process: Process name
|
|
41
|
+
- status: Current process status (if available)
|
|
42
|
+
- output: Command output
|
|
43
|
+
- host: Host where action was performed
|
|
44
|
+
|
|
45
|
+
Raises:
|
|
46
|
+
ValueError: If host not found or invalid parameters.
|
|
47
|
+
RuntimeError: If action fails.
|
|
48
|
+
|
|
49
|
+
Example:
|
|
50
|
+
>>> manage_process("production-server", "restart", "nginx")
|
|
51
|
+
{"success": true, "action": "restart", "process": "nginx", ...}
|
|
52
|
+
"""
|
|
53
|
+
audit = get_audit_logger()
|
|
54
|
+
|
|
55
|
+
# Validate action
|
|
56
|
+
valid_actions = ("start", "stop", "restart", "status")
|
|
57
|
+
if action not in valid_actions:
|
|
58
|
+
raise ValueError(f"Invalid action: {action}. Must be one of {valid_actions}")
|
|
59
|
+
|
|
60
|
+
# Get machine configuration
|
|
61
|
+
try:
|
|
62
|
+
machine = get_machine(host)
|
|
63
|
+
except Exception as e:
|
|
64
|
+
raise ValueError(f"Host not found: {host}") from e
|
|
65
|
+
|
|
66
|
+
# Get pool and client
|
|
67
|
+
pool = get_pool()
|
|
68
|
+
pool.register_machine(machine)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
client = pool.get_client(host)
|
|
72
|
+
try:
|
|
73
|
+
# Detect service manager if auto
|
|
74
|
+
if service_manager == "auto":
|
|
75
|
+
service_manager = _detect_service_manager(client, process_name)
|
|
76
|
+
|
|
77
|
+
# Build and execute command
|
|
78
|
+
command = _build_process_command(service_manager, action, process_name)
|
|
79
|
+
|
|
80
|
+
result = client.execute(command)
|
|
81
|
+
|
|
82
|
+
# Parse status if requested
|
|
83
|
+
status = None
|
|
84
|
+
if action == "status":
|
|
85
|
+
status = _parse_status(service_manager, result.stdout, result.exit_code)
|
|
86
|
+
|
|
87
|
+
audit.log(
|
|
88
|
+
event="process_managed",
|
|
89
|
+
host=host,
|
|
90
|
+
metadata={
|
|
91
|
+
"action": action,
|
|
92
|
+
"process": process_name,
|
|
93
|
+
"service_manager": service_manager,
|
|
94
|
+
"exit_code": result.exit_code,
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
"success": result.exit_code == 0 or (action == "status"),
|
|
100
|
+
"action": action,
|
|
101
|
+
"process": process_name,
|
|
102
|
+
"service_manager": service_manager,
|
|
103
|
+
"status": status,
|
|
104
|
+
"output": result.stdout or result.stderr,
|
|
105
|
+
"exit_code": result.exit_code,
|
|
106
|
+
"host": host,
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
finally:
|
|
110
|
+
pool.release_client(client)
|
|
111
|
+
|
|
112
|
+
except SSHExecutionError as e:
|
|
113
|
+
raise RuntimeError(f"Process management failed: {e}") from e
|
|
114
|
+
except Exception as e:
|
|
115
|
+
raise RuntimeError(f"SSH error: {e}") from e
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _detect_service_manager(client: Any, process_name: str) -> str:
|
|
119
|
+
"""Detect which service manager to use."""
|
|
120
|
+
# Try systemd first
|
|
121
|
+
try:
|
|
122
|
+
result = client.execute(f"systemctl is-enabled {process_name} 2>/dev/null")
|
|
123
|
+
if (
|
|
124
|
+
result.exit_code == 0
|
|
125
|
+
or "enabled" in result.stdout
|
|
126
|
+
or "disabled" in result.stdout
|
|
127
|
+
):
|
|
128
|
+
return "systemd"
|
|
129
|
+
except Exception:
|
|
130
|
+
pass
|
|
131
|
+
|
|
132
|
+
# Try pm2
|
|
133
|
+
try:
|
|
134
|
+
result = client.execute("which pm2 2>/dev/null")
|
|
135
|
+
if result.exit_code == 0:
|
|
136
|
+
result = client.execute(f"pm2 describe {process_name} 2>/dev/null")
|
|
137
|
+
if result.exit_code == 0:
|
|
138
|
+
return "pm2"
|
|
139
|
+
except Exception:
|
|
140
|
+
pass
|
|
141
|
+
|
|
142
|
+
# Try supervisor
|
|
143
|
+
try:
|
|
144
|
+
result = client.execute("which supervisorctl 2>/dev/null")
|
|
145
|
+
if result.exit_code == 0:
|
|
146
|
+
return "supervisor"
|
|
147
|
+
except Exception:
|
|
148
|
+
pass
|
|
149
|
+
|
|
150
|
+
# Default to systemd
|
|
151
|
+
return "systemd"
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _build_process_command(
|
|
155
|
+
service_manager: str,
|
|
156
|
+
action: str,
|
|
157
|
+
process_name: str,
|
|
158
|
+
) -> str:
|
|
159
|
+
"""Build command for the service manager."""
|
|
160
|
+
if service_manager == "systemd":
|
|
161
|
+
return f"systemctl {action} {process_name}"
|
|
162
|
+
elif service_manager == "pm2":
|
|
163
|
+
if action == "status":
|
|
164
|
+
return f"pm2 describe {process_name}"
|
|
165
|
+
return f"pm2 {action} {process_name}"
|
|
166
|
+
elif service_manager == "supervisor":
|
|
167
|
+
if action == "status":
|
|
168
|
+
return f"supervisorctl status {process_name}"
|
|
169
|
+
return f"supervisorctl {action} {process_name}"
|
|
170
|
+
else:
|
|
171
|
+
raise ValueError(f"Unknown service manager: {service_manager}")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_status(
|
|
175
|
+
service_manager: str,
|
|
176
|
+
output: str,
|
|
177
|
+
exit_code: int,
|
|
178
|
+
) -> str:
|
|
179
|
+
"""Parse status from command output."""
|
|
180
|
+
output_lower = output.lower()
|
|
181
|
+
|
|
182
|
+
if service_manager == "systemd":
|
|
183
|
+
if "active (running)" in output_lower:
|
|
184
|
+
return "running"
|
|
185
|
+
elif "inactive" in output_lower:
|
|
186
|
+
return "stopped"
|
|
187
|
+
elif "failed" in output_lower:
|
|
188
|
+
return "failed"
|
|
189
|
+
elif "activating" in output_lower:
|
|
190
|
+
return "starting"
|
|
191
|
+
else:
|
|
192
|
+
return "unknown"
|
|
193
|
+
|
|
194
|
+
elif service_manager == "pm2":
|
|
195
|
+
if "online" in output_lower:
|
|
196
|
+
return "running"
|
|
197
|
+
elif "stopped" in output_lower:
|
|
198
|
+
return "stopped"
|
|
199
|
+
elif "errored" in output_lower:
|
|
200
|
+
return "failed"
|
|
201
|
+
else:
|
|
202
|
+
return "unknown"
|
|
203
|
+
|
|
204
|
+
elif service_manager == "supervisor":
|
|
205
|
+
if "running" in output_lower:
|
|
206
|
+
return "running"
|
|
207
|
+
elif "stopped" in output_lower:
|
|
208
|
+
return "stopped"
|
|
209
|
+
elif "fatal" in output_lower or "error" in output_lower:
|
|
210
|
+
return "failed"
|
|
211
|
+
else:
|
|
212
|
+
return "unknown"
|
|
213
|
+
|
|
214
|
+
return "unknown"
|