Skip to content

Commit e83b375

Browse files
committed
feat: add SSH agent authentication
1 parent 2c63af1 commit e83b375

14 files changed

Lines changed: 201 additions & 19 deletions

File tree

README.md

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
1-
# pg_perf_bench
1+
# pg-perf-bench
22

33
## Overview
44

55
Based on [pg_perfbench](https://github.com/TantorLabs/pg_perfbench).
66

7-
`pg_perf_bench` runs controlled PostgreSQL benchmarks and stores the result
7+
`pg-perf-bench` runs controlled PostgreSQL benchmarks and stores the result
88
together with the facts required to interpret it: the effective workload,
99
PostgreSQL configuration, server version, host properties, execution timing,
1010
raw command output, and collection diagnostics.
1111

12+
The distribution and installed command are `pg-perf-bench`; the import package
13+
and GitHub repository are named `pg_perf_bench`.
14+
1215
Its benchmark question is maximum TPS for one workload profile and one complete
1316
environment, including OS and PostgreSQL settings. `pg_workload` schedules and
1417
runs workload profiles; `pg_perf_bench` sweeps load, resets the dataset for each
@@ -56,7 +59,7 @@ CLI and automation contract
5659

5760
The transport controls PostgreSQL and executes host fact collectors on the
5861
selected target. The workload commands themselves run on the machine where
59-
`pg_perf_bench` is invoked:
62+
`pg-perf-bench` is invoked:
6063

6164
| Transport | Host facts and PostgreSQL lifecycle | `pgbench` / `psql` |
6265
|---|---|---|
@@ -329,6 +332,35 @@ The workload reaches PostgreSQL through the port published on `--host` and
329332
--port 55432
330333
```
331334

335+
To use an identity already loaded into a local agent, replace `--ssh-key` with
336+
`--ssh-agent`:
337+
338+
```bash
339+
eval "$(ssh-agent -s)"
340+
ssh-add ~/.ssh/id_ed25519
341+
ssh-add -l
342+
343+
pg-perf-bench collect-all-info \
344+
--connection-type ssh \
345+
--ssh-host db-host.example \
346+
--ssh-user postgres \
347+
--ssh-agent \
348+
--ssh-known-hosts /secure/path/known_hosts \
349+
--remote-pg-host 127.0.0.1 \
350+
--remote-pg-port 5432 \
351+
--host 127.0.0.1 \
352+
--port 55432 \
353+
--database appdb \
354+
--pg-bin-path /usr/lib/postgresql/18/bin
355+
```
356+
357+
`--ssh-key` and `--ssh-agent` are mutually exclusive. Agent mode uses the live
358+
socket inherited through `SSH_AUTH_SOCK`; the socket must remain available
359+
until collection or benchmarking finishes. The utility does not start an
360+
agent, run `ssh-add`, or forward the agent to the remote host. Both modes use
361+
explicit public-key authentication and the selected `known_hosts` policy
362+
without loading the user's OpenSSH configuration.
363+
332364
For database modes, `--host` and `--port` are the local bind address and
333365
free port. `--remote-pg-host` and `--remote-pg-port` identify PostgreSQL from
334366
the SSH server. Host commands run remotely; `asyncpg`, `pgbench`, and `psql`

doc/ssh_mode_usage.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,20 @@ Install the public key for the account selected by `--ssh-user`. For benchmark
3030
mode that account must be able to run `pg_ctl` for the selected cluster; using
3131
the PostgreSQL service owner is the simplest model.
3232

33+
The key may be referenced directly with `--ssh-key`, or loaded into an
34+
already-running local agent and selected with `--ssh-agent`:
35+
36+
```bash
37+
eval "$(ssh-agent -s)"
38+
ssh-add ~/.ssh/pg_perf_bench
39+
ssh-add -l
40+
```
41+
42+
The two options are mutually exclusive. Agent mode requires a live Unix socket
43+
in the inherited `SSH_AUTH_SOCK` for the complete command. `pg-perf-bench`
44+
does not start the agent, load identities, or forward the agent to the target.
45+
Key mode explicitly disables agent fallback.
46+
3347
Host-key verification is enabled by default. Use `--ssh-known-hosts` for a
3448
dedicated file. Reserve `--ssh-insecure-no-host-key-check` for isolated,
3549
disposable stands.
@@ -110,8 +124,10 @@ database host through the already authenticated SSH session.
110124

111125
- host-key error: update the selected known-hosts file after independently
112126
verifying the server key;
113-
- authentication failure: verify `--ssh-user`, private key permissions, and
114-
the installed public key;
127+
- authentication failure in key mode: verify `--ssh-user`, private key
128+
permissions, and the installed public key;
129+
- authentication failure in agent mode: verify `SSH_AUTH_SOCK`, `ssh-add -l`,
130+
the agent lifetime, and the installed public key;
115131
- local bind failure: choose an unused `--port`;
116132
- PostgreSQL connection failure with working SSH: verify the remote address,
117133
PostgreSQL authentication, and listen rules;

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
44

55
[project]
66
name = "pg-perf-bench"
7-
version = "0.2.0"
7+
version = "0.2.1"
88
description = "Reproducible PostgreSQL TPS benchmarks with environment evidence and report comparison"
99
readme = "README.md"
1010
requires-python = ">=3.10"
@@ -25,7 +25,7 @@ dependencies = [
2525
"asyncpg>=0.29,<1",
2626
"asyncssh>=2.21,<3",
2727
"docker>=7.1,<8",
28-
"pg-diag>=0.10.3,<1",
28+
"pg-diag>=0.10.5,<1",
2929
"aenum>=3.1,<4; python_version < '3.11'",
3030
]
3131

@@ -47,7 +47,7 @@ dev = [
4747
pg-perf-bench = "pg_perf_bench.cli:main"
4848

4949
[project.urls]
50-
Homepage = "https://github.com/O2eg/pg_perf_bench"
50+
Homepage = "https://o2eg.com/"
5151
Repository = "https://github.com/O2eg/pg_perf_bench"
5252
Issues = "https://github.com/O2eg/pg_perf_bench/issues"
5353

src/pg_perf_bench/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
"""PostgreSQL performance benchmark and evidence collector."""
22

3-
__version__ = '0.2.0'
3+
__version__ = '0.2.1'

src/pg_perf_bench/cli.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,13 @@ def _add_host_args(parser: argparse.ArgumentParser) -> None:
113113
parser.add_argument('--ssh-host')
114114
parser.add_argument('--ssh-port', type=positive_int, default=22)
115115
parser.add_argument('--ssh-user', default='postgres')
116-
parser.add_argument('--ssh-key')
116+
ssh_auth = parser.add_mutually_exclusive_group()
117+
ssh_auth.add_argument('--ssh-key')
118+
ssh_auth.add_argument(
119+
'--ssh-agent',
120+
action='store_true',
121+
help='authenticate through the existing SSH_AUTH_SOCK agent',
122+
)
117123
parser.add_argument('--ssh-known-hosts')
118124
parser.add_argument('--ssh-insecure-no-host-key-check', action='store_true')
119125
parser.add_argument('--remote-pg-host')

src/pg_perf_bench/config.py

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from __future__ import annotations
44

55
import math
6+
import os
7+
import stat
68
from dataclasses import dataclass, field
79
from pathlib import Path
810
from typing import Any
@@ -22,6 +24,20 @@
2224
PROTECTED_DATABASES = frozenset({'postgres', 'template0', 'template1'})
2325

2426

27+
def resolve_ssh_agent_socket() -> Path:
28+
raw_path = os.environ.get('SSH_AUTH_SOCK')
29+
if not raw_path:
30+
raise ConfigurationError('--ssh-agent requires SSH_AUTH_SOCK to reference a running agent')
31+
path = Path(raw_path).expanduser()
32+
try:
33+
mode = path.stat().st_mode
34+
except OSError as exc:
35+
raise ConfigurationError(f'SSH agent socket is not available: {path}') from exc
36+
if not stat.S_ISSOCK(mode):
37+
raise ConfigurationError(f'SSH_AUTH_SOCK is not a socket: {path}')
38+
return path
39+
40+
2541
def _positive_int(value: Any, option: str) -> int:
2642
try:
2743
parsed = int(value)
@@ -100,6 +116,7 @@ class HostConfig:
100116
ssh_port: int = 22
101117
ssh_user: str = 'postgres'
102118
ssh_key: Path | None = None
119+
ssh_agent: bool = False
103120
ssh_known_hosts: Path | None = None
104121
ssh_insecure_no_host_key_check: bool = False
105122
remote_pg_host: str | None = None
@@ -131,8 +148,14 @@ def connection_kwargs(
131148
'host': self.ssh_host,
132149
'port': self.ssh_port,
133150
'username': self.ssh_user,
134-
'client_keys': str(self.ssh_key) if self.ssh_key else None,
151+
'client_keys': [str(self.ssh_key)] if self.ssh_key else [],
152+
'agent_path': str(resolve_ssh_agent_socket()) if self.ssh_agent else None,
153+
'agent_forwarding': False,
135154
'known_hosts': known_hosts,
155+
'config': None,
156+
'preferred_auth': ['publickey'],
157+
'password_auth': False,
158+
'kbdint_auth': False,
136159
'connect_timeout': min(self.command_timeout, 30.0),
137160
},
138161
'env': env,
@@ -267,6 +290,7 @@ def build_runtime_config(args: Any) -> RuntimeConfig:
267290
ssh_port=_positive_int(values.get('ssh_port') or 22, '--ssh-port'),
268291
ssh_user=values.get('ssh_user') or 'postgres',
269292
ssh_key=Path(ssh_key_value).expanduser() if ssh_key_value else None,
293+
ssh_agent=bool(values.get('ssh_agent')),
270294
ssh_known_hosts=(
271295
Path(values['ssh_known_hosts']).expanduser() if values.get('ssh_known_hosts') else None
272296
),
@@ -401,10 +425,12 @@ def _validate_host(host: HostConfig, *, needs_database: bool) -> None:
401425
return
402426
_required(host.ssh_host, '--ssh-host')
403427
_required(host.ssh_user, '--ssh-user')
404-
_required(host.ssh_key, '--ssh-key')
405-
assert host.ssh_key is not None
406-
if not host.ssh_key.is_file():
428+
if (host.ssh_key is None) == (not host.ssh_agent):
429+
raise ConfigurationError('SSH requires exactly one of --ssh-key or --ssh-agent')
430+
if host.ssh_key is not None and not host.ssh_key.is_file():
407431
raise ConfigurationError(f'SSH private key does not exist: {host.ssh_key}')
432+
if host.ssh_agent:
433+
resolve_ssh_agent_socket()
408434
if not host.ssh_insecure_no_host_key_check:
409435
known_hosts = host.ssh_known_hosts or Path('~/.ssh/known_hosts').expanduser()
410436
if not known_hosts.is_file():

src/pg_perf_bench/connections/ssh.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ async def start(self) -> None:
4242
except asyncssh.PermissionDenied as exc:
4343
await self.aclose()
4444
raise PermissionError(
45-
'Verify the SSH user and private key for remote server access.'
45+
'Verify the SSH user and selected key or agent identity for remote server access.'
4646
) from exc
4747
except (asyncssh.ConnectionLost, OSError, asyncio.TimeoutError) as exc:
4848
await self.aclose()

src/pg_perf_bench/context/base_context.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from pathlib import Path
22

3+
from pg_perf_bench.config import resolve_ssh_agent_socket
34
from pg_perf_bench.const import ConnectionType
45

56

@@ -43,17 +44,24 @@ def _add_connection_config(self, args):
4344

4445
def _add_ssh_connection_config(self, args):
4546
"""Add SSH connection configuration"""
47+
use_agent = bool(getattr(args, 'ssh_agent', False))
4648
conn_params = {
4749
'host': args.ssh_host,
4850
'port': args.ssh_port,
4951
'username': getattr(args, 'ssh_user', None) or 'postgres',
50-
'client_keys': args.ssh_key,
52+
'client_keys': [] if use_agent else [args.ssh_key],
53+
'agent_path': str(resolve_ssh_agent_socket()) if use_agent else None,
54+
'agent_forwarding': False,
5155
'known_hosts': (
5256
None
5357
if getattr(args, 'ssh_insecure_no_host_key_check', False)
5458
else getattr(args, 'ssh_known_hosts', None)
5559
or str(Path('~/.ssh/known_hosts').expanduser())
5660
),
61+
'config': None,
62+
'preferred_auth': ['publickey'],
63+
'password_auth': False,
64+
'kbdint_auth': False,
5765
'connect_timeout': float(getattr(args, 'connect_timeout', 5.0)),
5866
}
5967

src/pg_perf_bench/context/benchmark.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,6 @@ def filter_none(self, d: dict) -> dict:
9494
SSHConnectionArgs = [
9595
'ssh_host',
9696
'ssh_port',
97-
'ssh_key',
9897
'remote_pg_host',
9998
'remote_pg_port',
10099
]
@@ -122,6 +121,11 @@ def filter_none(self, d: dict) -> dict:
122121

123122
ctype = d.get('connection_type')
124123
if ctype == ConnectionType.SSH:
124+
if bool(d.get('ssh_key')) == bool(d.get('ssh_agent')):
125+
raise ValueError(
126+
'Exactly one of "--ssh-key" or "--ssh-agent" must be specified '
127+
f'for connection type "{ConnectionType.SSH}"'
128+
)
125129
for key in SSHConnectionArgs:
126130
if d.get(key) is None:
127131
raise ValueError(

src/pg_perf_bench/context/collect_info.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ def __init__(self, args, logger):
7777
}
7878

7979
def filter_none(self, d: dict) -> dict:
80-
SSHConnectionArgs = ['ssh_host', 'ssh_port', 'ssh_key']
80+
SSHConnectionArgs = ['ssh_host', 'ssh_port']
8181
DockerConnectionArgs = [
8282
'container_name',
8383
]
@@ -101,6 +101,11 @@ def filter_none(self, d: dict) -> dict:
101101

102102
ctype = d.get('connection_type')
103103
if ctype == ConnectionType.SSH:
104+
if bool(d.get('ssh_key')) == bool(d.get('ssh_agent')):
105+
raise ValueError(
106+
'Exactly one of "--ssh-key" or "--ssh-agent" must be specified '
107+
f'for connection type "{ConnectionType.SSH}"'
108+
)
104109
for key in SSHConnectionArgs:
105110
if d.get(key) is None:
106111
raise ValueError(

0 commit comments

Comments
 (0)