Compare commits
2 Commits
queue/322-
...
fix/457-ss
| Author | SHA1 | Date | |
|---|---|---|---|
| 66b0febdfb | |||
| 6d79bf7783 |
@@ -182,6 +182,15 @@ _SCRIPT_FAILURE_PHRASES = (
|
||||
"exit status",
|
||||
"non-zero exit",
|
||||
"did not complete",
|
||||
# SSH-specific failure patterns (#350)
|
||||
"no such file or directory",
|
||||
"command not found",
|
||||
"hermes binary not found",
|
||||
"hermes not found",
|
||||
"ssh: connect to host",
|
||||
"connection timed out",
|
||||
"host key verification failed",
|
||||
"no route to host",
|
||||
"could not run",
|
||||
"unable to execute",
|
||||
"permission denied",
|
||||
|
||||
212
cron/ssh_dispatch.py
Normal file
212
cron/ssh_dispatch.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""
|
||||
SSH dispatch utilities for cron jobs.
|
||||
|
||||
Provides validated remote execution so broken hermes binary paths
|
||||
are caught before draining the dispatch queue.
|
||||
|
||||
Usage:
|
||||
from cron.ssh_dispatch import SSHEnvironment, format_dispatch_report
|
||||
|
||||
ssh = SSHEnvironment(host="root@ezra", agent="allegro")
|
||||
result = ssh.dispatch("cron tick")
|
||||
if not result.success:
|
||||
print(result.failure_reason)
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
import shutil
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class DispatchResult:
|
||||
"""Structured result of a remote command dispatch."""
|
||||
host: str
|
||||
command: str
|
||||
success: bool
|
||||
exit_code: Optional[int] = None
|
||||
stdout: str = ""
|
||||
stderr: str = ""
|
||||
failure_reason: Optional[str] = None
|
||||
duration_s: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHEnvironment:
|
||||
"""Validates and dispatches commands to a remote host via SSH."""
|
||||
|
||||
host: str # e.g. "root@ezra" or "192.168.1.10"
|
||||
agent: str = "" # agent name for logging
|
||||
hermes_path: Optional[str] = None # explicit path, auto-detected if None
|
||||
timeout: int = 120 # seconds
|
||||
_validated_path: Optional[str] = field(default=None, init=False, repr=False)
|
||||
|
||||
def _ssh_base(self) -> List[str]:
|
||||
return [
|
||||
"ssh",
|
||||
"-o", "ConnectTimeout=10",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "BatchMode=yes",
|
||||
self.host,
|
||||
]
|
||||
|
||||
def _probe_remote_binary(self, candidate: str) -> bool:
|
||||
"""Check if a hermes binary exists and is executable on the remote host."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
self._ssh_base() + [f"test -x {candidate}"],
|
||||
capture_output=True, timeout=15,
|
||||
)
|
||||
return result.returncode == 0
|
||||
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||
return False
|
||||
|
||||
def detect_hermes_binary(self) -> Optional[str]:
|
||||
"""Find a working hermes binary on the remote host."""
|
||||
if self._validated_path:
|
||||
return self._validated_path
|
||||
|
||||
candidates = []
|
||||
if self.hermes_path:
|
||||
candidates.append(self.hermes_path)
|
||||
|
||||
# Common locations
|
||||
candidates.extend([
|
||||
"hermes", # on PATH
|
||||
"~/.local/bin/hermes",
|
||||
"/usr/local/bin/hermes",
|
||||
f"~/wizards/{self.agent}/venv/bin/hermes" if self.agent else "",
|
||||
f"/root/wizards/{self.agent}/venv/bin/hermes" if self.agent else "",
|
||||
])
|
||||
candidates = [c for c in candidates if c]
|
||||
|
||||
for candidate in candidates:
|
||||
if self._probe_remote_binary(candidate):
|
||||
self._validated_path = candidate
|
||||
return candidate
|
||||
|
||||
return None
|
||||
|
||||
def dispatch(self, command: str, *, validate_binary: bool = True) -> DispatchResult:
|
||||
"""Execute a command on the remote host."""
|
||||
import time
|
||||
start = time.monotonic()
|
||||
|
||||
if validate_binary:
|
||||
binary = self.detect_hermes_binary()
|
||||
if not binary:
|
||||
return DispatchResult(
|
||||
host=self.host,
|
||||
command=command,
|
||||
success=False,
|
||||
failure_reason=f"No working hermes binary found on {self.host}",
|
||||
duration_s=time.monotonic() - start,
|
||||
)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
self._ssh_base() + [command],
|
||||
capture_output=True,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
duration = time.monotonic() - start
|
||||
stdout = result.stdout.decode("utf-8", errors="replace")
|
||||
stderr = result.stderr.decode("utf-8", errors="replace")
|
||||
|
||||
failure_reason = None
|
||||
if result.returncode != 0:
|
||||
failure_reason = _classify_ssh_error(stderr, result.returncode)
|
||||
|
||||
return DispatchResult(
|
||||
host=self.host,
|
||||
command=command,
|
||||
success=result.returncode == 0,
|
||||
exit_code=result.returncode,
|
||||
stdout=stdout,
|
||||
stderr=stderr,
|
||||
failure_reason=failure_reason,
|
||||
duration_s=duration,
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
return DispatchResult(
|
||||
host=self.host,
|
||||
command=command,
|
||||
success=False,
|
||||
failure_reason=f"SSH command timed out after {self.timeout}s",
|
||||
duration_s=time.monotonic() - start,
|
||||
)
|
||||
except FileNotFoundError:
|
||||
return DispatchResult(
|
||||
host=self.host,
|
||||
command=command,
|
||||
success=False,
|
||||
failure_reason="ssh binary not found on local system",
|
||||
duration_s=time.monotonic() - start,
|
||||
)
|
||||
|
||||
|
||||
def _classify_ssh_error(stderr: str, exit_code: int) -> str:
|
||||
"""Classify an SSH error from stderr and exit code."""
|
||||
lower = stderr.lower()
|
||||
|
||||
if "no such file or directory" in lower:
|
||||
return f"Remote binary or file not found (exit {exit_code})"
|
||||
if "command not found" in lower:
|
||||
return f"Command not found on remote host (exit {exit_code})"
|
||||
if "permission denied" in lower:
|
||||
return f"Permission denied (exit {exit_code})"
|
||||
if "connection timed out" in lower or "connection refused" in lower:
|
||||
return f"SSH connection failed (exit {exit_code})"
|
||||
if "host key verification failed" in lower:
|
||||
return f"Host key verification failed (exit {exit_code})"
|
||||
if "no route to host" in lower:
|
||||
return f"No route to host (exit {exit_code})"
|
||||
if exit_code == 127:
|
||||
return f"Command not found (exit 127)"
|
||||
if exit_code == 126:
|
||||
return f"Command not executable (exit 126)"
|
||||
|
||||
return f"Command failed with exit code {exit_code}: {stderr[:200]}"
|
||||
|
||||
|
||||
def dispatch_to_hosts(
|
||||
hosts: List[str],
|
||||
command: str,
|
||||
agent: str = "",
|
||||
timeout: int = 120,
|
||||
) -> List[DispatchResult]:
|
||||
"""Dispatch a command to multiple hosts and return results."""
|
||||
results = []
|
||||
for host in hosts:
|
||||
ssh = SSHEnvironment(host=host, agent=agent, timeout=timeout)
|
||||
result = ssh.dispatch(command)
|
||||
results.append(result)
|
||||
return results
|
||||
|
||||
|
||||
def format_dispatch_report(results: List[DispatchResult]) -> str:
|
||||
"""Format a human-readable report of dispatch results."""
|
||||
lines = ["## Dispatch Report", ""]
|
||||
|
||||
succeeded = [r for r in results if r.success]
|
||||
failed = [r for r in results if not r.success]
|
||||
|
||||
lines.append(f"**Total:** {len(results)} hosts | "
|
||||
f"**OK:** {len(succeeded)} | **Failed:** {len(failed)}")
|
||||
lines.append("")
|
||||
|
||||
for r in results:
|
||||
status = "OK" if r.success else "FAIL"
|
||||
lines.append(f"### {r.host} [{status}]")
|
||||
lines.append(f"- Command: `{r.command}`")
|
||||
lines.append(f"- Duration: {r.duration_s:.1f}s")
|
||||
if r.exit_code is not None:
|
||||
lines.append(f"- Exit code: {r.exit_code}")
|
||||
if r.failure_reason:
|
||||
lines.append(f"- **Failure:** {r.failure_reason}")
|
||||
if r.stderr and not r.success:
|
||||
lines.append(f"- Stderr: `{r.stderr[:300]}`")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
Reference in New Issue
Block a user