From 244533dffd77fb77add2d8e824b5947f6ef51d68 Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Tue, 12 May 2026 16:40:25 -0400 Subject: [PATCH 1/6] feat: lookup secrets --- README.md | 108 ++++++++++++------ packages/trunnel-cli/README.md | 64 ++++++++++- packages/trunnel-cli/pyproject.toml | 2 +- .../trunnel-cli/src/trunnel_cli/discovery.py | 45 +++++++- packages/trunnel-cli/src/trunnel_cli/main.py | 52 +++++++-- pyproject.toml | 5 +- uv.lock | 22 +++- 7 files changed, 244 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index 8ec66b0..f51036b 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,9 @@ Trunnel (a play on the [East Side Trolley Tunnel], or a wooden peg used to form a strong connection between pieces of wood) helps automate securely connecting to your private AWS infrastructure through AWS Systems Manager (SSM). It -replaces manual SSH management with automated SSM discovery and a self-healing CDK bastion. Trunnel does NOT handle -fetching database credentials, but does make it easier to securely make the connection. +replaces manual SSH management with automated SSM discovery and a self-healing CDK bastion. Trunnel can discover +the bastion and RDS instance you need to connect to, fetch database credentials stored in Secrets Manager, and +bore the encrypted tunnel — all from a single CLI. This tool was designed in response to help reduce minor frustrations like, @@ -73,17 +74,28 @@ bastion.export_security_group("BastionSG-Production") ## Trunnel CLI -The Trunnel CLI makes it easy to find the bastion host and connect to your RDS database via an encrypted SSM tunnel. -This tool is basically a wrapper around the AWS CLI and the SSM Session Manager Plugin that helps lookup the correct -values for bridging the connection. It does this by finding resources that are tagged according with some configurable -`key=value` pair. +The Trunnel CLI makes it easy to find the bastion host and connect to your RDS database via an encrypted SSM tunnel, +and to fetch connection credentials stored in AWS Secrets Manager. Resources are located by tag `key=value` pairs. -### Example Usage +### Installation + +Before beginning, you must first have the following installed: + +- AWS CLI v2 +- SSM Session Manager Plugin -To connect to an RDS instance tagged with App=my-service: +Install into your project's development dependencies (for example using `uv`): ```bash -$ trunnel --rds-key Service --rds-value payments-api --reconnect +uv add --dev "trunnel-cli @ git+https://github.com/developmentseed/trunnel#subdirectory=packages/trunnel-cli" +``` + +### trunnel connect + +Opens an encrypted SSM port-forward tunnel to a private RDS instance. + +```bash +$ trunnel connect --rds-key Service --rds-value payments-api --reconnect šŸ” Searching AWS... Select a Bastion: @@ -92,7 +104,7 @@ Select a Bastion: Enter Bastion ID: i-0abcd1234efgh5678 -šŸš€ Trunnel Active: localhost:5432 -> payments-api-db.cluster.aws.com +šŸšŽ Trunnel Active: localhost:5432 -> payments-api-db.cluster.aws.com šŸ”— payments-api-db via Production-Bastion (i-0abcd1234efgh5678) Starting session with SessionId: developer-0123456789abcdef @@ -100,46 +112,76 @@ Port 5432 opened for session developer-0123456789abcdef. Waiting for connections... ``` -The Trunnel CLI can read You might consider using [direnv](https://direnv.net/) to help automate the resource tagging -definitions. For example, +```bash +$ trunnel connect --help + +Usage: trunnel connect [OPTIONS] + + Securely bore a tunnel to RDS via Trunnel. + +Options: + --bastion-key TEXT Tag key for Bastion. [env var: TRUNNEL_CONNECT_BASTION_KEY; default: Role] + --bastion-value TEXT Tag value for Bastion. [env var: TRUNNEL_CONNECT_BASTION_VALUE; default: Bastion] + --rds-key TEXT Tag key for RDS. [env var: TRUNNEL_CONNECT_RDS_KEY; required] + --rds-value TEXT Tag value for RDS. [env var: TRUNNEL_CONNECT_RDS_VALUE; required] + --local-port INTEGER [env var: TRUNNEL_CONNECT_LOCAL_PORT; default: 5432] + --profile TEXT AWS CLI profile. [env var: TRUNNEL_CONNECT_PROFILE] + --reconnect Auto-retry on disconnect. [env var: TRUNNEL_CONNECT_RECONNECT] + --help Show this message and exit. +``` + +### trunnel secrets + +Looks up an AWS Secrets Manager secret by tag and prints its value to stdout. ```bash -# .envrc -export TRUNNEL_RDS_KEY=Service -export TRUNNEL_RDS_VALUE=payments-api +$ trunnel secrets --secret-key Stack --secret-value payments-api + +šŸ” Searching AWS Secrets Manager... +{"username":"app","password":"s3cr3t","host":"payments-api-db.cluster.aws.com","port":5432} ``` -You can also view the full help text by passing `--help`, +If multiple secrets match the tag filter, Trunnel prompts you to choose: ```bash -$ trunnel --help +Select a Secret: + • payments-api/db-credentials - Primary database credentials + • payments-api/readonly-credentials - Read-only replica credentials -Usage: trunnel [OPTIONS] +Enter Secret ID: payments-api/db-credentials +``` - Securely bore a tunnel to RDS via Trunnel. +```bash +$ trunnel secrets --help + +Usage: trunnel secrets [OPTIONS] + + Fetch and print a Secrets Manager secret by tag. Options: - --bastion-key TEXT Tag key for Bastion. [env var: TRUNNEL_BASTION_KEY; default: Role] - --bastion-value TEXT Tag value for Bastion. [env var: TRUNNEL_BASTION_VALUE; default: Bastion] - --rds-key TEXT Tag key for RDS. [env var: TRUNNEL_RDS_KEY; required] - --rds-value TEXT Tag value for RDS. [env var: TRUNNEL_RDS_VALUE; required] - --local-port INTEGER [env var: TRUNNEL_LOCAL_PORT; default: 5432] - --profile TEXT AWS CLI profile. [env var: TRUNNEL_PROFILE] - --reconnect Auto-retry on disconnect. [env var: TRUNNEL_RECONNECT] - --help Show this message and exit. + --secret-key TEXT Tag key to filter secrets. [env var: TRUNNEL_SECRETS_SECRET_KEY; required] + --secret-value TEXT Tag value to filter secrets. [env var: TRUNNEL_SECRETS_SECRET_VALUE; required] + --profile TEXT AWS CLI profile. [env var: TRUNNEL_SECRETS_PROFILE] + --help Show this message and exit. ``` -### Installation +### Environment variables -Before beginning, you must first have the following installed, +All options can be set via environment variables. Each subcommand has its own prefix: -- AWS CLI v2 -- SSM Session Manager Plugin +| Subcommand | Prefix | Example | +|--------------------|-------------------------|--------------------------------------| +| `trunnel connect` | `TRUNNEL_CONNECT_` | `TRUNNEL_CONNECT_RDS_KEY=Service` | +| `trunnel secrets` | `TRUNNEL_SECRETS_` | `TRUNNEL_SECRETS_SECRET_KEY=Stack` | -Next, it into your project's development dependencies (for example using `uv`): +You might consider using [direnv](https://direnv.net/) to set these per project. For example, ```bash -uv add --dev "trunnel-cli @ git+https://github.com/developmentseed/trunnel#subdirectory=packages/trunnel-cli" +# .envrc +export TRUNNEL_CONNECT_RDS_KEY=Service +export TRUNNEL_CONNECT_RDS_VALUE=payments-api +export TRUNNEL_SECRETS_SECRET_KEY=Stack +export TRUNNEL_SECRETS_SECRET_VALUE=payments-api ``` [East Side Trolley Tunnel]: https://en.wikipedia.org/wiki/East_Side_Trolley_Tunnel diff --git a/packages/trunnel-cli/README.md b/packages/trunnel-cli/README.md index f72f7eb..88333bc 100644 --- a/packages/trunnel-cli/README.md +++ b/packages/trunnel-cli/README.md @@ -1,10 +1,10 @@ # trunnel-cli -> Automated SSM tunneling for private RDS instances. +> Automated SSM tunneling and credential lookup for private RDS instances. -`trunnel-cli` is the developer-facing component of the Trunnel project. It automates the discovery of Bastion hosts and -RDS instances via AWS tags and establishes an encrypted SSM tunnel to allow local database clients to connect to private -RDS instances. +`trunnel-cli` is the developer-facing component of the Trunnel project. It automates the discovery of Bastion hosts, +RDS instances, and Secrets Manager credentials via AWS tags, then establishes an encrypted SSM tunnel so local database +clients can connect to private RDS instances. ## Installation @@ -17,5 +17,59 @@ RDS instances. To add to your project using `uv`, ```bash -uv add --group deploy "trunnel-cli @ git+https://github.com/developmentseed/trunnel#subdirectory=packages/trunnel-cli" +uv add --dev "trunnel-cli @ git+https://github.com/developmentseed/trunnel#subdirectory=packages/trunnel-cli" +``` + +## Commands + +### `trunnel connect` + +Opens an encrypted SSM port-forward tunnel to a private RDS instance. Resources are located by tag `key=value` pairs. + +```bash +trunnel connect --rds-key Service --rds-value payments-api +``` + +| Option | Default | Env var | +|---|---|---| +| `--bastion-key` | `Role` | `TRUNNEL_CONNECT_BASTION_KEY` | +| `--bastion-value` | `Bastion` | `TRUNNEL_CONNECT_BASTION_VALUE` | +| `--rds-key` | *(required)* | `TRUNNEL_CONNECT_RDS_KEY` | +| `--rds-value` | *(required)* | `TRUNNEL_CONNECT_RDS_VALUE` | +| `--local-port` | `5432` | `TRUNNEL_CONNECT_LOCAL_PORT` | +| `--profile` | | `TRUNNEL_CONNECT_PROFILE` | +| `--reconnect` | | `TRUNNEL_CONNECT_RECONNECT` | + +### `trunnel secrets` + +Looks up an AWS Secrets Manager secret by tag and prints its `SecretString` to stdout. If multiple secrets match, +you are prompted to choose one by name. + +```bash +trunnel secrets --secret-key Stack --secret-value payments-api +``` + +The output is suitable for piping into tools like `jq`: + +```bash +trunnel secrets --secret-key Stack --secret-value payments-api | jq '.password' +``` + +| Option | Default | Env var | +|---|---|---| +| `--secret-key` | *(required)* | `TRUNNEL_SECRETS_SECRET_KEY` | +| `--secret-value` | *(required)* | `TRUNNEL_SECRETS_SECRET_VALUE` | +| `--profile` | | `TRUNNEL_SECRETS_PROFILE` | + +## Environment variables + +All options can be configured via environment variables. Each subcommand uses its own prefix +(`TRUNNEL_CONNECT_` or `TRUNNEL_SECRETS_`), which makes it easy to use [direnv](https://direnv.net/) per project: + +```bash +# .envrc +export TRUNNEL_CONNECT_RDS_KEY=Service +export TRUNNEL_CONNECT_RDS_VALUE=payments-api +export TRUNNEL_SECRETS_SECRET_KEY=Stack +export TRUNNEL_SECRETS_SECRET_VALUE=payments-api ``` diff --git a/packages/trunnel-cli/pyproject.toml b/packages/trunnel-cli/pyproject.toml index 7cb8492..5a9d20d 100644 --- a/packages/trunnel-cli/pyproject.toml +++ b/packages/trunnel-cli/pyproject.toml @@ -15,7 +15,7 @@ dependencies = [ [project.optional-dependencies] types = [ - "boto3-stubs[ec2,rds,resourcegroupstaggingapi]>=1.34.0", + "boto3-stubs[ec2,rds,resourcegroupstaggingapi,secretsmanager]>=1.34.0", ] [project.scripts] diff --git a/packages/trunnel-cli/src/trunnel_cli/discovery.py b/packages/trunnel-cli/src/trunnel_cli/discovery.py index ec4be7a..f87eedf 100644 --- a/packages/trunnel-cli/src/trunnel_cli/discovery.py +++ b/packages/trunnel-cli/src/trunnel_cli/discovery.py @@ -9,6 +9,7 @@ from mypy_boto3_ec2 import EC2Client from mypy_boto3_rds import RDSClient from mypy_boto3_resourcegroupstaggingapi import ResourceGroupsTaggingAPIClient + from mypy_boto3_secretsmanager import SecretsManagerClient @dataclass @@ -28,7 +29,15 @@ class Database: port: int -type Discoverable = Bastion | Database +@dataclass +class Secret: + """Secrets Manager secret metadata.""" + + id: str + name: str + + +type Discoverable = Bastion | Database | Secret @dataclass @@ -46,12 +55,15 @@ class TunnelDiscoverer: Injected RDS client. tag_client : ResourceGroupsTaggingAPIClient, optional Resource tagging API client. + secrets_client : SecretsManagerClient, optional + Injected Secrets Manager client. """ session: boto3.Session = field(default_factory=lambda: boto3.Session()) ec2_client: EC2Client | None = None rds_client: RDSClient | None = None tag_client: ResourceGroupsTaggingAPIClient | None = None + secrets_client: SecretsManagerClient | None = None def __post_init__(self) -> None: """ @@ -62,6 +74,9 @@ def __post_init__(self) -> None: self._tags: ResourceGroupsTaggingAPIClient = self.tag_client or self.session.client( "resourcegroupstaggingapi" ) + self._secrets: SecretsManagerClient = self.secrets_client or self.session.client( + "secretsmanager" + ) def find_bastions(self, key: str, value: str) -> list[Bastion]: """ @@ -152,3 +167,31 @@ def find_rds(self, key: str, value: str) -> list[Database]: ) return matches + + def find_secrets(self, tag_key: str, tag_value: str) -> list[Secret]: + """ + Find Secrets Manager secrets by tag key/value pair. + + Parameters + ---------- + tag_key : str + The tag key to filter by. + tag_value : str + The tag value to filter by. + + Returns + ------- + list[Secret] + A list of discovered Secret resources. + """ + paginator = self._secrets.get_paginator("list_secrets") + secrets: list[Secret] = [] + for page in paginator.paginate( + Filters=[ + {"Key": "tag-key", "Values": [tag_key]}, + {"Key": "tag-value", "Values": [tag_value]}, + ] + ): + for s in page.get("SecretList", []): + secrets.append(Secret(id=s["Name"], name=s.get("Description") or s["Name"])) + return secrets diff --git a/packages/trunnel-cli/src/trunnel_cli/main.py b/packages/trunnel-cli/src/trunnel_cli/main.py index e42e07c..ba53595 100644 --- a/packages/trunnel-cli/src/trunnel_cli/main.py +++ b/packages/trunnel-cli/src/trunnel_cli/main.py @@ -44,14 +44,14 @@ def _select_resource[T: Discoverable](resources: list[T], label: str) -> T: return resources[0] click.secho(f"\nSelect a {label}:", fg="yellow", bold=True) - options = {r.id: r for r in resources} - for r in resources: - click.echo(f" • {click.style(r.id, fg='cyan')} - {r.name}") + for i, r in enumerate(resources, 1): + click.echo(f" {click.style(str(i), fg='cyan', bold=True)}) {r.id} - {r.name}") - choice_id = click.prompt( - f"\nEnter {label} ID", type=click.Choice(list(options.keys())), show_choices=False + choice = click.prompt( + f"\nEnter number", + type=click.IntRange(1, len(resources)), ) - return options[choice_id] + return resources[choice - 1] def _verify_prerequisites() -> None: @@ -66,7 +66,12 @@ def _verify_prerequisites() -> None: opt = partial(click.option, show_envvar=True) -@click.command(context_settings={"auto_envvar_prefix": "TRUNNEL"}) +@click.group(context_settings={"auto_envvar_prefix": "TRUNNEL"}) +def main() -> None: + """Trunnel: Precision-bored SSM tunnels to private RDS instances.""" + + +@main.command() @click.pass_context @opt("--bastion-key", default="Role", show_default=True, help="Tag key for Bastion.") @opt("--bastion-value", default="Bastion", show_default=True, help="Tag value for Bastion.") @@ -75,7 +80,7 @@ def _verify_prerequisites() -> None: @opt("--local-port", default=5432, type=int, show_default=True) @opt("--profile", type=str, help="AWS CLI profile.") @opt("--reconnect", is_flag=True, help="Auto-retry on disconnect.") -def main( +def connect( ctx: click.Context, bastion_key: str, bastion_value: str, @@ -147,3 +152,34 @@ def main( click.echo("\nšŸ‘‹ Trunnel interrupted. Goodbye!") else: click.echo("\nšŸ‘‹ Trunnel disconnected. Goodbye!") + + +@main.command() +@opt("--secret-key", required=True, help="Tag key to filter secrets.") +@opt("--secret-value", required=True, help="Tag value to filter secrets.") +@opt("--profile", type=str, help="AWS CLI profile.") +def secrets( + secret_key: str, + secret_value: str, + profile: str | None, +) -> None: + """ + Fetch and print a Secrets Manager secret by tag. + """ + discoverer = TunnelDiscoverer(session=boto3.Session(profile_name=profile)) + click.echo("šŸ” Searching AWS Secrets Manager...") + + try: + found = discoverer.find_secrets(secret_key, secret_value) + except Exception as e: + raise click.ClickException(f"Secrets Lookup Failed: {e}") from e + + target = _select_resource(found, "Secret") + + try: + response = discoverer._secrets.get_secret_value(SecretId=target.id) + except Exception as e: + raise click.ClickException(f"Failed to fetch secret '{target.name}': {e}") from e + + secret_value = response.get("SecretString") or response.get("SecretBinary", b"").decode() + click.echo(secret_value) diff --git a/pyproject.toml b/pyproject.toml index 7f1d881..b4d27e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,16 @@ [tool.uv] managed = true dev-dependencies = [ - "boto3-stubs[ec2,rds,resourcegroupstaggingapi]>=1.34.0", + "trunnel-cli[types]", "pytest>=8.0.0", "pytest-mock>=3.14.0", "ruff>=0.6.0", "ty>=0.0.18", ] +[tool.uv.sources] +trunnel-cli = { workspace = true } + [tool.uv.workspace] members = ["packages/*"] diff --git a/uv.lock b/uv.lock index 35239a4..a1edbc3 100644 --- a/uv.lock +++ b/uv.lock @@ -10,10 +10,10 @@ members = [ [manifest.dependency-groups] dev = [ - { name = "boto3-stubs", extras = ["ec2", "rds", "resourcegroupstaggingapi"], specifier = ">=1.34.0" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-mock", specifier = ">=3.14.0" }, { name = "ruff", specifier = ">=0.6.0" }, + { name = "trunnel-cli", extras = ["types"], editable = "packages/trunnel-cli" }, { name = "ty", specifier = ">=0.0.18" }, ] @@ -123,6 +123,9 @@ rds = [ resourcegroupstaggingapi = [ { name = "mypy-boto3-resourcegroupstaggingapi" }, ] +secretsmanager = [ + { name = "mypy-boto3-secretsmanager" }, +] [[package]] name = "botocore" @@ -270,6 +273,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7d/5c/9a95415e3945ef10d467a613a1c27b447e48d46cdb5b1b14dfd96638f2f2/mypy_boto3_resourcegroupstaggingapi-1.42.3-py3-none-any.whl", hash = "sha256:22d866b17926dfb8222c6407b80f7b64fe87b631df8ffd1a6a24b3cff337388e", size = 25332, upload-time = "2025-12-04T21:10:50.586Z" }, ] +[[package]] +name = "mypy-boto3-secretsmanager" +version = "1.42.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/58/ccae71b7b7f550eab01d600e956d57e6e6bb9148dbf5d116696d0dc43369/mypy_boto3_secretsmanager-1.42.8.tar.gz", hash = "sha256:5ab42f35ce932765ebb1684146f478a87cc4b83bef950fd1aa0e268b88d59c81", size = 19863, upload-time = "2025-12-11T22:12:51.045Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1a/42/90cef7241c98f6e504cabc9a99d89dd38b84e4d40ff0774c89bc871ffb18/mypy_boto3_secretsmanager-1.42.8-py3-none-any.whl", hash = "sha256:50c891a88e725a8dba7444018e47590ea63d8e938abe2b1c0b25e5413f39d51d", size = 27243, upload-time = "2025-12-11T22:12:44.389Z" }, +] + [[package]] name = "packaging" version = "26.0" @@ -394,7 +406,7 @@ wheels = [ [[package]] name = "trunnel-cli" -version = "0.1.0" +version = "0.2.0" source = { editable = "packages/trunnel-cli" } dependencies = [ { name = "boto3" }, @@ -403,20 +415,20 @@ dependencies = [ [package.optional-dependencies] types = [ - { name = "boto3-stubs", extra = ["ec2", "rds", "resourcegroupstaggingapi"] }, + { name = "boto3-stubs", extra = ["ec2", "rds", "resourcegroupstaggingapi", "secretsmanager"] }, ] [package.metadata] requires-dist = [ { name = "boto3", specifier = ">=1.34.0" }, - { name = "boto3-stubs", extras = ["ec2", "rds", "resourcegroupstaggingapi"], marker = "extra == 'types'", specifier = ">=1.34.0" }, + { name = "boto3-stubs", extras = ["ec2", "rds", "resourcegroupstaggingapi", "secretsmanager"], marker = "extra == 'types'", specifier = ">=1.34.0" }, { name = "click", specifier = ">=8.1.0" }, ] provides-extras = ["types"] [[package]] name = "trunnel-infra" -version = "0.1.0" +version = "0.3.0" source = { editable = "packages/trunnel-infra" } dependencies = [ { name = "aws-cdk-lib" }, From 560796c23fe74ecae0e4cbe6f1859a7ede76824e Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Tue, 12 May 2026 16:56:00 -0400 Subject: [PATCH 2/6] feat: trunnel psql wraps psql for all-in-one --- README.md | 83 +++++++-- packages/trunnel-cli/README.md | 78 +++++--- packages/trunnel-cli/src/trunnel_cli/main.py | 181 ++++++++++++++++--- packages/trunnel-infra/README.md | 4 +- 4 files changed, 282 insertions(+), 64 deletions(-) diff --git a/README.md b/README.md index f51036b..a573c8f 100644 --- a/README.md +++ b/README.md @@ -25,9 +25,9 @@ Trunnel (a play on the [East Side Trolley Tunnel], or a wooden peg used to form a strong connection between pieces of wood) helps automate securely connecting to your private AWS infrastructure through AWS Systems Manager (SSM). It -replaces manual SSH management with automated SSM discovery and a self-healing CDK bastion. Trunnel can discover -the bastion and RDS instance you need to connect to, fetch database credentials stored in Secrets Manager, and -bore the encrypted tunnel — all from a single CLI. +replaces manual SSH management with automated SSM discovery and a self-healing CDK bastion. Trunnel can discover the +bastion and RDS instance you need to connect to, fetch database credentials stored in Secrets Manager, and bore the +encrypted tunnel — all from a single CLI. This tool was designed in response to help reduce minor frustrations like, @@ -74,8 +74,8 @@ bastion.export_security_group("BastionSG-Production") ## Trunnel CLI -The Trunnel CLI makes it easy to find the bastion host and connect to your RDS database via an encrypted SSM tunnel, -and to fetch connection credentials stored in AWS Secrets Manager. Resources are located by tag `key=value` pairs. +The Trunnel CLI makes it easy to find the bastion host and connect to your RDS database via an encrypted SSM tunnel, and +to fetch connection credentials stored in AWS Secrets Manager. Resources are located by tag `key=value` pairs. ### Installation @@ -98,11 +98,12 @@ Opens an encrypted SSM port-forward tunnel to a private RDS instance. $ trunnel connect --rds-key Service --rds-value payments-api --reconnect šŸ” Searching AWS... + Select a Bastion: - • i-0abcd1234efgh5678 - Production-Bastion - • i-09876fedcba54321 - Staging-Bastion + 1) i-0abcd1234efgh5678 - Production-Bastion + 2) i-09876fedcba54321 - Staging-Bastion -Enter Bastion ID: i-0abcd1234efgh5678 +Enter number: 1 šŸšŽ Trunnel Active: localhost:5432 -> payments-api-db.cluster.aws.com šŸ”— payments-api-db via Production-Bastion (i-0abcd1234efgh5678) @@ -145,10 +146,10 @@ If multiple secrets match the tag filter, Trunnel prompts you to choose: ```bash Select a Secret: - • payments-api/db-credentials - Primary database credentials - • payments-api/readonly-credentials - Read-only replica credentials + 1) payments-api/db-credentials - Primary database credentials + 2) payments-api/readonly-credentials - Read-only replica credentials -Enter Secret ID: payments-api/db-credentials +Enter number: 1 ``` ```bash @@ -165,23 +166,69 @@ Options: --help Show this message and exit. ``` +### trunnel psql + +Looks up credentials from Secrets Manager, opens an SSM tunnel, and drops you straight into a `psql` session. Requires +`psql` to be installed alongside the AWS CLI prerequisites. + +```bash +$ trunnel psql \ + --rds-key Stack --rds-value payments-api \ + --secret-key Stack --secret-value payments-api-user-alice + +šŸ” Searching AWS... + +šŸšŽ Opening tunnel: localhost:5432 -> payments-api-db.cluster.aws.com +šŸ”— payments-api-db via Production-Bastion (i-0abcd1234efgh5678) +ā³ Waiting for tunnel... +🐘 Connecting as alice... +psql (16.2) +Type "help" for help. + +payments_api=# +``` + +The RDS and secret tags can differ — useful when a shared database has per-user secrets with different tags. + +```bash +$ trunnel psql --help + +Usage: trunnel psql [OPTIONS] + + Fetch credentials, open a tunnel, and launch psql. + +Options: + --bastion-key TEXT Tag key for Bastion. [env var: TRUNNEL_PSQL_BASTION_KEY; default: Role] + --bastion-value TEXT Tag value for Bastion. [env var: TRUNNEL_PSQL_BASTION_VALUE; default: Bastion] + --rds-key TEXT Tag key for RDS. [env var: TRUNNEL_PSQL_RDS_KEY; required] + --rds-value TEXT Tag value for RDS. [env var: TRUNNEL_PSQL_RDS_VALUE; required] + --secret-key TEXT Tag key for the secret. [env var: TRUNNEL_PSQL_SECRET_KEY; required] + --secret-value TEXT Tag value for the secret. [env var: TRUNNEL_PSQL_SECRET_VALUE; required] + --local-port INTEGER [env var: TRUNNEL_PSQL_LOCAL_PORT; default: 5432] + --profile TEXT AWS CLI profile. [env var: TRUNNEL_PSQL_PROFILE] + --help Show this message and exit. +``` + ### Environment variables All options can be set via environment variables. Each subcommand has its own prefix: -| Subcommand | Prefix | Example | -|--------------------|-------------------------|--------------------------------------| -| `trunnel connect` | `TRUNNEL_CONNECT_` | `TRUNNEL_CONNECT_RDS_KEY=Service` | -| `trunnel secrets` | `TRUNNEL_SECRETS_` | `TRUNNEL_SECRETS_SECRET_KEY=Stack` | +| Subcommand | Prefix | Example | +| ----------------- | ------------------ | ---------------------------------- | +| `trunnel connect` | `TRUNNEL_CONNECT_` | `TRUNNEL_CONNECT_RDS_KEY=Service` | +| `trunnel secrets` | `TRUNNEL_SECRETS_` | `TRUNNEL_SECRETS_SECRET_KEY=Stack` | +| `trunnel psql` | `TRUNNEL_PSQL_` | `TRUNNEL_PSQL_RDS_KEY=Stack` | You might consider using [direnv](https://direnv.net/) to set these per project. For example, ```bash # .envrc -export TRUNNEL_CONNECT_RDS_KEY=Service +export TRUNNEL_CONNECT_RDS_KEY=Stack export TRUNNEL_CONNECT_RDS_VALUE=payments-api -export TRUNNEL_SECRETS_SECRET_KEY=Stack -export TRUNNEL_SECRETS_SECRET_VALUE=payments-api +export TRUNNEL_PSQL_RDS_KEY=Stack +export TRUNNEL_PSQL_RDS_VALUE=payments-api +export TRUNNEL_PSQL_SECRET_KEY=Stack +export TRUNNEL_PSQL_SECRET_VALUE=payments-api-user-alice ``` [East Side Trolley Tunnel]: https://en.wikipedia.org/wiki/East_Side_Trolley_Tunnel diff --git a/packages/trunnel-cli/README.md b/packages/trunnel-cli/README.md index 88333bc..4b45ec5 100644 --- a/packages/trunnel-cli/README.md +++ b/packages/trunnel-cli/README.md @@ -2,8 +2,8 @@ > Automated SSM tunneling and credential lookup for private RDS instances. -`trunnel-cli` is the developer-facing component of the Trunnel project. It automates the discovery of Bastion hosts, -RDS instances, and Secrets Manager credentials via AWS tags, then establishes an encrypted SSM tunnel so local database +`trunnel-cli` is the developer-facing component of the Trunnel project. It automates the discovery of Bastion hosts, RDS +instances, and Secrets Manager credentials via AWS tags, then establishes an encrypted SSM tunnel so local database clients can connect to private RDS instances. ## Installation @@ -30,20 +30,20 @@ Opens an encrypted SSM port-forward tunnel to a private RDS instance. Resources trunnel connect --rds-key Service --rds-value payments-api ``` -| Option | Default | Env var | -|---|---|---| -| `--bastion-key` | `Role` | `TRUNNEL_CONNECT_BASTION_KEY` | -| `--bastion-value` | `Bastion` | `TRUNNEL_CONNECT_BASTION_VALUE` | -| `--rds-key` | *(required)* | `TRUNNEL_CONNECT_RDS_KEY` | -| `--rds-value` | *(required)* | `TRUNNEL_CONNECT_RDS_VALUE` | -| `--local-port` | `5432` | `TRUNNEL_CONNECT_LOCAL_PORT` | -| `--profile` | | `TRUNNEL_CONNECT_PROFILE` | -| `--reconnect` | | `TRUNNEL_CONNECT_RECONNECT` | +| Option | Default | Env var | +| ----------------- | ------------ | ------------------------------- | +| `--bastion-key` | `Role` | `TRUNNEL_CONNECT_BASTION_KEY` | +| `--bastion-value` | `Bastion` | `TRUNNEL_CONNECT_BASTION_VALUE` | +| `--rds-key` | _(required)_ | `TRUNNEL_CONNECT_RDS_KEY` | +| `--rds-value` | _(required)_ | `TRUNNEL_CONNECT_RDS_VALUE` | +| `--local-port` | `5432` | `TRUNNEL_CONNECT_LOCAL_PORT` | +| `--profile` | | `TRUNNEL_CONNECT_PROFILE` | +| `--reconnect` | | `TRUNNEL_CONNECT_RECONNECT` | ### `trunnel secrets` -Looks up an AWS Secrets Manager secret by tag and prints its `SecretString` to stdout. If multiple secrets match, -you are prompted to choose one by name. +Looks up an AWS Secrets Manager secret by tag and prints its `SecretString` to stdout. If multiple secrets match, you +are prompted to choose one by name. ```bash trunnel secrets --secret-key Stack --secret-value payments-api @@ -55,21 +55,53 @@ The output is suitable for piping into tools like `jq`: trunnel secrets --secret-key Stack --secret-value payments-api | jq '.password' ``` -| Option | Default | Env var | -|---|---|---| -| `--secret-key` | *(required)* | `TRUNNEL_SECRETS_SECRET_KEY` | -| `--secret-value` | *(required)* | `TRUNNEL_SECRETS_SECRET_VALUE` | -| `--profile` | | `TRUNNEL_SECRETS_PROFILE` | +| Option | Default | Env var | +| ---------------- | ------------ | ------------------------------ | +| `--secret-key` | _(required)_ | `TRUNNEL_SECRETS_SECRET_KEY` | +| `--secret-value` | _(required)_ | `TRUNNEL_SECRETS_SECRET_VALUE` | +| `--profile` | | `TRUNNEL_SECRETS_PROFILE` | + +### `trunnel psql` + +Looks up credentials from Secrets Manager, opens an SSM tunnel in the background, and launches `psql`. Requires `psql` +to be installed. The RDS and secret tags can differ — useful when a shared database has per-user secrets. + +```bash +trunnel psql \ + --rds-key Stack --rds-value payments-api \ + --secret-key Stack --secret-value payments-api-user-alice +``` + +If port 5432 is already in use locally (e.g. a local Postgres), use `--local-port` to pick a free port: + +```bash +trunnel psql --rds-key Stack --rds-value payments-api \ + --secret-key Stack --secret-value payments-api-user-alice \ + --local-port 15432 +``` + +| Option | Default | Env var | +| ----------------- | ------------ | ---------------------------- | +| `--bastion-key` | `Role` | `TRUNNEL_PSQL_BASTION_KEY` | +| `--bastion-value` | `Bastion` | `TRUNNEL_PSQL_BASTION_VALUE` | +| `--rds-key` | _(required)_ | `TRUNNEL_PSQL_RDS_KEY` | +| `--rds-value` | _(required)_ | `TRUNNEL_PSQL_RDS_VALUE` | +| `--secret-key` | _(required)_ | `TRUNNEL_PSQL_SECRET_KEY` | +| `--secret-value` | _(required)_ | `TRUNNEL_PSQL_SECRET_VALUE` | +| `--local-port` | `5432` | `TRUNNEL_PSQL_LOCAL_PORT` | +| `--profile` | | `TRUNNEL_PSQL_PROFILE` | ## Environment variables -All options can be configured via environment variables. Each subcommand uses its own prefix -(`TRUNNEL_CONNECT_` or `TRUNNEL_SECRETS_`), which makes it easy to use [direnv](https://direnv.net/) per project: +All options can be configured via environment variables. Each subcommand uses its own prefix, which makes it easy to use +[direnv](https://direnv.net/) per project: ```bash # .envrc -export TRUNNEL_CONNECT_RDS_KEY=Service +export TRUNNEL_CONNECT_RDS_KEY=Stack export TRUNNEL_CONNECT_RDS_VALUE=payments-api -export TRUNNEL_SECRETS_SECRET_KEY=Stack -export TRUNNEL_SECRETS_SECRET_VALUE=payments-api +export TRUNNEL_PSQL_RDS_KEY=Stack +export TRUNNEL_PSQL_RDS_VALUE=payments-api +export TRUNNEL_PSQL_SECRET_KEY=Stack +export TRUNNEL_PSQL_SECRET_VALUE=payments-api-user-alice ``` diff --git a/packages/trunnel-cli/src/trunnel_cli/main.py b/packages/trunnel-cli/src/trunnel_cli/main.py index ba53595..9075427 100644 --- a/packages/trunnel-cli/src/trunnel_cli/main.py +++ b/packages/trunnel-cli/src/trunnel_cli/main.py @@ -5,7 +5,9 @@ from __future__ import annotations import json +import os import shutil +import socket import subprocess import time from functools import partial @@ -48,7 +50,7 @@ def _select_resource[T: Discoverable](resources: list[T], label: str) -> T: click.echo(f" {click.style(str(i), fg='cyan', bold=True)}) {r.id} - {r.name}") choice = click.prompt( - f"\nEnter number", + "\nEnter number", type=click.IntRange(1, len(resources)), ) return resources[choice - 1] @@ -63,6 +65,68 @@ def _verify_prerequisites() -> None: ) +def _assert_port_free(port: int) -> None: + """Raise early if the local port is already in use.""" + try: + with socket.create_connection(("localhost", port), timeout=0.5): + raise click.ClickException( + f"Port {port} is already in use (local Postgres?). " + f"Choose a free port with --local-port." + ) + except OSError: + pass # port is free + + +def _wait_for_port(port: int, timeout: float = 30.0) -> bool: + """Poll localhost: until it accepts a connection or timeout expires.""" + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + with socket.create_connection(("localhost", port), timeout=1): + return True + except OSError: + time.sleep(0.5) + return False + + +def _parse_db_secret(secret_string: str) -> dict[str, str]: + """Parse a Secrets Manager SecretString as RDS JSON credentials.""" + try: + data = json.loads(secret_string) + except json.JSONDecodeError as e: + raise click.ClickException(f"Secret is not valid JSON: {e}") from e + for field in ("username", "password"): + if field not in data: + raise click.ClickException(f"Secret JSON missing required field: '{field}'") + return data + + +def _build_ssm_cmd( + bastion_id: str, rds_host: str, rds_port: int, local_port: int, profile: str | None +) -> list[str]: + params = json.dumps( + { + "host": [rds_host], + "portNumber": [str(rds_port)], + "localPortNumber": [str(local_port)], + } + ) + cmd = [ + "aws", + "ssm", + "start-session", + "--target", + bastion_id, + "--document-name", + "AWS-StartPortForwardingSessionToRemoteHost", + "--parameters", + params, + ] + if profile: + cmd.extend(["--profile", profile]) + return cmd + + opt = partial(click.option, show_envvar=True) @@ -109,26 +173,7 @@ def connect( except Exception as e: raise click.ClickException(f"RDS Lookup Failed: {e}") from e - params = json.dumps( - { - "host": [target_rds.id], - "portNumber": [str(target_rds.port)], - "localPortNumber": [str(local_port)], - } - ) - aws_cmd = [ - "aws", - "ssm", - "start-session", - "--target", - target_bastion.id, - "--document-name", - "AWS-StartPortForwardingSessionToRemoteHost", - "--parameters", - params, - ] - if profile: - aws_cmd.extend(["--profile", profile]) + aws_cmd = _build_ssm_cmd(target_bastion.id, target_rds.id, target_rds.port, local_port, profile) try: while True: @@ -183,3 +228,97 @@ def secrets( secret_value = response.get("SecretString") or response.get("SecretBinary", b"").decode() click.echo(secret_value) + + +@main.command() +@opt("--bastion-key", default="Role", show_default=True, help="Tag key for Bastion.") +@opt("--bastion-value", default="Bastion", show_default=True, help="Tag value for Bastion.") +@opt("--rds-key", required=True, help="Tag key for RDS.") +@opt("--rds-value", required=True, help="Tag value for RDS.") +@opt("--secret-key", required=True, help="Tag key for the Secrets Manager secret.") +@opt("--secret-value", required=True, help="Tag value for the Secrets Manager secret.") +@opt("--local-port", default=5432, type=int, show_default=True) +@opt("--profile", type=str, help="AWS CLI profile.") +def psql( + bastion_key: str, + bastion_value: str, + rds_key: str, + rds_value: str, + secret_key: str, + secret_value: str, + local_port: int, + profile: str | None, +) -> None: + """ + Fetch credentials, open a tunnel, and launch psql. + """ + _verify_prerequisites() + if not shutil.which("psql"): + raise click.UsageError("Dependency 'psql' not found. Install PostgreSQL client tools.") + + discoverer = TunnelDiscoverer(session=boto3.Session(profile_name=profile)) + click.echo("šŸ” Searching AWS...") + + try: + target_bastion = _select_resource( + discoverer.find_bastions(bastion_key, bastion_value), "Bastion" + ) + except Exception as e: + raise click.ClickException(f"EC2 Lookup Failed: {e}") from e + try: + target_rds = _select_resource(discoverer.find_rds(rds_key, rds_value), "RDS") + except Exception as e: + raise click.ClickException(f"RDS Lookup Failed: {e}") from e + try: + target_secret = _select_resource( + discoverer.find_secrets(secret_key, secret_value), "Secret" + ) + except Exception as e: + raise click.ClickException(f"Secrets Lookup Failed: {e}") from e + + try: + response = discoverer._secrets.get_secret_value(SecretId=target_secret.id) + except Exception as e: + raise click.ClickException(f"Failed to fetch secret '{target_secret.name}': {e}") from e + + creds = _parse_db_secret( + response.get("SecretString") or response.get("SecretBinary", b"").decode() + ) + + _assert_port_free(local_port) + + aws_cmd = _build_ssm_cmd(target_bastion.id, target_rds.id, target_rds.port, local_port, profile) + + click.secho( + f"\nšŸšŽ Opening tunnel: localhost:{local_port} -> {target_rds.name}", + fg="green", + bold=True, + ) + click.secho(f"šŸ”— {target_rds.id} via {target_bastion.name} ({target_bastion.id})", fg="cyan") + + tunnel = subprocess.Popen(aws_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + try: + click.echo("ā³ Waiting for tunnel...") + if not _wait_for_port(local_port): + raise click.ClickException(f"Tunnel did not become ready on port {local_port}.") + + psql_cmd = [ + "psql", + "-h", + "localhost", + "-p", + str(local_port), + "-U", + creds["username"], + ] + if dbname := creds.get("dbname") or creds.get("database"): + psql_cmd.extend(["-d", dbname]) + + env = {**os.environ, "PGPASSWORD": creds["password"]} + click.secho(f"🐘 Connecting as {creds['username']}...", fg="green") + subprocess.run(psql_cmd, env=env, check=False) + except KeyboardInterrupt: + click.echo("\nšŸ‘‹ Interrupted. Goodbye!") + finally: + tunnel.terminate() + tunnel.wait() diff --git a/packages/trunnel-infra/README.md b/packages/trunnel-infra/README.md index 8fe9f18..f67b610 100644 --- a/packages/trunnel-infra/README.md +++ b/packages/trunnel-infra/README.md @@ -11,8 +11,8 @@ specifically for SSM-based tunneling. - **Auto-Rotation:** Automatically triggers an ASG Instance Refresh when a referenced SSM Parameter (AMI ID) is updated. - **Least Privilege:** Automatically manages Security Group egress/ingress based on the provided RDS targets using the CDK Connections API. -- **Unattended Upgrades:** Automatically applies security updates on every instance launch via `dnf-automatic` - (Amazon Linux 2023 default). +- **Unattended Upgrades:** Automatically applies security updates on every instance launch via `dnf-automatic` (Amazon + Linux 2023 default). ## Unattended Security Updates From 9de7085d6f6082b1121d52b57efd8401896a31fc Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Tue, 12 May 2026 17:09:14 -0400 Subject: [PATCH 3/6] fix: self review / refactor --- .../trunnel-cli/src/trunnel_cli/discovery.py | 17 ++ packages/trunnel-cli/src/trunnel_cli/main.py | 154 +++++++++++------- 2 files changed, 113 insertions(+), 58 deletions(-) diff --git a/packages/trunnel-cli/src/trunnel_cli/discovery.py b/packages/trunnel-cli/src/trunnel_cli/discovery.py index f87eedf..6d468f4 100644 --- a/packages/trunnel-cli/src/trunnel_cli/discovery.py +++ b/packages/trunnel-cli/src/trunnel_cli/discovery.py @@ -168,6 +168,23 @@ def find_rds(self, key: str, value: str) -> list[Database]: return matches + def fetch_secret(self, secret_id: str) -> str: + """ + Fetch the plaintext value of a secret by name or ARN. + + Parameters + ---------- + secret_id : str + The secret name or ARN. + + Returns + ------- + str + The secret's string value. + """ + response = self._secrets.get_secret_value(SecretId=secret_id) + return response.get("SecretString") or response.get("SecretBinary", b"").decode() + def find_secrets(self, tag_key: str, tag_value: str) -> list[Secret]: """ Find Secrets Manager secrets by tag key/value pair. diff --git a/packages/trunnel-cli/src/trunnel_cli/main.py b/packages/trunnel-cli/src/trunnel_cli/main.py index 9075427..c708eb6 100644 --- a/packages/trunnel-cli/src/trunnel_cli/main.py +++ b/packages/trunnel-cli/src/trunnel_cli/main.py @@ -10,7 +10,9 @@ import socket import subprocess import time -from functools import partial +from collections.abc import Callable, Generator +from contextlib import contextmanager +from typing import Any import boto3 import click @@ -56,6 +58,17 @@ def _select_resource[T: Discoverable](resources: list[T], label: str) -> T: return resources[choice - 1] +@contextmanager +def _aws_errors(label: str) -> Generator[None, None, None]: + """Translate unexpected AWS/boto3 exceptions into a clean ClickException.""" + try: + yield + except click.ClickException: + raise + except Exception as e: + raise click.ClickException(f"{label}: {e}") from e + + def _verify_prerequisites() -> None: """Ensure required CLI programs are installed""" for bin_name in ["aws", "session-manager-plugin"]: @@ -127,7 +140,64 @@ def _build_ssm_cmd( return cmd -opt = partial(click.option, show_envvar=True) +# --------------------------------------------------------------------------- +# Reusable option groups +# --------------------------------------------------------------------------- + +opt = click.option + + +def _bastion_opts(f: Callable[..., Any]) -> Callable[..., Any]: + f = opt( + "--bastion-value", + default="Bastion", + show_default=True, + show_envvar=True, + help="Tag value for Bastion.", + )(f) + f = opt( + "--bastion-key", + default="Role", + show_default=True, + show_envvar=True, + help="Tag key for Bastion.", + )(f) + return f + + +def _rds_opts(f: Callable[..., Any]) -> Callable[..., Any]: + f = opt("--rds-value", required=True, show_envvar=True, help="Tag value for RDS.")(f) + f = opt("--rds-key", required=True, show_envvar=True, help="Tag key for RDS.")(f) + return f + + +def _secret_opts(f: Callable[..., Any]) -> Callable[..., Any]: + f = opt( + "--secret-value", + required=True, + show_envvar=True, + help="Tag value for the Secrets Manager secret.", + )(f) + f = opt( + "--secret-key", + required=True, + show_envvar=True, + help="Tag key for the Secrets Manager secret.", + )(f) + return f + + +def _profile_opt(f: Callable[..., Any]) -> Callable[..., Any]: + return opt("--profile", type=str, show_envvar=True, help="AWS CLI profile.")(f) + + +def _local_port_opt(f: Callable[..., Any]) -> Callable[..., Any]: + return opt("--local-port", default=5432, type=int, show_default=True, show_envvar=True)(f) + + +# --------------------------------------------------------------------------- +# Commands +# --------------------------------------------------------------------------- @click.group(context_settings={"auto_envvar_prefix": "TRUNNEL"}) @@ -136,16 +206,12 @@ def main() -> None: @main.command() -@click.pass_context -@opt("--bastion-key", default="Role", show_default=True, help="Tag key for Bastion.") -@opt("--bastion-value", default="Bastion", show_default=True, help="Tag value for Bastion.") -@opt("--rds-key", required=True, help="Tag key for RDS.") -@opt("--rds-value", required=True, help="Tag value for RDS.") -@opt("--local-port", default=5432, type=int, show_default=True) -@opt("--profile", type=str, help="AWS CLI profile.") -@opt("--reconnect", is_flag=True, help="Auto-retry on disconnect.") +@_bastion_opts +@_rds_opts +@_local_port_opt +@_profile_opt +@opt("--reconnect", is_flag=True, show_envvar=True, help="Auto-retry on disconnect.") def connect( - ctx: click.Context, bastion_key: str, bastion_value: str, rds_key: str, @@ -162,16 +228,12 @@ def connect( discoverer = TunnelDiscoverer(session=boto3.Session(profile_name=profile)) click.echo("šŸ” Searching AWS...") - try: + with _aws_errors("EC2 Lookup Failed"): target_bastion = _select_resource( discoverer.find_bastions(bastion_key, bastion_value), "Bastion" ) - except Exception as e: - raise click.ClickException(f"EC2 Lookup Failed: {e}") from e - try: + with _aws_errors("RDS Lookup Failed"): target_rds = _select_resource(discoverer.find_rds(rds_key, rds_value), "RDS") - except Exception as e: - raise click.ClickException(f"RDS Lookup Failed: {e}") from e aws_cmd = _build_ssm_cmd(target_bastion.id, target_rds.id, target_rds.port, local_port, profile) @@ -200,9 +262,8 @@ def connect( @main.command() -@opt("--secret-key", required=True, help="Tag key to filter secrets.") -@opt("--secret-value", required=True, help="Tag value to filter secrets.") -@opt("--profile", type=str, help="AWS CLI profile.") +@_secret_opts +@_profile_opt def secrets( secret_key: str, secret_value: str, @@ -214,31 +275,21 @@ def secrets( discoverer = TunnelDiscoverer(session=boto3.Session(profile_name=profile)) click.echo("šŸ” Searching AWS Secrets Manager...") - try: - found = discoverer.find_secrets(secret_key, secret_value) - except Exception as e: - raise click.ClickException(f"Secrets Lookup Failed: {e}") from e + with _aws_errors("Secrets Lookup Failed"): + target = _select_resource(discoverer.find_secrets(secret_key, secret_value), "Secret") - target = _select_resource(found, "Secret") + with _aws_errors(f"Failed to fetch secret '{target.name}'"): + secret_string = discoverer.fetch_secret(target.id) - try: - response = discoverer._secrets.get_secret_value(SecretId=target.id) - except Exception as e: - raise click.ClickException(f"Failed to fetch secret '{target.name}': {e}") from e - - secret_value = response.get("SecretString") or response.get("SecretBinary", b"").decode() - click.echo(secret_value) + click.echo(secret_string) @main.command() -@opt("--bastion-key", default="Role", show_default=True, help="Tag key for Bastion.") -@opt("--bastion-value", default="Bastion", show_default=True, help="Tag value for Bastion.") -@opt("--rds-key", required=True, help="Tag key for RDS.") -@opt("--rds-value", required=True, help="Tag value for RDS.") -@opt("--secret-key", required=True, help="Tag key for the Secrets Manager secret.") -@opt("--secret-value", required=True, help="Tag value for the Secrets Manager secret.") -@opt("--local-port", default=5432, type=int, show_default=True) -@opt("--profile", type=str, help="AWS CLI profile.") +@_bastion_opts +@_rds_opts +@_secret_opts +@_local_port_opt +@_profile_opt def psql( bastion_key: str, bastion_value: str, @@ -259,31 +310,18 @@ def psql( discoverer = TunnelDiscoverer(session=boto3.Session(profile_name=profile)) click.echo("šŸ” Searching AWS...") - try: + with _aws_errors("EC2 Lookup Failed"): target_bastion = _select_resource( discoverer.find_bastions(bastion_key, bastion_value), "Bastion" ) - except Exception as e: - raise click.ClickException(f"EC2 Lookup Failed: {e}") from e - try: + with _aws_errors("RDS Lookup Failed"): target_rds = _select_resource(discoverer.find_rds(rds_key, rds_value), "RDS") - except Exception as e: - raise click.ClickException(f"RDS Lookup Failed: {e}") from e - try: + with _aws_errors("Secrets Lookup Failed"): target_secret = _select_resource( discoverer.find_secrets(secret_key, secret_value), "Secret" ) - except Exception as e: - raise click.ClickException(f"Secrets Lookup Failed: {e}") from e - - try: - response = discoverer._secrets.get_secret_value(SecretId=target_secret.id) - except Exception as e: - raise click.ClickException(f"Failed to fetch secret '{target_secret.name}': {e}") from e - - creds = _parse_db_secret( - response.get("SecretString") or response.get("SecretBinary", b"").decode() - ) + with _aws_errors(f"Failed to fetch secret '{target_secret.name}'"): + creds = _parse_db_secret(discoverer.fetch_secret(target_secret.id)) _assert_port_free(local_port) From eb980c9354e4e111ca538041b8316db2f59fe90e Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Tue, 12 May 2026 17:12:20 -0400 Subject: [PATCH 4/6] fix: exit early in psql command if tunnel doesn't open --- packages/trunnel-cli/src/trunnel_cli/main.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/trunnel-cli/src/trunnel_cli/main.py b/packages/trunnel-cli/src/trunnel_cli/main.py index c708eb6..4965a01 100644 --- a/packages/trunnel-cli/src/trunnel_cli/main.py +++ b/packages/trunnel-cli/src/trunnel_cli/main.py @@ -90,10 +90,12 @@ def _assert_port_free(port: int) -> None: pass # port is free -def _wait_for_port(port: int, timeout: float = 30.0) -> bool: - """Poll localhost: until it accepts a connection or timeout expires.""" +def _wait_for_port(port: int, tunnel: subprocess.Popen[bytes], timeout: float = 30.0) -> bool: + """Poll localhost: until it accepts a connection, the tunnel exits, or timeout expires.""" deadline = time.monotonic() + timeout while time.monotonic() < deadline: + if tunnel.poll() is not None: + return False try: with socket.create_connection(("localhost", port), timeout=1): return True @@ -337,7 +339,7 @@ def psql( tunnel = subprocess.Popen(aws_cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) try: click.echo("ā³ Waiting for tunnel...") - if not _wait_for_port(local_port): + if not _wait_for_port(local_port, tunnel): raise click.ClickException(f"Tunnel did not become ready on port {local_port}.") psql_cmd = [ From 1502927c1ce24c1de527ce489a0b501c5acf6382 Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Tue, 12 May 2026 17:12:35 -0400 Subject: [PATCH 5/6] fix: provide a useful error if secret isn't well formatted --- packages/trunnel-cli/src/trunnel_cli/discovery.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/trunnel-cli/src/trunnel_cli/discovery.py b/packages/trunnel-cli/src/trunnel_cli/discovery.py index 6d468f4..78b12e3 100644 --- a/packages/trunnel-cli/src/trunnel_cli/discovery.py +++ b/packages/trunnel-cli/src/trunnel_cli/discovery.py @@ -183,7 +183,13 @@ def fetch_secret(self, secret_id: str) -> str: The secret's string value. """ response = self._secrets.get_secret_value(SecretId=secret_id) - return response.get("SecretString") or response.get("SecretBinary", b"").decode() + value = response.get("SecretString") or response.get("SecretBinary", b"").decode() + if not value: + raise ValueError( + f"Secret '{secret_id}' has no value. " + "Expected a secret stored as JSON in SecretString or SecretBinary." + ) + return value def find_secrets(self, tag_key: str, tag_value: str) -> list[Secret]: """ From 8e7e0f9a7215fdbba620fbeae42f09852fe4dc66 Mon Sep 17 00:00:00 2001 From: Chris Holden Date: Tue, 12 May 2026 17:14:01 -0400 Subject: [PATCH 6/6] fix: remove opt alias --- packages/trunnel-cli/src/trunnel_cli/main.py | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/trunnel-cli/src/trunnel_cli/main.py b/packages/trunnel-cli/src/trunnel_cli/main.py index 4965a01..180020d 100644 --- a/packages/trunnel-cli/src/trunnel_cli/main.py +++ b/packages/trunnel-cli/src/trunnel_cli/main.py @@ -146,18 +146,16 @@ def _build_ssm_cmd( # Reusable option groups # --------------------------------------------------------------------------- -opt = click.option - def _bastion_opts(f: Callable[..., Any]) -> Callable[..., Any]: - f = opt( + f = click.option( "--bastion-value", default="Bastion", show_default=True, show_envvar=True, help="Tag value for Bastion.", )(f) - f = opt( + f = click.option( "--bastion-key", default="Role", show_default=True, @@ -168,19 +166,19 @@ def _bastion_opts(f: Callable[..., Any]) -> Callable[..., Any]: def _rds_opts(f: Callable[..., Any]) -> Callable[..., Any]: - f = opt("--rds-value", required=True, show_envvar=True, help="Tag value for RDS.")(f) - f = opt("--rds-key", required=True, show_envvar=True, help="Tag key for RDS.")(f) + f = click.option("--rds-value", required=True, show_envvar=True, help="Tag value for RDS.")(f) + f = click.option("--rds-key", required=True, show_envvar=True, help="Tag key for RDS.")(f) return f def _secret_opts(f: Callable[..., Any]) -> Callable[..., Any]: - f = opt( + f = click.option( "--secret-value", required=True, show_envvar=True, help="Tag value for the Secrets Manager secret.", )(f) - f = opt( + f = click.option( "--secret-key", required=True, show_envvar=True, @@ -190,11 +188,13 @@ def _secret_opts(f: Callable[..., Any]) -> Callable[..., Any]: def _profile_opt(f: Callable[..., Any]) -> Callable[..., Any]: - return opt("--profile", type=str, show_envvar=True, help="AWS CLI profile.")(f) + return click.option("--profile", type=str, show_envvar=True, help="AWS CLI profile.")(f) def _local_port_opt(f: Callable[..., Any]) -> Callable[..., Any]: - return opt("--local-port", default=5432, type=int, show_default=True, show_envvar=True)(f) + return click.option( + "--local-port", default=5432, type=int, show_default=True, show_envvar=True + )(f) # --------------------------------------------------------------------------- @@ -212,7 +212,7 @@ def main() -> None: @_rds_opts @_local_port_opt @_profile_opt -@opt("--reconnect", is_flag=True, show_envvar=True, help="Auto-retry on disconnect.") +@click.option("--reconnect", is_flag=True, show_envvar=True, help="Auto-retry on disconnect.") def connect( bastion_key: str, bastion_value: str,