Copy CCLI source to backend for Docker builds
🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
1
backend/ccli_src/executors/__init__.py
Normal file
1
backend/ccli_src/executors/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Executors Package
|
||||
BIN
backend/ccli_src/executors/__pycache__/__init__.cpython-310.pyc
Normal file
BIN
backend/ccli_src/executors/__pycache__/__init__.cpython-310.pyc
Normal file
Binary file not shown.
Binary file not shown.
148
backend/ccli_src/executors/simple_ssh_executor.py
Normal file
148
backend/ccli_src/executors/simple_ssh_executor.py
Normal file
@@ -0,0 +1,148 @@
|
||||
"""
|
||||
Simple SSH Executor for CCLI
|
||||
Uses subprocess for SSH execution without external dependencies.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import subprocess
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHResult:
|
||||
"""Result of an SSH command execution"""
|
||||
stdout: str
|
||||
stderr: str
|
||||
returncode: int
|
||||
duration: float
|
||||
host: str
|
||||
command: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHConfig:
|
||||
"""SSH connection configuration"""
|
||||
host: str
|
||||
username: str = "tony"
|
||||
connect_timeout: int = 5
|
||||
command_timeout: int = 30
|
||||
max_retries: int = 2
|
||||
ssh_options: Optional[Dict[str, str]] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.ssh_options is None:
|
||||
self.ssh_options = {
|
||||
"BatchMode": "yes",
|
||||
"ConnectTimeout": str(self.connect_timeout),
|
||||
"StrictHostKeyChecking": "no"
|
||||
}
|
||||
|
||||
|
||||
class SimpleSSHExecutor:
|
||||
"""Simple SSH command executor using subprocess"""
|
||||
|
||||
def __init__(self):
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def execute(self, config: SSHConfig, command: str, **kwargs) -> SSHResult:
|
||||
"""Execute a command via SSH with retries and error handling"""
|
||||
|
||||
for attempt in range(config.max_retries + 1):
|
||||
try:
|
||||
return await self._execute_once(config, command, **kwargs)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.warning(f"SSH execution attempt {attempt + 1} failed for {config.host}: {e}")
|
||||
|
||||
if attempt < config.max_retries:
|
||||
await asyncio.sleep(1) # Brief delay before retry
|
||||
else:
|
||||
# Final attempt failed
|
||||
raise Exception(f"SSH execution failed after {config.max_retries + 1} attempts: {e}")
|
||||
|
||||
async def _execute_once(self, config: SSHConfig, command: str, **kwargs) -> SSHResult:
|
||||
"""Execute command once via SSH"""
|
||||
start_time = time.time()
|
||||
|
||||
# Build SSH command
|
||||
ssh_cmd = self._build_ssh_command(config, command)
|
||||
|
||||
try:
|
||||
# Execute command with timeout
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*ssh_cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
**kwargs
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(),
|
||||
timeout=config.command_timeout
|
||||
)
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
return SSHResult(
|
||||
stdout=stdout.decode('utf-8'),
|
||||
stderr=stderr.decode('utf-8'),
|
||||
returncode=process.returncode,
|
||||
duration=duration,
|
||||
host=config.host,
|
||||
command=command
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
duration = time.time() - start_time
|
||||
raise Exception(f"SSH command timeout after {config.command_timeout}s on {config.host}")
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
self.logger.error(f"SSH execution error on {config.host}: {e}")
|
||||
raise
|
||||
|
||||
def _build_ssh_command(self, config: SSHConfig, command: str) -> list:
|
||||
"""Build SSH command array"""
|
||||
ssh_cmd = ["ssh"]
|
||||
|
||||
# Add SSH options
|
||||
for option, value in config.ssh_options.items():
|
||||
ssh_cmd.extend(["-o", f"{option}={value}"])
|
||||
|
||||
# Add destination
|
||||
if config.username:
|
||||
destination = f"{config.username}@{config.host}"
|
||||
else:
|
||||
destination = config.host
|
||||
|
||||
ssh_cmd.append(destination)
|
||||
ssh_cmd.append(command)
|
||||
|
||||
return ssh_cmd
|
||||
|
||||
async def test_connection(self, config: SSHConfig) -> bool:
|
||||
"""Test if SSH connection is working"""
|
||||
try:
|
||||
result = await self.execute(config, "echo 'connection_test'")
|
||||
return result.returncode == 0 and "connection_test" in result.stdout
|
||||
except Exception as e:
|
||||
self.logger.error(f"Connection test failed for {config.host}: {e}")
|
||||
return False
|
||||
|
||||
async def get_connection_stats(self) -> Dict[str, Any]:
|
||||
"""Get statistics about current connections (simplified for subprocess)"""
|
||||
return {
|
||||
"total_connections": 0, # subprocess doesn't maintain persistent connections
|
||||
"connection_type": "subprocess"
|
||||
}
|
||||
|
||||
async def cleanup(self):
|
||||
"""Cleanup resources (no-op for subprocess)"""
|
||||
pass
|
||||
|
||||
|
||||
# Alias for compatibility
|
||||
SSHExecutor = SimpleSSHExecutor
|
||||
221
backend/ccli_src/executors/ssh_executor.py
Normal file
221
backend/ccli_src/executors/ssh_executor.py
Normal file
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
SSH Executor for CCLI
|
||||
Handles SSH connections, command execution, and connection pooling for CLI agents.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import asyncssh
|
||||
import time
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict, Any
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHResult:
|
||||
"""Result of an SSH command execution"""
|
||||
stdout: str
|
||||
stderr: str
|
||||
returncode: int
|
||||
duration: float
|
||||
host: str
|
||||
command: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class SSHConfig:
|
||||
"""SSH connection configuration"""
|
||||
host: str
|
||||
username: str = "tony"
|
||||
connect_timeout: int = 5
|
||||
command_timeout: int = 30
|
||||
max_retries: int = 2
|
||||
known_hosts: Optional[str] = None
|
||||
|
||||
|
||||
class SSHConnectionPool:
|
||||
"""Manages SSH connection pooling for efficiency"""
|
||||
|
||||
def __init__(self, pool_size: int = 3, persist_timeout: int = 60):
|
||||
self.pool_size = pool_size
|
||||
self.persist_timeout = persist_timeout
|
||||
self.connections: Dict[str, Dict[str, Any]] = {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def get_connection(self, config: SSHConfig) -> asyncssh.SSHClientConnection:
|
||||
"""Get a pooled SSH connection, creating if needed"""
|
||||
host_key = f"{config.username}@{config.host}"
|
||||
|
||||
# Check if we have a valid connection
|
||||
if host_key in self.connections:
|
||||
conn_info = self.connections[host_key]
|
||||
connection = conn_info['connection']
|
||||
|
||||
# Check if connection is still alive and not expired
|
||||
if (not connection.is_closed() and
|
||||
time.time() - conn_info['created'] < self.persist_timeout):
|
||||
self.logger.debug(f"Reusing connection to {host_key}")
|
||||
return connection
|
||||
else:
|
||||
# Connection expired or closed, remove it
|
||||
self.logger.debug(f"Connection to {host_key} expired, creating new one")
|
||||
await self._close_connection(host_key)
|
||||
|
||||
# Create new connection
|
||||
self.logger.debug(f"Creating new SSH connection to {host_key}")
|
||||
connection = await asyncssh.connect(
|
||||
config.host,
|
||||
username=config.username,
|
||||
connect_timeout=config.connect_timeout,
|
||||
known_hosts=config.known_hosts
|
||||
)
|
||||
|
||||
self.connections[host_key] = {
|
||||
'connection': connection,
|
||||
'created': time.time(),
|
||||
'uses': 0
|
||||
}
|
||||
|
||||
return connection
|
||||
|
||||
async def _close_connection(self, host_key: str):
|
||||
"""Close and remove a connection from the pool"""
|
||||
if host_key in self.connections:
|
||||
try:
|
||||
conn_info = self.connections[host_key]
|
||||
if not conn_info['connection'].is_closed():
|
||||
conn_info['connection'].close()
|
||||
await conn_info['connection'].wait_closed()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error closing connection to {host_key}: {e}")
|
||||
finally:
|
||||
del self.connections[host_key]
|
||||
|
||||
async def close_all(self):
|
||||
"""Close all pooled connections"""
|
||||
for host_key in list(self.connections.keys()):
|
||||
await self._close_connection(host_key)
|
||||
|
||||
|
||||
class SSHExecutor:
|
||||
"""Main SSH command executor with connection pooling and error handling"""
|
||||
|
||||
def __init__(self, pool_size: int = 3, persist_timeout: int = 60):
|
||||
self.pool = SSHConnectionPool(pool_size, persist_timeout)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
async def execute(self, config: SSHConfig, command: str, **kwargs) -> SSHResult:
|
||||
"""Execute a command via SSH with retries and error handling"""
|
||||
|
||||
for attempt in range(config.max_retries + 1):
|
||||
try:
|
||||
return await self._execute_once(config, command, **kwargs)
|
||||
|
||||
except (asyncssh.Error, asyncio.TimeoutError, OSError) as e:
|
||||
self.logger.warning(f"SSH execution attempt {attempt + 1} failed for {config.host}: {e}")
|
||||
|
||||
if attempt < config.max_retries:
|
||||
# Close any bad connections and retry
|
||||
host_key = f"{config.username}@{config.host}"
|
||||
await self.pool._close_connection(host_key)
|
||||
await asyncio.sleep(1) # Brief delay before retry
|
||||
else:
|
||||
# Final attempt failed
|
||||
raise Exception(f"SSH execution failed after {config.max_retries + 1} attempts: {e}")
|
||||
|
||||
async def _execute_once(self, config: SSHConfig, command: str, **kwargs) -> SSHResult:
|
||||
"""Execute command once via SSH"""
|
||||
start_time = time.time()
|
||||
|
||||
try:
|
||||
connection = await self.pool.get_connection(config)
|
||||
|
||||
# Execute command with timeout
|
||||
result = await asyncio.wait_for(
|
||||
connection.run(command, check=False, **kwargs),
|
||||
timeout=config.command_timeout
|
||||
)
|
||||
|
||||
duration = time.time() - start_time
|
||||
|
||||
# Update connection usage stats
|
||||
host_key = f"{config.username}@{config.host}"
|
||||
if host_key in self.pool.connections:
|
||||
self.pool.connections[host_key]['uses'] += 1
|
||||
|
||||
return SSHResult(
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
returncode=result.exit_status,
|
||||
duration=duration,
|
||||
host=config.host,
|
||||
command=command
|
||||
)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
duration = time.time() - start_time
|
||||
raise Exception(f"SSH command timeout after {config.command_timeout}s on {config.host}")
|
||||
|
||||
except Exception as e:
|
||||
duration = time.time() - start_time
|
||||
self.logger.error(f"SSH execution error on {config.host}: {e}")
|
||||
raise
|
||||
|
||||
async def test_connection(self, config: SSHConfig) -> bool:
|
||||
"""Test if SSH connection is working"""
|
||||
try:
|
||||
result = await self.execute(config, "echo 'connection_test'")
|
||||
return result.returncode == 0 and "connection_test" in result.stdout
|
||||
except Exception as e:
|
||||
self.logger.error(f"Connection test failed for {config.host}: {e}")
|
||||
return False
|
||||
|
||||
async def get_connection_stats(self) -> Dict[str, Any]:
|
||||
"""Get statistics about current connections"""
|
||||
stats = {
|
||||
"total_connections": len(self.pool.connections),
|
||||
"connections": {}
|
||||
}
|
||||
|
||||
for host_key, conn_info in self.pool.connections.items():
|
||||
stats["connections"][host_key] = {
|
||||
"created": conn_info["created"],
|
||||
"age_seconds": time.time() - conn_info["created"],
|
||||
"uses": conn_info["uses"],
|
||||
"is_closed": conn_info["connection"].is_closed()
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
async def cleanup(self):
|
||||
"""Close all connections and cleanup resources"""
|
||||
await self.pool.close_all()
|
||||
|
||||
@asynccontextmanager
|
||||
async def connection_context(self, config: SSHConfig):
|
||||
"""Context manager for SSH connections"""
|
||||
try:
|
||||
connection = await self.pool.get_connection(config)
|
||||
yield connection
|
||||
except Exception as e:
|
||||
self.logger.error(f"SSH connection context error: {e}")
|
||||
raise
|
||||
# Connection stays in pool for reuse
|
||||
|
||||
|
||||
# Module-level convenience functions
|
||||
_default_executor = None
|
||||
|
||||
def get_default_executor() -> SSHExecutor:
|
||||
"""Get the default SSH executor instance"""
|
||||
global _default_executor
|
||||
if _default_executor is None:
|
||||
_default_executor = SSHExecutor()
|
||||
return _default_executor
|
||||
|
||||
async def execute_ssh_command(host: str, command: str, **kwargs) -> SSHResult:
|
||||
"""Convenience function for simple SSH command execution"""
|
||||
config = SSHConfig(host=host)
|
||||
executor = get_default_executor()
|
||||
return await executor.execute(config, command, **kwargs)
|
||||
Reference in New Issue
Block a user