Skip to content

Commit 3860267

Browse files
authored
Merge pull request #3 from yechielb2000/feature/parallel-rce
2 parents 220a19e + dc5ed7a commit 3860267

10 files changed

Lines changed: 269 additions & 5 deletions

File tree

README.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,33 @@ with RemoteMachine("example.com", "user") as conn:
161161
all_vars = conn.env.list()
162162
```
163163

164+
## Parallel Execution
165+
166+
Run commands on multiple machines concurrently for efficient bulk operations.
167+
168+
```python
169+
from remote_machine import RemoteMachine
170+
from remote_machine.parallel import ParallelExecutor
171+
172+
# Create connections to multiple hosts
173+
clients = [
174+
RemoteMachine("host1.example.com", "user", key_path="~/.ssh/id_rsa"),
175+
RemoteMachine("host2.example.com", "user", key_path="~/.ssh/id_rsa"),
176+
RemoteMachine("host3.example.com", "user", key_path="~/.ssh/id_rsa"),
177+
]
178+
179+
# Execute command in parallel
180+
executor = ParallelExecutor(max_workers=5)
181+
results = executor.run(clients, "uptime")
182+
183+
# Process results
184+
for result in results:
185+
if result.success:
186+
print(f"{result.host}: {result.output}")
187+
else:
188+
print(f"{result.host} failed: {result.error}")
189+
```
190+
164191
## Logging & Telemetry
165192

166193
RemoteMachine supports structured logging and optional telemetry for monitoring and observability.

remote_machine/actions/sys.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ def info(self) -> SystemInfo:
5959

6060
def uname(self) -> UnameInfo:
6161
"""Get system name and information as a dataclass."""
62-
sysname = self.protocol.run_command("uname -s", self.state)
63-
nodename = self.protocol.run_command("uname -n", self.state)
64-
release = self.protocol.run_command("uname -r", self.state)
65-
version = self.protocol.run_command("uname -v", self.state)
66-
machine = self.protocol.run_command("uname -m", self.state)
62+
sysname = self.protocol.run_command("uname -s", self.state).strip()
63+
nodename = self.protocol.run_command("uname -n", self.state).strip()
64+
release = self.protocol.run_command("uname -r", self.state).strip()
65+
version = self.protocol.run_command("uname -v", self.state).strip()
66+
machine = self.protocol.run_command("uname -m", self.state).strip()
6767

6868
return UnameInfo(
6969
sysname=sysname,

remote_machine/core.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from remote_machine import actions
99
from remote_machine.errors import ProtocolNotAvailable, PermissionDenied
10+
from remote_machine.errors.error_mapper import ErrorMapper
1011
from remote_machine.models.capabilities import Capabilities
1112
from remote_machine.models.remote_state import RemoteState
1213
from remote_machine.protocols.ssh import SSHProtocol
@@ -146,6 +147,28 @@ def _initialize_state(self) -> None:
146147
result = ssh.exec("sudo -n true", self.state)
147148
self.state.has_sudo = result.exit_code == 0
148149

150+
def run(self, command: str) -> str:
151+
"""Run a command on the remote machine and return stdout.
152+
153+
Args:
154+
command: Command to execute
155+
156+
Returns:
157+
stdout as string
158+
159+
Raises:
160+
Appropriate exception if command fails
161+
"""
162+
ssh: SSHProtocol = self._protocols["ssh"]
163+
result = ssh.exec(command, self.state)
164+
ErrorMapper.raise_if_error(result)
165+
return result.stdout
166+
167+
@property
168+
def host(self) -> str:
169+
"""Return the remote host address."""
170+
return self._protocols["ssh"].host
171+
149172
def add_ssh_layer(self, ssh: SSHProtocol) -> None:
150173
"""Add an SSHProtocol to the layers (for tunnel chaining)."""
151174
self._ssh_layers.append(ssh)
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""Parallel execution module."""
2+
3+
from .executor import ParallelExecutor
4+
from .result import ParallelResult
5+
6+
__all__ = ["ParallelExecutor", "ParallelResult"]
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import time
2+
from concurrent.futures import ThreadPoolExecutor, as_completed
3+
from typing import Iterable, List
4+
5+
from ..logging import get_logger
6+
from .result import ParallelResult
7+
8+
logger = get_logger(__name__)
9+
10+
11+
class ParallelExecutor:
12+
def __init__(self, max_workers: int = 10):
13+
if max_workers <= 0:
14+
raise ValueError("max_workers must be > 0")
15+
self.max_workers = max_workers
16+
17+
def run(self, clients: Iterable, command: str) -> List[ParallelResult]:
18+
"""
19+
Run the same command across multiple RemoteMachine instances in parallel.
20+
"""
21+
22+
results: List[ParallelResult] = []
23+
24+
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
25+
future_map = {
26+
executor.submit(self._execute, client, command): client
27+
for client in clients
28+
}
29+
30+
for future in as_completed(future_map):
31+
result = future.result()
32+
results.append(result)
33+
34+
return results
35+
36+
def _execute(self, client, command: str) -> ParallelResult:
37+
start = time.time()
38+
try:
39+
output = client.run(command)
40+
duration_ms = (time.time() - start) * 1000
41+
42+
logger.info(
43+
"parallel_command_success",
44+
extra={
45+
"host": client.host,
46+
"duration_ms": duration_ms,
47+
},
48+
)
49+
50+
return ParallelResult(
51+
host=client.host,
52+
success=True,
53+
output=output,
54+
error=None,
55+
duration_ms=duration_ms,
56+
)
57+
58+
except Exception as e:
59+
duration_ms = (time.time() - start) * 1000
60+
61+
logger.error(
62+
"parallel_command_failed",
63+
extra={
64+
"host": client.host,
65+
"error": str(e),
66+
},
67+
)
68+
69+
return ParallelResult(
70+
host=client.host,
71+
success=False,
72+
output=None,
73+
error=e,
74+
duration_ms=duration_ms,
75+
)

remote_machine/parallel/result.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from dataclasses import dataclass
2+
from typing import Any
3+
4+
5+
@dataclass
6+
class ParallelResult:
7+
host: str
8+
success: bool
9+
output: Any | None
10+
error: Exception | None
11+
duration_ms: float

tests/test_net_actions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,12 @@ def exec(self, command: str, state):
2323
command=command, stdout="", stderr="", exit_code=0, success=True
2424
)
2525

26+
def run_command(self, command: str, state, thread: bool = False) -> str:
27+
result = self.exec(command, state)
28+
if result.exit_code != 0:
29+
raise Exception(f"Command failed: {result.stderr}")
30+
return result.stdout
31+
2632

2733
def test_interfaces_uses_parser(monkeypatch):
2834
ip_mod = types.ModuleType("linux_parsers.parsers.network.ip")

tests/test_parallel.py

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
"""Unit tests for parallel execution."""
2+
3+
import pytest
4+
pytestmark = [pytest.mark.parallel]
5+
6+
import os
7+
import sys
8+
import time
9+
10+
# Make the package importable in tests run in this environment
11+
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), "..")))
12+
13+
from remote_machine.parallel import ParallelExecutor, ParallelResult
14+
from remote_machine.models.command_result import CommandResult
15+
from remote_machine.models.remote_state import RemoteState
16+
17+
18+
class FakeRemoteMachine:
19+
def __init__(self, host: str, responses: dict[str, str] = None, should_fail: bool = False):
20+
self.host = host
21+
self.responses = responses or {}
22+
self.should_fail = should_fail
23+
24+
def run(self, command: str) -> str:
25+
if self.should_fail:
26+
raise Exception("Simulated failure")
27+
return self.responses.get(command, f"executed {command} on {self.host}")
28+
29+
30+
def test_parallel_executor_success():
31+
"""Test successful parallel execution."""
32+
clients = [
33+
FakeRemoteMachine("host1"),
34+
FakeRemoteMachine("host2"),
35+
FakeRemoteMachine("host3"),
36+
]
37+
38+
executor = ParallelExecutor(max_workers=2)
39+
results = executor.run(clients, "uptime")
40+
41+
assert len(results) == 3
42+
for result in results:
43+
assert result.success
44+
assert result.error is None
45+
assert result.duration_ms >= 0
46+
assert "uptime" in result.output
47+
assert result.host in ["host1", "host2", "host3"]
48+
49+
50+
def test_parallel_executor_failure():
51+
"""Test parallel execution with failures."""
52+
clients = [
53+
FakeRemoteMachine("host1"),
54+
FakeRemoteMachine("host2", should_fail=True),
55+
FakeRemoteMachine("host3"),
56+
]
57+
58+
executor = ParallelExecutor(max_workers=2)
59+
results = executor.run(clients, "uptime")
60+
61+
assert len(results) == 3
62+
63+
success_results = [r for r in results if r.success]
64+
failure_results = [r for r in results if not r.success]
65+
66+
assert len(success_results) == 2
67+
assert len(failure_results) == 1
68+
69+
failure = failure_results[0]
70+
assert failure.host == "host2"
71+
assert failure.output is None
72+
assert isinstance(failure.error, Exception)
73+
assert str(failure.error) == "Simulated failure"
74+
75+
76+
def test_parallel_executor_max_workers():
77+
"""Test that max_workers limits concurrency."""
78+
# This is hard to test directly, but we can check the parameter validation
79+
with pytest.raises(ValueError):
80+
ParallelExecutor(max_workers=0)
81+
82+
with pytest.raises(ValueError):
83+
ParallelExecutor(max_workers=-1)
84+
85+
# Valid
86+
executor = ParallelExecutor(max_workers=5)
87+
assert executor.max_workers == 5
88+
89+
90+
def test_parallel_result_structure():
91+
"""Test ParallelResult dataclass structure."""
92+
result = ParallelResult(
93+
host="testhost",
94+
success=True,
95+
output="output",
96+
error=None,
97+
duration_ms=123.45
98+
)
99+
100+
assert result.host == "testhost"
101+
assert result.success is True
102+
assert result.output == "output"
103+
assert result.error is None
104+
assert result.duration_ms == 123.45

tests/test_ps_actions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ def exec(self, command: str, state: RemoteState) -> CommandResult:
2222
return CommandResult(command=command, stdout=out, stderr="", exit_code=0)
2323
return CommandResult(command=command, stdout="", stderr="", exit_code=0)
2424

25+
def run_command(self, command: str, state: RemoteState, thread: bool = False) -> str:
26+
result = self.exec(command, state)
27+
if result.exit_code != 0:
28+
raise Exception(f"Command failed: {result.stderr}")
29+
return result.stdout
30+
2531

2632
def test_list_uses_parser(monkeypatch):
2733
ps_mod = types.ModuleType("linux_parsers.parsers.process.ps")

tests/test_sys_actions.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,12 @@ def exec(self, command: str, state: RemoteState) -> CommandResult:
3030
# default empty
3131
return CommandResult(command=command, stdout="", stderr="", exit_code=0)
3232

33+
def run_command(self, command: str, state: RemoteState, thread: bool = False) -> str:
34+
result = self.exec(command, state)
35+
if result.exit_code != 0:
36+
raise Exception(f"Command failed: {result.stderr}")
37+
return result.stdout
38+
3339

3440
def test_uname_parsing():
3541
responses = {

0 commit comments

Comments
 (0)