dbctl 0.7.0__tar.gz → 0.7.2__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {dbctl-0.7.0 → dbctl-0.7.2}/CHANGELOG.md +38 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/PKG-INFO +7 -1
- {dbctl-0.7.0 → dbctl-0.7.2}/README.md +6 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/db.py +8 -0
- dbctl-0.7.2/dbctl/refs.py +173 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/base.py +7 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/ssm.py +64 -13
- {dbctl-0.7.0 → dbctl-0.7.2}/docs/connections.md +55 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/docs/operations.md +13 -13
- {dbctl-0.7.0 → dbctl-0.7.2}/pyproject.toml +1 -1
- {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_copy_features.py +2 -2
- dbctl-0.7.2/tests/test_refs.py +292 -0
- dbctl-0.7.2/tests/test_sso_cache.py +188 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/uv.lock +214 -1
- {dbctl-0.7.0 → dbctl-0.7.2}/.dbctl/connections.yaml +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/.dbctl/operations.yaml +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/.github/workflows/ci.yml +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/.github-local/ci.yml +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/.gitignore +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/Makefile +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/__init__.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/__main__.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/audit.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/cli.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/config.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/connections.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/execute.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/init.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/multi.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/operations.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/reports.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/runtime.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/__init__.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/direct.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/k8s.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/ssh.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/docker-compose.yml +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/docs/ACTION_OUTPUT.md +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/docs/DESIGN.md +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/docs/SESSION_STATE.md +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/docs/logo.png +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/docs/logo_small.png +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/docs/tutorial.md +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/seed/mssql.sql +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/seed/mysql.sql +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/seed/postgres.sql +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_bastion_tags.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_connections_loader.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_issue_1.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_k8s_tunnel.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_regressions.py +0 -0
- {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_smoke.py +0 -0
|
@@ -5,6 +5,44 @@ All notable changes to this project will be documented here.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [0.7.2] — 2026-08-04
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- **`{{ssm:...}}` connection references** — any string field on a
|
|
13
|
+
connection (`password`, `username`, `database`, `driver`, `url`, or a
|
|
14
|
+
nested tunnel field like `ssm.remote_host` / `direct.host`) can now be
|
|
15
|
+
a placeholder — `{{ssm:<parameter-name>[;property:<json-key>][;profile:<aws-profile>]}}`
|
|
16
|
+
— resolved against AWS SSM Parameter Store instead of a literal value.
|
|
17
|
+
`SecureString` parameters are fetched with decryption; `property`
|
|
18
|
+
extracts one key from a JSON-object parameter value (so several
|
|
19
|
+
credentials can share one parameter). Resolution is lazy — it runs via
|
|
20
|
+
`dbctl.refs.resolve_connection` right before a connection is actually
|
|
21
|
+
used to open a tunnel or build an engine, never at `connections.yaml`
|
|
22
|
+
load time and never from `dbctl connections list` / `show` — so
|
|
23
|
+
browsing the registry never makes an AWS call or risks echoing a
|
|
24
|
+
decrypted secret, and one unreachable profile can't break unrelated
|
|
25
|
+
connections. See `docs/connections.md` for the full reference.
|
|
26
|
+
|
|
27
|
+
## [0.7.1] — 2026-08-03
|
|
28
|
+
|
|
29
|
+
### Fixed
|
|
30
|
+
|
|
31
|
+
- **SSM tunnel SSO token check always reported "missing/expired" for
|
|
32
|
+
`sso_session`-based profiles** — `_sso_cache_path` hashed the *profile
|
|
33
|
+
name* with SHA-256, but the AWS CLI actually names SSO token-cache
|
|
34
|
+
files `sha1(<key>).json`, where `<key>` is the `sso_session` name (for
|
|
35
|
+
profiles using the now-recommended `sso_session = <name>` config
|
|
36
|
+
style — what `aws configure sso` generates by default) or the
|
|
37
|
+
profile's own `sso_start_url` (legacy inline-SSO profiles). Since
|
|
38
|
+
neither the hash algorithm nor the hashed value matched what the AWS
|
|
39
|
+
CLI actually wrote, `dbctl` reported every such profile as logged out
|
|
40
|
+
immediately after a successful `aws sso login`, and the automatic
|
|
41
|
+
re-login path (`disable_automatic_sso_login: false`, the default) hit
|
|
42
|
+
the same broken check right after re-authenticating. Fixed by
|
|
43
|
+
resolving the real cache key from `~/.aws/config` (or
|
|
44
|
+
`$AWS_CONFIG_FILE`) and hashing it with SHA-1.
|
|
45
|
+
|
|
8
46
|
## [0.7.0] — 2026-08-03
|
|
9
47
|
|
|
10
48
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dbctl
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2
|
|
4
4
|
Summary: Generic CLI to monitor, control, and administer multiple databases via SSM, SSH, or direct connection.
|
|
5
5
|
Author: dbctl contributors
|
|
6
6
|
License: MIT
|
|
@@ -339,6 +339,12 @@ DB password sources are mutually exclusive — pick **one** per connection:
|
|
|
339
339
|
| `windows_sso: true` | mssql+pyodbc only: Windows Integrated Security (`Trusted_Connection=yes`). No username/password needed. |
|
|
340
340
|
| `url: "…"` | full SQLAlchemy URL (overrides all the above). Use for Azure AD, ODBC-specific kwargs, or any non-standard connection string. |
|
|
341
341
|
|
|
342
|
+
Any of these — plus `username`, `database`, `driver`, or a tunnel field like
|
|
343
|
+
`direct.host` — can instead be a `{{ssm:/param/path;property:key;profile:name}}`
|
|
344
|
+
placeholder resolved against AWS SSM Parameter Store at connection time. See
|
|
345
|
+
[`docs/connections.md`](docs/connections.md#ssm-references) for the full
|
|
346
|
+
reference.
|
|
347
|
+
|
|
342
348
|
Secret-typed **operation** parameters (`type: secret`) are redacted in the
|
|
343
349
|
audit log regardless of which DB password source the connection uses.
|
|
344
350
|
|
|
@@ -305,6 +305,12 @@ DB password sources are mutually exclusive — pick **one** per connection:
|
|
|
305
305
|
| `windows_sso: true` | mssql+pyodbc only: Windows Integrated Security (`Trusted_Connection=yes`). No username/password needed. |
|
|
306
306
|
| `url: "…"` | full SQLAlchemy URL (overrides all the above). Use for Azure AD, ODBC-specific kwargs, or any non-standard connection string. |
|
|
307
307
|
|
|
308
|
+
Any of these — plus `username`, `database`, `driver`, or a tunnel field like
|
|
309
|
+
`direct.host` — can instead be a `{{ssm:/param/path;property:key;profile:name}}`
|
|
310
|
+
placeholder resolved against AWS SSM Parameter Store at connection time. See
|
|
311
|
+
[`docs/connections.md`](docs/connections.md#ssm-references) for the full
|
|
312
|
+
reference.
|
|
313
|
+
|
|
308
314
|
Secret-typed **operation** parameters (`type: secret`) are redacted in the
|
|
309
315
|
audit log regardless of which DB password source the connection uses.
|
|
310
316
|
|
|
@@ -102,7 +102,15 @@ def build_engine(conn: Connection, tunnel: Tunnel, *, echo: bool = False) -> Eng
|
|
|
102
102
|
password injected — the URL is just ``driver:///path/to/file``. The
|
|
103
103
|
tunnel's local bind is irrelevant for file-based DBs (the file is
|
|
104
104
|
local), and injecting `host:port` makes SQLAlchemy reject the URL.
|
|
105
|
+
|
|
106
|
+
Resolves any ``{{ssm:...}}`` placeholders on ``conn`` first (see
|
|
107
|
+
``dbctl.refs``) — this is the point a connection is actually used, so
|
|
108
|
+
it's the right place for that lazy resolution to happen.
|
|
105
109
|
"""
|
|
110
|
+
from dbctl.refs import resolve_connection
|
|
111
|
+
|
|
112
|
+
conn = resolve_connection(conn)
|
|
113
|
+
|
|
106
114
|
driver = _driver_name(conn)
|
|
107
115
|
_check_driver_available(driver)
|
|
108
116
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Resolve ``{{ssm:...}}`` placeholders in connection values against AWS SSM
|
|
2
|
+
Parameter Store, so secrets never need to live in plaintext in
|
|
3
|
+
``connections.yaml``.
|
|
4
|
+
|
|
5
|
+
Reference format::
|
|
6
|
+
|
|
7
|
+
{{ssm:<parameter-name>[;property:<json-key>][;profile:<aws-profile>]}}
|
|
8
|
+
|
|
9
|
+
Resolution shells out to the ``aws`` CLI — the same convention already used
|
|
10
|
+
by :mod:`dbctl.tunnels.ssm` for bastion/session lookups — so no extra AWS SDK
|
|
11
|
+
dependency is required.
|
|
12
|
+
|
|
13
|
+
Resolution is deliberately **lazy**: nothing in this module runs at
|
|
14
|
+
``connections.yaml`` load time. It only runs when :func:`resolve_connection`
|
|
15
|
+
is called, which happens right before a connection is actually used (see
|
|
16
|
+
``build_tunnel`` / ``build_engine``). A connection nobody is using — or one
|
|
17
|
+
just being displayed via ``dbctl connections show`` — never triggers an AWS
|
|
18
|
+
call, so one unreachable profile can't break unrelated connections.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
from typing import TYPE_CHECKING, Any
|
|
27
|
+
|
|
28
|
+
if TYPE_CHECKING:
|
|
29
|
+
from dbctl.config import Connection
|
|
30
|
+
|
|
31
|
+
_REF_RE = re.compile(r"\{\{ssm:([^}]+)\}\}")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RefResolutionError(RuntimeError):
|
|
35
|
+
"""Raised when a ``{{ssm:...}}`` reference cannot be resolved.
|
|
36
|
+
|
|
37
|
+
Messages here must only ever name the *reference* (parameter name,
|
|
38
|
+
property, profile) — never the resolved value — so this exception is
|
|
39
|
+
always safe to print to logs/stderr/console.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _parse_ref(spec: str) -> tuple[str, str | None, str | None]:
|
|
44
|
+
"""Split ``<name>[;property:<key>][;profile:<profile>]`` into its parts."""
|
|
45
|
+
parts = spec.split(";")
|
|
46
|
+
name = parts[0].strip()
|
|
47
|
+
if not name:
|
|
48
|
+
raise RefResolutionError(f"ssm reference {{{{ssm:{spec}}}}} is missing a parameter name")
|
|
49
|
+
|
|
50
|
+
property_: str | None = None
|
|
51
|
+
profile: str | None = None
|
|
52
|
+
for raw_part in parts[1:]:
|
|
53
|
+
part = raw_part.strip()
|
|
54
|
+
if not part:
|
|
55
|
+
continue
|
|
56
|
+
key, sep, value = part.partition(":")
|
|
57
|
+
if not sep:
|
|
58
|
+
raise RefResolutionError(
|
|
59
|
+
f"ssm reference {{{{ssm:{spec}}}}}: malformed option {part!r} (expected key:value)"
|
|
60
|
+
)
|
|
61
|
+
key = key.strip()
|
|
62
|
+
value = value.strip()
|
|
63
|
+
if key == "property":
|
|
64
|
+
property_ = value
|
|
65
|
+
elif key == "profile":
|
|
66
|
+
profile = value
|
|
67
|
+
else:
|
|
68
|
+
raise RefResolutionError(f"ssm reference {{{{ssm:{spec}}}}}: unknown option {key!r}")
|
|
69
|
+
return name, property_, profile
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _fetch_parameter(name: str, profile: str | None) -> str:
|
|
73
|
+
"""Fetch a parameter's raw value via ``aws ssm get-parameter``.
|
|
74
|
+
|
|
75
|
+
Always passes ``--with-decryption`` — a no-op for plain ``String``
|
|
76
|
+
parameters, required for ``SecureString`` ones.
|
|
77
|
+
"""
|
|
78
|
+
cmd = ["aws", "ssm", "get-parameter", "--name", name, "--with-decryption", "--output", "json"]
|
|
79
|
+
if profile:
|
|
80
|
+
cmd[1:1] = ["--profile", profile]
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
result = subprocess.run(cmd, check=True, capture_output=True, text=True, timeout=15)
|
|
84
|
+
except FileNotFoundError as e:
|
|
85
|
+
raise RefResolutionError(
|
|
86
|
+
"the `aws` CLI was not found on PATH — install it before resolving ssm: references"
|
|
87
|
+
) from e
|
|
88
|
+
except subprocess.CalledProcessError as e:
|
|
89
|
+
stderr = (e.stderr or "").strip()
|
|
90
|
+
raise RefResolutionError(
|
|
91
|
+
f"failed to resolve ssm parameter {name!r}: {stderr[:300] or 'unknown error'}"
|
|
92
|
+
) from e
|
|
93
|
+
except subprocess.TimeoutExpired as e:
|
|
94
|
+
raise RefResolutionError(f"aws ssm get-parameter timed out resolving {name!r}") from e
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
payload: Any = json.loads(result.stdout)
|
|
98
|
+
value = payload["Parameter"]["Value"]
|
|
99
|
+
except (json.JSONDecodeError, KeyError, TypeError) as e:
|
|
100
|
+
raise RefResolutionError(f"unexpected response resolving ssm parameter {name!r}") from e
|
|
101
|
+
if not isinstance(value, str):
|
|
102
|
+
raise RefResolutionError(f"unexpected response resolving ssm parameter {name!r}")
|
|
103
|
+
return value
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_property(raw_value: str, property_name: str, param_name: str) -> str:
|
|
107
|
+
try:
|
|
108
|
+
parsed = json.loads(raw_value)
|
|
109
|
+
except json.JSONDecodeError as e:
|
|
110
|
+
raise RefResolutionError(
|
|
111
|
+
f"ssm parameter {param_name!r} is not valid JSON; cannot extract property {property_name!r}"
|
|
112
|
+
) from e
|
|
113
|
+
if not isinstance(parsed, dict) or property_name not in parsed:
|
|
114
|
+
raise RefResolutionError(f"ssm parameter {param_name!r} has no property {property_name!r}")
|
|
115
|
+
return str(parsed[property_name])
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def resolve_ssm_ref(spec: str, *, cache: dict[str, str]) -> str:
|
|
119
|
+
"""Resolve one ``<name>[;property:...][;profile:...]`` reference body
|
|
120
|
+
(the part inside ``{{ssm: }}``), consulting/populating ``cache`` so the
|
|
121
|
+
same reference is never fetched twice within one resolution pass."""
|
|
122
|
+
if spec in cache:
|
|
123
|
+
return cache[spec]
|
|
124
|
+
name, property_, profile = _parse_ref(spec)
|
|
125
|
+
raw = _fetch_parameter(name, profile)
|
|
126
|
+
value = _extract_property(raw, property_, name) if property_ else raw
|
|
127
|
+
cache[spec] = value
|
|
128
|
+
return value
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def resolve_string(value: str, *, cache: dict[str, str]) -> str:
|
|
132
|
+
"""Replace every ``{{ssm:...}}`` occurrence in ``value`` with its
|
|
133
|
+
resolved value. Strings with no reference are returned unchanged
|
|
134
|
+
(and never trigger an AWS call)."""
|
|
135
|
+
if "{{ssm:" not in value:
|
|
136
|
+
return value
|
|
137
|
+
return _REF_RE.sub(lambda m: resolve_ssm_ref(m.group(1), cache=cache), value)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def resolve_value(value: Any, *, cache: dict[str, str]) -> Any:
|
|
141
|
+
"""Recursively resolve ``{{ssm:...}}`` references nested in dicts/lists."""
|
|
142
|
+
if isinstance(value, str):
|
|
143
|
+
return resolve_string(value, cache=cache)
|
|
144
|
+
if isinstance(value, dict):
|
|
145
|
+
return {k: resolve_value(v, cache=cache) for k, v in value.items()}
|
|
146
|
+
if isinstance(value, list):
|
|
147
|
+
return [resolve_value(v, cache=cache) for v in value]
|
|
148
|
+
return value
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def resolve_connection(conn: Connection) -> Connection:
|
|
152
|
+
"""Return a copy of ``conn`` with every ``{{ssm:...}}`` placeholder in
|
|
153
|
+
its string fields (including nested tunnel blocks) resolved.
|
|
154
|
+
|
|
155
|
+
Call this right before a connection is actually used (opening a tunnel,
|
|
156
|
+
building an engine) — never at ``connections.yaml`` load time or from a
|
|
157
|
+
listing/display command, so browsing the registry never makes an AWS
|
|
158
|
+
call or risks echoing a decrypted secret to the screen.
|
|
159
|
+
"""
|
|
160
|
+
from dbctl.config import Connection
|
|
161
|
+
|
|
162
|
+
raw = conn.model_dump()
|
|
163
|
+
resolved = resolve_value(raw, cache={})
|
|
164
|
+
return Connection.model_validate(resolved)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = [
|
|
168
|
+
"RefResolutionError",
|
|
169
|
+
"resolve_connection",
|
|
170
|
+
"resolve_ssm_ref",
|
|
171
|
+
"resolve_string",
|
|
172
|
+
"resolve_value",
|
|
173
|
+
]
|
|
@@ -56,12 +56,19 @@ def build_tunnel(conn: Connection, *, override_port: int | None = None) -> Tunne
|
|
|
56
56
|
the local bind port even when the config says ``local_port: 0`` (auto).
|
|
57
57
|
For ``direct`` tunnels the override replaces the upstream port (useful
|
|
58
58
|
for pointing at a different port than the config declares).
|
|
59
|
+
|
|
60
|
+
Resolves any ``{{ssm:...}}`` placeholders on ``conn`` first (see
|
|
61
|
+
``dbctl.refs``) — this is the point a connection is actually used, so
|
|
62
|
+
it's the right place for that lazy resolution to happen.
|
|
59
63
|
"""
|
|
64
|
+
from dbctl.refs import resolve_connection
|
|
60
65
|
from dbctl.tunnels.direct import DirectTunnel as _Direct
|
|
61
66
|
from dbctl.tunnels.k8s import K8sTunnel as _K8k
|
|
62
67
|
from dbctl.tunnels.ssh import SshTunnel as _Ssh
|
|
63
68
|
from dbctl.tunnels.ssm import SsmTunnel as _Ssm
|
|
64
69
|
|
|
70
|
+
conn = resolve_connection(conn)
|
|
71
|
+
|
|
65
72
|
match conn.type.value:
|
|
66
73
|
case "ssm":
|
|
67
74
|
assert conn.ssm
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import atexit
|
|
6
|
+
import configparser
|
|
6
7
|
import hashlib
|
|
7
8
|
import json
|
|
9
|
+
import os
|
|
8
10
|
import subprocess
|
|
9
11
|
from datetime import UTC, datetime
|
|
10
12
|
from pathlib import Path
|
|
@@ -17,28 +19,77 @@ from dbctl.tunnels.base import _terminate, find_free_port, wait_local_open
|
|
|
17
19
|
_console = Console(stderr=True)
|
|
18
20
|
|
|
19
21
|
|
|
20
|
-
def
|
|
21
|
-
"""Return the AWS
|
|
22
|
+
def _aws_config_path() -> Path:
|
|
23
|
+
"""Return the AWS CLI config file path, honoring ``AWS_CONFIG_FILE``
|
|
24
|
+
exactly like the ``aws`` CLI itself does."""
|
|
25
|
+
override = os.environ.get("AWS_CONFIG_FILE")
|
|
26
|
+
return Path(override) if override else Path.home() / ".aws" / "config"
|
|
22
27
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
|
|
29
|
+
def _sso_cache_key(profile: str) -> str | None:
|
|
30
|
+
"""Resolve the AWS CLI SSO token-cache *key* for ``profile`` by reading
|
|
31
|
+
``~/.aws/config`` (or ``$AWS_CONFIG_FILE``).
|
|
32
|
+
|
|
33
|
+
The AWS CLI caches SSO tokens under a filename derived from a specific
|
|
34
|
+
string, which depends on the profile's config style:
|
|
35
|
+
|
|
36
|
+
* **`sso_session`-based** (the config ``aws configure sso`` now
|
|
37
|
+
generates, and what a separate ``[sso-session <name>]`` block
|
|
38
|
+
implies) — the cache key is the **session name** itself, e.g.
|
|
39
|
+
``sso_session = my-session`` → key is ``"my-session"``. Note this is
|
|
40
|
+
*not* the session's ``sso_start_url``.
|
|
41
|
+
* **legacy inline SSO** (no ``sso_session``, `sso_start_url` set
|
|
42
|
+
directly on the profile) — the cache key is that ``sso_start_url``.
|
|
43
|
+
|
|
44
|
+
Returns ``None`` if the profile (or `default`) isn't found in the
|
|
45
|
+
config file, or has neither ``sso_session`` nor ``sso_start_url`` (e.g.
|
|
46
|
+
a plain access-key profile — not an SSO profile at all).
|
|
47
|
+
"""
|
|
48
|
+
config_path = _aws_config_path()
|
|
49
|
+
if not config_path.exists():
|
|
50
|
+
return None
|
|
51
|
+
parser = configparser.ConfigParser()
|
|
52
|
+
try:
|
|
53
|
+
parser.read(config_path)
|
|
54
|
+
except configparser.Error:
|
|
55
|
+
return None
|
|
56
|
+
section_name = "default" if profile == "default" else f"profile {profile}"
|
|
57
|
+
if section_name not in parser:
|
|
58
|
+
return None
|
|
59
|
+
section = parser[section_name]
|
|
60
|
+
session_name = section.get("sso_session")
|
|
61
|
+
if session_name:
|
|
62
|
+
return session_name
|
|
63
|
+
return section.get("sso_start_url")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _sso_cache_path(profile: str) -> Path | None:
|
|
67
|
+
"""Return the AWS SSO token cache file for the given profile, or
|
|
68
|
+
``None`` when the profile isn't an SSO profile (or isn't found) — see
|
|
69
|
+
``_sso_cache_key``.
|
|
70
|
+
|
|
71
|
+
AWS caches SSO tokens at ``~/.aws/sso/cache/<sha1(key)>.json`` (SHA-1,
|
|
72
|
+
hex-encoded, of the cache key resolved by ``_sso_cache_key`` — *not* a
|
|
73
|
+
hash of the profile name). Each file contains ``accessToken`` +
|
|
74
|
+
``expiresAt`` (ISO 8601 UTC).
|
|
26
75
|
"""
|
|
27
|
-
key =
|
|
28
|
-
|
|
76
|
+
key = _sso_cache_key(profile)
|
|
77
|
+
if key is None:
|
|
78
|
+
return None
|
|
79
|
+
hashed = hashlib.sha1(key.encode()).hexdigest() # AWS CLI's own cache-key scheme
|
|
80
|
+
return Path.home() / ".aws" / "sso" / "cache" / f"{hashed}.json"
|
|
29
81
|
|
|
30
82
|
|
|
31
83
|
def _sso_token_is_valid(profile: str) -> bool:
|
|
32
84
|
"""Check whether the SSO token for ``profile`` exists and hasn't expired.
|
|
33
85
|
|
|
34
|
-
Returns ``True`` when the token is valid
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
exists at all (which means the user needs to log in).
|
|
86
|
+
Returns ``True`` when the token is valid. Returns ``False`` when the
|
|
87
|
+
profile can't be resolved to an SSO cache key, when a token file exists
|
|
88
|
+
but is past its ``expiresAt`` timestamp, or when no token file exists at
|
|
89
|
+
all (which means the user needs to log in).
|
|
39
90
|
"""
|
|
40
91
|
cache_file = _sso_cache_path(profile)
|
|
41
|
-
if not cache_file.exists():
|
|
92
|
+
if cache_file is None or not cache_file.exists():
|
|
42
93
|
return False
|
|
43
94
|
try:
|
|
44
95
|
data = json.loads(cache_file.read_text())
|
|
@@ -97,6 +97,61 @@ connections.yaml: 1 invalid connection skipped:
|
|
|
97
97
|
The offending connection is simply absent from `dbctl connections list` until
|
|
98
98
|
you fix it — the CLI never refuses to start because of one bad entry.
|
|
99
99
|
|
|
100
|
+
## `{{ssm:...}}` references
|
|
101
|
+
|
|
102
|
+
Any string field on a connection — `password`, `username`, `database`,
|
|
103
|
+
`driver`, `url`, or a nested tunnel field like `ssm.remote_host` or
|
|
104
|
+
`direct.host` — can be a placeholder that resolves against AWS SSM
|
|
105
|
+
Parameter Store instead of a literal value:
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
{{ssm:<parameter-name>[;property:<json-key>][;profile:<aws-profile>]}}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
| part | required | meaning |
|
|
112
|
+
|------|----------|---------|
|
|
113
|
+
| `<parameter-name>` | yes | full SSM parameter name/path, e.g. `/prod/db/password`. |
|
|
114
|
+
| `property` | no | if the parameter value is a JSON object, extract only this key. Omit to use the whole raw value. |
|
|
115
|
+
| `profile` | no | AWS CLI profile for the lookup. Omit to use the default credential chain (env vars, default profile, instance role). |
|
|
116
|
+
|
|
117
|
+
```yaml
|
|
118
|
+
connections:
|
|
119
|
+
prod-pg:
|
|
120
|
+
type: direct
|
|
121
|
+
driver: postgresql+psycopg
|
|
122
|
+
database: app
|
|
123
|
+
username: app_admin
|
|
124
|
+
password: "{{ssm:/prod/db/credentials;property:password;profile:prod-admin}}"
|
|
125
|
+
direct:
|
|
126
|
+
host: "{{ssm:/prod/db/credentials;property:host;profile:prod-admin}}"
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
Given an SSM parameter `/prod/db/credentials` holding
|
|
130
|
+
`{"username": "app_admin", "password": "s3cret", "host": "prod.rds.internal"}`,
|
|
131
|
+
`{{ssm:/prod/db/credentials;property:password}}` resolves to `s3cret`.
|
|
132
|
+
|
|
133
|
+
**When resolution happens.** References are resolved lazily, right before a
|
|
134
|
+
connection is actually used to open a tunnel or build an engine (`dbctl
|
|
135
|
+
<conn> health`, `dbctl tunnel open`, an operation, …) — never at load time.
|
|
136
|
+
`dbctl connections list` / `dbctl connections show` never touch AWS and
|
|
137
|
+
never print a resolved secret; they show the raw `{{ssm:...}}` placeholder
|
|
138
|
+
as written in the YAML. Because resolution is per-connection and on-demand,
|
|
139
|
+
an unreachable profile on one connection never breaks loading or using any
|
|
140
|
+
other connection.
|
|
141
|
+
|
|
142
|
+
**Resolution details:**
|
|
143
|
+
- Parameters are always fetched `--with-decryption`, so `SecureString`
|
|
144
|
+
parameters work transparently; plain `String` parameters are unaffected.
|
|
145
|
+
- If `property` is set but the parameter's value isn't valid JSON, or the
|
|
146
|
+
key is missing, resolution fails with an error naming the parameter and
|
|
147
|
+
property — never the (partial) value.
|
|
148
|
+
- If the parameter doesn't exist or access is denied, the error names the
|
|
149
|
+
parameter but never attempts to print a value.
|
|
150
|
+
- Resolution shells out to the `aws` CLI (same mechanism `ssm` tunnels
|
|
151
|
+
already use for bastion lookups), so no extra AWS SDK dependency is
|
|
152
|
+
required — just a working `aws` CLI on `PATH` and credentials for the
|
|
153
|
+
chosen profile.
|
|
154
|
+
|
|
100
155
|
## `ssm` block
|
|
101
156
|
|
|
102
157
|
AWS SSM port-forwarding to a remote host (typically RDS) through an EC2
|
|
@@ -282,7 +282,7 @@ cross-engine migration needs, without writing a custom script:
|
|
|
282
282
|
|
|
283
283
|
`rstrip` exists specifically for the SQL Server → PostgreSQL case: SQL
|
|
284
284
|
Server's default collation treats `'abc'` and `'abc '` as equal, so a
|
|
285
|
-
column like `
|
|
285
|
+
column like `notes` can accumulate trailing padding that never
|
|
286
286
|
breaks a lookup on the source — until it's copied byte-exact into a
|
|
287
287
|
target whose collation *is* whitespace-sensitive, at which point every
|
|
288
288
|
padded row silently stops matching.
|
|
@@ -290,17 +290,17 @@ cross-engine migration needs, without writing a custom script:
|
|
|
290
290
|
```yaml
|
|
291
291
|
operations:
|
|
292
292
|
copy-lookup-tables:
|
|
293
|
-
description: "Migrate
|
|
293
|
+
description: "Migrate legacy lookup tables (SQL Server) -> Postgres"
|
|
294
294
|
scope: multi
|
|
295
295
|
mode: copy
|
|
296
296
|
roles: [src, trg]
|
|
297
297
|
copy_spec:
|
|
298
298
|
batch_size: 10000
|
|
299
|
-
tables: [
|
|
299
|
+
tables: [orders, order_items]
|
|
300
300
|
exclude_columns: [Id] # source IDENTITY column; trg generates its own
|
|
301
301
|
transforms:
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
notes: rstrip # trailing-space padding from src collation
|
|
303
|
+
category: rstrip
|
|
304
304
|
```
|
|
305
305
|
|
|
306
306
|
```bash
|
|
@@ -329,12 +329,12 @@ dbctl copy-lookup-tables mssql pg --validate-data
|
|
|
329
329
|
|
|
330
330
|
```text
|
|
331
331
|
pre-flight: scanning src rows against trg's column constraints; nothing will be written yet
|
|
332
|
-
|
|
333
|
-
┃ table
|
|
334
|
-
|
|
335
|
-
│
|
|
336
|
-
│
|
|
337
|
-
|
|
332
|
+
┏━━━━━━━━━━━━━┳━━━━━━━━┳━━━━━━━━━━┳━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
|
|
333
|
+
┃ table ┃ column ┃ kind ┃ row ┃ detail ┃
|
|
334
|
+
┡━━━━━━━━━━━━━╇━━━━━━━━╇━━━━━━━━━━╇━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
|
|
335
|
+
│ order_items │ region │ not_null │ 482 │ NULL value for a NOT NULL │
|
|
336
|
+
│ │ │ │ │ column │
|
|
337
|
+
└─────────────┴────────┴──────────┴─────┴─────────────────────────────┘
|
|
338
338
|
1 violation(s) found — copy aborted, nothing was written
|
|
339
339
|
```
|
|
340
340
|
|
|
@@ -354,8 +354,8 @@ row range that reproduces the failure, and reports the driver's
|
|
|
354
354
|
root-cause error instead of the full wrapped SQLAlchemy exception:
|
|
355
355
|
|
|
356
356
|
```text
|
|
357
|
-
Error: table '
|
|
358
|
-
duplicate key value violates unique constraint "
|
|
357
|
+
Error: table 'orders': insert failed at row 2847 of this batch:
|
|
358
|
+
duplicate key value violates unique constraint "orders_pkey"
|
|
359
359
|
```
|
|
360
360
|
|
|
361
361
|
Disable it with `--no-diagnose-failures` (or `diagnose_failures: false` in
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dbctl"
|
|
7
|
-
version = "0.7.
|
|
7
|
+
version = "0.7.2"
|
|
8
8
|
description = "Generic CLI to monitor, control, and administer multiple databases via SSM, SSH, or direct connection."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -237,12 +237,12 @@ def test_copy_spec_accepts_exclude_columns_and_transforms():
|
|
|
237
237
|
"copy_spec": {
|
|
238
238
|
"tables": ["t"],
|
|
239
239
|
"exclude_columns": ["Id"],
|
|
240
|
-
"transforms": {"
|
|
240
|
+
"transforms": {"notes": "rstrip"},
|
|
241
241
|
},
|
|
242
242
|
}
|
|
243
243
|
)
|
|
244
244
|
assert op.copy_spec.exclude_columns == ["Id"]
|
|
245
|
-
assert op.copy_spec.transforms == {"
|
|
245
|
+
assert op.copy_spec.transforms == {"notes": ColumnTransform.rstrip_}
|
|
246
246
|
assert op.copy_spec.diagnose_failures is True # default on
|
|
247
247
|
|
|
248
248
|
|