diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 40d7e2cc..8cee447c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.63.2", + "version": "0.63.3", "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 968959b9..caaaa100 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -436,6 +436,12 @@ kbagent data-app secrets-list --project ALIAS --app-id ID [--branch ID] [--show- kbagent data-app secrets-get --project ALIAS --app-id ID --key 'KEY' [--branch ID] # '#' optional; plain values return their value, encrypted return metadata only kbagent data-app secrets-remove --project ALIAS --app-id ID --key 'KEY' [--key ...] [--branch ID] [--yes] [--dry-run] # '#' optional kbagent data-app validate-repo --git-repo URL [--git-branch BRANCH] [--git-public/--no-git-public] [--git-pat-env VAR | --git-pat-file PATH] [--type python-js] [--strict] +kbagent data-app git-repo --project NAME --app-id ID +kbagent data-app git-branches --project NAME --app-id ID +kbagent data-app git-entrypoints --project NAME --app-id ID +kbagent data-app git-credentials --project NAME --app-id ID +kbagent data-app git-credentials-create --project NAME --app-id ID --type ssh_key|http_token --permissions readOnly|readWrite [--public-key KEY | --public-key-file PATH] [--name LABEL] [--yes] +# git-repo/git-branches/git-entrypoints introspect the deployed-from git repo (sandboxes-service /apps/{id}/git-repo/*); they return 409 "no Git repository configured" until the app has been DEPLOYED at least once (git config syncs Storage->DS record at deploy). git-credentials* manage credentials for a MANAGED repo only; apps from `data-app create --git-repo` are external => git-credentials-create returns 409. http_token mints a ONE-TIME secret (shown once); credentials endpoints need an admin storage token. kbagent component list [--project NAME] [--type TYPE] [--query QUERY] kbagent component detail --component-id ID [--project NAME] diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index b6138005..c6816a70 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.63.2", + "version": "0.63.3", "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 84b24176..17408b1d 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -134,6 +134,7 @@ a critical failure. | Read one secret / env-var key | `kbagent data-app secrets-get --project P --app-id N --key 'KEY'` (0.43.9+: `#` optional) -- ENCRYPTED -> metadata only (`value: null`); PLAIN -> literal value (`encrypted: false`) | -- | `--key '#KEY'` when stored without `#` (exact-match -> NOT_FOUND); trying to decrypt an encrypted secret | | Remove a secret / env-var key from a data app | `kbagent data-app secrets-remove --project P --app-id N --key 'KEY' --yes` (0.43.9+: `#` optional; removes encrypted + plain) -- idempotent; missing keys exit 0, `removed: 0` | `tool call update_config` with the secrets sub-dict deleted -- ONLY for batch removes needing a custom change description | `config update --set 'parameters.dataApp.secrets={}'` -- drops EVERY secret, not just the named ones | | Pre-flight a data-app repo before create | `kbagent data-app validate-repo --git-repo URL --type python-js [--git-pat-env VAR]` (0.29.0+) -- BLOCKING / WARN / OK with help-doc citations; ≤5 GitHub API calls regardless of repo size | git-clone the repo locally and inspect by hand | `data-app create --dry-run` (only shows the request bodies; does not validate repo structure) | +| Inspect / manage the git repo of an EXISTING data app | `kbagent data-app git-repo \| git-branches \| git-entrypoints --project P --app-id N` (0.63.3+) for introspection; `data-app git-credentials \| git-credentials-create` for MANAGED-repo credentials | `tool call get_configs keboola.data-apps` then read `parameters.dataApp.git` (Storage view only -- no live branches/entrypoints) | calling them on a `--no-deploy` app (409 until first deploy) or `git-credentials-create` on an external repo (409 -- managed only) | | Rename a project alias | `kbagent project edit --project OLD --new-alias NEW [--dry-run]` (0.31.0+) -- cascades through `config.json` (`projects` key + `default_project`) and the nested-sync directory `//`. Combined with `--url`/`--token` in one call, those mutations target the new alias post-rename. `--dry-run` previews collision detection, planned disk-rename method, and the lineage-cache warning without mutating state. **Lineage cache (if any) is NOT auto-updated**: rebuild via `kbagent lineage build` after the rename | `kbagent project remove` + `kbagent project add` (re-enters the token; loses any nested sync workspace) | hand-editing `~/.config/keboola-agent-cli/config.json` (no validation, easy to miss `default_project` cascade) | | Run kbagent headless from a daemon / container / CI with only a token in env (no `project add`, no `config.json`) | Export `KBAGENT_PROJECT_FROM_ENV=1` + `KBC_TOKEN` + `KBC_STORAGE_API_URL`, then `kbagent --json storage file-upload --project __env__ --file X` (0.50.0+). Synthesizes an in-memory `__env__` project; token NEVER persisted (stripped on any save); same env setup also powers `kbagent serve` (POST `project=__env__`) | a one-shot `kbagent project add --project env --token ... --url ...` (works but writes the token to `config.json` on disk -- defeats "no local config") | hand-writing a `config.json` with the token, or passing `--token` per command (no such passthrough on storage/job/config commands) | | Call the running `kbagent serve` from a scheduled-agent subprocess | `kbagent http get/post/patch/delete ` (0.40.0+) -- uses `KBAGENT_SERVE_URL` + `KBAGENT_SERVE_TOKEN` env vars auto-injected by the scheduler. `kbagent http get /openapi.json` to discover endpoints. Treats the live serve as source-of-truth (no stale local config) | forking `kbagent ` (also fine -- `KBAGENT_CONFIG_DIR` is propagated so the spawned CLI sees the SAME config the serve uses; no more "I'm in the wrong directory" surprises) | `curl $KBAGENT_SERVE_URL/...` by hand (works, but `kbagent http` adds auth header automatically, structured error mapping, and JSON-mode formatting) | @@ -255,6 +256,14 @@ read it when a trigger fires. Each `(X.Y.Z+)` tag is the version floor. KMS -- ciphertext does NOT cross projects. - **`validate-repo`** (0.29.0+): GitHub-only, `--type python-js` only, <=5 API calls; run BEFORE `data-app create`. +- **`git-repo` / `git-branches` / `git-entrypoints`** (0.63.3+): introspect the + repo an app deploys from (clone URLs, branches, root `.py` entrypoints). They + return 409 `no Git repository configured` until the app has been **deployed at + least once** -- the git block syncs Storage->DS app record at deploy time, so a + `--no-deploy` app reads as having no repo. `git-credentials` / + `git-credentials-create` (0.63.3+) work on **managed** repos only (admin + storage token; `http_token` returns a one-time secret shown once); apps from + `data-app create --git-repo ` are external -> 409 on credential create. **Project / Manage** diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index 4bc8bef4..e1234659 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -179,6 +179,11 @@ When working inside a git repository or project directory, run `kbagent init` (o | Show ONE key from parameters.dataApp.secrets | `kbagent data-app secrets-get --project PROJECT --app-id APP-ID --key KEY` | | Remove one or more app-runtime secrets. | `kbagent data-app secrets-remove --project PROJECT --app-id APP-ID --key KEY` | | Pre-flight check that a git repo follows the Keboola data-app Golden Rule | `kbagent data-app validate-repo --git-repo GIT-REPO` | +| Show the clone URLs of a data app's configured git repository | `kbagent data-app git-repo --project PROJECT --app-id APP-ID` | +| List the remote branches of a data app's git repository | `kbagent data-app git-branches --project PROJECT --app-id APP-ID` | +| List root-level .py entrypoint files of a data app's git repository | `kbagent data-app git-entrypoints --project PROJECT --app-id APP-ID` | +| List the credentials of a data app's MANAGED git repository | `kbagent data-app git-credentials --project PROJECT --app-id APP-ID` | +| Create a git credential (SSH key or HTTP token) for a MANAGED repo | `kbagent data-app git-credentials-create --project PROJECT --app-id APP-ID --type CRED-TYPE --permissions PERMISSIONS` | | List jobs from connected projects | `kbagent job list` | | Show detailed information about a specific job | `kbagent job detail --project PROJECT --job-id JOB-ID` | | Run a job for a component configuration | `kbagent job run --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | @@ -409,7 +414,7 @@ For detailed response parsing rules and common pitfalls, see [gotchas](reference | Workspace SQL debugging | [workspace-workflow](references/workspace-workflow.md) | | **Agent Tasks via CLI** (`kbagent agent` CRUD + run + cron-preview + prompt-improve; cron / manual / chained; mcp_tool / cli_command / ai_agent action flavours) | [agent-tasks-cli-workflow](references/agent-tasks-cli-workflow.md) | | **Agent Tasks via REST** (`kbagent http /agents...` from inside scheduled subprocesses; SSE streaming) | [agent-tasks-rest-workflow](references/agent-tasks-rest-workflow.md) | -| **Data apps** (create / deploy / start / stop / password / delete; the §9 redeploy contract) | [data-app-workflow](references/data-app-workflow.md) | +| **Data apps** (create / deploy / start / stop / password / delete; the §9 redeploy contract; git-repo introspection + managed-repo credentials) | [data-app-workflow](references/data-app-workflow.md) | | Storage Files (upload, download, tags, load/unload) | [storage-files-workflow](references/storage-files-workflow.md) | | **Python library** (`from keboola_agent_cli import Client` -- in-process query + Storage Files, no CLI/daemon/config-dir) | [library-workflow](references/library-workflow.md) | | **Data Streams (OTLP / OpenTelemetry)** (create/inspect OTLP source, masked secret-in-URL, OTEL_EXPORTER_OTLP_ENDPOINT) | [stream-workflow](references/stream-workflow.md) | diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 816b9aef..9ef3e6ef 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -190,6 +190,11 @@ Lifecycle for `keboola.data-apps`. Combines Storage API (config body, git block, - `data-app secrets-get --project ALIAS --app-id ID --key 'KEY' [--branch ID]` -- show ONE key from `parameters.dataApp.secrets`. The leading `#` is OPTIONAL (since v0.43.9); the block holds both encrypted secrets (`#`) and plain unencrypted env-var values, and `secrets-list` enumerates both. For an ENCRYPTED secret it stays metadata-only (`encrypted: true`, `value: null`, fingerprint/prefix) -- the decrypted plaintext is NEVER echoed (Encryption API is one-way). For a PLAIN value it returns the literal value (`encrypted: false`), which is already visible via `config detail`. NOT_FOUND on absent key (exact match, no `#KEY`<->`KEY` fuzzing); never enumerates siblings. - `data-app secrets-remove --project ALIAS --app-id ID --key 'KEY' [--key ...] [--branch ID] [--yes] [--dry-run]` -- destructive (can break a running app at next deploy). Leading `#` OPTIONAL (since v0.43.9): removes both encrypted secrets and plain env-var keys. Idempotent: missing keys exit 0 with `removed: 0`. - `data-app validate-repo --git-repo URL [--git-branch BRANCH] [--git-public/--no-git-public] [--git-pat-env VAR | --git-pat-file PATH] [--type python-js] [--strict]` -- pre-flight Golden-Rule check for a data-app git repo (https://help.keboola.com/data-apps/python-js/). GitHub-only; ≤5 API calls (1 tree + ≤4 contents) regardless of repo size. `--type` restricted to `python-js` in 0.28.0; streamlit / pure-Python / R / Node-only follow-up. `--strict` treats WARNs as failures. +- `data-app git-repo --project NAME --app-id ID` (since 0.63.3) -- show the clone URLs (`ssh_url` / `https_url`) of the app's configured git repo + `is_managed_git_repo` (sandboxes-service `GET /apps/{id}/git-repo`). Read-only, project storage token only. **GOTCHA**: returns 409 `no Git repository configured` until the app has been DEPLOYED at least once -- the git block is synced from the Storage config into the Data Science app record at deploy time; a `--no-deploy` app has no git repo from the service's point of view. +- `data-app git-branches --project NAME --app-id ID` (since 0.63.3) -- list remote branches with commit metadata (`branch`, `sha`, `comment`, `author{name,email}`, `date`); raw top-level array from the server. Same deploy-once precondition as `git-repo`. +- `data-app git-entrypoints --project NAME --app-id ID` (since 0.63.3) -- list root-level `.py` entrypoint files on the configured branch; extension hardcoded to `py` server-side (non-Python entrypoints not listable). Same deploy-once precondition. +- `data-app git-credentials --project NAME --app-id ID` (since 0.63.3) -- list the credentials of the app's MANAGED git repo (`id`, `type`, `permissions`, `name`, `owner_admin_id`, `created_at`). The secret is NEVER returned here. Needs an admin storage token; external repos have none. +- `data-app git-credentials-create --project NAME --app-id ID --type ssh_key|http_token --permissions readOnly|readWrite [--public-key KEY | --public-key-file PATH] [--name LABEL] [--yes]` (since 0.63.3) -- mint a git credential for the app's MANAGED git repo. `ssh_key` requires a public key; `http_token` returns a ONE-TIME secret (shown once, never retrievable again -- mirrors `data-app password`). Needs an admin storage token. Apps from `data-app create --git-repo` are EXTERNAL => 409 `no managed Git repository`. Confirmation unless `--yes`/`--json`. ## MCP Tools - `tool list [--project NAME] [--branch ID]` -- list available MCP tools (multi_project annotation) diff --git a/plugins/kbagent/skills/kbagent/references/data-app-workflow.md b/plugins/kbagent/skills/kbagent/references/data-app-workflow.md index 03993c29..401f4ba2 100644 --- a/plugins/kbagent/skills/kbagent/references/data-app-workflow.md +++ b/plugins/kbagent/skills/kbagent/references/data-app-workflow.md @@ -136,6 +136,55 @@ repo. Public repos: drop `--git-pat-env` and use `--git-public`. Total GitHub call budget per run is ≤5 (1 tree + ≤4 contents) regardless of repo size, so the 60/hour unauth limit rarely fires; pass a PAT for CI loops. +### Inspect the deployed-from git repo (since v0.63.3) + +```bash +# Clone URLs + whether the repo is managed by Keboola: +kbagent data-app git-repo --project prod --app-id 12345678 +# ssh_url / https_url / is_managed_git_repo + +# Remote branches with commit metadata (branch, sha, comment, author, date): +kbagent data-app git-branches --project prod --app-id 12345678 + +# Root-level .py entrypoint files on the configured branch: +kbagent data-app git-entrypoints --project prod --app-id 12345678 +``` + +These read the repo *the app is actually deployed from*, via the +sandboxes-service (`GET /apps/{id}/git-repo`, `/branches`, `/entrypoints`). +They complement `validate-repo`, which inspects an arbitrary repo URL via the +GitHub API *before* you create an app; the `git-*` commands inspect the repo of +an *existing* app server-side. + +**Precondition:** the app must have been **deployed at least once**. The git +block is synced from the Storage config into the Data Science app record at +deploy time, so a fresh `--no-deploy` app returns 409 "no Git repository +configured" from all three. Run `data-app deploy` first. + +### Manage git credentials for a managed repo (since v0.63.3) + +```bash +# List credentials of a MANAGED git repo (the secret is never returned): +kbagent data-app git-credentials --project prod --app-id 12345678 + +# Mint an HTTP token (the one-time secret is printed once, never again): +kbagent data-app git-credentials-create \ + --project prod --app-id 12345678 \ + --type http_token --permissions readOnly --name ci-readonly --yes + +# Register an SSH public key instead: +kbagent data-app git-credentials-create \ + --project prod --app-id 12345678 \ + --type ssh_key --permissions readWrite --public-key-file ./deploy_key.pub +``` + +Credential management applies **only to managed git repos** +(`app.managedGitRepoId` set, typically provisioned in the UI). Apps created via +`data-app create --git-repo ` are **external**, so `git-credentials-create` +returns 409 "no managed Git repository" for them. Both credential commands also +need an **admin** storage token (`CanManageAppRepoCredentials`), unlike the read +trio above which need only the ordinary project storage token. + ### Manage app-runtime secrets (since v0.29.0) ```bash diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index c291a63e..bc40c72d 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -2607,6 +2607,37 @@ forking `kbagent`, and never needs those super-admin credentials. `KBC_TOKEN` (storage) is retained, and `cli_command` tasks are unchanged. (Private advisory GHSA-wm54-r2hh-cxm9.) +## `data-app git-repo` / `git-branches` / `git-entrypoints` need a deployed app (since v0.63.3) + +The three git-repo introspection commands (sandboxes-service +`GET /apps/{id}/git-repo`, `/branches`, `/entrypoints`) return **409 "App has no +Git repository configured"** until the app has been **deployed at least once** -- +even though `data-app create --git-repo ` already wrote the git block into +the Storage config. The git block is synced from the Storage config into the +Data Science app record at *deploy* time; a `--no-deploy` app has no git repo +from the service's point of view. Fix: run `kbagent data-app deploy` (the sync +happens before the container build, so it works even if the build later fails), +then re-run the git-repo command. + +Other behaviors of this family: + +- `git-branches` returns a **raw top-level JSON array** of + `{branch, sha, comment, author{...}, date}` (not wrapped in + `{branches: [...]}`); `git-entrypoints` returns a **raw `array`** of + root-level filenames. The service hardcodes the entrypoint extension to `.py`, + so non-Python entrypoints are never listed. +- `git-credentials` / `git-credentials-create` only apply to a **managed** git + repo (`app.managedGitRepoId` set). Apps created via + `data-app create --git-repo ` are **external**, so + `git-credentials-create` returns **409 "no managed Git repository"** for them. + These two endpoints also need an **admin** storage token + (`CanManageAppRepoCredentials`), unlike the read trio which need only the + ordinary project storage token. +- For `--type http_token`, the create response carries a **one-time secret** + that is printed once and can never be retrieved again (mirrors + `data-app password`); the `git-credentials` list never returns it. `--type + ssh_key` requires a `--public-key` / `--public-key-file` and returns no secret. + ## Core platform gotchas (version-independent) These are Keboola-platform behaviors, not kbagent features, so they carry no diff --git a/pyproject.toml b/pyproject.toml index 44debebf..9280acbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.63.2" +version = "0.63.3" 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 41c2a7bb..0184846a 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,29 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.63.3": [ + "Fix: `kbagent context` no longer renders API path templates as " + "`/apps//logs/tail`. `AGENT_CONTEXT` is an f-string (it " + "interpolates the version), so unescaped `{id}` placeholders were evaluated against the " + "Python builtin `id`; the literal braces are now escaped so paths render the OpenAPI-style " + "`{id}` consistently (affected /jobs/{id}/kill, /tables/{id}/swap, /tables/{id}/pull, " + "/apps/{id}/logs/tail).", + "New: `data-app git-repo`, `data-app git-branches`, and `data-app git-entrypoints` introspect " + "the git repository a data app is deployed from (sandboxes-service `/apps/{id}/git-repo/*`): " + "clone URLs plus a managed flag, remote branches with commit metadata, and the root-level " + "`.py` entrypoint files. These read endpoints work for any configured repo (managed or " + 'external) and need only the project storage token. Gotcha: they return 409 "no Git ' + 'repository configured" until the app has been deployed at least once -- the git block is ' + "synced from the Storage config into the Data Science app record at deploy time, so a " + "`--no-deploy` app has no git repo from the service's point of view.", + "New: `data-app git-credentials` and `data-app git-credentials-create` list and mint git " + "credentials (an SSH key or an HTTP token) for a *managed* git repo. Apps created via " + "`data-app create --git-repo ` are external (not managed), so `git-credentials-create` " + "returns 409 for them; credential management applies to managed repos and requires an admin " + "storage token (CanManageAppRepoCredentials). For `--type http_token` the response carries a " + "one-time secret that is printed once and can never be retrieved again (mirrors `data-app " + "password`); `--type ssh_key` requires `--public-key` / `--public-key-file`.", + ], "0.63.2": [ "Docs: the in-process Python SDK is now documented. New `docs/sdk.md` is the deep " "guide -- the importable `Client` facade vs the CLI vs the `serve` REST API, where the " diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index 20ddf271..4e3218ee 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -48,6 +48,7 @@ from .services.branch_service import BranchService from .services.component_service import ComponentService from .services.config_service import ConfigService +from .services.data_app_git_service import DataAppGitService from .services.data_app_service import DataAppService from .services.deep_lineage_service import DeepLineageService from .services.doctor_service import DoctorService @@ -326,6 +327,7 @@ def main( schedule_service = ScheduleService(config_store=config_store) workspace_service = WorkspaceService(config_store=config_store) data_app_service = DataAppService(config_store=config_store) + data_app_git_service = DataAppGitService(config_store=config_store) semantic_layer_service = SemanticLayerService(config_store=config_store) repo_validate_service = RepoValidateService(config_store=config_store) kai_service = KaiService(config_store=config_store) @@ -380,6 +382,7 @@ def main( ctx.obj["schedule_service"] = schedule_service ctx.obj["workspace_service"] = workspace_service ctx.obj["data_app_service"] = data_app_service + ctx.obj["data_app_git_service"] = data_app_git_service ctx.obj["semantic_layer_service"] = semantic_layer_service ctx.obj["repo_validate_service"] = repo_validate_service ctx.obj["kai_service"] = kai_service diff --git a/src/keboola_agent_cli/commands/_data_app_git.py b/src/keboola_agent_cli/commands/_data_app_git.py new file mode 100644 index 00000000..5f795e20 --- /dev/null +++ b/src/keboola_agent_cli/commands/_data_app_git.py @@ -0,0 +1,343 @@ +"""``data-app git-*`` commands (sandboxes-service ``/apps/{id}/git-repo/*``). + +Split out of ``data_app.py`` to respect the file-size budget +(CONTRIBUTING.md "File-size budgets"). The commands attach to the existing +``data-app`` Typer sub-app via :func:`register_git_commands`, called at the +bottom of ``data_app.py`` -- so they still surface as +``kbagent data-app git-*`` and the permission gate +(``data-app.``) and CLI command-sync checks see them unchanged. + +Read trio (git-repo / git-branches / git-entrypoints) needs only the project +storage token; the credential pair (git-credentials / +git-credentials-create) needs an admin storage token and targets *managed* +git repos only. See ``references/gotchas.md`` for the deploy-once precondition. +""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from rich.console import Console +from rich.markup import escape + +from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ._helpers import get_formatter, get_service, map_error_to_exit_code + +# sandboxes-service git-repo credential enums (API contract literals). +_GIT_CRED_TYPES = ("ssh_key", "http_token") +_GIT_CRED_PERMISSIONS = ("readOnly", "readWrite") + + +def register_git_commands(app: typer.Typer) -> None: + """Attach the five ``data-app git-*`` commands to the data-app sub-app.""" + + @app.command("git-repo") + def data_app_git_repo( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), + ) -> None: + """Show the clone URLs of a data app's configured git repository. + + Returns sshUrl / httpsUrl and whether the repo is *managed* by Keboola. + Apps created with `data-app create --git-repo ` are *external* + (not managed), so the git-credentials commands do not apply to them. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "data_app_git_service") + try: + result = service.get_data_app_git_repo(alias=project, app_id=app_id) + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + formatter.output( + result, + lambda c, d: ( + c.print( + f"\n[bold]Git repository[/bold] (app {d['app_id']} in {d['project_alias']})" + ), + c.print(f" [bold]Managed:[/bold] {d['is_managed_git_repo']}"), + c.print(f" [bold]SSH URL:[/bold] {d.get('ssh_url') or '-'}"), + c.print(f" [bold]HTTPS URL:[/bold] {d.get('https_url') or '-'}"), + ), + ) + + @app.command("git-branches") + def data_app_git_branches( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), + ) -> None: + """List the remote branches of a data app's git repository.""" + formatter = get_formatter(ctx) + service = get_service(ctx, "data_app_git_service") + try: + result = service.list_data_app_git_branches(alias=project, app_id=app_id) + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + def _human(c: Console, d: dict) -> None: + branches = d.get("branches", []) + if not branches: + c.print("[dim]No branches returned.[/dim]") + return + c.print( + f"\n[bold]{d['count']} branch(es)[/bold] " + f"(app {d['app_id']} in {d['project_alias']})" + ) + for b in branches: + author = b.get("author", {}) + c.print( + f" [bold cyan]{b['branch']}[/bold cyan] " + f"[yellow]{b.get('sha', '')}[/yellow] " + f"[dim]{author.get('name', '')} · {b.get('date', '')}[/dim]" + ) + if b.get("comment"): + c.print(f" {escape(b['comment'])}") + + formatter.output(result, _human) + + @app.command("git-entrypoints") + def data_app_git_entrypoints( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), + ) -> None: + """List root-level .py entrypoint files of a data app's git repository. + + The server only lists Python (.py) files at the repo root on the + configured branch (or the repo default). + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "data_app_git_service") + try: + result = service.list_data_app_git_entrypoints(alias=project, app_id=app_id) + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + def _human(c: Console, d: dict) -> None: + entrypoints = d.get("entrypoints", []) + if not entrypoints: + c.print("[dim]No .py entrypoints found at the repo root.[/dim]") + return + c.print( + f"\n[bold]{d['count']} entrypoint(s)[/bold] " + f"(app {d['app_id']} in {d['project_alias']})" + ) + for name in entrypoints: + c.print(f" [cyan]{name}[/cyan]") + + formatter.output(result, _human) + + @app.command("git-credentials") + def data_app_git_credentials( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), + ) -> None: + """List the credentials of a data app's MANAGED git repository. + + Only applies to managed repos (the server returns 409 for external + repos). The credential secret is never returned by this endpoint -- + it is shown once at create time only. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "data_app_git_service") + try: + result = service.list_data_app_git_credentials(alias=project, app_id=app_id) + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + def _human(c: Console, d: dict) -> None: + credentials = d.get("credentials", []) + if not credentials: + c.print("[dim]No git credentials configured.[/dim]") + return + c.print( + f"\n[bold]{d['count']} credential(s)[/bold] " + f"(app {d['app_id']} in {d['project_alias']})" + ) + for cred in credentials: + c.print( + f" [bold]{cred['id']}[/bold] " + f"[cyan]{cred.get('type', '')}[/cyan] " + f"[magenta]{cred.get('permissions', '')}[/magenta] " + f"{cred.get('name', '') or '(no name)'} " + f"[dim]owner={cred.get('owner_admin_id', '')} · " + f"{cred.get('created_at', '')}[/dim]" + ) + + formatter.output(result, _human) + + @app.command("git-credentials-create") + def data_app_git_credentials_create( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), + cred_type: str = typer.Option( + ..., + "--type", + help="Credential type: ssh_key | http_token.", + ), + permissions: str = typer.Option( + ..., + "--permissions", + help="Access level: readOnly | readWrite.", + ), + public_key: str | None = typer.Option( + None, + "--public-key", + help=( + "SSH public key inline (required for --type ssh_key; " + "mutually exclusive with --public-key-file)." + ), + ), + public_key_file: Path | None = typer.Option( + None, + "--public-key-file", + help="Read the SSH public key from this file (required for --type ssh_key).", + ), + name: str | None = typer.Option( + None, + "--name", + help="Optional display label for the credential.", + ), + yes: bool = typer.Option( + False, + "--yes", + "-y", + help="Skip the confirmation prompt.", + ), + ) -> None: + """Create a git credential (SSH key or HTTP token) for a MANAGED repo. + + Only managed repos accept credentials (external repos return 409, and a + non-admin storage token returns 403). For --type http_token the response + includes a ONE-TIME secret that is shown once and cannot be retrieved + again. For --type ssh_key you must supply a public key. + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "data_app_git_service") + + if cred_type not in _GIT_CRED_TYPES: + raise typer.BadParameter( + f"--type must be one of {', '.join(_GIT_CRED_TYPES)} (got {cred_type!r}).", + param_hint="--type", + ) + if permissions not in _GIT_CRED_PERMISSIONS: + raise typer.BadParameter( + f"--permissions must be one of {', '.join(_GIT_CRED_PERMISSIONS)} " + f"(got {permissions!r}).", + param_hint="--permissions", + ) + if public_key and public_key_file: + raise typer.BadParameter( + "--public-key and --public-key-file are mutually exclusive; pick one.", + param_hint="--public-key", + ) + + resolved_public_key = public_key + if public_key_file is not None: + try: + resolved_public_key = public_key_file.read_text(encoding="utf-8").strip() + except OSError as exc: + raise typer.BadParameter( + f"Cannot read public-key file {public_key_file}: {exc}", + param_hint="--public-key-file", + ) from exc + + if cred_type == "ssh_key" and not resolved_public_key: + raise typer.BadParameter( + "--type ssh_key requires --public-key or --public-key-file.", + param_hint="--public-key", + ) + if cred_type == "http_token" and resolved_public_key: + raise typer.BadParameter( + "--type http_token must not be given a public key.", + param_hint="--public-key", + ) + + if ( + not yes + and not formatter.json_mode + and not typer.confirm( + f"Create a {cred_type} credential ({permissions}) for data app " + f"{app_id} in '{project}'?" + ) + ): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + + try: + result = service.create_data_app_git_credential( + alias=project, + app_id=app_id, + type_=cred_type, + permissions=permissions, + public_key=resolved_public_key, + name=name, + ) + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + def _human(c: Console, d: dict) -> None: + cred = d.get("credential", {}) + c.print(f"[bold green]Success:[/bold green] {d['message']}") + c.print(f" [bold]ID:[/bold] {cred.get('id', '')}") + c.print(f" [bold]Type:[/bold] {cred.get('type', '')}") + c.print(f" [bold]Permissions:[/bold] {cred.get('permissions', '')}") + if cred.get("name"): + c.print(f" [bold]Name:[/bold] {cred['name']}") + secret = cred.get("secret") + if secret: + c.print( + f"\n[bold yellow]One-time secret (won't be shown again):[/bold yellow] {secret}" + ) + + formatter.output(result, _human) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 7c99fccb..92d54e99 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -292,7 +292,7 @@ jq pattern: `.error.details.logTail? // .data.logTail? // []` picks up the tail regardless of exit. kbagent job terminate --project NAME (--job-id ID [--job-id ID ...] | --status any|created|waiting|processing [--component-id ID] [--config-id ID] [--branch ID] [--limit N]) [--dry-run] [--yes] - Kill running jobs via Queue API (POST /jobs/{id}/kill). Use to stop runaway loops or pile-ups. + Kill running jobs via Queue API (POST /jobs/{{id}}/kill). Use to stop runaway loops or pile-ups. Two modes: single/batch by --job-id, or bulk by --status. --status any covers all killable states (created+waiting+processing). Response partitions into killed / already_finished / not_found / failed. Idempotent: re-running on terminal jobs reports them as already_finished rather than failing. @@ -401,7 +401,7 @@ Delete one or more buckets. --force cascade-deletes tables. Linked/shared buckets protected. Branch-aware. kbagent storage swap-tables --project NAME --table-id ID --target-table-id ID --branch ID [--dry-run] [--yes] - Swap two storage tables in any branch, including the default/production branch (POST /tables/{id}/swap). Both tables exchange physical positions; + Swap two storage tables in any branch, including the default/production branch (POST /tables/{{id}}/swap). Both tables exchange physical positions; aliases are NOT transferred (they keep pointing at the same physical position and therefore expose the OTHER table's data after the swap). Use to promote a typed rebuild back into the original name without touching downstream config references. branch_id is mandatory (--branch or active branch via 'kbagent @@ -410,7 +410,7 @@ does not carry storage schema). kbagent storage clone-table --project NAME --table-id ID --branch ID [--dry-run] - Clone (pull) a production table into a dev branch (POST /tables/{id}/pull). On storage-branches projects a + Clone (pull) a production table into a dev branch (POST /tables/{{id}}/pull). On storage-branches projects a dev branch reads prod tables transparently until first write, so mutating a table's schema in the branch (swap-tables, dropping columns) first needs a branch-local copy. This materializes that copy (one-way: default -> branch). Branch is mandatory; service guards before any HTTP call when no branch is set. @@ -769,7 +769,7 @@ and CANNOT be rotated -- delete and recreate the app to mint a new one. kbagent data-app logs --project NAME --app-id ID [--lines N] [--since ISO8601] - Tail the container log buffer (Data Science /apps/{id}/logs/tail). + Tail the container log buffer (Data Science /apps/{{id}}/logs/tail). Plain-text body covering the full spin-up trace ([TIMING] git_clone, Cloning into /app, uv install, supervisord boot, runtime stack traces). Default --lines 500; pass --lines 0 to fetch the full current buffer @@ -830,6 +830,42 @@ pure-Python / R / Node-only follow-up. --strict treats WARNs as failures (exit 1). + kbagent data-app git-repo --project NAME --app-id ID + Show the clone URLs (ssh_url / https_url) of the app's configured git + repository plus is_managed_git_repo (sandboxes-service + GET /apps/{{id}}/git-repo). Read-only; project storage token only. + GOTCHA: returns 409 "no Git repository configured" until the app has + been DEPLOYED at least once -- the git block is synced from the Storage + config into the Data Science app record at deploy time, so a fresh + --no-deploy app has no git repo from the service's point of view. + + kbagent data-app git-branches --project NAME --app-id ID + List the remote branches of the app's git repository with commit + metadata (branch, sha, comment, author name+email, date). Raw + top-level array from the server. Same deploy-once precondition as + git-repo. + + kbagent data-app git-entrypoints --project NAME --app-id ID + List root-level .py entrypoint files of the app's git repository on the + configured branch. Extension is hardcoded to py server-side (non-Python + entrypoints are not listable). Same deploy-once precondition. + + kbagent data-app git-credentials --project NAME --app-id ID + List the credentials of the app's MANAGED git repository (id, type, + permissions, name, owner_admin_id, created_at). The secret is NEVER + returned here. Needs an admin storage token. External repos (the kind + `data-app create --git-repo` produces) have no managed credentials. + + kbagent data-app git-credentials-create --project NAME --app-id ID + --type ssh_key|http_token --permissions readOnly|readWrite + [--public-key KEY | --public-key-file PATH] [--name LABEL] [--yes] + Mint a git credential for the app's MANAGED git repository. ssh_key + requires a public key; http_token returns a ONE-TIME secret printed + once and never retrievable again (mirrors data-app password). Requires + an admin storage token. Apps created via `data-app create --git-repo` + are EXTERNAL (not managed) -> 409 "no managed Git repository". + Confirmation prompt unless --yes or --json. + ### Project Sync kbagent sync init --project ALIAS [--directory DIR] [--git-branching] [--adopt-existing] diff --git a/src/keboola_agent_cli/commands/data_app.py b/src/keboola_agent_cli/commands/data_app.py index ab4869b6..5b4088a3 100644 --- a/src/keboola_agent_cli/commands/data_app.py +++ b/src/keboola_agent_cli/commands/data_app.py @@ -21,6 +21,7 @@ from ..constants import DEFAULT_JOB_RUN_TIMEOUT from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ._data_app_git import register_git_commands from ._helpers import ( check_cli_permission, emit_project_warnings, @@ -35,6 +36,7 @@ _REF_PYTHON_JS = "https://help.keboola.com/data-apps/python-js/" _REF_STORAGE_ACCESS = "https://help.keboola.com/data-apps/storage-access/" + data_app_app = typer.Typer(help="Keboola data-app lifecycle (create, deploy, manage)") @@ -1269,3 +1271,9 @@ def data_app_validate_repo( raise typer.Exit(code=1) # Human mode: the verdict line above conveyed the failure. raise typer.Exit(code=1) + + +# Attach the data-app git-* commands. They live in _data_app_git.py to keep +# this module under the file-size budget (CONTRIBUTING.md "File-size budgets"); +# they still register as `kbagent data-app git-*` on the same sub-app. +register_git_commands(data_app_app) diff --git a/src/keboola_agent_cli/data_science_client.py b/src/keboola_agent_cli/data_science_client.py index f3086faf..31ca1329 100644 --- a/src/keboola_agent_cli/data_science_client.py +++ b/src/keboola_agent_cli/data_science_client.py @@ -228,3 +228,115 @@ def tail_app_logs( # the caller wants the server's default buffer-all behavior. response = self._do_request("GET", path, params=params or None) return response.text + + # ------------------------------------------------------------------ + # Git repository (sandboxes-service /apps/{id}/git-repo/*) + # + # These endpoints introspect and manage the git repository a data app + # is deployed from. Ground truth: keboola/sandboxes-service server + # source + docs/swagger.yaml. Two functional groups: + # + # * Repo introspection (git-repo, /branches, /entrypoints) -- works + # for ANY configured repo (managed or external); auth = the same + # X-StorageApi-Token, permission CanManageApp. + # * Credential management (/credentials GET + POST) -- ONLY for a + # *managed* git repo (app.managedGitRepoId set); a repo configured + # via `data-app create --git-repo ` is *external*, so these + # return 409. Auth needs an admin storage token + # (CanManageAppRepoCredentials). + # + # IMPORTANT response-shape gotchas (verified in both sources): + # * /branches returns a RAW top-level JSON array (NOT wrapped in + # {branches: [...]}). + # * /entrypoints returns a RAW top-level array. + # * /credentials (GET) IS wrapped: {"credentials": [...]}. + # * POST /credentials returns the created credential; the one-time + # ``secret`` is present ONLY for type=http_token and ONLY here. + # ------------------------------------------------------------------ + + def get_git_repo(self, app_id: str) -> dict[str, Any]: + """Return the clone URLs of the app's configured git repository. + + Shape: ``{"sshUrl": str|None, "httpsUrl": str|None, + "isManagedGitRepo": bool}``. For external repos only the URL + matching the configured protocol is populated (the other is + ``None``) and embedded credentials are stripped. ``409`` if the + app has no git repository configured. + """ + response = self._do_request("GET", f"/apps/{quote(str(app_id), safe='')}/git-repo") + body = response.json() + return body if isinstance(body, dict) else {} + + def list_git_branches(self, app_id: str) -> list[dict[str, Any]]: + """List the remote branches of the app's configured git repository. + + Returns the server's RAW top-level array of branch objects + ``[{"branch", "comment", "sha", "author": {"name", "email"}, + "date"}]`` (HEAD/origin/HEAD filtered, sorted by name). Works for + managed and external repos alike. + """ + response = self._do_request("GET", f"/apps/{quote(str(app_id), safe='')}/git-repo/branches") + body = response.json() + return body if isinstance(body, list) else [] + + def list_git_entrypoints(self, app_id: str) -> list[str]: + """List root-level ``.py`` entrypoint files of the app's repo. + + Returns the server's RAW top-level ``array`` of root + filenames on the configured branch (or the repo default). + Extension is hardcoded to ``py`` server-side, so non-Python + entrypoints are not listable here. + """ + response = self._do_request( + "GET", f"/apps/{quote(str(app_id), safe='')}/git-repo/entrypoints" + ) + body = response.json() + return [str(item) for item in body] if isinstance(body, list) else [] + + def list_git_credentials(self, app_id: str) -> dict[str, Any]: + """List the credentials of the app's MANAGED git repository. + + Shape: ``{"credentials": [{"id", "type", "name", "permissions", + "ownerAdminId", "createdAt"}]}``. The ``secret`` is NEVER returned + here. ``409`` if the app has no managed git repository; requires an + admin storage token. + """ + response = self._do_request( + "GET", f"/apps/{quote(str(app_id), safe='')}/git-repo/credentials" + ) + body = response.json() + return body if isinstance(body, dict) else {} + + def create_git_credential( + self, + app_id: str, + *, + type_: str, + permissions: str, + public_key: str | None = None, + name: str | None = None, + ) -> dict[str, Any]: + """Create a credential for the app's MANAGED git repository. + + ``type_`` is ``"ssh_key"`` or ``"http_token"``; ``permissions`` is + ``"readOnly"`` or ``"readWrite"``. ``public_key`` is required IFF + ``type_ == "ssh_key"`` and MUST be absent otherwise (the server + returns 400 on a wrong combination). + + Returns the created credential. The one-time ``secret`` field is + present ONLY when ``type_ == "http_token"`` and is never retrievable + again. ``409`` if the app has no managed git repository; requires an + admin storage token. + """ + payload: dict[str, Any] = {"type": type_, "permissions": permissions} + if public_key is not None: + payload["publicKey"] = public_key + if name is not None: + payload["name"] = name + response = self._do_request( + "POST", + f"/apps/{quote(str(app_id), safe='')}/git-repo/credentials", + content=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json"}, + ) + return response.json() diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index 0b73e533..55cb1dc7 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -165,6 +165,12 @@ "data-app.secrets-get": "read", "data-app.secrets-remove": "destructive", "data-app.validate-repo": "read", + # Data apps - git-repo introspection + managed-repo credentials + "data-app.git-repo": "read", + "data-app.git-branches": "read", + "data-app.git-entrypoints": "read", + "data-app.git-credentials": "read", + "data-app.git-credentials-create": "write", # Developer Portal — identity sub-app leaves (composed by the # identity_app callback as "dev-portal.identity.") "dev-portal.identity.add": "write", diff --git a/src/keboola_agent_cli/server/dependencies.py b/src/keboola_agent_cli/server/dependencies.py index 6edb0d52..d7b9bbe0 100644 --- a/src/keboola_agent_cli/server/dependencies.py +++ b/src/keboola_agent_cli/server/dependencies.py @@ -18,6 +18,7 @@ from ..services.branch_service import BranchService from ..services.component_service import ComponentService from ..services.config_service import ConfigService +from ..services.data_app_git_service import DataAppGitService from ..services.data_app_service import DataAppService from ..services.deep_lineage_service import DeepLineageService from ..services.dev_portal_service import DeveloperPortalService @@ -74,6 +75,7 @@ class ServiceRegistry: deep_lineage: DeepLineageService = field(init=False) sharing: SharingService = field(init=False) data_app: DataAppService = field(init=False) + data_app_git: DataAppGitService = field(init=False) dev_portal: DeveloperPortalService = field(init=False) semantic_layer: SemanticLayerService = field(init=False) repo_validate: RepoValidateService = field(init=False) @@ -105,6 +107,7 @@ def __post_init__(self) -> None: self.deep_lineage = DeepLineageService(config_store=cs) self.sharing = SharingService(config_store=cs) self.data_app = DataAppService(config_store=cs) + self.data_app_git = DataAppGitService(config_store=cs) self.dev_portal = DeveloperPortalService( config_store=cs, client_factory=lambda identity: DeveloperPortalClient(identity), diff --git a/src/keboola_agent_cli/server/routers/data_apps.py b/src/keboola_agent_cli/server/routers/data_apps.py index e6ee9e8b..eaac5670 100644 --- a/src/keboola_agent_cli/server/routers/data_apps.py +++ b/src/keboola_agent_cli/server/routers/data_apps.py @@ -80,6 +80,13 @@ class RepoValidate(BaseModel): strict: bool = False +class GitCredentialCreate(BaseModel): + type: str + permissions: str + public_key: str | None = None + name: str | None = None + + @router.get("", summary="List data apps across projects") def list_apps( project: list[str] | None = Query(None), @@ -312,3 +319,66 @@ def validate_repo( type_=body.type, strict=body.strict, ) + + +@router.get("/{project}/{app_id}/git-repo", summary="Get a data app's git repository") +def git_repo( + project: str, app_id: str, registry: ServiceRegistry = Depends(get_registry) +) -> dict[str, Any]: + """Show clone URLs of the app's configured git repo. Mirrors `kbagent data-app git-repo`. + + Returns 409 until the app has been deployed at least once (the git block is + synced from the Storage config into the Data Science app record at deploy + time). + """ + return registry.data_app_git.get_data_app_git_repo(alias=project, app_id=app_id) + + +@router.get("/{project}/{app_id}/git-repo/branches", summary="List a data app's git branches") +def git_branches( + project: str, app_id: str, registry: ServiceRegistry = Depends(get_registry) +) -> dict[str, Any]: + """List remote branches of the app's git repo. Mirrors `kbagent data-app git-branches`.""" + return registry.data_app_git.list_data_app_git_branches(alias=project, app_id=app_id) + + +@router.get("/{project}/{app_id}/git-repo/entrypoints", summary="List a data app's git entrypoints") +def git_entrypoints( + project: str, app_id: str, registry: ServiceRegistry = Depends(get_registry) +) -> dict[str, Any]: + """List root-level .py entrypoints. Mirrors `kbagent data-app git-entrypoints`.""" + return registry.data_app_git.list_data_app_git_entrypoints(alias=project, app_id=app_id) + + +@router.get("/{project}/{app_id}/git-repo/credentials", summary="List managed git credentials") +def git_credentials( + project: str, app_id: str, registry: ServiceRegistry = Depends(get_registry) +) -> dict[str, Any]: + """List credentials of the app's MANAGED git repo. Mirrors `kbagent data-app git-credentials`. + + The credential secret is never returned here; needs an admin storage token. + """ + return registry.data_app_git.list_data_app_git_credentials(alias=project, app_id=app_id) + + +@router.post("/{project}/{app_id}/git-repo/credentials", summary="Create a managed git credential") +def git_credentials_create( + project: str, + app_id: str, + body: GitCredentialCreate, + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """Mint a git credential for the app's MANAGED git repo. + + Mirrors `kbagent data-app git-credentials-create`. For ``type=http_token`` + the response carries a one-time ``secret``. Needs an admin storage token; + external repos return 409. + """ + return registry.data_app_git.create_data_app_git_credential( + alias=project, + app_id=app_id, + type_=body.type, + permissions=body.permissions, + public_key=body.public_key, + name=body.name, + ) diff --git a/src/keboola_agent_cli/services/data_app_git_service.py b/src/keboola_agent_cli/services/data_app_git_service.py new file mode 100644 index 00000000..d8555913 --- /dev/null +++ b/src/keboola_agent_cli/services/data_app_git_service.py @@ -0,0 +1,224 @@ +"""Git-repository introspection + managed-repo credential management for data apps. + +Split out of ``data_app_service.py`` to respect the file-size budget +(CONTRIBUTING.md "File-size budgets"): the lifecycle service was already over +its hard ceiling, so this distinct concern (the sandboxes-service +``/apps/{id}/git-repo/*`` surface) lives in its own service. + +See :class:`~keboola_agent_cli.data_science_client.DataScienceClient` for the +response-shape gotchas. Two functional groups: + +* Repo introspection (git-repo, branches, entrypoints) -- works for any + configured repo (managed or external); needs only the project storage token. + Returns 409 until the app has been deployed at least once (the git block is + synced from the Storage config into the Data Science app record at deploy + time). +* Credential management (credentials GET + POST) -- only for a *managed* git + repo (``app.managedGitRepoId`` set); apps created via + ``data-app create --git-repo `` are external, so these return 409. Needs + an admin storage token. +""" + +from __future__ import annotations + +from typing import Any + +from ..data_science_client import DataScienceClient +from ..errors import ErrorCode, KeboolaApiError +from .base import BaseService, ClientFactory + +DataScienceClientFactory = Any # Callable[[str, str], DataScienceClient] + + +def _default_ds_client_factory(stack_url: str, token: str) -> DataScienceClient: + return DataScienceClient(stack_url=stack_url, token=token) + + +class DataAppGitService(BaseService): + """Service for the data-app git-repo endpoints (sandboxes-service).""" + + def __init__( + self, + config_store: Any, + client_factory: ClientFactory | None = None, + ds_client_factory: DataScienceClientFactory | None = None, + ) -> None: + super().__init__(config_store=config_store, client_factory=client_factory) + self._ds_client_factory = ds_client_factory or _default_ds_client_factory + + def get_data_app_git_repo(self, alias: str, app_id: str) -> dict[str, Any]: + """Return the clone URLs of a data app's configured git repository.""" + projects = self.resolve_projects([alias]) + project = projects[alias] + ds_client = self._ds_client_factory(project.stack_url, project.token) + try: + repo = ds_client.get_git_repo(app_id) + finally: + ds_client.close() + return { + "project_alias": alias, + "app_id": str(app_id), + "ssh_url": repo.get("sshUrl"), + "https_url": repo.get("httpsUrl"), + "is_managed_git_repo": bool(repo.get("isManagedGitRepo", False)), + } + + def list_data_app_git_branches(self, alias: str, app_id: str) -> dict[str, Any]: + """List the remote branches of a data app's git repository.""" + projects = self.resolve_projects([alias]) + project = projects[alias] + ds_client = self._ds_client_factory(project.stack_url, project.token) + try: + branches = ds_client.list_git_branches(app_id) + finally: + ds_client.close() + normalized = [ + { + "branch": b.get("branch", ""), + "sha": b.get("sha", ""), + "comment": b.get("comment", ""), + "author": { + "name": (b.get("author") or {}).get("name", ""), + "email": (b.get("author") or {}).get("email", ""), + }, + "date": b.get("date", ""), + } + for b in branches + if isinstance(b, dict) + ] + return { + "project_alias": alias, + "app_id": str(app_id), + "branches": normalized, + "count": len(normalized), + } + + def list_data_app_git_entrypoints(self, alias: str, app_id: str) -> dict[str, Any]: + """List root-level ``.py`` entrypoint files of a data app's repo.""" + projects = self.resolve_projects([alias]) + project = projects[alias] + ds_client = self._ds_client_factory(project.stack_url, project.token) + try: + entrypoints = ds_client.list_git_entrypoints(app_id) + finally: + ds_client.close() + return { + "project_alias": alias, + "app_id": str(app_id), + "entrypoints": entrypoints, + "count": len(entrypoints), + } + + def list_data_app_git_credentials(self, alias: str, app_id: str) -> dict[str, Any]: + """List the credentials of a data app's MANAGED git repository. + + The ``secret`` is never returned by this endpoint. A 409 from the + server (no managed git repo) propagates as a ``KeboolaApiError`` the + command layer maps to a non-zero exit. + """ + projects = self.resolve_projects([alias]) + project = projects[alias] + ds_client = self._ds_client_factory(project.stack_url, project.token) + try: + payload = ds_client.list_git_credentials(app_id) + finally: + ds_client.close() + raw = payload.get("credentials", []) if isinstance(payload, dict) else [] + credentials = [self._normalize_git_credential(c) for c in raw if isinstance(c, dict)] + return { + "project_alias": alias, + "app_id": str(app_id), + "credentials": credentials, + "count": len(credentials), + } + + def create_data_app_git_credential( + self, + *, + alias: str, + app_id: str, + type_: str, + permissions: str, + public_key: str | None = None, + name: str | None = None, + ) -> dict[str, Any]: + """Create a credential for a data app's MANAGED git repository. + + Validates the ``type_`` / ``public_key`` combination locally (the same + mutex the command layer enforces with a clean exit-2) so the + ``kbagent serve`` route does not round-trip a bare 400. The one-time + ``secret`` is returned only for ``http_token``. A 409 (no managed git + repo) propagates as a ``KeboolaApiError``. + """ + if type_ not in ("ssh_key", "http_token"): + raise KeboolaApiError( + message=("git credential 'type' must be 'ssh_key' or 'http_token'."), + status_code=0, + error_code=ErrorCode.INVALID_ARGUMENT, + retryable=False, + ) + if permissions not in ("readOnly", "readWrite"): + raise KeboolaApiError( + message=("git credential 'permissions' must be 'readOnly' or 'readWrite'."), + status_code=0, + error_code=ErrorCode.INVALID_ARGUMENT, + retryable=False, + ) + if type_ == "ssh_key" and not public_key: + raise KeboolaApiError( + message="git credential type 'ssh_key' requires a public key.", + status_code=0, + error_code=ErrorCode.INVALID_ARGUMENT, + retryable=False, + ) + if type_ == "http_token" and public_key: + raise KeboolaApiError( + message=("git credential type 'http_token' must not carry a public key."), + status_code=0, + error_code=ErrorCode.INVALID_ARGUMENT, + retryable=False, + ) + projects = self.resolve_projects([alias]) + project = projects[alias] + ds_client = self._ds_client_factory(project.stack_url, project.token) + try: + created = ds_client.create_git_credential( + app_id, + type_=type_, + permissions=permissions, + public_key=public_key, + name=name, + ) + finally: + ds_client.close() + credential = self._normalize_git_credential(created) + secret = created.get("secret") if isinstance(created, dict) else None + if secret: + # Keep the one-time secret on the normalized credential so the + # command layer can surface it; it is never retrievable again. + credential["secret"] = secret + message = f"Created {type_} credential for data app {app_id} in '{alias}'." + ( + " The one-time secret is shown below and cannot be retrieved again." if secret else "" + ) + return { + "project_alias": alias, + "app_id": str(app_id), + "credential": credential, + "message": message, + } + + @staticmethod + def _normalize_git_credential(raw: dict[str, Any]) -> dict[str, Any]: + """Translate a sandboxes-service credential record to snake_case. + + Deliberately omits ``secret`` -- callers that need the one-time secret + read it from the raw create response, never from this map. + """ + return { + "id": raw.get("id", ""), + "type": raw.get("type", ""), + "name": raw.get("name", ""), + "permissions": raw.get("permissions", ""), + "owner_admin_id": raw.get("ownerAdminId", ""), + "created_at": raw.get("createdAt", ""), + } diff --git a/tests/test_data_app_git_repo.py b/tests/test_data_app_git_repo.py new file mode 100644 index 00000000..690fdc3b --- /dev/null +++ b/tests/test_data_app_git_repo.py @@ -0,0 +1,610 @@ +"""Tests for the ``data-app git-repo`` family (sandboxes-service +``/apps/{id}/git-repo/*``): service-layer orchestration + CLI dual output. + +Service tests mock the Data Science client factory and assert the +camelCase->snake_case translation, the raw-array shapes, the managed-repo +credential mutex validation, and that the one-time secret surfaces only for +``http_token`` (and never leaks into the credential *list*). CLI tests patch +the cli.py service factory and assert exit codes, JSON envelopes, the +``--type`` / ``--public-key`` usage validation, and the one-time-secret print. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.data_science_client import DataScienceClient +from keboola_agent_cli.errors import ErrorCode, KeboolaApiError +from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.services.config_service import ConfigService +from keboola_agent_cli.services.data_app_git_service import DataAppGitService +from keboola_agent_cli.services.job_service import JobService +from keboola_agent_cli.services.project_service import ProjectService + +TEST_TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Shared fixtures +# --------------------------------------------------------------------------- + + +def _make_store(tmp_path: Path) -> ConfigStore: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + project_name="prod", + project_id=5725, + ), + ) + return store + + +def _make_service(store: ConfigStore, ds_mock: MagicMock) -> DataAppGitService: + return DataAppGitService( + config_store=store, + client_factory=lambda url, token: MagicMock(), + ds_client_factory=lambda url, token: ds_mock, + ) + + +def _invoke(args: list[str], *, store: ConfigStore, data_app_mock: MagicMock): + """Run the CLI with cli.py services patched to mocks.""" + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.ProjectService") as MockProj, + patch("keboola_agent_cli.cli.ConfigService") as MockCfg, + patch("keboola_agent_cli.cli.JobService") as MockJob, + patch("keboola_agent_cli.cli.DataAppGitService") as MockDataAppGitService, + ): + MockStore.return_value = store + MockProj.return_value = ProjectService(config_store=store) + MockCfg.return_value = ConfigService(config_store=store) + MockJob.return_value = JobService(config_store=store) + MockDataAppGitService.return_value = data_app_mock + return runner.invoke(app, args) + + +# --------------------------------------------------------------------------- +# Service layer +# --------------------------------------------------------------------------- + + +class TestGitRepoService: + def test_get_git_repo_translates_fields(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + ds = MagicMock() + ds.get_git_repo.return_value = { + "sshUrl": None, + "httpsUrl": "https://github.com/o/r", + "isManagedGitRepo": False, + } + result = _make_service(store, ds).get_data_app_git_repo("prod", "42") + assert result["ssh_url"] is None + assert result["https_url"] == "https://github.com/o/r" + assert result["is_managed_git_repo"] is False + assert result["app_id"] == "42" + ds.close.assert_called_once() + + def test_list_branches_normalizes_items(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + ds = MagicMock() + ds.list_git_branches.return_value = [ + { + "branch": "master", + "sha": "8bd2197", + "comment": "Make spirals more awesome", + "author": {"name": "Thiago", "email": "t@example.com"}, + "date": "2023-11-02T12:32:17-07:00", + } + ] + result = _make_service(store, ds).list_data_app_git_branches("prod", "42") + assert result["count"] == 1 + branch = result["branches"][0] + assert branch["branch"] == "master" + assert branch["author"]["name"] == "Thiago" + assert branch["author"]["email"] == "t@example.com" + + def test_list_entrypoints_passes_through(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + ds = MagicMock() + ds.list_git_entrypoints.return_value = ["streamlit_app.py", "app.py"] + result = _make_service(store, ds).list_data_app_git_entrypoints("prod", "42") + assert result["entrypoints"] == ["streamlit_app.py", "app.py"] + assert result["count"] == 2 + + def test_list_credentials_never_leaks_secret(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + ds = MagicMock() + ds.list_git_credentials.return_value = { + "credentials": [ + { + "id": "uuid-1", + "type": "http_token", + "name": "ci", + "permissions": "readOnly", + "ownerAdminId": "7", + "createdAt": "2026-06-13T00:00:00+00:00", + # A hostile/forward-compatible server adding 'secret' here + # must NOT propagate through the normalizer. + "secret": "ghs_should_never_appear", + } + ] + } + result = _make_service(store, ds).list_data_app_git_credentials("prod", "42") + assert result["count"] == 1 + cred = result["credentials"][0] + assert cred["owner_admin_id"] == "7" + assert cred["created_at"] == "2026-06-13T00:00:00+00:00" + assert "secret" not in cred + + def test_create_http_token_returns_one_time_secret(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + ds = MagicMock() + ds.create_git_credential.return_value = { + "id": "uuid-2", + "type": "http_token", + "name": "ci", + "permissions": "readOnly", + "ownerAdminId": "7", + "createdAt": "2026-06-13T00:00:00+00:00", + "secret": "ghs_one_time", + } + result = _make_service(store, ds).create_data_app_git_credential( + alias="prod", + app_id="42", + type_="http_token", + permissions="readOnly", + name="ci", + ) + assert result["credential"]["secret"] == "ghs_one_time" + # The client must be called without a publicKey for http_token. + _, kwargs = ds.create_git_credential.call_args + assert kwargs["public_key"] is None + + def test_create_ssh_key_has_no_secret_and_forwards_key(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + ds = MagicMock() + ds.create_git_credential.return_value = { + "id": "uuid-3", + "type": "ssh_key", + "name": "deploy", + "permissions": "readWrite", + "ownerAdminId": "7", + "createdAt": "2026-06-13T00:00:00+00:00", + } + result = _make_service(store, ds).create_data_app_git_credential( + alias="prod", + app_id="42", + type_="ssh_key", + permissions="readWrite", + public_key="ssh-ed25519 AAAA...", + ) + assert "secret" not in result["credential"] + _, kwargs = ds.create_git_credential.call_args + assert kwargs["public_key"] == "ssh-ed25519 AAAA..." + + def test_create_ssh_key_without_public_key_rejected(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + ds = MagicMock() + service = _make_service(store, ds) + try: + service.create_data_app_git_credential( + alias="prod", app_id="42", type_="ssh_key", permissions="readOnly" + ) + raise AssertionError("expected KeboolaApiError") + except KeboolaApiError as exc: + assert exc.error_code == ErrorCode.INVALID_ARGUMENT + ds.create_git_credential.assert_not_called() + + def test_create_http_token_with_public_key_rejected(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + ds = MagicMock() + service = _make_service(store, ds) + try: + service.create_data_app_git_credential( + alias="prod", + app_id="42", + type_="http_token", + permissions="readOnly", + public_key="ssh-ed25519 AAAA...", + ) + raise AssertionError("expected KeboolaApiError") + except KeboolaApiError as exc: + assert exc.error_code == ErrorCode.INVALID_ARGUMENT + ds.create_git_credential.assert_not_called() + + def test_create_invalid_type_rejected(self, tmp_path: Path) -> None: + store = _make_store(tmp_path) + ds = MagicMock() + service = _make_service(store, ds) + try: + service.create_data_app_git_credential( + alias="prod", app_id="42", type_="pgp", permissions="readOnly" + ) + raise AssertionError("expected KeboolaApiError") + except KeboolaApiError as exc: + assert exc.error_code == ErrorCode.INVALID_ARGUMENT + ds.create_git_credential.assert_not_called() + + +# --------------------------------------------------------------------------- +# CLI layer +# --------------------------------------------------------------------------- + + +def _store(tmp_path: Path) -> ConfigStore: + config_dir = tmp_path / "config" + config_dir.mkdir() + store = ConfigStore(config_dir=config_dir) + store.add_project( + "prod", + ProjectConfig( + stack_url="https://connection.keboola.com", + token=TEST_TOKEN, + project_name="prod", + project_id=1234, + ), + ) + return store + + +class TestGitRepoCli: + def test_git_repo_json(self, tmp_path: Path) -> None: + store = _store(tmp_path) + mock = MagicMock() + mock.get_data_app_git_repo.return_value = { + "project_alias": "prod", + "app_id": "42", + "ssh_url": None, + "https_url": "https://github.com/o/r", + "is_managed_git_repo": False, + } + result = _invoke( + ["--json", "data-app", "git-repo", "--project", "prod", "--app-id", "42"], + store=store, + data_app_mock=mock, + ) + assert result.exit_code == 0, result.output + body = json.loads(result.output) + assert body["data"]["https_url"] == "https://github.com/o/r" + assert body["data"]["is_managed_git_repo"] is False + + def test_git_repo_api_error_exit_1(self, tmp_path: Path) -> None: + store = _store(tmp_path) + mock = MagicMock() + mock.get_data_app_git_repo.side_effect = KeboolaApiError( + message='App "42" has no Git repository configured', + status_code=409, + error_code=ErrorCode.API_ERROR, + retryable=False, + ) + result = _invoke( + ["--json", "data-app", "git-repo", "--project", "prod", "--app-id", "42"], + store=store, + data_app_mock=mock, + ) + assert result.exit_code == 1, result.output + body = json.loads(result.output) + assert body["status"] == "error" + + def test_git_branches_human(self, tmp_path: Path) -> None: + store = _store(tmp_path) + mock = MagicMock() + mock.list_data_app_git_branches.return_value = { + "project_alias": "prod", + "app_id": "42", + "branches": [ + { + "branch": "master", + "sha": "8bd2197", + "comment": "Make spirals more awesome", + "author": {"name": "Thiago", "email": "t@example.com"}, + "date": "2023-11-02T12:32:17-07:00", + } + ], + "count": 1, + } + result = _invoke( + ["data-app", "git-branches", "--project", "prod", "--app-id", "42"], + store=store, + data_app_mock=mock, + ) + assert result.exit_code == 0, result.output + assert "master" in result.output + assert "8bd2197" in result.output + + def test_git_entrypoints_json(self, tmp_path: Path) -> None: + store = _store(tmp_path) + mock = MagicMock() + mock.list_data_app_git_entrypoints.return_value = { + "project_alias": "prod", + "app_id": "42", + "entrypoints": ["streamlit_app.py"], + "count": 1, + } + result = _invoke( + ["--json", "data-app", "git-entrypoints", "--project", "prod", "--app-id", "42"], + store=store, + data_app_mock=mock, + ) + assert result.exit_code == 0, result.output + body = json.loads(result.output) + assert body["data"]["entrypoints"] == ["streamlit_app.py"] + + def test_git_credentials_empty(self, tmp_path: Path) -> None: + store = _store(tmp_path) + mock = MagicMock() + mock.list_data_app_git_credentials.return_value = { + "project_alias": "prod", + "app_id": "42", + "credentials": [], + "count": 0, + } + result = _invoke( + ["data-app", "git-credentials", "--project", "prod", "--app-id", "42"], + store=store, + data_app_mock=mock, + ) + assert result.exit_code == 0, result.output + assert "No git credentials" in result.output + + def test_credentials_create_ssh_key_without_public_key_exit_2(self, tmp_path: Path) -> None: + store = _store(tmp_path) + mock = MagicMock() + result = _invoke( + [ + "--json", + "data-app", + "git-credentials-create", + "--project", + "prod", + "--app-id", + "42", + "--type", + "ssh_key", + "--permissions", + "readOnly", + "--yes", + ], + store=store, + data_app_mock=mock, + ) + assert result.exit_code == 2, result.output + mock.create_data_app_git_credential.assert_not_called() + + def test_credentials_create_http_token_with_public_key_exit_2(self, tmp_path: Path) -> None: + store = _store(tmp_path) + mock = MagicMock() + result = _invoke( + [ + "--json", + "data-app", + "git-credentials-create", + "--project", + "prod", + "--app-id", + "42", + "--type", + "http_token", + "--permissions", + "readOnly", + "--public-key", + "ssh-ed25519 AAAA...", + "--yes", + ], + store=store, + data_app_mock=mock, + ) + assert result.exit_code == 2, result.output + mock.create_data_app_git_credential.assert_not_called() + + def test_credentials_create_http_token_prints_one_time_secret(self, tmp_path: Path) -> None: + store = _store(tmp_path) + mock = MagicMock() + mock.create_data_app_git_credential.return_value = { + "project_alias": "prod", + "app_id": "42", + "credential": { + "id": "uuid", + "type": "http_token", + "name": "ci", + "permissions": "readOnly", + "owner_admin_id": "7", + "created_at": "2026-06-13T00:00:00+00:00", + "secret": "ghs_one_time", + }, + "message": "Created http_token credential.", + } + result = _invoke( + [ + "data-app", + "git-credentials-create", + "--project", + "prod", + "--app-id", + "42", + "--type", + "http_token", + "--permissions", + "readOnly", + "--yes", + ], + store=store, + data_app_mock=mock, + ) + assert result.exit_code == 0, result.output + assert "One-time secret" in result.output + assert "ghs_one_time" in result.output + _, kwargs = mock.create_data_app_git_credential.call_args + assert kwargs["type_"] == "http_token" + assert kwargs["public_key"] is None + + def test_credentials_create_ssh_key_from_file(self, tmp_path: Path) -> None: + store = _store(tmp_path) + key_file = tmp_path / "deploy.pub" + key_file.write_text("ssh-ed25519 AAAAfromfile\n", encoding="utf-8") + mock = MagicMock() + mock.create_data_app_git_credential.return_value = { + "project_alias": "prod", + "app_id": "42", + "credential": { + "id": "uuid", + "type": "ssh_key", + "name": "", + "permissions": "readWrite", + "owner_admin_id": "7", + "created_at": "2026-06-13T00:00:00+00:00", + }, + "message": "Created ssh_key credential.", + } + result = _invoke( + [ + "--json", + "data-app", + "git-credentials-create", + "--project", + "prod", + "--app-id", + "42", + "--type", + "ssh_key", + "--permissions", + "readWrite", + "--public-key-file", + str(key_file), + "--yes", + ], + store=store, + data_app_mock=mock, + ) + assert result.exit_code == 0, result.output + _, kwargs = mock.create_data_app_git_credential.call_args + assert kwargs["public_key"] == "ssh-ed25519 AAAAfromfile" + + def test_credentials_create_invalid_type_exit_2(self, tmp_path: Path) -> None: + store = _store(tmp_path) + mock = MagicMock() + result = _invoke( + [ + "--json", + "data-app", + "git-credentials-create", + "--project", + "prod", + "--app-id", + "42", + "--type", + "pgp", + "--permissions", + "readOnly", + "--yes", + ], + store=store, + data_app_mock=mock, + ) + assert result.exit_code == 2, result.output + mock.create_data_app_git_credential.assert_not_called() + + +# --------------------------------------------------------------------------- +# Client HTTP layer (via httpx_mock): URL composition + raw-shape passthrough +# --------------------------------------------------------------------------- + + +class TestGitRepoClient: + DATA_SCIENCE_BASE = "https://data-science.keboola.com" + + def _client(self) -> DataScienceClient: + return DataScienceClient( + stack_url="https://connection.keboola.com", + token="901-test-token", + ) + + def test_get_git_repo_url_and_shape(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{self.DATA_SCIENCE_BASE}/apps/42/git-repo", + json={"sshUrl": None, "httpsUrl": "https://github.com/o/r", "isManagedGitRepo": True}, + status_code=200, + ) + with self._client() as client: + repo = client.get_git_repo("42") + assert repo["isManagedGitRepo"] is True + + def test_list_branches_returns_raw_array(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{self.DATA_SCIENCE_BASE}/apps/42/git-repo/branches", + json=[{"branch": "master", "sha": "abc", "author": {"name": "x", "email": "y"}}], + status_code=200, + ) + with self._client() as client: + branches = client.list_git_branches("42") + assert isinstance(branches, list) + assert branches[0]["branch"] == "master" + + def test_list_entrypoints_returns_raw_string_array(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{self.DATA_SCIENCE_BASE}/apps/42/git-repo/entrypoints", + json=["streamlit_app.py", "app.py"], + status_code=200, + ) + with self._client() as client: + entrypoints = client.list_git_entrypoints("42") + assert entrypoints == ["streamlit_app.py", "app.py"] + + def test_list_credentials_wrapped(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{self.DATA_SCIENCE_BASE}/apps/42/git-repo/credentials", + json={"credentials": [{"id": "u1", "type": "ssh_key"}]}, + status_code=200, + ) + with self._client() as client: + payload = client.list_git_credentials("42") + assert payload["credentials"][0]["id"] == "u1" + + def test_create_ssh_key_sends_public_key(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{self.DATA_SCIENCE_BASE}/apps/42/git-repo/credentials", + method="POST", + json={"id": "u2", "type": "ssh_key", "permissions": "readOnly"}, + status_code=201, + ) + with self._client() as client: + client.create_git_credential( + "42", type_="ssh_key", permissions="readOnly", public_key="ssh-ed25519 AAAA" + ) + sent = json.loads(httpx_mock.get_requests()[0].content) + assert sent == { + "type": "ssh_key", + "permissions": "readOnly", + "publicKey": "ssh-ed25519 AAAA", + } + + def test_create_http_token_omits_public_key(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{self.DATA_SCIENCE_BASE}/apps/42/git-repo/credentials", + method="POST", + json={"id": "u3", "type": "http_token", "secret": "ghs_x"}, + status_code=201, + ) + with self._client() as client: + created = client.create_git_credential( + "42", type_="http_token", permissions="readWrite", name="ci" + ) + sent = json.loads(httpx_mock.get_requests()[0].content) + assert "publicKey" not in sent + assert sent == {"type": "http_token", "permissions": "readWrite", "name": "ci"} + assert created["secret"] == "ghs_x" diff --git a/tests/test_e2e.py b/tests/test_e2e.py index c7c8fa8d..bde002df 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -7763,6 +7763,106 @@ def test_data_app_lifecycle_public(self) -> None: "Storage config version should be populated after PUT" ) + @skip_without_data_app_public + def test_data_app_git_repo_introspection(self) -> None: + """git-repo / git-branches / git-entrypoints against a deployed public app. + + The three introspection endpoints (sandboxes-service + ``/apps/{id}/git-repo/*``) return 409 "no Git repository configured" + until the app has been DEPLOYED at least once -- the git block is + synced from the Storage config into the Data Science app record at + deploy time. We fire a deploy (no ``--wait``, so we don't block on a + container build) and poll ``git-repo`` until the sync lands. + """ + _step(1, "Create a public-repo data app (no deploy yet)") + repo = os.environ[ENV_DATA_APP_GIT_REPO_PUBLIC] + slug = f"e2e-git-{RUN_ID}"[:60] + create = _json_ok( + _invoke( + self.config_dir, + [ + "--json", + "data-app", + "create", + "--project", + self.alias, + "--name", + f"E2E Git {RUN_ID}", + "--slug", + slug, + "--git-repo", + repo, + "--git-public", + "--auth", + "public", + "--no-deploy", + ], + ) + ) + app_id = create["data"]["app_id"] + self._created_app_ids.append(app_id) + + _step(2, "Fire deploy (no wait) so the git block syncs into the DS record") + _invoke( + self.config_dir, + ["--json", "data-app", "deploy", "--project", self.alias, "--app-id", app_id], + ) + + _step(3, "Poll git-repo until the deploy-time git sync completes") + repo_data = None + for _ in range(20): + res = _invoke( + self.config_dir, + ["--json", "data-app", "git-repo", "--project", self.alias, "--app-id", app_id], + ) + if res.exit_code == 0: + repo_data = json.loads(res.output)["data"] + break + time.sleep(3) + if repo_data is None: + pytest.skip("git-repo did not become available within the poll budget") + assert repo_data["https_url"] or repo_data["ssh_url"], "expected a clone URL" + assert "is_managed_git_repo" in repo_data + + _step(4, "git-branches returns commit metadata") + branches = _json_ok( + _invoke( + self.config_dir, + ["--json", "data-app", "git-branches", "--project", self.alias, "--app-id", app_id], + ) + )["data"] + assert branches["count"] >= 1 + assert branches["branches"][0]["branch"] + assert "author" in branches["branches"][0] + + _step(5, "git-entrypoints returns a (possibly empty) list of root .py files") + entry = _json_ok( + _invoke( + self.config_dir, + [ + "--json", + "data-app", + "git-entrypoints", + "--project", + self.alias, + "--app-id", + app_id, + ], + ) + )["data"] + assert isinstance(entry["entrypoints"], list) + + _step(6, "git-credentials on an external repo lists no managed credentials") + cred_res = _invoke( + self.config_dir, + ["--json", "data-app", "git-credentials", "--project", self.alias, "--app-id", app_id], + ) + # External repos (the kind `data-app create --git-repo` produces) have + # no managed credential store: the list endpoint returns an empty list + # (200). A 409 here would also satisfy the managed-only contract. + if cred_res.exit_code == 0: + assert json.loads(cred_res.output)["data"]["credentials"] == [] + @skip_without_data_app_private def test_data_app_lifecycle_private_and_redeploy(self) -> None: _step(1, "Create private-repo simpleAuth data app", "encryption + git PAT") diff --git a/uv.lock b/uv.lock index 8ad926ed..c5d5e300 100644 --- a/uv.lock +++ b/uv.lock @@ -580,7 +580,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.63.2" +version = "0.63.3" source = { editable = "." } dependencies = [ { name = "croniter" },