Skip to content

Commit f32342b

Browse files
committed
added --regex option to filter hosts aliases based on regex pattern
1 parent 7223f6b commit f32342b

6 files changed

Lines changed: 127 additions & 9 deletions

File tree

.github/workflows/test.pypi.yml

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: Publish to Test PyPI
2+
3+
on:
4+
push:
5+
branches:
6+
- dev
7+
8+
jobs:
9+
build_package:
10+
runs-on: ubuntu-latest
11+
12+
steps:
13+
- uses: actions/checkout@v4
14+
15+
- uses: actions/setup-python@v5
16+
with:
17+
python-version: "3.x"
18+
19+
- name: Install uv
20+
uses: astral-sh/setup-uv@v5
21+
22+
- name: Build release distribution
23+
run: |
24+
uv build
25+
26+
- name: Upload distribution
27+
uses: actions/upload-artifact@v4
28+
with:
29+
name: test-release-dists
30+
path: dist/
31+
32+
test-pypi-publish:
33+
runs-on: ubuntu-latest
34+
needs: build_package
35+
permissions:
36+
id-token: write
37+
38+
environment:
39+
name: test-pypi
40+
41+
steps:
42+
- name: Retrieve release distributions
43+
uses: actions/download-artifact@v4
44+
with:
45+
name: test-release-dists
46+
path: dist/
47+
48+
- name: Publish release distributions to Test PyPI
49+
uses: pypa/gh-action-pypi-publish@release/v1
50+
with:
51+
packages-dir: dist/
52+
repository-url: https://test.pypi.org/legacy/

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.10.0"
3+
version = "0.11.0"
44
description = "sshsync is a CLI tool to run shell commands across multiple servers via SSH, either on specific groups or all servers. It also supports pushing and pulling files to and from remote hosts."
55
readme = "README.md"
66
authors = [

src/sshsync/cli.py

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
add_hosts_to_group,
1111
assign_groups_to_hosts,
1212
check_path_exists,
13+
is_valid_regex,
1314
list_configuration,
1415
print_dry_run_results,
1516
print_error,
@@ -45,6 +46,9 @@ def all(
4546
try:
4647
config = Config()
4748

49+
if not config.hosts:
50+
print_error("No hosts", True)
51+
4852
ssh_client = SSHClient()
4953
if dry_run:
5054
dry_run_results = ssh_client.begin_dry_run_exec(
@@ -64,6 +68,9 @@ def all(
6468
def group(
6569
name: str = typer.Argument(..., help="Name of the host group to target."),
6670
cmd: str = typer.Argument(..., help="The shell command to execute on the group."),
71+
regex: str = typer.Option(
72+
"", help="Filter group members by matching alias with a regex pattern."
73+
),
6774
timeout: int = typer.Option(
6875
10, help="Timeout in seconds for SSH command execution."
6976
),
@@ -77,12 +84,19 @@ def group(
7784
Args:
7885
name (str): The name of the host group to target.
7986
cmd (str): The shell command to execute remotely.
87+
regex (str): Filter group members by matching alias with regex pattern.
8088
timeout (int): Timeout (in seconds) for both SSH connection and command execution.
8189
dry_run (bool): Show command and host info without executing.
8290
"""
8391
try:
92+
if regex and not is_valid_regex(regex):
93+
print_error("Invalid regex", True)
94+
8495
config = Config()
85-
hosts = config.get_hosts_by_group(name)
96+
hosts = config.get_hosts_by_group(name, regex)
97+
98+
if not hosts:
99+
print_error("Invalid group", True)
86100

87101
ssh_client = SSHClient()
88102
if dry_run:
@@ -163,6 +177,9 @@ def push(
163177
),
164178
all: bool = typer.Option(False, help="Push to all configured hosts."),
165179
group: str = typer.Option("", help="Push to a specific group of hosts."),
180+
regex: str = typer.Option(
181+
"", help="Filter group members by matching alias with a regex pattern."
182+
),
166183
host: str = typer.Option("", help="Push to a single specific host."),
167184
recurse: bool = typer.Option(
168185
False, help="Recursively push a directory and its contents."
@@ -182,17 +199,33 @@ def push(
182199
remote_path (str): The destination path on the remote host(s).
183200
all (bool): Push to all hosts.
184201
group (str): Push to a specified group of hosts.
202+
regex (str): Filter group members by matching alias with regex pattern.
185203
host (str): Push to a specified individual host.
186204
recurse (bool): If True, recursively push a directory and all its contents.
187205
dry_run (bool): Show transfer and host info without executing.
188206
"""
189-
options = [all, bool(group != ""), bool(host != "")]
207+
has_all = all
208+
has_group = bool(group != "")
209+
has_host = bool(host != "")
210+
has_regex = bool(regex != "")
211+
212+
if has_regex and not is_valid_regex(regex):
213+
print_error("Invalid regex", True)
214+
215+
options = [has_all, has_group, has_host]
216+
190217
if sum(options) != 1:
191218
print_error(
192219
"You must specify exactly one of --all, --group, or --host.",
193220
True,
194221
)
195222

223+
if has_regex and not has_group:
224+
print_error(
225+
"--regex can only be used with --group.",
226+
True,
227+
)
228+
196229
if not check_path_exists(local_path):
197230
print_error(f"Path ({local_path}) does not exist", True)
198231

@@ -205,7 +238,7 @@ def push(
205238
config.configured_hosts()
206239
if all
207240
else (
208-
config.get_hosts_by_group(group)
241+
config.get_hosts_by_group(group, regex)
209242
if group
210243
else [host_obj]
211244
if host_obj is not None
@@ -241,6 +274,9 @@ def pull(
241274
),
242275
all: bool = typer.Option(False, "--all", help="Pull from all configured hosts."),
243276
group: str = typer.Option("", help="Pull from a specific group of hosts."),
277+
regex: str = typer.Option(
278+
"", help="Filter group members by matching alias with a regex pattern."
279+
),
244280
host: str = typer.Option("", help="Pull from a single specific host."),
245281
recurse: bool = typer.Option(
246282
False, help="Recursively pull a directory and its contents."
@@ -260,17 +296,33 @@ def pull(
260296
local_path (str): The local file path.
261297
all (bool): Pull from all hosts.
262298
group (str): Pull from a specified group of hosts.
299+
regex (str): Filter group members by matching alias with regex pattern.
263300
host (str): Pull from a specified individual host.
264301
recurse (bool): If True, recursively pull directories and all their contents.
265302
dry_run (bool): Show transfer and host info without executing.
266303
"""
267-
options = [all, bool(group), bool(host)]
304+
has_all = all
305+
has_group = bool(group != "")
306+
has_host = bool(host != "")
307+
has_regex = bool(regex != "")
308+
309+
if has_regex and not is_valid_regex(regex):
310+
print_error("Invalid regex", True)
311+
312+
options = [has_all, has_group, has_host]
313+
268314
if sum(options) != 1:
269315
print_error(
270316
"You must specify exactly one of --all, --group, or --host.",
271317
True,
272318
)
273319

320+
if has_regex and not has_group:
321+
print_error(
322+
"--regex can only be used with --group.",
323+
True,
324+
)
325+
274326
if not check_path_exists(local_path):
275327
print_error(f"Path ({local_path}) does not exist", True)
276328

@@ -283,7 +335,7 @@ def pull(
283335
config.configured_hosts()
284336
if all
285337
else (
286-
config.get_hosts_by_group(group)
338+
config.get_hosts_by_group(group, regex)
287339
if group
288340
else [host_obj]
289341
if host_obj is not None

src/sshsync/config.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import re
12
from pathlib import Path
23

34
import structlog
@@ -154,19 +155,22 @@ def _save_yaml(self) -> None:
154155
indent=4,
155156
)
156157

157-
def get_hosts_by_group(self, group: str) -> list[Host]:
158+
def get_hosts_by_group(self, group: str, regex: str = "") -> list[Host]:
158159
"""Return all hosts that belong to the specified group.
159160
160161
Args:
161162
group (str): Group name to filter hosts by.
163+
regex (str): Only include host aliases with the matching regex
162164
163165
Returns:
164166
list[Host]: Hosts that are members of the group.
165167
"""
166168
return [
167169
host
168170
for host in self.hosts
169-
if group in host.groups and host.alias != "default"
171+
if group in host.groups
172+
and host.alias != "default"
173+
and (not regex or re.search(regex, host.alias))
170174
]
171175

172176
def get_host_by_name(self, name: str) -> Host | None:

src/sshsync/utils.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import asyncio
22
import ipaddress
3+
import re
34
import socket
45
from pathlib import Path
56

@@ -75,6 +76,15 @@ def is_valid_ip(ip: str) -> bool:
7576
return False
7677

7778

79+
def is_valid_regex(pattern: str) -> bool:
80+
"Check if the string is a valid regex pattern"
81+
try:
82+
re.compile(pattern)
83+
return True
84+
except Exception:
85+
return False
86+
87+
7888
def get_host_name_or_ip() -> str:
7989
"""Prompt the user to enter a valid hostname or ip address"""
8090
while True:

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)