Skip to content

Commit 8c39f2e

Browse files
committed
added --dry-run flag to print ssh tasks
1 parent 20f8f9b commit 8c39f2e

6 files changed

Lines changed: 211 additions & 41 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sshsync"
3-
version = "0.8.1"
3+
version = "0.9.0"
44
description = "sshsync is a CLI tool to run shell commands across multiple servers via SSH, either on specific groups or all servers. Upcoming features include file push/pull support."
55
readme = "README.md"
66
authors = [

src/sshsync/cli.py

Lines changed: 66 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import asyncio
21
import importlib.metadata
32

43
import typer
@@ -12,6 +11,7 @@
1211
assign_groups_to_hosts,
1312
check_path_exists,
1413
list_configuration,
14+
print_dry_run_results,
1515
print_error,
1616
print_message,
1717
print_ssh_results,
@@ -29,21 +29,31 @@ def all(
2929
timeout: int = typer.Option(
3030
10, help="Timeout in seconds for SSH command execution."
3131
),
32+
dry_run: bool = typer.Option(
33+
False, help="Show command and host info without executing."
34+
),
3235
):
3336
"""
3437
Run a shell command on all configured hosts concurrently.
3538
3639
Args:
3740
cmd (str): The shell command to execute remotely.
3841
timeout (int): Timeout (in seconds) for SSH command execution.
42+
dry_run (bool): Show command and host info without executing.
3943
"""
4044

4145
try:
4246
config = Config()
4347

4448
ssh_client = SSHClient()
45-
results = ssh_client.begin(cmd, config.configured_hosts(), timeout)
46-
print_ssh_results(results)
49+
if dry_run:
50+
dry_run_results = ssh_client.begin_dry_run_exec(
51+
cmd, config.configured_hosts(), "exec"
52+
)
53+
print_dry_run_results(dry_run_results)
54+
else:
55+
results = ssh_client.begin(cmd, config.configured_hosts(), timeout)
56+
print_ssh_results(results)
4757
except ConfigError as e:
4858
print_error(e, True)
4959

@@ -57,6 +67,9 @@ def group(
5767
timeout: int = typer.Option(
5868
10, help="Timeout in seconds for SSH command execution."
5969
),
70+
dry_run: bool = typer.Option(
71+
False, help="Show command and host info without executing."
72+
),
6073
):
6174
"""
6275
Run a shell command on all hosts within the specified group concurrently.
@@ -65,14 +78,19 @@ def group(
6578
name (str): The name of the host group to target.
6679
cmd (str): The shell command to execute remotely.
6780
timeout (int): Timeout (in seconds) for both SSH connection and command execution.
81+
dry_run (bool): Show command and host info without executing.
6882
"""
6983
try:
7084
config = Config()
7185
hosts = config.get_hosts_by_group(name)
7286

7387
ssh_client = SSHClient()
74-
results = ssh_client.begin(cmd, hosts, timeout)
75-
print_ssh_results(results)
88+
if dry_run:
89+
dry_run_results = ssh_client.begin_dry_run_exec(cmd, hosts, "exec")
90+
print_dry_run_results(dry_run_results)
91+
else:
92+
results = ssh_client.begin(cmd, hosts, timeout)
93+
print_ssh_results(results)
7694
except ConfigError as e:
7795
print_error(e, True)
7896

@@ -143,11 +161,14 @@ def push(
143161
remote_path: str = typer.Argument(
144162
..., help="The remote destination path where the file/directory will be placed."
145163
),
146-
all: bool = typer.Option(False, "--all", help="Push to all configured hosts."),
147-
group: str = typer.Option("--group", help="Push to a specific group of hosts."),
148-
host: str = typer.Option("--host", help="Push to a single specific host."),
164+
all: bool = typer.Option(False, help="Push to all configured hosts."),
165+
group: str = typer.Option("", help="Push to a specific group of hosts."),
166+
host: str = typer.Option("", help="Push to a single specific host."),
149167
recurse: bool = typer.Option(
150-
False, "--recurse", help="Recursively push a directory and its contents."
168+
False, help="Recursively push a directory and its contents."
169+
),
170+
dry_run: bool = typer.Option(
171+
False, help="Show transfer and host info without executing."
151172
),
152173
):
153174
"""
@@ -163,8 +184,9 @@ def push(
163184
group (str): Push to a specified group of hosts.
164185
host (str): Push to a specified individual host.
165186
recurse (bool): If True, recursively push a directory and all its contents.
187+
dry_run (bool): Show transfer and host info without executing.
166188
"""
167-
options = [all, bool(group), bool(host)]
189+
options = [all, bool(group != ""), bool(host != "")]
168190
if sum(options) != 1:
169191
print_error(
170192
"You must specify exactly one of --all, --group, or --host.",
@@ -185,20 +207,24 @@ def push(
185207
else (
186208
config.get_hosts_by_group(group)
187209
if group
188-
else [host_obj]
189-
if host_obj is not None
190-
else []
210+
else [host_obj] if host_obj is not None else []
191211
)
192212
)
193213

194214
if not hosts:
195215
return print_error("Invalid host or group")
196216

197-
results = ssh_client.begin_transfer(
198-
local_path, remote_path, hosts, FileTransferAction.PUSH, recurse
199-
)
217+
if dry_run:
218+
results = ssh_client.begin_dry_run_transfer(
219+
hosts, local_path, remote_path, "push"
220+
)
221+
print_dry_run_results(results)
222+
else:
223+
results = ssh_client.begin_transfer(
224+
local_path, remote_path, hosts, FileTransferAction.PUSH, recurse
225+
)
200226

201-
print_ssh_results(results)
227+
print_ssh_results(results)
202228
except ConfigError as e:
203229
print_error(e, True)
204230

@@ -212,12 +238,13 @@ def pull(
212238
..., help="The local destination path where the file/directory will be placed."
213239
),
214240
all: bool = typer.Option(False, "--all", help="Pull from all configured hosts."),
215-
group: str = typer.Option(
216-
"", "--group", help="Pull from a specific group of hosts."
217-
),
218-
host: str = typer.Option("--host", help="Pull from a single specific host."),
241+
group: str = typer.Option("", help="Pull from a specific group of hosts."),
242+
host: str = typer.Option("", help="Pull from a single specific host."),
219243
recurse: bool = typer.Option(
220-
False, "--recurse", help="Recursively pull a directory and its contents."
244+
False, help="Recursively pull a directory and its contents."
245+
),
246+
dry_run: bool = typer.Option(
247+
False, help="Show transfer and host info without executing."
221248
),
222249
):
223250
"""
@@ -233,6 +260,7 @@ def pull(
233260
group (str): Pull from a specified group of hosts.
234261
host (str): Pull from a specified individual host.
235262
recurse (bool): If True, recursively pull directories and all their contents.
263+
dry_run (bool): Show transfer and host info without executing.
236264
"""
237265
options = [all, bool(group), bool(host)]
238266
if sum(options) != 1:
@@ -255,24 +283,28 @@ def pull(
255283
else (
256284
config.get_hosts_by_group(group)
257285
if group
258-
else [host_obj]
259-
if host_obj is not None
260-
else []
286+
else [host_obj] if host_obj is not None else []
261287
)
262288
)
263289

264290
if not hosts:
265291
return print_error("Invalid host or group")
266292

267-
results = ssh_client.begin_transfer(
268-
local_path,
269-
remote_path,
270-
hosts,
271-
FileTransferAction.PULL,
272-
recurse,
273-
)
293+
if dry_run:
294+
results = ssh_client.begin_dry_run_transfer(
295+
hosts, local_path, remote_path, "pull"
296+
)
297+
print_dry_run_results(results)
298+
else:
299+
results = ssh_client.begin_transfer(
300+
local_path,
301+
remote_path,
302+
hosts,
303+
FileTransferAction.PULL,
304+
recurse,
305+
)
274306

275-
print_ssh_results(results)
307+
print_ssh_results(results)
276308
except ConfigError as e:
277309
print_error(e, True)
278310

@@ -290,7 +322,7 @@ def ls(
290322
with_status (bool): Whether to include network reachability status for each host.
291323
"""
292324
try:
293-
asyncio.run(list_configuration(with_status))
325+
list_configuration(with_status)
294326
except ConfigError as e:
295327
print_error(e, True)
296328

src/sshsync/client.py

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,14 @@
11
import asyncio
22
from os import EX_OK
33
from pathlib import Path
4+
from typing import Literal
45

56
import asyncssh
67
import structlog
78

89
from sshsync.config import Config
910
from sshsync.logging import setup_logging
10-
from sshsync.schemas import FileTransferAction, Host, SSHResult
11+
from sshsync.schemas import FileTransferAction, Host, SSHDryRun, SSHResult
1112

1213
setup_logging()
1314

@@ -52,6 +53,67 @@ async def _run_command_across_hosts(
5253
*[self._execute_command(host, cmd) for host in hosts]
5354
)
5455

56+
def begin_dry_run_exec(
57+
self, cmd: str, hosts: list[Host], operation: Literal["exec", "push", "pull"]
58+
) -> list[SSHDryRun]:
59+
"""
60+
Simulate a shell command on multiple hosts.
61+
62+
Args:
63+
cmd (str): Command to simulate.
64+
hosts (list[Host]): Hosts to run the command on.
65+
operation (Literal["exec", "push", "pull"]): Operation type.
66+
67+
Returns:
68+
list[SSHDryRun]: Simulated command details per host.
69+
"""
70+
return [
71+
SSHDryRun(
72+
host.address,
73+
host.alias,
74+
host.username,
75+
host.port,
76+
operation,
77+
cmd,
78+
None,
79+
None,
80+
)
81+
for host in hosts
82+
]
83+
84+
def begin_dry_run_transfer(
85+
self,
86+
hosts: list[Host],
87+
local_path: str,
88+
remote_path: str,
89+
operation: Literal["push", "pull"],
90+
) -> list[SSHDryRun]:
91+
"""
92+
Simulate a file transfer on multiple hosts.
93+
94+
Args:
95+
hosts (list[Host]): Hosts involved.
96+
local_path (str): Local file/directory path.
97+
remote_path (str): Remote destination path.
98+
direction (Literal["push", "pull"]): Transfer direction.
99+
100+
Returns:
101+
list[SSHDryRun]: Simulated transfer details per host.
102+
"""
103+
return [
104+
SSHDryRun(
105+
host.address,
106+
host.alias,
107+
host.username,
108+
host.port,
109+
operation,
110+
None,
111+
local_path,
112+
remote_path,
113+
)
114+
for host in hosts
115+
]
116+
55117
async def _execute_command(self, host: Host, cmd: str) -> SSHResult:
56118
"""Establish an SSH connection to a host and run a command.
57119
@@ -302,7 +364,10 @@ async def _pull(self, local_path: str, remote_path: str, host: Host) -> SSHResul
302364
return SSHResult(**data)
303365

304366
def begin(
305-
self, cmd: str, hosts: list[Host], timeout: int | None = 10
367+
self,
368+
cmd: str,
369+
hosts: list[Host],
370+
timeout: int = 10,
306371
) -> list[SSHResult]:
307372
"""Execute a command across multiple hosts using asyncio.
308373

src/sshsync/schemas.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from dataclasses import asdict, dataclass
22
from enum import Enum
3+
from typing import Literal
34

45
from asyncssh import BytesOrStr
56

@@ -77,3 +78,34 @@ class SSHResult:
7778
exit_status: int | None
7879
success: bool
7980
output: BytesOrStr | None
81+
82+
83+
@dataclass
84+
class SSHDryRun:
85+
"""
86+
Represents the intended SSH operation during a dry run, without execution or validation.
87+
88+
Attributes:
89+
host (str): Target host.
90+
alias (str): Alias of the host specified in ~/.ssh/config
91+
username (str): Username
92+
port (int): Port on which ssh server is running
93+
operation (Literal["exec", "push", "pull"]): Type of SSH operation.
94+
command (str | None): Command to be run (for 'exec').
95+
local_path (str | None): Local file path (used in 'push' or 'pull').
96+
remote_path (str | None): Remote file path (used in 'push' or 'pull').
97+
"""
98+
99+
host: str
100+
alias: str
101+
username: str
102+
port: int
103+
operation: Literal["exec", "push", "pull"]
104+
105+
# For exec
106+
command: str | None = None
107+
108+
# For push/pull
109+
local_path: str | None = None
110+
111+
remote_path: str | None = None

0 commit comments

Comments
 (0)