diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f1a50902..0e2b4a18 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.29.0", + "version": "0.30.0", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/CLAUDE.md b/CLAUDE.md index 90ca492e..1c07975c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -264,6 +264,7 @@ kbagent project description-get --project NAME kbagent project description-set --project NAME [--text STR | --file PATH | --stdin] kbagent project use ALIAS kbagent project current +kbagent project info --project NAME kbagent project invite --project ALIAS --email EMAIL --role admin|guest|readOnly|share [--reason TEXT] [--dry-run] kbagent project invite --from-csv FILE [--default-role ROLE] [--workers N] [--dry-run] kbagent project member-list --project ALIAS [--include-pending] @@ -286,6 +287,12 @@ kbagent config get-metadata --project NAME --component-id ID --config-id ID --ke kbagent config set-metadata --project NAME --component-id ID --config-id ID --key KEY --value VALUE [--branch ID] kbagent config delete-metadata --project NAME --component-id ID --config-id ID --metadata-id ID [--branch ID] [--yes] kbagent config set-folder --project NAME --component-id ID --config-id ID --name FOLDER [--branch ID] +kbagent config row-create --project NAME --component-id ID --config-id ID --name ROW_NAME [--description D] [--configuration JSON|@file|-] [--is-disabled] [--branch ID] +kbagent config row-update --project NAME --component-id ID --config-id ID --row-id ID [--name N] [--description D] [--configuration JSON|@file|-] [--is-disabled | --is-enabled] [--branch ID] +kbagent config row-delete --project NAME --component-id ID --config-id ID --row-id ID [--branch ID] [--yes] +kbagent config oauth-url --project NAME --component-id ID --config-id ID [--redirect-url URL] + +kbagent search QUERY [--project NAME] [--type table|bucket|config|flow|data-app|transformation] [--search-type textual|config-based] [--limit N] kbagent job list [--project NAME] [--component-id ID] [--status STATUS] [--limit N] kbagent job detail --project NAME --job-id ID diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index fde66363..f693a46e 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.29.0", + "version": "0.30.0", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index cd41b595..2120ed92 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -72,6 +72,8 @@ a critical failure. `project invite` / `project member-*` / `project invitation-*` need 0.29.0+, `data-app secrets-* / validate-repo` need 0.29.0+, + `search`, `project info`, `config row-create`, `config row-update`, + `config row-delete`, `config oauth-url` need 0.30.0+, `storage retype` is a future composite), you MUST refuse the task and return a handoff message to the parent: `"Cannot proceed safely on kbagent . Missing: . @@ -97,6 +99,8 @@ a critical failure. | Create Snowflake transformation | `kbagent config new --component-id keboola.snowflake-transformation` + `config update --set ...` | `tool call create_sql_transformation` (lower schema, avoids the component refusal) | `tool call create_config` (refuses keboola.snowflake-transformation) | | Update SQL transformation body (script[]) | `kbagent config update --project P --component-id keboola.snowflake-transformation --config-id K --configuration @body.json` (0.28.0+ auto-normalizes string `script` to array; SQL gets statement-level split, Python/R gets `[script]` wrap; envelope's `normalizations: [...]` records every change) | `kbagent --hint client config update ...` if you need to bypass the auto-normalize for some reason | `tool call update_sql_transformation` -- still vulnerable to the #245 string-vs-array runtime crash because it pushes raw to Storage API; raw `PUT /v2/storage/components/.../configs/...` -- same trap | | Run a job (and wait) | `kbagent job run --project P --component-id C --config-id K --wait` | `tool call run_component` | `job run` without `--wait` when user expects the result | +| Search items by name across projects | `kbagent search QUERY [--project P] [--type table\|bucket\|config\|flow] [--limit N]` (0.30.0+) | `tool call search_tables` / `tool call search_configurations` (one resource-type per call) | chaining multiple `tool call` for different types | +| Search config JSON bodies | `kbagent search QUERY --search-type config-based [--project P]` (0.30.0+) | `kbagent config search --query Q` (config-body only, no tables/buckets) | repeated `tool call get_config` to grep locally | | Browse configs (exploration) | `kbagent config list` / `kbagent config search --query Q` | `tool call list_configs` | full-project pull via MCP just to grep locally | | Fetch a specific config | `kbagent config detail --project P --component-id C --config-id K --json` | `tool call get_config` | re-using an earlier JSON dump | | Override the auto-derived output bucket on a config | `kbagent config set-default-bucket --bucket in.c-name` (0.26.0+) -- read-modify-write of `storage.output.default_bucket`, preserves siblings; `--clear` removes it | `kbagent config update --set 'storage.output.default_bucket=in.c-name'` (works pre-0.26.0 but not discoverable) | editing the raw JSON in the UI; full-config replace with `--configuration` (wipes other storage keys) | @@ -107,6 +111,11 @@ a critical failure. | Debug a failed job | `kbagent job detail --project P --job-id J --json` + `kbagent job run ... --log-tail-lines 200` | `kbagent workspace from-transformation` for SQL repro | "I think the issue is..." without reading logs | | Ad-hoc SQL / row-count / type audit | `kbagent workspace create` + `kbagent workspace load` + `kbagent workspace query --sql "..."` | `kbagent workspace from-transformation` for existing transform debugging | querying Keboola Storage directly via Snowflake credentials outside the workspace abstraction | | Inspect dev branch | `kbagent branch list --project P`, `kbagent branch use --project P --branch ID` | `tool call get_branch` | acting on `main` when a dev branch exists | +| Audit project capabilities / features | `kbagent project info --project P` (0.30.0+) -- returns project ID, name, backend, enabled features, quota limits, and metrics | `tool call verify_token` (returns less structured info; no feature list) | inspecting the UI project settings manually | +| Create a config row | `kbagent config row-create --project P --component-id C --config-id K --name NAME` (0.30.0+) | `tool call create_config_row` | `POST /v2/storage/components/C/configs/K/rows` (raw REST) | +| Update a config row | `kbagent config row-update --project P --component-id C --config-id K --row-id R [--name N] [--configuration JSON]` (0.30.0+) | `tool call update_config_row` | `PUT /v2/storage/components/C/configs/K/rows/R` (raw REST) | +| Delete a config row | `kbagent config row-delete --project P --component-id C --config-id K --row-id R [--yes]` (0.30.0+) -- destructive (gated behind `--allow-destructive`); branch-aware | `tool call delete_config_row` | `DELETE /v2/storage/components/C/configs/K/rows/R` (raw REST) | +| Get OAuth authorization URL | `kbagent config oauth-url --project P --component-id C --config-id K` (0.30.0+) -- returns URL to open in browser to complete OAuth flow | -- | raw `GET /v2/storage/components/C/configs/K/oauth/authorize` | | Inventory data apps | `kbagent data-app list --project P` (0.27.0+) | `tool call get_configs --component_id keboola.data-apps` (Storage view only -- no state, no URL, no configVersion) | iterating `tool call` per project to reconstruct the join with the Data Science index | | Bring a new data app online from a git repo | `kbagent data-app create --project P --name N --slug S --git-repo URL [--git-pat-env VAR \| --git-public]` (0.27.0+) | broken into `tool call create_config keboola.data-apps` + manual `kbagent encrypt values` + raw `POST /apps` -- ONLY if you need a custom shape kbagent doesn't support | raw `POST data-science/apps` followed by `PATCH desiredState=running` without `configVersion + restartIfRunning` (the §9 footgun -- pins to v2 empty shell, runner errors `dataApp.git.repository is required in /data/config.json`) | | Roll out a new code or config version on a data app | `kbagent data-app deploy --project P --app-id N --wait` (0.27.0+) -- always sends the §9 trio | `kbagent --hint client data-app deploy ...` to inspect the generated `patch_app(desired_state=, config_version=, restart_if_running=True)` call | `tool call update_config` then `tool call run_component` (data apps are not jobs -- the queue runner does not deploy them) | diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index f4a73f40..651f7e36 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -85,6 +85,7 @@ When working inside a git repository or project directory, run `kbagent init` (o |------|---------| | Update kbagent to the latest version | `kbagent update` | | Show recent changelog (what changed in each version) | `kbagent changelog` | +| Search for items (tables, buckets, configs, flows, …) by name or content | `kbagent search ` | | List all operations with their risk category and current allowed/denied status | `kbagent permissions list` | | Show the current active permission policy | `kbagent permissions show` | | Set the permission policy (firewall rules) | `kbagent permissions set --mode MODE` | @@ -100,6 +101,7 @@ When working inside a git repository or project directory, run `kbagent init` (o | Show the effective default project | `kbagent project current` | | Get the Keboola dashboard project description | `kbagent project description-get --project PROJECT` | | Set the Keboola dashboard project description (markdown) | `kbagent project description-set --project PROJECT` | +| Show detailed project metadata | `kbagent project info --project PROJECT` | | Invite a user (or many users via CSV) to one or more projects | `kbagent project invite` | | List active members of a project (and optionally pending invitations) | `kbagent project member-list --project PROJECT` | | List pending project invitations | `kbagent project invitation-list --project PROJECT` | @@ -125,6 +127,10 @@ When working inside a git repository or project directory, run `kbagent init` (o | Assign variables to a config (auto-creates backing keboola.variables on first call) | `kbagent config variables-set --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | | Read the current variable values attached to a config | `kbagent config variables-get --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | | Unlink variables from a config (does NOT delete the underlying keboola.variables) | `kbagent config variables-clear --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | +| Create a new configuration row | `kbagent config row-create --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID --name NAME` | +| Update an existing configuration row | `kbagent config row-update --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID --row-id ROW-ID` | +| Delete a configuration row | `kbagent config row-delete --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID --row-id ROW-ID` | +| Requires master token. | `kbagent config oauth-url --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | | List data apps across one or more registered projects | `kbagent data-app list` | | Show merged Data Science + Storage detail for one data app | `kbagent data-app detail --project PROJECT --app-id APP-ID` | | Create a Keboola data app end-to-end (POST + encrypt + PUT + deploy) | `kbagent data-app create --project PROJECT --name NAME --slug SLUG --git-repo GIT-REPO` | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 91ad739a..1a25f306 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -64,6 +64,13 @@ All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the - `config set-metadata --project NAME --component-id ID --config-id ID --key KEY --value VALUE [--branch ID]` -- set (upsert) a metadata key/value on a configuration. Common keys: `KBC.configuration.folderName`, plus any custom `KBC.*` agent-facing tags - `config delete-metadata --project NAME --component-id ID --config-id ID --metadata-id ID [--branch ID] [--yes]` -- delete a configuration metadata entry by its numeric ID (from `metadata-list`) - `config set-folder --project NAME --component-id ID --config-id ID --name FOLDER [--branch ID]` -- set (or clear, with empty `--name`) the `KBC.configuration.folderName` metadata, which groups configs into named folders in the Keboola UI. See `config-metadata-workflow.md` +- `config row-create --project NAME --component-id ID --config-id ID --name ROW_NAME [--description D] [--configuration JSON|@file|-] [--is-disabled] [--branch ID]` -- create a new configuration row. Returns the full row dict with `id`, `name`, `version`. Optional `--configuration` accepts JSON inline, `@file`, or stdin (`-`). +- `config row-update --project NAME --component-id ID --config-id ID --row-id ID [--name N] [--description D] [--configuration JSON|@file|-] [--is-disabled | --is-enabled] [--branch ID]` -- update an existing configuration row. Pass only the fields you want to change; omitted fields are preserved. `--is-disabled` / `--is-enabled` toggle the row's enabled state. +- `config row-delete --project NAME --component-id ID --config-id ID --row-id ID [--branch ID] [--yes]` -- delete a configuration row. Destructive (gated behind `--allow-destructive`). Branch-aware. Without `--yes` and outside `--json` mode, prompts for interactive confirmation; `--json` mode auto-skips the prompt. +- `config oauth-url --project NAME --component-id ID --config-id ID [--redirect-url URL]` -- return the OAuth authorization URL for a component that uses OAuth authentication. **Requires a master Storage API token** (canManageTokens privilege) -- non-master tokens fail with `MISSING_MASTER_TOKEN` exit 3 on a fail-fast pre-flight check before any HTTP write happens. Open the URL in a browser to complete the OAuth flow. + +## Cross-Project Search +- `search QUERY [--project NAME] [--type table|bucket|config|flow|data-app|transformation] [--search-type textual|config-based] [--limit N]` -- search for items across one or more projects. **Textual** mode (default, fast) matches item names via the Storage API `global-search` endpoint. **Config-based** mode scans full configuration JSON bodies (slow, complete). `--type` is repeatable; `--limit` applies per project in textual mode (1-100, default 50). `--project` is repeatable for multi-project scope. ## Job History - `job list [--project NAME] [--component-id ID] [--config-id ID] [--status STATUS] [--limit N]` -- list jobs (default 50, max 500) diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 529fce82..e5cd4ba0 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -1170,3 +1170,37 @@ The trade-off is deliberate: one big call avoids the O(unique-parents) round-tri - To inspect or remove schedules: `kbagent flow schedule-remove` deletes all scheduler configs that target the flow. Pair it with `--dry-run` to see the affected configs (cron + timezone) without calling `delete_config`. + +## `search` is a top-level command, not `config search` (since v0.30.0) + +`kbagent search QUERY` searches across **all item types** (tables, buckets, configs, flows, data apps, transformations) via the Storage API global-search endpoint. It is distinct from `kbagent config search --query Q` which scans only configuration JSON bodies. +- `search --search-type config-based` delegates to `config search` internally but exposes the unified results shape. +- Options (`--type`, `--project`, `--limit`) must come AFTER the QUERY argument: `kbagent search "text" --type table --limit 10`. + +## `config row-create` / `row-update` / `row-delete` lifecycle (since v0.30.0) + +Full CRUD for configuration rows is exposed as a separate `Rows` command panel: +- `row-create` returns the new row object including `id`. Capture this ID for subsequent `row-update` / `row-delete` calls. +- `row-update` preserves all unspecified fields — pass only the keys you want to change. `--is-disabled` and `--is-enabled` are mutually exclusive flags for toggling the row's active state. +- `row-delete` is **destructive** (gated behind `--allow-destructive` if the session firewall is on). 404 from the API on a non-existent row surfaces as `NOT_FOUND` exit 1 — deletion is **not** treated as idempotent success. +- `--json` mode auto-skips the interactive confirmation prompt on `row-delete`; in human mode pass `--yes` to skip. + +## `config oauth-url` requires a master Storage API token (since v0.30.0) + +The OAuth wizard URL embeds a short-lived **child** Storage API token scoped +to the target component. Minting this child token via `POST /v2/storage/tokens` +requires `canManageTokens` privilege, which only **master tokens** carry. + +- Pre-flight: `kbagent` calls `verify_token` first and refuses with + `MISSING_MASTER_TOKEN` (exit 3) before any HTTP write happens. Without this + guard the Storage API returns a vague 500 "Application error" that misleads + operators into thinking the OAuth wizard is broken. +- Fix path: re-add the project with a master token + (`kbagent project edit --project --token `) or open + the OAuth flow via the Keboola UI instead. +- AI agents creating the project token via `kbagent project add` / + `kbagent project refresh` get a non-master token by default — they must + switch to a master token before calling `config oauth-url`. See + https://github.com/padak/keboola_agent_cli/issues/ for the upstream + request to make `project add` / `project refresh` mint a token with + `canManageTokens` so OAuth flows work out of the box. diff --git a/pyproject.toml b/pyproject.toml index 4952d430..c1d2407c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.29.0" +version = "0.30.0" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index b1dc6d09..e59f19c0 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,18 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.30.0": [ + "New: `kbagent search QUERY` -- top-level cross-project item search. Two modes: **textual** (default) calls the Storage API `GET /v2/storage/global-search` endpoint (name-based, fast, parallel multi-project fan-out via `BaseService._run_parallel()` with per-project error accumulation; one project failing does NOT stop others); **config-based** (`--search-type config-based`) delegates to the existing `ConfigService.search_configs()` for full JSON-body scanning. `--type` is repeatable and accepts `table`, `bucket`, `config`, `flow`, `data-app`, `transformation`; the user-facing names map to the API's `types[]` parameter (config/data-app both translate to `configuration`, with the data-app variant post-filtered to `component_id == 'keboola.data-apps'` so `--type data-app` no longer returns ALL configurations). `--limit` applies per project in textual mode (1-100, default 50). `--project` is repeatable for narrow scope; omitted means all configured projects. Pre-flight `has_feature('global-search')` check returns a clear per-project error rather than a raw 404 on stacks where the feature flag is off. Closes the `search_tables` / `search_configurations` MCP-parity gap from #244.", + "New: `kbagent project info --project NAME` -- returns full project metadata in a single call: project ID, project name, stack URL, default backend, the complete `features` list (used by AI agents to gate behavior, e.g. `storage-branches`, `global-search`, `queuev2`), quota limits, and usage metrics. Backed by `KeboolaClient.get_project_info()` which returns the raw `GET /v2/storage/tokens/verify` payload. Distinct from `project status` (connectivity ping) and `project list` (multi-project summary) -- `info` is the canonical single-project audit command. Hint definitions for both `--hint client` (`KeboolaClient.get_project_info()`) and `--hint service` (`ProjectService.get_info()`).", + "New: `kbagent config row-create --project P --component-id C --config-id K --name NAME [--description D] [--configuration JSON|@file|-] [--is-disabled] [--branch ID]`, `kbagent config row-update --project P --component-id C --config-id K --row-id R [--name N] [--description D] [--configuration JSON|@file|-] [--set PATH=VALUE ...] [--merge] [--is-disabled | --is-enabled] [--dry-run] [--branch ID]`, and `kbagent config row-delete --project P --component-id C --config-id K --row-id R [--branch ID] [--yes]` -- first-class lifecycle for configuration rows. `row-create` returns the full row dict with the API-assigned `id`; capture it for subsequent updates. `row-update` preserves all unspecified fields and accepts the same `--set` / `--merge` / `--dry-run` semantics as `config update`. `--is-disabled` / `--is-enabled` are mutually exclusive on `row-update` and toggle the row's active state (Storage API `isDisabled` field). `row-delete` is destructive (permission level `destructive`, gated behind `--allow-destructive` if the session firewall is on); 404 surfaces as `NOT_FOUND` exit 1 -- deletion is NOT treated as idempotent success. Branch-aware. Closes the `create_config_row` / `update_config_row` / `delete_config_row` MCP-parity gap.", + "New: `kbagent config oauth-url --project P --component-id C --config-id K [--redirect-url URL]` -- generate the OAuth authorization URL for a component that uses OAuth. Mints a short-lived component-scoped Storage API token (1h expiry) via `create_short_lived_token()` and constructs the `https://external.keboola.com/oauth/index.html` URL with `token` + `sapiUrl` query params and `/component_id/config_id` URL fragment. Optional `--redirect-url` adds a `returnUrl` query param so the OAuth wizard redirects the user back to a custom URL after the flow completes. **Requires a master Storage API token** (`canManageTokens` privilege) -- non-master tokens fail-fast with `MISSING_MASTER_TOKEN` exit 3 on a pre-flight check before any HTTP write happens. The OAuth host is now exposed as `OAUTH_HOST` constant in `constants.py` (per Keboola hosting convention `external.keboola.com` is the wizard host across all stacks; the per-stack difference is reflected in the `sapiUrl` parameter, not the wizard host). Tracking issue #260 covers making `project add` / `project refresh` mint master tokens by default so OAuth flows work out of the box.", + 'Architecture: `kbagent search` is registered as a top-level `app.command("search")` rather than a sub-app via `app.add_typer()`. This avoids a Click-Group quirk where `allow_interspersed_args=False` (the default for groups) treats anything after the QUERY positional argument as a subcommand name and rejects `kbagent search test --type table` with exit code 2. The leaf-command registration parses options after positional args correctly. The other documented kbagent commands that use `app.add_typer()` (config, storage, job, ...) are unaffected because their callbacks have no positional args.', + "UX: `kbagent config --help` now groups its 20 subcommands into 7 Rich help panels via `rich_help_panel`: Browse (list, detail, search), Lifecycle (update, rename, delete, new), Storage (set-default-bucket), Metadata (metadata-list, get-metadata, set-metadata, delete-metadata, set-folder), Variables (variables-set / -get / -clear), Rows (row-create, row-update, row-delete), OAuth (oauth-url). `oauth-url`'s short help line leads with 'Requires master token.' so AI agents see the prerequisite at a glance.", + "Permissions: new entries in `OPERATION_REGISTRY` -- `search` (read), `config.row-create` (write), `config.row-update` (write), `config.row-delete` (destructive), `config.oauth-url` (read), `project.info` (read). The `--deny-writes` and `--deny-destructive` session firewalls now correctly recognize these commands. Coverage enforced by `tests/test_permissions.py::test_all_subapp_commands_registered` so future drift will fail CI.", + "New: `ErrorCode.MISSING_MASTER_TOKEN` (`src/keboola_agent_cli/errors.py`) maps to authentication category and exit code 3 (mirrors `INVALID_TOKEN` semantics). Used exclusively by `config oauth-url` pre-flight today; reserved for future commands that mint child tokens.", + "Tests: ~80 new tests across 4 new test files (`tests/test_search_cli.py`, `tests/test_search_service.py`, `tests/test_config_row_cli.py`, `tests/test_config_row_service.py`, `tests/test_project_info_cli.py`, `tests/test_project_info_service.py`) plus extensions to `tests/test_hints.py` (5 new hint short-circuit tests). All three layers covered (client / service / CLI), per-project error accumulation in textual search verified against multi-project fixtures, master-token pre-flight in oauth-url verified for both branches, data-app post-filter and feature-gate verified, and `should_hint` short-circuit verified for every new command. E2E coverage: live-validated against `kbagent-e2e` and `padak` projects -- 12 of 12 row-create / row-update variants (incl. `--set`, `--merge`, `--dry-run`, `--configuration` with inline / @file / stdin, `--is-disabled` / `--is-enabled`, validation), `row-delete` happy path + 404 on re-delete, `oauth-url` with master token (URL generated with embedded short-lived child token).", + "Plugin: `keboola-expert.md` tool selection matrix gains 8 rows for the new commands (search-name, search-config-bodies, project info, row-create, row-update, row-delete, oauth-url, config-help groups); VERSION GATE Rule 6 lists `search`, `project info`, `config row-create / row-update / row-delete / oauth-url` as `0.30.0+`. New `(since v0.30.0)` `gotchas.md` entries: `search` is a top-level command (not `config search`); options must follow the QUERY argument; row CRUD lifecycle (capture id from row-create, --is-disabled/--is-enabled mutex on row-update, row-delete is destructive); `config oauth-url` requires a master token. `commands-reference.md`, `context.py` AGENT_CONTEXT, and `CLAUDE.md` `## All CLI Commands` all updated. `SKILL.md` decision table auto-regenerates via the pre-commit hook (CI-checked).", + ], "0.29.0": [ "BREAKING: `KBC_MANAGE_API_TOKEN` is now ignored by default. The three commands that consume it (`org setup`, `project refresh`, `data-app password`) prompt for the token on a TTY by default. Pass the new top-level flag `--allow-env-manage-token` to restore the legacy env-var behaviour (e.g. for CI/CD). Without the flag and without a TTY, the resolver exits 2 with an actionable message naming the flag. The change closes the AI-exfiltration risk where any subprocess running as the same user (including the AI agent itself) inherits the manage token via env. Migration: prepend `--allow-env-manage-token` to existing CI invocations. Storage tokens (`KBC_TOKEN`) are unaffected. Closes the manage-token UX flagged on #236; supersedes the per-stack design discussed in #238.", "Security: `resolve_manage_token` (`src/keboola_agent_cli/commands/_helpers.py`) refactored to default-deny env, TTY-first. When the env var is set but the flag is not passed, a one-shot stderr warning fires (`Warning: KBC_MANAGE_API_TOKEN found in environment but ignored. Pass --allow-env-manage-token to opt in.`) and the resolver falls through to the TTY prompt. No cache, no keyring, no temp file -- next invocation prompts again. The bulk-prompt-once contract (`project refresh --all`) is preserved by construction: the resolver lives at command entry, before any per-project loop.", diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index 8ff13a9e..c0666ab2 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -24,6 +24,7 @@ from .commands.project import project_app from .commands.repl import repl_command from .commands.schedule import schedule_app +from .commands.search import search_command from .commands.sharing import sharing_app from .commands.storage import storage_app from .commands.sync import sync_app @@ -53,6 +54,7 @@ from .services.project_service import ProjectService from .services.repo_validate_service import RepoValidateService from .services.schedule_service import ScheduleService +from .services.search_service import SearchService from .services.sharing_service import SharingService from .services.storage_service import StorageService from .services.sync_service import SyncService @@ -86,6 +88,12 @@ _BROWSE = "Browse & Inspect" app.add_typer(component_app, name="component", rich_help_panel=_BROWSE) app.add_typer(config_app, name="config", rich_help_panel=_BROWSE) +app.command( + "search", + rich_help_panel=_BROWSE, + help="Search for items (tables, buckets, configs, flows, …) by name or content.", + no_args_is_help=True, +)(search_command) app.add_typer(data_app_app, name="data-app", rich_help_panel=_BROWSE) app.add_typer(job_app, name="job", rich_help_panel=_BROWSE) app.add_typer(storage_app, name="storage", rich_help_panel=_BROWSE) @@ -308,6 +316,7 @@ def main( mcp_service = McpService(config_store=config_store) branch_service = BranchService(config_store=config_store) sharing_service = SharingService(config_store=config_store) + search_service = SearchService(config_store=config_store) storage_service = StorageService(config_store=config_store) sync_service = SyncService(config_store=config_store) variables_service = VariablesService(config_store=config_store) @@ -364,6 +373,7 @@ def main( ctx.obj["mcp_service"] = mcp_service ctx.obj["branch_service"] = branch_service ctx.obj["sharing_service"] = sharing_service + ctx.obj["search_service"] = search_service ctx.obj["storage_service"] = storage_service ctx.obj["sync_service"] = sync_service ctx.obj["variables_service"] = variables_service diff --git a/src/keboola_agent_cli/client.py b/src/keboola_agent_cli/client.py index 3d96ca44..59eb7eb4 100644 --- a/src/keboola_agent_cli/client.py +++ b/src/keboola_agent_cli/client.py @@ -31,6 +31,8 @@ IMPORT_JOB_MAX_WAIT, JOB_POLL_CURVE, METADATA_NOT_FOUND, + OAUTH_HOST, + OAUTH_PATH, QUERY_JOB_MAX_WAIT, QUERY_JOB_POLL_INTERVAL, STORAGE_JOB_MAX_WAIT, @@ -233,6 +235,140 @@ def verify_token(self) -> TokenVerifyResponse: self._features_cache = frozenset(response.features) return response + def get_project_info(self) -> dict[str, Any]: + """Return full project/token info from /v2/storage/tokens/verify. + + Unlike verify_token() which parses only a subset of fields into + TokenVerifyResponse, this method returns the complete raw API response + so callers can access all fields (features, limits, metrics, etc.). + + Returns: + Full JSON response dict from /v2/storage/tokens/verify. + + Raises: + KeboolaApiError: If token is invalid (401) or other API error. + """ + response = self._request("GET", "/v2/storage/tokens/verify") + return response.json() + + def create_short_lived_token( + self, + description: str, + component_access: list[str], + expires_in: int = 3600, + ) -> dict[str, Any]: + """Create a short-lived Storage API token restricted to a component. + + POST /v2/storage/tokens + + Args: + description: Human-readable token description. + component_access: List of component IDs this token may access. + expires_in: Token lifetime in seconds (default: 3600 = 1 hour). + + Returns: + Token dict from the API, including the 'token' field. + """ + response = self._request( + "POST", + "/v2/storage/tokens", + data={ + "description": description, + "expiresIn": str(expires_in), + "componentAccess[]": component_access, + }, + ) + return response.json() + + def global_search( + self, + query: str, + project_id: int, + types: list[str] | None = None, + branch_type: str = "production", + branch_id: int | None = None, + limit: int = 50, + offset: int = 0, + ) -> dict[str, Any]: + """Search for items by name across the project using the Storage API global-search endpoint. + + Calls GET /v2/storage/global-search with the given query and optional type filters. + This performs textual (name-based) search only — it does not scan configuration bodies. + Results are scoped to the single project identified by ``project_id``. + + Args: + query: Search string to match against item names. + project_id: Numeric Keboola project ID (required by the API). + types: Optional list of item types to filter results. Supported values: + ``bucket``, ``table``, ``flow``, ``transformation``, ``configuration``, + ``configuration-row``, ``workspace``, ``shared-code``. + If None or empty, all types are returned. + branch_type: ``"production"`` (default) or ``"development"``. + branch_id: Required when ``branch_type="development"``; ignored otherwise. + limit: Maximum number of results to return (default 50, max 100). + offset: Pagination offset (default 0). + + Returns: + Raw API response dict with keys ``"all"`` (total count) and + ``"items"`` (list of matching item dicts). + + Raises: + KeboolaApiError: On API errors (auth, network, rate limits). + """ + params: dict[str, Any] = { + "query": query, + "projectIds[]": project_id, + "limit": limit, + "offset": offset, + } + if types: + params["types[]"] = types + if branch_type == "development" and branch_id is not None: + params["branchTypes[]"] = "development" + params["branchIds[]"] = branch_id + else: + params["branchTypes[]"] = "production" + + response = self._request("GET", "/v2/storage/global-search", params=params) + return response.json() + + def get_oauth_url( + self, + component_id: str, + config_id: str, + redirect_url: str | None = None, + ) -> str: + """Generate an OAuth authorization URL for a component configuration. + + Creates a short-lived, component-scoped Storage API token and builds + the URL the user must open to grant OAuth access. + + Args: + component_id: The component ID (e.g. 'keboola.ex-google-drive'). + config_id: The configuration ID to authorize. + redirect_url: Optional URL the OAuth wizard returns to after the + flow completes (passed as the ``returnUrl`` query param). + + Returns: + The full OAuth authorization URL as a string. + """ + from urllib.parse import urlencode, urlunsplit + + token_response = self.create_short_lived_token( + description=f"Short-lived token for OAuth URL - {component_id}/{config_id}", + component_access=[component_id], + expires_in=3600, + ) + sapi_token = token_response["token"] + + query: dict[str, str] = {"token": sapi_token, "sapiUrl": self._stack_url} + if redirect_url: + query["returnUrl"] = redirect_url + query_params = urlencode(query) + fragment = f"/{component_id}/{config_id}" + + return urlunsplit(("https", OAUTH_HOST, OAUTH_PATH, query_params, fragment)) + def get_project_features(self) -> frozenset[str]: """Return the project's feature flags, fetching once per client lifetime. @@ -366,6 +502,31 @@ def list_config_rows( ) return resp.json() + def get_config_row( + self, + component_id: str, + config_id: str, + row_id: str, + branch_id: int | None = None, + ) -> dict[str, Any]: + """Get a single configuration row by ID. + + Args: + component_id: Component identifier. + config_id: Configuration ID. + row_id: Row ID. + branch_id: If set, target a specific dev branch. + + Returns: + Row detail dict from the API. + """ + prefix = f"/v2/storage/branch/{branch_id}" if branch_id else "/v2/storage" + resp = self._request( + "GET", + f"{prefix}/components/{quote(component_id)}/configs/{quote(config_id)}/rows/{quote(row_id)}", + ) + return resp.json() + def get_config_detail( self, component_id: str, @@ -604,24 +765,38 @@ def create_config_row( name: str, configuration: dict[str, Any], description: str = "", + is_disabled: bool = False, branch_id: int | None = None, ) -> dict[str, Any]: """Create a new configuration row. POST /v2/storage/[branch/{id}/]components/{comp_id}/configs/{config_id}/rows + Args: + component_id: The component ID. + config_id: The parent configuration ID. + name: Row name. + configuration: Row-level configuration dict. + description: Optional row description. + is_disabled: When True, the row is created in disabled state and + excluded from job runs until re-enabled. + branch_id: Optional dev branch ID. + Returns: Created row dict including the assigned 'id'. """ prefix = f"/v2/storage/branch/{branch_id}" if branch_id else "/v2/storage" + data: dict[str, Any] = { + "name": name, + "description": description, + "configuration": json.dumps(configuration), + } + if is_disabled: + data["isDisabled"] = "1" resp = self._request( "POST", f"{prefix}/components/{quote(component_id)}/configs/{quote(config_id)}/rows", - data={ - "name": name, - "description": description, - "configuration": json.dumps(configuration), - }, + data=data, ) return resp.json() @@ -633,12 +808,17 @@ def update_config_row( name: str | None = None, configuration: dict[str, Any] | None = None, description: str | None = None, + is_disabled: bool | None = None, change_description: str = "", branch_id: int | None = None, ) -> dict[str, Any]: """Update an existing configuration row. PUT /v2/storage/[branch/{id}/]components/{comp_id}/configs/{config_id}/rows/{row_id} + + Args: + is_disabled: When True, disable the row; when False, enable it; + when None, leave the current state unchanged. """ prefix = f"/v2/storage/branch/{branch_id}" if branch_id else "/v2/storage" data: dict[str, Any] = {} @@ -648,6 +828,8 @@ def update_config_row( data["description"] = description if configuration is not None: data["configuration"] = json.dumps(configuration) + if is_disabled is not None: + data["isDisabled"] = "1" if is_disabled else "0" if change_description: data["changeDescription"] = change_description resp = self._request( diff --git a/src/keboola_agent_cli/commands/_helpers.py b/src/keboola_agent_cli/commands/_helpers.py index 64cce61c..b4ef7b78 100644 --- a/src/keboola_agent_cli/commands/_helpers.py +++ b/src/keboola_agent_cli/commands/_helpers.py @@ -94,7 +94,7 @@ def map_error_to_exit_code(exc: KeboolaApiError) -> int: job; scripts can distinguish "we killed it" from "it failed on its own") - Everything else -> 1 (general error) """ - if exc.error_code == "INVALID_TOKEN": + if exc.error_code in ("INVALID_TOKEN", "MISSING_MASTER_TOKEN"): return 3 if exc.error_code in ( "TIMEOUT", diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py index 17e06a0c..f4dd1453 100644 --- a/src/keboola_agent_cli/commands/config.py +++ b/src/keboola_agent_cli/commands/config.py @@ -68,7 +68,7 @@ def _config_permission_check(ctx: typer.Context) -> None: check_cli_permission(ctx, "config") -@config_app.command("list") +@config_app.command("list", rich_help_panel="Browse") def config_list( ctx: typer.Context, project: list[str] | None = typer.Option( @@ -166,7 +166,7 @@ def config_list( emit_project_warnings(formatter, result) -@config_app.command("detail") +@config_app.command("detail", rich_help_panel="Browse") def config_detail( ctx: typer.Context, project: list[str] | None = typer.Option( @@ -400,7 +400,7 @@ def _format_config_detail_bulk( console.print() -@config_app.command("search") +@config_app.command("search", rich_help_panel="Browse") def config_search( ctx: typer.Context, query: str = typer.Option(..., "--query", "-q", help="Search string or regex pattern"), @@ -538,7 +538,7 @@ def _parse_set_value(raw: str) -> object: return raw -@config_app.command("update") +@config_app.command("update", rich_help_panel="Lifecycle") def config_update( ctx: typer.Context, project: str = typer.Option( @@ -757,7 +757,7 @@ def _emit_normalizations_warning(formatter: Any, normalizations: list[dict[str, ) -@config_app.command("set-default-bucket") +@config_app.command("set-default-bucket", rich_help_panel="Storage") def config_set_default_bucket( ctx: typer.Context, project: str = typer.Option( @@ -900,7 +900,7 @@ def config_set_default_bucket( formatter.success(f"Set default_bucket on {target} to '{bucket}'.") -@config_app.command("rename") +@config_app.command("rename", rich_help_panel="Lifecycle") def config_rename( ctx: typer.Context, project: str = typer.Option( @@ -1011,7 +1011,7 @@ def config_rename( ) -@config_app.command("delete") +@config_app.command("delete", rich_help_panel="Lifecycle") def config_delete( ctx: typer.Context, project: str = typer.Option( @@ -1091,7 +1091,7 @@ def config_delete( } -@config_app.command("new") +@config_app.command("new", rich_help_panel="Lifecycle") def config_new( ctx: typer.Context, component_id: str = typer.Option( @@ -1198,7 +1198,7 @@ def config_new( # ── Config metadata commands ─────────────────────────────────────────── -@config_app.command("metadata-list") +@config_app.command("metadata-list", rich_help_panel="Metadata") def config_metadata_list( ctx: typer.Context, project: str = typer.Option(..., "--project", help="Project alias"), @@ -1250,7 +1250,7 @@ def config_metadata_list( ) -@config_app.command("get-metadata") +@config_app.command("get-metadata", rich_help_panel="Metadata") def config_get_metadata( ctx: typer.Context, project: str = typer.Option(..., "--project", help="Project alias"), @@ -1297,7 +1297,7 @@ def config_get_metadata( formatter.output(result, lambda c, d: c.print(d["value"])) -@config_app.command("set-metadata") +@config_app.command("set-metadata", rich_help_panel="Metadata") def config_set_metadata( ctx: typer.Context, project: str = typer.Option(..., "--project", help="Project alias"), @@ -1346,7 +1346,7 @@ def config_set_metadata( ) -@config_app.command("delete-metadata") +@config_app.command("delete-metadata", rich_help_panel="Metadata") def config_delete_metadata( ctx: typer.Context, project: str = typer.Option(..., "--project", help="Project alias"), @@ -1402,7 +1402,7 @@ def config_delete_metadata( ) -@config_app.command("set-folder") +@config_app.command("set-folder", rich_help_panel="Metadata") def config_set_folder( ctx: typer.Context, project: str = typer.Option(..., "--project", help="Project alias"), @@ -1465,7 +1465,7 @@ def _parse_kv_var(raw: str) -> tuple[str, str]: return key, value -@config_app.command("variables-set") +@config_app.command("variables-set", rich_help_panel="Variables") def config_variables_set( ctx: typer.Context, project: str = typer.Option(..., "--project", help="Project alias"), @@ -1619,7 +1619,7 @@ def config_variables_set( _format_variables_set(formatter, result) -@config_app.command("variables-get") +@config_app.command("variables-get", rich_help_panel="Variables") def config_variables_get( ctx: typer.Context, project: str = typer.Option(..., "--project", help="Project alias"), @@ -1672,7 +1672,7 @@ def config_variables_get( _format_variables_get(formatter, result) -@config_app.command("variables-clear") +@config_app.command("variables-clear", rich_help_panel="Variables") def config_variables_clear( ctx: typer.Context, project: str = typer.Option(..., "--project", help="Project alias"), @@ -1841,3 +1841,512 @@ def _format_variables_dry_run(formatter: Any, result: dict) -> None: else: display = "" if k.startswith("#") else escape(str(current_v)) formatter.console.print(f" [dim]= {escape(k)} = {display}[/dim]") + + +# ── config row-create ────────────────────────────────────────────────────────── + + +@config_app.command("row-create", rich_help_panel="Rows") +def config_row_create( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias", + ), + component_id: str = typer.Option( + ..., + "--component-id", + help="Component ID (e.g. keboola.python-transformation-v2)", + ), + config_id: str = typer.Option( + ..., + "--config-id", + help="Configuration ID to add the row to", + ), + name: str = typer.Option( + ..., + "--name", + help="Row name", + ), + description: str = typer.Option( + "", + "--description", + help="Row description", + ), + configuration: str | None = typer.Option( + None, + "--configuration", + help="Row configuration JSON: inline, @file.json, or - for stdin", + ), + is_disabled: bool = typer.Option( + False, + "--is-disabled", + help="Create the row in disabled state (excluded from job runs)", + ), + branch: int | None = typer.Option( + None, + "--branch", + help="Create in a specific dev branch ID (defaults to active branch)", + ), +) -> None: + """Create a new configuration row. + + \b + Examples: + # Create a row with a name only (empty configuration) + kbagent config row-create --project P --component-id C --config-id ID --name "Row 1" + + # Create a row with configuration content + kbagent config row-create --project P --component-id C --config-id ID \\ + --name "Row 1" --configuration '{"parameters": {"table": "orders"}}' + + # Create from a JSON file + kbagent config row-create --project P --component-id C --config-id ID \\ + --name "Row 1" --configuration @row.json + + # Create a disabled row + kbagent config row-create --project P --component-id C --config-id ID \\ + --name "Row 1" --is-disabled + """ + if should_hint(ctx): + emit_hint( + ctx, + "config.row-create", + project=project, + component_id=component_id, + config_id=config_id, + name=name, + description=description, + configuration=configuration, + is_disabled=is_disabled, + branch=branch, + ) + + formatter = get_formatter(ctx) + service = get_service(ctx, "config_service") + + config_dict: dict | None = None + if configuration: + try: + config_dict = _parse_json_input(configuration) + except (json.JSONDecodeError, FileNotFoundError) as exc: + formatter.error( + message=f"Invalid --configuration input: {exc}", + error_code=ErrorCode.VALIDATION_ERROR, + ) + raise typer.Exit(code=2) from None + + try: + result = service.create_config_row( + alias=project, + component_id=component_id, + config_id=config_id, + name=name, + description=description, + configuration=config_dict, + is_disabled=is_disabled, + branch_id=branch, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + + if formatter.json_mode: + formatter.output(result) + else: + row_name = result.get("name", name) + row_id = result.get("id", "") + branch_info = "" + if result.get("branch_id"): + branch_info = f" (branch {result['branch_id']})" + formatter.success( + f"Created row '{escape(row_name)}' [{row_id}] " + f"in {escape(component_id)}/{escape(config_id)}{branch_info}" + ) + + +# ── config row-update ────────────────────────────────────────────────────────── + + +@config_app.command("row-update", rich_help_panel="Rows") +def config_row_update( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias", + ), + component_id: str = typer.Option( + ..., + "--component-id", + help="Component ID", + ), + config_id: str = typer.Option( + ..., + "--config-id", + help="Configuration ID", + ), + row_id: str = typer.Option( + ..., + "--row-id", + help="Row ID to update", + ), + name: str | None = typer.Option( + None, + "--name", + help="New row name", + ), + description: str | None = typer.Option( + None, + "--description", + help="New row description", + ), + configuration: str | None = typer.Option( + None, + "--configuration", + help="Row configuration JSON: inline, @file.json, or - for stdin", + ), + set_values: list[str] | None = typer.Option( + None, + "--set", + help="Set a nested value: PATH=VALUE (e.g. --set 'parameters.table=orders')", + ), + merge: bool = typer.Option( + False, + "--merge", + help="Deep-merge into existing row config instead of replacing", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show what would change without applying", + ), + is_disabled: bool = typer.Option( + False, + "--is-disabled", + help="Disable the row (mutually exclusive with --is-enabled)", + ), + is_enabled: bool = typer.Option( + False, + "--is-enabled", + help="Enable the row (mutually exclusive with --is-disabled)", + ), + branch: int | None = typer.Option( + None, + "--branch", + help="Update in a specific dev branch ID (defaults to active branch)", + ), +) -> None: + """Update an existing configuration row. + + \b + Content options modify the row configuration JSON: + --configuration : provide a full JSON blob (inline, @file, or -) + --set PATH=VALUE : set a single nested key (repeatable) + --merge : deep-merge into existing row config (preserves sibling keys) + --dry-run : preview changes without applying + + \b + Examples: + # Update just the name + kbagent config row-update --project P --component-id C --config-id ID --row-id R --name "New name" + + # Replace row configuration from a file + kbagent config row-update --project P --component-id C --config-id ID --row-id R \\ + --configuration @row.json + + # Set a single nested value (merge implied) + kbagent config row-update --project P --component-id C --config-id ID --row-id R \\ + --set 'parameters.table=new_table' + + # Preview changes without applying + kbagent config row-update --project P --component-id C --config-id ID --row-id R \\ + --set 'parameters.table=new_table' --dry-run + + # Disable a row (excludes it from job runs) + kbagent config row-update --project P --component-id C --config-id ID --row-id R --is-disabled + """ + if is_disabled and is_enabled: + formatter = get_formatter(ctx) + formatter.error( + message="--is-disabled and --is-enabled are mutually exclusive.", + error_code=ErrorCode.VALIDATION_ERROR, + ) + raise typer.Exit(code=2) from None + + is_disabled_value: bool | None = None + if is_disabled: + is_disabled_value = True + elif is_enabled: + is_disabled_value = False + + if should_hint(ctx): + emit_hint( + ctx, + "config.row-update", + project=project, + component_id=component_id, + config_id=config_id, + row_id=row_id, + name=name, + description=description, + configuration=configuration, + set=set_values, + merge=merge, + dry_run=dry_run, + is_disabled=is_disabled_value, + branch=branch, + ) + + formatter = get_formatter(ctx) + service = get_service(ctx, "config_service") + + config_dict: dict | None = None + if configuration: + try: + config_dict = _parse_json_input(configuration) + except (json.JSONDecodeError, FileNotFoundError) as exc: + formatter.error( + message=f"Invalid --configuration input: {exc}", + error_code=ErrorCode.VALIDATION_ERROR, + ) + raise typer.Exit(code=2) from None + + parsed_sets: list[tuple[str, object]] | None = None + if set_values: + parsed_sets = [] + for item in set_values: + if "=" not in item: + formatter.error( + message=f"Invalid --set format: '{item}'. Expected PATH=VALUE.", + error_code=ErrorCode.VALIDATION_ERROR, + ) + raise typer.Exit(code=2) from None + path, _, raw_value = item.partition("=") + parsed_sets.append((path.strip(), _parse_set_value(raw_value.strip()))) + + effective_merge = merge or bool(parsed_sets) + + try: + result = service.update_config_row( + alias=project, + component_id=component_id, + config_id=config_id, + row_id=row_id, + name=name, + description=description, + configuration=config_dict, + set_paths=parsed_sets, + merge=effective_merge, + dry_run=dry_run, + is_disabled=is_disabled_value, + branch_id=branch, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + + if result.get("dry_run"): + changes = result.get("changes", []) + if formatter.json_mode: + formatter.output(result) + else: + if not changes: + formatter.success("No changes detected.") + else: + formatter.console.print(f"\n[bold]Dry-run: {len(changes)} change(s)[/bold]\n") + for change in changes: + formatter.console.print(f" {change}") + formatter.console.print() + return + + if formatter.json_mode: + formatter.output(result) + else: + updated_name = result.get("name", row_id) + branch_info = "" + if result.get("branch_id"): + branch_info = f" (branch {result['branch_id']})" + formatter.success( + f"Updated row '{escape(updated_name)}' [{row_id}] " + f"in {escape(component_id)}/{escape(config_id)}{branch_info}" + ) + + +# ── config row-delete ────────────────────────────────────────────────────────── + + +@config_app.command("row-delete", rich_help_panel="Rows") +def config_row_delete( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + component_id: str = typer.Option(..., "--component-id", help="Component ID"), + config_id: str = typer.Option(..., "--config-id", help="Configuration ID"), + row_id: str = typer.Option(..., "--row-id", help="Row ID to delete"), + branch: int | None = typer.Option( + None, "--branch", help="Delete from a specific dev branch ID (defaults to active branch)" + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip confirmation prompt"), +) -> None: + """Delete a configuration row. + + \b + Examples: + kbagent config row-delete --project P --component-id C --config-id ID --row-id ROW + kbagent config row-delete --project P --component-id C --config-id ID --row-id ROW --yes + """ + if should_hint(ctx): + emit_hint( + ctx, + "config.row-delete", + project=project, + component_id=component_id, + config_id=config_id, + row_id=row_id, + branch=branch, + ) + return + + formatter = get_formatter(ctx) + + if ( + not yes + and not formatter.json_mode + and not typer.confirm(f"Delete row [{row_id}] from {component_id}/{config_id}?") + ): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + + service = get_service(ctx, "config_service") + + try: + result = service.delete_config_row( + alias=project, + component_id=component_id, + config_id=config_id, + row_id=row_id, + branch_id=branch, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + + if formatter.json_mode: + formatter.output(result) + else: + branch_info = "" + if result.get("branch_id"): + branch_info = f" (branch {result['branch_id']})" + formatter.success( + f"Deleted row [{row_id}] from {escape(component_id)}/{escape(config_id)}{branch_info}" + ) + + +# ── config oauth-url ─────────────────────────────────────────────────────────── + + +@config_app.command( + "oauth-url", + rich_help_panel="OAuth", + help=( + "Requires master token. Generate an OAuth authorization URL for a component configuration." + ), +) +def config_oauth_url( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias", + ), + component_id: str = typer.Option( + ..., + "--component-id", + help="Component ID (e.g. keboola.ex-google-drive)", + ), + config_id: str = typer.Option( + ..., + "--config-id", + help="Configuration ID to authorize", + ), + redirect_url: str | None = typer.Option( + None, + "--redirect-url", + help="Optional URL to return to after the OAuth flow completes (sets returnUrl query param)", + ), +) -> None: + """Generate an OAuth authorization URL for a component configuration. + + Opens a short-lived, component-scoped authorization link. + The user must open this URL in a browser and grant access. + + \b + Examples: + kbagent config oauth-url --project P --component-id keboola.ex-google-drive --config-id ID + + # Redirect back to a custom URL after the OAuth flow completes + kbagent config oauth-url --project P --component-id keboola.ex-google-drive --config-id ID \\ + --redirect-url https://example.com/oauth-done + """ + if should_hint(ctx): + emit_hint( + ctx, + "config.oauth-url", + project=project, + component_id=component_id, + config_id=config_id, + redirect_url=redirect_url, + ) + + formatter = get_formatter(ctx) + service = get_service(ctx, "config_service") + + try: + result = service.get_oauth_url( + alias=project, + component_id=component_id, + config_id=config_id, + redirect_url=redirect_url, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + + if formatter.json_mode: + formatter.output(result) + else: + formatter.console.print( + f"[bold]OAuth URL for[/bold] [cyan]{escape(component_id)}[/cyan]/" + f"[cyan]{escape(config_id)}[/cyan]:\n" + ) + formatter.console.print(f" [link]{result['url']}[/link]") + formatter.console.print("\n[dim]Open this URL in a browser and grant access.[/dim]") diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 4c967e5a..9ac8b23b 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -94,6 +94,10 @@ Print the effective default project and its source (env / pin / none). Resolution order for single-project operations: --project > KBAGENT_PROJECT > pin. + kbagent project info --project NAME + Return full project details: ID, name, stack URL, default backend, enabled features, + quota limits, and usage metrics. Useful for auditing project capabilities. + ### Project Members & Invitations (since v0.29.0) Requires KBC_MANAGE_API_TOKEN (Manage API auth). Allowed roles: admin, guest, readOnly, share. @@ -215,6 +219,26 @@ Sugar: writes KBC.configuration.folderName metadata. Groups the config in the Keboola UI. Pass --name "" to remove the folder assignment. + kbagent config row-create --project NAME --component-id ID --config-id ID --name ROW_NAME [--description D] [--configuration JSON|@file|-] [--is-disabled] [--branch ID] + Create a new configuration row. Returns the new row ID. Optional --configuration accepts JSON inline, @file, or stdin. + + kbagent config row-update --project NAME --component-id ID --config-id ID --row-id ID [--name N] [--description D] [--configuration JSON|@file|-] [--is-disabled | --is-enabled] [--branch ID] + Update an existing configuration row. Pass only the fields you want to change. + + kbagent config row-delete --project NAME --component-id ID --config-id ID --row-id ID [--branch ID] [--yes] + Delete a configuration row. Destructive; --yes to skip confirmation prompt. + + kbagent config oauth-url --project NAME --component-id ID --config-id ID [--redirect-url URL] + Return the OAuth authorization URL for a component that uses OAuth authentication. + Open the URL in a browser to complete the OAuth flow. + +### Cross-Project Search + + kbagent search QUERY [--project NAME] [--type table|bucket|config|flow|data-app|transformation] [--search-type textual|config-based] [--limit N] + Search for items across one or more projects. Textual mode (default) searches item names + via the Storage API global-search endpoint. Config-based mode scans full configuration JSON bodies. + --type is repeatable. --limit applies per project in textual mode (1-100, default 50). + ### Job History kbagent job list [--project NAME] [--component-id ID] [--config-id ID] [--status STATUS] [--limit N] diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index 38c3e55e..fb6d74dd 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -659,6 +659,79 @@ def project_description_set( raise typer.Exit(code=5) from None +def _format_info_table(console: Console, data: dict[str, Any]) -> None: + """Render detailed project metadata as a Rich table.""" + from rich.panel import Panel + + table = Table(show_header=False, box=None, padding=(0, 1)) + table.add_column("Field", style="bold cyan", no_wrap=True) + table.add_column("Value") + + table.add_row("Alias", str(data.get("alias", ""))) + table.add_row("Project ID", str(data.get("project_id", ""))) + table.add_row("Project Name", str(data.get("project_name", ""))) + table.add_row("Stack URL", str(data.get("stack_url", ""))) + table.add_row("Default Backend", str(data.get("default_backend", ""))) + table.add_row("Token ID", str(data.get("token_id", ""))) + table.add_row("Token Description", str(data.get("token_description", ""))) + table.add_row("Master Token", "Yes" if data.get("is_master_token") else "No") + + expires = data.get("token_expires") + table.add_row("Token Expires", str(expires) if expires else "[dim]never[/dim]") + + features = data.get("features", []) + if features: + table.add_row("Features", ", ".join(sorted(features))) + else: + table.add_row("Features", "[dim](none)[/dim]") + + limits = data.get("limits", {}) + if limits: + limit_lines = [f"{k}: {v}" for k, v in sorted(limits.items())] + table.add_row("Limits", "\n".join(limit_lines)) + + metrics = data.get("metrics", {}) + if metrics: + metric_lines = [f"{k}: {v}" for k, v in sorted(metrics.items())] + table.add_row("Metrics", "\n".join(metric_lines)) + + console.print(Panel(table, title=f"Project Info: {data.get('alias', '')}", expand=False)) + + +@project_app.command("info") +def project_info( + ctx: typer.Context, + project: str = typer.Option( + ..., + "--project", + help="Project alias to query", + ), +) -> None: + """Show detailed project metadata. + + Returns project name, ID, stack URL, default backend, feature flags, + storage limits and metrics, and token information. + """ + if should_hint(ctx): + emit_hint(ctx, "project.info", project=project) + return + + formatter = get_formatter(ctx) + service = get_service(ctx, "project_service") + + try: + result = service.get_info(alias=project) + except KeboolaApiError as exc: + exit_code = map_error_to_exit_code(exc) + formatter.error(message=exc.message, error_code=exc.error_code, retryable=exc.retryable) + raise typer.Exit(code=exit_code) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + formatter.output(result, _format_info_table) + + # ── Project members & invitations (since v0.26.1) ───────────────────── diff --git a/src/keboola_agent_cli/commands/search.py b/src/keboola_agent_cli/commands/search.py new file mode 100644 index 00000000..9edb93da --- /dev/null +++ b/src/keboola_agent_cli/commands/search.py @@ -0,0 +1,191 @@ +"""CLI commands for cross-project search. + +Provides a single ``kbagent search QUERY`` command that searches item names or +configuration bodies across all (or selected) Keboola projects. + +Supports two search modes: +- ``textual`` (default): fast name-based search via Storage API global-search. +- ``config-based``: slower full-body scan via ConfigService (searches JSON bodies). + +Examples:: + + kbagent search "customer_data" + kbagent search "sales" --type table --project prod + kbagent search "WHERE" --search-type config-based + kbagent --json search "revenue" --type config --type flow +""" + +from __future__ import annotations + +from typing import Any + +import typer +from rich.table import Table + +from ..commands._helpers import ( + emit_hint, + emit_project_warnings, + get_formatter, + get_service, + should_hint, +) +from ..errors import ConfigError, ErrorCode, KeboolaApiError + +# Valid user-facing type values. +VALID_TYPES = ["table", "bucket", "config", "flow", "data-app", "transformation"] + +# Valid search type values. +VALID_SEARCH_TYPES = ["textual", "config-based"] + + +def search_command( + ctx: typer.Context, + query: str = typer.Argument(..., help="Search query string."), + project: list[str] | None = typer.Option( + None, + "--project", + "-p", + help="Project alias to search (repeatable; defaults to all projects).", + ), + item_type: list[str] | None = typer.Option( + None, + "--type", + "-t", + help=( + f"Item type to restrict results. Repeatable. Valid values: {', '.join(VALID_TYPES)}." + ), + ), + search_type: str = typer.Option( + "textual", + "--search-type", + help=( + "Search mode. ``textual`` (default) searches item names via the " + "Storage API. ``config-based`` scans full configuration JSON bodies." + ), + ), + limit: int = typer.Option( + 50, + "--limit", + "-l", + min=1, + max=100, + help="Maximum number of results per project (textual search only, 1-100).", + ), +) -> None: + """Search for items across one or more Keboola projects. + + In ``textual`` mode (default) the Storage API global-search endpoint is + called, which matches item names efficiently. In ``config-based`` mode the + full JSON body of every configuration is scanned for the query string. + + Results from all queried projects are merged and printed together. + One project failing does not stop others. + + Examples: + + kbagent search customer_data + + kbagent search sales --type table --project prod + + kbagent search "JOIN orders" --search-type config-based + + kbagent --json search revenue --type config --type flow + """ + if should_hint(ctx): + emit_hint( + ctx, + "search.search", + query=query, + project=project, + item_type=item_type, + search_type=search_type, + limit=limit, + ) + + formatter = get_formatter(ctx) + service = get_service(ctx, "search_service") + + # Validate --type values. + if item_type: + for t in item_type: + if t not in VALID_TYPES: + formatter.error( + message=(f"Invalid item type '{t}'. Valid values: {', '.join(VALID_TYPES)}"), + error_code=ErrorCode.INVALID_ARGUMENT, + ) + raise typer.Exit(code=2) + + # Validate --search-type. + if search_type not in VALID_SEARCH_TYPES: + formatter.error( + message=( + f"Invalid search type '{search_type}'. " + f"Valid values: {', '.join(VALID_SEARCH_TYPES)}" + ), + error_code=ErrorCode.INVALID_ARGUMENT, + ) + raise typer.Exit(code=2) + + try: + result = service.search( + query=query, + aliases=project or None, + item_types=item_type or None, + search_type=search_type, + limit=limit, + ) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + except KeboolaApiError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.API_ERROR) + raise typer.Exit(code=1) from None + + if formatter.json_mode: + formatter.output(result) + else: + _format_search_results(formatter.console, result, query, search_type) + emit_project_warnings(formatter, result) + + +# ── Human-readable output ────────────────────────────────────────────────── + + +def _format_search_results(console: Any, result: dict, query: str, search_type: str) -> None: + """Render search results as a Rich table with stats header.""" + stats = result.get("stats", {}) + projects_searched = stats.get("projects_searched", 0) + results_found = stats.get("results_found", 0) + errors = result.get("errors", []) + + mode_label = "config-based" if search_type == "config-based" else "textual" + console.print( + f'[bold]Search results[/bold] for [yellow]"{query}"[/yellow] ' + f"([dim]{mode_label}[/dim]) — " + f"[cyan]{results_found}[/cyan] result(s) across " + f"[cyan]{projects_searched}[/cyan] project(s)" + + (f", [yellow]{len(errors)} error(s)[/yellow]" if errors else "") + ) + + rows = result.get("results", []) + if not rows: + console.print("[dim]No results found.[/dim]") + return + + table = Table(show_header=True, header_style="bold blue", show_lines=False) + table.add_column("Project", style="cyan", no_wrap=True) + table.add_column("Type", style="magenta", no_wrap=True) + table.add_column("ID", style="green", overflow="fold") + table.add_column("Name", overflow="fold") + table.add_column("Component ID", style="dim", overflow="fold") + + for row in rows: + table.add_row( + row.get("project_alias", ""), + row.get("type", ""), + row.get("id", ""), + row.get("name", ""), + row.get("component_id") or "", + ) + + console.print(table) diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index 799a9bb8..7851a3f5 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -175,6 +175,21 @@ # See plugins/kbagent/skills/kbagent/references/storage-types-workflow.md. STORAGE_BRANCHES_FEATURE: str = "storage-branches" +# --- Global Search --- +# Feature flag that gates the Storage API ``GET /v2/storage/global-search`` +# endpoint used by ``kbagent search`` (textual mode). Projects without this +# flag receive a 404; ``SearchService`` checks the flag pre-flight and +# returns a descriptive per-project error rather than letting the raw 404 +# bubble up. +GLOBAL_SEARCH_FEATURE: str = "global-search" + +# --- OAuth --- +# Host of the Keboola-hosted OAuth wizard used by ``kbagent config oauth-url``. +# Constant across all stacks (EU/US/AWS/GCP/Azure); the per-stack difference +# is reflected in the ``sapiUrl`` query parameter, not the wizard host. +OAUTH_HOST: str = "external.keboola.com" +OAUTH_PATH: str = "/oauth/index.html" + # --- Kai (Keboola AI Assistant) --- KAI_FEATURE_FLAG: str = "agent-chat" KAI_REQUEST_TIMEOUT: float = 300.0 # 5 min for non-streaming requests diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py index 9948c537..09075190 100644 --- a/src/keboola_agent_cli/errors.py +++ b/src/keboola_agent_cli/errors.py @@ -16,6 +16,7 @@ class ErrorCode(StrEnum): INVALID_TOKEN = "INVALID_TOKEN" ACCESS_DENIED = "ACCESS_DENIED" PERMISSION_DENIED = "PERMISSION_DENIED" + MISSING_MASTER_TOKEN = "MISSING_MASTER_TOKEN" # Network / transport TIMEOUT = "TIMEOUT" @@ -176,6 +177,7 @@ def __init__(self, operation: str, message: str = "") -> None: _ERROR_CODE_TO_TYPE: dict[str, str] = { ErrorCode.INVALID_TOKEN: "authentication", + ErrorCode.MISSING_MASTER_TOKEN: "authentication", ErrorCode.TIMEOUT: "network", ErrorCode.CONNECTION_ERROR: "network", ErrorCode.RETRY_EXHAUSTED: "network", diff --git a/src/keboola_agent_cli/hints/definitions/__init__.py b/src/keboola_agent_cli/hints/definitions/__init__.py index a5ac1558..2ad90ac5 100644 --- a/src/keboola_agent_cli/hints/definitions/__init__.py +++ b/src/keboola_agent_cli/hints/definitions/__init__.py @@ -14,6 +14,7 @@ org, # noqa: F401 project, # noqa: F401 schedule, # noqa: F401 + search, # noqa: F401 sharing, # noqa: F401 storage, # noqa: F401 tool, # noqa: F401 diff --git a/src/keboola_agent_cli/hints/definitions/config.py b/src/keboola_agent_cli/hints/definitions/config.py index 868cf0e4..678c2b79 100644 --- a/src/keboola_agent_cli/hints/definitions/config.py +++ b/src/keboola_agent_cli/hints/definitions/config.py @@ -543,6 +543,211 @@ # ── config set-default-bucket ────────────────────────────────────── +HintRegistry.register( + CommandHint( + cli_command="config.row-create", + description="Create a new configuration row under a parent configuration", + steps=[ + HintStep( + comment="Create configuration row via Storage API POST", + client=ClientCall( + method="create_config_row", + args={ + "component_id": "{component_id}", + "config_id": "{config_id}", + "name": "{name}", + "configuration": "{configuration}", + "description": "{description}", + "is_disabled": "{is_disabled}", + "branch_id": "{branch}", + }, + result_var="row", + result_hint="dict", + ), + service=ServiceCall( + service_class="ConfigService", + service_module="config_service", + method="create_config_row", + args={ + "alias": "{project}", + "component_id": "{component_id}", + "config_id": "{config_id}", + "name": "{name}", + "description": "{description}", + "configuration": "{configuration}", + "is_disabled": "{is_disabled}", + "branch_id": "{branch}", + }, + ), + ), + ], + notes=[ + "configuration defaults to {} if omitted.", + "The returned dict includes the new row 'id' assigned by the API.", + "Rows are sub-units of a configuration; one config may have many rows.", + "is_disabled=True creates the row in disabled state (excluded from job runs).", + ], + ) +) + +# ── config row-update ────────────────────────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="config.row-update", + description="Update an existing configuration row (metadata and/or content)", + steps=[ + HintStep( + comment=( + "Fetch current row config if --merge or --set is used, then write back. " + "For --dry-run, compare and return diff without writing." + ), + client=ClientCall( + method="update_config_row", + args={ + "component_id": "{component_id}", + "config_id": "{config_id}", + "row_id": "{row_id}", + "name": "{name}", + "description": "{description}", + "configuration": "{configuration}", + "is_disabled": "{is_disabled}", + "branch_id": "{branch}", + }, + result_var="row", + result_hint="dict", + ), + service=ServiceCall( + service_class="ConfigService", + service_module="config_service", + method="update_config_row", + args={ + "alias": "{project}", + "component_id": "{component_id}", + "config_id": "{config_id}", + "row_id": "{row_id}", + "name": "{name}", + "description": "{description}", + "configuration": "{configuration}", + "set_paths": "{set}", + "merge": "{merge}", + "dry_run": "{dry_run}", + "is_disabled": "{is_disabled}", + "branch_id": "{branch}", + }, + ), + ), + ], + notes=[ + "--set PATH=VALUE is repeatable; implies --merge (fetches current row first).", + "--merge deep-merges the provided configuration into the existing row config.", + "--dry-run returns {'dry_run': True, 'changes': [...], 'old_configuration': {...}, " + "'new_configuration': {...}} without writing.", + "--is-disabled / --is-enabled toggle the row's enabled state (mutually exclusive).", + "At least one of --name, --description, --configuration, --set, --is-disabled, " + "or --is-enabled must be provided.", + ], + ) +) + +# ── config row-delete ────────────────────────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="config.row-delete", + description="Delete a configuration row by ID", + steps=[ + HintStep( + comment="Delete configuration row via Storage API DELETE", + client=ClientCall( + method="delete_config_row", + args={ + "component_id": "{component_id}", + "config_id": "{config_id}", + "row_id": "{row_id}", + "branch_id": "{branch}", + }, + result_var="_", + result_hint="None", + ), + service=ServiceCall( + service_class="ConfigService", + service_module="config_service", + method="delete_config_row", + args={ + "alias": "{project}", + "component_id": "{component_id}", + "config_id": "{config_id}", + "row_id": "{row_id}", + "branch_id": "{branch}", + }, + ), + ), + ], + notes=[ + "Destructive: irreversible deletion of the row from the Storage API.", + "404 from API surfaces as KeboolaApiError(NOT_FOUND); deleting a " + "non-existent row is treated as an error, not idempotent success.", + "Branch-aware: pass branch_id to delete from a dev branch.", + ], + ) +) + +# ── config oauth-url ─────────────────────────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="config.oauth-url", + description=( + "Generate a short-lived OAuth authorization URL for a component configuration. " + "The user opens this URL in a browser to grant access." + ), + steps=[ + HintStep( + comment=( + "Create a short-lived component-scoped Storage API token, " + "then build https://external.keboola.com/oauth/index.html URL." + ), + client=ClientCall( + method="get_oauth_url", + args={ + "component_id": "{component_id}", + "config_id": "{config_id}", + "redirect_url": "{redirect_url}", + }, + result_var="url", + result_hint="str", + ), + service=ServiceCall( + service_class="ConfigService", + service_module="config_service", + method="get_oauth_url", + args={ + "alias": "{project}", + "component_id": "{component_id}", + "config_id": "{config_id}", + "redirect_url": "{redirect_url}", + }, + ), + ), + ], + notes=[ + "Response: {'url': str, 'component_id': str, 'config_id': str, 'project_alias': str}.", + "The short-lived token embedded in the URL expires in 1 hour.", + "Only applicable to OAuth-requiring components (e.g. keboola.ex-google-drive, " + "keboola.ex-google-analytics-v4, keboola.ex-gmail).", + "Call this AFTER creating the configuration, not before.", + "redirect_url adds a returnUrl query param so the OAuth wizard returns to a " + "custom URL after the flow completes.", + "Requires a MASTER Storage API token on the project (canManageTokens). " + "Non-master tokens fail with MISSING_MASTER_TOKEN (exit 3) on a fail-fast " + "pre-flight check before any HTTP write happens.", + ], + ) +) + +# ── config set-default-bucket ────────────────────────────────────────────────── + HintRegistry.register( CommandHint( cli_command="config.set-default-bucket", diff --git a/src/keboola_agent_cli/hints/definitions/project.py b/src/keboola_agent_cli/hints/definitions/project.py index 32c69288..616524f7 100644 --- a/src/keboola_agent_cli/hints/definitions/project.py +++ b/src/keboola_agent_cli/hints/definitions/project.py @@ -1,4 +1,4 @@ -"""Hint definitions for project-level commands (project description).""" +"""Hint definitions for project-level commands (project description, info).""" from .. import HintRegistry from ..models import ClientCall, CommandHint, HintStep, ServiceCall @@ -71,3 +71,38 @@ ], ) ) + +# ── project info ────────────────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="project.info", + description="Return full project metadata from /v2/storage/tokens/verify", + steps=[ + HintStep( + comment=( + "Fetch the full token-verify response; includes owner.features, " + "owner.limits, owner.metrics, owner.defaultBackend, token expiry." + ), + client=ClientCall( + method="get_project_info", + args={}, + result_var="info", + result_hint="dict", + ), + service=ServiceCall( + service_class="ProjectService", + service_module="project_service", + method="get_info", + args={"alias": "{project}"}, + ), + ), + ], + notes=[ + "get_project_info() returns the raw API response dict; " + "the service layer formats it into a stable structure.", + "Features are in owner.features (list of strings). " + "Limits and metrics are in owner.limits / owner.metrics (dicts).", + ], + ) +) diff --git a/src/keboola_agent_cli/hints/definitions/search.py b/src/keboola_agent_cli/hints/definitions/search.py new file mode 100644 index 00000000..11cc3c5d --- /dev/null +++ b/src/keboola_agent_cli/hints/definitions/search.py @@ -0,0 +1,58 @@ +"""Hint definitions for the search command.""" + +from .. import HintRegistry +from ..models import ClientCall, CommandHint, HintStep, ServiceCall + +# ── search ──────────────────────────────────────────────────────────────── + +HintRegistry.register( + CommandHint( + cli_command="search.search", + description="Search for items by name or content across one or more projects", + steps=[ + HintStep( + comment=( + "Textual mode: call GET /v2/storage/global-search to match item names. " + "Requires the project's numeric ID (from verify_token). " + "Pass types[] to restrict to specific item types. " + "Config-based mode: use ConfigService.search_configs() instead to scan " + "full configuration JSON bodies for the query string." + ), + client=ClientCall( + method="global_search", + args={ + "query": "{query}", + "project_id": "", + "types": "{item_type}", + "limit": "{limit}", + }, + result_var="search_response", + result_hint="dict with 'all' (int) and 'items' (list[dict])", + ), + service=ServiceCall( + service_class="SearchService", + service_module="search_service", + method="search", + args={ + "query": "{query}", + "aliases": "{project}", + "item_types": "{item_type}", + "search_type": "{search_type}", + "limit": "{limit}", + }, + ), + ), + ], + notes=[ + "Textual search uses GET /v2/storage/global-search (name-based, fast). " + "The endpoint requires projectIds[], types[], limit, offset, and branchTypes[].", + "Config-based search scans full config JSON bodies via ConfigService.search_configs(). " + "It is slower but finds matches inside configuration parameters, rows, etc.", + "The project's numeric ID must be obtained first via " + "KeboolaClient.verify_token().project_id before calling global_search().", + "Item types: bucket, table, flow, transformation, configuration, configuration-row. " + "User-facing aliases: 'config' maps to 'configuration', 'data-app' to 'configuration'.", + "Results include: id, name, type, fullPath, componentId, projectId, projectName.", + ], + ) +) diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index 8fb85a77..095b5154 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -24,6 +24,7 @@ "project.description-set": "write", "project.use": "write", "project.current": "read", + "project.info": "read", "project.invite": "admin", "project.member-list": "read", "project.invitation-list": "read", @@ -47,6 +48,10 @@ "config.set-metadata": "write", "config.delete-metadata": "destructive", "config.set-folder": "write", + "config.row-create": "write", + "config.row-update": "write", + "config.row-delete": "destructive", + "config.oauth-url": "read", # Job history "job.list": "read", "job.detail": "read", @@ -167,6 +172,7 @@ "schedule.detail": "read", "schedule.find": "read", # Top-level commands + "search": "read", "init": "admin", "doctor": "read", "version": "read", diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index 6ab7d4ad..a2a7ff29 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -1381,3 +1381,350 @@ def _search_project_configs( ) finally: client.close() + + # ── config row-create ────────────────────────────────────────────────────── + + def create_config_row( + self, + alias: str, + component_id: str, + config_id: str, + name: str, + description: str = "", + configuration: dict[str, Any] | None = None, + is_disabled: bool = False, + branch_id: int | None = None, + ) -> dict[str, Any]: + """Create a new configuration row. + + Args: + alias: Project alias. + component_id: The component ID. + config_id: The configuration ID the row belongs to. + name: Row name (required by Storage API). + description: Optional row description. + configuration: Row-level configuration dict. Defaults to empty dict. + is_disabled: Create the row in disabled state (excluded from job runs). + branch_id: If set, create in a specific dev branch. Falls back to + the project's active branch when None. + + Returns: + The created row dict from the API (includes the new 'id'). + + Raises: + ConfigError: If the alias is not found. + KeboolaApiError: If the API call fails. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + effective_branch_id = branch_id or project.active_branch_id + client = self._client_factory(project.stack_url, project.token) + try: + result = client.create_config_row( + component_id=component_id, + config_id=config_id, + name=name, + configuration=configuration if configuration is not None else {}, + description=description, + is_disabled=is_disabled, + branch_id=effective_branch_id, + ) + finally: + client.close() + + result["project_alias"] = alias + result["branch_id"] = effective_branch_id + return result + + # ── config row-update ────────────────────────────────────────────────────── + + def update_config_row( + self, + alias: str, + component_id: str, + config_id: str, + row_id: str, + name: str | None = None, + description: str | None = None, + configuration: dict[str, Any] | None = None, + set_paths: list[tuple[str, Any]] | None = None, + merge: bool = False, + dry_run: bool = False, + is_disabled: bool | None = None, + branch_id: int | None = None, + ) -> dict[str, Any]: + """Update an existing configuration row. + + Args: + alias: Project alias. + component_id: The component ID. + config_id: The configuration ID. + row_id: The row ID to update. + name: New row name (if None, not changed). + description: New description (if None, not changed). + configuration: Full configuration dict to set/merge. + set_paths: List of (path, value) tuples for targeted updates. + merge: If True, deep-merge *configuration* into the existing row + config instead of replacing. + dry_run: If True, compute and return the diff without applying. + is_disabled: When True, disable the row; when False, enable it; + when None, leave the current state unchanged. + branch_id: If set, update in a specific dev branch. Falls back to + the project's active branch when None. + + Returns: + Dict with the updated row from the API. + When *dry_run* is True the dict contains ``"dry_run": True`` + and a ``"changes"`` list instead of the API response. + + Raises: + ConfigError: If the alias is not found. + KeboolaApiError: If the API call fails or no changes are requested. + """ + has_content = configuration is not None or bool(set_paths) + has_metadata = name is not None or description is not None or is_disabled is not None + + if not has_content and not has_metadata: + raise KeboolaApiError( + status_code=400, + error_code=ErrorCode.VALIDATION_ERROR, + message=( + "At least one of --name, --description, --configuration, --set, " + "--is-disabled, or --is-enabled must be provided." + ), + ) + + projects = self.resolve_projects([alias]) + project = projects[alias] + effective_branch_id = branch_id or project.active_branch_id + client = self._client_factory(project.stack_url, project.token) + + try: + final_config: dict[str, Any] | None = None + + if has_content: + final_config = self._resolve_row_configuration( + client=client, + component_id=component_id, + config_id=config_id, + row_id=row_id, + configuration=configuration, + set_paths=set_paths, + merge=merge, + branch_id=effective_branch_id, + ) + + if dry_run: + current_row = client.get_config_row( + component_id, config_id, row_id, branch_id=effective_branch_id + ) + old_cfg = current_row.get("configuration", {}) + if isinstance(old_cfg, str): + old_cfg = json.loads(old_cfg) if old_cfg else {} + new_cfg = final_config if final_config is not None else old_cfg + changes = compute_diff(old_cfg, new_cfg) + if is_disabled is not None: + old_state = bool(current_row.get("isDisabled", False)) + if old_state != is_disabled: + changes.append(f"isDisabled: {old_state} -> {is_disabled}") + return { + "dry_run": True, + "project_alias": alias, + "component_id": component_id, + "config_id": config_id, + "row_id": row_id, + "branch_id": effective_branch_id, + "changes": changes, + "old_configuration": old_cfg, + "new_configuration": new_cfg, + } + + change_parts = [] + if has_metadata: + change_parts.append("metadata") + if has_content: + change_parts.append("configuration") + change_desc = f"Updated {' + '.join(change_parts)} via kbagent config row-update" + + result = client.update_config_row( + component_id=component_id, + config_id=config_id, + row_id=row_id, + name=name, + description=description, + configuration=final_config, + is_disabled=is_disabled, + change_description=change_desc, + branch_id=effective_branch_id, + ) + finally: + client.close() + + result["project_alias"] = alias + result["branch_id"] = effective_branch_id + return result + + def _resolve_row_configuration( + self, + client: Any, + component_id: str, + config_id: str, + row_id: str, + configuration: dict[str, Any] | None, + set_paths: list[tuple[str, Any]] | None, + merge: bool, + branch_id: int | None, + ) -> dict[str, Any]: + """Build the final row configuration dict by merging/setting paths. + + Mirrors ``_resolve_configuration`` but operates on a row's config. + """ + needs_current = merge or bool(set_paths) + + if needs_current: + current_row = client.get_config_row( + component_id, config_id, row_id, branch_id=branch_id + ) + current_cfg: dict[str, Any] = current_row.get("configuration", {}) + if isinstance(current_cfg, str): + current_cfg = json.loads(current_cfg) if current_cfg else {} + else: + current_cfg = {} + + if set_paths: + result = current_cfg + for path, value in set_paths: + result = set_nested_value(result, path, value) + if configuration: + result = deep_merge(result, configuration) + return result + + if merge and configuration: + return deep_merge(current_cfg, configuration) + + return configuration if configuration is not None else current_cfg + + # ── config row-delete ────────────────────────────────────────────────────── + + def delete_config_row( + self, + alias: str, + component_id: str, + config_id: str, + row_id: str, + branch_id: int | None = None, + ) -> dict[str, Any]: + """Delete a configuration row. + + Args: + alias: Project alias. + component_id: The component ID. + config_id: The configuration ID the row belongs to. + row_id: The row ID to delete. + branch_id: If set, delete from a specific dev branch. Falls back + to the project's active branch when None. + + Returns: + Dict with ``deleted: True`` plus identifiers (``project_alias``, + ``component_id``, ``config_id``, ``row_id``, ``branch_id``). + + Raises: + ConfigError: If the alias is not found. + KeboolaApiError: If the API call fails (e.g. row not found = 404). + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + effective_branch_id = branch_id or project.active_branch_id + client = self._client_factory(project.stack_url, project.token) + try: + client.delete_config_row( + component_id=component_id, + config_id=config_id, + row_id=row_id, + branch_id=effective_branch_id, + ) + finally: + client.close() + + return { + "deleted": True, + "project_alias": alias, + "component_id": component_id, + "config_id": config_id, + "row_id": row_id, + "branch_id": effective_branch_id, + } + + # ── config oauth-url ─────────────────────────────────────────────────────── + + def get_oauth_url( + self, + alias: str, + component_id: str, + config_id: str, + redirect_url: str | None = None, + ) -> dict[str, Any]: + """Generate an OAuth authorization URL for a component configuration. + + Creates a short-lived Storage API token scoped to the component and + builds the URL the user must open in a browser to grant OAuth access. + + Args: + alias: Project alias. + component_id: The component ID (e.g. 'keboola.ex-google-drive'). + config_id: The configuration ID to authorize. + redirect_url: Optional URL the OAuth wizard returns to after the + flow completes (passed as the ``returnUrl`` query param). + + Returns: + Dict with 'url', 'component_id', 'config_id', 'project_alias', + and ``redirect_url`` when provided. + + Raises: + ConfigError: If the alias is not found. + KeboolaApiError: If the API call fails. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + client = self._client_factory(project.stack_url, project.token) + try: + # Pre-flight: minting a short-lived component-scoped child token + # via POST /v2/storage/tokens requires `canManageTokens`, which only + # master tokens carry by default. Without this guard the Storage + # API returns a vague 500 "Application error" that misleads + # operators into thinking the OAuth wizard is broken. + info = client.get_project_info() + if not info.get("isMasterToken", False): + raise KeboolaApiError( + status_code=403, + error_code=ErrorCode.MISSING_MASTER_TOKEN, + message=( + f"`config oauth-url` requires a master Storage API token " + f"on project '{alias}'. The current token " + f"(id={info.get('id', '?')}, " + f"description='{info.get('description', '?')}') is not a " + f"master token, so it cannot mint the short-lived " + f"component-scoped child token the OAuth wizard expects. " + f"Either re-add the project with a master token " + f"(`kbagent project edit --project {alias} --token `) " + f"or open the OAuth flow via the Keboola UI." + ), + ) + + url = client.get_oauth_url( + component_id=component_id, + config_id=config_id, + redirect_url=redirect_url, + ) + finally: + client.close() + + result: dict[str, Any] = { + "url": url, + "component_id": component_id, + "config_id": config_id, + "project_alias": alias, + } + if redirect_url: + result["redirect_url"] = redirect_url + return result diff --git a/src/keboola_agent_cli/services/project_service.py b/src/keboola_agent_cli/services/project_service.py index 73019fa1..10c5c94c 100644 --- a/src/keboola_agent_cli/services/project_service.py +++ b/src/keboola_agent_cli/services/project_service.py @@ -327,6 +327,50 @@ def current_project(self) -> dict[str, Any]: "env_points_to_configured_project": None, } + def get_info(self, alias: str) -> dict[str, Any]: + """Return detailed project metadata for a single project. + + Calls /v2/storage/tokens/verify and formats the full response + into a structured dict suitable for both JSON and human output. + + Args: + alias: The project alias to query. + + Returns: + Dict with project_id, project_name, stack_url, default_backend, + features, limits, metrics, token_id, token_description, + is_master_token, token_expires, and description fields. + + Raises: + ConfigError: If the alias does not exist. + KeboolaApiError: If the API call fails. + """ + project = self._config_store.get_project(alias) + if project is None: + raise ConfigError(f"Project '{alias}' not found.") + + client = self._client_factory(project.stack_url, project.token) + try: + raw = client.get_project_info() + finally: + client.close() + + owner = raw.get("owner", {}) + return { + "alias": alias, + "project_id": owner.get("id"), + "project_name": owner.get("name", ""), + "stack_url": project.stack_url, + "default_backend": owner.get("defaultBackend", "snowflake"), + "features": owner.get("features", []), + "limits": owner.get("limits", {}), + "metrics": owner.get("metrics", {}), + "token_id": str(raw.get("id", "")), + "token_description": raw.get("description", ""), + "is_master_token": raw.get("isMasterToken", False), + "token_expires": raw.get("expires"), + } + def resolve_pinned_alias(self, explicit: str | None = None) -> tuple[str, str]: """Resolve the effective project alias for a single-project operation. diff --git a/src/keboola_agent_cli/services/search_service.py b/src/keboola_agent_cli/services/search_service.py new file mode 100644 index 00000000..b4584a01 --- /dev/null +++ b/src/keboola_agent_cli/services/search_service.py @@ -0,0 +1,331 @@ +"""Search service — cross-project item search using the Storage API global-search endpoint. + +Provides textual (name-based) search across buckets, tables, configurations, +flows, data apps, and other Keboola item types. Supports multi-project fan-out +via BaseService._run_parallel() with per-project error accumulation. + +The ``config-based`` search type is delegated to ``ConfigService.search_configs()`` +(which scans full configuration JSON bodies for the query string) and is included +here for a unified ``search`` command surface. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..constants import GLOBAL_SEARCH_FEATURE +from ..errors import KeboolaApiError +from ..models import ProjectConfig +from .base import BaseService, sanitize_unexpected_error +from .config_service import ConfigService + +logger = logging.getLogger(__name__) + +# Mapping from user-facing --type values to Storage API ``types[]`` values. +# The API accepts a strict set; we expose a friendlier subset as CLI options. +USER_TYPE_TO_API_TYPES: dict[str, list[str]] = { + "bucket": ["bucket"], + "table": ["table"], + "config": ["configuration"], + "flow": ["flow"], + # data-app reuses the "configuration" API type; results are post-filtered + # to component_id == "keboola.data-apps" in `_normalise_item` so users + # asking for --type data-app do not get every other configuration too. + "data-app": ["configuration"], + "transformation": ["transformation"], +} + +# Component ID used to identify data-app configurations after post-filtering. +DATA_APP_COMPONENT_ID = "keboola.data-apps" + +# All API types for unfiltered search. +ALL_API_TYPES: list[str] = [ + "bucket", + "table", + "flow", + "transformation", + "configuration", + "configuration-row", +] + + +class SearchService(BaseService): + """Service for cross-project textual search using the Storage API global-search endpoint. + + Supports: + - Textual search: uses ``GET /v2/storage/global-search`` (name-based, fast). + - Config-based search: delegates to ConfigService for body scanning (slower). + + Multi-project fan-out runs in parallel via ``_run_parallel()``. A failing + project is recorded in ``errors`` without stopping others. + """ + + def search( + self, + query: str, + aliases: list[str] | None = None, + item_types: list[str] | None = None, + search_type: str = "textual", + limit: int = 50, + ) -> dict[str, Any]: + """Search for items across one or more projects. + + Args: + query: Search string to match against item names (textual) or + config bodies (config-based). + aliases: Project aliases to search. ``None`` means all projects. + item_types: Optional list of user-facing type names. Supported: + ``bucket``, ``table``, ``config``, ``flow``, + ``data-app``, ``transformation``. + ``None`` or empty means search all types. + search_type: ``"textual"`` (default) uses the fast global-search + endpoint (name-based). ``"config-based"`` scans full + config JSON bodies via ConfigService. + limit: Maximum number of results per project (textual only). + + Returns: + Dict with keys: + - ``"results"``: list of result dicts + - ``"errors"``: list of per-project error dicts + - ``"stats"``: dict with ``projects_searched`` and ``results_found`` + """ + projects = self.resolve_projects(aliases) + + if search_type == "config-based": + return self._search_config_based(query, projects, item_types) + + return self._search_textual(query, projects, item_types, limit) + + # ── Textual search (global-search endpoint) ──────────────────────────── + + def _search_textual( + self, + query: str, + projects: dict[str, ProjectConfig], + item_types: list[str] | None, + limit: int, + ) -> dict[str, Any]: + """Fan out textual search across projects in parallel.""" + api_types = _resolve_api_types(item_types) + + def worker( + alias: str, project: ProjectConfig + ) -> tuple[str, list[dict[str, Any]], bool] | tuple[str, dict[str, str]]: + return self._search_project_textual( + alias, project, query, api_types, limit, item_types=item_types + ) + + successes, errors = self._run_parallel(projects, worker) + + all_results: list[dict[str, Any]] = [] + for _alias, results, _ok in successes: + all_results.extend(results) + + all_results.sort(key=lambda r: (r["project_alias"], r["type"], r["id"])) + errors.sort(key=lambda e: e.get("project_alias", "")) + + return { + "results": all_results, + "errors": errors, + "stats": { + "projects_searched": len(successes), + "results_found": len(all_results), + }, + } + + def _search_project_textual( + self, + alias: str, + project: ProjectConfig, + query: str, + api_types: list[str], + limit: int, + item_types: list[str] | None = None, + ) -> tuple[str, list[dict[str, Any]], bool] | tuple[str, dict[str, str]]: + """Worker: run textual search against a single project (thread-safe).""" + client = self._client_factory(project.stack_url, project.token) + try: + # Resolve project_id via token verify; cached within client lifetime. + token_info = client.verify_token() + project_id = token_info.project_id + if project_id is None: + return ( + alias, + { + "project_alias": alias, + "error_code": "CONFIG_ERROR", + "message": "Could not determine project ID from token info.", + }, + ) + + # Pre-flight: refuse fast on stacks/projects without the global-search + # feature instead of letting a raw 404/403 confuse the caller. + if not client.has_feature(GLOBAL_SEARCH_FEATURE): + return ( + alias, + { + "project_alias": alias, + "error_code": "FEATURE_NOT_ENABLED", + "message": ( + f"Project does not have the '{GLOBAL_SEARCH_FEATURE}' feature " + "enabled. Use --search-type config-based for body scanning, or " + "ask a Keboola admin to enable the feature." + ), + }, + ) + + raw = client.global_search( + query=query, + project_id=project_id, + types=api_types if api_types else None, + limit=limit, + ) + items = raw.get("items", []) + results = [_normalise_item(alias, item) for item in items] + + # Post-filter when --type data-app was requested without --type config: + # data-app maps to the same API type as config, so without filtering + # users would receive every configuration in the project. + if item_types and "data-app" in item_types and "config" not in item_types: + results = [ + r + for r in results + if r.get("type") != "configuration" + or r.get("component_id") == DATA_APP_COMPONENT_ID + ] + + return alias, results, True + + except KeboolaApiError as exc: + return ( + alias, + { + "project_alias": alias, + "error_code": exc.error_code or "API_ERROR", + "message": exc.message, + }, + ) + except Exception as exc: + logger.debug("Unexpected error searching project '%s': %s", alias, exc) + return ( + alias, + { + "project_alias": alias, + "error_code": "UNEXPECTED_ERROR", + "message": sanitize_unexpected_error(exc), + }, + ) + + # ── Config-based search (full body scan) ─────────────────────────────── + + def _search_config_based( + self, + query: str, + projects: dict[str, ProjectConfig], + item_types: list[str] | None, + ) -> dict[str, Any]: + """Delegate to ConfigService for JSON-body search across projects. + + Translates user-facing ``item_types`` to ConfigService's + ``component_type`` filter where possible, then reformats the result + to the unified search output shape. + """ + # Map item_types to a component_type filter for ConfigService. + component_type = _item_types_to_component_type(item_types) + + config_service = ConfigService( + config_store=self._config_store, + client_factory=self._client_factory, + ) + aliases = list(projects.keys()) if projects else None + raw = config_service.search_configs( + query=query, + aliases=aliases, + component_type=component_type, + ) + + # Re-shape matches into the unified results format. + results = [ + { + "project_alias": m["project_alias"], + "type": "configuration", + "id": m["config_id"], + "name": m["config_name"], + "description": m.get("description", ""), + "component_id": m.get("component_id"), + "match_count": m.get("match_count", 0), + "match_locations": m.get("match_locations", []), + } + for m in raw.get("matches", []) + ] + + return { + "results": results, + "errors": raw.get("errors", []), + "stats": { + "projects_searched": raw["stats"]["projects_searched"], + "results_found": len(results), + }, + } + + +# ── Helpers ──────────────────────────────────────────────────────────────── + + +def _resolve_api_types(item_types: list[str] | None) -> list[str]: + """Translate user-facing type names to Storage API type values. + + Args: + item_types: List of user-facing type names, or None for all. + + Returns: + Deduplicated list of API type strings. Empty list means "all types". + """ + if not item_types: + return [] + seen: set[str] = set() + result: list[str] = [] + for t in item_types: + for api_type in USER_TYPE_TO_API_TYPES.get(t, [t]): + if api_type not in seen: + seen.add(api_type) + result.append(api_type) + return result + + +def _item_types_to_component_type(item_types: list[str] | None) -> str | None: + """Map user-facing item types to a ConfigService component_type filter. + + Returns None (no filter) for types that do not map to a specific + component type (e.g. bucket, table) or when no filter is given. + """ + if not item_types: + return None + types_set = set(item_types) + if types_set == {"transformation"}: + return "transformation" + return None + + +def _normalise_item(alias: str, item: dict[str, Any]) -> dict[str, Any]: + """Normalise a raw global-search API item into the unified result shape. + + Args: + alias: Project alias this result came from. + item: Raw item dict from the Storage API response. + + Returns: + Normalised result dict with consistent keys across all item types. + """ + full_path = item.get("fullPath", {}) + return { + "project_alias": alias, + "type": item.get("type", ""), + "id": item.get("id", ""), + "name": item.get("name", ""), + "description": full_path.get("description", ""), + "component_id": item.get("componentId"), + "project_id": item.get("projectId"), + "project_name": item.get("projectName", ""), + } diff --git a/tests/test_config_row_cli.py b/tests/test_config_row_cli.py new file mode 100644 index 00000000..e9a08692 --- /dev/null +++ b/tests/test_config_row_cli.py @@ -0,0 +1,877 @@ +"""CLI tests for config row-create, row-update, and oauth-url commands.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from typer.testing import CliRunner + +from helpers import setup_single_project +from keboola_agent_cli.cli import app +from keboola_agent_cli.errors import KeboolaApiError +from keboola_agent_cli.services.config_service import ConfigService + +runner = CliRunner() + +# --------------------------------------------------------------------------- +# Shared sample data +# --------------------------------------------------------------------------- + +SAMPLE_ROW = { + "id": "row-001", + "name": "My Row", + "description": "", + "configuration": { + "parameters": { + "table": "orders", + "limit": 1000, + } + }, + "isDisabled": False, + "project_alias": "prod", + "branch_id": None, +} + +OAUTH_RESULT = { + "url": ( + "https://external.keboola.com/oauth/index.html" + "?token=abc123&sapiUrl=https%3A%2F%2Fconnection.keboola.com" + "#/keboola.ex-google-drive/cfg-001" + ), + "component_id": "keboola.ex-google-drive", + "config_id": "cfg-001", + "project_alias": "prod", +} + + +def _make_service(tmp_config_dir: Path) -> ConfigService: + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.get_config_row.return_value = { + "id": "row-001", + "name": "My Row", + "configuration": {"parameters": {"table": "orders", "limit": 1000}}, + } + mock_client.create_config_row.return_value = {**SAMPLE_ROW, "id": "row-new"} + mock_client.update_config_row.return_value = {**SAMPLE_ROW, "name": "Updated Row"} + return ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + +def _invoke(tmp_config_dir: Path, subcmd: str, args: list[str]) -> object: + return runner.invoke( + app, + ["--json", "--config-dir", str(tmp_config_dir), "config", subcmd, *args], + ) + + +# --------------------------------------------------------------------------- +# config row-create CLI tests +# --------------------------------------------------------------------------- + + +class TestConfigRowCreateCli: + """CLI-level tests for config row-create.""" + + def test_minimal_create_success(self, tmp_config_dir: Path) -> None: + """Minimal create (name only) returns exit 0 and status=ok.""" + service = _make_service(tmp_config_dir) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-create", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-db-snowflake", + "--config-id", + "cfg-001", + "--name", + "My Row", + ], + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["status"] == "ok" + + def test_create_with_configuration_json(self, tmp_config_dir: Path) -> None: + """--configuration accepts inline JSON and sends it to service.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.create_config_row.return_value = {**SAMPLE_ROW, "id": "row-x"} + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + cfg = {"parameters": {"table": "invoices"}} + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-create", + [ + "--project", + "prod", + "--component-id", + "comp", + "--config-id", + "cfg-001", + "--name", + "Row", + "--configuration", + json.dumps(cfg), + ], + ) + + assert result.exit_code == 0, result.output + call_kwargs = mock_client.create_config_row.call_args.kwargs + assert call_kwargs["configuration"] == cfg + + def test_create_invalid_json_exits_2(self, tmp_config_dir: Path) -> None: + """Invalid JSON in --configuration gives exit code 2.""" + service = _make_service(tmp_config_dir) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-create", + [ + "--project", + "prod", + "--component-id", + "comp", + "--config-id", + "cfg-001", + "--name", + "Row", + "--configuration", + "NOT_JSON", + ], + ) + + assert result.exit_code == 2 + + def test_create_missing_name_exits_nonzero(self, tmp_config_dir: Path) -> None: + """Missing --name causes non-zero exit (Typer validation).""" + result = runner.invoke( + app, + [ + "--json", + "--config-dir", + str(tmp_config_dir), + "config", + "row-create", + "--project", + "prod", + "--component-id", + "comp", + "--config-id", + "cfg-001", + ], + ) + assert result.exit_code != 0 + + def test_create_with_file(self, tmp_config_dir: Path, tmp_path: Path) -> None: + """--configuration @file reads JSON from disk.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.create_config_row.return_value = {**SAMPLE_ROW, "id": "row-file"} + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + cfg = {"parameters": {"source": "file"}} + cfg_file = tmp_path / "row.json" + cfg_file.write_text(json.dumps(cfg)) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-create", + [ + "--project", + "prod", + "--component-id", + "comp", + "--config-id", + "cfg-001", + "--name", + "Row", + "--configuration", + f"@{cfg_file}", + ], + ) + + assert result.exit_code == 0, result.output + call_kwargs = mock_client.create_config_row.call_args.kwargs + assert call_kwargs["configuration"] == cfg + + +# --------------------------------------------------------------------------- +# config row-update CLI tests +# --------------------------------------------------------------------------- + + +class TestConfigRowUpdateCli: + """CLI-level tests for config row-update.""" + + def test_name_update_success(self, tmp_config_dir: Path) -> None: + """Updating only --name returns exit 0.""" + service = _make_service(tmp_config_dir) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-update", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-db-snowflake", + "--config-id", + "cfg-001", + "--row-id", + "row-001", + "--name", + "New Name", + ], + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["status"] == "ok" + + def test_set_flag(self, tmp_config_dir: Path) -> None: + """--set PATH=VALUE is parsed and forwarded to service.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.get_config_row.return_value = { + "id": "row-001", + "configuration": {"parameters": {"table": "orders", "limit": 1000}}, + } + mock_client.update_config_row.return_value = {**SAMPLE_ROW} + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-update", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-db-snowflake", + "--config-id", + "cfg-001", + "--row-id", + "row-001", + "--set", + "parameters.table=invoices", + ], + ) + + assert result.exit_code == 0, result.output + cfg = mock_client.update_config_row.call_args.kwargs["configuration"] + assert cfg["parameters"]["table"] == "invoices" + assert cfg["parameters"]["limit"] == 1000 # sibling preserved + + def test_dry_run_output(self, tmp_config_dir: Path) -> None: + """--dry-run shows diff in JSON mode.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.get_config_row.return_value = { + "id": "row-001", + "configuration": {"parameters": {"table": "orders"}}, + } + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-update", + [ + "--project", + "prod", + "--component-id", + "comp", + "--config-id", + "cfg-001", + "--row-id", + "row-001", + "--set", + "parameters.table=changed", + "--dry-run", + ], + ) + + assert result.exit_code == 0, result.output + output = json.loads(result.output) + data = output["data"] + assert data["dry_run"] is True + assert any("parameters.table" in c for c in data["changes"]) + mock_client.update_config_row.assert_not_called() + + def test_invalid_set_format_exits_2(self, tmp_config_dir: Path) -> None: + """--set without = sign gives exit code 2.""" + service = _make_service(tmp_config_dir) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-update", + [ + "--project", + "prod", + "--component-id", + "comp", + "--config-id", + "cfg-001", + "--row-id", + "row-001", + "--set", + "no-equals-sign", + ], + ) + + assert result.exit_code == 2 + + def test_no_changes_exits_nonzero(self, tmp_config_dir: Path) -> None: + """Providing no update options gives a non-zero exit (validation error).""" + service = _make_service(tmp_config_dir) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-update", + [ + "--project", + "prod", + "--component-id", + "comp", + "--config-id", + "cfg-001", + "--row-id", + "row-001", + ], + ) + + assert result.exit_code != 0 + + def test_configuration_inline_json(self, tmp_config_dir: Path) -> None: + """--configuration accepts inline JSON and sends full replace.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.update_config_row.return_value = {**SAMPLE_ROW} + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + new_cfg = {"parameters": {"table": "users"}} + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-update", + [ + "--project", + "prod", + "--component-id", + "comp", + "--config-id", + "cfg-001", + "--row-id", + "row-001", + "--configuration", + json.dumps(new_cfg), + ], + ) + + assert result.exit_code == 0, result.output + call_kwargs = mock_client.update_config_row.call_args.kwargs + assert call_kwargs["configuration"] == new_cfg + # No merge, so get_config_row should NOT have been called + mock_client.get_config_row.assert_not_called() + + +# --------------------------------------------------------------------------- +# config oauth-url CLI tests +# --------------------------------------------------------------------------- + + +class TestConfigOauthUrlCli: + """CLI-level tests for config oauth-url.""" + + def _make_oauth_service( + self, tmp_config_dir: Path, is_master_token: bool = True + ) -> ConfigService: + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.get_oauth_url.return_value = OAUTH_RESULT["url"] + mock_client.get_project_info.return_value = { + "id": "9001", + "description": "test-token", + "isMasterToken": is_master_token, + "owner": {"id": 1234, "name": "Test Project"}, + } + return ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + def test_returns_url_json(self, tmp_config_dir: Path) -> None: + """JSON mode returns url, component_id, config_id, project_alias.""" + service = self._make_oauth_service(tmp_config_dir) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "oauth-url", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-google-drive", + "--config-id", + "cfg-001", + ], + ) + + assert result.exit_code == 0, result.output + output = json.loads(result.output) + assert output["status"] == "ok" + data = output["data"] + assert "url" in data + assert data["url"].startswith("https://external.keboola.com") + assert data["component_id"] == "keboola.ex-google-drive" + assert data["config_id"] == "cfg-001" + assert data["project_alias"] == "prod" + + def test_api_error_exits_nonzero(self, tmp_config_dir: Path) -> None: + """API error from get_oauth_url gives non-zero exit code.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.get_project_info.return_value = { + "id": "9001", + "description": "test-token", + "isMasterToken": True, + "owner": {"id": 1234, "name": "Test"}, + } + mock_client.get_oauth_url.side_effect = KeboolaApiError( + status_code=403, error_code="ACCESS_DENIED", message="Forbidden" + ) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "oauth-url", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-google-drive", + "--config-id", + "cfg-001", + ], + ) + + assert result.exit_code != 0 + + def test_missing_project_exits_nonzero(self, tmp_config_dir: Path) -> None: + """Missing required --project causes non-zero exit.""" + result = runner.invoke( + app, + [ + "--json", + "--config-dir", + str(tmp_config_dir), + "config", + "oauth-url", + "--component-id", + "keboola.ex-google-drive", + "--config-id", + "cfg-001", + ], + ) + assert result.exit_code != 0 + + def test_redirect_url_propagates(self, tmp_config_dir: Path) -> None: + """--redirect-url is forwarded to the service and surfaced in JSON output.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.get_project_info.return_value = { + "id": "9001", + "description": "test-token", + "isMasterToken": True, + "owner": {"id": 1234, "name": "Test"}, + } + mock_client.get_oauth_url.return_value = ( + "https://external.keboola.com/oauth/index.html" + "?token=abc&sapiUrl=https%3A%2F%2Fconnection.keboola.com" + "&returnUrl=https%3A%2F%2Fexample.com%2Fdone" + "#/keboola.ex-google-drive/cfg-001" + ) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "oauth-url", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-google-drive", + "--config-id", + "cfg-001", + "--redirect-url", + "https://example.com/done", + ], + ) + + assert result.exit_code == 0, result.output + kwargs = mock_client.get_oauth_url.call_args.kwargs + assert kwargs["redirect_url"] == "https://example.com/done" + output = json.loads(result.output) + assert output["data"]["redirect_url"] == "https://example.com/done" + + +# --------------------------------------------------------------------------- +# is_disabled / is_enabled CLI tests +# --------------------------------------------------------------------------- + + +class TestRowDisableFlagsCli: + """CLI-level tests for --is-disabled / --is-enabled on row-create / row-update.""" + + def test_create_with_is_disabled(self, tmp_config_dir: Path) -> None: + """row-create --is-disabled forwards is_disabled=True to the client.""" + service = _make_service(tmp_config_dir) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-create", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-mysql", + "--config-id", + "cfg-001", + "--name", + "New Row", + "--is-disabled", + ], + ) + + assert result.exit_code == 0, result.output + + def test_update_is_disabled_and_is_enabled_mutually_exclusive( + self, tmp_config_dir: Path + ) -> None: + """Passing both --is-disabled and --is-enabled exits 2.""" + service = _make_service(tmp_config_dir) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-update", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-mysql", + "--config-id", + "cfg-001", + "--row-id", + "row-001", + "--is-disabled", + "--is-enabled", + ], + ) + + assert result.exit_code == 2 + + def test_update_is_enabled_alone_is_valid(self, tmp_config_dir: Path) -> None: + """row-update --is-enabled (without other flags) is valid; forwards False.""" + service = _make_service(tmp_config_dir) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-update", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-mysql", + "--config-id", + "cfg-001", + "--row-id", + "row-001", + "--is-enabled", + ], + ) + + assert result.exit_code == 0, result.output + + +class TestOauthUrlMasterTokenGate: + """CLI-level tests for the master-token pre-flight on `config oauth-url`.""" + + def test_non_master_token_exits_3(self, tmp_config_dir: Path) -> None: + """Non-master token -> exit 3 (auth) with MISSING_MASTER_TOKEN code.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.get_project_info.return_value = { + "id": "10851170", + "description": "kbagent-cli [petr@keboola.com]", + "isMasterToken": False, + "owner": {"id": 901, "name": "Padak"}, + } + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "oauth-url", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-google-drive", + "--config-id", + "cfg-001", + ], + ) + + assert result.exit_code == 3, result.output + output = json.loads(result.output) + assert output["status"] == "error" + assert output["error"]["code"] == "MISSING_MASTER_TOKEN" + # No URL should ever be minted on a non-master token. + mock_client.get_oauth_url.assert_not_called() + + +class TestConfigRowDeleteCli: + """CLI-level tests for `config row-delete`.""" + + def test_delete_with_yes_flag(self, tmp_config_dir: Path) -> None: + """--yes skips confirmation and deletes the row.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.delete_config_row.return_value = None + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-delete", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-mysql", + "--config-id", + "cfg-001", + "--row-id", + "row-001", + "--yes", + ], + ) + + assert result.exit_code == 0, result.output + output = json.loads(result.output) + assert output["status"] == "ok" + assert output["data"]["deleted"] is True + assert output["data"]["row_id"] == "row-001" + mock_client.delete_config_row.assert_called_once() + + def test_delete_json_mode_skips_prompt(self, tmp_config_dir: Path) -> None: + """--json mode skips interactive prompt even without --yes.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.delete_config_row.return_value = None + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + # Note: _invoke already passes --json + result = _invoke( + tmp_config_dir, + "row-delete", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-mysql", + "--config-id", + "cfg-001", + "--row-id", + "row-001", + ], + ) + + assert result.exit_code == 0, result.output + mock_client.delete_config_row.assert_called_once() + + def test_delete_404_returns_nonzero_exit(self, tmp_config_dir: Path) -> None: + """Delete on a non-existent row -> non-zero exit, NOT_FOUND error code.""" + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.delete_config_row.side_effect = KeboolaApiError( + status_code=404, error_code="NOT_FOUND", message="Row not found" + ) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.MonkeyPatch.context() as mp: + mp.setattr( + "keboola_agent_cli.commands.config.get_service", + lambda ctx, name: service, + ) + result = _invoke( + tmp_config_dir, + "row-delete", + [ + "--project", + "prod", + "--component-id", + "keboola.ex-mysql", + "--config-id", + "cfg-001", + "--row-id", + "missing", + "--yes", + ], + ) + + assert result.exit_code != 0 + output = json.loads(result.output) + assert output["error"]["code"] == "NOT_FOUND" + + def test_delete_missing_row_id_exits_nonzero(self, tmp_config_dir: Path) -> None: + """Missing required --row-id causes non-zero exit (Typer validation).""" + result = runner.invoke( + app, + [ + "--json", + "--config-dir", + str(tmp_config_dir), + "config", + "row-delete", + "--project", + "prod", + "--component-id", + "comp", + "--config-id", + "cfg", + ], + ) + assert result.exit_code != 0 diff --git a/tests/test_config_row_service.py b/tests/test_config_row_service.py new file mode 100644 index 00000000..6a0e7cab --- /dev/null +++ b/tests/test_config_row_service.py @@ -0,0 +1,577 @@ +"""Tests for ConfigService.create_config_row, update_config_row, and get_oauth_url.""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from helpers import setup_single_project +from keboola_agent_cli.errors import KeboolaApiError +from keboola_agent_cli.services.config_service import ConfigService + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +SAMPLE_ROW = { + "id": "row-001", + "name": "My Row", + "description": "", + "configuration": { + "parameters": { + "table": "orders", + "limit": 1000, + } + }, + "isDisabled": False, +} + + +def _make_service( + tmp_config_dir: Path, is_master_token: bool = True +) -> tuple[ConfigService, MagicMock]: + """Create a ConfigService backed by a mock client. + + By default the mock token is a master token so OAuth pre-flight passes. + Tests that need to exercise the non-master branch override via + ``is_master_token=False``. + """ + store = setup_single_project(tmp_config_dir) + mock_client = MagicMock() + mock_client.get_config_row.return_value = SAMPLE_ROW + mock_client.create_config_row.return_value = {**SAMPLE_ROW, "id": "row-new"} + mock_client.update_config_row.return_value = {**SAMPLE_ROW, "name": "Updated Row"} + mock_client.get_oauth_url.return_value = ( + "https://external.keboola.com/oauth/index.html" + "?token=abc123&sapiUrl=https%3A%2F%2Fconnection.keboola.com" + "#/keboola.ex-google-drive/cfg-001" + ) + mock_client.get_project_info.return_value = { + "id": "9001", + "description": "test-token", + "isMasterToken": is_master_token, + "owner": {"id": 1234, "name": "Test Project"}, + } + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + return service, mock_client + + +# --------------------------------------------------------------------------- +# create_config_row tests +# --------------------------------------------------------------------------- + + +class TestCreateConfigRow: + """Tests for ConfigService.create_config_row.""" + + def test_create_minimal(self, tmp_config_dir: Path) -> None: + """Creates a row with name only; configuration defaults to {}.""" + service, client = _make_service(tmp_config_dir) + + result = service.create_config_row( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="cfg-001", + name="My Row", + ) + + client.create_config_row.assert_called_once() + call_kwargs = client.create_config_row.call_args.kwargs + assert call_kwargs["name"] == "My Row" + assert call_kwargs["configuration"] == {} + assert call_kwargs["description"] == "" + assert result["project_alias"] == "prod" + + def test_create_with_configuration(self, tmp_config_dir: Path) -> None: + """Creates a row with explicit configuration dict.""" + service, client = _make_service(tmp_config_dir) + cfg = {"parameters": {"table": "invoices"}} + + service.create_config_row( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="cfg-001", + name="Invoices Row", + configuration=cfg, + ) + + call_kwargs = client.create_config_row.call_args.kwargs + assert call_kwargs["configuration"] == cfg + + def test_create_with_description(self, tmp_config_dir: Path) -> None: + """Creates a row with optional description.""" + service, client = _make_service(tmp_config_dir) + + service.create_config_row( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="cfg-001", + name="Row", + description="Row for invoices table", + ) + + call_kwargs = client.create_config_row.call_args.kwargs + assert call_kwargs["description"] == "Row for invoices table" + + def test_create_with_branch(self, tmp_config_dir: Path) -> None: + """Branch ID is passed through to the client.""" + service, client = _make_service(tmp_config_dir) + + service.create_config_row( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="cfg-001", + name="Row", + branch_id=42, + ) + + call_kwargs = client.create_config_row.call_args.kwargs + assert call_kwargs["branch_id"] == 42 + + def test_result_contains_project_alias(self, tmp_config_dir: Path) -> None: + """Result dict is enriched with project_alias.""" + service, _ = _make_service(tmp_config_dir) + + result = service.create_config_row( + alias="prod", + component_id="comp", + config_id="cfg", + name="Row", + ) + + assert result["project_alias"] == "prod" + + def test_api_error_propagates(self, tmp_config_dir: Path) -> None: + """KeboolaApiError from client propagates to caller.""" + service, client = _make_service(tmp_config_dir) + client.create_config_row.side_effect = KeboolaApiError( + status_code=404, error_code="NOT_FOUND", message="Config not found" + ) + + with pytest.raises(KeboolaApiError, match="Config not found"): + service.create_config_row( + alias="prod", + component_id="comp", + config_id="cfg-missing", + name="Row", + ) + + +# --------------------------------------------------------------------------- +# update_config_row tests +# --------------------------------------------------------------------------- + + +class TestUpdateConfigRow: + """Tests for ConfigService.update_config_row.""" + + def test_name_only_update(self, tmp_config_dir: Path) -> None: + """Updating only the name does not fetch current config.""" + service, client = _make_service(tmp_config_dir) + + service.update_config_row( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="cfg-001", + row_id="row-001", + name="New Name", + ) + + call_kwargs = client.update_config_row.call_args.kwargs + assert call_kwargs["name"] == "New Name" + assert call_kwargs["configuration"] is None + client.get_config_row.assert_not_called() + + def test_full_replace(self, tmp_config_dir: Path) -> None: + """Without --merge, configuration is sent as-is (no fetch needed).""" + service, client = _make_service(tmp_config_dir) + new_cfg = {"parameters": {"table": "users"}} + + service.update_config_row( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="cfg-001", + row_id="row-001", + configuration=new_cfg, + ) + + call_kwargs = client.update_config_row.call_args.kwargs + assert call_kwargs["configuration"] == new_cfg + client.get_config_row.assert_not_called() + + def test_merge_preserves_siblings(self, tmp_config_dir: Path) -> None: + """With merge=True, sibling keys are preserved.""" + service, client = _make_service(tmp_config_dir) + partial = {"parameters": {"limit": 9999}} + + service.update_config_row( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="cfg-001", + row_id="row-001", + configuration=partial, + merge=True, + ) + + merged = client.update_config_row.call_args.kwargs["configuration"] + # Original key preserved + assert merged["parameters"]["table"] == "orders" + # Merged key applied + assert merged["parameters"]["limit"] == 9999 + + def test_set_path_preserves_siblings(self, tmp_config_dir: Path) -> None: + """--set targets a specific key without touching siblings.""" + service, client = _make_service(tmp_config_dir) + + service.update_config_row( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="cfg-001", + row_id="row-001", + set_paths=[("parameters.table", "invoices")], + ) + + cfg = client.update_config_row.call_args.kwargs["configuration"] + # Changed key + assert cfg["parameters"]["table"] == "invoices" + # Sibling key preserved + assert cfg["parameters"]["limit"] == 1000 + + def test_dry_run_returns_diff(self, tmp_config_dir: Path) -> None: + """dry_run=True returns changes without calling update_config_row.""" + service, client = _make_service(tmp_config_dir) + + result = service.update_config_row( + alias="prod", + component_id="keboola.ex-db-snowflake", + config_id="cfg-001", + row_id="row-001", + set_paths=[("parameters.table", "changed_table")], + dry_run=True, + ) + + assert result["dry_run"] is True + assert len(result["changes"]) >= 1 + assert any("parameters.table" in c for c in result["changes"]) + client.update_config_row.assert_not_called() + + def test_validation_error_when_nothing_provided(self, tmp_config_dir: Path) -> None: + """Raise error if no metadata or configuration is given.""" + service, _ = _make_service(tmp_config_dir) + + with pytest.raises(KeboolaApiError, match="must be provided"): + service.update_config_row( + alias="prod", + component_id="comp", + config_id="cfg", + row_id="row-001", + ) + + def test_result_contains_project_alias(self, tmp_config_dir: Path) -> None: + """Result dict is enriched with project_alias.""" + service, _ = _make_service(tmp_config_dir) + + result = service.update_config_row( + alias="prod", + component_id="comp", + config_id="cfg", + row_id="row-001", + name="New Name", + ) + + assert result["project_alias"] == "prod" + + def test_branch_id_passed_through(self, tmp_config_dir: Path) -> None: + """branch_id is forwarded to the client.""" + service, client = _make_service(tmp_config_dir) + + service.update_config_row( + alias="prod", + component_id="comp", + config_id="cfg", + row_id="row-001", + name="Name", + branch_id=99, + ) + + call_kwargs = client.update_config_row.call_args.kwargs + assert call_kwargs["branch_id"] == 99 + + +# --------------------------------------------------------------------------- +# delete_config_row tests +# --------------------------------------------------------------------------- + + +class TestDeleteConfigRow: + """Tests for ConfigService.delete_config_row.""" + + def test_delete_calls_client(self, tmp_config_dir: Path) -> None: + """Service forwards row identifiers to client.delete_config_row.""" + service, client = _make_service(tmp_config_dir) + + result = service.delete_config_row( + alias="prod", + component_id="keboola.ex-mysql", + config_id="cfg-001", + row_id="row-001", + ) + + client.delete_config_row.assert_called_once_with( + component_id="keboola.ex-mysql", + config_id="cfg-001", + row_id="row-001", + branch_id=None, + ) + assert result["deleted"] is True + assert result["project_alias"] == "prod" + assert result["row_id"] == "row-001" + + def test_delete_with_branch(self, tmp_config_dir: Path) -> None: + """Branch ID is forwarded to the client.""" + service, client = _make_service(tmp_config_dir) + + service.delete_config_row( + alias="prod", + component_id="keboola.ex-mysql", + config_id="cfg-001", + row_id="row-001", + branch_id=42, + ) + + kwargs = client.delete_config_row.call_args.kwargs + assert kwargs["branch_id"] == 42 + + def test_delete_propagates_api_error(self, tmp_config_dir: Path) -> None: + """Storage API 404 (row not found) propagates as KeboolaApiError.""" + service, client = _make_service(tmp_config_dir) + client.delete_config_row.side_effect = KeboolaApiError( + status_code=404, error_code="NOT_FOUND", message="Row not found" + ) + + with pytest.raises(KeboolaApiError, match="Row not found"): + service.delete_config_row( + alias="prod", + component_id="keboola.ex-mysql", + config_id="cfg-001", + row_id="missing-row", + ) + + +# --------------------------------------------------------------------------- +# get_oauth_url tests +# --------------------------------------------------------------------------- + + +class TestGetOauthUrl: + """Tests for ConfigService.get_oauth_url.""" + + def test_returns_url(self, tmp_config_dir: Path) -> None: + """Returns a dict with a 'url' key containing the OAuth URL.""" + service, _client = _make_service(tmp_config_dir) + + result = service.get_oauth_url( + alias="prod", + component_id="keboola.ex-google-drive", + config_id="cfg-001", + ) + + assert "url" in result + assert result["url"].startswith("https://external.keboola.com") + assert result["component_id"] == "keboola.ex-google-drive" + assert result["config_id"] == "cfg-001" + assert result["project_alias"] == "prod" + + def test_client_called_with_correct_args(self, tmp_config_dir: Path) -> None: + """The client's get_oauth_url is invoked with component_id and config_id.""" + service, client = _make_service(tmp_config_dir) + + service.get_oauth_url( + alias="prod", + component_id="keboola.ex-gmail", + config_id="gmail-cfg", + ) + + client.get_oauth_url.assert_called_once_with( + component_id="keboola.ex-gmail", + config_id="gmail-cfg", + redirect_url=None, + ) + + def test_api_error_propagates(self, tmp_config_dir: Path) -> None: + """KeboolaApiError from token creation propagates to caller.""" + service, client = _make_service(tmp_config_dir) + client.get_oauth_url.side_effect = KeboolaApiError( + status_code=403, error_code="ACCESS_DENIED", message="Token creation denied" + ) + + with pytest.raises(KeboolaApiError, match="Token creation denied"): + service.get_oauth_url( + alias="prod", + component_id="keboola.ex-google-drive", + config_id="cfg-001", + ) + + def test_redirect_url_propagates_to_client(self, tmp_config_dir: Path) -> None: + """When redirect_url is provided, it is forwarded to the client.""" + service, client = _make_service(tmp_config_dir) + + result = service.get_oauth_url( + alias="prod", + component_id="keboola.ex-google-drive", + config_id="cfg-001", + redirect_url="https://example.com/oauth-done", + ) + + client.get_oauth_url.assert_called_once_with( + component_id="keboola.ex-google-drive", + config_id="cfg-001", + redirect_url="https://example.com/oauth-done", + ) + assert result["redirect_url"] == "https://example.com/oauth-done" + + def test_redirect_url_omitted_from_result_when_not_provided(self, tmp_config_dir: Path) -> None: + """Without redirect_url, the result dict does NOT contain a redirect_url key.""" + service, _client = _make_service(tmp_config_dir) + + result = service.get_oauth_url( + alias="prod", + component_id="keboola.ex-google-drive", + config_id="cfg-001", + ) + + assert "redirect_url" not in result + + def test_non_master_token_fails_with_missing_master_token(self, tmp_config_dir: Path) -> None: + """Non-master token short-circuits before any HTTP write happens. + + OAuth URL generation calls POST /v2/storage/tokens (via + create_short_lived_token) which requires canManageTokens privilege, + a master-token-only capability. Without this guard the Storage API + returns a vague 500 'Application error' that misleads operators. + """ + service, client = _make_service(tmp_config_dir, is_master_token=False) + + with pytest.raises(KeboolaApiError) as excinfo: + service.get_oauth_url( + alias="prod", + component_id="keboola.ex-google-drive", + config_id="cfg-001", + ) + + assert excinfo.value.error_code == "MISSING_MASTER_TOKEN" + assert "master" in excinfo.value.message.lower() + # Most importantly: the actual mint endpoint MUST NOT have been called. + client.get_oauth_url.assert_not_called() + + def test_master_token_proceeds_to_url_generation(self, tmp_config_dir: Path) -> None: + """Master token passes pre-flight and reaches the underlying client call.""" + service, client = _make_service(tmp_config_dir, is_master_token=True) + + service.get_oauth_url( + alias="prod", + component_id="keboola.ex-google-drive", + config_id="cfg-001", + ) + + client.get_oauth_url.assert_called_once() + + +# --------------------------------------------------------------------------- +# is_disabled / is_enabled tests +# --------------------------------------------------------------------------- + + +class TestRowIsDisabledFlag: + """Tests for ``--is-disabled`` / ``--is-enabled`` semantics.""" + + def test_create_with_is_disabled_true(self, tmp_config_dir: Path) -> None: + """create_config_row with is_disabled=True forwards the flag to the client.""" + service, client = _make_service(tmp_config_dir) + + service.create_config_row( + alias="prod", + component_id="keboola.ex-mysql", + config_id="cfg-001", + name="My Row", + is_disabled=True, + ) + + kwargs = client.create_config_row.call_args.kwargs + assert kwargs["is_disabled"] is True + + def test_create_default_is_not_disabled(self, tmp_config_dir: Path) -> None: + """create_config_row defaults to is_disabled=False.""" + service, client = _make_service(tmp_config_dir) + + service.create_config_row( + alias="prod", + component_id="keboola.ex-mysql", + config_id="cfg-001", + name="My Row", + ) + + kwargs = client.create_config_row.call_args.kwargs + assert kwargs["is_disabled"] is False + + def test_update_with_is_disabled_true(self, tmp_config_dir: Path) -> None: + """update_config_row with is_disabled=True forwards True to the client.""" + service, client = _make_service(tmp_config_dir) + + service.update_config_row( + alias="prod", + component_id="keboola.ex-mysql", + config_id="cfg-001", + row_id="row-001", + is_disabled=True, + ) + + kwargs = client.update_config_row.call_args.kwargs + assert kwargs["is_disabled"] is True + + def test_update_with_is_disabled_false_means_enable(self, tmp_config_dir: Path) -> None: + """update_config_row with is_disabled=False forwards False (re-enable).""" + service, client = _make_service(tmp_config_dir) + + service.update_config_row( + alias="prod", + component_id="keboola.ex-mysql", + config_id="cfg-001", + row_id="row-001", + is_disabled=False, + ) + + kwargs = client.update_config_row.call_args.kwargs + assert kwargs["is_disabled"] is False + + def test_update_with_is_disabled_none_omits_field(self, tmp_config_dir: Path) -> None: + """update_config_row with is_disabled=None forwards None (no change).""" + service, client = _make_service(tmp_config_dir) + + service.update_config_row( + alias="prod", + component_id="keboola.ex-mysql", + config_id="cfg-001", + row_id="row-001", + name="Just rename", + ) + + kwargs = client.update_config_row.call_args.kwargs + assert kwargs["is_disabled"] is None + + def test_update_with_only_is_disabled_is_valid(self, tmp_config_dir: Path) -> None: + """is_disabled alone (no other field) does NOT raise the 'must provide' error.""" + service, client = _make_service(tmp_config_dir) + + service.update_config_row( + alias="prod", + component_id="keboola.ex-mysql", + config_id="cfg-001", + row_id="row-001", + is_disabled=True, + ) + + client.update_config_row.assert_called_once() diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 52985bb2..6dedf575 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -7004,3 +7004,351 @@ def _run(*args: str) -> dict: assert invite_email.casefold() not in final_emails, ( f"{invite_email} still pending after cancel: {final_emails}" ) + + +# --------------------------------------------------------------------------- +# MCP-parity commands (since v0.30.0) +# --------------------------------------------------------------------------- + + +@skip_without_credentials +@pytest.mark.e2e +class TestE2EMcpParityCommands: + """E2E coverage for the 0.30.0 MCP-parity commands. + + Lives apart from the giant TestFullE2E to keep cleanup tight and let + `pytest -k Mcp` exercise just these flows when iterating. + + Covered: + - ``kbagent project info`` -- full project metadata + - ``kbagent config row-create`` / ``row-update`` / ``row-delete`` -- + complete row CRUD against a throwaway ``ex-generic-v2`` config + created and torn down inside the test class + - ``kbagent search`` -- pre-flight feature gate path (asserts + either real results OR a clean ``FEATURE_NOT_ENABLED`` error, + never the raw 404 the API returns without the gate) + - ``kbagent config oauth-url`` -- master-token pre-flight path + (asserts either a real URL OR ``MISSING_MASTER_TOKEN`` exit 3) + """ + + @pytest.fixture(autouse=True) + def setup(self, tmp_path: Path) -> Any: + self.token = os.environ[ENV_TOKEN] + raw_url = os.environ.get(ENV_URL, "connection.keboola.com") + self.url = raw_url if raw_url.startswith("https://") else f"https://{raw_url}" + self.alias = f"{RUN_ID}-mcpparity"[:60] + + self.config_dir = tmp_path / "config" + self.config_dir.mkdir() + + result = _invoke( + self.config_dir, + [ + "--json", + "project", + "add", + "--project", + self.alias, + "--url", + self.url, + "--token", + self.token, + ], + ) + assert result.exit_code == 0, f"project add failed: {result.output}" + + self.client = KeboolaClient(stack_url=self.url, token=self.token) + self._configs: list[tuple[str, str]] = [] # (component_id, config_id) + + yield + + # Cleanup: delete the throwaway configs we created for row CRUD tests + for comp_id, cfg_id in self._configs: + try: + self.client.delete_config(component_id=comp_id, config_id=cfg_id) + except Exception as exc: + print(f" WARN: delete_config {comp_id}/{cfg_id}: {exc}") + self.client.close() + + def _run(self, *args: str) -> Any: + return _invoke(self.config_dir, ["--json", *args]) + + def _run_ok(self, *args: str) -> dict[str, Any]: + return _json_ok(self._run(*args)) + + def _create_throwaway_config(self) -> str: + """Create an ex-generic-v2 config we own and will delete in cleanup.""" + cfg = self.client.create_config( + component_id="ex-generic-v2", + name=f"{RUN_ID}-mcpparity-rows", + description="E2E throwaway -- row CRUD test harness", + configuration={}, + ) + cfg_id = str(cfg["id"]) + self._configs.append(("ex-generic-v2", cfg_id)) + return cfg_id + + # ------------------------------------------------------------------ + # project info + # ------------------------------------------------------------------ + + def test_project_info_returns_full_metadata(self) -> None: + """`project info` returns alias, project_id, features, limits, metrics, token info.""" + data = self._run_ok("project", "info", "--project", self.alias)["data"] + + assert data["alias"] == self.alias + assert isinstance(data["project_id"], int) + assert data["project_id"] > 0 + assert data["project_name"] + assert data["stack_url"] == self.url + assert isinstance(data["features"], list) + assert isinstance(data["limits"], dict) + assert isinstance(data["metrics"], dict) + assert "is_master_token" in data + assert "token_id" in data + + # ------------------------------------------------------------------ + # config row-create / row-update / row-delete + # ------------------------------------------------------------------ + + def test_config_row_lifecycle(self) -> None: + """row-create -> row-update (--set + --merge + --is-disabled/--is-enabled + --dry-run) + -> row-delete -> 404 on re-delete.""" + cfg_id = self._create_throwaway_config() + + # 1. row-create with inline JSON + create = self._run_ok( + "config", + "row-create", + "--project", + self.alias, + "--component-id", + "ex-generic-v2", + "--config-id", + cfg_id, + "--name", + f"{RUN_ID}-row", + "--description", + "E2E test row", + "--configuration", + '{"parameters": {"endpoint": "/users", "limit": 100}}', + )["data"] + row_id = create["id"] + assert create["name"] == f"{RUN_ID}-row" + assert create["configuration"]["parameters"]["endpoint"] == "/users" + assert create["isDisabled"] is False + + # 2. row-update --set: preserves siblings, changes one key + upd_set = self._run_ok( + "config", + "row-update", + "--project", + self.alias, + "--component-id", + "ex-generic-v2", + "--config-id", + cfg_id, + "--row-id", + row_id, + "--set", + "parameters.limit=999", + )["data"] + params = upd_set["configuration"]["parameters"] + assert params["endpoint"] == "/users", "sibling preserved" + assert params["limit"] == 999, "set applied" + + # 3. row-update --merge: deep-merge, preserves all siblings + upd_merge = self._run_ok( + "config", + "row-update", + "--project", + self.alias, + "--component-id", + "ex-generic-v2", + "--config-id", + cfg_id, + "--row-id", + row_id, + "--merge", + "--configuration", + '{"parameters": {"timeout": 30}}', + )["data"] + params = upd_merge["configuration"]["parameters"] + assert params["endpoint"] == "/users" + assert params["limit"] == 999 + assert params["timeout"] == 30 + + # 4. row-update --dry-run: previews changes without writing + dry = self._run_ok( + "config", + "row-update", + "--project", + self.alias, + "--component-id", + "ex-generic-v2", + "--config-id", + cfg_id, + "--row-id", + row_id, + "--set", + "parameters.endpoint=/preview", + "--dry-run", + )["data"] + assert dry["dry_run"] is True + assert dry["new_configuration"]["parameters"]["endpoint"] == "/preview" + # Assert dry-run did NOT persist + check = self._run_ok( + "config", + "row-update", + "--project", + self.alias, + "--component-id", + "ex-generic-v2", + "--config-id", + cfg_id, + "--row-id", + row_id, + "--description", + "noop verify", + )["data"] + assert check["configuration"]["parameters"]["endpoint"] == "/users" + + # 5. row-update --is-disabled toggles isDisabled flag + disabled = self._run_ok( + "config", + "row-update", + "--project", + self.alias, + "--component-id", + "ex-generic-v2", + "--config-id", + cfg_id, + "--row-id", + row_id, + "--is-disabled", + )["data"] + assert disabled["isDisabled"] is True + + enabled = self._run_ok( + "config", + "row-update", + "--project", + self.alias, + "--component-id", + "ex-generic-v2", + "--config-id", + cfg_id, + "--row-id", + row_id, + "--is-enabled", + )["data"] + assert enabled["isDisabled"] is False + + # 6. row-update with --is-disabled AND --is-enabled exits 2 + result = self._run( + "config", + "row-update", + "--project", + self.alias, + "--component-id", + "ex-generic-v2", + "--config-id", + cfg_id, + "--row-id", + row_id, + "--is-disabled", + "--is-enabled", + ) + assert result.exit_code == 2 + + # 7. row-delete (with --yes to skip confirmation) + deleted = self._run_ok( + "config", + "row-delete", + "--project", + self.alias, + "--component-id", + "ex-generic-v2", + "--config-id", + cfg_id, + "--row-id", + row_id, + "--yes", + )["data"] + assert deleted["deleted"] is True + assert deleted["row_id"] == row_id + + # 8. Re-delete the same row -> 404 NOT_FOUND (deletion is NOT idempotent) + result = self._run( + "config", + "row-delete", + "--project", + self.alias, + "--component-id", + "ex-generic-v2", + "--config-id", + cfg_id, + "--row-id", + row_id, + "--yes", + ) + assert result.exit_code != 0 + envelope = json.loads(result.output) + assert envelope["error"]["code"] == "NOT_FOUND" + + # ------------------------------------------------------------------ + # search (feature-gate aware) + # ------------------------------------------------------------------ + + def test_search_returns_results_or_feature_gate_error(self) -> None: + """Either the project has `global-search` and we get results, or we get + a clean FEATURE_NOT_ENABLED per-project error -- never a raw 404.""" + result = self._run_ok("search", "data", "--project", self.alias, "--limit", "5") + data = result["data"] + assert "results" in data + assert "errors" in data + assert "stats" in data + + if data["errors"]: + # Project does not have the feature -- the pre-flight check kicks in + err = data["errors"][0] + assert err["error_code"] == "FEATURE_NOT_ENABLED", err + assert "global-search" in err["message"] + # If feature is enabled, results may or may not be empty; both are valid. + + # ------------------------------------------------------------------ + # config oauth-url (master-token gate) + # ------------------------------------------------------------------ + + def test_config_oauth_url_master_token_gate(self) -> None: + """Either we have a master token and get a URL, or we exit 3 with + MISSING_MASTER_TOKEN -- never a raw 500 from the underlying API.""" + # Need a real component_id + config_id to attempt OAuth on. We use a + # throwaway config (the OAuth URL builder doesn't actually validate the + # component supports OAuth -- it just embeds the IDs in the URL fragment). + cfg_id = self._create_throwaway_config() + result = self._run( + "config", + "oauth-url", + "--project", + self.alias, + "--component-id", + "keboola.ex-google-drive", + "--config-id", + cfg_id, + ) + + envelope = json.loads(result.output) + if envelope["status"] == "ok": + # Master token: must return a well-formed URL + assert result.exit_code == 0 + url = envelope["data"]["url"] + assert url.startswith("https://external.keboola.com/oauth/index.html") + assert "token=" in url + assert "sapiUrl=" in url + assert f"/keboola.ex-google-drive/{cfg_id}" in url + else: + # Non-master token: must exit 3 with MISSING_MASTER_TOKEN + assert result.exit_code == 3 + assert envelope["error"]["code"] == "MISSING_MASTER_TOKEN" + assert "master" in envelope["error"]["message"].lower() diff --git a/tests/test_hints.py b/tests/test_hints.py index 8fb1408a..38dd4dae 100644 --- a/tests/test_hints.py +++ b/tests/test_hints.py @@ -543,6 +543,128 @@ def test_hint_skips_auto_update(self, tmp_path: Path) -> None: assert result.exit_code == 0 + def test_hint_client_config_row_create(self, tmp_path: Path) -> None: + """`--hint client config row-create` short-circuits before service call.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.client.KeboolaClient") as MockClient: + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--hint", + "client", + "config", + "row-create", + "--project", + "p", + "--component-id", + "keboola.ex-mysql", + "--config-id", + "cfg-1", + "--name", + "My Row", + ], + ) + # No HTTP client must be instantiated when hint guard is in place. + MockClient.assert_not_called() + + assert result.exit_code == 0, result.stdout + assert "create_config_row" in result.stdout + + def test_hint_client_config_row_update(self, tmp_path: Path) -> None: + """`--hint client config row-update` short-circuits before service call.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.client.KeboolaClient") as MockClient: + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--hint", + "client", + "config", + "row-update", + "--project", + "p", + "--component-id", + "keboola.ex-mysql", + "--config-id", + "cfg-1", + "--row-id", + "row-1", + "--name", + "Updated", + ], + ) + MockClient.assert_not_called() + + assert result.exit_code == 0, result.stdout + assert "update_config_row" in result.stdout + + def test_hint_client_config_oauth_url(self, tmp_path: Path) -> None: + """`--hint client config oauth-url` short-circuits before service call.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.client.KeboolaClient") as MockClient: + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--hint", + "client", + "config", + "oauth-url", + "--project", + "p", + "--component-id", + "keboola.ex-google-drive", + "--config-id", + "cfg-1", + ], + ) + MockClient.assert_not_called() + + assert result.exit_code == 0, result.stdout + assert "get_oauth_url" in result.stdout + + def test_hint_client_config_row_delete(self, tmp_path: Path) -> None: + """`--hint client config row-delete` short-circuits before service call.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + + with patch("keboola_agent_cli.client.KeboolaClient") as MockClient: + result = runner.invoke( + app, + [ + "--config-dir", + str(config_dir), + "--hint", + "client", + "config", + "row-delete", + "--project", + "p", + "--component-id", + "keboola.ex-mysql", + "--config-id", + "cfg-1", + "--row-id", + "row-1", + "--yes", + ], + ) + MockClient.assert_not_called() + + assert result.exit_code == 0, result.stdout + assert "delete_config_row" in result.stdout + # ── Security tests ───────────────────────────────────────────────── diff --git a/tests/test_project_info_cli.py b/tests/test_project_info_cli.py new file mode 100644 index 00000000..82b0d215 --- /dev/null +++ b/tests/test_project_info_cli.py @@ -0,0 +1,241 @@ +"""CLI tests for `kbagent project info` command.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError, KeboolaApiError +from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.services.project_service import ProjectService + +runner = CliRunner() + +FULL_INFO = { + "alias": "prod", + "project_id": 1234, + "project_name": "Production", + "stack_url": "https://connection.keboola.com", + "default_backend": "snowflake", + "features": ["storage-branches", "orchestrator-tasks"], + "limits": {"dataSizeBytes": {"name": "dataSizeBytes", "value": 5000000000}}, + "metrics": {"dataSizeBytes": 123456}, + "token_id": "99", + "token_description": "Agent token", + "is_master_token": False, + "token_expires": None, +} + + +def _make_service_with_info(info: dict, tmp_path: Path) -> ProjectService: + """Return a ProjectService whose get_info() returns the given dict.""" + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-xxx", + project_name="Production", + project_id=1234, + ), + ) + service = MagicMock(spec=ProjectService) + service.get_info.return_value = info + return service, store + + +class TestProjectInfoJson: + """Tests for `kbagent project info --json` output.""" + + def test_success_json(self, tmp_path: Path) -> None: + """project info --json emits structured ok response.""" + service, store = _make_service_with_info(FULL_INFO, tmp_path) + + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.ProjectService", return_value=service), + ): + result = runner.invoke( + app, + ["--json", "project", "info", "--project", "prod"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["status"] == "ok" + info = data["data"] + assert info["project_id"] == 1234 + assert info["project_name"] == "Production" + assert info["default_backend"] == "snowflake" + assert "storage-branches" in info["features"] + assert info["token_description"] == "Agent token" + assert info["is_master_token"] is False + assert info["token_expires"] is None + + def test_success_json_contains_limits_and_metrics(self, tmp_path: Path) -> None: + """project info --json includes limits and metrics dicts.""" + service, store = _make_service_with_info(FULL_INFO, tmp_path) + + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.ProjectService", return_value=service), + ): + result = runner.invoke( + app, + ["--json", "project", "info", "--project", "prod"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert "limits" in data["data"] + assert "metrics" in data["data"] + + def test_config_error_exit_5(self, tmp_path: Path) -> None: + """project info with unknown alias exits with code 5.""" + service, store = _make_service_with_info(FULL_INFO, tmp_path) + service.get_info.side_effect = ConfigError("Project 'missing' not found.") + + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.ProjectService", return_value=service), + ): + result = runner.invoke( + app, + ["--json", "project", "info", "--project", "missing"], + ) + + assert result.exit_code == 5 + data = json.loads(result.output) + assert data["status"] == "error" + assert "not found" in data["error"]["message"] + + def test_api_error_invalid_token_exit_3(self, tmp_path: Path) -> None: + """project info with bad token exits with code 3.""" + service, store = _make_service_with_info(FULL_INFO, tmp_path) + service.get_info.side_effect = KeboolaApiError( + message="Invalid token", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.ProjectService", return_value=service), + ): + result = runner.invoke( + app, + ["--json", "project", "info", "--project", "prod"], + ) + + assert result.exit_code == 3 + data = json.loads(result.output) + assert data["status"] == "error" + + def test_api_error_generic_exit_1(self, tmp_path: Path) -> None: + """project info with generic API error exits with code 1.""" + service, store = _make_service_with_info(FULL_INFO, tmp_path) + service.get_info.side_effect = KeboolaApiError( + message="Internal server error", + status_code=500, + error_code="INTERNAL_ERROR", + retryable=True, + ) + + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.ProjectService", return_value=service), + ): + result = runner.invoke( + app, + ["--json", "project", "info", "--project", "prod"], + ) + + assert result.exit_code == 1 + data = json.loads(result.output) + assert data["status"] == "error" + + +class TestProjectInfoHuman: + """Tests for `kbagent project info` human-readable output.""" + + def test_human_output_contains_key_fields(self, tmp_path: Path) -> None: + """project info human output contains project name and ID.""" + service, store = _make_service_with_info(FULL_INFO, tmp_path) + + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.ProjectService", return_value=service), + ): + result = runner.invoke( + app, + ["project", "info", "--project", "prod"], + catch_exceptions=False, + ) + + assert result.exit_code == 0, result.output + assert "Production" in result.output + assert "1234" in result.output + assert "snowflake" in result.output + + def test_human_output_shows_features(self, tmp_path: Path) -> None: + """project info human output lists feature flags.""" + service, store = _make_service_with_info(FULL_INFO, tmp_path) + + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.ProjectService", return_value=service), + ): + result = runner.invoke( + app, + ["project", "info", "--project", "prod"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "storage-branches" in result.output + + def test_human_no_features_shows_none(self, tmp_path: Path) -> None: + """project info human output gracefully handles empty features list.""" + info = dict(FULL_INFO) + info["features"] = [] + service, store = _make_service_with_info(info, tmp_path) + + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.ProjectService", return_value=service), + ): + result = runner.invoke( + app, + ["project", "info", "--project", "prod"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "(none)" in result.output + + def test_master_token_displays(self, tmp_path: Path) -> None: + """project info shows master token status.""" + info = dict(FULL_INFO) + info["is_master_token"] = True + service, store = _make_service_with_info(info, tmp_path) + + with ( + patch("keboola_agent_cli.cli.ConfigStore", return_value=store), + patch("keboola_agent_cli.cli.ProjectService", return_value=service), + ): + result = runner.invoke( + app, + ["project", "info", "--project", "prod"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + assert "Yes" in result.output diff --git a/tests/test_project_info_service.py b/tests/test_project_info_service.py new file mode 100644 index 00000000..e05c696f --- /dev/null +++ b/tests/test_project_info_service.py @@ -0,0 +1,179 @@ +"""Unit tests for ProjectService.get_info().""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError, KeboolaApiError +from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.services.project_service import ProjectService + +# Minimal raw API response from /v2/storage/tokens/verify +RAW_API_RESPONSE = { + "id": 99, + "description": "Agent token", + "isMasterToken": False, + "expires": None, + "owner": { + "id": 1234, + "name": "Production", + "defaultBackend": "snowflake", + "features": ["storage-branches", "orchestrator-tasks"], + "limits": { + "dataSizeBytes": {"name": "dataSizeBytes", "value": 5000000000}, + }, + "metrics": { + "dataSizeBytes": 123456, + }, + }, +} + + +def _make_service(tmp_config_dir: Path, raw_response: dict) -> ProjectService: + """Create ProjectService with one project and a client returning raw_response.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-xxx", + project_name="Production", + project_id=1234, + ), + ) + + mock_client = MagicMock() + mock_client.get_project_info.return_value = raw_response + + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + return service, mock_client + + +class TestGetInfo: + """Tests for ProjectService.get_info().""" + + def test_get_info_success_basic_fields(self, tmp_config_dir: Path) -> None: + """get_info returns structured dict with all expected fields.""" + service, _mock_client = _make_service(tmp_config_dir, RAW_API_RESPONSE) + result = service.get_info(alias="prod") + + assert result["alias"] == "prod" + assert result["project_id"] == 1234 + assert result["project_name"] == "Production" + assert result["stack_url"] == "https://connection.keboola.com" + assert result["default_backend"] == "snowflake" + + def test_get_info_token_fields(self, tmp_config_dir: Path) -> None: + """get_info extracts token metadata correctly.""" + service, _ = _make_service(tmp_config_dir, RAW_API_RESPONSE) + result = service.get_info(alias="prod") + + assert result["token_id"] == "99" + assert result["token_description"] == "Agent token" + assert result["is_master_token"] is False + assert result["token_expires"] is None + + def test_get_info_features_list(self, tmp_config_dir: Path) -> None: + """get_info includes the full features list from owner.features.""" + service, _ = _make_service(tmp_config_dir, RAW_API_RESPONSE) + result = service.get_info(alias="prod") + + assert "storage-branches" in result["features"] + assert "orchestrator-tasks" in result["features"] + + def test_get_info_limits_and_metrics(self, tmp_config_dir: Path) -> None: + """get_info includes limits and metrics dicts.""" + service, _ = _make_service(tmp_config_dir, RAW_API_RESPONSE) + result = service.get_info(alias="prod") + + assert "dataSizeBytes" in result["limits"] + assert result["metrics"]["dataSizeBytes"] == 123456 + + def test_get_info_calls_client_and_closes(self, tmp_config_dir: Path) -> None: + """get_info calls get_project_info() and always closes the client.""" + service, mock_client = _make_service(tmp_config_dir, RAW_API_RESPONSE) + service.get_info(alias="prod") + + mock_client.get_project_info.assert_called_once() + mock_client.close.assert_called_once() + + def test_get_info_missing_alias_raises_config_error(self, tmp_config_dir: Path) -> None: + """get_info raises ConfigError for an unknown alias.""" + store = ConfigStore(config_dir=tmp_config_dir) + service = ProjectService( + config_store=store, + client_factory=lambda url, token: MagicMock(), + ) + + with pytest.raises(ConfigError, match="not found"): + service.get_info(alias="nonexistent") + + def test_get_info_api_error_propagates(self, tmp_config_dir: Path) -> None: + """get_info propagates KeboolaApiError from the client.""" + store = ConfigStore(config_dir=tmp_config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token="901-xxx", + project_name="Production", + project_id=1234, + ), + ) + + mock_client = MagicMock() + mock_client.get_project_info.side_effect = KeboolaApiError( + message="Invalid token", + status_code=401, + error_code="INVALID_TOKEN", + retryable=False, + ) + + service = ProjectService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + with pytest.raises(KeboolaApiError) as exc_info: + service.get_info(alias="prod") + + assert exc_info.value.error_code == "INVALID_TOKEN" + # Client must still be closed even on error + mock_client.close.assert_called_once() + + def test_get_info_empty_owner_fields(self, tmp_config_dir: Path) -> None: + """get_info handles missing/empty owner fields gracefully.""" + sparse_response = { + "id": 42, + "description": "", + "isMasterToken": True, + "expires": "2030-01-01T00:00:00+00:00", + "owner": {}, + } + service, _ = _make_service(tmp_config_dir, sparse_response) + result = service.get_info(alias="prod") + + assert result["project_id"] is None + assert result["project_name"] == "" + assert result["default_backend"] == "snowflake" + assert result["features"] == [] + assert result["limits"] == {} + assert result["metrics"] == {} + assert result["is_master_token"] is True + assert result["token_expires"] == "2030-01-01T00:00:00+00:00" + + def test_get_info_bigquery_backend(self, tmp_config_dir: Path) -> None: + """get_info correctly reads defaultBackend = bigquery.""" + response = dict(RAW_API_RESPONSE) + response["owner"] = dict(RAW_API_RESPONSE["owner"]) + response["owner"]["defaultBackend"] = "bigquery" + + service, _ = _make_service(tmp_config_dir, response) + result = service.get_info(alias="prod") + + assert result["default_backend"] == "bigquery" diff --git a/tests/test_search_cli.py b/tests/test_search_cli.py new file mode 100644 index 00000000..ab3f2191 --- /dev/null +++ b/tests/test_search_cli.py @@ -0,0 +1,357 @@ +"""Tests for `kbagent search` command via CliRunner.""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.services.project_service import ProjectService + +TEST_TOKEN = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + +runner = CliRunner() + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_store(config_dir: Path, projects: dict[str, dict] | None = None) -> ConfigStore: + store = ConfigStore(config_dir=config_dir) + if projects: + for alias, info in projects.items(): + store.add_project( + alias, + ProjectConfig( + stack_url=info.get("stack_url", "https://connection.keboola.com"), + token=info.get("token", TEST_TOKEN), + project_name=info.get("project_name", alias), + project_id=info.get("project_id", 1234), + ), + ) + return store + + +def _make_service_result( + results: list[dict] | None = None, + errors: list[dict] | None = None, + projects_searched: int = 1, +) -> dict: + res = results or [] + return { + "results": res, + "errors": errors or [], + "stats": { + "projects_searched": projects_searched, + "results_found": len(res), + }, + } + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + + +class TestSearchCommandJson: + """JSON output tests for `kbagent search`.""" + + def test_basic_search_returns_ok(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService") as MockSearchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + mock_svc = MagicMock() + mock_svc.search.return_value = _make_service_result( + results=[ + { + "project_alias": "prod", + "type": "table", + "id": "in.c-main.orders", + "name": "orders", + "description": "", + "component_id": None, + "project_id": 1234, + "project_name": "Prod", + } + ] + ) + MockSearchService.return_value = mock_svc + + result = runner.invoke(app, ["--json", "search", "orders"], catch_exceptions=False) + + assert result.exit_code == 0, f"Exit {result.exit_code}: {result.output}" + data = json.loads(result.output) + assert data["status"] == "ok" + assert "results" in data["data"] + assert "errors" in data["data"] + assert "stats" in data["data"] + assert data["data"]["stats"]["results_found"] == 1 + + def test_no_results_returns_ok_with_empty_list(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService") as MockSearchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + mock_svc = MagicMock() + mock_svc.search.return_value = _make_service_result() + MockSearchService.return_value = mock_svc + + result = runner.invoke( + app, ["--json", "search", "xyzzy_nonexistent"], catch_exceptions=False + ) + + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["status"] == "ok" + assert data["data"]["results"] == [] + assert data["data"]["stats"]["results_found"] == 0 + + def test_type_filter_passed_to_service(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService") as MockSearchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + mock_svc = MagicMock() + mock_svc.search.return_value = _make_service_result() + MockSearchService.return_value = mock_svc + + result = runner.invoke( + app, + ["--json", "search", "revenue", "--type", "table", "--type", "bucket"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + call_kwargs = mock_svc.search.call_args + item_types = call_kwargs.kwargs.get("item_types") or call_kwargs[1].get("item_types") + assert "table" in item_types + assert "bucket" in item_types + + def test_config_based_search_type(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService") as MockSearchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + mock_svc = MagicMock() + mock_svc.search.return_value = _make_service_result() + MockSearchService.return_value = mock_svc + + result = runner.invoke( + app, + ["--json", "search", "WHERE", "--search-type", "config-based"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + call_kwargs = mock_svc.search.call_args + search_type = call_kwargs.kwargs.get("search_type") or call_kwargs[1].get("search_type") + assert search_type == "config-based" + + def test_project_filter_passed_to_service(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService") as MockSearchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + mock_svc = MagicMock() + mock_svc.search.return_value = _make_service_result() + MockSearchService.return_value = mock_svc + + result = runner.invoke( + app, + ["--json", "search", "test", "--project", "prod"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + call_kwargs = mock_svc.search.call_args + aliases = call_kwargs.kwargs.get("aliases") or call_kwargs[1].get("aliases") + assert aliases == ["prod"] + + def test_limit_option_passed_to_service(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService") as MockSearchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + mock_svc = MagicMock() + mock_svc.search.return_value = _make_service_result() + MockSearchService.return_value = mock_svc + + result = runner.invoke( + app, + ["--json", "search", "test", "--limit", "10"], + catch_exceptions=False, + ) + + assert result.exit_code == 0 + call_kwargs = mock_svc.search.call_args + limit = call_kwargs.kwargs.get("limit") or call_kwargs[1].get("limit") + assert limit == 10 + + +class TestSearchCommandValidation: + """Input validation tests.""" + + def test_invalid_type_exits_with_code_2(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService"), + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + result = runner.invoke( + app, + ["--json", "search", "test", "--type", "invalid-type"], + ) + + assert result.exit_code == 2 + + def test_invalid_search_type_exits_with_code_2(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService"), + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + result = runner.invoke( + app, + ["--json", "search", "test", "--search-type", "bad-mode"], + ) + + assert result.exit_code == 2 + + def test_missing_query_shows_help(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService"), + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + + result = runner.invoke(app, ["search"]) + + # Missing required argument should exit non-zero. + assert result.exit_code != 0 + + +class TestSearchCommandHuman: + """Human-readable output tests.""" + + def test_human_output_shows_table(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService") as MockSearchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + mock_svc = MagicMock() + mock_svc.search.return_value = _make_service_result( + results=[ + { + "project_alias": "prod", + "type": "table", + "id": "in.c-main.orders", + "name": "orders", + "description": "", + "component_id": None, + } + ] + ) + MockSearchService.return_value = mock_svc + + result = runner.invoke(app, ["search", "orders"], catch_exceptions=False) + + assert result.exit_code == 0 + # Should contain the result ID somewhere in human output. + assert "orders" in result.output or "in.c-main" in result.output + + def test_human_output_no_results_message(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = _make_store(config_dir, {"prod": {"token": TEST_TOKEN}}) + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.SearchService") as MockSearchService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + mock_svc = MagicMock() + mock_svc.search.return_value = _make_service_result() + MockSearchService.return_value = mock_svc + + result = runner.invoke(app, ["search", "xyzzy_nonexistent"], catch_exceptions=False) + + assert result.exit_code == 0 + assert "No results" in result.output diff --git a/tests/test_search_service.py b/tests/test_search_service.py new file mode 100644 index 00000000..c3a3d2f1 --- /dev/null +++ b/tests/test_search_service.py @@ -0,0 +1,511 @@ +"""Tests for SearchService — textual and config-based search across projects.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.models import ProjectConfig, TokenVerifyResponse +from keboola_agent_cli.services.search_service import ( + SearchService, + _normalise_item, + _resolve_api_types, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +TEST_TOKEN = "test-token-12345" +TEST_STACK_URL = "https://connection.keboola.com" + + +def _make_store(tmp_path: Path, projects: dict[str, dict] | None = None) -> ConfigStore: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + if projects: + for alias, info in projects.items(): + store.add_project( + alias, + ProjectConfig( + stack_url=info.get("stack_url", TEST_STACK_URL), + token=info.get("token", TEST_TOKEN), + project_name=info.get("project_name", alias), + project_id=info.get("project_id", 1234), + ), + ) + return store + + +def _make_verify_response(project_id: int = 1234) -> TokenVerifyResponse: + return TokenVerifyResponse( + token_id="1", + token_description="test", + project_id=project_id, + project_name="Test Project", + owner_name="Test Project", + default_backend="snowflake", + features=[], + ) + + +def _make_mock_client( + project_id: int = 1234, + global_search_result: dict | None = None, + has_feature: bool = True, +) -> MagicMock: + mock = MagicMock() + mock.verify_token.return_value = _make_verify_response(project_id) + mock.global_search.return_value = global_search_result or {"all": 0, "items": []} + # Default: feature is enabled. Tests that need to exercise the feature-gate + # branch override this via _make_mock_client(has_feature=False). + mock.has_feature.return_value = has_feature + return mock + + +# --------------------------------------------------------------------------- +# Unit tests for helpers +# --------------------------------------------------------------------------- + + +class TestResolveApiTypes: + def test_none_returns_empty(self) -> None: + assert _resolve_api_types(None) == [] + + def test_empty_returns_empty(self) -> None: + assert _resolve_api_types([]) == [] + + def test_table_maps_correctly(self) -> None: + assert _resolve_api_types(["table"]) == ["table"] + + def test_bucket_maps_correctly(self) -> None: + assert _resolve_api_types(["bucket"]) == ["bucket"] + + def test_config_maps_to_configuration(self) -> None: + assert _resolve_api_types(["config"]) == ["configuration"] + + def test_flow_maps_correctly(self) -> None: + assert _resolve_api_types(["flow"]) == ["flow"] + + def test_multiple_types_deduped(self) -> None: + result = _resolve_api_types(["table", "bucket"]) + assert "table" in result + assert "bucket" in result + assert len(result) == 2 + + def test_unknown_type_passed_through(self) -> None: + # Unknown types are passed directly to the API. + result = _resolve_api_types(["unknown-type"]) + assert result == ["unknown-type"] + + +class TestNormaliseItem: + def test_table_item(self) -> None: + raw = { + "id": "in.c-main.orders", + "name": "orders", + "type": "table", + "fullPath": {"description": "Order data"}, + "componentId": None, + "projectId": 42, + "projectName": "Prod", + } + result = _normalise_item("prod", raw) + assert result["project_alias"] == "prod" + assert result["type"] == "table" + assert result["id"] == "in.c-main.orders" + assert result["name"] == "orders" + assert result["description"] == "Order data" + assert result["project_id"] == 42 + + def test_config_item(self) -> None: + raw = { + "id": "123", + "name": "My Extractor", + "type": "configuration", + "fullPath": {}, + "componentId": "keboola.ex-db-snowflake", + "projectId": 1, + "projectName": "Dev", + } + result = _normalise_item("dev", raw) + assert result["component_id"] == "keboola.ex-db-snowflake" + assert result["type"] == "configuration" + + +# --------------------------------------------------------------------------- +# SearchService.search() — textual mode +# --------------------------------------------------------------------------- + + +class TestSearchServiceTextual: + def test_single_project_success(self, tmp_path: Path) -> None: + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + mock_client = _make_mock_client( + global_search_result={ + "all": 1, + "items": [ + { + "id": "in.c-main.orders", + "name": "orders", + "type": "table", + "fullPath": {}, + "componentId": None, + "projectId": 1234, + "projectName": "Prod", + } + ], + } + ) + service = SearchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + result = service.search(query="orders") + + assert result["stats"]["projects_searched"] == 1 + assert result["stats"]["results_found"] == 1 + assert result["errors"] == [] + assert len(result["results"]) == 1 + assert result["results"][0]["project_alias"] == "prod" + assert result["results"][0]["type"] == "table" + assert result["results"][0]["id"] == "in.c-main.orders" + + def test_no_results(self, tmp_path: Path) -> None: + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + mock_client = _make_mock_client(global_search_result={"all": 0, "items": []}) + service = SearchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + result = service.search(query="xyzzy_nonexistent") + + assert result["stats"]["results_found"] == 0 + assert result["results"] == [] + assert result["errors"] == [] + + def test_multi_project_fan_out(self, tmp_path: Path) -> None: + store = _make_store( + tmp_path, + { + "prod": {"token": TEST_TOKEN, "project_id": 1}, + "dev": {"token": "other-token", "project_id": 2}, + }, + ) + + def factory(url: str, token: str) -> MagicMock: + pid = 1 if token == TEST_TOKEN else 2 + return _make_mock_client( + project_id=pid, + global_search_result={ + "all": 1, + "items": [ + { + "id": f"bucket-{pid}", + "name": "sales", + "type": "bucket", + "fullPath": {}, + "componentId": None, + "projectId": pid, + "projectName": f"Project {pid}", + } + ], + }, + ) + + service = SearchService(config_store=store, client_factory=factory) + result = service.search(query="sales") + + assert result["stats"]["projects_searched"] == 2 + assert result["stats"]["results_found"] == 2 + assert result["errors"] == [] + project_aliases = {r["project_alias"] for r in result["results"]} + assert project_aliases == {"prod", "dev"} + + def test_project_api_error_accumulates(self, tmp_path: Path) -> None: + from keboola_agent_cli.errors import KeboolaApiError + + store = _make_store( + tmp_path, + { + "prod": {"token": TEST_TOKEN}, + "broken": {"token": "bad-token"}, + }, + ) + + def factory(url: str, token: str) -> MagicMock: + mock = MagicMock() + if token == "bad-token": + mock.verify_token.side_effect = KeboolaApiError( + "INVALID_TOKEN", "Token is invalid." + ) + else: + mock.verify_token.return_value = _make_verify_response() + mock.global_search.return_value = {"all": 0, "items": []} + return mock + + service = SearchService(config_store=store, client_factory=factory) + result = service.search(query="test") + + assert result["stats"]["projects_searched"] == 1 + assert len(result["errors"]) == 1 + assert result["errors"][0]["project_alias"] == "broken" + + def test_type_filter_passed_to_client(self, tmp_path: Path) -> None: + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + mock_client = _make_mock_client() + service = SearchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + service.search(query="test", item_types=["table", "bucket"]) + + # Verify global_search was called with the correct types. + call_kwargs = mock_client.global_search.call_args + types_arg = ( + call_kwargs.kwargs.get("types") or call_kwargs[1].get("types") or call_kwargs[0][2] + ) + assert "table" in types_arg + assert "bucket" in types_arg + + def test_specific_project_alias(self, tmp_path: Path) -> None: + store = _make_store( + tmp_path, + { + "prod": {"token": TEST_TOKEN}, + "dev": {"token": "other-token"}, + }, + ) + mock_client = _make_mock_client() + service = SearchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + result = service.search(query="test", aliases=["prod"]) + + # Only one project searched. + assert result["stats"]["projects_searched"] == 1 + + def test_unknown_project_alias_raises_config_error(self, tmp_path: Path) -> None: + from keboola_agent_cli.errors import ConfigError + + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + service = SearchService(config_store=store) + + with pytest.raises(ConfigError): + service.search(query="test", aliases=["nonexistent"]) + + +# --------------------------------------------------------------------------- +# SearchService.search() — config-based mode +# --------------------------------------------------------------------------- + + +class TestSearchServiceConfigBased: + def test_config_based_delegates_to_config_service(self, tmp_path: Path) -> None: + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + service = SearchService(config_store=store) + + mock_result = { + "matches": [ + { + "project_alias": "prod", + "component_id": "keboola.ex-db-snowflake", + "config_id": "123", + "config_name": "My Extractor", + "description": "", + "match_count": 2, + "match_locations": ["parameters.db.host"], + } + ], + "errors": [], + "stats": { + "projects_searched": 1, + "configs_searched": 5, + "matches_found": 1, + }, + } + + with patch("keboola_agent_cli.services.search_service.ConfigService") as MockConfigService: + mock_cs = MagicMock() + mock_cs.search_configs.return_value = mock_result + MockConfigService.return_value = mock_cs + + result = service.search(query="snowflake", search_type="config-based") + + assert result["stats"]["results_found"] == 1 + assert len(result["results"]) == 1 + assert result["results"][0]["type"] == "configuration" + assert result["results"][0]["id"] == "123" + assert result["results"][0]["name"] == "My Extractor" + assert result["results"][0]["component_id"] == "keboola.ex-db-snowflake" + + def test_config_based_empty_result(self, tmp_path: Path) -> None: + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + service = SearchService(config_store=store) + + mock_result = { + "matches": [], + "errors": [], + "stats": {"projects_searched": 1, "configs_searched": 3, "matches_found": 0}, + } + + with patch("keboola_agent_cli.services.search_service.ConfigService") as MockConfigService: + mock_cs = MagicMock() + mock_cs.search_configs.return_value = mock_result + MockConfigService.return_value = mock_cs + + result = service.search(query="xyzzy", search_type="config-based") + + assert result["results"] == [] + assert result["stats"]["results_found"] == 0 + + +# --------------------------------------------------------------------------- +# Feature-flag pre-flight check +# --------------------------------------------------------------------------- + + +class TestGlobalSearchFeatureGate: + """Pre-flight has_feature(GLOBAL_SEARCH_FEATURE) check before global_search call.""" + + def test_feature_disabled_returns_per_project_error(self, tmp_path: Path) -> None: + """A project without the global-search feature gets a clear FEATURE_NOT_ENABLED error.""" + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + mock_client = _make_mock_client(has_feature=False) + service = SearchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.search(query="anything") + + assert result["results"] == [] + assert len(result["errors"]) == 1 + err = result["errors"][0] + assert err["error_code"] == "FEATURE_NOT_ENABLED" + assert "global-search" in err["message"] + # global_search MUST NOT be called when the feature is off + mock_client.global_search.assert_not_called() + + def test_feature_enabled_proceeds_to_api_call(self, tmp_path: Path) -> None: + """When the feature is enabled, global_search is called normally.""" + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + mock_client = _make_mock_client( + has_feature=True, + global_search_result={"items": [{"type": "table", "id": "t1", "name": "t1"}]}, + ) + service = SearchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.search(query="anything") + + assert result["errors"] == [] + assert len(result["results"]) == 1 + mock_client.global_search.assert_called_once() + + +# --------------------------------------------------------------------------- +# data-app vs config post-filter +# --------------------------------------------------------------------------- + + +class TestDataAppPostFilter: + """`--type data-app` only returns configurations of `keboola.data-apps`.""" + + def test_data_app_filters_to_data_apps_component_id(self, tmp_path: Path) -> None: + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + mock_client = _make_mock_client( + global_search_result={ + "items": [ + { + "type": "configuration", + "id": "cfg-1", + "name": "Data App", + "componentId": "keboola.data-apps", + }, + { + "type": "configuration", + "id": "cfg-2", + "name": "MySQL Extractor", + "componentId": "keboola.ex-mysql", + }, + ] + } + ) + service = SearchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.search(query="x", item_types=["data-app"]) + + # Only the keboola.data-apps row survives the post-filter. + assert len(result["results"]) == 1 + assert result["results"][0]["component_id"] == "keboola.data-apps" + + def test_config_does_not_post_filter(self, tmp_path: Path) -> None: + """`--type config` returns all configurations (no post-filter).""" + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + mock_client = _make_mock_client( + global_search_result={ + "items": [ + { + "type": "configuration", + "id": "cfg-1", + "name": "Data App", + "componentId": "keboola.data-apps", + }, + { + "type": "configuration", + "id": "cfg-2", + "name": "MySQL Extractor", + "componentId": "keboola.ex-mysql", + }, + ] + } + ) + service = SearchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.search(query="x", item_types=["config"]) + + assert len(result["results"]) == 2 + + def test_config_and_data_app_together_keeps_all_configs(self, tmp_path: Path) -> None: + """When both `config` and `data-app` are requested, NO post-filter is applied.""" + store = _make_store(tmp_path, {"prod": {"token": TEST_TOKEN}}) + mock_client = _make_mock_client( + global_search_result={ + "items": [ + { + "type": "configuration", + "id": "cfg-1", + "name": "Data App", + "componentId": "keboola.data-apps", + }, + { + "type": "configuration", + "id": "cfg-2", + "name": "MySQL Extractor", + "componentId": "keboola.ex-mysql", + }, + ] + } + ) + service = SearchService( + config_store=store, + client_factory=lambda url, token: mock_client, + ) + + result = service.search(query="x", item_types=["config", "data-app"]) + + assert len(result["results"]) == 2 diff --git a/uv.lock b/uv.lock index 73ad4950..ed8a97ac 100644 --- a/uv.lock +++ b/uv.lock @@ -439,7 +439,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.29.0" +version = "0.30.0" source = { editable = "." } dependencies = [ { name = "httpx" },