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.
Files changed (52) hide show
  1. {dbctl-0.7.0 → dbctl-0.7.2}/CHANGELOG.md +38 -0
  2. {dbctl-0.7.0 → dbctl-0.7.2}/PKG-INFO +7 -1
  3. {dbctl-0.7.0 → dbctl-0.7.2}/README.md +6 -0
  4. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/db.py +8 -0
  5. dbctl-0.7.2/dbctl/refs.py +173 -0
  6. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/base.py +7 -0
  7. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/ssm.py +64 -13
  8. {dbctl-0.7.0 → dbctl-0.7.2}/docs/connections.md +55 -0
  9. {dbctl-0.7.0 → dbctl-0.7.2}/docs/operations.md +13 -13
  10. {dbctl-0.7.0 → dbctl-0.7.2}/pyproject.toml +1 -1
  11. {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_copy_features.py +2 -2
  12. dbctl-0.7.2/tests/test_refs.py +292 -0
  13. dbctl-0.7.2/tests/test_sso_cache.py +188 -0
  14. {dbctl-0.7.0 → dbctl-0.7.2}/uv.lock +214 -1
  15. {dbctl-0.7.0 → dbctl-0.7.2}/.dbctl/connections.yaml +0 -0
  16. {dbctl-0.7.0 → dbctl-0.7.2}/.dbctl/operations.yaml +0 -0
  17. {dbctl-0.7.0 → dbctl-0.7.2}/.github/workflows/ci.yml +0 -0
  18. {dbctl-0.7.0 → dbctl-0.7.2}/.github-local/ci.yml +0 -0
  19. {dbctl-0.7.0 → dbctl-0.7.2}/.gitignore +0 -0
  20. {dbctl-0.7.0 → dbctl-0.7.2}/Makefile +0 -0
  21. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/__init__.py +0 -0
  22. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/__main__.py +0 -0
  23. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/audit.py +0 -0
  24. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/cli.py +0 -0
  25. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/config.py +0 -0
  26. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/connections.py +0 -0
  27. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/execute.py +0 -0
  28. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/init.py +0 -0
  29. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/multi.py +0 -0
  30. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/operations.py +0 -0
  31. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/reports.py +0 -0
  32. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/runtime.py +0 -0
  33. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/__init__.py +0 -0
  34. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/direct.py +0 -0
  35. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/k8s.py +0 -0
  36. {dbctl-0.7.0 → dbctl-0.7.2}/dbctl/tunnels/ssh.py +0 -0
  37. {dbctl-0.7.0 → dbctl-0.7.2}/docker-compose.yml +0 -0
  38. {dbctl-0.7.0 → dbctl-0.7.2}/docs/ACTION_OUTPUT.md +0 -0
  39. {dbctl-0.7.0 → dbctl-0.7.2}/docs/DESIGN.md +0 -0
  40. {dbctl-0.7.0 → dbctl-0.7.2}/docs/SESSION_STATE.md +0 -0
  41. {dbctl-0.7.0 → dbctl-0.7.2}/docs/logo.png +0 -0
  42. {dbctl-0.7.0 → dbctl-0.7.2}/docs/logo_small.png +0 -0
  43. {dbctl-0.7.0 → dbctl-0.7.2}/docs/tutorial.md +0 -0
  44. {dbctl-0.7.0 → dbctl-0.7.2}/seed/mssql.sql +0 -0
  45. {dbctl-0.7.0 → dbctl-0.7.2}/seed/mysql.sql +0 -0
  46. {dbctl-0.7.0 → dbctl-0.7.2}/seed/postgres.sql +0 -0
  47. {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_bastion_tags.py +0 -0
  48. {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_connections_loader.py +0 -0
  49. {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_issue_1.py +0 -0
  50. {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_k8s_tunnel.py +0 -0
  51. {dbctl-0.7.0 → dbctl-0.7.2}/tests/test_regressions.py +0 -0
  52. {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.0
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 _sso_cache_path(profile: str) -> Path:
21
- """Return the AWS SSO token cache file for the given profile.
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
- AWS caches SSO tokens at ``~/.aws/sso/cache/<sha256(profile)>.json``
24
- (the hash is SHA-256 of the profile name, hex-encoded). Each file
25
- contains ``accessToken`` + ``expiresAt`` (ISO 8601 UTC).
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 = hashlib.sha256(profile.encode()).hexdigest()
28
- return Path.home() / ".aws" / "sso" / "cache" / f"{key}.json"
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 (or when the profile has no
35
- SSO token cached but we determine it's not an SSO profile — e.g. a
36
- plain access-key profile). Returns ``False`` only when a token file
37
- exists but is past its ``expiresAt`` timestamp, or when no token file
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 `model_clean` can accumulate trailing padding that never
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 DE-test lookup tables (SQL Server) → India Postgres"
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: [ce_repair_cost_lookup_data, replacement_lookup_data]
299
+ tables: [orders, order_items]
300
300
  exclude_columns: [Id] # source IDENTITY column; trg generates its own
301
301
  transforms:
302
- model_clean: rstrip # trailing-space padding from src collation
303
- Manufacturer: rstrip
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 ┃ column ┃ kind ┃ row ┃ detail ┃
334
- ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━╇━━━━━━━━━━╇━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩
335
- │ replacement_lookup_data │ Search_Manu │ not_null │ 482 │ NULL value for a NOT NULL │
336
- │ │ │ │ │ column │
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 'ce_repair_cost_lookup_data': insert failed at row 2847 of this batch:
358
- duplicate key value violates unique constraint "ce_repair_cost_lookup_data_pkey"
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.0"
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": {"model_clean": "rstrip"},
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 == {"model_clean": ColumnTransform.rstrip_}
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