Skip to content

Commit d0eb14c

Browse files
committed
added platform specific logging, updated README to include hadd, sync commands and logging section
1 parent e59a583 commit d0eb14c

6 files changed

Lines changed: 217 additions & 97 deletions

File tree

README.md

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- 🧠 Group-based configuration for easy targeting
1212
- 🕒 Adjustable SSH timeout settings
1313
- 📁 **Push/pull files** between local and remote hosts
14-
- 📊 (Coming Soon) Execution history and logging
14+
- 📊 Operation history and logging
1515

1616
## Installation 📦
1717

@@ -156,6 +156,21 @@ sshsync gadd [OPTIONS] GROUP
156156
sshsync gadd web
157157
```
158158

159+
#### Add a Host to SSH Config
160+
161+
```bash
162+
sshsync hadd [OPTIONS]
163+
```
164+
165+
This command interactively adds a new host to your SSH config file.
166+
167+
**Example:**
168+
169+
```bash
170+
# Add a new host to your SSH configuration
171+
sshsync hadd
172+
```
173+
159174
#### Synchronize Ungrouped Hosts
160175

161176
```bash
@@ -216,6 +231,16 @@ You can edit this file manually or use the built-in commands to manage groups an
216231
217232
> **Note**: sshsync leverages your existing SSH configuration for host details, making it easier to maintain a single source of truth for SSH connections.
218233
234+
## Logging 📝
235+
236+
sshsync now includes operation history and logging functionality. Logs are stored in platform-specific locations:
237+
238+
- **Windows**: `%LOCALAPPDATA%\sshsync\logs`
239+
- **macOS**: `~/Library/Logs/sshsync`
240+
- **Linux**: `~/.local/state/sshsync`
241+
242+
These logs track command executions, file transfers, and any errors that occur during operations.
243+
219244
## Examples 🧪
220245

221246
```bash
@@ -234,6 +259,9 @@ sshsync pull --group web-servers /var/log/nginx/error.log ./logs/
234259
# Add hosts to the dev group
235260
sshsync gadd dev
236261
262+
# Add a new host to your SSH configuration
263+
sshsync hadd
264+
237265
# Assign groups to all ungrouped hosts
238266
sshsync sync
239267
@@ -243,9 +271,9 @@ sshsync ls --with-status
243271

244272
## Upcoming Features 🛣️
245273

246-
- Initial implementation of execution history and logging
247-
- Support for additional authentication methods
274+
- Live results display (--live flag) to show command outputs as they complete
248275
- Performance optimizations for large server fleets
276+
- Support for additional authentication methods
249277
- Automated versioning using release-please for streamlined releases
250278

251279
## License 📄

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "sshsync"
3-
version = "0.7.0"
3+
version = "0.8.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 = [
@@ -13,6 +13,7 @@ dependencies = [
1313
"pyyaml>=6.0.2",
1414
"rich>=14.0.0",
1515
"sshconf>=0.2.7",
16+
"structlog>=25.3.0",
1617
"typer>=0.15.3",
1718
]
1819

src/sshsync/cli.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,9 @@ def push(
185185
else (
186186
config.get_hosts_by_group(group)
187187
if group
188-
else [host_obj] if host_obj is not None else []
188+
else [host_obj]
189+
if host_obj is not None
190+
else []
189191
)
190192
)
191193

@@ -253,7 +255,9 @@ def pull(
253255
else (
254256
config.get_hosts_by_group(group)
255257
if group
256-
else [host_obj] if host_obj is not None else []
258+
else [host_obj]
259+
if host_obj is not None
260+
else []
257261
)
258262
)
259263

src/sshsync/client.py

Lines changed: 133 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,20 @@
33
from pathlib import Path
44

55
import asyncssh
6+
import structlog
67

78
from sshsync.config import Config
9+
from sshsync.logging import setup_logging
810
from sshsync.schemas import FileTransferAction, Host, SSHResult
911

12+
setup_logging()
13+
1014

1115
class SSHClient:
1216
def __init__(self) -> None:
1317
"""Initialize the SSHClient with configuration data from the config file."""
1418
self.config = Config()
19+
self.logger = structlog.get_logger()
1520

1621
def _is_key_encrypted(self, key_path: str) -> bool:
1722
"""Check if the given ssh key is protected by a passphrase
@@ -67,42 +72,54 @@ async def _execute_command(self, host: Host, cmd: str) -> SSHResult:
6772
conn_kwargs["client_keys"] = [host.identity_file]
6873
async with asyncssh.connect(**conn_kwargs) as conn:
6974
result = await conn.run(cmd, check=True, timeout=self.timeout)
70-
return SSHResult(
71-
host=host.address,
72-
exit_status=result.exit_status,
73-
success=result.exit_status == EX_OK,
74-
output=(
75+
data = {
76+
"host": host.address,
77+
"exit_status": result.exit_status,
78+
"success": result.exit_status == EX_OK,
79+
"output": (
7580
result.stdout if result.exit_status == EX_OK else result.stderr
7681
),
77-
)
82+
}
83+
self.logger.info("SSH Execution completed", **data)
84+
return SSHResult(**data)
7885
except asyncssh.KeyEncryptionError as e:
79-
return SSHResult(
80-
host=host.address,
81-
exit_status=None,
82-
success=False,
83-
output=f"Encrypted private key, passphrase required: {e}",
84-
)
86+
data = {
87+
"host": host.address,
88+
"exit_status": None,
89+
"success": False,
90+
"output": f"Encrypted private key, passphrase required: {e}",
91+
}
92+
self.logger.error("SSH error: Encrypted private key", **data)
93+
return SSHResult(**data)
8594
except asyncssh.PermissionDenied as e:
86-
return SSHResult(
87-
host=host.address,
88-
exit_status=None,
89-
success=False,
90-
output=f"Permission denied: {e.reason}",
91-
)
95+
data = {
96+
"host": host.address,
97+
"exit_status": None,
98+
"success": False,
99+
"output": f"Permission denied: {e.reason}",
100+
}
101+
self.logger.error("SSH error: Permission denied", **data)
102+
return SSHResult(**data)
103+
92104
except asyncssh.ProcessError as e:
93-
return SSHResult(
94-
host=host.address,
95-
exit_status=e.exit_status,
96-
success=False,
97-
output=f"Command failed: {e.stderr}",
98-
)
105+
data = {
106+
"host": host.address,
107+
"exit_status": e.exit_status,
108+
"success": False,
109+
"output": f"Command failed: {e.stderr}",
110+
}
111+
self.logger.error("SSH error: Command failed", **data)
112+
return SSHResult(**data)
113+
99114
except Exception as e:
100-
return SSHResult(
101-
host=host.address,
102-
exit_status=None,
103-
success=False,
104-
output=f"Unexpected error: {e}",
105-
)
115+
data = {
116+
"host": host.address,
117+
"exit_status": None,
118+
"success": False,
119+
"output": f"Unexpected error: {e}",
120+
}
121+
self.logger.error("SSH error: Unexpected error", **data)
122+
return SSHResult(**data)
106123

107124
async def _transfer_file_across_hosts(
108125
self,
@@ -158,40 +175,53 @@ async def _push(self, local_path: str, remote_path: str, host: Host) -> SSHResul
158175
await asyncssh.scp(
159176
local_path, (conn, remote_path), recurse=self.recurse
160177
)
161-
return SSHResult(
162-
host=host.address,
163-
exit_status=EX_OK,
164-
success=True,
165-
output=f"Successfully sent to {host.address}:{remote_path}",
166-
)
178+
data = {
179+
"host": host.address,
180+
"exit_status": EX_OK,
181+
"success": True,
182+
"output": f"Successfully sent to {host.address}:{remote_path}",
183+
}
184+
self.logger.info("Upload successful", **data)
185+
return SSHResult(**data)
167186
except asyncssh.PermissionDenied as e:
168-
return SSHResult(
169-
host=host.address,
170-
exit_status=None,
171-
success=False,
172-
output=f"Permission denied: {e.reason}",
173-
)
187+
data = {
188+
"host": host.address,
189+
"exit_status": None,
190+
"success": False,
191+
"output": f"Permission denied: {e.reason}",
192+
}
193+
self.logger.error("SSH error: Permission denied", **data)
194+
return SSHResult(**data)
195+
174196
except asyncssh.SFTPError as e:
175-
return SSHResult(
176-
host=host.address,
177-
exit_status=None,
178-
success=False,
179-
output=f"SFTP error: {e.reason}",
180-
)
197+
data = {
198+
"host": host.address,
199+
"exit_status": None,
200+
"success": False,
201+
"output": f"SFTP error: {e.reason}",
202+
}
203+
self.logger.error("SSH error: SFTP error", **data)
204+
return SSHResult(**data)
205+
181206
except asyncssh.ChannelOpenError as e:
182-
return SSHResult(
183-
host=host.address,
184-
exit_status=None,
185-
success=False,
186-
output=f"Channel open error: {e.reason}",
187-
)
207+
data = {
208+
"host": host.address,
209+
"exit_status": None,
210+
"success": False,
211+
"output": f"Channel open error: {e.reason}",
212+
}
213+
self.logger.error("SSH error: Channel open error", **data)
214+
return SSHResult(**data)
215+
188216
except Exception as e:
189-
return SSHResult(
190-
host=host.address,
191-
exit_status=None,
192-
success=False,
193-
output=f"Unexpected error: {e}",
194-
)
217+
data = {
218+
"host": host.address,
219+
"exit_status": None,
220+
"success": False,
221+
"output": f"Unexpected error: {e}",
222+
}
223+
self.logger.error("SSH error: Unexpected error", **data)
224+
return SSHResult(**data)
195225

196226
async def _pull(self, local_path: str, remote_path: str, host: Host) -> SSHResult:
197227
"""Pull a file or directory from a remote host to the local machine over SSH.
@@ -223,41 +253,53 @@ async def _pull(self, local_path: str, remote_path: str, host: Host) -> SSHResul
223253
await asyncssh.scp(
224254
(conn, remote_path), unique_path, recurse=self.recurse
225255
)
226-
227-
return SSHResult(
228-
host=host.address,
229-
exit_status=EX_OK,
230-
success=True,
231-
output=f"Downloaded successfully from {host.address}:{remote_path}",
232-
)
256+
data = {
257+
"host": host.address,
258+
"exit_status": EX_OK,
259+
"success": True,
260+
"output": f"Downloaded successfully from {host.address}:{remote_path}",
261+
}
262+
self.logger.info("Download successful", **data)
263+
return SSHResult(**data)
233264
except asyncssh.PermissionDenied as e:
234-
return SSHResult(
235-
host=host.address,
236-
exit_status=None,
237-
success=False,
238-
output=f"Permission denied: {e.reason}",
239-
)
265+
data = {
266+
"host": host.address,
267+
"exit_status": None,
268+
"success": False,
269+
"output": f"Permission denied: {e.reason}",
270+
}
271+
self.logger.error("SSH error: Permission denied", **data)
272+
return SSHResult(**data)
273+
240274
except asyncssh.SFTPError as e:
241-
return SSHResult(
242-
host=host.address,
243-
exit_status=None,
244-
success=False,
245-
output=f"SFTP error: {e.reason}",
246-
)
275+
data = {
276+
"host": host.address,
277+
"exit_status": None,
278+
"success": False,
279+
"output": f"SFTP error: {e.reason}",
280+
}
281+
self.logger.error("SSH error: SFTP error", **data)
282+
return SSHResult(**data)
283+
247284
except asyncssh.ChannelOpenError as e:
248-
return SSHResult(
249-
host=host.address,
250-
exit_status=None,
251-
success=False,
252-
output=f"Channel open error: {e.reason}",
253-
)
285+
data = {
286+
"host": host.address,
287+
"exit_status": None,
288+
"success": False,
289+
"output": f"Channel open error: {e.reason}",
290+
}
291+
self.logger.error("SSH error: Channel open error", **data)
292+
return SSHResult(**data)
293+
254294
except Exception as e:
255-
return SSHResult(
256-
host=host.address,
257-
exit_status=None,
258-
success=False,
259-
output=f"Unexpected error: {e}",
260-
)
295+
data = {
296+
"host": host.address,
297+
"exit_status": None,
298+
"success": False,
299+
"output": f"Unexpected error: {e}",
300+
}
301+
self.logger.error("SSH error: Unexpected error", **data)
302+
return SSHResult(**data)
261303

262304
def begin(
263305
self, cmd: str, hosts: list[Host], timeout: int | None = 10

0 commit comments

Comments
 (0)