diff --git a/CLAUDE.md b/CLAUDE.md index a1ffd3cf6..709334cca 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -70,6 +70,8 @@ src/keboola_agent_cli/ models.py # Pydantic models shared across layers effective_branch.py # resolve_branch(): the ONLY code that applies the `branch use` active branch; # records project + branch for the `Target:` line / `targets` key (#766) + project_ref.py # resolve_project_ref(): a project ID given where an alias is expected -> the + # alias (CLI-22); applied by commands/_project_ref.py and serve's dependency output.py # OutputFormatter: JSON vs Rich dual-mode output errors.py # KeboolaApiError, ConfigError, ErrorCode enum, mask_token() config_store.py # JSON persistence for config.json (0600 permissions) @@ -303,6 +305,20 @@ Full author checklist: see `CONTRIBUTING.md` > "Releasing a beta (pre-release) v `tests/test_effective_branch.py` fails on a new direct read. A read that only shows or manages the active branch must be on its list, with a reason. +20. **Services receive project aliases, never project IDs.** `--project` + (and `project use`, `KBAGENT_PROJECT`, serve's `{project}` / `?project=`) + also takes a project ID; the root command group + (`commands/_project_ref.py`) translates it to the alias before the command + runs. A new command gets this without extra code. A new command whose + `--project` is not a registry lookup (a NEW alias like `project add`, an + offline filter like `lineage show`) must be added to `NO_LOOKUP_COMMANDS` + there, and an option with another name that takes an existing alias (like + `config clone --target-project`) to `ALIAS_OPTIONS`. + `tests/test_project_ref.py` fails on a new option whose flag contains + `project`, `alias` or `stack` until it is in `ALIAS_OPTIONS` or in the + test's `NOT_AN_ALIAS` list; an option with any other name (like + `sync clone --target`) needs that decision by hand. + ## Claude Code Plugin The plugin lives here in `plugins/kbagent/` and is **published through `keboola/ai-kit`**. It exposes: a CLI (`kbagent`), three skills (`kbagent`, `kbagent-cicd-migration`, `kbagent-promotion-pipeline`), three slash commands (`/kbagent:setup`, `/keboola`, `/kbagent:review`), and two specialist subagents (`keboola-expert`, `kbagent-pr-reviewer`). All are namespaced under `kbagent:`. `/kbagent:setup` is the documented one-command first-run path (install CLI -> connect project -> `doctor`); it runs in the main context and spawns no subagent. @@ -367,6 +383,13 @@ plugins/kbagent/ # settings.json -> env). Neither set = header omitted, as before. Version gate for this entry # lives in gotchas.md -- a `(since vNEXT)` tag cannot be written on these `# ` comment lines, # because check_version_gates.py parses them as ATX markdown headings (where a `vNEXT` is fatal). +# --project (CLI-22) takes a registered alias OR a project ID. An alias wins; an ID registered under +# several aliases is CONFIG_ERROR (exit 5) listing them, unless all are on one stack and exactly one is a +# session alias. Same for KBAGENT_PROJECT, `project use`, `config clone --target-project`, +# `sync clone --target`, the `semantic-layer promote/diff` project options, `auth * --stack`, and +# serve's {project} / ?project= / ?stack= (path and query only, not request bodies). Not for +# `project add` / `project create` (new alias) or `lineage show` (offline filter). Version gate in +# gotchas.md. # Headless / token-only (0.50.0+): export KBAGENT_PROJECT_FROM_ENV=1 + KBC_TOKEN + KBC_STORAGE_API_URL to synthesize an in-memory `__env__` project (no `project add`, no config.json on disk; token never persisted). Use `--project __env__`. Same env setup also powers `kbagent serve`. kbagent auth login [--stack URL|alias] [--device-code] [--register-projects] @@ -418,7 +441,7 @@ kbagent auth register-projects [--stack URL|alias] [--all] [--project-id ID ...] # AUTH_BROWSER_UNAVAILABLE, AUTH_STATE_MISMATCH, SESSION_EXPIRED, SESSION_NOT_FOUND. # `auth register-projects` (0.80.0+): fixes the usability gap where nothing was registered unless # --register-projects was passed at login, and where the alias offered was a slug of the project -# NAME (never the numeric id, so `--project 9840` never resolves). Lists every project the session +# NAME (never the numeric id; the id itself resolves as `--project` once registered, CLI-22). Lists every project the session # can access with a collision-free suggested alias, then lets the caller pick which to register. # --all selects every candidate; --project-id ID (repeatable) selects specific ones (unknown id -> # ConfigError); omitting both runs an interactive arrow-key + spacebar checkbox picker (every diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10ecf7e05..c5967d13e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -354,6 +354,7 @@ When adding a new command (e.g., `kbagent storage create-foo`), you must update - [ ] **Command function** in `commands/` -- Typer options, formatter, error handling - [ ] **Permission registration** in `permissions.py` (`OPERATION_REGISTRY` dict) - [ ] **Branch choice** through `resolve_branch()` in `effective_branch.py` when the command takes `--branch` or uses the active branch -- never read `ProjectConfig.active_branch_id` directly. The function reports the branch (`Target:` line, `targets` in `--json`); `tests/test_effective_branch.py` fails on a new direct read. +- [ ] **`--project` naming a NEW alias** -- every `--project` option also takes a project ID, translated to the alias before the command runs (`commands/_project_ref.py`, CLI-22). A command whose `--project` is not a registry lookup (a new alias like `project add`, an offline filter like `lineage show`) must be added to `NO_LOOKUP_COMMANDS` there, and an option with another name that takes an existing alias (like `config clone --target-project`) to `ALIAS_OPTIONS`. `tests/test_project_ref.py` fails on a new option whose flag contains `project`, `alias` or `stack` until it is in `ALIAS_OPTIONS` or in the test's `NOT_AN_ALIAS` list (with a reason); an option with any other name needs this decision by hand. - [ ] **Service wiring** in `cli.py` if adding a new service class - [ ] **HTTP API endpoint** in `src/keboola_agent_cli/server/routers/.py` -- `kbagent serve` exposes the CLI as a REST API so external applications (Web UI, scheduled AI agents, Slack bots, Streamlit dashboards, CI pipelines) can call the platform without forking CLI subprocesses. The current convention is **1:1**: every command in a group has a matching endpoint in that group's router (e.g. `commands/flow.py` has 8 commands, `server/routers/flows.py` has 8 routes). If you add a new command, add the corresponding route. **Skip allowed** only for genuinely terminal-only commands (interactive prompts, Rich-rendered output that has no useful JSON shape, `doctor`/`init`/`update`-style infrastructure that manages kbagent itself rather than Keboola). Document any skip in the PR description with a one-line reason so reviewers don't flag it. diff --git a/plugins/kbagent/agents/keboola-expert.md b/plugins/kbagent/agents/keboola-expert.md index 2e08d1e6c..b9d0fc610 100644 --- a/plugins/kbagent/agents/keboola-expert.md +++ b/plugins/kbagent/agents/keboola-expert.md @@ -200,6 +200,20 @@ its absence is NOT a promise the entry is version-independent (see §1 Rule 6). or run `project use`. On <= 0.90.1 the same commands silently used the FIRST registered project and ignored the pin (issue #684). gotchas.md. +**`--project` given a numeric project ID (vNEXT+)** +- `--project`, `KBAGENT_PROJECT`, `project use`, the other alias options + (`config clone --target-project`, `sync clone --target`, `semantic-layer + promote` / `diff` project options, `auth * --stack`) and serve `{project}` + / `?project=` (path and query only, never a request body) take a + registered project's ID and use its alias. A registered + alias wins over an ID. An ID registered under several aliases is exit 5 + (`CONFIG_ERROR`) listing them -- pick one; only a lone session alias on one + stack wins by itself. Output names the alias. A digits-only alias that is + also another project's ID wins, with a stderr warning naming that project + (also under `--json`). Below vNEXT an ID is "not found". `project add` / + `project create` treat the value as a NEW alias; `lineage show --project` + is an offline filter, not translated. gotchas.md (CLI-22). + **Which branch did a command use? (vNEXT+)** - Every command that picks a branch names it: `Target: project 'P', branch ID (from 'kbagent branch use')` on stderr, `targets` in `--json` @@ -417,10 +431,10 @@ its absence is NOT a promise the entry is version-independent (see §1 Rule 6). Confirming **revokes the session it gave you** -- that is the design, not a failure: follow it with `auth login --stack URL`, and note the alias survives (the sentinel keys on project id + stack, never the session). -- **Aliases derive from the project NAME, never the numeric id** -- - `--project 9840` never resolves. Use `kbagent project list` or - `auth register-projects` to find/register the real alias; it never overwrites - an existing registration. +- **Aliases derive from the project NAME, never the numeric id.** + `--project 9840` resolves only once project 9840 is registered (vNEXT+; + never below). Use `kbagent project list` or `auth register-projects` to + find/register the real alias; it never overwrites an existing registration. - **Session auth covers almost every command** -- only three features still need a static token: `kbagent kai`, `semantic-layer token --encrypt`, and the importable SDK (`keboola_agent_cli.Client`). **Do NOT reconstruct that list diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 594af3090..86934a46b 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -2,6 +2,8 @@ All commands support `--json` for structured output. Multi-project flags (`--project`) can be repeated. +`--project` takes a registered alias or a registered project's numeric ID *(since vNEXT)*; an alias wins over an ID, and an ID registered under several aliases is `CONFIG_ERROR` (exit 5) listing them, unless all are on one stack and exactly one is a session alias. `KBAGENT_PROJECT`, `project use`, `config clone --target-project`, `sync clone --target`, `semantic-layer promote --from-project/--to-project`, `semantic-layer diff --project-a/--project-b`, `auth * --stack`, the `project invite --from-csv` `project` column and the `kbagent serve` `{project}` / `?project=` / `?stack=` parameters take an ID the same way (serve: path and query parameters only, not request bodies); `project add` / `project create` do not (their `--project` is the new alias), nor does `lineage show --project` (an offline graph filter). A digits-only alias that is also another project's ID wins, with a warning on stderr. See `gotchas.md`. + ## Setup & Info - `init [--from-global] [--project ALIAS ...]` -- create local `.kbagent/` workspace in current directory; `--project ALIAS` (repeatable) copies only the named project(s) from the global config and implies `--from-global` - `doctor` -- health check for CLI config (no `--fix` since v0.85.0 -- it only installed the MCP server) @@ -32,7 +34,7 @@ headlessly.** Both issue a USER-scoped "programmatic session" - `auth login-password --email EMAIL (--password PASSWORD | --password-stdin) [--totp-secret SECRET] [--stack URL|alias] [--register-projects]` -- sign in via a password grant, no browser. Prefer `--password-stdin` (or `KBC_LOGIN_PASSWORD`) over `--password` -- a value on the command line lands in shell history and process listings; `--password`/`--password-stdin` are mutually exclusive (`ConfigError` if both are given). `--email`/`--password`/`--totp-secret` also read from `KBC_LOGIN_EMAIL`/`KBC_LOGIN_PASSWORD`/`KBC_LOGIN_TOTP_SECRET` env vars (same convention as `KBC_TOKEN`), so a CI workflow sets them once in a step's `env:` block. `--totp-secret` is the account's base32 TOTP seed (from its authenticator enrollment), NOT a 6-digit code -- kbagent computes the current code itself (`auth/totp.py`, stdlib RFC 6238), so nothing here needs a human typing a live code. Only resolves TOTP-based MFA; a WebAuthn/passkey-only account gets `AUTH_MFA_INVALID` and must use `auth login` instead (that ceremony needs a real browser). The resulting session is stored and used identically to a browser-login session -- same `auth.json`, same `project list` "session" auth-mode, same `--register-projects` contract. Storing an account's password (and TOTP seed) as CI secrets is a bigger blast radius than one scoped project token; use a dedicated, least-privileged service account. - `auth status [--stack URL|alias]` -- show session state (`live`/`refreshed`/`degraded`/`expired`/`missing`), signed-in user, accessible projects, and token expiry. Proactively refreshes the access token if stale before reporting (a healthy session routinely shows an expired 1h access token next to a valid 30-day refresh token) -- `refreshed` means a rotation just happened, `live` means the cached token was still fresh. - `auth logout [--stack URL|alias] [--remove-projects] [--yes]` -- revoke the refresh token server-side and delete the local session from `auth.json`. `--remove-projects` also removes `config.json` aliases pointing at this session (sentinel-token projects only; a static-token project on the same stack is never touched). -- `auth register-projects [--stack URL|alias] [--all] [--project-id ID ...] [--alias ID=ALIAS ...] [--yes]` -- register an EXISTING session's accessible projects as `config.json` aliases, without re-running `login`. Fixes two usability gaps in plain `login`: nothing was registered unless `--register-projects` was passed, and the suggested alias was always slugified from the project NAME, so a project id like `9840` from the login table never resolved as `--project 9840`. `--all` selects every accessible project; `--project-id ID` (repeatable) selects specific ones (an inaccessible id raises a `ConfigError`); passing neither starts an interactive arrow-key + spacebar checkbox picker -- every not-yet-registered project preselected, up/down or `j`/`k` move, `space` toggles, `a` selects/deselects all, `enter` accepts, `q`/`esc`/`ctrl-c` cancels -- followed by a single `Edit aliases?` confirm (default no) that opens the old per-project alias prompt only if you opt in (each row already shows its suggested alias), then a final `typer.confirm`. On a piped stdin or a terminal without real interactive capabilities, the picker falls back to the original typed prompt (numbers / ranges `1-3` / `all` / `none`). In a non-TTY or `--json` context with neither `--all` nor `--project-id`, the command fails fast telling the caller to pass `--all` or `--project-id` instead of hanging on a prompt. `--alias ID=ALIAS` (repeatable) overrides the suggested alias for a given project id in every mode, including as the picker's prefilled default. `--yes` skips only the picker's final confirmation. Two collision rules, in both modes: a project already registered under an alias for this project+stack reports `status: "exists"` (no-op -- rename via `project edit --new-alias` instead of re-registering); an alias already claimed by a different project (or a static-token project) reports `status: "skipped"` with a rename-hint note -- an existing `config.json` entry is never overwritten. `auth login` (without `--register-projects`) now also offers this same picker interactively right after a successful login, when stdout is a TTY and `--json` was not used; otherwise it just prints the hint to run this command later, and a failure in that optional follow-up never changes `login`'s own (already-successful) exit code. +- `auth register-projects [--stack URL|alias] [--all] [--project-id ID ...] [--alias ID=ALIAS ...] [--yes]` -- register an EXISTING session's accessible projects as `config.json` aliases, without re-running `login`. Fixes two usability gaps in plain `login`: nothing was registered unless `--register-projects` was passed, and the suggested alias was always slugified from the project NAME, so a project id like `9840` from the login table never resolved as `--project 9840` (since vNEXT it does, once the project is registered). `--all` selects every accessible project; `--project-id ID` (repeatable) selects specific ones (an inaccessible id raises a `ConfigError`); passing neither starts an interactive arrow-key + spacebar checkbox picker -- every not-yet-registered project preselected, up/down or `j`/`k` move, `space` toggles, `a` selects/deselects all, `enter` accepts, `q`/`esc`/`ctrl-c` cancels -- followed by a single `Edit aliases?` confirm (default no) that opens the old per-project alias prompt only if you opt in (each row already shows its suggested alias), then a final `typer.confirm`. On a piped stdin or a terminal without real interactive capabilities, the picker falls back to the original typed prompt (numbers / ranges `1-3` / `all` / `none`). In a non-TTY or `--json` context with neither `--all` nor `--project-id`, the command fails fast telling the caller to pass `--all` or `--project-id` instead of hanging on a prompt. `--alias ID=ALIAS` (repeatable) overrides the suggested alias for a given project id in every mode, including as the picker's prefilled default. `--yes` skips only the picker's final confirmation. Two collision rules, in both modes: a project already registered under an alias for this project+stack reports `status: "exists"` (no-op -- rename via `project edit --new-alias` instead of re-registering); an alias already claimed by a different project (or a static-token project) reports `status: "skipped"` with a rename-hint note -- an existing `config.json` entry is never overwritten. `auth login` (without `--register-projects`) now also offers this same picker interactively right after a successful login, when stdout is a TTY and `--json` was not used; otherwise it just prints the hint to run this command later, and a failure in that optional follow-up never changes `login`'s own (already-successful) exit code. A session project works with almost every command -- only three features still require a static Storage token (listed below). `serve` reaches the supported @@ -81,7 +83,7 @@ end-to-end walkthrough and troubleshooting. - `project refresh --project ALIAS | --all [--dry-run] [--force] [--yes] [--token-description DESC] [--token-expires-in N]` -- mint replacement Storage tokens via the Manage API for projects whose token is expired or invalid. `--force` also replaces still-valid non-expiring tokens. Browser-login (session) projects land in `skipped` with the reason that there is no static token to replace -- their credential lives in `auth.json` and rotates on its own -- and `--force` does not convert them either; use `project edit --token` for a deliberate single-project conversion (since v0.80.0) - `project description-get --project NAME` -- read the dashboard project description (KBC.projectDescription on the default branch). Returns `{"description": ""}` if not set, not an error - `project description-set --project NAME [--text STR | --file PATH | --stdin]` -- set the dashboard project description (markdown). Pass exactly one of `--text`, `--file`, or `--stdin`. Writes to `KBC.projectDescription` on the default branch -- always the main branch, regardless of any active dev branch -- `project use ALIAS` -- pin `ALIAS` as the persistent default project. Stored as `default_project` in config.json. Overridden at runtime by `KBAGENT_PROJECT=ALIAS` (env, beats pin) and by `--project ALIAS` (CLI flag, beats both) +- `project use ALIAS` -- pin `ALIAS` as the persistent default project. Stored as `default_project` in config.json. A registered project ID pins that project's alias *(since vNEXT)*. Overridden at runtime by `KBAGENT_PROJECT=ALIAS` (env, beats pin) and by `--project ALIAS` (CLI flag, beats both) - `project current` -- print the effective default project and its source (`env` / `pin` / `none`). Reports both the env override AND the persisted pin so misconfigurations are visible. Returns `{"alias": null, "source": "none"}` when neither is set - `project info --project NAME` -- show detailed project metadata. Also carries `auth_mode`, rendered as an `Auth` row above the Token rows (on a session project those rows describe the rotating access token). `project current`, `project add` and `project edit` deliberately do **not** carry `auth_mode` -- `current` answers which alias is effective (and reports a `KBAGENT_PROJECT` override that may name an alias absent from the config), the other two are write confirmations. The same keys appear over HTTP on `/projects`, `/projects/status` and `/projects/{alias}/info` (since v0.80.0) @@ -90,7 +92,7 @@ end-to-end walkthrough and troubleshooting. All seven commands authenticate via `KBC_MANAGE_API_TOKEN` (Manage API), not the project's Storage token. Allowed roles are exactly `admin`, `guest`, `readOnly`, `share` -- the API self-reports this list in its 400 validation error and `constants.PROJECT_ROLES` mirrors it. - `project invite --project ALIAS --email EMAIL --role admin|guest|readOnly|share [--reason TEXT] [--dry-run]` -- single-shot invitation. Returns `{"status": "ok", "invitation_id": ..., ...}`. Re-inviting an already-invited or already-member email returns `{"status": "noop", "note": "already_invited" | "already_member"}` (HTTP 400 from the Manage API, normalised to a no-op). -- `project invite --from-csv FILE [--default-role ROLE] [--workers N] [--dry-run]` -- bulk invitation. CSV header required; columns: `email`, `project` (alias) or `project_id` (numeric), `role` (optional with `--default-role`), `reason` (optional). Parallelised via `ThreadPoolExecutor` (default 8 workers). Single-stack-URL invariant per file: rows referencing different stacks raise `ConfigError` upfront. Result is `{"total","succeeded","noop","failed","rows":[...]}`; `rows[]` order is *not deterministic*. Exit 0 even with `failed > 0` -- inspect the JSON. +- `project invite --from-csv FILE [--default-role ROLE] [--workers N] [--dry-run]` -- bulk invitation. CSV header required; columns: `email`, `project` (alias, or a project ID -- an alias wins *(since vNEXT)*, and a row fails when that alias hides another project's ID) or `project_id` (numeric, ID only), `role` (optional with `--default-role`), `reason` (optional). Parallelised via `ThreadPoolExecutor` (default 8 workers). Single-stack-URL invariant per file: rows referencing different stacks raise `ConfigError` upfront. Result is `{"total","succeeded","noop","failed","rows":[...]}`; `rows[]` order is *not deterministic*. Exit 0 even with `failed > 0` -- inspect the JSON. - `project member-list --project ALIAS [--include-pending]` -- list active members. Each member dict carries `id`, `email`, `name`, `role`, `status`, `mfa_enabled`. With `--include-pending`, the response also includes `pending_invitations: [...]`. - `project invitation-list --project ALIAS` -- list pending (unaccepted) invitations only. - `project invitation-cancel --project ALIAS --email EMAIL [--invitation-id ID] [--yes]` -- cancel a pending invitation. Without `--invitation-id`, the service resolves it by listing pending invitations and matching `--email` (case-insensitive). 204 No Content on success; `KeboolaApiError(NOT_FOUND)` if the email has no pending invitation. diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index b187268a3..4b79b50af 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -78,6 +78,60 @@ Versioning convention: `branch use`. An active branch set by an older version has no name. - `kbagent serve` responses do not carry `targets` (REST reporting: #791). +## `--project` takes a project ID as well as an alias + +*(since vNEXT)* (CLI-22) + +- **A registered project's numeric ID works wherever `--project` takes an + alias.** `kbagent config list --project 9840` runs against the alias whose + stored `project_id` is 9840. The same applies to `KBAGENT_PROJECT`, + `kbagent project use 9840` (it pins the alias), and to the `kbagent serve` + `{project}` / `{alias}` path parameters and `?project=` / `?alias=` / + `?stack=` (on `/auth/*`) query parameters. +- **The other CLI options that name a registered alias take an ID too:** + `config clone --target-project`, `sync clone --target`, `semantic-layer + promote --from-project/--to-project`, `semantic-layer diff + --project-a/--project-b`, and `--stack` of `auth login` / `login-password` + / `status` / `logout` / `register-projects` (a stack URL is used as before). +- **`kbagent serve` translates path and query parameters only.** A project in + a request body (for example `target_project` of the config clone route, or + the semantic-layer `from_project` / `to_project` / `project_a` / + `project_b`) still needs the alias. +- **An alias wins.** When the alias `1234` belongs to project 5555, + `--project 1234` means that alias, not project 1234. The CLI then prints a + warning on stderr, in `--json` mode too (stdout stays one JSON document): + `'1234' is an alias of project 5555; project ID 1234 is registered as + 'prod'. Pass 'prod' to use that project.` +- **An ID registered more than once is an error, never a guess:** + `CONFIG_ERROR`, exit 5 (HTTP 400 over serve), and the message lists each + matching alias with its stack. A project ID is unique only per stack, and + one project can be registered under two aliases (two tokens). One + exception: when every match is on the same stack and exactly one of them is + a session (`auth login`) alias, that alias is used. +- **Output names the alias, not the ID.** `targets[].project_alias` and the + `project_alias` fields in results carry the resolved alias. Human mode also + prints `Project ID 9840 resolved to alias 'prod'` on stderr, before the + `Target:` line. +- **An ID that matches nothing is still "not found"**; the message names the + missing alias and adds that `--project` and the other project options also + take a registered project's ID. A project registered without a stored + `project_id` (an old entry) is never matched by ID -- re-add it with + `project add` to store the ID. +- **`project current` with an ambiguous `KBAGENT_PROJECT`** keeps the value as + typed, reports `env_points_to_configured_project: false` (commands using it + fail) and puts the ambiguity message in the new `env_error` field. +- **Not translated where `--project` is no registry lookup.** `project add + --project` and `project create --project` take the value as the new alias; + `lineage show --project` filters the aliases inside an offline graph file. +- **`project invite --from-csv` changed:** a `project` column value now + follows the same rules -- an alias wins over a project ID, and an ID + registered more than once fails that row with the list of aliases. Before, + an all-digit value was always taken as an ID, and the first project with + that ID was used. A `project_id` column value is still only an ID. One + difference from `--project`: when a digits-only alias is also the project ID + of a different registered project, the row fails and names both, because an + invite grants membership and a bulk run must not guess the project. + ## A semantic-layer dataset `fqn` is the table's real warehouse location, not `"KEBOOLA"` *(since 0.95.0, #761)* @@ -274,7 +328,9 @@ Versioning convention: `--alias ID=ALIAS`, or request a second alias for an already-registered project. An existing entry is never overwritten either way. - **Registered aliases derive from the project NAME, never the numeric - project id -- `--project 9840` will never resolve.** `login`'s + project id.** Before vNEXT `--project 9840` never resolved; since then it + resolves once the project is registered (see "`--project` takes a project + ID as well as an alias"). `login`'s accessible-projects table shows a numeric `id`, but the alias `--register-projects` (or `auth register-projects`, or the login picker) writes is a slug of the project *name* (e.g. `jirka-bq-sox`), suffixed diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index 1f12636e1..e781987b9 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -8,6 +8,7 @@ import typer from . import telemetry +from .commands._project_ref import ProjectRefGroup from .commands.agent import agent_app from .commands.auth import auth_app from .commands.billing import billing_app @@ -101,6 +102,8 @@ name="kbagent", help="Keboola Agent CLI -- AI-friendly interface to Keboola projects", invoke_without_command=True, + # Translates a project ID given as --project to its alias (CLI-22). + cls=ProjectRefGroup, ) # -- Setup & Info -- diff --git a/src/keboola_agent_cli/commands/_project_ref.py b/src/keboola_agent_cli/commands/_project_ref.py new file mode 100644 index 000000000..2b9202c35 --- /dev/null +++ b/src/keboola_agent_cli/commands/_project_ref.py @@ -0,0 +1,155 @@ +"""Let every ``--project`` option take a project ID as well as an alias (CLI-22). + +There is no shared ``--project`` option object: each command declares its +own, as a single value or a repeatable list. :class:`ProjectRefGroup` is the +class of the root command group, so Typer builds it last, with every command +already built; it then walks the tree once and adds +:func:`_translate_project_ref` as the callback of each ``--project`` option. +The same goes for the few other parameters that name an existing alias +(:data:`ALIAS_ARGUMENTS`, :data:`ALIAS_OPTIONS`). +Click runs that callback while it parses the command's arguments, which is +after the root callback put the config store into ``ctx.obj`` and before the +command body runs. The command and its services therefore only ever see the +alias. The rules are in :mod:`keboola_agent_cli.project_ref`; +``tests/test_project_ref.py`` invokes every command with ``--project``, and +every table entry, to prove the translation reaches it. +""" + +from collections.abc import Callable +from typing import Any + +import typer +from typer.core import TyperGroup + +from ..errors import ConfigError, ErrorCode +from ..project_ref import alias_shadow_notice, is_project_id, resolve_project_ref + +# Commands whose --project is NOT a lookup in the registered projects, so it +# is never translated: +# - `project add` / `project create`: the value is the NEW alias. Translating +# an ID would register the project under another project's alias. +# - `lineage show`: the value filters the aliases inside an offline graph +# file; the command needs no config at all. +NO_LOOKUP_COMMANDS: frozenset[tuple[str, ...]] = frozenset( + { + ("project", "add"), + ("project", "create"), + ("lineage", "show"), + } +) + +# Positional arguments that name an existing alias, keyed by command path. +ALIAS_ARGUMENTS: dict[tuple[str, ...], str] = {("project", "use"): "alias"} + +# Options other than --project that name an existing alias, keyed by command +# path. `sl` is the hidden second name of `semantic-layer`, a separate subtree. +# `--stack` takes a stack URL or an alias; only the alias form is translated. +ALIAS_OPTIONS: dict[tuple[str, ...], frozenset[str]] = { + ("auth", "login"): frozenset({"--stack"}), + ("auth", "login-password"): frozenset({"--stack"}), + ("auth", "status"): frozenset({"--stack"}), + ("auth", "logout"): frozenset({"--stack"}), + ("auth", "register-projects"): frozenset({"--stack"}), + ("config", "clone"): frozenset({"--target-project"}), + ("sync", "clone"): frozenset({"--target"}), + ("semantic-layer", "promote"): frozenset({"--from-project", "--to-project"}), + ("semantic-layer", "diff"): frozenset({"--project-a", "--project-b"}), + ("sl", "promote"): frozenset({"--from-project", "--to-project"}), + ("sl", "diff"): frozenset({"--project-a", "--project-b"}), +} + +# Click context, parameter, value. Typed Any: Typer >= 0.25 vendors Click as +# `typer._click`, older releases use the `click` package, and pyproject.toml +# allows both (typer>=0.12), so neither module can be imported here. +ParamCallback = Callable[[Any, Any, Any], Any] + + +class ProjectRefGroup(TyperGroup): + """Root command group that adds the project-ID translation to the command tree.""" + + def __init__(self, **attrs: Any) -> None: + super().__init__(**attrs) + add_project_ref_callbacks(self) + + +def add_project_ref_callbacks(group: TyperGroup, path: tuple[str, ...] = ()) -> None: + """Add the translation to every parameter under ``group`` that names an existing alias.""" + for name, command in group.commands.items(): + command_path = (*path, name) + if isinstance(command, TyperGroup): + add_project_ref_callbacks(command, command_path) + continue + if command_path in NO_LOOKUP_COMMANDS: + continue + for param in command.params: + if _names_existing_alias(command_path, param): + param.callback = _with_translation(param.callback) + + +def _names_existing_alias(command_path: tuple[str, ...], param: Any) -> bool: + """True for ``--project`` and for the parameters listed in the two tables above.""" + if "--project" in param.opts or ALIAS_ARGUMENTS.get(command_path) == param.name: + return True + return not ALIAS_OPTIONS.get(command_path, frozenset()).isdisjoint(param.opts) + + +def _with_translation(original: ParamCallback | None) -> ParamCallback: + """Run the translation after the callback the option already has, if any.""" + if original is None: + return _translate_project_ref + + def _chained(ctx: Any, param: Any, value: Any) -> Any: + return _translate_project_ref(ctx, param, original(ctx, param, value)) + + return _chained + + +def _translate_project_ref(ctx: Any, param: Any, value: Any) -> Any: + """Replace each project ID in ``value`` (one string or a tuple of them) with its alias.""" + refs = list(value) if isinstance(value, tuple | list) else [value] + obj = ctx.obj if isinstance(ctx.obj, dict) else {} + config_store = obj.get("config_store") + if config_store is None or not any(ref and is_project_id(ref) for ref in refs): + return value + + formatter = obj["formatter"] + try: + projects = config_store.load().projects + aliases = [resolve_project_ref(projects, ref) for ref in refs] + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + for ref, alias in zip(refs, aliases, strict=True): + if ref != alias and not formatter.json_mode: + formatter.err_console.print( + f"Project ID {ref} resolved to alias '{alias}'", + style="dim", + markup=False, + highlight=False, + ) + # On stderr in --json mode too: the alias silently won over an ID the + # caller may have meant, and stdout must stay one JSON document. + notice = alias_shadow_notice(projects, ref) + if notice: + formatter.err_console.print( + f"Warning: {notice}", style="yellow", markup=False, highlight=False + ) + return type(value)(aliases) if isinstance(value, tuple | list) else aliases[0] + + +def env_override_warning(current: dict[str, Any]) -> str | None: + """The `project current` warning for a KBAGENT_PROJECT no command can use, or None. + + ``current`` is the ``ProjectService.current_project()`` result. An ID + registered under several aliases (``env_error``, CLI-22) is not "missing" + from the config, so it gets its own message. + """ + if current.get("env_error"): + return f"{current['env_error']} Commands that use KBAGENT_PROJECT will fail." + if current.get("env_points_to_configured_project") is False: + return ( + f"'{current['alias']}' is NOT in your configured projects. " + "Commands that use this pin will fail." + ) + return None diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index a2368e45b..3aa7d9e04 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -161,8 +161,9 @@ EXISTING session, without re-running login. Fixes the usability trap where `login` prints an accessible-project table but nothing gets registered unless --register-projects was passed, and where the - suggested alias is slugified from the project NAME -- the numeric - project id (e.g. 9840) is never a valid alias on its own. + suggested alias is slugified from the project NAME, never the numeric + project id (e.g. 9840). Once registered, `--project 9840` resolves to + that alias too (since vNEXT; see Tips 3). --all registers every accessible project. --project-id ID (repeatable) registers specific ones (an id the session cannot access raises a ConfigError naming it). Omitting both starts an interactive arrow-key + @@ -342,7 +343,8 @@ --file, or --stdin. Writes KBC.projectDescription to the default branch. kbagent project use ALIAS - Pin ALIAS as the default project. Persists to config.json. + Pin ALIAS as the default project. Persists to config.json. A registered + project ID pins that project's alias (since vNEXT; see Tips 3). Env var KBAGENT_PROJECT=ALIAS overrides the pin for a single shell/session; an explicit --project flag overrides both. @@ -371,7 +373,8 @@ kbagent project invite --from-csv FILE [--default-role ROLE] [--workers N] [--dry-run] Bulk invite. CSV must have a header row with columns: email, project (alias or - numeric ID), role (optional if --default-role is given), reason (optional). + numeric ID -- an alias wins, see Tips 3; since vNEXT) or project_id (ID only), + role (optional if --default-role is given), reason (optional). Parallelised with ThreadPoolExecutor (default 8 workers). Per-row results in `rows[]` with status=ok|noop|failed; `failed_rows` ordering is not deterministic. @@ -2176,6 +2179,22 @@ 3. Multi-project: most read commands accept repeatable --project flag. Omit --project to query ALL connected projects in parallel. + --project takes an alias or a registered project's numeric ID (since vNEXT). + An alias wins over an ID. An ID registered under several aliases fails with + CONFIG_ERROR (exit 5) and lists them -- unless all are on one stack and + exactly one is a session (browser-login) alias, which then wins. The same + applies to KBAGENT_PROJECT, `project use`, `config clone --target-project`, + `sync clone --target`, `semantic-layer promote --from-project/--to-project`, + `semantic-layer diff --project-a/--project-b`, `auth * --stack`, the + `project` column of `project invite --from-csv`, and the `kbagent serve` + {{project}} / {{alias}} path and ?project= / ?alias= / ?stack= query + parameters. serve translates path and query parameters only: a project in + a request body still needs the alias. Output names the resolved alias + (`targets`, `project_alias`). When a digits-only alias is also another + project's ID, the alias is used and a warning names that project on stderr + (in --json mode too). `project add` / `project create` take --project as a + NEW alias, never an ID; `lineage show --project` filters an offline graph + and is not translated either. 4. Tokens are always masked in output (e.g. 901-...XXXX) -- expected behavior. @@ -2202,7 +2221,7 @@ KBC_MASTER_TOKEN Master token for sharing ops (global fallback) KBC_MASTER_TOKEN_* Per-project master token (e.g. KBC_MASTER_TOKEN_PROD) KBAGENT_CONFIG_DIR Override config directory - KBAGENT_PROJECT Override the pinned default project for this shell/session (beats pin, loses to --project) + KBAGENT_PROJECT Override the pinned default project for this shell/session (alias or project ID; beats pin, loses to --project) KBAGENT_PROJECT_FROM_ENV Set to "1" (or true/yes/on) to synthesize an in-memory project under the reserved alias __env__ from KBC_TOKEN + KBC_STORAGE_API_URL. Headless / token-only mode: no `project add`, no config.json on disk. Use diff --git a/src/keboola_agent_cli/commands/init.py b/src/keboola_agent_cli/commands/init.py index 5095f665a..f7bcfcb27 100644 --- a/src/keboola_agent_cli/commands/init.py +++ b/src/keboola_agent_cli/commands/init.py @@ -173,7 +173,8 @@ def _filter_global_projects( available = ", ".join(sorted(config.projects)) or "(none)" formatter.error( message=( - f"Unknown project alias(es): {', '.join(missing)}. " + f"Unknown project alias(es): {', '.join(missing)} " + "(no alias or project ID matches). " f"Available in global config: {available}" ), error_code=ErrorCode.CONFIG_ERROR, diff --git a/src/keboola_agent_cli/commands/project.py b/src/keboola_agent_cli/commands/project.py index 786e68972..4a5d8f1d7 100644 --- a/src/keboola_agent_cli/commands/project.py +++ b/src/keboola_agent_cli/commands/project.py @@ -33,6 +33,7 @@ ) from ._metadata_input import resolve_text_input from ._project_create import format_provision_result +from ._project_ref import env_override_warning class ProjectBackend(StrEnum): @@ -729,11 +730,9 @@ def _human(c: Console, d: dict[str, Any]) -> None: return if source == "env": c.print(f"[bold cyan]{alias}[/bold cyan] [dim](source: KBAGENT_PROJECT env var)[/dim]") - if d.get("env_points_to_configured_project") is False: - c.print( - f"[yellow]Warning:[/yellow] '{alias}' is NOT in your " - "configured projects. Commands that use this pin will fail." - ) + warning = env_override_warning(d) + if warning: + c.print(f"[yellow]Warning:[/yellow] {escape(warning)}") pinned = d.get("pinned") if pinned: c.print(f"[dim] (pinned in config: {pinned}, overridden)[/dim]") diff --git a/src/keboola_agent_cli/config_store.py b/src/keboola_agent_cli/config_store.py index 77f2883b8..3b1707b5c 100644 --- a/src/keboola_agent_cli/config_store.py +++ b/src/keboola_agent_cli/config_store.py @@ -150,6 +150,14 @@ def _has_stored_session(config_path: object) -> bool: return bool(isinstance(payload, dict) and payload.get("sessions")) +# Appended to the "not found" errors below. They cannot say whether the value +# was already looked up as a project ID: a value from a REST body or a direct +# service call never is (CLI-22). +PROJECT_ID_HINT = ( + "--project and the other project options of the CLI also take the ID of a registered project." +) + + def project_not_found_error(alias: str, config_path: object, source: object) -> ConfigError: """Build the canonical "project not found" error. @@ -162,8 +170,9 @@ def project_not_found_error(alias: str, config_path: object, source: object) -> remedy is NOT ``project add`` -- a session user has no static token to paste, and would have to hand-write a ``kbc-session://`` sentinel. Point those users at the picker instead (0.80.0). Note that a session - registers aliases from the project *name*, so the numeric project id is - never a valid alias, which is the exact trap this hint exists to defuse. + registers aliases from the project *name*, so the numeric project ID is + never the alias. ``--project`` takes the ID too (CLI-22), but only for a + project that is registered -- this hint covers the one that is not. A module-level function (not a ConfigStore method) so services can build a real ConfigError even when their config_store is a test double -- the @@ -171,8 +180,9 @@ def project_not_found_error(alias: str, config_path: object, source: object) -> """ message = ( f"Project '{alias}' not found in {config_path} " - f"(source: {source}). " - "Run 'kbagent project list' to see configured projects." + f"(source: {source}): no registered alias '{alias}'. " + "Run 'kbagent project list' to see configured projects. " + f"{PROJECT_ID_HINT}" ) if _has_stored_session(config_path): message += ( @@ -183,6 +193,18 @@ def project_not_found_error(alias: str, config_path: object, source: object) -> return ConfigError(message) +def project_not_registered_error(alias: str) -> ConfigError: + """Build the short "not registered" error of the single-project services. + + Used by the ``token`` / ``stream`` / ``snapshot`` / ``feature`` / + ``member`` services; unlike :func:`project_not_found_error` it names no + config path. + """ + return ConfigError( + f"Project '{alias}' is not registered. Run `kbagent project list`. {PROJECT_ID_HINT}" + ) + + def resolve_config_dir(cli_config_dir: str | None = None) -> tuple[Path, str]: """Resolve the config directory using the priority chain. diff --git a/src/keboola_agent_cli/project_ref.py b/src/keboola_agent_cli/project_ref.py new file mode 100644 index 000000000..c59353591 --- /dev/null +++ b/src/keboola_agent_cli/project_ref.py @@ -0,0 +1,108 @@ +"""Translate a project ID to the registered alias it names (CLI-22). + +``--project``, the other CLI options that name a registered alias, +``KBAGENT_PROJECT``, ``project use`` and the ``kbagent serve`` path and query +project parameters take a registered alias. They also take a Keboola project +ID: :func:`resolve_project_ref` turns it into the alias of the one registered +project with that ``ProjectConfig.project_id``. + +The translation runs once, before a command body or route handler runs +(``commands/_project_ref.py`` for the CLI, ``server/dependencies.py`` for +``kbagent serve``). Services use the value as a dict key after resolution +(``projects[alias]``), so they must only ever receive aliases. + +Rules: + +- An alias wins. A registered alias is returned unchanged, even when it is + also the ID of another project. +- A project ID is unique only per stack, and one project can be registered + under two aliases (two tokens). When several registered projects have the + ID, the one session-token alias wins only if all of them are on the same + stack. Every other multiple match is an error: nothing picks one silently. +- No match returns the value unchanged, so the caller's own "not found" error + applies. A registered project without a stored ``project_id`` (an old entry) + never matches by ID. +""" + +import re +from collections.abc import Mapping + +from .auth.sentinel import is_session_token +from .errors import ConfigError +from .models import ProjectConfig + +# Up to 18 digits: every real project ID fits, and int() of a longer string can +# raise (Python caps int() at 4300 digits) or produce an ID no project has. +_PROJECT_ID_RE = re.compile(r"[0-9]{1,18}") + + +def is_project_id(value: str) -> bool: + """True when ``value`` has the shape of a Keboola project ID (1-18 ASCII digits).""" + return bool(_PROJECT_ID_RE.fullmatch(value)) + + +def _aliases_with_id(projects: Mapping[str, ProjectConfig], project_id: int) -> list[str]: + return sorted(alias for alias, project in projects.items() if project.project_id == project_id) + + +def resolve_project_ref(projects: Mapping[str, ProjectConfig], ref: str) -> str: + """Return the registered alias that ``ref`` names, or ``ref`` when nothing matches. + + Raises: + ConfigError: ``ref`` is the project ID of several registered projects + and none of them wins (see the module docstring). + """ + if ref in projects or not is_project_id(ref): + return ref + return resolve_project_id(projects, int(ref)) or ref + + +def resolve_project_id(projects: Mapping[str, ProjectConfig], project_id: int) -> str | None: + """Return the alias of the registered project with ``project_id``, or None. + + The ID half of :func:`resolve_project_ref`, for a value that is only ever + an ID (never looked up as an alias). + + Raises: + ConfigError: several registered projects have that ID and none of + them wins (see the module docstring). + """ + matches = _aliases_with_id(projects, project_id) + if len(matches) <= 1: + return matches[0] if matches else None + session_aliases = [alias for alias in matches if is_session_token(projects[alias].token)] + stacks = {projects[alias].stack_url for alias in matches} + if len(stacks) == 1 and len(session_aliases) == 1: + return session_aliases[0] + listed = ", ".join(f"'{alias}' ({projects[alias].stack_url})" for alias in matches) + raise ConfigError( + f"Project ID {project_id} matches more than one registered project: {listed}. " + "Use one of these aliases instead." + ) + + +def alias_shadow_notice(projects: Mapping[str, ProjectConfig], ref: str) -> str | None: + """Explain an alias that hides another project's ID, or None when nothing is hidden. + + ``ref`` resolves to the alias ``ref`` (an alias wins), but when it is also + the project ID of a DIFFERENT registered project, the caller may have meant + that project. Aliases of the same project (same ID and stack) hide nothing. + """ + if ref not in projects or not is_project_id(ref): + return None + own = projects[ref] + hidden = [ + alias + for alias in _aliases_with_id(projects, int(ref)) + if (projects[alias].project_id, projects[alias].stack_url) + != (own.project_id, own.stack_url) + ] + if not hidden: + return None + owner = f"project {own.project_id}" if own.project_id is not None else "a project" + listed = ", ".join(f"'{alias}'" for alias in hidden) + remedy = f"Pass {listed}" if len(hidden) == 1 else "Pass one of those aliases" + return ( + f"'{ref}' is an alias of {owner}; project ID {ref} is registered as {listed}. " + f"{remedy} to use that project." + ) diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index 0e0b39813..07354ac02 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -22,7 +22,7 @@ from typing import Any from urllib.parse import parse_qs -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.openapi.utils import get_openapi from fastapi.responses import JSONResponse @@ -36,7 +36,12 @@ from ._serve_command_map import SERVE_COMMAND_MAP from .agents_store import AgentStore from .auth import PUBLIC_PATHS, AuthSettings, install_auth -from .dependencies import ServiceRegistry, install_permission_engine, install_registry +from .dependencies import ( + ServiceRegistry, + install_permission_engine, + install_registry, + translate_project_refs, +) from .routers import ( agents, ai_chat, @@ -844,6 +849,8 @@ async def _lifespan(app_: FastAPI): app = FastAPI( lifespan=_lifespan, # type: ignore[arg-type] + # A project ID in `{project}` / `?project=` becomes its alias (CLI-22). + dependencies=[Depends(translate_project_refs("project"))], title="kbagent serve", description=APP_DESCRIPTION, version=__version__, diff --git a/src/keboola_agent_cli/server/dependencies.py b/src/keboola_agent_cli/server/dependencies.py index dc67aa76d..c276a134a 100644 --- a/src/keboola_agent_cli/server/dependencies.py +++ b/src/keboola_agent_cli/server/dependencies.py @@ -10,6 +10,7 @@ from collections.abc import Callable from dataclasses import dataclass, field +from urllib.parse import parse_qsl, urlencode from fastapi import Depends, FastAPI, Request @@ -17,6 +18,7 @@ from ..dev_portal_client import DeveloperPortalClient from ..errors import PermissionDeniedError from ..permissions import PermissionEngine +from ..project_ref import is_project_id, resolve_project_ref from ..services.auth_service import AuthService from ..services.billing_service import BillingService from ..services.branch_service import BranchService @@ -254,6 +256,47 @@ def _check_permission( return _check_permission +def translate_project_refs(*names: str) -> Callable[..., None]: + """Build a dependency that replaces a project ID in the named parameters with its alias. + + The REST form of the CLI's ``--project`` translation (CLI-22): a ``names`` + path parameter or query parameter that holds a registered project ID is + rewritten in the request scope. FastAPI solves this dependency before it + reads the route's own path and query parameters, so the handler and its + services only receive the alias. An ID shared by several projects raises + :class:`ConfigError` (HTTP 400 ``CONFIG_ERROR``). Request bodies are not + translated. + """ + + def _translate(request: Request, registry: ServiceRegistry = Depends(get_registry)) -> None: + path_params = request.scope.get("path_params") or {} + raw_query = request.scope.get("query_string", b"").decode("latin-1") + query = parse_qsl(raw_query, keep_blank_values=True) + refs = [path_params[name] for name in names if name in path_params] + refs += [value for key, value in query if key in names] + if not any(is_project_id(ref) for ref in refs): + return + projects = registry.config_store.load().projects + for name in names: + if name in path_params: + path_params[name] = resolve_project_ref(projects, path_params[name]) + translated = [ + (key, resolve_project_ref(projects, value) if key in names else value) + for key, value in query + ] + if translated != query: + request.scope["query_string"] = urlencode(translated).encode("latin-1") + # FastAPI already read the query while it solved this dependency's + # own parameters, and Starlette keeps that copy on the request. + # Drop it, so the route reads the rewritten query string. Private + # Starlette attribute: tests/test_server_project_ref.py:: + # test_query_param_project_ids_reach_service_as_aliases fails if + # an upgrade renames it. + request.__dict__.pop("_query_params", None) + + return _translate + + def get_manage_token(request: Request) -> str | None: """Return the per-request manage token from the X-Manage-Token header. diff --git a/src/keboola_agent_cli/server/routers/auth.py b/src/keboola_agent_cli/server/routers/auth.py index efed21ec8..7030664f7 100644 --- a/src/keboola_agent_cli/server/routers/auth.py +++ b/src/keboola_agent_cli/server/routers/auth.py @@ -32,9 +32,20 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel, ConfigDict, Field -from ..dependencies import ServiceRegistry, get_registry, require_permission +from ..dependencies import ( + ServiceRegistry, + get_registry, + require_permission, + translate_project_refs, +) -router = APIRouter(prefix="/auth", tags=["auth"]) +# `?stack=` takes a stack URL or a registered alias, so a project ID works too +# (CLI-22). A URL is never all digits, so it is left alone. +router = APIRouter( + prefix="/auth", + tags=["auth"], + dependencies=[Depends(translate_project_refs("stack"))], +) class RegisterProjectsBody(BaseModel): diff --git a/src/keboola_agent_cli/server/routers/projects.py b/src/keboola_agent_cli/server/routers/projects.py index 154b90ab0..844622434 100644 --- a/src/keboola_agent_cli/server/routers/projects.py +++ b/src/keboola_agent_cli/server/routers/projects.py @@ -7,9 +7,15 @@ from fastapi import APIRouter, Depends from pydantic import BaseModel -from ..dependencies import ServiceRegistry, get_registry - -router = APIRouter(prefix="/projects", tags=["projects"]) +from ..dependencies import ServiceRegistry, get_registry, translate_project_refs + +# `{alias}` / `?alias=` name an existing project here, so a project ID works +# too (CLI-22). The new alias of `POST /projects` is in the body, untouched. +router = APIRouter( + prefix="/projects", + tags=["projects"], + dependencies=[Depends(translate_project_refs("alias"))], +) class ProjectCreate(BaseModel): diff --git a/src/keboola_agent_cli/services/base.py b/src/keboola_agent_cli/services/base.py index c4da5dd90..d2aebdcb1 100644 --- a/src/keboola_agent_cli/services/base.py +++ b/src/keboola_agent_cli/services/base.py @@ -13,7 +13,12 @@ from ..auth.sentinel import is_session_token, parse_session_project_id, require_static_token from ..client import KeboolaClient -from ..config_store import ConfigError, ConfigStore, project_not_found_error +from ..config_store import ( + ConfigError, + ConfigStore, + project_not_found_error, + project_not_registered_error, +) from ..constants import ( ENV_KBAGENT_PROJECT, ENV_MAX_PARALLEL_WORKERS, @@ -21,6 +26,7 @@ ) from ..errors import ErrorCode from ..models import ProjectConfig +from ..project_ref import resolve_project_ref logger = logging.getLogger(__name__) @@ -53,13 +59,13 @@ def resolve_project_credentials( Shared by the single-project services (``token`` / ``stream`` / ``snapshot``) whose ``_resolve_project`` helpers were previously byte-identical. Uses the - same short, actionable "not registered" message they already emitted (kept - verbatim rather than switching to the richer - :meth:`ConfigStore.project_not_found_error`, to preserve behavior). + same short, actionable "not registered" message they already emitted + (:func:`project_not_registered_error`) rather than the richer + :meth:`ConfigStore.project_not_found_error`, to preserve behavior. """ project = config_store.get_project(alias) if project is None: - raise ConfigError(f"Project alias '{alias}' is not registered. Run `kbagent project list`.") + raise project_not_registered_error(alias) return ResolvedProjectCredentials(stack_url=project.stack_url, token=project.token) @@ -311,8 +317,14 @@ def resolve_pinned_alias(self, explicit: str | None = None) -> tuple[str, str]: project must go through this cascade -- never a "first registered project" shortcut, which ignores the ``project use`` pin (issue #684). + ``explicit`` and the env var may give a project ID instead of an + alias (CLI-22, :func:`~keboola_agent_cli.project_ref.resolve_project_ref`); + the returned value is always the alias. The CLI translates + ``--project`` before a command runs, so for ``explicit`` this only + matters to callers that pass their own value (telemetry, REST bodies). + Args: - explicit: Explicit alias from a CLI flag, or None. + explicit: Explicit alias (or project ID) from a CLI flag, or None. Returns: Tuple of (alias, source). @@ -324,21 +336,23 @@ def resolve_pinned_alias(self, explicit: str | None = None) -> tuple[str, str]: config = self._config_store.load() if explicit: - if explicit not in config.projects: + alias = resolve_project_ref(config.projects, explicit) + if alias not in config.projects: raise project_not_found_error( explicit, self._config_store.config_path, self._config_store.source ) - return explicit, "explicit" + return alias, "explicit" env_value = os.environ.get(ENV_KBAGENT_PROJECT) if env_value: - if env_value not in config.projects: + alias = resolve_project_ref(config.projects, env_value) + if alias not in config.projects: raise ConfigError( f"{ENV_KBAGENT_PROJECT}='{env_value}' points to a project " - "that is not registered. Use 'kbagent project add' or " - "unset the env var." + "that is not registered (no alias or project ID matches it). " + "Use 'kbagent project add' or unset the env var." ) - return env_value, "env" + return alias, "env" pinned = config.default_project if pinned: diff --git a/src/keboola_agent_cli/services/feature_service.py b/src/keboola_agent_cli/services/feature_service.py index e9c758582..6139d7267 100644 --- a/src/keboola_agent_cli/services/feature_service.py +++ b/src/keboola_agent_cli/services/feature_service.py @@ -23,7 +23,7 @@ from dataclasses import dataclass from typing import Any -from ..config_store import ConfigStore +from ..config_store import ConfigStore, project_not_registered_error from ..errors import ConfigError from ..manage_client import ManageClient from ..models import Feature @@ -243,9 +243,7 @@ def _resolve_alias(self, alias: str) -> _ResolvedAlias: """Resolve ``alias`` to its stack URL + numeric project_id for project ops.""" project = self._config_store.get_project(alias) if project is None: - raise ConfigError( - f"Project alias '{alias}' is not registered. Run `kbagent project list`." - ) + raise project_not_registered_error(alias) if project.project_id is None: raise ConfigError( f"Project alias '{alias}' has no numeric project_id; " @@ -262,7 +260,5 @@ def _resolve_stack_url(self, alias: str) -> str: """ project = self._config_store.get_project(alias) if project is None: - raise ConfigError( - f"Project alias '{alias}' is not registered. Run `kbagent project list`." - ) + raise project_not_registered_error(alias) return project.stack_url diff --git a/src/keboola_agent_cli/services/member_service.py b/src/keboola_agent_cli/services/member_service.py index fb5f4a95b..c92d78cb4 100644 --- a/src/keboola_agent_cli/services/member_service.py +++ b/src/keboola_agent_cli/services/member_service.py @@ -27,7 +27,7 @@ from pathlib import Path from typing import Any -from ..config_store import ConfigStore +from ..config_store import ConfigStore, project_not_registered_error from ..constants import DEFAULT_INVITE_WORKERS, PROJECT_ROLES from ..errors import ConfigError, ErrorCode, KeboolaApiError from ..manage_client import ManageClient @@ -37,6 +37,12 @@ ProjectInvitation, ProjectMember, ) +from ..project_ref import ( + alias_shadow_notice, + is_project_id, + resolve_project_id, + resolve_project_ref, +) logger = logging.getLogger(__name__) @@ -114,8 +120,8 @@ def invite_bulk( CSV must have a header row. Recognised columns (case-insensitive): ``email`` (required), ``project`` or ``project_id`` (one required), ``role`` (optional if ``default_role`` is given), ``reason`` (optional). - Extra columns are ignored. ``project`` values that are all-digits are - resolved as numeric project IDs without an alias lookup. + Extra columns are ignored. A ``project`` value is an alias or a + project ID (an alias wins, CLI-22); a ``project_id`` value is an ID. """ if default_role is not None: self._validate_role(default_role) @@ -329,9 +335,7 @@ def _resolve_alias(self, alias: str) -> tuple[str, int]: """Look up ``alias`` in the config store and return ``(stack_url, project_id)``.""" project = self._config_store.get_project(alias) if project is None: - raise ConfigError( - f"Project alias '{alias}' is not registered. Run `kbagent project list`." - ) + raise project_not_registered_error(alias) if project.project_id is None: raise ConfigError( f"Project alias '{alias}' has no numeric project_id; " @@ -340,22 +344,40 @@ def _resolve_alias(self, alias: str) -> tuple[str, int]: return project.stack_url, project.project_id def _stack_for_row(self, row: dict[str, Any]) -> tuple[str, int]: - """Resolve a CSV row's project field to ``(stack_url, project_id)``.""" + """Resolve a CSV row's project field to ``(stack_url, project_id)``. + + A ``project`` value follows the ``--project`` rules (CLI-22): an alias + wins, then a project ID, and an ID registered more than once is a + ``ConfigError`` listing the aliases. A ``project_id`` value is only + ever looked up as an ID. The stack URL comes from the registered project. + + One difference from ``--project``: when a digits-only alias is also the + project ID of a DIFFERENT registered project, the row fails instead of + taking the alias. An invite grants membership, and a bulk run has no + notice a person reads before the grant, so kbagent must not guess. + """ project_field = str(row["project"]).strip() - if project_field.isdigit(): - # Numeric project_id rows still need a stack_url; we infer from the - # currently-registered projects sharing that ID, falling back to - # any default. - project_id = int(project_field) - for cfg in self._config_store.load().projects.values(): - if cfg.project_id == project_id: - return cfg.stack_url, project_id + projects = self._config_store.load().projects + if row.get("by_id") and is_project_id(project_field): + alias = resolve_project_id(projects, int(project_field)) + else: + shadow = alias_shadow_notice(projects, project_field) + if shadow is not None: + raise ConfigError( + f"{shadow} kbagent skips this row, because it does not guess the project " + "of an invite: put the alias in the `project` column, or the ID in the " + "`project_id` column." + ) + alias = resolve_project_ref(projects, project_field) + if alias is not None and alias in projects: + return self._resolve_alias(alias) + if is_project_id(project_field): raise ConfigError( - f"CSV row references project_id={project_id}, which is not registered " + f"CSV row references project_id={project_field}, which is not registered " "in this kbagent config; add it via `kbagent project add` so we know " "which stack URL to use." ) - return self._resolve_alias(project_field) + raise project_not_registered_error(project_field) @staticmethod def _resolve_member_id(manage_client: ManageClient, project_id: int, email: str) -> int: @@ -537,6 +559,7 @@ def _parse_invite_csv(self, csv_path: Path, default_role: str | None) -> list[di { "email": email, "project": project, + "by_id": project_key == "project_id", "role": role, "reason": reason or None, } diff --git a/src/keboola_agent_cli/services/project_service.py b/src/keboola_agent_cli/services/project_service.py index 1939979ba..e8c6c0516 100644 --- a/src/keboola_agent_cli/services/project_service.py +++ b/src/keboola_agent_cli/services/project_service.py @@ -16,6 +16,7 @@ from ..constants import ENV_KBAGENT_PROJECT from ..errors import ConfigError, KeboolaApiError, mask_token from ..models import ProjectConfig, normalize_stack_url +from ..project_ref import resolve_project_ref from .base import BaseService # Credential type of a config.json project entry, surfaced as ``auth_mode`` in @@ -900,11 +901,14 @@ def current_project(self) -> dict[str, Any]: The env override is reported even when it points at a project that is not (yet) registered in config.json -- callers get the true effective alias plus an ``env_points_to_configured_project`` flag to reason about - it. This avoids silently masking misconfigurations. + it. This avoids silently masking misconfigurations. A project ID + reports as its alias (CLI-22). An ID registered under several aliases + stays as typed, the flag is False (commands using it fail), and + ``env_error`` carries the message those commands fail with. Returns: Dict with keys: alias, source ('env' | 'pin' | 'none'), pinned, - env_override, env_points_to_configured_project. + env_override, env_points_to_configured_project, env_error. """ config = self._config_store.load() pinned = config.default_project or None @@ -916,12 +920,19 @@ def current_project(self) -> dict[str, Any]: env_override = env_value if env_value else None if env_override is not None: + alias = env_override + env_error: str | None = None + try: + alias = resolve_project_ref(config.projects, env_override) + except ConfigError as exc: + env_error = exc.message return { - "alias": env_override, + "alias": alias, "source": "env", "pinned": pinned, "env_override": env_override, - "env_points_to_configured_project": env_override in config.projects, + "env_points_to_configured_project": alias in config.projects, + "env_error": env_error, } return { @@ -930,6 +941,7 @@ def current_project(self) -> dict[str, Any]: "pinned": pinned, "env_override": None, "env_points_to_configured_project": None, + "env_error": None, } def get_info(self, alias: str) -> dict[str, Any]: diff --git a/tests/test_member_service.py b/tests/test_member_service.py index f8fb8f624..654bee1e1 100644 --- a/tests/test_member_service.py +++ b/tests/test_member_service.py @@ -9,7 +9,7 @@ from keboola_agent_cli.config_store import ConfigStore from keboola_agent_cli.errors import ConfigError, ErrorCode, KeboolaApiError -from keboola_agent_cli.models import ProjectConfig +from keboola_agent_cli.models import BulkInviteResult, ProjectConfig from keboola_agent_cli.services.member_service import MemberService STACK_URL = "https://connection.us-east4.gcp.keboola.com" @@ -386,6 +386,73 @@ def test_numeric_project_id_resolves( assert result.rows[0].project_id == PROJECT_ID +class TestInviteBulkProjectColumn: + """The CSV `project` column follows the --project rules (CLI-22); `project_id` is only an ID.""" + + EU_STACK = "https://connection.eu-central-1.keboola.com" + + def _store(self, tmp_config_dir: Path, projects: dict[str, tuple[int, str]]) -> ConfigStore: + store = ConfigStore(config_dir=tmp_config_dir) + for alias, (project_id, stack_url) in projects.items(): + store.add_project( + alias, + ProjectConfig( + stack_url=stack_url, token="901-fake-token-1234567890", project_id=project_id + ), + ) + return store + + def _dry_run( + self, tmp_path: Path, store: ConfigStore, column: str, value: str + ) -> BulkInviteResult: + csv_path = _write_csv( + tmp_path / "bulk.csv", f"email,{column},role\nx@y.com,{value},guest\n" + ) + svc = MemberService(store, manage_client_factory=MagicMock()) + return svc.invite_bulk(manage_token=MANAGE_TOKEN, csv_path=csv_path, dry_run=True) + + def test_same_id_on_two_stacks_is_a_config_error( + self, tmp_path: Path, tmp_config_dir: Path + ) -> None: + store = self._store(tmp_config_dir, {"us": (77, STACK_URL), "eu": (77, self.EU_STACK)}) + svc = MemberService(store, manage_client_factory=MagicMock()) + with pytest.raises(ConfigError, match="matches more than one registered project"): + svc._stack_for_row({"project": "77"}) + + result = self._dry_run(tmp_path, store, "project", "77") + assert result.rows[0].status == "failed" + assert "'eu'" in result.rows[0].note + assert "'us'" in result.rows[0].note + + def test_numeric_alias_that_hides_another_projects_id_fails_the_row( + self, tmp_path: Path, tmp_config_dir: Path + ) -> None: + # An invite grants membership: the row must not guess between the alias + # `4242` (project 5555) and project ID 4242 (alias `prod`). + store = self._store(tmp_config_dir, {"4242": (5555, STACK_URL), "prod": (4242, STACK_URL)}) + + result = self._dry_run(tmp_path, store, "project", "4242") + assert result.rows[0].status == "failed" + assert "'prod'" in result.rows[0].note + assert "`project_id` column" in result.rows[0].note + + def test_numeric_alias_without_a_hidden_id_is_used( + self, tmp_path: Path, tmp_config_dir: Path + ) -> None: + store = self._store(tmp_config_dir, {"4242": (5555, STACK_URL), "prod": (6666, STACK_URL)}) + + result = self._dry_run(tmp_path, store, "project", "4242") + assert result.rows[0].status == "ok" + assert result.rows[0].project_id == 5555 + + def test_project_id_column_is_only_an_id(self, tmp_path: Path, tmp_config_dir: Path) -> None: + store = self._store(tmp_config_dir, {"4242": (5555, STACK_URL), "prod": (4242, STACK_URL)}) + + result = self._dry_run(tmp_path, store, "project_id", "4242") + assert result.rows[0].status == "ok" + assert result.rows[0].project_id == 4242 + + # ────────────────────────────────────────────────────────────────────── # member-list, invitation-list, invitation-cancel # ────────────────────────────────────────────────────────────────────── diff --git a/tests/test_project_ref.py b/tests/test_project_ref.py new file mode 100644 index 000000000..5dd5e60a6 --- /dev/null +++ b/tests/test_project_ref.py @@ -0,0 +1,484 @@ +"""A project ID works wherever a registered alias is expected (CLI-22). + +Covers the resolver rules (``project_ref.resolve_project_ref``), the CLI entry +point that applies them to every ``--project`` option before a command runs +(``commands/_project_ref.py``), ``project use``, and ``KBAGENT_PROJECT``. The +REST API is covered in ``test_server_project_ref.py``. +""" + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest +import typer +from typer.core import TyperGroup +from typer.testing import CliRunner + +from keboola_agent_cli.auth.sentinel import make_session_token +from keboola_agent_cli.cli import app +from keboola_agent_cli.commands._project_ref import ( + ALIAS_ARGUMENTS, + ALIAS_OPTIONS, + NO_LOOKUP_COMMANDS, +) +from keboola_agent_cli.config_store import PROJECT_ID_HINT, ConfigStore +from keboola_agent_cli.constants import ENV_KBAGENT_PROJECT +from keboola_agent_cli.errors import ConfigError +from keboola_agent_cli.models import AppConfig, ProjectConfig +from keboola_agent_cli.project_ref import alias_shadow_notice, resolve_project_ref +from keboola_agent_cli.services.project_service import ProjectService + +US = "https://connection.keboola.com" +EU = "https://connection.eu-central-1.keboola.com" +TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" +PROJECT_ID = 4242 + +runner = CliRunner() + + +def _project(project_id: int | None, *, stack: str = US, token: str = TOKEN) -> ProjectConfig: + return ProjectConfig(stack_url=stack, token=token, project_id=project_id) + + +def _write_config(config_dir: Path, projects: dict[str, ProjectConfig]) -> ConfigStore: + store = ConfigStore(config_dir=config_dir) + store.save(AppConfig(projects=projects)) + return store + + +class TestResolveProjectRef: + """The resolution rules, on a plain alias -> ProjectConfig mapping.""" + + def test_project_id_resolves_to_its_alias(self) -> None: + projects = {"prod": _project(PROJECT_ID), "dev": _project(1)} + assert resolve_project_ref(projects, str(PROJECT_ID)) == "prod" + + def test_alias_wins_over_another_projects_id(self) -> None: + # Alias "4242" is project 5555; project 4242 is registered as "prod". + projects = {"4242": _project(5555), "prod": _project(PROJECT_ID)} + assert resolve_project_ref(projects, "4242") == "4242" + + def test_alias_is_returned_unchanged(self) -> None: + assert resolve_project_ref({"prod": _project(PROJECT_ID)}, "prod") == "prod" + + def test_no_match_returns_value_unchanged(self) -> None: + assert resolve_project_ref({"prod": _project(PROJECT_ID)}, "999") == "999" + + def test_non_numeric_value_is_not_looked_up_as_id(self) -> None: + assert resolve_project_ref({"prod": _project(PROJECT_ID)}, "4242x") == "4242x" + + def test_project_without_stored_id_is_not_matched(self) -> None: + projects = {"legacy": _project(None)} + assert resolve_project_ref(projects, str(PROJECT_ID)) == str(PROJECT_ID) + + def test_two_static_aliases_on_one_stack_are_ambiguous(self) -> None: + projects = {"prod": _project(PROJECT_ID), "prod-ro": _project(PROJECT_ID)} + with pytest.raises(ConfigError) as exc_info: + resolve_project_ref(projects, str(PROJECT_ID)) + assert f"'prod' ({US})" in exc_info.value.message + assert f"'prod-ro' ({US})" in exc_info.value.message + + def test_same_id_on_two_stacks_is_ambiguous(self) -> None: + projects = {"us": _project(PROJECT_ID), "eu": _project(PROJECT_ID, stack=EU)} + with pytest.raises(ConfigError, match=r"'eu' \(https://connection\.eu-central-1"): + resolve_project_ref(projects, str(PROJECT_ID)) + + def test_session_alias_wins_over_static_alias_on_one_stack(self) -> None: + projects = { + "prod": _project(PROJECT_ID), + "prod-session": _project(PROJECT_ID, token=make_session_token(PROJECT_ID)), + } + assert resolve_project_ref(projects, str(PROJECT_ID)) == "prod-session" + + def test_session_and_static_plus_another_stack_is_ambiguous(self) -> None: + projects = { + "prod": _project(PROJECT_ID), + "prod-session": _project(PROJECT_ID, token=make_session_token(PROJECT_ID)), + "eu": _project(PROJECT_ID, stack=EU), + } + with pytest.raises(ConfigError) as exc_info: + resolve_project_ref(projects, str(PROJECT_ID)) + for alias in ("prod", "prod-session", "eu"): + assert f"'{alias}'" in exc_info.value.message + + def test_two_session_aliases_on_one_stack_are_ambiguous(self) -> None: + session = make_session_token(PROJECT_ID) + projects = { + "a": _project(PROJECT_ID, token=session), + "b": _project(PROJECT_ID, token=session), + } + with pytest.raises(ConfigError, match="matches more than one registered project"): + resolve_project_ref(projects, str(PROJECT_ID)) + + def test_overlong_digit_string_is_not_an_id(self) -> None: + # int() refuses more than 4300 digits; 19+ digits is no project ID anyway. + projects = {"prod": _project(PROJECT_ID)} + for ref in ("1" * 19, "9" * 5000): + assert resolve_project_ref(projects, ref) == ref + + def test_shadow_notice_names_the_project_the_id_would_pick(self) -> None: + projects = {"4242": _project(5555), "prod": _project(PROJECT_ID)} + notice = alias_shadow_notice(projects, "4242") + assert notice == ( + "'4242' is an alias of project 5555; project ID 4242 is registered as 'prod'. " + "Pass 'prod' to use that project." + ) + + def test_no_shadow_notice_for_aliases_of_the_same_project(self) -> None: + projects = {"4242": _project(PROJECT_ID), "prod": _project(PROJECT_ID)} + assert alias_shadow_notice(projects, "4242") is None + assert alias_shadow_notice({"prod": _project(PROJECT_ID)}, "prod") is None + + +# --- Every --project option, proven by invoking the real command tree ------------------- + +# Commands whose --project is not a registry lookup (a new alias, an offline +# graph filter), so an ID must reach them unchanged. +EXPECTED_EXCLUDED = {("project", "add"), ("project", "create"), ("lineage", "show")} + +# Options whose flag says project / alias / stack but that never name an +# existing registered alias, so they are rightly NOT translated. +NOT_AN_ALIAS: dict[str, str] = { + "--alias": "auth register-projects: ID=ALIAS names a NEW alias; dev-portal: an identity", + "--new-alias": "names the NEW alias (project edit) or identity (dev-portal identity edit)", + "alias": "dev-portal identity use: a developer-portal identity, not a project", + "--project-id": "already a project ID (auth register-projects)", + "--project-ids": "already project IDs (org setup)", + "--source-project-id": "already a project ID (sharing link)", + "--target-project-ids": "already project IDs (sharing share)", + "--all-projects": "a boolean flag", + "--register-projects": "a boolean flag", + "--remove-projects": "a boolean flag", +} + + +def _commands_with_project_option( + group: Any, path: tuple[str, ...] = () +) -> list[tuple[tuple[str, ...], Any]]: + """Every leaf command that has a --project option, found by walking list_commands().""" + found = [] + for name in group.list_commands(typer.Context(group)): + command = group.get_command(typer.Context(group), name) + command_path = (*path, name) + if hasattr(command, "list_commands"): + found += _commands_with_project_option(command, command_path) + elif any("--project" in param.opts for param in command.params): + found.append((command_path, command)) + return found + + +def _leaf_commands(group: Any, path: tuple[str, ...] = ()) -> dict[tuple[str, ...], Any]: + """Every leaf command, by path, found through ``group.commands`` (not list_commands).""" + found: dict[tuple[str, ...], Any] = {} + for name, command in group.commands.items(): + if hasattr(command, "commands"): + found.update(_leaf_commands(command, (*path, name))) + else: + found[(*path, name)] = command + return found + + +def _dummy_value(param: Any, existing_file: Path) -> str: + """A value Click accepts for ``param`` (the command body never runs).""" + type_name = type(param.type).__name__ + if type_name == "TyperChoice": + return str(param.type.choices[0]) + if type_name == "TyperPath": + return str(existing_file) + if type_name == "IntParamType": + return "1" + return "x" + + +def _argv_for(command: Any, given: Any, flag: str, existing_file: Path) -> list[str]: + """``flag PROJECT_ID`` for the ``given`` parameter, dummies for the other required ones.""" + options: list[str] = [] + arguments: list[str] = [] + for param in command.params: + if param is given: + value = str(PROJECT_ID) + elif param.required: + value = _dummy_value(param, existing_file) + else: + continue + if param.param_type_name == "argument": + arguments.append(value) + else: + options += [flag if param is given else param.opts[0], value] + return options + arguments + + +def _command_at(root: Any, path: tuple[str, ...]) -> Any: + """The command at ``path`` in the tree; fails when it no longer exists.""" + node = root + for name in path: + node = node.get_command(typer.Context(node), name) + assert node is not None, f"{' '.join(path)} no longer exists" + return node + + +def _invoke_recording(root: Any, config_dir: Path, path: tuple[str, ...], argv: list[str]) -> Any: + """Run ``path`` with the body replaced by a recorder; return the recorded params.""" + received: dict[str, Any] = {} + _command_at(root, path).callback = received.update + args = ["--config-dir", str(config_dir), *path, *argv] + exit_code = root.main(args=args, prog_name="kbagent", standalone_mode=False) + assert exit_code in (None, 0), f"{' '.join(path)} exited {exit_code}" + return received + + +def _first(value: Any) -> Any: + return value[0] if isinstance(value, tuple | list) else value + + +class TestEveryProjectOptionIsTranslated: + """Invoke every command that has --project with a project ID, and record what it gets. + + The command bodies are replaced by a recorder on the real Click tree, so the + root callback, the group callbacks and Click's own parsing run exactly as in + production. A new command with --project is picked up without editing this test. + """ + + def test_every_command_receives_the_alias(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + _write_config(config_dir, {"prod": _project(PROJECT_ID), "other": _project(1)}) + existing_file = tmp_path / "input.txt" + existing_file.write_text("x") + + root = typer.main.get_command(app) + assert isinstance(root, TyperGroup) + commands = _commands_with_project_option(root) + # A walk that stops early would test only part of the tree: compare it + # with a second, independent walk over `group.commands`. + independent = { + path + for path, command in _leaf_commands(root).items() + if any("--project" in param.opts for param in command.params) + } + assert {path for path, _ in commands} == independent + + for path, command in commands: + param = next(p for p in command.params if "--project" in p.opts) + argv = _argv_for(command, param, "--project", existing_file) + received = _invoke_recording(root, config_dir, path, argv) + expected = str(PROJECT_ID) if path in EXPECTED_EXCLUDED else "prod" + got = _first(received[param.name]) + assert got == expected, f"{' '.join(path)} received {received[param.name]!r}" + + def test_every_table_entry_receives_the_alias(self, tmp_path: Path) -> None: + """ALIAS_OPTIONS and ALIAS_ARGUMENTS: each entry must exist and get the alias.""" + config_dir = tmp_path / "config" + _write_config(config_dir, {"prod": _project(PROJECT_ID), "other": _project(1)}) + existing_file = tmp_path / "input.txt" + existing_file.write_text("x") + entries = [(path, flag) for path, flags in ALIAS_OPTIONS.items() for flag in sorted(flags)] + entries += list(ALIAS_ARGUMENTS.items()) + root = typer.main.get_command(app) + + for path, flag in entries: + command = _command_at(root, path) + param = next((p for p in command.params if flag in p.opts), None) + assert param is not None, f"{' '.join(path)} has no {flag} any more" + argv = _argv_for(command, param, flag, existing_file) + received = _invoke_recording(root, config_dir, path, argv) + got = _first(received[param.name]) + assert got == "prod", f"{' '.join(path)} {flag} received {received[param.name]!r}" + + def test_every_project_alias_or_stack_option_is_decided(self) -> None: + """A parameter whose flag says project / alias / stack is translated or allowlisted. + + A new option such as `--source-project` fails here until it is added to + ALIAS_OPTIONS (it names an existing alias) or to NOT_AN_ALIAS (it does not). + """ + root = typer.main.get_command(app) + undecided = [] + for path, command in _leaf_commands(root).items(): + for param in command.params: + for flag in param.opts: + if not any(word in flag for word in ("project", "alias", "stack")): + continue + translated = ( + "--project" in param.opts + or flag in ALIAS_OPTIONS.get(path, frozenset()) + or ALIAS_ARGUMENTS.get(path) == param.name + ) + if not translated and flag not in NOT_AN_ALIAS: + undecided.append(f"{' '.join(path)} {flag}") + assert not undecided, f"add to ALIAS_OPTIONS or NOT_AN_ALIAS: {undecided}" + + def test_exclusions_are_exactly_the_no_lookup_commands(self) -> None: + assert NO_LOOKUP_COMMANDS == EXPECTED_EXCLUDED + root = typer.main.get_command(app) + assert isinstance(root, TyperGroup) + paths = {path for path, _ in _commands_with_project_option(root)} + assert paths >= EXPECTED_EXCLUDED, "an excluded command no longer exists" + + def test_repeatable_option_translates_each_value(self, tmp_path: Path) -> None: + config_dir = tmp_path / "config" + _write_config(config_dir, {"prod": _project(PROJECT_ID), "other": _project(1)}) + root = typer.main.get_command(app) + assert isinstance(root, TyperGroup) + argv = ["--project", str(PROJECT_ID), "--project", "other", "--project", "1"] + received = _invoke_recording(root, config_dir, ("config", "list"), argv) + + assert list(received["project"]) == ["prod", "other", "other"] + + +def _flow_detail(config_dir: Path, project: str, *global_flags: str) -> Any: + """Run `flow detail` -- a command that records its target -- with ``--project project``.""" + argv = ["--config-dir", str(config_dir), *global_flags, "flow", "detail"] + return runner.invoke(app, [*argv, "--project", project, "--flow-id", "9"]) + + +class TestCliBehavior: + """What a caller sees: the alias in the output, the notice, the errors.""" + + @pytest.fixture + def config_dir(self, tmp_path: Path) -> Path: + config_dir = tmp_path / "config" + _write_config( + config_dir, + { + "prod": _project(PROJECT_ID), + "a": _project(77), + "b": _project(77, stack=EU), + }, + ) + return config_dir + + def test_command_body_and_targets_name_the_alias(self, config_dir: Path) -> None: + with patch("keboola_agent_cli.cli.FlowService") as flow_service_cls: + service = flow_service_cls.return_value + service.get_flow_detail.return_value = {"id": "9"} + result = _flow_detail(config_dir, str(PROJECT_ID), "--json") + + assert result.exit_code == 0, result.output + assert service.get_flow_detail.call_args.kwargs["alias"] == "prod" + envelope = json.loads(result.stdout) + assert [target["project_alias"] for target in envelope["targets"]] == ["prod"] + assert "resolved to alias" not in result.stderr + + def test_human_mode_prints_the_resolved_alias(self, config_dir: Path) -> None: + with patch("keboola_agent_cli.cli.FlowService") as flow_service_cls: + flow_service_cls.return_value.get_flow_detail.return_value = {"id": "9"} + result = _flow_detail(config_dir, str(PROJECT_ID)) + + assert f"Project ID {PROJECT_ID} resolved to alias 'prod'" in result.stderr + assert "Target: project 'prod'" in result.stderr + + def test_ambiguous_id_is_a_config_error_listing_the_aliases(self, config_dir: Path) -> None: + result = _flow_detail(config_dir, "77", "--json") + + assert result.exit_code == 5 + error = json.loads(result.stdout)["error"] + assert error["code"] == "CONFIG_ERROR" + assert f"'a' ({US})" in error["message"] + assert f"'b' ({EU})" in error["message"] + + def test_unmatched_id_keeps_the_not_found_error(self, config_dir: Path) -> None: + result = runner.invoke( + app, ["--config-dir", str(config_dir), "--json", "project", "info", "--project", "999"] + ) + + assert result.exit_code == 5 + message = json.loads(result.stdout)["error"]["message"] + assert "Project '999' not found" in message + assert "no registered alias '999'" in message + assert PROJECT_ID_HINT in message + + def test_project_use_pins_the_alias(self, config_dir: Path) -> None: + result = runner.invoke( + app, ["--config-dir", str(config_dir), "--json", "project", "use", str(PROJECT_ID)] + ) + + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["data"]["alias"] == "prod" + assert ConfigStore(config_dir=config_dir).load().default_project == "prod" + + +class TestAliasShadowsAnId: + """Alias '4242' is project 5555 while project 4242 is 'prod': the alias wins, with a notice.""" + + NOTICE = ( + "'4242' is an alias of project 5555; project ID 4242 is registered as 'prod'. " + "Pass 'prod' to use that project." + ) + + @pytest.fixture + def config_dir(self, tmp_path: Path) -> Path: + config_dir = tmp_path / "config" + _write_config(config_dir, {"4242": _project(5555), "prod": _project(PROJECT_ID)}) + return config_dir + + @pytest.mark.parametrize("global_flags", [(), ("--json",)], ids=["human", "json"]) + def test_notice_on_stderr_in_both_modes( + self, config_dir: Path, global_flags: tuple[str, ...] + ) -> None: + with patch("keboola_agent_cli.cli.FlowService") as flow_service_cls: + service = flow_service_cls.return_value + service.get_flow_detail.return_value = {"id": "9"} + result = _flow_detail(config_dir, "4242", *global_flags) + + assert result.exit_code == 0, result.output + assert service.get_flow_detail.call_args.kwargs["alias"] == "4242" + assert self.NOTICE in " ".join(result.stderr.split()) + assert "resolved to alias" not in result.stderr + if global_flags: + assert json.loads(result.stdout)["status"] == "ok" + assert "is an alias of project" not in result.stdout + + +class TestEnvVar: + """KBAGENT_PROJECT takes a project ID too.""" + + @pytest.fixture + def service(self, tmp_path: Path) -> ProjectService: + store = _write_config( + tmp_path / "config", {"prod": _project(PROJECT_ID), "dev": _project(1)} + ) + return ProjectService(config_store=store, client_factory=MagicMock()) + + def test_env_project_id_resolves_to_alias( + self, service: ProjectService, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv(ENV_KBAGENT_PROJECT, str(PROJECT_ID)) + assert service.resolve_pinned_alias() == ("prod", "env") + + def test_explicit_project_id_resolves_to_alias(self, service: ProjectService) -> None: + assert service.resolve_pinned_alias(explicit=str(PROJECT_ID)) == ("prod", "explicit") + + def test_env_unmatched_id_says_nothing_matches( + self, service: ProjectService, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv(ENV_KBAGENT_PROJECT, "999") + with pytest.raises(ConfigError, match="no alias or project ID matches it"): + service.resolve_pinned_alias() + + def test_project_current_reports_the_alias( + self, service: ProjectService, monkeypatch: pytest.MonkeyPatch + ) -> None: + monkeypatch.setenv(ENV_KBAGENT_PROJECT, str(PROJECT_ID)) + current = service.current_project() + assert current["alias"] == "prod" + assert current["env_override"] == str(PROJECT_ID) + assert current["env_points_to_configured_project"] is True + + def test_project_current_reports_an_ambiguous_id( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + config_dir = tmp_path / "ambiguous" + _write_config(config_dir, {"a": _project(77), "b": _project(77, stack=EU)}) + monkeypatch.setenv(ENV_KBAGENT_PROJECT, "77") + + current = ProjectService(config_store=ConfigStore(config_dir=config_dir)).current_project() + assert current["alias"] == "77" + assert current["env_points_to_configured_project"] is False + assert "matches more than one registered project" in current["env_error"] + + result = runner.invoke(app, ["--config-dir", str(config_dir), "project", "current"]) + output = " ".join(result.output.split()) + assert result.exit_code == 0, result.output + assert "matches more than one registered project" in output + assert "NOT in your configured projects" not in output diff --git a/tests/test_server_project_ref.py b/tests/test_server_project_ref.py new file mode 100644 index 000000000..7644f3dfe --- /dev/null +++ b/tests/test_server_project_ref.py @@ -0,0 +1,117 @@ +"""`kbagent serve` takes a project ID where a route takes a project alias (CLI-22). + +The app-wide dependency translates ``{project}`` / ``?project=``; the projects +router adds ``{alias}`` / ``?alias=`` and the auth router ``?stack=``. Real +config on disk, real registry, only the called service is a mock -- so the test +sees what the service receives. +""" + +from __future__ import annotations + +import importlib.util +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +import pytest + +if importlib.util.find_spec("fastapi") is None: # pragma: no cover + pytest.skip( + "FastAPI not installed; run `uv pip install -e '.[server]'`", allow_module_level=True + ) + +from fastapi.testclient import TestClient + +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.models import AppConfig, ProjectConfig +from keboola_agent_cli.server import create_app + +AUTH = {"Authorization": "Bearer test-token"} +US = "https://connection.keboola.com" +EU = "https://connection.eu-central-1.keboola.com" +TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" + + +@dataclass +class AuthStatusStub: + """A dataclass the auth routes can `asdict()`.""" + + ok: bool = True + + +@pytest.fixture +def app(tmp_path: Path) -> Any: + projects = { + "prod": ProjectConfig(stack_url=US, token=TOKEN, project_id=4242), + "other": ProjectConfig(stack_url=US, token=TOKEN, project_id=1), + "a": ProjectConfig(stack_url=US, token=TOKEN, project_id=77), + "b": ProjectConfig(stack_url=EU, token=TOKEN, project_id=77), + } + ConfigStore(config_dir=tmp_path).save(AppConfig(projects=projects)) + app = create_app(config_dir=str(tmp_path), auth_token="test-token") + app.state.registry.branch = MagicMock() + return app + + +def test_path_param_project_id_reaches_service_as_alias(app: Any) -> None: + app.state.registry.branch.reset_branch.return_value = {"ok": True} + with TestClient(app) as client: + res = client.post("/branches/4242/reset", headers=AUTH) + + assert res.status_code == 200, res.text + app.state.registry.branch.reset_branch.assert_called_once_with(alias="prod") + + +def test_query_param_project_ids_reach_service_as_aliases(app: Any) -> None: + app.state.registry.branch.list_branches.return_value = {"branches": []} + with TestClient(app) as client: + res = client.get("/branches?project=4242&project=other", headers=AUTH) + + assert res.status_code == 200, res.text + app.state.registry.branch.list_branches.assert_called_once_with(aliases=["prod", "other"]) + + +def test_project_use_route_pins_the_alias(app: Any, tmp_path: Path) -> None: + with TestClient(app) as client: + res = client.post("/projects/use/4242", headers=AUTH) + + assert res.status_code == 200, res.text + assert ConfigStore(config_dir=tmp_path).load().default_project == "prod" + + +def test_ambiguous_project_id_is_a_config_error(app: Any) -> None: + with TestClient(app) as client: + res = client.post("/branches/77/reset", headers=AUTH) + + assert res.status_code == 400 + error = res.json()["error"] + assert error["code"] == "CONFIG_ERROR" + assert "'a'" in error["message"] + assert "'b'" in error["message"] + app.state.registry.branch.reset_branch.assert_not_called() + + +@pytest.mark.parametrize("path", ["/auth/status", "/auth/projects"]) +def test_auth_stack_query_project_id_reaches_service_as_alias(app: Any, path: str) -> None: + auth = MagicMock() + auth.status.return_value = AuthStatusStub() + auth.list_project_candidates.return_value = AuthStatusStub() + app.state.registry.auth = auth + with TestClient(app) as client: + res = client.get(f"{path}?stack=4242", headers=AUTH) + + assert res.status_code == 200, res.text + called = auth.status if path == "/auth/status" else auth.list_project_candidates + called.assert_called_once_with(stack="prod") + + +def test_auth_stack_url_is_left_alone(app: Any) -> None: + auth = MagicMock() + auth.status.return_value = AuthStatusStub() + app.state.registry.auth = auth + with TestClient(app) as client: + res = client.get(f"/auth/status?stack={US}", headers=AUTH) + + assert res.status_code == 200, res.text + auth.status.assert_called_once_with(stack=US)