diff --git a/README.md b/README.md index 9be3898..86dc362 100644 --- a/README.md +++ b/README.md @@ -37,21 +37,7 @@ make build ./bin/ana --version ``` -### Windows SmartScreen - -Official release binaries are Authenticode-signed via Azure Trusted Signing -(see the `sign-windows` job in `.github/workflows/release.yml`), so -SmartScreen should not prompt when running an `ana.exe` downloaded from -GitHub Releases. - -Binaries built from source (`go install` / `make build`) are **not** signed -and SmartScreen may still block them on first run with "Windows protected -your PC". Click **More info → Run anyway**, or unblock the file before -running: - -```powershell -Unblock-File -Path .\ana.exe -``` +Windows users: see [docs/windows-smartscreen.md](docs/windows-smartscreen.md). ## Usage @@ -59,18 +45,6 @@ Unblock-File -Path .\ana.exe ana [global flags] [args] ``` -Global flags: - -| Flag | Description | -|------|-------------| -| `--endpoint ` | Override the API endpoint | -| `--token-file ` | Path to a bearer-token file | -| `--profile ` | Select a config profile | -| `--json` | Emit JSON output | -| `--version`, `-V` | Print version info | - -### Getting started - ```bash ana auth login --endpoint https://app.textql.com ana org show @@ -80,17 +54,10 @@ ana chat send "show me last month's revenue" Run `ana --help` or `ana --help` for command-specific flags. -Connector-create commands are structured as `dialect auth-mode` subcommands -(e.g. `ana connector create postgres password --name prod --host … --user … ---database … --password-stdin`) so new dialects and auth modes land as -additions rather than growing a conditional flag matrix. - ## Configuration `ana` stores tokens and per-profile endpoints at -`$XDG_CONFIG_HOME/ana/config.json` (falling back to -`~/.config/ana/config.json`). Override with `--token-file` or the -`ANA_TOKEN_FILE` environment variable. +`$XDG_CONFIG_HOME/ana/config.json` (falling back to `~/.config/ana/config.json`). ## Development @@ -102,26 +69,6 @@ make build # -> ./bin/ana make release-local # goreleaser check + snapshot (requires goreleaser) ``` -Conventional commits drive the release pipeline: a `feat:` or `fix:` landing -on `main` causes [release-please](https://github.com/googleapis/release-please) -to open a PR; merging that PR tags the release and triggers GoReleaser to -publish binaries, archives, checksums, and SBOMs to GitHub Releases. - -### CI scope - -PRs are gated by a single required check, `CI Complete`. To keep runner time -proportional to impact, docs-only PRs skip the Go lint / test / build / -goreleaser jobs and `CI Complete` reports green immediately. A PR counts as -"code" when it touches any of: - -- `**/*.go`, `go.mod`, `go.sum` -- `Makefile`, `.goreleaser.yml`, `install.sh` -- `.github/workflows/**` - -Everything else — `README.md`, `LICENSE`, `docs/**`, `api-catalog/**`, -`.claude/**`, `.gitignore` — skips the heavy jobs. Release-please likewise -ignores doc-only merges on `main`. - ## License MIT — see [LICENSE](LICENSE). diff --git a/cmd/ana/main.go b/cmd/ana/main.go index b91a0de..e15e4a0 100644 --- a/cmd/ana/main.go +++ b/cmd/ana/main.go @@ -115,7 +115,7 @@ func run(args []string, stdio cli.IO, env func(string) string) error { } client := transport.New(resolved.Endpoint, tokenFn) - verbs := buildVerbs(client, env, cfgPath, profileName) + verbs := buildVerbs(client, env, cfgPath, profileName, resolved.Endpoint) ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) defer stop() @@ -127,13 +127,16 @@ func run(args []string, stdio cli.IO, env func(string) string) error { // client and config path. Pulled out of run() so main_test can exercise it in // isolation. profileName is the slot that auth.Load/SaveCfg should read from // and write into — resolved upstream so login/logout always target the same -// profile the rest of the invocation used. -func buildVerbs(client *transport.Client, env func(string) string, cfgPath, profileName string) map[string]cli.Command { +// profile the rest of the invocation used. endpoint is the resolved API base +// URL (not just the --endpoint override), so verbs whose user-facing output +// references the web app — e.g. connector OAuth success notes — can point +// self-hosted and non-prod profiles at the right place. +func buildVerbs(client *transport.Client, env func(string) string, cfgPath, profileName, endpoint string) map[string]cli.Command { return map[string]cli.Command{ "auth": auth.New(authDeps(client, env, cfgPath, profileName)), "profile": profile.New(profileDeps(env, cfgPath)), "org": org.New(org.Deps{Unary: client.Unary}), - "connector": connector.New(connector.Deps{Unary: client.Unary}), + "connector": connector.New(connector.Deps{Unary: client.Unary, Endpoint: endpoint}), "chat": chat.New(chatDeps(client)), "dashboard": dashboard.New(dashboard.Deps{Unary: client.Unary}), "playbook": playbook.New(playbook.Deps{Unary: client.Unary}), diff --git a/cmd/ana/main_test.go b/cmd/ana/main_test.go index 49dd226..2619e0e 100644 --- a/cmd/ana/main_test.go +++ b/cmd/ana/main_test.go @@ -286,7 +286,7 @@ func TestAuthDeps_EmptyPath_FallsBackToEnv(t *testing.T) { func TestBuildVerbs_Shape(t *testing.T) { t.Parallel() client := transport.New("https://example", func(context.Context) (string, error) { return "", nil }) - verbs := buildVerbs(client, func(string) string { return "" }, "", "default") + verbs := buildVerbs(client, func(string) string { return "" }, "", "default", "https://example") want := []string{"auth", "profile", "org", "connector", "chat", "dashboard", "playbook", "ontology", "feed", "audit", "version"} for _, v := range want { if _, ok := verbs[v]; !ok { diff --git a/docs/CLAUDE.md b/docs/CLAUDE.md index 2b8c781..37251cc 100644 --- a/docs/CLAUDE.md +++ b/docs/CLAUDE.md @@ -6,3 +6,5 @@ Human-readable planning docs. Read these before touching `api-catalog/`. - `features.md` — inventory of TextQL surfaces (auth, chat, connectors, dashboards, playbooks, etc.), service/endpoint counts, verified enums, and per-surface quirks. "Last verified" dated. - `cli-readiness.md` — CLI-implementer's view of `features.md`. TL;DR, per-surface confidence table (✅/🟡/❗), enum catalog, known quirks, first-cut command shape, and prioritized follow-up probes. +- `ci-scope.md` — which file globs count as "code" and trigger the full CI matrix vs docs-only skip. +- `windows-smartscreen.md` — why signed release binaries run clean but `go install` / `make build` builds get SmartScreen-blocked, and how to unblock. diff --git a/docs/ci-scope.md b/docs/ci-scope.md new file mode 100644 index 0000000..62f0598 --- /dev/null +++ b/docs/ci-scope.md @@ -0,0 +1,14 @@ +# CI scope + +PRs are gated by a single required check, `CI Complete`. To keep runner time +proportional to impact, docs-only PRs skip the Go lint / test / build / +goreleaser jobs and `CI Complete` reports green immediately. A PR counts as +"code" when it touches any of: + +- `**/*.go`, `go.mod`, `go.sum` +- `Makefile`, `.goreleaser.yml`, `install.sh` +- `.github/workflows/**` + +Everything else — `README.md`, `LICENSE`, `docs/**`, `api-catalog/**`, +`.claude/**`, `.gitignore` — skips the heavy jobs. Release-please likewise +ignores doc-only merges on `main`. diff --git a/docs/cli-readiness.md b/docs/cli-readiness.md index f595abd..29d94a2 100644 --- a/docs/cli-readiness.md +++ b/docs/cli-readiness.md @@ -7,7 +7,7 @@ A pass over `docs/features.md` and `api-catalog/` to call out what is solid enou ## TL;DR - **Auth is SOLVED.** `Authorization: Bearer ` where `apiKeyHash` is the one-time plaintext returned by `rbac.RBACService/CreateApiKey`. Verified via curl with no cookies. See `features.md#api-shape-global` → "CLI auth". -- **CRUD is solid** for: chats (create/send/stream/get/history/rename/bookmark/duplicate/delete/share), API keys (create/rotate/revoke/list), service accounts (create/delete/list), connectors (test/create/get/update/delete, Postgres dialect). +- **CRUD is solid** for: chats (create/send/stream/get/history/rename/bookmark/duplicate/delete/share), API keys (create/rotate/revoke/list), service accounts (create/delete/list), connectors (test/create/get/update/delete, Postgres + all four Snowflake auth modes shipped). - **Readonly is solid** for: dashboards, playbooks, feed, notifications, ontology, context library, observability stats, audit log, packages, SCIM, Slack binding. - **Not covered at all by the probe** (CLI will have to probe or ask): dashboard CRUD, playbook CRUD (schedule + active + runNow), dataset CRUD, ontology CRUD, context prompt CRUD, report CRUD, file/attachment uploads. @@ -19,7 +19,7 @@ Confidence key: ✅ full CRUD verified · 🟡 partial / readonly verified · | --- | --- | --- | | Auth (API key) | ✅ | None for personal keys. Service-account keys: untested end-to-end — we created an SA and then deleted it before creating a key scoped to it. | | Chats | ✅ | `CreateChat.paradigm` — only `universal` observed; unknown if other paradigms exist (SQL-only? notebook?). Tool-selection flags (`sqlEnabled`, `pythonEnabled`, `webSearchEnabled`) defaults undocumented — must inspect `CreateChat` sample. `UpdateChat` only verified to touch `summary`; other mutable fields unknown. | -| Connectors | 🟡 | Three dialects fully probed: Postgres (password), Snowflake (password / key-pair / oauth-sso / oauth-individual), Databricks (access-token / client-credentials / oauth-sso / oauth-individual). See connector grid below. Remaining dialects (BigQuery, Redshift, MySQL, SQLServer, Supabase, MotherDuck, Tableau, PowerBI) still unverified. OAuth leaves ship instructional-only — callback URL hardcoded to webapp, CLI can't complete the handshake. | +| Connectors | 🟡 | Three dialects fully probed: Postgres (password), Snowflake (password / key-pair / oauth-sso / oauth-individual — all four shipped), Databricks (access-token / client-credentials / oauth-sso / oauth-individual — probed, not yet shipped). See connector grid below. Remaining dialects (BigQuery, Redshift, MySQL, SQLServer, Supabase, MotherDuck, Tableau, PowerBI) still unverified. OAuth leaves POST the full create payload in a pending-OAuth state; user completes the browser handshake at `app.textql.com` to activate. | | Service accounts | 🟡 | Created + deleted. NOT verified: creating an API key *on* a service account (the kebab menu has "Create API Key" — would need to confirm whether that uses `CreateApiKey` with a `memberId` override or a different RPC). | | Dashboards | 🟡 | List/get/spawn/health covered. Create/update/delete NOT probed. | | Playbooks | 🟡 | Get/list/reports/lineage covered. Create/update/delete/run-now NOT probed. | @@ -64,7 +64,7 @@ Grading: ✅ Live-tested (probe + at least one real connector created) · 🟢 I | Dialect | password | key-pair | access-token | client-credentials | oauth-sso | oauth-individual | | --- | --- | --- | --- | --- | --- | --- | | Postgres | 🟢 | — | — | — | — | — | -| Snowflake | 🟦 | 🟦 | — | — | 🟦* | 🟦* | +| Snowflake | 🟢 | 🟢 | — | — | 🟢* | 🟢* | | Databricks | — | — | 🟦 | 🟦 | 🟦* | 🟦* | | BigQuery | ⚪ | ⚪ | ⚪ | ⚪ | ⚪ | ⚪ | | Redshift | ⚪ | — | — | — | — | — | @@ -75,7 +75,7 @@ Grading: ✅ Live-tested (probe + at least one real connector created) · 🟢 I | Tableau | ⚪ | — | — | — | — | — | | PowerBI | ⚪ | — | — | — | — | — | -*OAuth leaves ship instructional-only — redirect URI hardcoded to `app.textql.com/auth//callback`, CLI cannot receive the callback. Wire shape captured, but the CLI command will print a message pointing at the webapp rather than completing the handshake. +*OAuth leaves POST the full `CreateConnector` payload (including `clientId` / `clientSecret`) through the normal wire shape; the server accepts the row in a pending-OAuth state. The user then completes the browser handshake at `app.textql.com/auth//callback` to activate the connector — the CLI prints a note to that effect on success. Databricks OAuth leaves (not yet shipped) will follow the same pattern once probed. Cells that show `—` are auth modes the dialect doesn't expose in the webapp's UI. @@ -94,7 +94,7 @@ Cells that show `—` are auth modes the dialect doesn't expose in the webapp's 11. **DeleteServiceAccount cascades:** revokes every API key owned by that SA. Confirm before deleting. 12. **Connector auth discrimination differs by dialect.** Snowflake password vs key-pair both send `authStrategy="service_role"`; the server disambiguates by which credential field is populated (`password` vs `privateKey`). Databricks uses a proper nested `databricksAuth` one-of instead (`pat` / `clientCredentials` / `oauthU2m`). When building the Snowflake per-auth-mode leaves, never set both `password` and `privateKey` on the same request — behavior is undefined. 13. **Databricks OAuth wire name is `oauthU2m`, not `oauthSso`.** The UI labels the two OAuth tabs "OAuth (SSO)" and "OAuth (Individual)", but the wire uses the same `databricksAuth.oauthU2m.{clientId, clientSecret}` variant for both — only the top-level `authStrategy` differs (`oauth_sso` vs `per_member_oauth`). Sending `databricksAuth.oauthSso` returns 400 with `"databricks.databricks_auth requires pat, client_credentials, or oauth_u2m"`. -14. **OAuth leaves cannot complete the handshake from a CLI.** All four OAuth modes (Snowflake sso/individual, Databricks sso/individual) persist the connector with clientId/clientSecret only, but authenticated query access requires a browser redirect to `app.textql.com/auth//callback`. The CLI can't receive that callback. Ship OAuth leaves as instructional errors pointing users to the webapp, not as real creation flows. +14. **OAuth leaves create a pending row; the browser activates it.** All four OAuth modes (Snowflake sso/individual, Databricks sso/individual) persist the connector via the normal `CreateConnector` wire shape with `clientId` / `clientSecret`, and the server accepts the row in a pending-OAuth state. Authenticated query access requires the user to finish the handshake via the browser redirect at `app.textql.com/auth//callback`. The CLI can't receive that callback, so the shipped Snowflake OAuth leaves print a note on success pointing users at the webapp to activate. Databricks OAuth leaves will follow the same pattern once shipped. 15. **Databricks `memberAuthenticated` field.** `GetConnectors` adds a `memberAuthenticated: bool` field only for `per_member_oauth` Databricks connectors, indicating whether the viewing user has completed their personal OAuth dance. Useful for CLI status output ("⚠ you haven't authenticated this connector yet"). ## Recommended CLI command shape (first cut) @@ -111,8 +111,8 @@ ana connector list ana connector create postgres password --name X --host ... --database ... ana connector create snowflake password --name X --locator ABC-123 --database D --warehouse W --user U --password-stdin ana connector create snowflake key-pair --name X --locator ABC-123 --database D --warehouse W --user U --private-key-file ./rsa.p8 -ana connector create snowflake oauth-sso # prints instructional message, exits non-zero -ana connector create snowflake oauth-individual # same +ana connector create snowflake oauth-sso --name X --account ... --database D --warehouse W --client-id ... --client-secret-stdin # creates pending row; user activates at app.textql.com +ana connector create snowflake oauth-individual --name X --account ... --database D --warehouse W --client-id ... --client-secret-stdin # same ana connector create databricks access-token --name X --host ... --http-path ... --catalog C --schema S --pat-stdin ana connector create databricks client-credentials --name X --host ... --http-path ... --catalog C --schema S --client-id ... --client-secret-stdin ana connector create databricks oauth-sso # instructional diff --git a/docs/windows-smartscreen.md b/docs/windows-smartscreen.md new file mode 100644 index 0000000..08613b9 --- /dev/null +++ b/docs/windows-smartscreen.md @@ -0,0 +1,15 @@ +# Windows SmartScreen + +Official release binaries are Authenticode-signed via Azure Trusted Signing +(see the `sign-windows` job in `.github/workflows/release.yml`), so +SmartScreen should not prompt when running an `ana.exe` downloaded from +GitHub Releases. + +Binaries built from source (`go install` / `make build`) are **not** signed +and SmartScreen may still block them on first run with "Windows protected +your PC". Click **More info → Run anyway**, or unblock the file before +running: + +```powershell +Unblock-File -Path .\ana.exe +``` diff --git a/internal/connector/CLAUDE.md b/internal/connector/CLAUDE.md index eb31ec7..d96d277 100644 --- a/internal/connector/CLAUDE.md +++ b/internal/connector/CLAUDE.md @@ -5,12 +5,23 @@ The `ana connector` verb tree: `list`, `get`, `create`, `update`, `delete`, `tes ## Files - `connector.go` — `New`, `Deps`, service path prefix. -- `types.go` — shared wire shapes consumed by create + update: `createReq`, `updateReq`, `configEnvelope`, `postgresSpec`, `createResp`, `getConnectorResp`. Per-dialect spec types will land alongside (`types_snowflake.go`, etc.) as new dialects are probed. -- `list.go` / `get.go` — `GetConnectors` / `GetConnector` (readonly). -- `create.go` — `newCreateGroup` (dialect-selector Group) + the shared `resolvePassword` helper reused by per-dialect password leaves and `update.go`. -- `create_postgres.go` — `newPostgresCreateGroup` (Postgres dialect Group whose inheritable `Flags` closure owns `--name`/`--ssl`) and `postgresPasswordCmd` (leaf for the `password` auth mode). Implements `cli.Flagger` so `--help` enumerates own + ancestor flags. Additional Postgres auth modes (key-based, cert-based) land as sibling leaf files — no reshuffling. -- `update.go` — `UpdateConnector`. Pre-fetches the baseline so interleaved flags merge correctly (see commit `1433e01`). Still a flat leaf today; dialect-aware validation + auth-mode-swap subtree are Phase 3g work. -- `delete.go`, `test.go` (TestConnector), `tables.go` (ListConnectorTables), `examples.go` (GetExampleQueries) — remaining CRUD + diagnostic verbs. +- `types.go` — shared wire shapes for create + update. +- `types_postgres.go` — Postgres wire spec. +- `types_snowflake.go` — Snowflake wire spec. +- `list.go` / `get.go` — readonly `GetConnectors` / `GetConnector`. +- `create.go` — dialect-selector Group and the shared `resolveSecret` helper. +- `create_postgres.go` — Postgres dialect Group; sibling files add auth-mode leaves. +- `create_snowflake.go` — Snowflake dialect Group; sibling files add auth-mode leaves. +- `create_snowflake_password.go` — `snowflake password` leaf. +- `create_snowflake_keypair.go` — `snowflake keypair` leaf; reads PEM key from file. +- `create_snowflake_oauth_sso.go` — `snowflake oauth-sso` leaf. +- `create_snowflake_oauth_individual.go` — `snowflake oauth-individual` leaf. +- `update.go` — `UpdateConnector`; pre-fetches baseline to merge partial updates. +- `delete.go`, `test.go`, `tables.go`, `examples.go` — remaining CRUD + diagnostic verbs. - `connector_test.go` — shared `fakeDeps`, `errReader`, `TestNew*`/`TestHelp*`. -- `create_postgres_password_test.go` — covers the Postgres password leaf end-to-end by dispatching through `newCreateGroup` so ancestor-flag plumbing (`--name`, `--ssl` from the Postgres Group) is exercised the same way real CLI dispatch does. `requiredArgs()` builder starts with `"postgres", "password"` because every test routes through the Group. +- `create_postgres_password_test.go` — covers the Postgres password leaf via `newCreateGroup`. +- `create_snowflake_password_test.go` — covers the Snowflake password leaf via `newCreateGroup`. +- `create_snowflake_keypair_test.go` — covers the keypair leaf including key-file edge cases. +- `create_snowflake_oauth_sso_test.go` — covers the oauth-sso leaf. +- `create_snowflake_oauth_individual_test.go` — covers the oauth-individual leaf. - `list_test.go` / `get_test.go` / `update_test.go` / `delete_test.go` / `test_test.go` / `tables_test.go` / `examples_test.go` — one per non-create source file. diff --git a/internal/connector/connector.go b/internal/connector/connector.go index f0b1f54..02c9b42 100644 --- a/internal/connector/connector.go +++ b/internal/connector/connector.go @@ -14,11 +14,33 @@ import ( // under. Centralised so tests can assert on the full path without drift. const servicePath = "/rpc/public/textql.rpc.public.connector.ConnectorService" +// defaultEndpoint is the fallback base URL used in human-readable success +// notes when Deps.Endpoint is empty (e.g. tests that wire a bare Deps). +// Mirrors config.DefaultEndpoint without importing it — verb packages do not +// depend on internal/config. +const defaultEndpoint = "https://app.textql.com" + // Deps is the narrow injection boundary. Unary JSON-encodes req, POSTs it to // path, and JSON-decodes the response into *resp. A concrete wiring layer // adapts transport.Client to this function field; tests pass a recording fake. +// +// Endpoint is the resolved API base URL (after --endpoint / profile / env +// precedence), used by OAuth leaves whose success notes direct users at the +// correct TextQL web app to complete the browser handshake. Empty is +// tolerated — resolveEndpoint falls back to defaultEndpoint. type Deps struct { - Unary func(ctx context.Context, path string, req, resp any) error + Unary func(ctx context.Context, path string, req, resp any) error + Endpoint string +} + +// resolveEndpoint returns d.Endpoint when non-empty, else defaultEndpoint. +// OAuth success notes call this so self-hosted and non-prod profiles point +// users at the right web app instead of always echoing app.textql.com. +func (d Deps) resolveEndpoint() string { + if d.Endpoint != "" { + return d.Endpoint + } + return defaultEndpoint } // New returns the `connector` verb group. The returned *cli.Group is safe to diff --git a/internal/connector/create.go b/internal/connector/create.go index abd719a..62aebe5 100644 --- a/internal/connector/create.go +++ b/internal/connector/create.go @@ -19,34 +19,40 @@ func newCreateGroup(deps Deps) *cli.Group { return &cli.Group{ Summary: "Create a new connector. Pick a dialect, then an auth mode.", Children: map[string]cli.Command{ - "postgres": newPostgresCreateGroup(deps), + "postgres": newPostgresCreateGroup(deps), + "snowflake": newSnowflakeCreateGroup(deps), }, } } -// resolvePassword resolves the password from either --password-stdin (reads -// one line from r via cli.ReadPassword, preserving every byte except the -// trailing line terminator) or --password. If both are set, --password-stdin -// wins (it's the more secure channel). Neither set → usage error. Preserving -// surrounding whitespace is intentional: a password may legitimately start or -// end with spaces/tabs, and silently trimming would cause hard-to-diagnose -// auth failures. +// resolveSecret resolves a required secret (password, OAuth client secret, +// PAT, …) from either ---stdin (reads one line from r via +// cli.ReadPassword, preserving every byte except the trailing line +// terminator) or --. If both are set, ---stdin wins (it's the +// more secure channel). Neither set → usage error. Preserving surrounding +// whitespace is intentional: a secret may legitimately start or end with +// spaces/tabs, and silently trimming would cause hard-to-diagnose auth +// failures. +// +// flagName is the flag base (e.g. "password", "oauth-client-secret") used +// only in error messages so they read as `---stdin set but stdin was +// empty`. // // Lives in create.go rather than a per-dialect file because update.go also -// reuses it when --password{,-stdin} is supplied on an edit. -func resolvePassword(passFlag string, stdinFlag bool, r io.Reader) (string, error) { +// reuses it when a secret flag is supplied on an edit. +func resolveSecret(flagName, secretVal string, stdinFlag bool, r io.Reader) (string, error) { if stdinFlag { pass, err := cli.ReadPassword(r) if err != nil { - return "", fmt.Errorf("read password: %w", err) + return "", fmt.Errorf("read %s: %w", flagName, err) } if pass == "" { - return "", cli.UsageErrf("--password-stdin set but stdin was empty") + return "", cli.UsageErrf("--%s-stdin set but stdin was empty", flagName) } return pass, nil } - if passFlag == "" { - return "", cli.UsageErrf("--password or --password-stdin is required") + if secretVal == "" { + return "", cli.UsageErrf("--%s or --%s-stdin is required", flagName, flagName) } - return passFlag, nil + return secretVal, nil } diff --git a/internal/connector/create_postgres.go b/internal/connector/create_postgres.go index 357612d..c3904e1 100644 --- a/internal/connector/create_postgres.go +++ b/internal/connector/create_postgres.go @@ -94,7 +94,7 @@ func (c *postgresPasswordCmd) Run(ctx context.Context, args []string, stdio cli. if c.port <= 0 || c.port > 65535 { return cli.UsageErrf("connector create postgres password: --port must be in 1..65535 (got %d)", c.port) } - resolvedPass, err := resolvePassword(c.password, c.passStdin, stdio.Stdin) + resolvedPass, err := resolveSecret("password", c.password, c.passStdin, stdio.Stdin) if err != nil { return fmt.Errorf("connector create postgres password: %w", err) } diff --git a/internal/connector/create_postgres_password_test.go b/internal/connector/create_postgres_password_test.go index 07c4749..380aca2 100644 --- a/internal/connector/create_postgres_password_test.go +++ b/internal/connector/create_postgres_password_test.go @@ -158,7 +158,7 @@ func TestCreatePostgresPasswordStdinReadErr(t *testing.T) { func TestResolvePasswordNilReader(t *testing.T) { t.Parallel() - _, err := resolvePassword("", true, nil) + _, err := resolveSecret("password", "", true, nil) if err == nil { t.Errorf("want error on nil reader") } @@ -171,12 +171,12 @@ func TestResolvePasswordNilReader(t *testing.T) { // only the trailing line terminator (\n or \r\n) and nothing else. func TestResolvePasswordPreservesSurroundingWhitespace(t *testing.T) { t.Parallel() - got, err := resolvePassword("", true, strings.NewReader(" secret\twith\tabs \n")) + got, err := resolveSecret("password", "", true, strings.NewReader(" secret\twith\tabs \n")) if err != nil { - t.Fatalf("resolvePassword: %v", err) + t.Fatalf("resolveSecret: %v", err) } if want := " secret\twith\tabs "; got != want { - t.Errorf("resolvePassword=%q want %q", got, want) + t.Errorf("resolveSecret=%q want %q", got, want) } } @@ -184,12 +184,12 @@ func TestResolvePasswordPreservesSurroundingWhitespace(t *testing.T) { // stripped cleanly without swallowing any user bytes that precede it. func TestResolvePasswordStripsCRLF(t *testing.T) { t.Parallel() - got, err := resolvePassword("", true, strings.NewReader(" hunter2 \r\n")) + got, err := resolveSecret("password", "", true, strings.NewReader(" hunter2 \r\n")) if err != nil { - t.Fatalf("resolvePassword: %v", err) + t.Fatalf("resolveSecret: %v", err) } if want := " hunter2 "; got != want { - t.Errorf("resolvePassword=%q want %q", got, want) + t.Errorf("resolveSecret=%q want %q", got, want) } } @@ -323,7 +323,7 @@ func TestCreateGroupHelpMentionsDialects(t *testing.T) { t.Parallel() g := newCreateGroup(Deps{}) h := g.Help() - for _, d := range []string{"postgres"} { + for _, d := range []string{"postgres", "snowflake"} { if !strings.Contains(h, d) { t.Errorf("create Help missing dialect %q: %q", d, h) } diff --git a/internal/connector/create_snowflake.go b/internal/connector/create_snowflake.go new file mode 100644 index 0000000..190bbcf --- /dev/null +++ b/internal/connector/create_snowflake.go @@ -0,0 +1,76 @@ +package connector + +import ( + "flag" + + "github.com/highperformance-tech/ana-cli/internal/cli" +) + +// newSnowflakeCreateGroup returns the Snowflake create-dialect Group. Flags +// common to every Snowflake auth-mode leaf are declared on the Group's +// inheritable Flags closure; each auth-mode leaf declares its own +// credential-specific flags and reads the Group's via cli.ApplyAncestorFlags. +// +// "locator" is TextQL's wire name for what Snowflake's own docs call +// `account` (e.g. `abc12345.us-east-1`); the CLI flag uses --locator to +// match the wire. `--database` is required; `--warehouse`/`--schema`/`--role` +// are optional per the captured UI behavior. +func newSnowflakeCreateGroup(deps Deps) *cli.Group { + var ( + name string + locator string + database string + warehouse string + schema string + role string + ) + return &cli.Group{ + Summary: "Create a Snowflake connector. Pick an auth mode.", + Flags: func(fs *flag.FlagSet) { + cli.DeclareString(fs, &name, "name", "", "connector name (required)") + cli.DeclareString(fs, &locator, "locator", "", "Snowflake account locator, e.g. abc12345.us-east-1 (required)") + cli.DeclareString(fs, &database, "database", "", "database name (required)") + cli.DeclareString(fs, &warehouse, "warehouse", "", "default warehouse (optional)") + cli.DeclareString(fs, &schema, "schema", "", "default schema (optional)") + cli.DeclareString(fs, &role, "role", "", "default role (optional)") + }, + Children: map[string]cli.Command{ + "password": &snowflakePasswordCmd{ + deps: deps, + name: &name, + locator: &locator, + database: &database, + warehouse: &warehouse, + schema: &schema, + role: &role, + }, + "keypair": &snowflakeKeypairCmd{ + deps: deps, + name: &name, + locator: &locator, + database: &database, + warehouse: &warehouse, + schema: &schema, + role: &role, + }, + "oauth-sso": &snowflakeOAuthSSOCmd{ + deps: deps, + name: &name, + locator: &locator, + database: &database, + warehouse: &warehouse, + schema: &schema, + role: &role, + }, + "oauth-individual": &snowflakeOAuthIndividualCmd{ + deps: deps, + name: &name, + locator: &locator, + database: &database, + warehouse: &warehouse, + schema: &schema, + role: &role, + }, + }, + } +} diff --git a/internal/connector/create_snowflake_keypair.go b/internal/connector/create_snowflake_keypair.go new file mode 100644 index 0000000..3fea459 --- /dev/null +++ b/internal/connector/create_snowflake_keypair.go @@ -0,0 +1,133 @@ +package connector + +import ( + "context" + "flag" + "fmt" + "io" + "os" + + "github.com/highperformance-tech/ana-cli/internal/cli" +) + +// snowflakeKeypairCmd is the leaf for +// `ana connector create snowflake keypair`. Auth mode shares +// authStrategy=service_role with password; the server discriminates by which +// credential field is populated (`privateKey` vs `password`). +// +// Snowflake binds RSA public keys to a user, so `--user` is still required +// even though the credential is the private key. The passphrase pair is +// optional (PKCS#8 keys may be unencrypted); when either passphrase flag is +// set, its value populates `privateKeyPassphrase` on the wire. +type snowflakeKeypairCmd struct { + deps Deps + + // Ancestor-flag targets. + name *string + locator *string + database *string + warehouse *string + schema *string + role *string + + // Leaf-specific flag targets. + user string + privateKeyFile string + privateKeyPass string + privateKeyPassStdin bool +} + +func (c *snowflakeKeypairCmd) Help() string { + return "keypair Key-pair Snowflake auth (authStrategy=service_role; private key in PEM).\n" + + "Usage: ana connector create snowflake keypair --name --locator --database --user --private-key-file [--private-key-passphrase

|--private-key-passphrase-stdin] [--warehouse ] [--schema ] [--role ]" +} + +func (c *snowflakeKeypairCmd) Flags(fs *flag.FlagSet) { + fs.StringVar(&c.user, "user", "", "Snowflake username bound to the key (required)") + fs.StringVar(&c.privateKeyFile, "private-key-file", "", "path to PEM-encoded PKCS#8 private key file (required)") + fs.StringVar(&c.privateKeyPass, "private-key-passphrase", "", "passphrase for encrypted PKCS#8 key (optional)") + fs.BoolVar(&c.privateKeyPassStdin, "private-key-passphrase-stdin", false, "read passphrase from the first stdin line (optional)") +} + +func (c *snowflakeKeypairCmd) Run(ctx context.Context, args []string, stdio cli.IO) error { + fs := cli.NewFlagSet("connector create snowflake keypair") + c.Flags(fs) + cli.ApplyAncestorFlags(ctx, fs) + if err := cli.ParseFlags(fs, args); err != nil { + return err + } + if err := cli.RequireFlags(fs, "connector create snowflake keypair", + "name", "locator", "database", "user", "private-key-file"); err != nil { + return err + } + for _, p := range []struct { + name, val string + }{ + {"name", *c.name}, {"locator", *c.locator}, {"database", *c.database}, + {"user", c.user}, {"private-key-file", c.privateKeyFile}, + } { + if p.val == "" { + return cli.UsageErrf("connector create snowflake keypair: --%s must not be empty", p.name) + } + } + keyBytes, err := os.ReadFile(c.privateKeyFile) + if err != nil { + return fmt.Errorf("connector create snowflake keypair: read --private-key-file: %w", err) + } + if len(keyBytes) == 0 { + return cli.UsageErrf("connector create snowflake keypair: --private-key-file %q is empty", c.privateKeyFile) + } + passphrase, err := resolveOptionalPassphrase(c.privateKeyPass, c.privateKeyPassStdin, stdio.Stdin) + if err != nil { + return fmt.Errorf("connector create snowflake keypair: %w", err) + } + + req := createReq{Config: configEnvelope{ + ConnectorType: "SNOWFLAKE", + Name: *c.name, + AuthStrategy: "service_role", + Snowflake: &snowflakeSpec{ + Locator: *c.locator, + Database: *c.database, + Warehouse: *c.warehouse, + Schema: *c.schema, + Role: *c.role, + Username: c.user, + PrivateKey: string(keyBytes), + PrivateKeyPassphrase: passphrase, + }, + }} + var raw map[string]any + if err := c.deps.Unary(ctx, servicePath+"/CreateConnector", req, &raw); err != nil { + return fmt.Errorf("connector create snowflake keypair: %w", err) + } + var typed createResp + if err := cli.RenderOutput(stdio.Stdout, raw, cli.GlobalFrom(ctx).JSON, &typed, func(w io.Writer, t *createResp) error { + _, err := fmt.Fprintf(w, "connectorId: %d\nname: %s\nconnectorType: %s\n", + t.ConnectorID, t.Name, t.ConnectorType) + return err + }); err != nil { + return fmt.Errorf("connector create snowflake keypair: %w", err) + } + return nil +} + +// resolveOptionalPassphrase returns "" when neither flag is set (passphrase +// is legitimately optional for unencrypted PKCS#8 keys), reads one line from +// r when --…-stdin is set (empty stdin is a usage error — the flag was set +// but the secret didn't arrive), and otherwise returns the --… value +// verbatim. Whitespace semantics match cli.ReadPassword: only the trailing +// line terminator is stripped. +func resolveOptionalPassphrase(passFlag string, stdinFlag bool, r io.Reader) (string, error) { + if stdinFlag { + pass, err := cli.ReadPassword(r) + if err != nil { + return "", fmt.Errorf("read passphrase: %w", err) + } + if pass == "" { + return "", cli.UsageErrf("--private-key-passphrase-stdin set but stdin was empty") + } + return pass, nil + } + return passFlag, nil +} diff --git a/internal/connector/create_snowflake_keypair_test.go b/internal/connector/create_snowflake_keypair_test.go new file mode 100644 index 0000000..2d69e77 --- /dev/null +++ b/internal/connector/create_snowflake_keypair_test.go @@ -0,0 +1,317 @@ +package connector + +import ( + "bytes" + "context" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/highperformance-tech/ana-cli/internal/cli" + "github.com/highperformance-tech/ana-cli/internal/testcli" +) + +// samplePEM is a structurally plausible PKCS#8 PEM block used only so wire- +// shape assertions can check the literal string round-trips through the +// request body. No cryptography happens client-side; the server stores it +// opaquely. +const samplePEM = "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC\n-----END PRIVATE KEY-----\n" + +func writeKeyFile(t *testing.T, body string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "key.p8") + if err := os.WriteFile(path, []byte(body), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + return path +} + +func snowflakeKeypairArgs(keyPath string) []string { + return []string{ + "snowflake", "keypair", + "--name", "sf1", + "--locator", "abc12345.us-east-1", + "--database", "D", + "--user", "U", + "--private-key-file", keyPath, + } +} + +func runSnowflakeKeypair(t *testing.T, deps Deps, args []string, stdin string) (*bytes.Buffer, error) { + t.Helper() + g := newCreateGroup(deps) + stdio, out, _ := testcli.NewIO(strings.NewReader(stdin)) + return out, g.Run(context.Background(), args, stdio) +} + +func TestCreateSnowflakeKeypairHappy(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 77.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + out, err := runSnowflakeKeypair(t, f.deps(), snowflakeKeypairArgs(keyPath), "") + if err != nil { + t.Fatalf("err=%v", err) + } + s := out.String() + if !strings.Contains(s, "connectorId: 77") || !strings.Contains(s, "name: sf1") { + t.Errorf("stdout=%q", s) + } + req := string(f.lastRawReq) + for _, want := range []string{ + `"connectorType":"SNOWFLAKE"`, `"name":"sf1"`, `"authStrategy":"service_role"`, + `"locator":"abc12345.us-east-1"`, `"username":"U"`, + `"privateKey":"-----BEGIN PRIVATE KEY-----`, + } { + if !strings.Contains(req, want) { + t.Errorf("req missing %s in %s", want, req) + } + } + // Password-mode field must be absent; passphrase absent when not set. + for _, unwanted := range []string{`"password":`, `"privateKeyPassphrase":`, `"oauthClientId":`} { + if strings.Contains(req, unwanted) { + t.Errorf("req unexpectedly contains %s in %s", unwanted, req) + } + } +} + +func TestCreateSnowflakeKeypairWithPassphraseFlag(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + args := append(snowflakeKeypairArgs(keyPath), + "--private-key-passphrase", "secret-phrase", + ) + _, err := runSnowflakeKeypair(t, f.deps(), args, "") + if err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(string(f.lastRawReq), `"privateKeyPassphrase":"secret-phrase"`) { + t.Errorf("req=%s", string(f.lastRawReq)) + } +} + +func TestCreateSnowflakeKeypairWithPassphraseStdin(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + f := &fakeDeps{} + args := append(snowflakeKeypairArgs(keyPath), "--private-key-passphrase-stdin") + _, err := runSnowflakeKeypair(t, f.deps(), args, "stdin-phrase\n") + if err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(string(f.lastRawReq), `"privateKeyPassphrase":"stdin-phrase"`) { + t.Errorf("req=%s", string(f.lastRawReq)) + } +} + +func TestCreateSnowflakeKeypairPassphraseStdinEmpty(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + args := append(snowflakeKeypairArgs(keyPath), "--private-key-passphrase-stdin") + _, err := runSnowflakeKeypair(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeKeypairPassphraseStdinReadErr(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + args := append(snowflakeKeypairArgs(keyPath), "--private-key-passphrase-stdin") + g := newCreateGroup((&fakeDeps{}).deps()) + stdio, _, _ := testcli.NewIO(errReader{err: errors.New("read fail")}) + err := g.Run(context.Background(), args, stdio) + if err == nil || !strings.Contains(err.Error(), "read fail") { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeKeypairOptionalFields(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + args := append(snowflakeKeypairArgs(keyPath), + "--warehouse", "W", + "--schema", "S", + "--role", "R", + ) + _, err := runSnowflakeKeypair(t, f.deps(), args, "") + if err != nil { + t.Fatalf("err=%v", err) + } + req := string(f.lastRawReq) + for _, want := range []string{`"warehouse":"W"`, `"schema":"S"`, `"role":"R"`} { + if !strings.Contains(req, want) { + t.Errorf("req missing %s in %s", want, req) + } + } +} + +func TestCreateSnowflakeKeypairMissingKeyFile(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "keypair", + "--name", "n", + "--locator", "acct", + "--database", "D", + "--user", "U", + } + _, err := runSnowflakeKeypair(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeKeypairMissingFlags(t *testing.T) { + t.Parallel() + _, err := runSnowflakeKeypair(t, (&fakeDeps{}).deps(), []string{"snowflake", "keypair"}, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeKeypairEmptyString(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + for _, flag := range []string{"name", "locator", "database", "user"} { + t.Run(flag, func(t *testing.T) { + t.Parallel() + args := append(snowflakeKeypairArgs(keyPath), "--"+flag, "") + _, err := runSnowflakeKeypair(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) || !strings.Contains(err.Error(), "--"+flag) { + t.Errorf("err=%v", err) + } + }) + } +} + +func TestCreateSnowflakeKeypairKeyFileMissing(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "keypair", + "--name", "n", + "--locator", "acct", + "--database", "D", + "--user", "U", + "--private-key-file", "/no/such/file.p8", + } + _, err := runSnowflakeKeypair(t, (&fakeDeps{}).deps(), args, "") + if err == nil || !strings.Contains(err.Error(), "read --private-key-file") { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeKeypairKeyFileEmpty(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, "") + _, err := runSnowflakeKeypair(t, (&fakeDeps{}).deps(), snowflakeKeypairArgs(keyPath), "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeKeypairJSONBypass(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "n", "connectorType": "SNOWFLAKE"} + return nil + }, + } + ctx := cli.WithGlobal(context.Background(), cli.Global{JSON: true}) + g := newCreateGroup(f.deps()) + stdio, out, _ := testcli.NewIO(strings.NewReader("")) + if err := g.Run(ctx, snowflakeKeypairArgs(keyPath), stdio); err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(out.String(), "\"connectorId\"") { + t.Errorf("stdout=%q", out.String()) + } +} + +func TestCreateSnowflakeKeypairRenderWriteErr(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + g := newCreateGroup(f.deps()) + err := g.Run(context.Background(), snowflakeKeypairArgs(keyPath), testcli.FailingIO()) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Errorf("err=%v want boom", err) + } +} + +func TestCreateSnowflakeKeypairBadFlag(t *testing.T) { + t.Parallel() + _, err := runSnowflakeKeypair(t, (&fakeDeps{}).deps(), []string{"snowflake", "keypair", "--nope"}, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeKeypairUnaryErr(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + f := &fakeDeps{unaryFn: func(_ context.Context, _ string, _, _ any) error { return errors.New("boom") }} + _, err := runSnowflakeKeypair(t, f.deps(), snowflakeKeypairArgs(keyPath), "") + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeKeypairRemarshalErr(t *testing.T) { + t.Parallel() + keyPath := writeKeyFile(t, samplePEM) + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": "not-an-int"} + return nil + }, + } + _, err := runSnowflakeKeypair(t, f.deps(), snowflakeKeypairArgs(keyPath), "") + if err == nil || !strings.Contains(err.Error(), "decode response") { + t.Errorf("err=%v", err) + } +} + +// TestResolveOptionalPassphraseUnset locks in that the passphrase is +// legitimately optional — PKCS#8 keys may be unencrypted and the UI omits +// the field entirely in that case. +func TestResolveOptionalPassphraseUnset(t *testing.T) { + t.Parallel() + got, err := resolveOptionalPassphrase("", false, nil) + if err != nil { + t.Fatalf("err=%v", err) + } + if got != "" { + t.Errorf("got=%q want empty", got) + } +} diff --git a/internal/connector/create_snowflake_oauth_individual.go b/internal/connector/create_snowflake_oauth_individual.go new file mode 100644 index 0000000..927e7c9 --- /dev/null +++ b/internal/connector/create_snowflake_oauth_individual.go @@ -0,0 +1,99 @@ +package connector + +import ( + "context" + "flag" + "fmt" + "io" + + "github.com/highperformance-tech/ana-cli/internal/cli" +) + +// snowflakeOAuthIndividualCmd is the leaf for +// `ana connector create snowflake oauth-individual` +// (authStrategy=per_member_oauth). +// +// Wire shape is identical to oauth-sso — only `authStrategy` differs. +// Unlike oauth-sso, no up-front handshake is needed: each member +// authenticates lazily at their first query. The CLI just creates the row. +type snowflakeOAuthIndividualCmd struct { + deps Deps + + name *string + locator *string + database *string + warehouse *string + schema *string + role *string + + oauthClientID string + oauthClientSecret string + oauthSecretStdin bool +} + +func (c *snowflakeOAuthIndividualCmd) Help() string { + return "oauth-individual Per-member OAuth auth (authStrategy=per_member_oauth).\n" + + "Usage: ana connector create snowflake oauth-individual --name --locator --database --oauth-client-id (--oauth-client-secret |--oauth-client-secret-stdin) [--warehouse ] [--schema ] [--role ]\n" + + "Note: each member authenticates lazily at first query; no up-front browser step." +} + +func (c *snowflakeOAuthIndividualCmd) Flags(fs *flag.FlagSet) { + fs.StringVar(&c.oauthClientID, "oauth-client-id", "", "Snowflake OAuth client id (required)") + fs.StringVar(&c.oauthClientSecret, "oauth-client-secret", "", "Snowflake OAuth client secret (discouraged; prefer stdin)") + fs.BoolVar(&c.oauthSecretStdin, "oauth-client-secret-stdin", false, "read oauth client secret from the first stdin line") +} + +func (c *snowflakeOAuthIndividualCmd) Run(ctx context.Context, args []string, stdio cli.IO) error { + fs := cli.NewFlagSet("connector create snowflake oauth-individual") + c.Flags(fs) + cli.ApplyAncestorFlags(ctx, fs) + if err := cli.ParseFlags(fs, args); err != nil { + return err + } + if err := cli.RequireFlags(fs, "connector create snowflake oauth-individual", + "name", "locator", "database", "oauth-client-id"); err != nil { + return err + } + for _, p := range []struct { + name, val string + }{ + {"name", *c.name}, {"locator", *c.locator}, {"database", *c.database}, + {"oauth-client-id", c.oauthClientID}, + } { + if p.val == "" { + return cli.UsageErrf("connector create snowflake oauth-individual: --%s must not be empty", p.name) + } + } + secret, err := resolveSecret("oauth-client-secret", c.oauthClientSecret, c.oauthSecretStdin, stdio.Stdin) + if err != nil { + return fmt.Errorf("connector create snowflake oauth-individual: %w", err) + } + + req := createReq{Config: configEnvelope{ + ConnectorType: "SNOWFLAKE", + Name: *c.name, + AuthStrategy: "per_member_oauth", + Snowflake: &snowflakeSpec{ + Locator: *c.locator, + Database: *c.database, + Warehouse: *c.warehouse, + Schema: *c.schema, + Role: *c.role, + OAuthClientID: c.oauthClientID, + OAuthClientSecret: secret, + }, + }} + var raw map[string]any + if err := c.deps.Unary(ctx, servicePath+"/CreateConnector", req, &raw); err != nil { + return fmt.Errorf("connector create snowflake oauth-individual: %w", err) + } + var typed createResp + if err := cli.RenderOutput(stdio.Stdout, raw, cli.GlobalFrom(ctx).JSON, &typed, func(w io.Writer, t *createResp) error { + _, err := fmt.Fprintf(w, "connectorId: %d\nname: %s\nconnectorType: %s\nnote: members authenticate lazily at first query\n", + t.ConnectorID, t.Name, t.ConnectorType) + return err + }); err != nil { + return fmt.Errorf("connector create snowflake oauth-individual: %w", err) + } + return nil +} diff --git a/internal/connector/create_snowflake_oauth_individual_test.go b/internal/connector/create_snowflake_oauth_individual_test.go new file mode 100644 index 0000000..29b9581 --- /dev/null +++ b/internal/connector/create_snowflake_oauth_individual_test.go @@ -0,0 +1,235 @@ +package connector + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/highperformance-tech/ana-cli/internal/cli" + "github.com/highperformance-tech/ana-cli/internal/testcli" +) + +func snowflakeOAuthIndividualArgs() []string { + return []string{ + "snowflake", "oauth-individual", + "--name", "sf1", + "--locator", "abc12345.us-east-1", + "--database", "D", + "--oauth-client-id", "cid", + "--oauth-client-secret", "csec", + } +} + +func runSnowflakeOAuthIndividual(t *testing.T, deps Deps, args []string, stdin string) (*bytes.Buffer, error) { + t.Helper() + g := newCreateGroup(deps) + stdio, out, _ := testcli.NewIO(strings.NewReader(stdin)) + return out, g.Run(context.Background(), args, stdio) +} + +func TestCreateSnowflakeOAuthIndividualHappy(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 66.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + out, err := runSnowflakeOAuthIndividual(t, f.deps(), snowflakeOAuthIndividualArgs(), "") + if err != nil { + t.Fatalf("err=%v", err) + } + s := out.String() + if !strings.Contains(s, "connectorId: 66") || !strings.Contains(s, "lazily at first query") { + t.Errorf("stdout=%q", s) + } + req := string(f.lastRawReq) + for _, want := range []string{ + `"connectorType":"SNOWFLAKE"`, `"authStrategy":"per_member_oauth"`, + `"locator":"abc12345.us-east-1"`, `"oauthClientId":"cid"`, `"oauthClientSecret":"csec"`, + } { + if !strings.Contains(req, want) { + t.Errorf("req missing %s in %s", want, req) + } + } + for _, unwanted := range []string{`"username":`, `"password":`, `"privateKey":`} { + if strings.Contains(req, unwanted) { + t.Errorf("req unexpectedly contains %s in %s", unwanted, req) + } + } +} + +func TestCreateSnowflakeOAuthIndividualSecretStdin(t *testing.T) { + t.Parallel() + f := &fakeDeps{} + args := []string{ + "snowflake", "oauth-individual", + "--name", "sf1", "--locator", "acct", "--database", "D", + "--oauth-client-id", "cid", "--oauth-client-secret-stdin", + } + _, err := runSnowflakeOAuthIndividual(t, f.deps(), args, "stdin-secret\n") + if err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(string(f.lastRawReq), `"oauthClientSecret":"stdin-secret"`) { + t.Errorf("req=%s", string(f.lastRawReq)) + } +} + +func TestCreateSnowflakeOAuthIndividualSecretStdinEmpty(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "oauth-individual", + "--name", "sf1", "--locator", "acct", "--database", "D", + "--oauth-client-id", "cid", "--oauth-client-secret-stdin", + } + _, err := runSnowflakeOAuthIndividual(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthIndividualSecretStdinReadErr(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "oauth-individual", + "--name", "sf1", "--locator", "acct", "--database", "D", + "--oauth-client-id", "cid", "--oauth-client-secret-stdin", + } + g := newCreateGroup((&fakeDeps{}).deps()) + stdio, _, _ := testcli.NewIO(errReader{err: errors.New("read fail")}) + err := g.Run(context.Background(), args, stdio) + if err == nil || !strings.Contains(err.Error(), "read fail") { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthIndividualMissingSecret(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "oauth-individual", + "--name", "sf1", "--locator", "acct", "--database", "D", + "--oauth-client-id", "cid", + } + _, err := runSnowflakeOAuthIndividual(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthIndividualMissingFlags(t *testing.T) { + t.Parallel() + _, err := runSnowflakeOAuthIndividual(t, (&fakeDeps{}).deps(), []string{"snowflake", "oauth-individual"}, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthIndividualEmptyString(t *testing.T) { + t.Parallel() + for _, flag := range []string{"name", "locator", "database", "oauth-client-id"} { + t.Run(flag, func(t *testing.T) { + t.Parallel() + args := append(snowflakeOAuthIndividualArgs(), "--"+flag, "") + _, err := runSnowflakeOAuthIndividual(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) || !strings.Contains(err.Error(), "--"+flag) { + t.Errorf("err=%v", err) + } + }) + } +} + +func TestCreateSnowflakeOAuthIndividualOptionalFields(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + args := append(snowflakeOAuthIndividualArgs(), + "--warehouse", "W", "--schema", "S", "--role", "R", + ) + _, err := runSnowflakeOAuthIndividual(t, f.deps(), args, "") + if err != nil { + t.Fatalf("err=%v", err) + } + req := string(f.lastRawReq) + for _, want := range []string{`"warehouse":"W"`, `"schema":"S"`, `"role":"R"`} { + if !strings.Contains(req, want) { + t.Errorf("req missing %s in %s", want, req) + } + } +} + +func TestCreateSnowflakeOAuthIndividualJSONBypass(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "n", "connectorType": "SNOWFLAKE"} + return nil + }, + } + ctx := cli.WithGlobal(context.Background(), cli.Global{JSON: true}) + g := newCreateGroup(f.deps()) + stdio, out, _ := testcli.NewIO(strings.NewReader("")) + if err := g.Run(ctx, snowflakeOAuthIndividualArgs(), stdio); err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(out.String(), "\"connectorId\"") { + t.Errorf("stdout=%q", out.String()) + } +} + +func TestCreateSnowflakeOAuthIndividualRenderWriteErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + g := newCreateGroup(f.deps()) + err := g.Run(context.Background(), snowflakeOAuthIndividualArgs(), testcli.FailingIO()) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Errorf("err=%v want boom", err) + } +} + +func TestCreateSnowflakeOAuthIndividualBadFlag(t *testing.T) { + t.Parallel() + _, err := runSnowflakeOAuthIndividual(t, (&fakeDeps{}).deps(), []string{"snowflake", "oauth-individual", "--nope"}, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthIndividualUnaryErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{unaryFn: func(_ context.Context, _ string, _, _ any) error { return errors.New("boom") }} + _, err := runSnowflakeOAuthIndividual(t, f.deps(), snowflakeOAuthIndividualArgs(), "") + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthIndividualRemarshalErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": "not-an-int"} + return nil + }, + } + _, err := runSnowflakeOAuthIndividual(t, f.deps(), snowflakeOAuthIndividualArgs(), "") + if err == nil || !strings.Contains(err.Error(), "decode response") { + t.Errorf("err=%v", err) + } +} diff --git a/internal/connector/create_snowflake_oauth_sso.go b/internal/connector/create_snowflake_oauth_sso.go new file mode 100644 index 0000000..cf0cd77 --- /dev/null +++ b/internal/connector/create_snowflake_oauth_sso.go @@ -0,0 +1,102 @@ +package connector + +import ( + "context" + "flag" + "fmt" + "io" + + "github.com/highperformance-tech/ana-cli/internal/cli" +) + +// snowflakeOAuthSSOCmd is the leaf for +// `ana connector create snowflake oauth-sso`. authStrategy=oauth_sso. +// +// Server accepts CreateConnector in a pending-OAuth state — the row +// persists with just {name, locator, database, oauthClientId, +// oauthClientSecret}; the browser handshake at +// `app.textql.com/auth/snowflake/callback` happens separately and is what +// actually produces the refresh token. CLI users must complete that in a +// browser; the CLI ships as a real leaf that creates the row. +type snowflakeOAuthSSOCmd struct { + deps Deps + + name *string + locator *string + database *string + warehouse *string + schema *string + role *string + + oauthClientID string + oauthClientSecret string + oauthSecretStdin bool +} + +func (c *snowflakeOAuthSSOCmd) Help() string { + return "oauth-sso Shared-token OAuth auth (authStrategy=oauth_sso).\n" + + "Usage: ana connector create snowflake oauth-sso --name --locator --database --oauth-client-id (--oauth-client-secret |--oauth-client-secret-stdin) [--warehouse ] [--schema ] [--role ]\n" + + "Note: a human must complete the OAuth handshake in the TextQL web app you're pointed at after create — the CLI cannot receive the redirect. The success message prints the exact URL based on the active profile." +} + +func (c *snowflakeOAuthSSOCmd) Flags(fs *flag.FlagSet) { + fs.StringVar(&c.oauthClientID, "oauth-client-id", "", "Snowflake OAuth client id (required)") + fs.StringVar(&c.oauthClientSecret, "oauth-client-secret", "", "Snowflake OAuth client secret (discouraged; prefer stdin)") + fs.BoolVar(&c.oauthSecretStdin, "oauth-client-secret-stdin", false, "read oauth client secret from the first stdin line") +} + +func (c *snowflakeOAuthSSOCmd) Run(ctx context.Context, args []string, stdio cli.IO) error { + fs := cli.NewFlagSet("connector create snowflake oauth-sso") + c.Flags(fs) + cli.ApplyAncestorFlags(ctx, fs) + if err := cli.ParseFlags(fs, args); err != nil { + return err + } + if err := cli.RequireFlags(fs, "connector create snowflake oauth-sso", + "name", "locator", "database", "oauth-client-id"); err != nil { + return err + } + for _, p := range []struct { + name, val string + }{ + {"name", *c.name}, {"locator", *c.locator}, {"database", *c.database}, + {"oauth-client-id", c.oauthClientID}, + } { + if p.val == "" { + return cli.UsageErrf("connector create snowflake oauth-sso: --%s must not be empty", p.name) + } + } + secret, err := resolveSecret("oauth-client-secret", c.oauthClientSecret, c.oauthSecretStdin, stdio.Stdin) + if err != nil { + return fmt.Errorf("connector create snowflake oauth-sso: %w", err) + } + + req := createReq{Config: configEnvelope{ + ConnectorType: "SNOWFLAKE", + Name: *c.name, + AuthStrategy: "oauth_sso", + Snowflake: &snowflakeSpec{ + Locator: *c.locator, + Database: *c.database, + Warehouse: *c.warehouse, + Schema: *c.schema, + Role: *c.role, + OAuthClientID: c.oauthClientID, + OAuthClientSecret: secret, + }, + }} + var raw map[string]any + if err := c.deps.Unary(ctx, servicePath+"/CreateConnector", req, &raw); err != nil { + return fmt.Errorf("connector create snowflake oauth-sso: %w", err) + } + endpoint := c.deps.resolveEndpoint() + var typed createResp + if err := cli.RenderOutput(stdio.Stdout, raw, cli.GlobalFrom(ctx).JSON, &typed, func(w io.Writer, t *createResp) error { + _, err := fmt.Fprintf(w, "connectorId: %d\nname: %s\nconnectorType: %s\nnote: complete OAuth at %s to activate\n", + t.ConnectorID, t.Name, t.ConnectorType, endpoint) + return err + }); err != nil { + return fmt.Errorf("connector create snowflake oauth-sso: %w", err) + } + return nil +} diff --git a/internal/connector/create_snowflake_oauth_sso_test.go b/internal/connector/create_snowflake_oauth_sso_test.go new file mode 100644 index 0000000..61775c9 --- /dev/null +++ b/internal/connector/create_snowflake_oauth_sso_test.go @@ -0,0 +1,279 @@ +package connector + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/highperformance-tech/ana-cli/internal/cli" + "github.com/highperformance-tech/ana-cli/internal/testcli" +) + +func snowflakeOAuthSSOArgs() []string { + return []string{ + "snowflake", "oauth-sso", + "--name", "sf1", + "--locator", "abc12345.us-east-1", + "--database", "D", + "--oauth-client-id", "cid", + "--oauth-client-secret", "csec", + } +} + +func runSnowflakeOAuthSSO(t *testing.T, deps Deps, args []string, stdin string) (*bytes.Buffer, error) { + t.Helper() + g := newCreateGroup(deps) + stdio, out, _ := testcli.NewIO(strings.NewReader(stdin)) + return out, g.Run(context.Background(), args, stdio) +} + +func TestCreateSnowflakeOAuthSSOHappy(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 55.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + out, err := runSnowflakeOAuthSSO(t, f.deps(), snowflakeOAuthSSOArgs(), "") + if err != nil { + t.Fatalf("err=%v", err) + } + s := out.String() + if !strings.Contains(s, "connectorId: 55") || !strings.Contains(s, "complete OAuth at https://app.textql.com") { + t.Errorf("stdout=%q", s) + } + req := string(f.lastRawReq) + for _, want := range []string{ + `"connectorType":"SNOWFLAKE"`, `"name":"sf1"`, `"authStrategy":"oauth_sso"`, + `"locator":"abc12345.us-east-1"`, `"database":"D"`, + `"oauthClientId":"cid"`, `"oauthClientSecret":"csec"`, + } { + if !strings.Contains(req, want) { + t.Errorf("req missing %s in %s", want, req) + } + } + // OAuth modes send no username / password / privateKey. + for _, unwanted := range []string{`"username":`, `"password":`, `"privateKey":`} { + if strings.Contains(req, unwanted) { + t.Errorf("req unexpectedly contains %s in %s", unwanted, req) + } + } +} + +func TestCreateSnowflakeOAuthSSOCustomEndpoint(t *testing.T) { + t.Parallel() + // Self-hosted / non-prod operators resolve an endpoint that is not + // app.textql.com; the success note must echo that URL so users complete + // the OAuth handshake in the right web app. + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 77.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + deps := f.deps() + deps.Endpoint = "https://staging.example.com" + g := newCreateGroup(deps) + stdio, out, _ := testcli.NewIO(strings.NewReader("")) + if err := g.Run(context.Background(), snowflakeOAuthSSOArgs(), stdio); err != nil { + t.Fatalf("err=%v", err) + } + s := out.String() + if !strings.Contains(s, "complete OAuth at https://staging.example.com") { + t.Errorf("stdout=%q missing custom endpoint URL", s) + } + // And it must NOT leak the hardcoded default into a non-prod profile. + if strings.Contains(s, "https://app.textql.com") { + t.Errorf("stdout=%q leaked default endpoint", s) + } +} + +func TestCreateSnowflakeOAuthSSOSecretStdin(t *testing.T) { + t.Parallel() + f := &fakeDeps{} + args := []string{ + "snowflake", "oauth-sso", + "--name", "sf1", + "--locator", "acct", + "--database", "D", + "--oauth-client-id", "cid", + "--oauth-client-secret-stdin", + } + _, err := runSnowflakeOAuthSSO(t, f.deps(), args, "stdin-secret\n") + if err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(string(f.lastRawReq), `"oauthClientSecret":"stdin-secret"`) { + t.Errorf("req=%s", string(f.lastRawReq)) + } +} + +func TestCreateSnowflakeOAuthSSOSecretStdinEmpty(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "oauth-sso", + "--name", "sf1", + "--locator", "acct", + "--database", "D", + "--oauth-client-id", "cid", + "--oauth-client-secret-stdin", + } + _, err := runSnowflakeOAuthSSO(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthSSOSecretStdinReadErr(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "oauth-sso", + "--name", "sf1", + "--locator", "acct", + "--database", "D", + "--oauth-client-id", "cid", + "--oauth-client-secret-stdin", + } + g := newCreateGroup((&fakeDeps{}).deps()) + stdio, _, _ := testcli.NewIO(errReader{err: errors.New("read fail")}) + err := g.Run(context.Background(), args, stdio) + if err == nil || !strings.Contains(err.Error(), "read fail") { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthSSOMissingSecret(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "oauth-sso", + "--name", "sf1", + "--locator", "acct", + "--database", "D", + "--oauth-client-id", "cid", + } + _, err := runSnowflakeOAuthSSO(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthSSOMissingFlags(t *testing.T) { + t.Parallel() + _, err := runSnowflakeOAuthSSO(t, (&fakeDeps{}).deps(), []string{"snowflake", "oauth-sso"}, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthSSOEmptyString(t *testing.T) { + t.Parallel() + for _, flag := range []string{"name", "locator", "database", "oauth-client-id"} { + t.Run(flag, func(t *testing.T) { + t.Parallel() + args := append(snowflakeOAuthSSOArgs(), "--"+flag, "") + _, err := runSnowflakeOAuthSSO(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) || !strings.Contains(err.Error(), "--"+flag) { + t.Errorf("err=%v", err) + } + }) + } +} + +func TestCreateSnowflakeOAuthSSOOptionalFields(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + args := append(snowflakeOAuthSSOArgs(), + "--warehouse", "W", + "--schema", "S", + "--role", "R", + ) + _, err := runSnowflakeOAuthSSO(t, f.deps(), args, "") + if err != nil { + t.Fatalf("err=%v", err) + } + req := string(f.lastRawReq) + for _, want := range []string{`"warehouse":"W"`, `"schema":"S"`, `"role":"R"`} { + if !strings.Contains(req, want) { + t.Errorf("req missing %s in %s", want, req) + } + } +} + +func TestCreateSnowflakeOAuthSSOJSONBypass(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "n", "connectorType": "SNOWFLAKE"} + return nil + }, + } + ctx := cli.WithGlobal(context.Background(), cli.Global{JSON: true}) + g := newCreateGroup(f.deps()) + stdio, out, _ := testcli.NewIO(strings.NewReader("")) + if err := g.Run(ctx, snowflakeOAuthSSOArgs(), stdio); err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(out.String(), "\"connectorId\"") { + t.Errorf("stdout=%q", out.String()) + } +} + +func TestCreateSnowflakeOAuthSSORenderWriteErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + g := newCreateGroup(f.deps()) + err := g.Run(context.Background(), snowflakeOAuthSSOArgs(), testcli.FailingIO()) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Errorf("err=%v want boom", err) + } +} + +func TestCreateSnowflakeOAuthSSOBadFlag(t *testing.T) { + t.Parallel() + _, err := runSnowflakeOAuthSSO(t, (&fakeDeps{}).deps(), []string{"snowflake", "oauth-sso", "--nope"}, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthSSOUnaryErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{unaryFn: func(_ context.Context, _ string, _, _ any) error { return errors.New("boom") }} + _, err := runSnowflakeOAuthSSO(t, f.deps(), snowflakeOAuthSSOArgs(), "") + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakeOAuthSSORemarshalErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": "not-an-int"} + return nil + }, + } + _, err := runSnowflakeOAuthSSO(t, f.deps(), snowflakeOAuthSSOArgs(), "") + if err == nil || !strings.Contains(err.Error(), "decode response") { + t.Errorf("err=%v", err) + } +} diff --git a/internal/connector/create_snowflake_password.go b/internal/connector/create_snowflake_password.go new file mode 100644 index 0000000..3b8def9 --- /dev/null +++ b/internal/connector/create_snowflake_password.go @@ -0,0 +1,94 @@ +package connector + +import ( + "context" + "flag" + "fmt" + "io" + + "github.com/highperformance-tech/ana-cli/internal/cli" +) + +// snowflakePasswordCmd is the leaf for +// `ana connector create snowflake password`. Ancestor flag pointers +// (--name, --locator, --database, --warehouse, --schema, --role) come from +// newSnowflakeCreateGroup's closure. +type snowflakePasswordCmd struct { + deps Deps + + // Ancestor-flag targets. + name *string + locator *string + database *string + warehouse *string + schema *string + role *string + + // Leaf-specific flag targets. + user string + password string + passStdin bool +} + +func (c *snowflakePasswordCmd) Help() string { + return "password Password-based Snowflake auth (authStrategy=service_role).\n" + + "Usage: ana connector create snowflake password --name --locator --database --user (--password

|--password-stdin) [--warehouse ] [--schema ] [--role ]" +} + +func (c *snowflakePasswordCmd) Flags(fs *flag.FlagSet) { + fs.StringVar(&c.user, "user", "", "Snowflake username (required)") + fs.StringVar(&c.password, "password", "", "Snowflake password (discouraged; prefer --password-stdin)") + fs.BoolVar(&c.passStdin, "password-stdin", false, "read password from the first stdin line") +} + +func (c *snowflakePasswordCmd) Run(ctx context.Context, args []string, stdio cli.IO) error { + fs := cli.NewFlagSet("connector create snowflake password") + c.Flags(fs) + cli.ApplyAncestorFlags(ctx, fs) + if err := cli.ParseFlags(fs, args); err != nil { + return err + } + if err := cli.RequireFlags(fs, "connector create snowflake password", + "name", "locator", "database", "user"); err != nil { + return err + } + for _, p := range []struct { + name, val string + }{{"name", *c.name}, {"locator", *c.locator}, {"database", *c.database}, {"user", c.user}} { + if p.val == "" { + return cli.UsageErrf("connector create snowflake password: --%s must not be empty", p.name) + } + } + resolvedPass, err := resolveSecret("password", c.password, c.passStdin, stdio.Stdin) + if err != nil { + return fmt.Errorf("connector create snowflake password: %w", err) + } + + req := createReq{Config: configEnvelope{ + ConnectorType: "SNOWFLAKE", + Name: *c.name, + AuthStrategy: "service_role", + Snowflake: &snowflakeSpec{ + Locator: *c.locator, + Database: *c.database, + Warehouse: *c.warehouse, + Schema: *c.schema, + Role: *c.role, + Username: c.user, + Password: resolvedPass, + }, + }} + var raw map[string]any + if err := c.deps.Unary(ctx, servicePath+"/CreateConnector", req, &raw); err != nil { + return fmt.Errorf("connector create snowflake password: %w", err) + } + var typed createResp + if err := cli.RenderOutput(stdio.Stdout, raw, cli.GlobalFrom(ctx).JSON, &typed, func(w io.Writer, t *createResp) error { + _, err := fmt.Fprintf(w, "connectorId: %d\nname: %s\nconnectorType: %s\n", + t.ConnectorID, t.Name, t.ConnectorType) + return err + }); err != nil { + return fmt.Errorf("connector create snowflake password: %w", err) + } + return nil +} diff --git a/internal/connector/create_snowflake_password_test.go b/internal/connector/create_snowflake_password_test.go new file mode 100644 index 0000000..b50b09c --- /dev/null +++ b/internal/connector/create_snowflake_password_test.go @@ -0,0 +1,267 @@ +package connector + +import ( + "bytes" + "context" + "errors" + "strings" + "testing" + + "github.com/highperformance-tech/ana-cli/internal/cli" + "github.com/highperformance-tech/ana-cli/internal/testcli" +) + +// snowflakePasswordArgs returns the full dispatch args for +// `connector create snowflake password ...` with every required flag set. +// Routes through newCreateGroup so ancestor-flag plumbing (--name, +// --locator, --database, etc. declared on the Snowflake Group) is exercised. +func snowflakePasswordArgs() []string { + return []string{ + "snowflake", "password", + "--name", "sf1", + "--locator", "abc12345.us-east-1", + "--database", "D", + "--user", "U", + "--password", "p", + } +} + +func runSnowflakePassword(t *testing.T, deps Deps, args []string, stdin string) (*bytes.Buffer, error) { + t.Helper() + g := newCreateGroup(deps) + stdio, out, _ := testcli.NewIO(strings.NewReader(stdin)) + return out, g.Run(context.Background(), args, stdio) +} + +func TestCreateSnowflakePasswordHappy(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 42.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + out, err := runSnowflakePassword(t, f.deps(), snowflakePasswordArgs(), "") + if err != nil { + t.Fatalf("err=%v", err) + } + s := out.String() + if !strings.Contains(s, "connectorId: 42") || !strings.Contains(s, "name: sf1") { + t.Errorf("stdout=%q", s) + } + req := string(f.lastRawReq) + for _, want := range []string{ + `"connectorType":"SNOWFLAKE"`, `"name":"sf1"`, `"authStrategy":"service_role"`, + `"snowflake":`, `"locator":"abc12345.us-east-1"`, `"database":"D"`, + `"username":"U"`, `"password":"p"`, + } { + if !strings.Contains(req, want) { + t.Errorf("req missing %s in %s", want, req) + } + } + // Optional fields not set → omitted. + for _, unwanted := range []string{`"warehouse":`, `"schema":`, `"role":`, `"privateKey":`, `"oauthClientId":`} { + if strings.Contains(req, unwanted) { + t.Errorf("req unexpectedly contains %s in %s", unwanted, req) + } + } + if f.lastPath != servicePath+"/CreateConnector" { + t.Errorf("path=%s", f.lastPath) + } +} + +func TestCreateSnowflakePasswordOptionalFields(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + args := append(snowflakePasswordArgs(), + "--warehouse", "W", + "--schema", "S", + "--role", "R", + ) + _, err := runSnowflakePassword(t, f.deps(), args, "") + if err != nil { + t.Fatalf("err=%v", err) + } + req := string(f.lastRawReq) + for _, want := range []string{`"warehouse":"W"`, `"schema":"S"`, `"role":"R"`} { + if !strings.Contains(req, want) { + t.Errorf("req missing %s in %s", want, req) + } + } +} + +func TestCreateSnowflakePasswordJSONBypass(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "n", "connectorType": "SNOWFLAKE"} + return nil + }, + } + ctx := cli.WithGlobal(context.Background(), cli.Global{JSON: true}) + g := newCreateGroup(f.deps()) + stdio, out, _ := testcli.NewIO(strings.NewReader("")) + if err := g.Run(ctx, snowflakePasswordArgs(), stdio); err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(out.String(), "\"connectorId\"") { + t.Errorf("stdout=%q", out.String()) + } +} + +func TestCreateSnowflakePasswordStdin(t *testing.T) { + t.Parallel() + f := &fakeDeps{} + args := []string{ + "snowflake", "password", + "--name", "n", + "--locator", "acct", + "--database", "D", + "--user", "U", + "--password-stdin", + } + _, err := runSnowflakePassword(t, f.deps(), args, "secret-line\n") + if err != nil { + t.Fatalf("err=%v", err) + } + if !strings.Contains(string(f.lastRawReq), `"password":"secret-line"`) { + t.Errorf("req=%s", string(f.lastRawReq)) + } +} + +func TestCreateSnowflakePasswordStdinEmpty(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "password", + "--name", "n", + "--locator", "acct", + "--database", "D", + "--user", "U", + "--password-stdin", + } + _, err := runSnowflakePassword(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakePasswordStdinReadErr(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "password", + "--name", "n", + "--locator", "acct", + "--database", "D", + "--user", "U", + "--password-stdin", + } + g := newCreateGroup((&fakeDeps{}).deps()) + stdio, _, _ := testcli.NewIO(errReader{err: errors.New("read fail")}) + err := g.Run(context.Background(), args, stdio) + if err == nil || !strings.Contains(err.Error(), "read fail") { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakePasswordMissingPassword(t *testing.T) { + t.Parallel() + args := []string{ + "snowflake", "password", + "--name", "n", + "--locator", "acct", + "--database", "D", + "--user", "U", + } + _, err := runSnowflakePassword(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakePasswordMissingFlags(t *testing.T) { + t.Parallel() + args := []string{"snowflake", "password"} + _, err := runSnowflakePassword(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakePasswordEmptyString(t *testing.T) { + t.Parallel() + for _, flag := range []string{"name", "locator", "database", "user"} { + t.Run(flag, func(t *testing.T) { + t.Parallel() + args := append(snowflakePasswordArgs(), "--"+flag, "") + _, err := runSnowflakePassword(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) || !strings.Contains(err.Error(), "--"+flag) { + t.Errorf("err=%v", err) + } + }) + } +} + +func TestCreateSnowflakePasswordRenderWriteErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": 1.0, "name": "sf1", "connectorType": "SNOWFLAKE"} + return nil + }, + } + g := newCreateGroup(f.deps()) + err := g.Run(context.Background(), snowflakePasswordArgs(), testcli.FailingIO()) + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Errorf("err=%v want boom", err) + } +} + +func TestCreateSnowflakePasswordBadFlag(t *testing.T) { + t.Parallel() + args := []string{"snowflake", "password", "--nope"} + _, err := runSnowflakePassword(t, (&fakeDeps{}).deps(), args, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakePasswordUnaryErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{unaryFn: func(_ context.Context, _ string, _, _ any) error { return errors.New("boom") }} + _, err := runSnowflakePassword(t, f.deps(), snowflakePasswordArgs(), "") + if err == nil || !strings.Contains(err.Error(), "boom") { + t.Errorf("err=%v", err) + } +} + +func TestCreateSnowflakePasswordRemarshalErr(t *testing.T) { + t.Parallel() + f := &fakeDeps{ + unaryFn: func(_ context.Context, _ string, _, resp any) error { + out := resp.(*map[string]any) + *out = map[string]any{"connectorId": "not-an-int"} + return nil + }, + } + _, err := runSnowflakePassword(t, f.deps(), snowflakePasswordArgs(), "") + if err == nil || !strings.Contains(err.Error(), "decode response") { + t.Errorf("err=%v", err) + } +} + +func TestCreateGroupUnknownSnowflakeAuthMode(t *testing.T) { + t.Parallel() + _, err := runSnowflakePassword(t, (&fakeDeps{}).deps(), []string{"snowflake", "certificate"}, "") + if !errors.Is(err, cli.ErrUsage) { + t.Errorf("err=%v", err) + } +} diff --git a/internal/connector/types.go b/internal/connector/types.go index e886286..07a0420 100644 --- a/internal/connector/types.go +++ b/internal/connector/types.go @@ -2,8 +2,8 @@ package connector // Shared wire types for the Connect-RPC Connector service. Field names follow // the captured API shapes in `api-catalog/`; anything else is rejected -// server-side. Kept in one file so per-dialect files only need to add their -// own `Spec` struct and point configEnvelope at it. +// server-side. Per-dialect spec structs live in `types_.go` so new +// dialects only need to add their own file and point configEnvelope at it. // createReq mirrors the exact wire shape captured in the API catalog. type createReq struct { @@ -17,24 +17,17 @@ type updateReq struct { Config configEnvelope `json:"config"` } -// configEnvelope is shared by create + update. The Postgres pointer is a -// pointer (not a value) so update can omit the block when no postgres flags -// were set (partial-update case). +// configEnvelope is shared by create + update. Dialect pointers (not values) +// so update can omit the block when no dialect flags were set (partial-update +// case). AuthStrategy sits at envelope level per the captured wire shape +// (`config.authStrategy`, not nested under a dialect sub-object); it's empty +// for Postgres, populated for Snowflake/Databricks. type configEnvelope struct { - ConnectorType string `json:"connectorType,omitempty"` - Name string `json:"name,omitempty"` - Postgres *postgresSpec `json:"postgres,omitempty"` -} - -// postgresSpec matches the oneof leaf for the POSTGRES dialect. Port is an int -// per the catalog; sslMode is a boolean named `sslMode` (not `ssl`). -type postgresSpec struct { - Host string `json:"host,omitempty"` - Port int `json:"port,omitempty"` - User string `json:"user,omitempty"` - Password string `json:"password,omitempty"` - Database string `json:"database,omitempty"` - SSLMode bool `json:"sslMode,omitempty"` + ConnectorType string `json:"connectorType,omitempty"` + Name string `json:"name,omitempty"` + AuthStrategy string `json:"authStrategy,omitempty"` + Postgres *postgresSpec `json:"postgres,omitempty"` + Snowflake *snowflakeSpec `json:"snowflake,omitempty"` } // createResp is the `{connectorId, name, connectorType}` captured response. diff --git a/internal/connector/types_postgres.go b/internal/connector/types_postgres.go new file mode 100644 index 0000000..aa444bf --- /dev/null +++ b/internal/connector/types_postgres.go @@ -0,0 +1,12 @@ +package connector + +// postgresSpec matches the oneof leaf for the POSTGRES dialect. Port is an int +// per the catalog; sslMode is a boolean named `sslMode` (not `ssl`). +type postgresSpec struct { + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + User string `json:"user,omitempty"` + Password string `json:"password,omitempty"` + Database string `json:"database,omitempty"` + SSLMode bool `json:"sslMode,omitempty"` +} diff --git a/internal/connector/types_snowflake.go b/internal/connector/types_snowflake.go new file mode 100644 index 0000000..537213c --- /dev/null +++ b/internal/connector/types_snowflake.go @@ -0,0 +1,21 @@ +package connector + +// snowflakeSpec matches the oneof leaf for the SNOWFLAKE dialect. One struct +// covers all four auth modes (password, key-pair, oauth-sso, oauth-individual) +// — the server discriminates by which credential field is populated, paired +// with `configEnvelope.AuthStrategy`. Every field is omitempty so each leaf +// only emits the fields its mode actually uses. `locator` is TextQL's wire +// name for what Snowflake's own docs call `account`. +type snowflakeSpec struct { + Locator string `json:"locator,omitempty"` + Database string `json:"database,omitempty"` + Warehouse string `json:"warehouse,omitempty"` + Schema string `json:"schema,omitempty"` + Role string `json:"role,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + PrivateKey string `json:"privateKey,omitempty"` + PrivateKeyPassphrase string `json:"privateKeyPassphrase,omitempty"` + OAuthClientID string `json:"oauthClientId,omitempty"` + OAuthClientSecret string `json:"oauthClientSecret,omitempty"` +} diff --git a/internal/connector/update.go b/internal/connector/update.go index f2dfd9d..b573584 100644 --- a/internal/connector/update.go +++ b/internal/connector/update.go @@ -97,7 +97,7 @@ func (c *updateCmd) Run(ctx context.Context, args []string, stdio cli.IO) error // user didn't touch --password{,-stdin}, leave pg.Password empty and the // server keeps the existing secret. Otherwise resolve and overlay. if cli.FlagWasSet(fs, "password") || cli.FlagWasSet(fs, "password-stdin") { - resolved, err := resolvePassword(*pass, *passStdin, stdio.Stdin) + resolved, err := resolveSecret("password", *pass, *passStdin, stdio.Stdin) if err != nil { return fmt.Errorf("connector update: %w", err) }