diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f81c1f1b..0b233cb9 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.31.0", + "version": "0.31.1", "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 be8c7ab1..cd4b8282 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -380,7 +380,7 @@ kbagent data-app validate-repo --git-repo URL [--git-branch BRANCH] [--git-publi kbagent component list [--project NAME] [--type TYPE] [--query QUERY] kbagent component detail --component-id ID [--project NAME] -kbagent config new --component-id ID [--name NAME] [--project NAME] [--output-dir DIR] +kbagent config new --component-id ID [--name NAME] [--project NAME] [--output-dir DIR] [--push --no-files --description D --configuration JSON|@file|- --configuration-file PATH --no-validate --branch ID --dry-run] kbagent encrypt values --project ALIAS --component-id ID --input JSON|@file|- [--output-file PATH] diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index 9f24f6c0..f099a670 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.31.0", + "version": "0.31.1", "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 c0658955..77b30c23 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -100,7 +100,7 @@ a critical failure. |---|---|---|---| | Update flow (rename, description, phases) | `kbagent flow update` (partial, no `--file`) | `--file` after fetching current phases, merging locally, passing full YAML | `tool call update_flow` (strips `behavior.onError` pre-MCP v1.60); partial `--file` that drops fields | | Schedule flow | `kbagent flow schedule --cron ... [--timezone]` | `tool call create_flow_schedule` | raw REST to `/storage/configurations/keboola.scheduler` | -| 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) | +| Create Snowflake transformation | `kbagent config new --component-id keboola.snowflake-transformation --name N --project P --push --no-files` (0.31.1+; one-shot, no scaffold, body defaults to `{}` and validation auto-skips for empty shell -- then `config update --set ...` to fill in script) **or** `kbagent config new --component-id keboola.snowflake-transformation --project P --output-dir D` + `config update --set ...` (scaffold-then-patch) | `tool call create_sql_transformation` (lower schema, avoids the MCP `create_config` Snowflake refusal) | `tool call create_config` (refuses keboola.snowflake-transformation) -- note: `config new --push` does NOT inherit this refusal because it wraps the raw Storage API directly | | 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. 0.31.0+ also re-splits multi-statement LIST elements -- closes the #274 ODBC `statement count 2 vs desired 1` crash that survives the 0.28.0 string fix) | `kbagent --hint client config update ...` if you need to bypass the auto-normalize for some reason | `tool call update_sql_transformation` -- still vulnerable to BOTH the #245 string-vs-array AND #274 list-element runtime crashes 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 | @@ -116,6 +116,7 @@ a critical failure. | 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 new config (one-shot remote, no scaffold to disk) | `kbagent config new --project P --component-id C --name N --push --no-files [--configuration @body.json] [--branch ID]` (0.31.1+) -- single CLI call POSTs to `/v2/storage/components/{cid}/configs`; default body is `{}` (FIIA empty-shell pattern, validation auto-skips); explicit `--configuration` body is schema-validated by default (`--no-validate` opts out); works for ALL component types incl. `keboola.snowflake-transformation` | `kbagent config new --output-dir D` then edit + `kbagent sync push` (scaffold-then-push GitOps flow) | `tool call create_config` (refuses keboola.snowflake-transformation; raw MCP envelope, no validation) | | 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) | @@ -158,10 +159,16 @@ success, not a failure. `update_flow` strip bug, reached via a different door. - **Snowflake transformation scaffolding**: `tool call create_config` - REFUSES `keboola.snowflake-transformation`. Use - `kbagent config new --component-id keboola.snowflake-transformation` - for the local scaffold, then `kbagent config update` for the body. - Or MCP `create_sql_transformation` which uses a lower-level schema. + REFUSES `keboola.snowflake-transformation`. Three options that work: + (a) `kbagent config new --component-id keboola.snowflake-transformation + --name N --project P --push --no-files` (0.31.1+) -- one-shot remote + create wrapping the raw Storage API, then `kbagent config update --set + parameters.blocks...=...` to fill in the body. **Recommended path.** + (b) `kbagent config new --component-id keboola.snowflake-transformation + --output-dir D` for the local scaffold, then `kbagent config update` for + the body. (c) MCP `tool call create_sql_transformation` which uses a + lower-level schema. `config new --push` does NOT inherit the MCP + refusal because it calls Storage API directly. - **`script[]` string-vs-array runtime crash** (0.28.0+ auto-fix; #245): the Storage API silently accepts `parameters.blocks[].codes[].script` diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 7000b366..f28741e0 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -118,7 +118,7 @@ When working inside a git repository or project directory, run `kbagent init` (o | Set or clear ``storage.output.default_bucket`` on a configuration | `kbagent config set-default-bucket --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | | Rename a configuration (update name via API + rename local sync directory) | `kbagent config rename --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID --name NAME` | | Delete a configuration from a project | `kbagent config delete --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | -| Generate boilerplate configuration files for a Keboola component | `kbagent config new --component-id COMPONENT-ID` | +| Generate boilerplate configuration files for a Keboola component, optionally creating the config remotely in one shot | `kbagent config new --component-id COMPONENT-ID` | | List all metadata entries on a configuration | `kbagent config metadata-list --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | | Read a single metadata value by key | `kbagent config get-metadata --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID --key KEY` | | Set a metadata key/value on a configuration (upsert) | `kbagent config set-metadata --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID --key KEY --value VALUE` | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index eabb9d73..30c559a4 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -55,7 +55,7 @@ All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the - `config set-default-bucket --project NAME --component-id ID --config-id ID (--bucket BUCKET_ID | --clear) [--dry-run] [--branch ID]` -- set or clear `configuration.storage.output.default_bucket` on a configuration. Discoverable shortcut for the raw-mode workaround at https://keboola.atlassian.net/wiki/spaces/SUP/pages/3770155030/. Read-modify-write that preserves sibling keys; returns `{"changed": false}` when the value already matches the requested state. Honored by output tables that don't pin their own `destination`. - `config rename --project NAME --component-id ID --config-id ID --name "New Name" [--branch ID] [--directory DIR]` -- rename a configuration (API update + local sync directory rename with git mv support) - `config delete --project NAME --component-id ID --config-id ID [--branch ID]` -- delete a configuration -- `config new --component-id ID [--project NAME] [--name NAME] [--output-dir DIR]` -- scaffold new config from component schema +- `config new --component-id ID [--project NAME] [--name NAME] [--output-dir DIR] [--push --no-files --description D --configuration JSON|@file|- --configuration-file PATH --no-validate --branch ID --dry-run]` -- **two modes**. **Default (no `--push`)**: scaffold new config from component schema; writes files to `--output-dir` or prints to stdout. **Zero API calls.** **With `--push`** (0.31.1+, requires `--project` + non-empty `--name`): also POSTs to `/v2/storage/components/{cid}/configs` for a one-shot remote create. `--no-files` skips the filesystem step entirely (FIIA-style empty-shell pattern). `--configuration` / `--configuration-file` override the POSTed body (default is `{}`, with validation auto-skipped for the default empty shell). `--dry-run` previews the planned POST + validation result without creating. Schema validation runs by default when an explicit body is given (fail-closed: `ConfigError` exit 5 on mismatch) but skips silently if the AI Service has no schema for the component or returns an error; `--no-validate` opts out. Works for ALL component types including `keboola.snowflake-transformation` (unlike `tool call create_config`, which refuses that component). - `config variables-set --project NAME --component-id ID --config-id ID --var KEY=VALUE [--var ...] [--replace] [--variables-id ID] [--values-id ID] [--branch ID] [--dry-run] [--allow-plaintext-on-encrypt-failure] [--yes]` -- attach variable values to a config. Auto-creates a sibling `keboola.variables` config + default row on first use and links it via the parent's `runtime.variables_id` / `variables_values_id`. Defaults to merge; `--replace` drops keys not in `--var`. `#`-prefixed values encrypt via the Encryption API (fail-closed; exit non-zero on `ENCRYPTION_FAILED`). See `variables-workflow.md` - `config variables-get --project NAME --component-id ID --config-id ID [--branch ID]` -- resolve `variables_id` + `values_id` from the parent config and fetch the current KEY=VALUE map. Returns `{linked: bool, variables_id, values_id, values}`; `linked=false` means the parent has no variables attached - `config variables-clear --project NAME --component-id ID --config-id ID [--branch ID] [--yes]` -- unlink variables from the parent config (strips `variables_id` + `variables_values_id`). **Does NOT delete** the backing `keboola.variables` config -- use `config delete` explicitly if you've verified nothing else references it diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 9f1ec964..e58c2266 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -1,5 +1,52 @@ # Gotchas -- Response Parsing and Common Pitfalls +## `kbagent config new --push` is one-shot remote create; default is scaffold-only (since v0.31.1) + +- **Pre-v0.31.1**, `kbagent config new` was scaffold-only -- it wrote + boilerplate files to `--output-dir` (or stdout) and made **zero API calls**. + The intended flow was scaffold → edit → `kbagent sync push`. The agent docs + in `keboola-expert.md` and SKILL.md conflated this with "create config" + intent, which was wrong if the goal was an API mutation. +- **Since v0.31.1**, `--push` adds a one-shot remote create: + `kbagent config new --component-id C --name N --project P --push` calls + `POST /v2/storage/components/C/configs` after the scaffold step. Returns + the new config ID immediately. `--no-files` skips the filesystem step + entirely (no scaffold to disk or stdout, only the API POST) -- this is + the FIIA-style "empty shell, then patch via `config update --set ...`" + pattern. +- `--push` **requires** `--project` AND a non-empty `--name`. All other + push-gated flags (`--no-files`, `--description`, `--configuration` / + `--configuration-file`, `--no-validate`, `--branch`, `--dry-run`) are + no-ops without `--push` and exit 2 if set independently. +- `--configuration` and `--configuration-file` are mutually exclusive; + `--no-files` and `--output-dir` are mutually exclusive. +- **MCP `create_config` quirk does NOT apply**: the raw MCP tool refuses + `keboola.snowflake-transformation` and routes you to + `tool call create_sql_transformation`. `kbagent config new --push` does + NOT refuse; the typed CLI wraps the raw Storage API directly. For + Snowflake transformations: one `config new --push` call works; the + MCP-typed `create_sql_transformation` shape is only needed if you + specifically want that envelope. +- **Schema validation** runs by default whenever `--configuration` / + `--configuration-file` provide an explicit body. On mismatch the create + aborts with exit 5 and a list of error paths. If the AI Service has no + schema for the component or returns an error, validation skips silently + (the result envelope shows `validation_status: "skipped"`). Use + `--no-validate` to skip the AI Service call entirely. +- **Empty-shell exception**: when no body is provided (default `{}`), + validation auto-skips. Component schemas almost always require parameters + and would reject `{}` -- skipping is the FIIA-pattern-friendly default. + Passing `--configuration '{}'` explicitly does NOT take the skip path: + the body is treated as caller-provided and validated, which typically + fails. Use `--no-validate` to suppress validation entirely. +- **`--push --dry-run`** returns the planned POST body + validation result + without making the API call (`dry_run: true` in the envelope, exit 0 even + on validation failure -- dry-run is inspection-only). +- The result envelope on success includes the full Storage API response + plus `project_alias`, `branch_id`, `validation_status`, and + `validation_errors` (always present, even if empty). Shape-symmetric with + `config detail` single-config mode and `config row-create`. + ## `project edit --new-alias` does NOT rewrite lineage caches (since v0.31.0) - `kbagent project edit --project OLD --new-alias NEW` cascades the rename diff --git a/plugins/kbagent/skills/kbagent/references/scaffold-workflow.md b/plugins/kbagent/skills/kbagent/references/scaffold-workflow.md index 35414620..2f2ad117 100644 --- a/plugins/kbagent/skills/kbagent/references/scaffold-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/scaffold-workflow.md @@ -1,10 +1,22 @@ # Creating New Configurations (Scaffold Workflow) +> **Two modes, one command (since v0.31.1):** +> - `kbagent config new --output-dir DIR` (this workflow) -- generate +> scaffold files to disk, edit them, then push later with +> `kbagent sync push`. The "GitOps for configs" path. +> - `kbagent config new --push --no-files --project P --name N` -- one-shot +> remote create via Storage API, no filesystem step. The FIIA "empty +> shell, then patch via `config update --set ...`" path. See +> `gotchas.md` "`kbagent config new --push` is one-shot remote create" +> for the full side-by-side and the schema-validation behavior. + ## When to use - User wants to create a new extractor, writer, transformation, or application - User asks "how do I set up a new Snowflake extractor" or similar -- User wants to scaffold config files for a component +- User wants to scaffold config files for a component (default mode), OR + wants a single CLI call that posts to the Storage API and returns the new + config ID (use `--push --no-files`) ## Step-by-step workflow @@ -34,6 +46,14 @@ kbagent --json config new --component-id COMPONENT_ID --project ALIAS --name "Co # To disk (auto-detects kbc project structure, writes under main/ if applicable) kbagent config new --component-id COMPONENT_ID --project ALIAS --name "Config Name" --output-dir . + +# One-shot remote create (since 0.31.1) -- no filesystem, just POST + return ID +kbagent --json config new --component-id COMPONENT_ID --project ALIAS --name "Config Name" \ + --push --no-files + +# Scaffold AND remote create in one step (writes files AND POSTs) +kbagent config new --component-id COMPONENT_ID --project ALIAS --name "Config Name" \ + --output-dir . --push ``` Generated files by component type: diff --git a/pyproject.toml b/pyproject.toml index 025bebf4..a4fc1e80 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-agent-cli" -version = "0.31.0" +version = "0.31.1" 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 ce8bac16..36d83f35 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,13 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.31.1": [ + "Feature: `kbagent config new --push` -- one-shot remote configuration create via the Storage API. Previously `config new` was scaffold-only (writes files to disk, zero API calls); the AI agent docs (`keboola-expert.md` tool selection matrix, SKILL.md decision row) conflated this with API-create intent. `--push` adds the actual remote create as a single CLI call. Requires `--project` and a non-empty `--name`. `--no-files` skips the filesystem step entirely for FIIA-style 'empty shell, then patch via `config update --set`' workflows. Optional `--configuration JSON|@file|-` / `--configuration-file PATH` override the POSTed body (default is `{}`). `--dry-run` previews the planned POST and validation outcome without calling the API. `--branch ID` targets a specific dev branch. `--description D` sets the description. Resolves the F2 gap in `kbagent-feature-gaps.md`. Without `--push`, today's scaffold-only behavior is byte-for-byte preserved -- pure regression-zero addition.", + "Schema validation: when `--push` is set with an explicit `--configuration` / `--configuration-file` body, the body is validated against the component's AI Service JSON schema (`configurationSchema`) using `jsonschema.Draft7Validator` before POSTing. Fail-closed: mismatch raises `ConfigError` (exit 5) with a list of error paths. Graceful fallback when the AI Service has no schema for the component, returns an error, or has a malformed schema -- validation skips silently (`validation_status: \"skipped\"` in the envelope) so a missing schema never blocks a real create. `--no-validate` opts out of the AI Service call entirely. The default empty-shell case (no body passed, POST `{}`) auto-skips validation because component schemas almost always require parameters and would reject `{}` -- this matches FIIA's create-then-patch pattern. Real-create success envelopes also include `validation_errors: []` symmetric with the dry-run envelope so JSON consumers can rely on the key being present.", + "Snowflake transformation matrix fix: `tool call create_config` refuses `keboola.snowflake-transformation` (per agent docs); the recommended fallback was the MCP-typed `create_sql_transformation`. `kbagent config new --push` wraps the raw Storage API directly and does NOT inherit the refusal -- the Snowflake row in `keboola-expert.md` is updated to surface `config new --push --no-files` as the first-choice one-shot path. Works for ALL component types.", + "Tests: 35 new tests across `tests/test_config_create_service.py` (15 service-level: happy paths, dry-run, schema validation ok/failed/skipped, malformed schemas, AI Service errors, client cleanup) and `tests/test_config_create_cli.py` (20 CLI-level: flag-combination validation, push-mode happy paths, body parsing via inline/@file/stdin, error propagation, dry-run envelopes). Existing `TestConfigNew` in `test_component_cli.py` continues to pass byte-for-byte (regression coverage for scaffold-only mode). E2E flow extended with step 19b: `config new --push --dry-run` then real `config new --push --no-files` then `config detail` verify then `config update --set` patch then `config delete` cleanup -- cleanup wrapped in try/finally and pre-registered in the safety-net teardown list so a mid-flow failure still reaps the remote config. Hint definition added in `hints/definitions/config.py` (emits `client.create_config` / `service.create_config` snippets only when `--push` is set).", + "Docs sync (convention #17): all 7 silent-drift surfaces updated -- `commands/context.py` (inventory + usage note), `CLAUDE.md ## All CLI Commands`, `plugins/kbagent/agents/keboola-expert.md` (amended Snowflake matrix row + amended inline Snowflake gotcha block + new 'Create a new config (one-shot remote)' row), `SKILL.md` (auto-regenerated via `make skill-gen`), `commands-reference.md` (two-mode entry with full flag inventory), `gotchas.md` (new `(since v0.31.1)` section clarifying scaffold-vs-push split + MCP refusal nuance + validation behavior), `scaffold-workflow.md` (dual-mode callout + new push examples in step 3). `pyproject.toml` 0.31.0 → 0.31.1, changelog entry, plugin.json auto-synced via `make version-sync`, `uv.lock` refreshed via `uv sync`.", + ], "0.31.0": [ "New: `kbagent project edit --new-alias NEW [--dry-run]` -- rename the alias of an existing project connection without going through `project remove` + `project add` (which forces token re-entry). Cascades the rename through everything that persists the alias on disk: the `config.json` `projects` dict key (`pop(old)` + insert under `new`) AND the `default_project` field if it matched the old alias. When a nested-layout sync workspace is present at `//.keboola/manifest.json`, the directory itself is also renamed to `//` -- mirrors the `kbagent config rename` precedent (`-2`-suffix collision handling, git-mv with shutil.move fallback). Skips the disk step when no sync workspace is present. Combined with `--url` and/or `--token` in a single invocation those mutations target the NEW alias post-rename, so `kbagent project edit --project foo --new-alias bar --token NEW` is one atomic operation with the expected ordering. Backed by the new `ConfigStore.rename_project(old, new)` method (atomic dict-key swap + `default_project` update saved as one transaction) and a fail-closed `ProjectService._rename_project_alias()` helper that validates collision before touching any state. Validation: empty `new_alias`, whitespace-only `new_alias`, and `new_alias` that already exists are all rejected with `ConfigError` exit code 5.", "New: `--dry-run` previews the rename (collision detection, planned disk-rename method `git_mv` vs `shutil_move`, lineage-cache warning) without mutating any state. Validation errors (`..` path-traversal, collision, invalid format) raise the same `ConfigError` exit-5 codes as the live path -- callers can rely on `--dry-run` as a 1:1 pre-flight. Token re-verification is also skipped in dry-run mode (no API hit). Result dict carries `dry_run: True` and a `planned` sub-dict. Backed by `_plan_project_alias_rename()` and `_plan_nested_sync_dir()` helpers in `services/project_service.py` -- pure read-only mirrors of the live `_rename_project_alias` / `_rename_nested_sync_dir`. Addresses PR #266 review NIT (UX consideration: even non-classically-destructive ops benefit from a dry-run pre-flight).", diff --git a/src/keboola_agent_cli/commands/config.py b/src/keboola_agent_cli/commands/config.py index f4dd1453..ba50af23 100644 --- a/src/keboola_agent_cli/commands/config.py +++ b/src/keboola_agent_cli/commands/config.py @@ -1102,72 +1102,247 @@ def config_new( name: str = typer.Option( "", "--name", - help="Configuration name (default: auto-generated from component)", + help="Configuration name (default: auto-generated from component; required with --push)", ), project: str | None = typer.Option( None, "--project", - help="Project alias (for AI Service auth)", + help="Project alias (for AI Service auth; required with --push)", ), output_dir: str | None = typer.Option( None, "--output-dir", help="Write scaffold files to disk instead of printing", ), + push: bool = typer.Option( + False, + "--push", + help="Also create the configuration remotely via the Storage API (one-shot; requires --project and --name)", + ), + no_files: bool = typer.Option( + False, + "--no-files", + help="With --push: skip writing/printing scaffold; only POST to API (FIIA-style one-shot)", + ), + description: str = typer.Option( + "", + "--description", + help="Configuration description (used with --push)", + ), + configuration: str | None = typer.Option( + None, + "--configuration", + help="Override the configuration body to POST (used with --push): JSON inline, @file, or - for stdin", + ), + configuration_file: Path | None = typer.Option( + None, + "--configuration-file", + help="Override the configuration body from a JSON file (used with --push)", + exists=True, + readable=True, + ), + no_validate: bool = typer.Option( + False, + "--no-validate", + help="Skip schema validation against the component's AI Service spec (used with --push)", + ), + branch: int | None = typer.Option( + None, + "--branch", + help="Create in a specific dev branch (used with --push; defaults to active branch)", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="With --push: show planned POST + validation result without creating", + ), ) -> None: - """Generate boilerplate configuration files for a Keboola component. + """Generate boilerplate configuration files for a Keboola component, optionally creating the config remotely in one shot. + + \b + Two modes: + * Default (no --push): scaffold only -- generates ready-to-edit files + (config YAML, code blocks, description). Writes to --output-dir or + prints to stdout. **Zero API calls.** + * With --push: scaffold step + POST to Storage API. Requires --project + and a non-empty --name. Use --no-files for FIIA-style one-shot create + with no filesystem step. + + \b + Examples: + # Scaffold-only (today's behavior, unchanged) + kbagent config new --component-id keboola.ex-http --output-dir ./scratch + + # Scaffold AND remote create: + kbagent config new --component-id keboola.ex-http --name "API ingest" \\ + --project prod --output-dir ./scratch --push + + # FIIA-style: one-shot remote create with no filesystem step: + kbagent config new --component-id keboola.ex-http --name "API ingest" \\ + --project prod --push --no-files - Produces a ready-to-edit scaffold (config YAML, SQL/Python code blocks, - description) that can be written to disk with --output-dir or printed - to stdout for inspection. + # Override the POSTed body with a pre-made config: + kbagent config new --component-id keboola.python-transformation-v2 \\ + --name "T1" --project prod --push --no-files \\ + --configuration-file ./body.json --branch 42 + + # Preview the planned POST without creating: + kbagent config new --component-id keboola.ex-http --name "smoke" \\ + --project prod --push --no-files --dry-run """ formatter = get_formatter(ctx) - service = get_service(ctx, "component_service") - try: - scaffold = service.generate_scaffold( - alias=project, - component_id=component_id, - name=name or None, - ) - 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: - exit_code = map_error_to_exit_code(exc) - formatter.error( - message=exc.message, - error_code=exc.error_code, - project=project or "", - retryable=exc.retryable, - ) - raise typer.Exit(code=exit_code) from None + # ── Flag-combination validation ────────────────────────────────────────── + # + # All --push-gated flags must be set only when --push is on; --push itself + # requires --project and a non-empty --name; --configuration vs + # --configuration-file and --output-dir vs --no-files are mutually + # exclusive. + push_gated: list[tuple[str, bool]] = [ + ("--no-files", no_files), + ("--description", bool(description)), + ("--configuration", configuration is not None), + ("--configuration-file", configuration_file is not None), + ("--no-validate", no_validate), + ("--branch", branch is not None), + ("--dry-run", dry_run), + ] + if not push: + for flag_name, flag_set in push_gated: + if flag_set: + formatter.error( + message=f"{flag_name} requires --push", + error_code=ErrorCode.VALIDATION_ERROR, + ) + raise typer.Exit(code=2) + else: + if not project: + formatter.error( + message="--push requires --project", + error_code=ErrorCode.VALIDATION_ERROR, + ) + raise typer.Exit(code=2) + if not name: + formatter.error( + message="--push requires a non-empty --name", + error_code=ErrorCode.VALIDATION_ERROR, + ) + raise typer.Exit(code=2) + if configuration is not None and configuration_file is not None: + formatter.error( + message="--configuration and --configuration-file are mutually exclusive", + error_code=ErrorCode.VALIDATION_ERROR, + ) + raise typer.Exit(code=2) + if no_files and output_dir: + formatter.error( + message="--no-files and --output-dir are mutually exclusive", + error_code=ErrorCode.VALIDATION_ERROR, + ) + raise typer.Exit(code=2) + + # ── Parse the optional body override (used only with --push) ───────────── + config_body: dict[str, Any] | None = None + if push: + if configuration is not None: + try: + config_body = _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 + elif configuration_file is not None: + try: + config_body = json.loads(configuration_file.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + formatter.error( + message=f"Invalid JSON in --configuration-file {configuration_file}: {exc}", + error_code=ErrorCode.VALIDATION_ERROR, + ) + raise typer.Exit(code=2) from None - if output_dir: - # Detect kbc project branch prefix (e.g. "main/") - branch_prefix = _detect_branch_prefix(Path(output_dir)) - if branch_prefix: - scaffold_dir = branch_prefix + "/" + scaffold["directory"] - else: - scaffold_dir = scaffold["directory"] + # ── Scaffold step (runs in scaffold-only mode and in push+files modes) ─── + scaffold: dict[str, Any] | None = None + skip_scaffold = push and no_files + if not skip_scaffold: + component_service = get_service(ctx, "component_service") + try: + scaffold = component_service.generate_scaffold( + alias=project, + component_id=component_id, + name=name or None, + ) + 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: + exit_code = map_error_to_exit_code(exc) + formatter.error( + message=exc.message, + error_code=exc.error_code, + project=project or "", + retryable=exc.retryable, + ) + raise typer.Exit(code=exit_code) from None + + # ── Push path: also create remotely via Storage API ────────────────────── + if push: + if should_hint(ctx): + emit_hint( + ctx, + "config.new", + project=project, + component_id=component_id, + name=name, + description=description, + configuration=config_body if config_body is not None else {}, + no_validate=no_validate, + branch=branch, + dry_run=dry_run, + ) - base_path = Path(output_dir) / scaffold_dir - base_path.mkdir(parents=True, exist_ok=True) + config_service = get_service(ctx, "config_service") + try: + push_result = config_service.create_config( + alias=project, + component_id=component_id, + name=name, + description=description, + configuration=config_body, + branch_id=branch, + dry_run=dry_run, + validate=not no_validate, + ) + 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: + exit_code = map_error_to_exit_code(exc) + formatter.error( + message=exc.message, + error_code=exc.error_code, + project=project or "", + retryable=exc.retryable, + ) + raise typer.Exit(code=exit_code) from None - for file_entry in scaffold["files"]: - file_path = base_path / file_entry["path"] - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(file_entry["content"], encoding="utf-8") + # When --push is set AND --output-dir is given, ALSO write the scaffold + # to disk in addition to the POST (the "scaffold + push" combo). + if output_dir and scaffold is not None: + _write_scaffold_to_disk(formatter, scaffold, output_dir, json_mode=False) if formatter.json_mode: - formatter.output( - { - "directory": str(base_path), - "files_written": [f["path"] for f in scaffold["files"]], - } - ) + formatter.output(push_result) else: - formatter.success(f"Scaffold written to {base_path} ({len(scaffold['files'])} file(s))") + _render_push_result_human(formatter, push_result, component_id, name) + return + + # ── Scaffold-only path: today's behavior, byte-for-byte unchanged ──────── + assert scaffold is not None # narrowing for type-checker; skip_scaffold is False here. + if output_dir: + _write_scaffold_to_disk(formatter, scaffold, output_dir, json_mode=formatter.json_mode) else: # Print scaffold content if formatter.json_mode: @@ -1195,6 +1370,88 @@ def config_new( formatter.console.print() +def _write_scaffold_to_disk( + formatter: Any, + scaffold: dict[str, Any], + output_dir: str, + json_mode: bool, +) -> None: + """Shared helper: write the generated scaffold files under ``output_dir``. + + Detects an enclosing ``main/`` branch prefix the same way the pre-push + path does, so the layout matches what ``kbagent sync push`` would expect. + Emits a success line (or a JSON envelope) when invoked from scaffold-only + mode; in scaffold+push mode the caller suppresses that human banner so the + final "Created config ..." line dominates the output. + """ + branch_prefix = _detect_branch_prefix(Path(output_dir)) + if branch_prefix: + scaffold_dir = branch_prefix + "/" + scaffold["directory"] + else: + scaffold_dir = scaffold["directory"] + + base_path = Path(output_dir) / scaffold_dir + base_path.mkdir(parents=True, exist_ok=True) + + for file_entry in scaffold["files"]: + file_path = base_path / file_entry["path"] + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(file_entry["content"], encoding="utf-8") + + if json_mode: + formatter.output( + { + "directory": str(base_path), + "files_written": [f["path"] for f in scaffold["files"]], + } + ) + else: + formatter.console.print( + f"[dim]Scaffold written to {base_path} ({len(scaffold['files'])} file(s))[/dim]" + ) + + +def _render_push_result_human( + formatter: Any, + result: dict[str, Any], + component_id: str, + name: str, +) -> None: + """Render the human-mode banner for ``config new --push`` (dry-run + success).""" + validation_status = result.get("validation_status", "skipped") + validation_errors = result.get("validation_errors", []) or [] + branch_info = f" (branch {result['branch_id']})" if result.get("branch_id") else "" + + if result.get("dry_run"): + formatter.console.print( + f"\n[bold]Dry-run -- would POST {escape(component_id)} '{escape(name)}'{branch_info}[/bold]\n" + ) + formatter.console.print("[dim]Planned configuration body:[/dim]") + formatter.console.print(json.dumps(result.get("configuration", {}), indent=2)) + if validation_status == "ok": + formatter.console.print("\n[green]✓ Schema validation passed[/green]") + elif validation_status == "skipped": + formatter.console.print( + "\n[yellow]⚠ Schema validation skipped[/yellow] " + "[dim](empty shell, no schema available, or --no-validate)[/dim]" + ) + else: # failed + formatter.console.print("\n[red]✗ Schema validation failed:[/red]") + for err in validation_errors: + formatter.console.print(f" [red]• {escape(err)}[/red]") + return + + config_id = result.get("id", "") + formatter.success( + f"Created config '{escape(name)}' [{config_id}] in {escape(component_id)}{branch_info}" + ) + if validation_status == "skipped": + formatter.console.print( + "[dim]Note: schema validation was skipped " + "(empty shell, no schema available, or --no-validate).[/dim]" + ) + + # ── Config metadata commands ─────────────────────────────────────────── diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 41e18bc4..9fbf05f3 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -187,7 +187,14 @@ Delete a configuration. Branch-aware. kbagent config new --component-id ID [--name NAME] [--project NAME] [--output-dir DIR] - Generate boilerplate config from component schema. Use --output-dir to write files. + [--push --no-files --description D --configuration JSON|@file|- --configuration-file PATH --no-validate --branch ID --dry-run] + Default: generate boilerplate config from component schema (scaffold to --output-dir or stdout). + With --push (0.31.1+): also create the config remotely via Storage API in one shot. + --push requires --project AND a non-empty --name. --no-files skips the filesystem step + entirely for FIIA-style one-shot creates. Schema validation runs by default when an explicit + --configuration body is given (fail-closed; --no-validate opts out). Default body is {{}} + (empty shell, validation auto-skipped). Works for ALL component types including + keboola.snowflake-transformation (unlike tool call create_config which refuses it). kbagent config search --query PATTERN [--project NAME] [--component-type TYPE] [-i] [-r] [--branch ID] Search config bodies for string/regex. Reports match location in JSON tree. Branch-aware. diff --git a/src/keboola_agent_cli/hints/definitions/config.py b/src/keboola_agent_cli/hints/definitions/config.py index 678c2b79..9bdc20ee 100644 --- a/src/keboola_agent_cli/hints/definitions/config.py +++ b/src/keboola_agent_cli/hints/definitions/config.py @@ -791,3 +791,62 @@ ], ) ) + +# ── config new --push (one-shot remote create) ──────────────────────────────── +# +# Scaffold-only mode (no --push) has no hint -- it's a local filesystem +# operation with no API mapping. The hint emits only when --push is set; the +# command layer guards the emit_hint() call accordingly. + +HintRegistry.register( + CommandHint( + cli_command="config.new", + description=( + "Create a new configuration remotely via the Storage API " + "(`config new --push`). Scaffold-only mode is a local filesystem " + "operation and not represented in this hint." + ), + steps=[ + HintStep( + comment="Create configuration via Storage API POST", + client=ClientCall( + method="create_config", + args={ + "component_id": "{component_id}", + "name": "{name}", + "configuration": "{configuration}", + "description": "{description}", + "branch_id": "{branch}", + }, + result_var="config", + result_hint="dict", + ), + service=ServiceCall( + service_class="ConfigService", + service_module="config_service", + method="create_config", + args={ + "alias": "{project}", + "component_id": "{component_id}", + "name": "{name}", + "description": "{description}", + "configuration": "{configuration}", + "branch_id": "{branch}", + "dry_run": "{dry_run}", + "validate": "not {no_validate}", + }, + ), + ), + ], + notes=[ + "configuration defaults to {} (empty shell) when omitted -- FIIA's " + "'create-then-patch' pattern. Validation auto-skips for the empty case.", + "Without --no-validate, the body is validated against the component's " + "AI Service JSON schema before POSTing (fail-closed: exit 5 on mismatch). " + "Skips gracefully when the AI Service has no schema for the component.", + "The returned dict includes the new config 'id' assigned by the API " + "plus 'project_alias', 'branch_id', and 'validation_status' annotations.", + "The client-mode snippet bypasses validation; the service-mode snippet runs it.", + ], + ) +) diff --git a/src/keboola_agent_cli/services/config_service.py b/src/keboola_agent_cli/services/config_service.py index a2a7ff29..1a2a8baa 100644 --- a/src/keboola_agent_cli/services/config_service.py +++ b/src/keboola_agent_cli/services/config_service.py @@ -10,16 +10,27 @@ import re import shutil import subprocess +from collections.abc import Callable from pathlib import Path from typing import Any +from ..ai_client import AiServiceClient +from ..config_store import ConfigStore from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..json_utils import compute_diff, deep_merge, set_nested_value -from ..models import ProjectConfig +from ..models import ComponentDetail, ProjectConfig from ..sync.code_extraction import normalize_blocks_codes_script from ..sync.manifest import Manifest, load_manifest, save_manifest from ..sync.naming import sanitize_name -from .base import BaseService, sanitize_unexpected_error +from .base import BaseService, ClientFactory, sanitize_unexpected_error + +AiClientFactory = Callable[[str, str], AiServiceClient] + + +def _default_ai_client_factory(stack_url: str, token: str) -> AiServiceClient: + """Default factory: build an ``AiServiceClient`` for the given project.""" + return AiServiceClient(stack_url=stack_url, token=token) + logger = logging.getLogger(__name__) @@ -85,9 +96,20 @@ class ConfigService(BaseService): using ThreadPoolExecutor, collects results, and reports per-project errors without stopping others. - Uses dependency injection for config_store and client_factory. + Uses dependency injection for config_store, client_factory, and + ai_client_factory (the last one is only exercised by ``create_config`` + when the AI Service component-schema lookup runs). """ + def __init__( + self, + config_store: ConfigStore, + client_factory: ClientFactory | None = None, + ai_client_factory: AiClientFactory | None = None, + ) -> None: + super().__init__(config_store=config_store, client_factory=client_factory) + self._ai_client_factory = ai_client_factory or _default_ai_client_factory + def _fetch_project_configs( self, alias: str, @@ -1436,6 +1458,181 @@ def create_config_row( result["branch_id"] = effective_branch_id return result + # ── config create (one-shot remote create via `config new --push`) ───────── + + def create_config( + self, + alias: str, + component_id: str, + name: str, + description: str = "", + configuration: dict[str, Any] | None = None, + branch_id: int | None = None, + dry_run: bool = False, + validate: bool = True, + ) -> dict[str, Any]: + """Create a new configuration via the Storage API (one-shot remote). + + Backs the ``kbagent config new --push`` lifecycle path. When a body is + passed explicitly (via ``configuration``), the body is validated + against the component's AI Service JSON schema before POSTing (unless + ``validate=False``); on validation failure raises ``ConfigError``. + When no body is passed (default = ``{}``), validation is auto-skipped + because empty shells almost always fail component schemas that require + parameters -- this is FIIA's "empty shell, patch later" pattern. + + Args: + alias: Project alias. + component_id: The component ID. + name: Configuration name (required by Storage API). + description: Optional description. + configuration: Configuration body dict. ``None`` => default empty + shell ``{}`` and auto-skipped validation. + branch_id: If set, create in a specific dev branch. Falls back to + the project's active branch when None. + dry_run: If True, return the planned POST envelope (including + validation result) without calling the Storage API. + validate: If True (default), validate ``configuration`` against + the component schema when a body is explicitly provided. + + Returns: + The created configuration dict from the API (includes the new + ``id``) annotated with ``project_alias``, ``branch_id``, and + ``validation_status``. When ``dry_run`` the dict contains + ``dry_run: True`` plus the planned POST fields and validation + envelope, with no API call. + + Raises: + ConfigError: If the alias is not found, or if validation runs + and fails on a real (non-dry-run) create. + KeboolaApiError: If the Storage API call fails. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + effective_branch_id = branch_id or project.active_branch_id + + body_was_explicit = configuration is not None + effective_config: dict[str, Any] = configuration if body_was_explicit else {} + + # Validation only runs when the caller passed an explicit body (i.e. + # ``configuration`` is not ``None``). When no body is passed at all, + # the effective body defaults to ``{}`` and validation auto-skips -- + # most component schemas require parameters and would reject ``{}``, + # which would block FIIA's "empty shell, then patch via + # ``config update``" pattern. An *explicit* ``configuration={}`` IS + # validated (and typically fails for the same reason) -- pass + # ``--no-validate`` or omit ``--configuration`` to skip. + if validate and body_was_explicit: + validation_status, validation_errors = self._validate_config_body( + project, component_id, effective_config + ) + else: + validation_status = "skipped" + validation_errors = [] + + if validation_status == "failed" and not dry_run: + joined = "\n - ".join(validation_errors) + raise ConfigError( + f"Configuration body failed schema validation for '{component_id}':\n - {joined}" + ) + + if dry_run: + return { + "dry_run": True, + "project_alias": alias, + "component_id": component_id, + "name": name, + "description": description, + "configuration": effective_config, + "branch_id": effective_branch_id, + "validation_status": validation_status, + "validation_errors": validation_errors, + } + + client = self._client_factory(project.stack_url, project.token) + try: + result = client.create_config( + component_id=component_id, + name=name, + configuration=effective_config, + description=description, + branch_id=effective_branch_id, + ) + finally: + client.close() + + result["project_alias"] = alias + result["branch_id"] = effective_branch_id + result["validation_status"] = validation_status + # Symmetric with the dry-run envelope. On a successful real create + # ``validation_status`` is always "ok" or "skipped" so the list is + # empty -- but we annotate it anyway so JSON consumers can rely on + # the key being present. + result["validation_errors"] = validation_errors + return result + + def _validate_config_body( + self, + project: ProjectConfig, + component_id: str, + body: dict[str, Any], + ) -> tuple[str, list[str]]: + """Validate a configuration body against the component's JSON schema. + + Returns: + ``("ok", [])`` when the body matches the schema. + ``("failed", [errors])`` when validation reports issues. + ``("skipped", [])`` when the AI Service has no schema for this + component or the lookup itself fails (graceful fallback so a + missing schema does not block a create). + """ + # Local import to keep ``jsonschema`` out of the cold-start path of + # commands that never call ``create_config``. + import jsonschema + + ai_client = self._ai_client_factory(project.stack_url, project.token) + try: + try: + raw = ai_client.get_component_detail(component_id) + except KeboolaApiError: + return ("skipped", []) + finally: + ai_client.close() + + try: + detail = ComponentDetail(**raw) + except (TypeError, ValueError): + return ("skipped", []) + + schema = detail.configuration_schema + if not schema: + return ("skipped", []) + + try: + validator = jsonschema.Draft7Validator(schema) + errors: list[str] = [] + for err in validator.iter_errors(body): + path = ".".join(str(p) for p in err.absolute_path) or "" + errors.append(f"{path}: {err.message}") + except jsonschema.SchemaError: + # Component schema itself is malformed -- don't block the create. + return ("skipped", []) + except Exception: + # iter_errors() can also raise late (e.g. ``UnknownType`` for an + # unknown ``type`` keyword) when the schema is invalid in a way + # the constructor accepted. Treat that as "skipped" too -- a + # broken component schema must not block a real create. + logger.warning( + "Schema validation for component %s raised during iter_errors; treating as skipped", + component_id, + exc_info=True, + ) + return ("skipped", []) + + if errors: + return ("failed", errors) + return ("ok", []) + # ── config row-update ────────────────────────────────────────────────────── def update_config_row( diff --git a/tests/test_config_create_cli.py b/tests/test_config_create_cli.py new file mode 100644 index 00000000..da951afa --- /dev/null +++ b/tests/test_config_create_cli.py @@ -0,0 +1,632 @@ +"""CLI tests for ``kbagent config new --push`` (one-shot remote create). + +Scope: +- Flag-combination validation (exit 2 on misuse). +- Push-mode happy paths: minimal, --no-files, --output-dir + push, --dry-run. +- Body parsing: --configuration (inline / @file / -), --configuration-file. +- Validation propagation: service-layer ConfigError surfaces as exit 5. + +Regression coverage for scaffold-only mode lives in ``test_component_cli.py`` +(TestConfigNew) -- preserved byte-for-byte. +""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from click.testing import Result +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 + +TEST_TOKEN = "901-10493007-VDtlEDWDF6Tx5V8jjE8FshFlqM0Hl0c08KHqpt0k" + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _setup_config(config_dir: Path) -> ConfigStore: + store = ConfigStore(config_dir=config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + project_name="Production", + project_id=1234, + ), + ) + return store + + +def _push_result() -> dict: + """Canonical success envelope from ConfigService.create_config.""" + return { + "id": "12345", + "name": "test-config", + "description": "", + "configuration": {}, + "version": 1, + "created": "2026-05-11T10:00:00+00:00", + "project_alias": "prod", + "branch_id": None, + "validation_status": "skipped", + } + + +def _invoke_push( + args: list[str], + *, + config_service_mock: MagicMock | None = None, + component_service_mock: MagicMock | None = None, + config_dir: Path | None = None, + input_text: str | None = None, +) -> Result: + """Invoke the CLI with both service mocks installed.""" + assert config_dir is not None, "tests must pass a config_dir" + store = _setup_config(config_dir) + + svc_config = config_service_mock or MagicMock() + svc_component = component_service_mock or MagicMock() + if not config_service_mock: + svc_config.create_config.return_value = _push_result() + if not component_service_mock: + # Default scaffold response for paths that also exercise scaffold step. + svc_component.generate_scaffold.return_value = { + "component_id": "keboola.ex-http", + "component_name": "HTTP", + "component_type": "extractor", + "directory": "extractor/keboola.ex-http/test-config", + "files": [ + {"path": "_config.yml", "content": "name: test\n"}, + ], + } + + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProjService, + patch("keboola_agent_cli.cli.ComponentService") as MockCompService, + patch("keboola_agent_cli.cli.ConfigService") as MockConfigService, + ): + MockStore.return_value = store + MockProjService.return_value = ProjectService(config_store=store) + MockCompService.return_value = svc_component + MockConfigService.return_value = svc_config + return runner.invoke(app, args, input=input_text) + + +# --------------------------------------------------------------------------- +# Flag-combination validation +# --------------------------------------------------------------------------- + + +class TestConfigNewFlagValidation: + """Push-gated flags must reject misuse with exit 2.""" + + def test_push_requires_project(self, tmp_path: Path) -> None: + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--name", + "T1", + "--push", + ], + config_dir=tmp_path / "config", + ) + assert result.exit_code == 2, result.output + envelope = json.loads(result.output) + assert envelope["status"] == "error" + assert "requires --project" in envelope["error"]["message"] + + def test_push_requires_non_empty_name(self, tmp_path: Path) -> None: + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--push", + ], + config_dir=tmp_path / "config", + ) + assert result.exit_code == 2, result.output + assert "requires a non-empty --name" in json.loads(result.output)["error"]["message"] + + def test_no_files_without_push(self, tmp_path: Path) -> None: + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--no-files", + ], + config_dir=tmp_path / "config", + ) + assert result.exit_code == 2, result.output + assert "--no-files requires --push" in json.loads(result.output)["error"]["message"] + + def test_description_without_push(self, tmp_path: Path) -> None: + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--description", + "desc", + ], + config_dir=tmp_path / "config", + ) + assert result.exit_code == 2, result.output + + def test_branch_without_push(self, tmp_path: Path) -> None: + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--branch", + "42", + ], + config_dir=tmp_path / "config", + ) + assert result.exit_code == 2, result.output + + def test_dry_run_without_push(self, tmp_path: Path) -> None: + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--dry-run", + ], + config_dir=tmp_path / "config", + ) + assert result.exit_code == 2, result.output + + def test_no_validate_without_push(self, tmp_path: Path) -> None: + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--no-validate", + ], + config_dir=tmp_path / "config", + ) + assert result.exit_code == 2, result.output + + def test_configuration_and_configuration_file_mutually_exclusive(self, tmp_path: Path) -> None: + body_file = tmp_path / "body.json" + body_file.write_text("{}") + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "T1", + "--push", + "--configuration", + "{}", + "--configuration-file", + str(body_file), + ], + config_dir=tmp_path / "config", + ) + assert result.exit_code == 2, result.output + assert "mutually exclusive" in json.loads(result.output)["error"]["message"] + + def test_no_files_and_output_dir_mutually_exclusive(self, tmp_path: Path) -> None: + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "T1", + "--push", + "--no-files", + "--output-dir", + str(tmp_path / "scaffold"), + ], + config_dir=tmp_path / "config", + ) + assert result.exit_code == 2, result.output + + +# --------------------------------------------------------------------------- +# Push-mode happy paths +# --------------------------------------------------------------------------- + + +class TestConfigNewPushHappyPath: + def test_push_no_files_json(self, tmp_path: Path) -> None: + svc_config = MagicMock() + svc_config.create_config.return_value = _push_result() + + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--no-files", + ], + config_dir=tmp_path / "config", + config_service_mock=svc_config, + ) + + assert result.exit_code == 0, result.output + envelope = json.loads(result.output) + assert envelope["status"] == "ok" + data = envelope["data"] + assert data["id"] == "12345" + assert data["project_alias"] == "prod" + assert data["validation_status"] == "skipped" + + # Verify the service was called with validate=True and an empty body. + svc_config.create_config.assert_called_once() + call_kwargs = svc_config.create_config.call_args.kwargs + assert call_kwargs["alias"] == "prod" + assert call_kwargs["component_id"] == "keboola.ex-http" + assert call_kwargs["name"] == "test-config" + assert call_kwargs["configuration"] is None + assert call_kwargs["validate"] is True + assert call_kwargs["dry_run"] is False + + def test_push_with_output_dir_scaffolds_and_posts(self, tmp_path: Path) -> None: + """With --push --output-dir: scaffold to disk AND POST.""" + scaffold_dir = tmp_path / "scaffold" + scaffold_dir.mkdir() + svc_config = MagicMock() + svc_config.create_config.return_value = _push_result() + svc_component = MagicMock() + svc_component.generate_scaffold.return_value = { + "component_id": "keboola.ex-http", + "component_name": "HTTP", + "component_type": "extractor", + "directory": "extractor/keboola.ex-http/test-config", + "files": [{"path": "_config.yml", "content": "name: test\n"}], + } + + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--output-dir", + str(scaffold_dir), + ], + config_dir=tmp_path / "config", + config_service_mock=svc_config, + component_service_mock=svc_component, + ) + + assert result.exit_code == 0, result.output + svc_config.create_config.assert_called_once() + # The scaffold file should have been written. + written = scaffold_dir / "extractor/keboola.ex-http/test-config/_config.yml" + assert written.exists(), f"Scaffold file not written at {written}" + + def test_push_with_configuration_inline(self, tmp_path: Path) -> None: + svc_config = MagicMock() + svc_config.create_config.return_value = _push_result() + + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--no-files", + "--configuration", + '{"parameters":{"url":"https://api.example.com"}}', + ], + config_dir=tmp_path / "config", + config_service_mock=svc_config, + ) + + assert result.exit_code == 0, result.output + call_kwargs = svc_config.create_config.call_args.kwargs + assert call_kwargs["configuration"] == {"parameters": {"url": "https://api.example.com"}} + + def test_push_with_configuration_at_file(self, tmp_path: Path) -> None: + body_file = tmp_path / "body.json" + body_file.write_text('{"parameters": {"k": 1}}', encoding="utf-8") + svc_config = MagicMock() + svc_config.create_config.return_value = _push_result() + + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--no-files", + "--configuration", + f"@{body_file}", + ], + config_dir=tmp_path / "config", + config_service_mock=svc_config, + ) + + assert result.exit_code == 0, result.output + assert svc_config.create_config.call_args.kwargs["configuration"] == { + "parameters": {"k": 1} + } + + def test_push_with_configuration_stdin(self, tmp_path: Path) -> None: + svc_config = MagicMock() + svc_config.create_config.return_value = _push_result() + + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--no-files", + "--configuration", + "-", + ], + config_dir=tmp_path / "config", + config_service_mock=svc_config, + input_text='{"parameters":{"k":42}}', + ) + + assert result.exit_code == 0, result.output + assert svc_config.create_config.call_args.kwargs["configuration"] == { + "parameters": {"k": 42} + } + + def test_push_with_configuration_file_typed_path(self, tmp_path: Path) -> None: + body_file = tmp_path / "body.json" + body_file.write_text('{"parameters": {"k": "v"}}', encoding="utf-8") + svc_config = MagicMock() + svc_config.create_config.return_value = _push_result() + + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--no-files", + "--configuration-file", + str(body_file), + ], + config_dir=tmp_path / "config", + config_service_mock=svc_config, + ) + + assert result.exit_code == 0, result.output + assert svc_config.create_config.call_args.kwargs["configuration"] == { + "parameters": {"k": "v"} + } + + def test_push_no_validate_passed_through(self, tmp_path: Path) -> None: + svc_config = MagicMock() + svc_config.create_config.return_value = _push_result() + + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--no-files", + "--no-validate", + ], + config_dir=tmp_path / "config", + config_service_mock=svc_config, + ) + + assert result.exit_code == 0, result.output + assert svc_config.create_config.call_args.kwargs["validate"] is False + + def test_push_dry_run_returns_envelope(self, tmp_path: Path) -> None: + svc_config = MagicMock() + svc_config.create_config.return_value = { + "dry_run": True, + "project_alias": "prod", + "component_id": "keboola.ex-http", + "name": "test-config", + "description": "", + "configuration": {}, + "branch_id": None, + "validation_status": "skipped", + "validation_errors": [], + } + + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--no-files", + "--dry-run", + ], + config_dir=tmp_path / "config", + config_service_mock=svc_config, + ) + + assert result.exit_code == 0, result.output + envelope = json.loads(result.output) + assert envelope["data"]["dry_run"] is True + assert envelope["data"]["validation_status"] == "skipped" + assert svc_config.create_config.call_args.kwargs["dry_run"] is True + + +# --------------------------------------------------------------------------- +# Error propagation +# --------------------------------------------------------------------------- + + +class TestConfigNewPushErrors: + def test_invalid_inline_json_exits_2(self, tmp_path: Path) -> None: + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--no-files", + "--configuration", + "not-json", + ], + config_dir=tmp_path / "config", + ) + assert result.exit_code == 2, result.output + + def test_validation_failure_surfaces_as_exit_5(self, tmp_path: Path) -> None: + """ConfigError from the service => exit 5 + CONFIG_ERROR envelope.""" + svc_config = MagicMock() + svc_config.create_config.side_effect = ConfigError( + "Configuration body failed schema validation for 'X':\n - foo: bar" + ) + + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--no-files", + "--configuration", + '{"bad":"body"}', + ], + config_dir=tmp_path / "config", + config_service_mock=svc_config, + ) + + assert result.exit_code == 5, result.output + envelope = json.loads(result.output) + assert envelope["status"] == "error" + assert "schema validation" in envelope["error"]["message"] + + def test_api_error_surfaces_with_mapped_exit_code(self, tmp_path: Path) -> None: + svc_config = MagicMock() + svc_config.create_config.side_effect = KeboolaApiError( + message="500 boom", error_code="STORAGE_ERROR", status_code=500 + ) + + result = _invoke_push( + [ + "--json", + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + "prod", + "--name", + "test-config", + "--push", + "--no-files", + ], + config_dir=tmp_path / "config", + config_service_mock=svc_config, + ) + + # STORAGE_ERROR is not in the explicit "network/auth/timeout" mapping + # in map_error_to_exit_code(), so it falls through to the general + # error bucket (exit 1). Tighten the assertion so any future change + # to the mapping is caught. + assert result.exit_code == 1, result.output + envelope = json.loads(result.output) + assert envelope["status"] == "error" diff --git a/tests/test_config_create_service.py b/tests/test_config_create_service.py new file mode 100644 index 00000000..418ed819 --- /dev/null +++ b/tests/test_config_create_service.py @@ -0,0 +1,370 @@ +"""Tests for ConfigService.create_config (the `config new --push` lifecycle). + +Covers: +- Happy paths: minimal create, explicit body, description forwarding, + branch_id resolution. +- Dry-run: planned envelope without API call. +- Schema validation: ok / failed / skipped (no-schema, AI error, malformed + schema, validate=False). +- Empty-shell short-circuit: validation auto-skips when no body is provided. +- Cleanup: client.close() and ai_client.close() always called. +""" + +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +from helpers import setup_single_project +from keboola_agent_cli.errors import ConfigError, KeboolaApiError +from keboola_agent_cli.services.config_service import ConfigService + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +SAMPLE_CREATED = { + "id": "12345", + "name": "My Config", + "description": "", + "configuration": {}, + "version": 1, + "created": "2026-05-11T10:00:00+00:00", +} + +# A trivial JSON schema that requires a top-level "parameters" object with +# a required "table" string field. Used to drive the validation branch. +TABLE_SCHEMA = { + "type": "object", + "properties": { + "parameters": { + "type": "object", + "properties": {"table": {"type": "string"}}, + "required": ["table"], + } + }, + "required": ["parameters"], +} + +VALID_BODY = {"parameters": {"table": "orders"}} +INVALID_BODY = {"parameters": {"limit": 100}} # missing required "table" + + +def _make_service( + tmp_config_dir: Path, + *, + schema: dict | None = None, + ai_raises: Exception | None = None, +) -> tuple[ConfigService, MagicMock, MagicMock]: + """Build a ConfigService wired to a mock Storage client + mock AI client. + + Args: + tmp_config_dir: pytest fixture providing a temp directory for ConfigStore. + schema: when set, the mock AI client returns a component detail with + this ``configurationSchema``. When None, the AI client returns a + detail with no schema (the "no-schema available" branch). + ai_raises: when set, the mock AI client raises this exception on + ``get_component_detail`` to exercise the "AI Service error" branch. + + Returns: + (service, mock_storage_client, mock_ai_client) + """ + store = setup_single_project(tmp_config_dir) + + mock_storage = MagicMock() + mock_storage.create_config.return_value = dict(SAMPLE_CREATED) + + mock_ai = MagicMock() + if ai_raises is not None: + mock_ai.get_component_detail.side_effect = ai_raises + else: + # Mirror the raw AI Service response shape (camelCase). ComponentDetail + # parses this via its field aliases (componentId, componentName, ...). + mock_ai.get_component_detail.return_value = { + "componentId": "keboola.ex-db-snowflake", + "componentName": "Snowflake Extractor", + "componentType": "extractor", + "configurationSchema": schema or {}, + } + + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_storage, + ai_client_factory=lambda url, token: mock_ai, + ) + return service, mock_storage, mock_ai + + +# --------------------------------------------------------------------------- +# Core happy-path tests +# --------------------------------------------------------------------------- + + +class TestCreateConfigCore: + def test_minimal_create_empty_shell(self, tmp_config_dir: Path) -> None: + """No body provided => POST {} and skip validation (empty-shell mode).""" + service, storage, ai = _make_service(tmp_config_dir, schema=TABLE_SCHEMA) + + result = service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + ) + + storage.create_config.assert_called_once_with( + component_id="keboola.ex-db-snowflake", + name="My Config", + configuration={}, + description="", + branch_id=None, + ) + # AI Service must NOT be consulted for the empty-shell case -- it + # would always fail and that's not useful for FIIA's pattern. + ai.get_component_detail.assert_not_called() + + assert result["id"] == "12345" + assert result["project_alias"] == "prod" + assert result["branch_id"] is None + assert result["validation_status"] == "skipped" + # validation_errors is always present (symmetric with dry-run envelope). + assert result["validation_errors"] == [] + + def test_create_with_explicit_body_validates_ok(self, tmp_config_dir: Path) -> None: + """Explicit body + valid against schema => validation_status='ok'.""" + service, storage, ai = _make_service(tmp_config_dir, schema=TABLE_SCHEMA) + + result = service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + configuration=VALID_BODY, + ) + + ai.get_component_detail.assert_called_once_with("keboola.ex-db-snowflake") + storage.create_config.assert_called_once() + assert result["validation_status"] == "ok" + + def test_create_with_description(self, tmp_config_dir: Path) -> None: + """Description forwards to the client call.""" + service, storage, _ = _make_service(tmp_config_dir) + + service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + description="A test config", + ) + + call_kwargs = storage.create_config.call_args.kwargs + assert call_kwargs["description"] == "A test config" + + def test_branch_id_override(self, tmp_config_dir: Path) -> None: + """Explicit branch_id beats the project's active_branch_id.""" + service, storage, _ = _make_service(tmp_config_dir) + + result = service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + branch_id=42, + ) + + assert storage.create_config.call_args.kwargs["branch_id"] == 42 + assert result["branch_id"] == 42 + + def test_branch_id_falls_back_to_active(self, tmp_config_dir: Path) -> None: + """When branch_id is None, the project's active_branch_id is used.""" + store = setup_single_project(tmp_config_dir) + # Mutate the registered project to have an active branch. + config = store.load() + project = config.projects["prod"] + project.active_branch_id = 99 + store.save(config) + + mock_storage = MagicMock() + mock_storage.create_config.return_value = dict(SAMPLE_CREATED) + service = ConfigService( + config_store=store, + client_factory=lambda url, token: mock_storage, + ) + + result = service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + ) + + assert mock_storage.create_config.call_args.kwargs["branch_id"] == 99 + assert result["branch_id"] == 99 + + def test_unknown_project_raises_config_error(self, tmp_config_dir: Path) -> None: + """Alias not in the config store => ConfigError, no API call.""" + service, storage, _ = _make_service(tmp_config_dir) + + with pytest.raises(ConfigError, match="not found"): + service.create_config( + alias="unknown", + component_id="keboola.ex-db-snowflake", + name="My Config", + ) + storage.create_config.assert_not_called() + + def test_api_error_propagates_and_closes_client(self, tmp_config_dir: Path) -> None: + """Storage API failure propagates KeboolaApiError; client.close() still called.""" + service, storage, _ = _make_service(tmp_config_dir) + storage.create_config.side_effect = KeboolaApiError( + message="500 boom", + error_code="STORAGE_ERROR", + status_code=500, + ) + + with pytest.raises(KeboolaApiError, match="500 boom"): + service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + ) + storage.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Dry-run tests +# --------------------------------------------------------------------------- + + +class TestCreateConfigDryRun: + def test_dry_run_returns_planned_envelope_no_api_call(self, tmp_config_dir: Path) -> None: + """Dry-run returns the planned POST body; no API call is made.""" + service, storage, _ = _make_service(tmp_config_dir, schema=TABLE_SCHEMA) + + result = service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + configuration=VALID_BODY, + dry_run=True, + ) + + storage.create_config.assert_not_called() + assert result["dry_run"] is True + assert result["project_alias"] == "prod" + assert result["component_id"] == "keboola.ex-db-snowflake" + assert result["name"] == "My Config" + assert result["configuration"] == VALID_BODY + assert result["validation_status"] == "ok" + + def test_dry_run_with_validation_failure_does_not_raise(self, tmp_config_dir: Path) -> None: + """Dry-run + invalid body: envelope reports failure but doesn't raise.""" + service, storage, _ = _make_service(tmp_config_dir, schema=TABLE_SCHEMA) + + result = service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + configuration=INVALID_BODY, + dry_run=True, + ) + + storage.create_config.assert_not_called() + assert result["dry_run"] is True + assert result["validation_status"] == "failed" + assert result["validation_errors"], "Expected non-empty validation_errors" + + +# --------------------------------------------------------------------------- +# Schema validation tests +# --------------------------------------------------------------------------- + + +class TestCreateConfigSchemaValidation: + def test_validation_failure_aborts_real_create(self, tmp_config_dir: Path) -> None: + """Real (non-dry-run) create with invalid body => ConfigError; no POST.""" + service, storage, _ = _make_service(tmp_config_dir, schema=TABLE_SCHEMA) + + with pytest.raises(ConfigError, match="failed schema validation"): + service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + configuration=INVALID_BODY, + ) + storage.create_config.assert_not_called() + + def test_no_schema_available_skips_validation(self, tmp_config_dir: Path) -> None: + """No configurationSchema on the component => skip, proceed with POST.""" + service, storage, _ = _make_service(tmp_config_dir, schema=None) + + result = service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + configuration=VALID_BODY, + ) + + storage.create_config.assert_called_once() + assert result["validation_status"] == "skipped" + + def test_ai_service_error_skips_validation_silently(self, tmp_config_dir: Path) -> None: + """AI Service raises => skip validation, do NOT block the create.""" + service, storage, ai = _make_service( + tmp_config_dir, + ai_raises=KeboolaApiError( + message="503 unavailable", error_code="UNAVAILABLE", status_code=503 + ), + ) + + result = service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + configuration=VALID_BODY, + ) + + ai.get_component_detail.assert_called_once() + ai.close.assert_called_once() + storage.create_config.assert_called_once() + assert result["validation_status"] == "skipped" + + def test_validate_false_skips_ai_client_call_entirely(self, tmp_config_dir: Path) -> None: + """validate=False => AI Service is never consulted.""" + service, storage, ai = _make_service(tmp_config_dir, schema=TABLE_SCHEMA) + + result = service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + configuration=INVALID_BODY, # would fail validation, but skipped + validate=False, + ) + + ai.get_component_detail.assert_not_called() + storage.create_config.assert_called_once() + assert result["validation_status"] == "skipped" + + def test_malformed_schema_skips_validation(self, tmp_config_dir: Path) -> None: + """A broken JSON schema => skip validation rather than block the create.""" + broken_schema = {"type": "not-a-real-type"} # invalid Draft7 schema + service, storage, _ = _make_service(tmp_config_dir, schema=broken_schema) + + result = service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + configuration=VALID_BODY, + ) + + storage.create_config.assert_called_once() + assert result["validation_status"] == "skipped" + + def test_ai_client_closed_even_when_detail_call_succeeds(self, tmp_config_dir: Path) -> None: + """AI client.close() is called after a successful detail fetch.""" + service, _, ai = _make_service(tmp_config_dir, schema=TABLE_SCHEMA) + + service.create_config( + alias="prod", + component_id="keboola.ex-db-snowflake", + name="My Config", + configuration=VALID_BODY, + ) + + ai.close.assert_called_once() diff --git a/tests/test_e2e.py b/tests/test_e2e.py index fc20de3d..d9b57730 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -430,6 +430,9 @@ def test_full_cli_e2e(self) -> None: _step(19, "config new scaffold", "generate boilerplate for component") self._test_config_new_scaffold() + _step("19b", "config new --push", "one-shot remote create (0.31.1+)") + self._test_config_new_push() + # ============================================================== # PHASE 5: Component commands # ============================================================== @@ -1530,6 +1533,110 @@ def _test_config_new_scaffold(self) -> None: result = data["data"] assert "files_written" in result or "directory" in result + def _test_config_new_push(self) -> None: + """Test ``config new --push`` -- one-shot remote create (0.31.1+). + + Exercises the full lifecycle introduced in v0.31.1: + 1. ``--push --no-files --dry-run`` returns the planned POST envelope + with ``validation_status`` and no real API call. + 2. ``--push --no-files`` creates an empty-shell config and returns + ``project_alias`` / ``branch_id`` / ``validation_status="skipped"``. + 3. ``config detail`` finds the newly created config. + 4. ``config update --set`` patches it (proves create + update interop). + 5. ``config delete`` cleans up (also tracked in ``_created_config_ids`` + for the safety-net teardown). + """ + push_name = f"{RUN_ID} push-created" + + # 1) Dry-run -- envelope only, no POST. + dry = self._run_ok( + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + self.alias, + "--name", + f"{push_name} (dry)", + "--push", + "--no-files", + "--dry-run", + )["data"] + assert dry["dry_run"] is True, dry + assert dry["project_alias"] == self.alias + assert dry["component_id"] == "keboola.ex-http" + assert dry["configuration"] == {} # default empty shell + assert dry["validation_status"] in ("ok", "skipped", "failed") + + # 2) Real create. + created = self._run_ok( + "config", + "new", + "--component-id", + "keboola.ex-http", + "--project", + self.alias, + "--name", + push_name, + "--push", + "--no-files", + )["data"] + new_config_id = str(created["id"]) + # Register for the safety-net cleanup loop in teardown BEFORE any + # downstream assertion can raise -- guarantees the config is reaped + # even if a verification step below fails. + self._created_config_ids.append(("keboola.ex-http", new_config_id)) + + try: + assert created["project_alias"] == self.alias + # Empty-shell creation auto-skips validation (FIIA pattern). + assert created["validation_status"] == "skipped", created + # validation_errors is always annotated (symmetric with dry-run). + assert created["validation_errors"] == [], created + + # 3) Verify via config detail. + detail = self._run_ok( + "config", + "detail", + "--project", + self.alias, + "--component-id", + "keboola.ex-http", + "--config-id", + new_config_id, + )["data"] + assert str(detail["id"]) == new_config_id + assert detail["name"] == push_name + + # 4) Patch the freshly-pushed config. + self._run_ok( + "config", + "update", + "--project", + self.alias, + "--component-id", + "keboola.ex-http", + "--config-id", + new_config_id, + "--set", + "parameters.smoke_test=true", + ) + finally: + # 5) Inline cleanup so failures mid-flow still tear down the + # remote config promptly; the global teardown loop is a safety + # net for the case where this delete itself fails. + # ``config delete`` has no ``--yes`` flag (CLAUDE.md inventory). + self._run_ok( + "config", + "delete", + "--project", + self.alias, + "--component-id", + "keboola.ex-http", + "--config-id", + new_config_id, + ) + def _test_component_commands(self) -> None: """List components and get detail for one. diff --git a/uv.lock b/uv.lock index ef1b3695..1c7513c0 100644 --- a/uv.lock +++ b/uv.lock @@ -439,7 +439,7 @@ wheels = [ [[package]] name = "keboola-agent-cli" -version = "0.31.0" +version = "0.31.1" source = { editable = "." } dependencies = [ { name = "httpx" },