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/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..cb06b7fd 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -78,6 +78,8 @@ a critical failure. `config row-delete`, `config oauth-url` need 0.30.0+, `project edit --new-alias` (cascading rename across config.json + nested sync dir; warns on lineage cache rebuild) needs 0.31.0+, + `data-app *` JSON output uses key `app_id` (was bare `id`) on 0.31.1+ + -- pipe with `jq -r '.apps[].app_id'`, not `'.id'`, `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: . diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index eabb9d73..78727195 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -138,7 +138,7 @@ All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the - `workspace from-transformation --project ALIAS --component-id ID --config-id ID [--row-id ID]` -- workspace from existing transform ## Data Apps (Streamlit / Flask / Node deployments) -Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, encrypted secrets, runtime size) with Data Science API (`/apps` -- deployment record, state, URL, configVersion). The CLI encapsulates the §9 redeploy contract so callers cannot pin to the empty-shell v2; see `data-app-workflow.md` for the gotcha inventory and recipes. +Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, encrypted secrets, runtime size) with Data Science API (`/apps` -- deployment record, state, URL, configVersion). The CLI encapsulates the §9 redeploy contract so callers cannot pin to the empty-shell v2; see `data-app-workflow.md` for the gotcha inventory and recipes. Since v0.31.1 the JSON output envelope's data-app id key is `app_id` (renamed from bare `id` for symmetry with the `--app-id` input flag); `config_id` is unchanged. - `data-app list [--project NAME ...] [--branch ID]` -- list apps across projects (Data Science index merged with Storage names) - `data-app detail --project NAME --app-id ID [--branch ID]` -- merged view (state, desired, url, configVersion, slug, git block with PAT redacted) - `data-app create --project ALIAS --name NAME --slug SLUG --git-repo URL [--git-public/--no-git-public] [--git-username USER] [--git-pat-env VAR | --git-pat-file PATH | --git-pat-encrypted KBC::Project...] [--auth password|public] [--size tiny|small|medium|large] [--auto-suspend SECONDS] [--type python-js|python|streamlit|r|...] [--branch ID] [--no-deploy] [--wait] [--timeout SECONDS] [--keep-on-failure] [--dry-run]` -- POST shell + encrypt PAT + PUT Storage config (with auto-injected `parameters.id`) + PATCH deploy with the §9 trio. Cleanup-in-finally on failure unless `--keep-on-failure`. Default `--auth password` mints a 20-char hex simpleAuth password (retrievable via `data-app password`). diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 9f1ec964..a7116f4e 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -1,5 +1,29 @@ # Gotchas -- Response Parsing and Common Pitfalls +## `data-app` JSON output: key for the app's own id is `app_id` (since v0.31.1) + +- Every `kbagent --json data-app ` envelope emits the + data-app's own identifier under the key `app_id`. Prior to v0.31.1 the + same key was named bare `id`, which did not match the `--app-id` input + flag. Affects `data-app list / detail / create / deploy / start / stop / + delete / password / secrets-set / secrets-list / secrets-get / + secrets-remove`. The companion `config_id` key is unchanged. +- Pipe-friendly chain that v0.31.1 enables: + `kbagent --json data-app list | jq -r '.apps[].app_id' | xargs -I{} kbagent data-app deploy --project P --app-id {}`. + On pre-v0.31.1 you had to read `.apps[].id` (mismatched the input flag, + surprised AI agents that templated `.app_id`). +- **What is NOT renamed:** the Storage config back-pointer at + `parameters.id` inside the configuration body sent TO Storage (writeup + §5) -- that lives in the Storage config, not in kbagent's output + envelope. The auth-provider id (`auth_providers[].id == "simpleAuth"`) + is also unchanged. +- The Data Science API on every Keboola stack we've probed (europe-west3.gcp, + us-east4.gcp; 2026-05-12) serves camelCase keys on the wire (`id`, + `configId`, `desiredState`, `configVersion`, ...). kbagent reads those + camelCase keys directly and emits its own snake_case-ish output keys. + If a future API shape change introduces snake_case wire keys, this + helper will need a defensive alias pass -- not yet warranted. + ## `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/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..8a701bbf 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -8,6 +8,11 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.31.1": [ + "BREAKING (output-only): `kbagent --json data-app list / detail / create / deploy / start / stop / delete / password / secrets-set / secrets-list / secrets-get / secrets-remove` now emit the field name `app_id` instead of bare `id` for the data-app's own identifier. Renamed for input/output symmetry with the existing `--app-id` flag and the rest of the kbagent CLI -- `ConfigService` emits `config_id`, `StorageService` emits `bucket_id` / `table_id` on single-item operations, but `DataAppService` had been the lone hold-out emitting bare `id` since v0.27.0. The pipe-friendly chain `kbagent --json data-app list | jq -r '.apps[].app_id' | xargs -I{} kbagent data-app deploy --project P --app-id {}` now works as the input flag implies. `config_id` is unchanged. The Storage config back-pointer at `parameters.id` (writeup §5; lives INSIDE the configuration body sent TO Storage, not in kbagent output) is unchanged. Migration for any downstream consumer reading the JSON output: rename `.id` -> `.app_id` in jq filters / scripts. Live-validated 2026-05-12 against europe-west3.gcp projects 1143, 2738, 2959 (the project @ottomansky cited when raising the original UX complaint on v0.30.2; the more dramatic null-id symptom in that report could not be reproduced on v0.30.6 -- which is byte-identical to v0.30.2 for these files -- so this release is scoped to the rename only). Reported by @ottomansky.", + "Plugin docs: new `(since v0.31.1)` entry in `gotchas.md` documenting the `id` -> `app_id` output rename. `commands-reference.md` data-app section gets a one-line callout. `keboola-expert.md` VERSION GATE Rule 6 lists `data-app *` JSON output key as `app_id` on 0.31.1+. AGENT_CONTEXT and the auto-generated SKILL.md decision table are unaffected (no command-signature changes).", + "Tests: new `tests/test_data_app_service.py::TestDataAppListOutputKeys::test_list_emits_app_id_key` locks the rename + asserts the legacy `id` key is gone. New `tests/test_data_app_service.py::TestDataAppEnvelopesNoBareIdKey` (11 parametrised tests, one per envelope-emitting method: detail, create, deploy, start, stop, delete, password, secrets-set, secrets-list, secrets-get, secrets-remove) is a regression guard preventing any future edit from accidentally re-introducing the bare `id` key. Existing `tests/test_data_app_service.py`, `tests/test_data_app_cli.py`, `tests/test_data_app_secrets_cli.py` updated for the `app_id` rename. E2E coverage extended in `tests/test_e2e.py::TestE2EDataAppLifecycle::test_data_app_lifecycle_public` with a list-step round-trip assertion that the newly-created app appears with populated `app_id` and `config_id`.", + ], "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/data_app.py b/src/keboola_agent_cli/commands/data_app.py index 08ed3d56..e3340a2e 100644 --- a/src/keboola_agent_cli/commands/data_app.py +++ b/src/keboola_agent_cli/commands/data_app.py @@ -50,7 +50,7 @@ def _print_data_app_table(formatter, result: dict) -> None: return for app in apps: formatter.console.print( - f" [bold]{app['id']}[/bold] " + f" [bold]{app['app_id']}[/bold] " f"[cyan]{app.get('name', '')}[/cyan] " f"({app.get('type', '?')}) " f"state=[yellow]{app.get('state', '?')}[/yellow] " @@ -167,7 +167,7 @@ def data_app_detail( formatter.output( result, lambda c, d: ( - c.print(f"\n[bold]Data app:[/bold] {d.get('name', '')} ({d['id']})"), + c.print(f"\n[bold]Data app:[/bold] {d.get('name', '')} ({d['app_id']})"), c.print(f" [bold]Project:[/bold] {d['project_alias']}"), c.print(f" [bold]Slug:[/bold] {d.get('slug', '')}"), c.print(f" [bold]Type:[/bold] {d.get('type', '')}"), @@ -405,7 +405,7 @@ def data_app_create( formatter.console.print( f"[bold green]Success:[/bold green] {result.get('message', '')}" ) - formatter.console.print(f" [bold]App ID:[/bold] {result['id']}") + formatter.console.print(f" [bold]App ID:[/bold] {result['app_id']}") formatter.console.print(f" [bold]Config ID:[/bold] {result['config_id']}") if result.get("url"): formatter.console.print(f" [bold]URL:[/bold] {result['url']}") @@ -926,7 +926,7 @@ def data_app_secrets_list( return formatter.console.print( f"\n[bold]{result['count']} secret(s)[/bold] on data app " - f"[cyan]{result['id']}[/cyan] in [magenta]{result['project_alias']}[/magenta]:" + f"[cyan]{result['app_id']}[/cyan] in [magenta]{result['project_alias']}[/magenta]:" ) for entry in result["secrets"]: marker = ( diff --git a/src/keboola_agent_cli/services/data_app_service.py b/src/keboola_agent_cli/services/data_app_service.py index 9e4c3372..9b7cef79 100644 --- a/src/keboola_agent_cli/services/data_app_service.py +++ b/src/keboola_agent_cli/services/data_app_service.py @@ -360,7 +360,7 @@ def worker( merged.append( { "project_alias": alias, - "id": str(app.get("id", "")), + "app_id": str(app.get("id", "")), "config_id": config_id, "name": config_names.get(config_id, app.get("name", "")), "type": app.get("type", ""), @@ -393,7 +393,7 @@ def worker( all_apps: list[dict[str, Any]] = [] for _alias, apps, _ok in successes: all_apps.extend(apps) - all_apps.sort(key=lambda a: (a["project_alias"], a.get("id", ""))) + all_apps.sort(key=lambda a: (a["project_alias"], a.get("app_id", ""))) errors.sort(key=lambda e: e.get("project_alias", "")) return {"apps": all_apps, "errors": errors} @@ -441,7 +441,7 @@ def get_data_app( return { "project_alias": alias, - "id": str(app.get("id", "")), + "app_id": str(app.get("id", "")), "config_id": config_id, "config_version_storage": str(storage_config.get("version", "") or ""), "config_version_deployed": str(app.get("configVersion", "") or ""), @@ -642,9 +642,11 @@ def create_data_app( timeout_seconds=timeout_seconds, ) + url_record = deployed_record or shell + state_record = poll_result or deployed_record or shell return { "project_alias": alias, - "id": app_id, + "app_id": app_id, "config_id": config_id, "name": name, "slug": slug, @@ -657,9 +659,9 @@ def create_data_app( "config_version": storage_version, "deployed": bool(deploy), "wait": bool(wait), - "url": (deployed_record or shell).get("url", ""), - "state": (poll_result or deployed_record or shell).get("state", ""), - "desired_state": (poll_result or deployed_record or shell).get("desiredState", ""), + "url": url_record.get("url", ""), + "state": state_record.get("state", ""), + "desired_state": state_record.get("desiredState", ""), "last_start_timestamp": (poll_result or deployed_record or {}).get( "lastStartTimestamp" ), @@ -668,7 +670,7 @@ def create_data_app( auth=auth, deployed=bool(deploy), wait=bool(wait), - state=(poll_result or deployed_record or shell).get("state", ""), + state=state_record.get("state", ""), ), } except Exception: @@ -846,7 +848,7 @@ def delete_data_app(self, alias: str, app_id: str) -> dict[str, Any]: ds_client.close() return { "project_alias": alias, - "id": str(app_id), + "app_id": str(app_id), "deleted": True, "message": ( f"Data app {app_id} deleted from project '{alias}'. " @@ -888,7 +890,7 @@ def get_data_app_password( password = payload.get("password", "") if isinstance(payload, dict) else "" return { "project_alias": alias, - "id": str(app_id), + "app_id": str(app_id), "password": password, "message": ( f"Retrieved simpleAuth password for data app {app_id}. " @@ -1045,7 +1047,7 @@ def set_data_app_secrets( return { "dry_run": True, "project_alias": alias, - "id": str(app_id), + "app_id": str(app_id), "config_id": config_id, "secrets_set": sorted(_derive_runtime_env_var_name(k) for k in validated), "secrets_unchanged": unchanged, @@ -1128,7 +1130,7 @@ def set_data_app_secrets( secrets_set = sorted(_derive_runtime_env_var_name(k) for k in validated) return { "project_alias": alias, - "id": str(app_id), + "app_id": str(app_id), "config_id": config_id, "secrets_set": secrets_set, "secrets_unchanged": unchanged, @@ -1191,7 +1193,7 @@ def list_data_app_secrets( return { "project_alias": alias, - "id": str(app_id), + "app_id": str(app_id), "config_id": config_id, "secrets": entries, "count": len(entries), @@ -1245,7 +1247,7 @@ def get_data_app_secret( env_var = _derive_runtime_env_var_name(key) return { "project_alias": alias, - "id": str(app_id), + "app_id": str(app_id), "config_id": config_id, "key": key, "env_var": env_var, @@ -1305,7 +1307,7 @@ def remove_data_app_secrets( # Idempotent: removing a non-existent key is success. return { "project_alias": alias, - "id": str(app_id), + "app_id": str(app_id), "config_id": config_id, "removed": [], "not_found": not_found, @@ -1324,7 +1326,7 @@ def remove_data_app_secrets( return { "dry_run": True, "project_alias": alias, - "id": str(app_id), + "app_id": str(app_id), "config_id": config_id, "to_remove": removed, "not_found": not_found, @@ -1352,7 +1354,7 @@ def remove_data_app_secrets( new_version = str(put_response.get("version", "") or "") return { "project_alias": alias, - "id": str(app_id), + "app_id": str(app_id), "config_id": config_id, "removed": removed, "not_found": not_found, @@ -1862,7 +1864,7 @@ def _format_lifecycle_result( record = poll_result or deployed return { "project_alias": alias, - "id": str(app_id), + "app_id": str(app_id), "action": action, "state": record.get("state", ""), "desired_state": record.get("desiredState", ""), diff --git a/tests/test_data_app_cli.py b/tests/test_data_app_cli.py index 6aabd279..7c94fc20 100644 --- a/tests/test_data_app_cli.py +++ b/tests/test_data_app_cli.py @@ -80,7 +80,7 @@ def test_json_success(self, tmp_path: Path) -> None: "apps": [ { "project_alias": "prod", - "id": "42", + "app_id": "42", "config_id": "ulid", "name": "App", "type": "python-js", @@ -100,7 +100,8 @@ def test_json_success(self, tmp_path: Path) -> None: assert result.exit_code == 0, result.output body = json.loads(result.output) assert body["status"] == "ok" - assert body["data"]["apps"][0]["id"] == "42" + assert body["data"]["apps"][0]["app_id"] == "42" + assert body["data"]["apps"][0]["config_id"] == "ulid" # --------------------------------------------------------------------------- @@ -225,7 +226,7 @@ def test_deploy_success(self, tmp_path: Path) -> None: mock = MagicMock() mock.deploy_data_app.return_value = { "project_alias": "prod", - "id": "42", + "app_id": "42", "action": "deploy", "state": "starting", "desired_state": "running", @@ -293,7 +294,7 @@ def test_delete_with_yes(self, tmp_path: Path) -> None: mock = MagicMock() mock.delete_data_app.return_value = { "project_alias": "prod", - "id": "42", + "app_id": "42", "deleted": True, "message": "Data app 42 deleted.", } @@ -328,7 +329,7 @@ def test_password_success(self, tmp_path: Path, monkeypatch) -> None: mock = MagicMock() mock.get_data_app_password.return_value = { "project_alias": "prod", - "id": "42", + "app_id": "42", "password": "deadbeefcafe", "message": "Retrieved.", } diff --git a/tests/test_data_app_secrets_cli.py b/tests/test_data_app_secrets_cli.py index 104c6c57..aca7c4d3 100644 --- a/tests/test_data_app_secrets_cli.py +++ b/tests/test_data_app_secrets_cli.py @@ -135,7 +135,7 @@ def test_happy_path_json_envelope(self, tmp_path: Path) -> None: mock = MagicMock() mock.set_data_app_secrets.return_value = { "project_alias": "prod", - "id": "12345", + "app_id": "12345", "config_id": "01ABC", "secrets_set": ["API_KEY"], "secrets_unchanged": [], @@ -208,7 +208,7 @@ def test_empty_list_json(self, tmp_path: Path) -> None: mock = MagicMock() mock.list_data_app_secrets.return_value = { "project_alias": "prod", - "id": "12345", + "app_id": "12345", "config_id": "01ABC", "secrets": [], "count": 0, @@ -246,7 +246,7 @@ def test_decrypted_plaintext_never_in_output(self, tmp_path: Path) -> None: # Service returns metadata only -- the public contract. mock.get_data_app_secret.return_value = { "project_alias": "prod", - "id": "12345", + "app_id": "12345", "config_id": "01ABC", "key": "#API_KEY", "env_var": "API_KEY", @@ -298,7 +298,7 @@ def test_service_leak_attempt_is_filtered(self, tmp_path: Path) -> None: # appear in result.output -- the assertion is a regression guard. mock.get_data_app_secret.return_value = { "project_alias": "prod", - "id": "12345", + "app_id": "12345", "config_id": "01ABC", "key": "#API_KEY", "env_var": "API_KEY", @@ -376,7 +376,7 @@ def test_idempotent_on_missing_key_yes(self, tmp_path: Path) -> None: mock = MagicMock() mock.remove_data_app_secrets.return_value = { "project_alias": "prod", - "id": "12345", + "app_id": "12345", "config_id": "01ABC", "removed": [], "not_found": ["MISSING"], diff --git a/tests/test_data_app_service.py b/tests/test_data_app_service.py index 6548b383..bd53ef3b 100644 --- a/tests/test_data_app_service.py +++ b/tests/test_data_app_service.py @@ -468,7 +468,7 @@ def test_happy_path_private_repo(self, tmp_path: Path) -> None: restart_if_running=True, ) - assert result["id"] == "43661269" + assert result["app_id"] == "43661269" assert result["config_id"] == "01kqj88t0vktxe0vfhk6ps5kzs" assert result["url"].endswith("hub.us-east4.gcp.keboola.com") # Encrypted PAT is redacted in the returned dict for human display. @@ -804,7 +804,7 @@ def test_delete_calls_data_science_delete(self, tmp_path: Path) -> None: result = service.delete_data_app(alias="prod", app_id="42") ds_mock.delete_app.assert_called_once_with("42") assert result["deleted"] is True - assert result["id"] == "42" + assert result["app_id"] == "42" # --------------------------------------------------------------------------- @@ -851,7 +851,7 @@ def test_detail_merges_data_science_and_storage(self, tmp_path: Path) -> None: } result = service.get_data_app(alias="prod", app_id="42") - assert result["id"] == "42" + assert result["app_id"] == "42" assert result["state"] == "running" assert result["config_version_storage"] == "5" assert result["config_version_deployed"] == "3" @@ -914,3 +914,211 @@ def test_no_git_block_is_noop(self) -> None: def test_empty_dict_passes_through(self) -> None: assert _redact_storage_config({}) == {} + + +# --------------------------------------------------------------------------- +# List service — output key rename to `app_id` (v0.31.1) +# --------------------------------------------------------------------------- + + +class TestDataAppListOutputKeys: + """Lock the v0.31.1 JSON output rename ``id`` -> ``app_id``. + + Prior to v0.31.1 ``list_data_apps`` emitted the data-app's own + identifier as bare ``id``. The renamed key matches the ``--app-id`` + input flag and the rest of kbagent's CLI convention (e.g. + ``config_id``, ``bucket_id``, ``table_id``). + """ + + def test_list_emits_app_id_key(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, storage_mock, _enc = _make_service(store) + ds_mock.list_apps.return_value = [ + { + "id": "43661269", + "configId": "01kqj88t0vktxe0vfhk6ps5kzs", + "type": "python-js", + "state": "running", + "desiredState": "running", + "configVersion": "3", + "url": "https://x.hub.example.com", + "size": "tiny", + "autoSuspendAfterSeconds": 900, + "lastStartTimestamp": "2026-05-01T00:00:00Z", + } + ] + storage_mock.list_component_configs.return_value = [ + {"id": "01kqj88t0vktxe0vfhk6ps5kzs", "name": "App"} + ] + + result = service.list_data_apps(aliases=["prod"]) + + assert result["errors"] == [] + assert len(result["apps"]) == 1 + app = result["apps"][0] + assert app["app_id"] == "43661269" + # Regression guard: pre-0.31.1 callers would have read ``app["id"]``. + assert "id" not in app + assert app["config_id"] == "01kqj88t0vktxe0vfhk6ps5kzs" + assert app["name"] == "App" + + +class TestDataAppEnvelopesNoBareIdKey: + """Regression guard: NO ``DataAppService`` envelope emits the legacy bare + ``id`` key. The id key was renamed to ``app_id`` in v0.31.1; future + edits that accidentally re-add ``"id":`` to any envelope must fail here. + + Covers every method whose return dict carries the data-app identifier: + detail, create, deploy, start, stop, delete, password, secrets-* (set, + list, get, remove). ``list_data_apps`` is covered by + ``TestDataAppListOutputKeys``. + """ + + def test_detail_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, storage_mock, _enc = _make_service(store) + ds_mock.get_app.return_value = {"id": "42", "configId": "ulid", "state": "running"} + storage_mock.get_config_detail.return_value = {"version": "3", "name": "App"} + result = service.get_data_app(alias="prod", app_id="42") + assert result["app_id"] == "42" + assert "id" not in result + + def test_create_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, storage_mock, _enc = _make_service(store) + ds_mock.create_app.return_value = {"id": "42", "configId": "ulid"} + storage_mock.update_config.return_value = {"version": "2"} + result = service.create_data_app( + alias="prod", + name="App", + description="", + slug="my-app", + git_repo="https://github.com/o/r", + git_public=True, + auth="password", + size="tiny", + auto_suspend_after_seconds=900, + type_="python-js", + deploy=False, + wait=False, + dry_run=False, + ) + assert result["app_id"] == "42" + assert "id" not in result + + def test_deploy_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, storage_mock, _enc = _make_service(store) + ds_mock.get_app.return_value = {"configId": "ulid"} + storage_mock.get_config_detail.return_value = {"version": "3"} + ds_mock.patch_app.return_value = {"state": "starting", "desiredState": "running"} + result = service.deploy_data_app(alias="prod", app_id="42") + assert result["app_id"] == "42" + assert "id" not in result + + def test_start_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, _storage, _enc = _make_service(store) + ds_mock.patch_app.return_value = {"state": "starting", "desiredState": "running"} + result = service.start_data_app(alias="prod", app_id="42") + assert result["app_id"] == "42" + assert "id" not in result + + def test_stop_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, _storage, _enc = _make_service(store) + ds_mock.patch_app.return_value = {"state": "stopping", "desiredState": "stopped"} + result = service.stop_data_app(alias="prod", app_id="42") + assert result["app_id"] == "42" + assert "id" not in result + + def test_delete_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, _ds, _storage, _enc = _make_service(store) + result = service.delete_data_app(alias="prod", app_id="42") + assert result["app_id"] == "42" + assert "id" not in result + + def test_password_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, _storage, _enc = _make_service(store) + ds_mock.get_app_password.return_value = {"password": "deadbeefcafe"} + result = service.get_data_app_password( + alias="prod", app_id="42", manage_token=TEST_MANAGE_TOKEN + ) + assert result["app_id"] == "42" + assert "id" not in result + + def test_secrets_set_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, storage_mock, encrypt_mock = _make_service(store) + encrypt_mock.encrypt.return_value = {"#API_KEY": "KBC::ProjectSecureGKMS::xyz"} + ds_mock.get_app.return_value = {"configId": "ulid"} + storage_mock.get_config_detail.return_value = { + "version": "5", + "configuration": {"parameters": {"dataApp": {"slug": "x"}}}, + } + storage_mock.update_config.return_value = {"version": "6"} + result = service.set_data_app_secrets( + alias="prod", app_id="42", secrets={"#API_KEY": "plaintext"} + ) + assert result["app_id"] == "42" + assert "id" not in result + + def test_secrets_list_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, storage_mock, _enc = _make_service(store) + ds_mock.get_app.return_value = {"configId": "ulid"} + storage_mock.get_config_detail.return_value = { + "version": "5", + "configuration": { + "parameters": { + "dataApp": { + "slug": "x", + "secrets": {"#FOO": "KBC::ProjectSecureGKMS::abc"}, + } + } + }, + } + result = service.list_data_app_secrets(alias="prod", app_id="42") + assert result["app_id"] == "42" + assert "id" not in result + + def test_secrets_get_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, storage_mock, _enc = _make_service(store) + ds_mock.get_app.return_value = {"configId": "ulid"} + storage_mock.get_config_detail.return_value = { + "version": "5", + "configuration": { + "parameters": { + "dataApp": { + "slug": "x", + "secrets": {"#FOO": "KBC::ProjectSecureGKMS::abc"}, + } + } + }, + } + result = service.get_data_app_secret(alias="prod", app_id="42", key="#FOO") + assert result["app_id"] == "42" + assert "id" not in result + + def test_secrets_remove_envelope(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + service, ds_mock, storage_mock, _enc = _make_service(store) + ds_mock.get_app.return_value = {"configId": "ulid"} + storage_mock.get_config_detail.return_value = { + "version": "5", + "configuration": { + "parameters": { + "dataApp": { + "slug": "x", + "secrets": {"#FOO": "KBC::ProjectSecureGKMS::abc"}, + } + } + }, + } + storage_mock.update_config.return_value = {"version": "6"} + result = service.remove_data_app_secrets(alias="prod", app_id="42", keys=["#FOO"]) + assert result["app_id"] == "42" + assert "id" not in result diff --git a/tests/test_e2e.py b/tests/test_e2e.py index fc20de3d..c8635c2a 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -6293,11 +6293,33 @@ def test_data_app_lifecycle_public(self) -> None: ) assert result.exit_code == 0, result.output body = _json_ok(result) - app_id = body["data"]["id"] + # v0.31.1 rename: envelope key is ``app_id`` (was bare ``id``). + app_id = body["data"]["app_id"] assert app_id, "expected a numeric app id from POST /apps" + assert body["data"]["config_id"], "expected a config_id from POST /apps" self._created_app_ids.append(app_id) - _step(2, "Detail merges Data Science + Storage") + _step(2, "List shows the created app with populated app_id + config_id") + list_result = _json_ok( + _invoke( + self.config_dir, + [ + "--json", + "data-app", + "list", + "--project", + self.alias, + ], + ) + )["data"] + listed = next( + (a for a in list_result["apps"] if a["app_id"] == app_id), + None, + ) + assert listed is not None, f"newly-created app {app_id} not found in data-app list output" + assert listed["config_id"], "data-app list must emit a populated config_id" + + _step(3, "Detail merges Data Science + Storage") detail = _json_ok( _invoke( self.config_dir, @@ -6312,7 +6334,7 @@ def test_data_app_lifecycle_public(self) -> None: ], ) )["data"] - assert detail["id"] == app_id + assert detail["app_id"] == app_id assert detail["slug"] == slug assert detail["config_version_storage"], ( "Storage config version should be populated after PUT" @@ -6351,7 +6373,7 @@ def test_data_app_lifecycle_private_and_redeploy(self) -> None: ) assert result.exit_code == 0, result.output body = _json_ok(result) - app_id = body["data"]["id"] + app_id = body["data"]["app_id"] self._created_app_ids.append(app_id) # The encrypted PAT must NEVER appear in the JSON output. plaintext_pat = os.environ[ENV_DATA_APP_GIT_PAT] @@ -6432,7 +6454,7 @@ def test_data_app_secrets_round_trip(self) -> None: ], ) )["data"] - app_id = create["id"] + app_id = create["app_id"] self._created_app_ids.append(app_id) _step(2, "secrets-set: encrypt and write") 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" },