Skip to content

CXH-2380: reach the DB2 native DSN form through connector config - #149

Open
al-conductorone wants to merge 6 commits into
mainfrom
cxh-2380-baton-sql-fix-the-db2-native-dsn-form-being-unreachable
Open

CXH-2380: reach the DB2 native DSN form through connector config#149
al-conductorone wants to merge 6 commits into
mainfrom
cxh-2380-baton-sql-fix-the-db2-native-dsn-form-being-unreachable

Conversation

@al-conductorone

Copy link
Copy Markdown
Contributor

The Db2 native connection-string form documented for this connector now works through configuration, so a customer following the docs can connect instead of hitting a confusing setup error.

A native DB2 DSN (opaque ODBC keyword string like HOSTNAME=...;DATABASE=...)
was documented in docs/db2.md but unreachable through config: the engine forced
every DSN through url.Parse/String, so a scheme-less native form hit "database
scheme must be specified" and a native form with scheme:db2 got mangled into
db2://HOSTNAME=... and hit "database name is required in DSN path".

Connect now detects a native DB2 DSN and hands it to the driver verbatim,
bypassing the URL builder. ResolveDatabaseName extracts DATABASE= so the
resolved database name matches the equivalent db2://.../DB URL, keeping
resource IDs stable across the two DSN forms.
@linear-code

linear-code Bot commented Sep 2, 2026

Copy link
Copy Markdown

CXH-2380

Comment thread pkg/database/database.go
Comment thread pkg/database/database.go
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Connector PR Review: CXH-2380: reach the DB2 native DSN form through connector config

Blocking Issues: 0 | Suggestions: 8 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base 2963cce98f5b.
Review mode: incremental since d03f4c51
View review run

Review Summary

The full PR diff was re-scanned for security and correctness; the new commit adds pkg/database/autherror.go (AuthError, mapping SQLSTATE class 28 / MySQL 1045 to codes.Unauthenticated) plus a table-driven test, and wires it into the ping loop in Connector.Validate. The mapping itself is sound — class 28 is exclusively "invalid authorization", errors.As against an anonymous SQLState-method interface target is valid, wrapped errors are covered by a test, and no go.mod/go.sum change is needed (go-sql-driver/mysql, jackc/pgx/v5, and google.golang.org/grpc are already direct, vendored, and marked explicit in vendor/modules.txt). The two new suggestions below concern how narrow the detection actually is across this connector engine set, and the context lost when an auth error is returned; none of the six prior findings have been addressed at head, so they are carried forward unchanged (no duplicate inline threads posted for them).

Security Issues

None found. (Carried over: the placeholder-expansion concern at pkg/database/database.go:531 is an ODBC-quoting gap on an operator-controlled value, listed under Suggestions.)

Correctness Issues

None found.

Suggestions

  • (new) pkg/database/autherror.go:24-25 — only pgx implements a SQLState() string method among the vendored drivers, so this branch covers Postgres alone. Vertica exposes VError.SQLState as a struct field, go_ibm_db puts SQLSTATE in the State field of its Diag records, and go-mssqldb only offers SQLErrorNumber(); errors.As matches none of them, so DB2 (the engine this PR targets), Vertica, MSSQL, Oracle, and HANA bad-credential failures still surface as Unknown despite the doc comment naming Vertica as covered.
  • (new) pkg/connector/connector.go:100-102 — returning authErr directly discards both the database name and the original driver error, so a multi-database config gives no indication of which database rejected the credentials; wrapping with fmt.Errorf("database %q ping failed: %w", name, authErr) preserves the Unauthenticated code because status.FromError resolves wrapped statuses via errors.As.
  • (carried over, unaddressed) pkg/database/database.go:423 — the native-DB2 probe precedes the scheme check and IsNativeDSN matches any scheme-less DSN with a DATABASE= part, so a non-DB2 ODBC/ADO string (Server=x;Database=y;...) is misrouted to the DB2 driver and reports a DB2-specific error instead of "database scheme must be specified".
  • (carried over, unaddressed) pkg/database/database.go:433 — with databases.discovery_query, the admin connect and discovery query run before ConnectMany rejects the native-DSN + databases combination; validate the combination up front in openDatabases.
  • (carried over, unaddressed) pkg/database/database.go:434 — the mutual-exclusion error is a bare errors.New, so with the exit.LogExit change in this PR it exits Unknown (2) rather than InvalidArgument (3).
  • (carried over, unaddressed) pkg/database/db2/dsn.go:36 — whitespace handling is asymmetric: the ;-delimited part is trimmed but the value is not, so DATABASE= TESTDB yields a database key with a leading space while the driver connects to TESTDB, and DATABASE = TESTDB fails keyword detection entirely.
  • (carried over, unaddressed) pkg/database/database.go:531 — placeholder expansion in a native DSN is raw substitution with no ODBC quoting, so an expanded value containing ; or = can inject or override keywords; the db2:// path is protected by quoteDB2Value, the native path is not.
  • (carried over, unaddressed) pkg/database/db2/dsn.go:51-81 (splitDB2DSN) — atValueStart is cleared by any character after =, so a DATABASE= value whose ODBC brace quoting starts after a space is not recognized as quoted and splits mid-value.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/database/autherror.go`:
- Around lines 24-25: The anonymous SQLState-method interface probe only matches
  pgx (`*pgconn.PgError`); no other driver this connector vendors implements that
  method. The Vertica `*VError` type has `SQLState` as a struct field, the
  go_ibm_db `*Error` type carries SQLSTATE in the `State` field of its `Diag`
  records, and the go-mssqldb `Error` type exposes `SQLErrorNumber()` (18456 =
  login failed for user). So DB2 — the engine this PR is about — plus Vertica,
  MSSQL, Oracle, and HANA auth failures still map to `codes.Unknown`. Either add
  explicit branches for the drivers you care about (at minimum DB2 via the `Diag`
  record `State` prefix 28 / 08004, MSSQL via `SQLErrorNumber() == 18456`,
  Vertica via the `SQLState` field), or correct the doc comment on line 17 so it
  no longer claims Vertica is covered. Also consider MySQL 1698
  (ER_ACCESS_DENIED_NO_PASSWORD_ERROR) and 1044 (ER_DBACCESS_DENIED_ERROR)
  alongside 1045, and extend TestAuthError with cases for whichever drivers you
  add.

In `pkg/connector/connector.go`:
- Around lines 100-102: When `database.AuthError` matches, the handler returns the
  bare status error, discarding the name of the database that failed and the
  original driver error. With multiple databases configured the operator cannot
  tell which one rejected the credentials, and callers lose the ability to
  `errors.Is` / `errors.As` the driver error. Change the body to
  `return nil, fmt.Errorf("database %q ping failed: %w", name, authErr)`.
  `status.FromError` resolves a wrapped status via `errors.As` and returns
  ok=true, so `exit.LogExit` still exits with the `Unauthenticated` code.

In `pkg/database/database.go`:
- Around line 423: `Connect` calls `nativeDB2DSN(opts)` before the URL path check
  for "scheme must be specified", and `db2.IsNativeDSN` returns true for any
  scheme-less DSN containing a `DATABASE=` keyword part. A non-DB2 ODBC/ADO style
  connection string such as `Server=x;Database=y;User Id=u` is therefore routed to
  the DB2 driver and fails with a DB2-specific message. Tighten detection: either
  require the scheme to be explicitly `db2` when the DSN is not URL-shaped, or
  require the DB2-specific `HOSTNAME` keyword rather than accepting `DATABASE`
  alone as a marker. Update the `DATABASE=TESTDB;HOST=x` case in
  pkg/database/native_db2_dsn_test.go:25 to match whichever rule you choose.
- Around line 433: The native-DSN / structured-fields mutual-exclusion check runs
  inside `Connect`, so the `databases.discovery_query` flow in
  pkg/connector/connector.go:201 opens an admin connection and runs the discovery
  query before `ConnectMany` rejects the combination. Move the check up front: in
  `openDatabases`, reject a native DB2 DSN combined with `connect.databases` or
  `connect.database` before any connection is opened, and keep the in-`Connect`
  check as a backstop.
- Around line 434: The mutual-exclusion error is a bare `errors.New`, which
  `exit.LogExit` (added in this PR at cmd/baton-sql/main.go:33) maps to
  `codes.Unknown`, exiting 2. Return `status.Error(codes.InvalidArgument, ...)`
  instead so a misconfiguration exits 3 and is distinguishable from an internal
  failure.
- Around line 531: `nativeDB2DSN` expands dollar-brace placeholders with
  `expandValue`, which is raw substitution. An expanded value containing `;` or
  `=` can inject or override ODBC keywords in the DSN handed verbatim to the
  driver. Apply the same brace quoting the `db2://` path uses (`quoteDB2Value`)
  to expanded placeholder values in a native DSN, or reject expanded values that
  contain semicolons, equals signs, or ODBC brace characters. Add a test covering
  a DSN of `HOSTNAME=h;DATABASE=d;PWD=` plus a placeholder whose expansion is
  `x;DATABASE=other`.

In `pkg/database/db2/dsn.go`:
- Around line 36 (`DSNDatabase`) and line 25 (`IsNativeDSN`): whitespace handling
  is asymmetric. `strings.TrimSpace(part)` trims the whole part before `Cut`, so
  `DATABASE= TESTDB` produces a value with the leading space kept while the driver
  connects to `TESTDB`, and `DATABASE = TESTDB` leaves the keyword with a trailing
  space so `EqualFold` fails and detection falls through. Trim the keyword and the
  value independently after the `Cut` (trim the value only when it is not
  brace-quoted), and add test cases for both spacings in
  pkg/database/db2/dsn_test.go.
- Around lines 51-81 (`splitDB2DSN`): `atValueStart` is set only by `=` and cleared
  by the very next character, so a value whose ODBC brace quoting begins after a
  space is not treated as quoted and is split at the embedded `;`. Keep
  `atValueStart` true across whitespace following `=` so a brace-quoted value that
  starts after spaces is still recognized. Add a matching case to TestDSNDatabase.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking issues found — see review comments.

sync-test@v2 installs the CLI from conductorone/baton (latest v0.4.5,
pre-pebble), so baton grants rejects the pebble-format c1z with
"c1z: invalid file". @v4 pulls the CLI from conductorone/baton-sdk,
which reads pebble.
Comment thread pkg/database/database.go Outdated
Comment thread .github/workflows/ci.yaml

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new blocking issues in this pass, but the two blocking findings from the previous review are still unaddressed at 209040e (pkg/database/database.go:446 ignoring opts.Database on the native path, and pkg/database/database.go:551 silently discarding structured connect fields). Two new suggestions posted inline.

Address the CI review findings on the native DB2 DSN path:

- Reject a native DSN combined with structured connect fields (host, port,
  user, password, params) or a per-database override (connect.database,
  databases). The verbatim path never reaches buildConnectionURL, so those
  were silently dropped and multi-database sync opened every handle against
  the DSN's single DATABASE=. Now it errors clearly instead.
- Detect native DSNs and extract DATABASE= case-insensitively and with
  whitespace tolerance (ODBC keywords are case-insensitive; "; " spacing is
  common). A lowercase/spaced native DSN previously fell through to the URL
  path and hit "scheme must be specified", or resolved an empty database name.
- Apply the same case-insensitive passthrough in db2.convertToDB2DSN, which
  the native path now reaches for lowercase DSNs.
- Anchor the URL-shape check to a leading scheme so a native DSN whose value
  contains "://" (e.g. PWD=my://secret) is not misread as a URL.
- Bump account-provisioning to @v4 so it no longer depends on step ordering
  to pick up the pebble-compatible CLI.
- Document native-DSN exclusivity and case-insensitivity in docs/db2.md.

Verified live against a local DB2 container: lowercase native DSN syncs,
native DSN + connect.database is rejected, uppercase and db2:// URL forms
unaffected.
Comment thread pkg/database/database.go Outdated
Comment thread pkg/database/db2/dsn.go Outdated
Comment thread docs/db2.md

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Address review: splitDB2DSN now enters brace mode only when a value starts with '{'
(so PWD=p{q no longer swallows the next ';'), and detection/DATABASE-extraction move
to db2.IsNativeDSN/db2.DSNDatabase, used by both the router and convertToDB2DSN so they
cannot drift. Spaced native DSN verified live against Db2 v12.1.
Comment thread pkg/database/db2/dsn.go
// DSNDatabase returns the DATABASE keyword value from a native DB2 DSN, or "" if absent.
func DSNDatabase(dsn string) string {
for _, part := range splitDB2DSN(dsn) {
keyword, value, found := strings.Cut(strings.TrimSpace(part), "=")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: whitespace handling is asymmetric — TrimSpace is applied to the whole part (so a space after ; is tolerated, as documented), but not around the =. DATABASE= TESTDB yields the value " TESTDB", which becomes the dbs map key and the synthetic database row column while the driver connects to TESTDB; DATABASE = TESTDB doesn't match the keyword at all, so IsNativeDSN returns false and the DSN falls back to the URL path and the exact "scheme must be specified" error this PR fixes. Also DATABASE= {my;db} isn't brace-detected by splitDB2DSN since atValueStart is cleared by the space. Trimming the value (and allowing space before =/{) would make parsing match the ODBC leniency the docs advertise.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah can confirm this one, ran it through an actual go test: atValueStart gets reset by ANY intervening char in the default case, space included, so DATABASE= {SAM;PLE} (space right after =) never flips braced on — the ; inside the braces splits for real and DSNDatabase comes back with {SAM instead of SAM;PLE. No error, just a silently wrong database name flowing into ResolveDatabaseName. That said couldn't find anywhere in this repo (docs, examples, the DSN generator) that would ever actually emit a space between = and {, so real-world likelihood seems low — still, worth trimming the value (or skipping whitespace before checking for {) since the failure mode is silent corruption, not a loud error.

Comment thread pkg/database/database.go
return "", false, nil
}

dsn, err := expandValue(opts.DSN, lookup)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: expandValue is a raw ${VAR} substitution with no ODBC quoting, and the result is handed to the driver verbatim — so a placeholder value containing ;, =, or } changes the DSN's structure rather than being treated as data. HOSTNAME=${H};DATABASE=PROD with H="x;DATABASE=DEV" silently connects to DEV, and a password containing ; breaks the connection with a confusing driver error. The db2:// path is safe here because quoteDB2Value brace-quotes such values; the native path has no equivalent. Worth documenting in docs/db2.md that placeholder values with ;/=/spaces must be brace-quoted in the DSN (PWD={${DB_PASS}}).

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Replace os.Exit(1) in main.go with exit.LogExit(err) so an auth failure exits
with the mapped gRPC status code instead of a bare 1, letting the CI sync-test
auth-error check actually assert.
Comment thread pkg/database/database.go
// A native DB2 DSN is an opaque ODBC keyword=value string, not a URL. Routing it
// through buildConnectionURL corrupts it (url.Parse/.String mangles the opaque form),
// so hand it to the driver verbatim. See docs/db2.md.
nativeDSN, isNativeDB2, err := nativeDB2DSN(opts)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this native-DB2 probe runs before the parsedDsn.Scheme == "" check, and IsNativeDSN returns true for any scheme-less DSN with a DATABASE= part — including non-DB2 ODBC/ADO connection strings such as Server=x;Database=y;User Id=u (see the DATABASE=TESTDB;HOST=x case in native_db2_dsn_test.go:25). Such a config now fails with a DB2-specific message ("DB2 support not compiled...") instead of the clear "database scheme must be specified in DSN or configuration". Consider requiring an explicit db2 scheme when the DSN is not URL-shaped, or requiring the DB2-specific HOSTNAME keyword rather than DATABASE alone. (Confidence: medium.)

Comment thread pkg/database/database.go
// ConnectMany for databases.static / discovery_query) would be silently dropped
// on the verbatim path, so reject the combination instead of connecting to the
// wrong database. See docs/db2.md.
if hasStructuredConnectFields(opts) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: the mutual-exclusion check fires per-Connect, so the databases.discovery_query path (pkg/connector/connector.go:201) opens a real admin connection and executes the discovery query before ConnectMany rejects the native-DSN + databases combination. databases.static fails fast, but discovery does a full round trip first. Consider validating "native DSN + connect.databases/connect.database" once in openDatabases (or config validation) so the error surfaces before any connection is opened. (Confidence: high on the behavior, low severity.)

Comment thread pkg/database/database.go
// on the verbatim path, so reject the combination instead of connecting to the
// wrong database. See docs/db2.md.
if hasStructuredConnectFields(opts) {
return nil, Unknown, errors.New(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: this is a pure user-config error, but a bare errors.New carries no gRPC status. With the exit.LogExit change in this same PR (cmd/baton-sql/main.go:33), exitCode falls through to codes.Unknown and the process exits 2 rather than 3 (InvalidArgument). Wrapping with status.Error(codes.InvalidArgument, ...) would make the new exit-code mapping actually distinguish misconfiguration from an internal failure. (Confidence: high.)

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Pairs with the exit.LogExit change: Validate wrapped the ping error plainly, so
exit mapped auth failures to Unknown(2). database.AuthError maps SQLSTATE class 28
(Postgres/Redshift/Vertica/etc.) and MySQL 1045 to codes.Unauthenticated.
Comment thread pkg/database/autherror.go
Comment on lines +24 to +25
var sqlState interface{ SQLState() string }
if errors.As(err, &sqlState) && strings.HasPrefix(sqlState.SQLState(), "28") {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: among this repo's vendored drivers, only pgx implements SQLState() string (vendor/github.com/jackc/pgx/v5/pgconn/errors.go:58) — so this branch effectively covers Postgres only. Vertica exposes it as a struct field (VError.SQLState, vendor/github.com/vertica/vertica-sql-go/errors.go:46), go_ibm_db puts SQLSTATE in Error.Diag[].State, and go-mssqldb only has SQLErrorNumber() (18456 = login failed); Oracle and HANA likewise. errors.As won't match any of them, so their bad-credential failures still surface as Unknown — including DB2, the engine this PR targets. Worth either adding per-driver branches or dropping "Vertica" from the doc comment so it doesn't read as covered. (Also, MySQL 1698/1044 are access-denied variants that 1045 alone misses.)

Comment on lines +100 to +102
if authErr := database.AuthError(err); authErr != nil {
return nil, authErr
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Suggestion: returning authErr directly drops both the database name and the driver's original error, so with a multi-database config an operator sees only "database authentication failed" with no indication of which database rejected the credentials, and errors.Is/As on the driver error no longer works upstream. status.FromError resolves wrapped statuses via errors.As and preserves the code, so wrapping keeps the Unauthenticated exit behavior:

Suggested change
if authErr := database.AuthError(err); authErr != nil {
return nil, authErr
}
if authErr := database.AuthError(err); authErr != nil {
return nil, fmt.Errorf("database %q ping failed: %w", name, authErr)
}

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No blocking issues found.

Comment thread pkg/database/db2/dsn.go
case '}':
braced = false
atValueStart = false
case '{':

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Review] unterminated { eats the rest of the DSN, including a later HOSTNAME/DATABASE marker

If a value opens with { right after = but never gets closed (typo'd DSN, e.g. PWD={oops;DATABASE=X), braced just stays true for the rest of the scan — nothing ever un-braces it, so the whole remainder collapses into one part. That means IsNativeDSN never even sees the DATABASE= marker and returns false, so the string falls through to buildConnectionURL/url.Parse (which "succeeds" since there's no ://) and the user gets the generic "database scheme must be specified in DSN or configuration" instead of anything DB2-shaped.

Only a malformed-DSN edge case, not a regression for anything that currently works, but might be worth at least detecting the unterminated brace and erroring loudly instead of silently misrouting to a confusing message.

Comment thread pkg/database/db2/dsn.go
// than a URL. Shared by pkg/database's routing and convertToDB2DSN's passthrough so
// the two decisions cannot drift. ODBC keywords are case-insensitive and parts may
// carry whitespace after the ';', so both are normalized.
func IsNativeDSN(dsn string) bool {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Review] heads up, there's a third independent DSN-scheme detector elsewhere that doesn't know about this

pkg/bsql/offline_validate.go's resolveConnectScheme does its own ad-hoc strings.Index(dsn, "://") + url.Parse check, totally separate from IsNativeDSN here. Right now it's harmless because RejectNonV1ProductFeatures in that same file hard-rejects anything that isn't "postgres" regardless — but the moment that v1 restriction gets relaxed to allow DB2, it'll misclassify a native DB2 DSN (no :// at all) instead of routing it correctly.

Out of scope for this PR since it's a different file/package, but figured worth flagging now so it doesn't bite later — might be worth a one-line TODO pointing at this function.

Comment thread pkg/database/database.go
return expanded
}
}
if nativeDSN, isNativeDB2, err := nativeDB2DSN(opts); err == nil && isNativeDB2 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Review] minor: parses the DSN twice

nativeDB2DSN internally runs IsNativeDSN -> splitDB2DSN, then right after you call db2.DSNDatabase(nativeDSN) which runs splitDB2DSN again on the exact same string. Not a big deal at all given these are short strings parsed once per connection setup, not a hot loop — just noting it in case it's an easy freebie to fix while this code is fresh.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants