diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index a8bb4beb..763ad6ed 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "plugins": [ { "name": "kbagent", - "version": "0.84.1", + "version": "0.84.2", "source": "./plugins/kbagent", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "category": "development" diff --git a/CLAUDE.md b/CLAUDE.md index 213843a0..91b00e1e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -467,6 +467,16 @@ kbagent sharing edges [--project NAME] kbagent org setup --org-id ID --url URL [--dry-run] [--yes] [--token-description PREFIX] [--refresh] kbagent org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes] [--token-description PREFIX] [--refresh] +kbagent billing credits [--project ALIAS ...] +# billing credits (0.84.2+, issue #594 secondary ask): read-only PAYG credit balance, fanned out +# across all registered projects in parallel by default (--project repeatable narrows). A project +# without the `pay-as-you-go` owner.features flag never touches the billing host (NXDOMAIN on some +# non-PAYG stacks) -- it gets a per-project error_code PAYG_NOT_AVAILABLE instead; per-project +# failures degrade individually, the run never aborts. Rows report the API's native unit (credits) +# AND derived minutes (1 credit = 60 min, matching the Keboola UI). Purchase history / Stripe +# invoice IDs are NOT available here -- that data lives on connection.{stack} +# /pay-as-you-go/billing/*, which does not accept a Storage token (issue #594 primary ask, open). + # feature: requires a super-admin Manage API token (inline hidden prompt; never persisted; --allow-env-manage-token for CI). --project resolves the stack URL (+ project_id for project ops) from config. kbagent feature list --project ALIAS kbagent feature project-show --project ALIAS diff --git a/docs/error-codes.md b/docs/error-codes.md index e6d4fba0..0981a388 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -183,3 +183,9 @@ of `ErrorCode` in `src/keboola_agent_cli/errors.py`. | `SESSION_EXPIRED` | The programmatic-auth session's refresh token expired or was revoked; run `kbagent auth login` again | | `SESSION_NOT_FOUND` | No programmatic-auth session is persisted for this stack; run `kbagent auth login` | | `AUTH_MFA_INVALID` | `auth login-password` hit an MFA factor it cannot resolve without a browser (e.g. WebAuthn-only) -- use `kbagent auth login` for that account instead | + +### Billing (Pay-As-You-Go) + +| Code | Description | +|---|---| +| `PAYG_NOT_AVAILABLE` | The project does not have the `pay-as-you-go` feature, so it has no credit balance; the billing host may not even resolve on this stack | diff --git a/plugins/kbagent/.claude-plugin/plugin.json b/plugins/kbagent/.claude-plugin/plugin.json index ce767427..fce2a941 100644 --- a/plugins/kbagent/.claude-plugin/plugin.json +++ b/plugins/kbagent/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "kbagent", - "version": "0.84.1", + "version": "0.84.2", "description": "AI-friendly interface to Keboola Connection projects — explore configs, jobs, lineage, call MCP tools, manage dev branches, and debug SQL in workspaces", "author": { "name": "Keboola", diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index d47c380e..a61a2c90 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -15,7 +15,7 @@ description: > data stream, OTLP, scoped token, bucket sharing, encrypt secrets, feature flag, flow schedule, invite member, SQL transformation edit, sync action, keboola docs, table snapshot, auth, login, sign in, - browser login. + browser login, PAYG credits. --- # kbagent -- Keboola Agent CLI @@ -101,6 +101,7 @@ When working inside a git repository or project directory, run `kbagent init` (o | Mint a scoped Storage API token (secret shown once) | `kbagent token create --project PROJECT --description DESCRIPTION` | | Revoke a Storage API token immediately (destructive; only non-master tokens) | `kbagent token delete --project PROJECT --token-id TOKEN-ID` | | Rotate a token: generate a new value and invalidate the old one (secret shown once) | `kbagent token refresh --project PROJECT --token-id TOKEN-ID` | +| Show the current PAYG credit balance for one or more projects | `kbagent billing credits` | | List available components from connected projects | `kbagent component list` | | Show detailed information about a specific component | `kbagent component detail --component-id COMPONENT-ID` | | Run a synchronous component action such as testConnection | `kbagent component sync-action --component-id COMPONENT-ID --project PROJECT` | @@ -395,6 +396,7 @@ For detailed response parsing rules and common pitfalls, see [gotchas](reference | **Typify a typeless table** (profile -> CTAS -> swap-tables -> validate -> handoff) | [typify-table-workflow](references/typify-table-workflow.md) | | Bucket sharing & linking | [sharing-workflow](references/sharing-workflow.md) | | **Project members & invitations** (single + bulk via CSV, role change, remove) | [member-workflow](references/member-workflow.md) | +| **Billing / PAYG credits** (balance only; the shape of the invoice-history gap; PAYG_NOT_AVAILABLE; units) | [billing-workflow](references/billing-workflow.md) | | Dev branches | [branch-workflow](references/branch-workflow.md) | | Encrypting secrets for MCP tools | [encrypt-workflow](references/encrypt-workflow.md) | | Sync & Git-branching (GitOps) | [sync-workflow](references/sync-workflow.md) | diff --git a/plugins/kbagent/skills/kbagent/references/billing-workflow.md b/plugins/kbagent/skills/kbagent/references/billing-workflow.md new file mode 100644 index 00000000..5627b4a9 --- /dev/null +++ b/plugins/kbagent/skills/kbagent/references/billing-workflow.md @@ -0,0 +1,157 @@ +# Billing (PAYG Credits) workflow + +> Audience: a developer or a kbagent agent asked about Keboola PAYG (pay-as- +> you-go) usage, balance, or invoices. Goal: know exactly what `kbagent +> billing` can and cannot answer *before* burning a loop hunting for a +> command that does not exist. (since v0.84.2; issue +> https://github.com/keboola/cli/issues/594) + +## The shape of the gap (read this first) + +`kbagent billing` is **one command**: `billing credits`. It reports the +*current PAYG balance*, nothing more. There is **no** `billing history`, +`billing invoices`, or anything that returns `idStripeInvoice` — and there is +no way to build one from the CLI today. If a user asks to "reconcile our +Stripe invoices against Keboola projects" or "when did we last top up," the +correct answer is **that data is not reachable from kbagent** (see +[What is NOT reachable](#what-is-not-reachable-and-why) below) — not a +workaround, not a guess, and not a hand-rolled HTTP call. + +## What IS reachable today + +```bash +kbagent --json billing credits --project prod --project staging +``` + +```json +{ + "credits": [ + { + "project_alias": "prod", + "project_id": 9621, + "consumed": 12.5, + "remaining": 25.5, + "total": 38.0, + "consumed_minutes": 750.0, + "remaining_minutes": 1530.0, + "component_jobs_consumed": 11.75, + "workspace_jobs": [ + {"workspace_type": "sandbox-sql", "warehouse_size": "small", "consumed": 0.5}, + {"workspace_type": "writer", "warehouse_size": "small", "consumed": 0.25} + ] + }, + { + "project_alias": "staging", + "project_id": null, + "consumed": 0.0, + "remaining": 0.0, + "total": 0.0, + "consumed_minutes": 0.0, + "remaining_minutes": 0.0, + "component_jobs_consumed": 0.0, + "workspace_jobs": [] + } + ], + "errors": [] +} +``` + +- `--project ALIAS` is repeatable; omit it to fan out across every registered + project in parallel. +- `consumed` / `remaining` come straight off `GET /credits` on the + `billing.{stack}` host, which -- unlike the invoice endpoints below -- + **does** accept the CLI's normal per-project `X-StorageApi-Token`. No + manage token, no extra login step. +- `total` is a client-side convenience: `consumed + remaining`. It is NOT + named `purchased`, and must not be reported as an amount purchased. It + equals the purchased amount only if every credit ever added is either + still available or already consumed, so it silently mis-reports against + expired, revoked, or promo credits -- and credit purchases are precisely + what this command cannot see (see the gap section below). If a user asks + "how much have we bought", the honest answer is that the CLI cannot tell + them, not this number. +- Per-project failures land in `errors`, never abort the run -- always check + both arrays, not just `credits`. + +## The units trap + +The API speaks **credits**. The Keboola UI speaks **minutes**. The +conversion is fixed: **1 credit = 60 minutes**. Every row already carries +both — never hand-convert, and never convert in the other direction (minutes +-> credits) on a value that already came from the CLI. + +``` +consumed_minutes = consumed * 60 +remaining_minutes = remaining * 60 +``` + +Money is a separate axis again, and this is where it is easy to mislead a +user. The credits-to-minutes factor above is invariant; **the price per credit +is not**. It is contract- and stack-specific, the API never returns it, and the +CLI has no price field. Issue #594 records **$8.40 ex. VAT per credit** on one +contract — that is a single historical observation, NOT a platform constant. +Never quote it to a user as their rate, and never derive a dollar figure from +it. It appears here only to make the UI's arithmetic legible: on that contract, +8 credits rendered as "480 minutes ($67.20)". If a user needs the money number, +it comes from their contract or invoice, not from `billing credits`. + +## The PAYG gate + +Not every project is PAYG. Before `billing credits` ever calls the billing +host, it checks the `pay-as-you-go` flag in the project token's +`owner.features` (`GET /v2/storage/tokens/verify`). A project without that +flag gets a per-project entry: + +```json +{"project_alias": "legacy-project", "error_code": "PAYG_NOT_AVAILABLE", + "message": "Project 'legacy-project' does not have the pay-as-you-go feature enabled; PAYG balance is only available on PAYG projects."} +``` + +**This is a feature-flag verdict, not a network failure.** On a non-PAYG +stack (e.g. plain `eu-central-1`) the service index still *advertises* a +`billing.eu-central-1.keboola.com` host, but it does not resolve (NXDOMAIN) +— the feature check exists specifically so a non-PAYG project never dials +that host at all. Do not treat `PAYG_NOT_AVAILABLE` as something to retry or +debug as connectivity; it means "this project has no PAYG balance to show." + +## What is NOT reachable, and why + +Purchase history and `idStripeInvoice` live on `connection.{stack}` under +`/pay-as-you-go/billing/*` — a **completely different host and API surface** +from `billing.{stack}/credits`. That endpoint does not accept a Storage API +token: a request with `X-StorageApi-Token` gets the byte-identical +302-to-login response as an unauthenticated request, and presenting the +token as a bearer credential gets a plain 401. There is no +project-token-based path to it, so there is nothing for `kbagent billing` to +wrap. + +This is the **still-open primary ask of issue #594**: +https://github.com/keboola/cli/issues/594 — link it verbatim when a user +asks about invoice access so they can track the maintainer's answer. + +**Do not improvise a substitute.** In particular: + +- Do not attempt to reach `/pay-as-you-go/billing/*` with `kbagent http`, a + raw `httpx`/`curl` call, or by asking the user to paste a manage/session + token for it — none of those change what the endpoint accepts. +- Do not fall back to matching invoices to projects by `(date, amount)` + heuristics. It silently breaks the moment two projects top up the same + credit amount on the same day, and produces a wrong-but-confident answer + instead of an honest "not available." +- If asked to reconcile invoices, tell the user directly: kbagent can report + the current balance (`billing credits`) but not purchase history; that + reconciliation needs the Stripe/billing portal directly until #594 lands. + +## The money guardrail + +`POST /credits` on the billing service triggers a **real automatic top-up** +— actual money is charged. It is deliberately not wrapped by any `kbagent` +command, CLI or REST. Never reach for it, and never construct a raw HTTP +call to it (via `kbagent http`, a manual `httpx` request, or otherwise) even +if a user asks "just top up my credits" — that action requires going through +Keboola's own billing UI, not an agent-driven CLI. + +## Permission class + +`billing.credits` = read. Safe to run under `--deny-writes` / +`--deny-destructive`; it makes no mutating calls. diff --git a/plugins/kbagent/skills/kbagent/references/commands-reference.md b/plugins/kbagent/skills/kbagent/references/commands-reference.md index 23061c75..0b94abbe 100644 --- a/plugins/kbagent/skills/kbagent/references/commands-reference.md +++ b/plugins/kbagent/skills/kbagent/references/commands-reference.md @@ -102,6 +102,9 @@ The `permissions` subcommands persist a write/destructive policy to config.json - `org setup --org-id ID --url URL [--dry-run] [--yes]` -- bulk-onboard all projects from an org (org admin; manage token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) - `org setup --project-ids 1,2,3 --url URL [--dry-run] [--yes]` -- onboard specific projects by ID (any project member; manage token / Personal Access Token via interactive prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI on 0.29.0+) +## Billing (PAYG Credits) (since v0.84.2) +- `billing credits [--project ALIAS ...]` -- read-only PAYG credit balance (`GET /credits` on `billing.{stack}`, plain Storage token). Fans out across all registered projects in parallel by default; `--project` (repeatable) narrows. Per-project failures degrade individually and are collected in `errors`, never abort the run. A project without the `pay-as-you-go` `owner.features` flag never calls the billing host (NXDOMAIN on some non-PAYG stacks) -- it gets an `error_code: PAYG_NOT_AVAILABLE` entry instead. `--json` emits `{"credits": [...], "errors": [...]}`. Rows carry the API's native unit (`consumed`/`remaining` credits) plus derived `*_minutes` fields (1 credit = 60 minutes, matching the Keboola UI). Gives the current balance only -- purchase history / Stripe invoice IDs are not reachable with a project token (issue #594 primary ask, still open; that data lives on `connection.{stack}` `/pay-as-you-go/billing/*`). See [billing-workflow.md](billing-workflow.md) for the full shape of the invoice-history gap and why it must not be worked around. + ## Feature Flags (since v0.48.0) Requires a **super-admin** Manage API token (same kind as `org setup`). Same default-deny token policy: interactive hidden prompt by default, or `--allow-env-manage-token` + `KBC_MANAGE_API_TOKEN` for CI. `--project ALIAS` resolves the stack URL (and, for project ops, the numeric `project_id`) from config -- the alias is the only handle you pass. - `feature list --project ALIAS` -- the stack-wide feature catalogue (`GET /manage/features`). Returns `{alias, stack_url, features: [{name, title, description, type, ...}]}`. Only `name` is a stable identifier; extra fields pass through unmodified. diff --git a/plugins/kbagent/skills/kbagent/references/gotchas.md b/plugins/kbagent/skills/kbagent/references/gotchas.md index 9612ddfd..867e720b 100644 --- a/plugins/kbagent/skills/kbagent/references/gotchas.md +++ b/plugins/kbagent/skills/kbagent/references/gotchas.md @@ -3564,3 +3564,39 @@ machine's locale, and tolerates files that are not valid UTF-8. --value-file`, and `semantic-layer reference-data set --members-file` read UTF-8 without a fallback, so a genuinely mis-encoded input file fails loudly and identically everywhere rather than being silently mis-parsed. + +## `billing credits`: credits vs. minutes, array not object, feature-gated before it ever hits the network (since v0.84.2) + +`billing credits [--project ALIAS ...]` is a read-only PAYG (pay-as-you-go) +balance check (`GET /credits` on `billing.{stack}`, plain Storage token). +Four things a coding agent will otherwise get wrong: + +- **The API speaks credits; the Keboola UI speaks minutes.** `consumed` and + `remaining` in the raw response are in PAYG credits. The CLI derives + `consumed_minutes` / `remaining_minutes` as `credits * 60` + (`MINUTES_PER_CREDIT` in `constants.py`) because that is the unit the UI + actually displays. Always report the pre-computed `*_minutes` fields when a + user asks "how many minutes do I have left" -- do not hand-multiply the + credit fields yourself, and never assume the reverse conversion. +- **`stats.workspaceJobs` is an ARRAY, not the object shape the public + Keboola docs show.** The real payload is + `"workspaceJobs": [{"workspaceType": "sandbox-sql", "warehouseSize": + "small", "consumed": 5.0}, ...]` -- one entry per workspace type/size + combination, not a single object keyed by type. The CLI's `row.workspace_jobs` + is already this list, projected to snake_case; do not try to read it as a + dict. +- **`PAYG_NOT_AVAILABLE` is a feature-flag verdict, not a network failure.** + Before calling the billing host at all, the service checks the + `pay-as-you-go` flag in `owner.features` (`client.has_feature(PAYG_FEATURE)`). + A project without that flag never makes a billing-host request -- on some + stacks (e.g. a plain `eu-central-1` project) the `billing.{stack}` hostname + does not even resolve (NXDOMAIN). Seeing `error_code: PAYG_NOT_AVAILABLE` + in a project's `errors` entry means "this project isn't on PAYG," not "the + billing service is down." A genuine connection/DNS failure past the + feature gate is reported separately and mentions the host is unreachable. +- **Balance only -- no purchase history, no invoice IDs.** `billing credits` + cannot answer "when did we last top up" or "what's our Stripe invoice ID." + That data lives on `connection.{stack}` `/pay-as-you-go/billing/*`, which + does not accept a Storage API token -- it is the still-open primary ask of + issue #594. Do not imply this command covers billing/invoice history; tell + the user it is out of reach from the CLI today. diff --git a/pyproject.toml b/pyproject.toml index cb478a1a..7b867968 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "keboola-cli" -version = "0.84.1" +version = "0.84.2" description = "AI-friendly CLI for managing Keboola projects" readme = "README.md" requires-python = ">=3.12" diff --git a/src/keboola_agent_cli/changelog.py b/src/keboola_agent_cli/changelog.py index 64816248..36ffc154 100644 --- a/src/keboola_agent_cli/changelog.py +++ b/src/keboola_agent_cli/changelog.py @@ -24,6 +24,34 @@ # Ordered newest-first. Each value is a list of brief one-line descriptions. CHANGELOG: dict[str, list[str]] = { + "0.84.2": [ + "New (#594): `kbagent billing credits [--project ALIAS ...]` reads the Pay-As-You-Go " + "credit balance, fanned out across every registered project in parallel. Wraps `GET " + "/credits` on the `billing.{stack}` host, which accepts a plain per-project Storage " + "token -- the endpoint was reachable all along and simply had no command over it. Rows " + "carry the API's native unit (credits: `consumed` / `remaining` / derived `total`) " + "AND derived minutes, because the Keboola UI displays minutes = credits x 60; the " + "conversion only ever runs in that direction, so a unit bug cannot invert into a wrong " + "credit figure. The per-workspace breakdown is parsed tolerantly: the live API returns " + "`stats.workspaceJobs` as an ARRAY, not the object its public docs show. A project " + "without the `pay-as-you-go` flag in `owner.features` is gated out BEFORE any billing " + "request and reported as the new `PAYG_NOT_AVAILABLE` error code -- deliberately not a " + "mapped 4xx, because on non-PAYG stacks the service index advertises a `billing.` host " + "that does not resolve at all, so an ungated call would surface a DNS failure instead " + "of the real reason. Per-project failures degrade individually and never abort the " + "run. Mirrored on `kbagent serve` as `GET /billing/credits`. Read-only by design: the " + "billing service's `POST /credits` triggers a real automatic top-up (real money) and " + "is wrapped by nothing in kbagent.", + "Note (#594): credit PURCHASE history and the Stripe invoice IDs Keboola already " + "stores per project remain unreachable from the CLI. That data lives on " + "`connection.{stack}` under `/pay-as-you-go/billing/*`, which ignores " + "`X-StorageApi-Token` entirely (a Storage-token request returns the byte-identical " + "302-to-login as an unauthenticated one) and rejects it as a bearer with 401 -- so no " + "CLI, script, or scheduled agent can reach it, and anyone funding several PAYG " + 'projects on one billing identity still cannot answer "which project does this ' + 'Stripe invoice belong to" without matching on (date, amount). Issue #594 tracks the ' + "ask; `billing credits` is the half that was already implementable.", + ], "0.84.1": [ "Fix: `kbagent config new --push` schema validation now validates the body's " "`parameters` section instead of the whole configuration object (closes #587). A " diff --git a/src/keboola_agent_cli/cli.py b/src/keboola_agent_cli/cli.py index 94cb6a71..c72f43c0 100644 --- a/src/keboola_agent_cli/cli.py +++ b/src/keboola_agent_cli/cli.py @@ -8,6 +8,7 @@ from .commands.agent import agent_app from .commands.auth import auth_app +from .commands.billing import billing_app from .commands.branch import branch_app from .commands.changelog import changelog_command from .commands.component import component_app @@ -50,6 +51,7 @@ from .permissions import PermissionEngine from .services.agent_service import AgentService from .services.auth_service import AuthService +from .services.billing_service import BillingService from .services.branch_service import BranchService from .services.component_service import ComponentService from .services.config_service import ConfigService @@ -112,6 +114,7 @@ app.add_typer(org_app, name="org", rich_help_panel=_PROJ) app.add_typer(feature_app, name="feature", rich_help_panel=_PROJ) app.add_typer(token_app, name="token", rich_help_panel=_PROJ) +app.add_typer(billing_app, name="billing", rich_help_panel=_PROJ) # -- Browse & Inspect -- _BROWSE = "Browse & Inspect" @@ -355,6 +358,7 @@ def main( http_forwarder_service = HttpForwarderService() agent_service = AgentService(config_store=config_store, mcp_service=mcp_service) auth_service = AuthService(config_store=config_store) + billing_service = BillingService(config_store=config_store) try: config = config_store.load() @@ -414,6 +418,7 @@ def main( ctx.obj["http_forwarder_service"] = http_forwarder_service ctx.obj["agent_service"] = agent_service ctx.obj["auth_service"] = auth_service + ctx.obj["billing_service"] = billing_service # Warn if empty local config shadows global with projects (#104) if source == "local" and not json_output and ctx.invoked_subcommand != "init": diff --git a/src/keboola_agent_cli/client/_client.py b/src/keboola_agent_cli/client/_client.py index 7a64f716..ce753950 100644 --- a/src/keboola_agent_cli/client/_client.py +++ b/src/keboola_agent_cli/client/_client.py @@ -2,10 +2,10 @@ ``KeboolaClient`` is assembled here from the per-family mixins (storage tables, storage files, configs, queue, tokens, branches, stream, query, workspaces, -misc) over the shared ``_CoreClient`` plumbing base. It stays a single class -exposing every Storage/Queue method at its original signature, so -``keboola_agent_cli.Client`` and its ``.raw`` accessor are unaffected by the -split of the former single-file ``client.py`` into a package (issue #520). +billing, misc) over the shared ``_CoreClient`` plumbing base. It stays a +single class exposing every Storage/Queue method at its original signature, +so ``keboola_agent_cli.Client`` and its ``.raw`` accessor are unaffected by +the split of the former single-file ``client.py`` into a package (issue #520). Inherits shared retry/error logic from BaseHttpClient (via _CoreClient). """ @@ -13,6 +13,7 @@ import httpx from ._core import _CoreClient +from .billing import _BillingMixin from .branches import _BranchesMixin from .configs import _ConfigsMixin from .misc import _MiscMixin @@ -35,6 +36,7 @@ class KeboolaClient( _StreamMixin, _QueryMixin, _WorkspacesMixin, + _BillingMixin, _MiscMixin, _CoreClient, ): diff --git a/src/keboola_agent_cli/client/_core.py b/src/keboola_agent_cli/client/_core.py index 8d09565d..8c38774c 100644 --- a/src/keboola_agent_cli/client/_core.py +++ b/src/keboola_agent_cli/client/_core.py @@ -45,6 +45,7 @@ def __init__(self, stack_url: str, token: str, *, http_auth: httpx.Auth | None = self._query_client: httpx.Client | None = None self._encrypt_client: httpx.Client | None = None self._sync_actions_client: httpx.Client | None = None + self._billing_client: httpx.Client | None = None # Lazily built on first Data Streams call (per-device OTLP sources); the # Stream control plane is a sibling host reachable from this stack+token. self._stream_client: StreamClient | None = None @@ -71,6 +72,10 @@ def _encrypt_base_url(self) -> str: def _sync_actions_base_url(self) -> str: return self._derive_service_url(self._stack_url, "sync-actions") + @property + def _billing_base_url(self) -> str: + return self._derive_service_url(self._stack_url, "billing") + def close(self) -> None: """Close the underlying HTTP clients.""" super().close() @@ -82,6 +87,8 @@ def close(self) -> None: self._encrypt_client.close() if self._sync_actions_client is not None: self._sync_actions_client.close() + if self._billing_client is not None: + self._billing_client.close() if self._stream_client is not None: self._stream_client.close() @@ -157,6 +164,30 @@ def _sync_actions_request(self, method: str, path: str, **kwargs: Any) -> httpx. method, path, client=client, base_url=self._sync_actions_base_url, **kwargs ) + def _billing_get(self, path: str, **kwargs: Any) -> httpx.Response: + """Execute a read-only Billing API request with retry. + + The billing service is a sibling host derived from the stack URL + (``billing.{stack-suffix}``); the sub-client inherits the main + client's headers, so the ``X-StorageApi-Token`` auth carries over. On + stacks without Pay-As-You-Go the host may not resolve at all (DNS + failure) -- callers should feature-gate with ``has_feature()`` before + reaching this method rather than relying on the resulting error. + + Deliberately NOT shaped like its ``_queue_request`` / + ``_sync_actions_request`` siblings, which take an arbitrary ``method``: + the billing service exposes ``POST /credits``, which charges real money + by triggering an automatic top-up. Hardcoding the verb here means a + future caller cannot construct that request through this dispatcher at + all -- a guarantee in the signature, which no source-scanning test can + match. If a write to the billing service is ever wanted, it needs its + own method, its own review, and its own confirmation flow. + """ + client = self._get_or_create_sub_client("_billing_client", self._billing_base_url) + return self._do_request( + "GET", path, client=client, base_url=self._billing_base_url, **kwargs + ) + def _wait_for_storage_job( self, job: dict[str, Any], diff --git a/src/keboola_agent_cli/client/billing.py b/src/keboola_agent_cli/client/billing.py new file mode 100644 index 00000000..5f44fdb6 --- /dev/null +++ b/src/keboola_agent_cli/client/billing.py @@ -0,0 +1,29 @@ +"""Pay-As-You-Go credit balance -- GET /credits on the billing service. + +New for issue #594. The billing service also exposes ``POST /credits``, which +triggers a REAL automatic top-up (real money charged to the project). That +endpoint is deliberately NOT wrapped anywhere in this mixin -- kbagent's +billing surface is read-only by design; see the module-level guardrail in +``services/billing_service.py`` for the feature-gate that keeps non-PAYG +projects off this host entirely. +""" + +from typing import Any + +from ._core import _CoreClient + + +class _BillingMixin(_CoreClient): + """Pay-As-You-Go credit balance -- GET /credits on the billing service.""" + + def get_credits(self) -> dict[str, Any]: + """Fetch the project's PAYG credit balance. + + GETs ``/credits`` on the ``billing.{stack-suffix}`` host and returns + the raw JSON dict verbatim (no shaping here -- see + ``ProjectCredits`` in ``models.py`` for the tolerant parse used by + the service layer). Read-only: this is the only method this mixin + exposes, by design -- see the module docstring. + """ + response = self._billing_get("/credits") + return response.json() diff --git a/src/keboola_agent_cli/commands/billing.py b/src/keboola_agent_cli/commands/billing.py new file mode 100644 index 00000000..560f161d --- /dev/null +++ b/src/keboola_agent_cli/commands/billing.py @@ -0,0 +1,110 @@ +"""Pay-As-You-Go (PAYG) credit balance commands. + +Thin CLI layer over :class:`BillingService`. One subcommand: + +- ``billing credits`` -- current credit balance (consumed/remaining/total) + across one or more projects, sourced from ``GET /credits`` on the + ``billing.`` host. + +This surfaces the BALANCE ONLY. Purchase history and Stripe invoice IDs live +on ``connection.`` `/pay-as-you-go/billing/*`, which does not accept a +plain project Storage token -- that surface is out of scope for issue #594 +and is not exposed here. + +Read-only: safe under ``--deny-writes``. +""" + +from __future__ import annotations + +from typing import Any + +import typer +from rich.markup import escape +from rich.table import Table + +from ..errors import ConfigError, ErrorCode +from ._helpers import check_cli_permission, get_formatter, get_service + +billing_app = typer.Typer( + help="PAYG credit balance across projects (issue #594). Balance only -- " + "purchase history / Stripe invoice IDs are not reachable with a " + "project token and are not exposed by this command." +) + + +@billing_app.callback(invoke_without_command=True) +def _billing_permission_check(ctx: typer.Context) -> None: + check_cli_permission(ctx, "billing") + + +def _format_credits_table(formatter: Any, credits: list[dict[str, Any]]) -> None: + tbl = Table( + "Project", + "Remaining", + "Consumed", + "Total", + "Remaining (min)", + show_header=True, + header_style="bold cyan", + ) + for row in credits: + tbl.add_row( + escape(row.get("project_alias", "")), + f"{row.get('remaining', 0.0):.2f}", + f"{row.get('consumed', 0.0):.2f}", + f"{row.get('total', 0.0):.2f}", + f"{row.get('remaining_minutes', 0.0):.0f}", + ) + formatter.console.print(tbl) + + +def _emit_errors(formatter: Any, errors: list[dict[str, Any]]) -> None: + for err in errors: + formatter.warning( + f"Project '{escape(str(err.get('project_alias', '?')))}': " + f"{escape(str(err.get('message', 'error')))}" + ) + + +@billing_app.command("credits") +def billing_credits( + ctx: typer.Context, + project: list[str] | None = typer.Option( + None, + "--project", + help="Project alias (repeatable; omit for all registered projects)", + ), +) -> None: + """Show the current PAYG credit balance for one or more projects. + + Balance only -- consumed, remaining, and total (a client-side + `consumed + remaining`, NOT a purchase figure: credit purchases are + exactly what this command cannot see), plus the balance expressed in + minutes (the Keboola UI's unit: minutes = credits * 60). A project + without the `pay-as-you-go` feature flag surfaces as a per-project + warning (`PAYG_NOT_AVAILABLE`), not a hard failure -- one non-PAYG + project in a multi-project run never blocks the others. + + Purchase history and Stripe invoice IDs are NOT available here: that + data lives on `connection.` `/pay-as-you-go/billing/*`, which + does not accept a plain project Storage API token (issue #594). + """ + formatter = get_formatter(ctx) + service = get_service(ctx, "billing_service") + + try: + result = service.get_credits(aliases=project) + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + if formatter.json_mode: + formatter.output(result) + return + + credits = result.get("credits", []) + if not credits: + formatter.console.print("[dim]No PAYG projects found.[/dim]") + else: + _format_credits_table(formatter, credits) + _emit_errors(formatter, result.get("errors", [])) diff --git a/src/keboola_agent_cli/commands/context.py b/src/keboola_agent_cli/commands/context.py index 2b15be31..6125cded 100644 --- a/src/keboola_agent_cli/commands/context.py +++ b/src/keboola_agent_cli/commands/context.py @@ -833,6 +833,25 @@ Default-deny since 0.29.0 -- closes the AI-exfiltration risk where subprocesses inherit the manage token via env. +### Billing / PAYG Credits (since v0.84.2) + + kbagent billing credits [--project ALIAS ...] + Read-only PAYG (pay-as-you-go) credit balance. Fans out across all + registered projects in parallel by default; --project (repeatable) + narrows to specific aliases. Per-project failures degrade individually + and never abort the run -- check the "errors" array in --json output. + A project without the `pay-as-you-go` owner.features flag never touches + the billing host (it can be NXDOMAIN on non-PAYG stacks) -- it gets a + per-project error entry with error_code PAYG_NOT_AVAILABLE instead of a + generic network error. + Units: the API speaks credits; rows also carry derived minutes + (1 credit = 60 minutes, matching what the Keboola UI displays) -- never + hand-convert, use the minutes fields already in the row. + This command gives the CURRENT BALANCE only. Purchase history / Stripe + invoice IDs are NOT available -- that data lives on connection.{{stack}} + /pay-as-you-go/billing/*, which does not accept a Storage token + (issue #594, still open). Do not imply invoices are retrievable. + ### Feature Flags (since v0.48.0) Requires a SUPER-ADMIN Manage API token (same kind as `org setup`). Same diff --git a/src/keboola_agent_cli/constants.py b/src/keboola_agent_cli/constants.py index a1d08041..f91704f0 100644 --- a/src/keboola_agent_cli/constants.py +++ b/src/keboola_agent_cli/constants.py @@ -484,6 +484,21 @@ def _resolve_app_name() -> str: KAI_STREAM_TIMEOUT: float = 600.0 # 10 min for SSE streaming responses SECRET_PLACEHOLDER: str = "" +# --- Billing / Pay-As-You-Go (issue #594) --- +# The billing service (GET /credits on the `billing.` host) +# speaks CREDITS natively; the Keboola UI displays MINUTES = credits * 60. +# kbagent surfaces the API's native unit and DERIVES minutes from it (never +# the reverse) so a client-side unit bug can never invert into a wrong +# credits figure. +MINUTES_PER_CREDIT: int = 60 +# `owner.features` flag from `GET /v2/storage/tokens/verify` that gates +# whether a project has a PAYG credit balance at all. Projects without it may +# resolve to a billing host that does not exist on that stack (e.g. +# `billing.eu-central-1.keboola.com` is NXDOMAIN) -- checking this flag FIRST +# lets the billing service return a clear PAYG_NOT_AVAILABLE error instead of +# an opaque connection/DNS failure. +PAYG_FEATURE: str = "pay-as-you-go" + # --- Changelog rendering --- # `kbagent changelog` shows a one-line summary per version by default (--full # expands). A summary is the note's first sentence, capped at this many chars diff --git a/src/keboola_agent_cli/errors.py b/src/keboola_agent_cli/errors.py index 2b6622a1..f499a0f1 100644 --- a/src/keboola_agent_cli/errors.py +++ b/src/keboola_agent_cli/errors.py @@ -140,6 +140,9 @@ class ErrorCode(StrEnum): # Password-grant login (since 0.81.0) AUTH_MFA_INVALID = "AUTH_MFA_INVALID" + # Billing / Pay-As-You-Go (since #594) + PAYG_NOT_AVAILABLE = "PAYG_NOT_AVAILABLE" + def mask_token(token: str) -> str: """Mask a Keboola Storage API token for safe display. @@ -321,6 +324,13 @@ def __init__(self, feature: str, *, remedy: str = "") -> None: ErrorCode.SESSION_EXPIRED: "authentication", ErrorCode.SESSION_NOT_FOUND: "authentication", ErrorCode.AUTH_MFA_INVALID: "authentication", + # Not reachable from today's only emitter: `BillingService` puts + # PAYG_NOT_AVAILABLE in its per-project `errors` list, which renders via + # `formatter.warning()` and never passes through `formatter.error()`. + # Classified anyway so the first single-project billing command to raise + # it inherits the right category instead of silently taking the "api" + # default -- a missing project feature is a configuration problem. + ErrorCode.PAYG_NOT_AVAILABLE: "configuration", } diff --git a/src/keboola_agent_cli/models.py b/src/keboola_agent_cli/models.py index 326a41a4..3d9de56c 100644 --- a/src/keboola_agent_cli/models.py +++ b/src/keboola_agent_cli/models.py @@ -466,3 +466,62 @@ class BulkInviteResult(BaseModel): failed: int rows: list[MemberInviteRow] = Field(default_factory=list) dry_run: bool = Field(default=False) + + +class WorkspaceJobCredits(BaseModel): + """One workspace-type breakdown row from `GET /credits` on the billing service. + + Deliberately tolerant (`extra="allow"`, every field optional/defaulted): + the publicly documented shape of `stats.workspaceJobs` is an OBJECT, but + the live API (verified against north-europe.azure) returns an ARRAY of + these rows instead. A strict model built against the docs would raise on + every real payload, so nothing here is required. + """ + + workspace_type: str | None = Field(default=None, alias="workspaceType") + warehouse_size: str | None = Field(default=None, alias="warehouseSize") + consumed: float = Field(default=0.0) + + model_config = {"populate_by_name": True, "extra": "allow"} + + +class ComponentJobCredits(BaseModel): + """`stats.componentJobs` breakdown from `GET /credits` -- extras pass through.""" + + consumed: float = Field(default=0.0) + + model_config = {"extra": "allow"} + + +class CreditStats(BaseModel): + """`stats` block from `GET /credits`. + + See `WorkspaceJobCredits` for why `workspace_jobs` must stay a tolerant + list default rather than a required field: the API's real shape does not + match its own public docs, and `stats` itself, or `workspaceJobs` within + it, can be absent entirely on a project with no workspace usage yet. + """ + + component_jobs: ComponentJobCredits | None = Field(default=None, alias="componentJobs") + workspace_jobs: list[WorkspaceJobCredits] = Field(default_factory=list, alias="workspaceJobs") + + model_config = {"populate_by_name": True, "extra": "allow"} + + +class ProjectCredits(BaseModel): + """`GET /credits` response from the billing service (`billing.` host). + + `stats` defaults to `None` because the top-level `consumed`/`remaining` + balance is meaningful on its own -- a caller that only needs the balance + should not be forced through a populated breakdown. Combined with the + tolerance built into `CreditStats`/`WorkspaceJobCredits`, this model + parses the verbatim live payload as well as a minimal + `{"consumed": ..., "remaining": ...}` response with no `stats` key at all, + without ever raising on either. + """ + + consumed: float = Field(default=0.0) + remaining: float = Field(default=0.0) + stats: CreditStats | None = Field(default=None) + + model_config = {"extra": "allow"} diff --git a/src/keboola_agent_cli/permissions.py b/src/keboola_agent_cli/permissions.py index 3f376f5b..a74c3fc8 100644 --- a/src/keboola_agent_cli/permissions.py +++ b/src/keboola_agent_cli/permissions.py @@ -342,6 +342,8 @@ "schedule.list": "read", "schedule.detail": "read", "schedule.find": "read", + # PAYG credit balance (issue #594) -- read-only, GET /credits only. + "billing.credits": "read", # Top-level commands "search": "read", "init": "admin", diff --git a/src/keboola_agent_cli/server/app.py b/src/keboola_agent_cli/server/app.py index bbae675a..4d7e1080 100644 --- a/src/keboola_agent_cli/server/app.py +++ b/src/keboola_agent_cli/server/app.py @@ -34,6 +34,7 @@ from .routers import ( agents, ai_chat, + billing, branches, components, configs, @@ -116,6 +117,16 @@ "Mirrors `kbagent feature list|project-*|user-*`." ), }, + { + "name": "billing", + "description": ( + "**Project Management.** " + "PAYG credit balance across projects (read-only). Purchase " + "history / Stripe invoice IDs are not reachable with a " + "project token. " + "Mirrors `kbagent billing credits`." + ), + }, # ---- Configurations ---- { "name": "configs", @@ -671,6 +682,7 @@ async def _generic_handler(_request, exc: Exception): app.include_router(projects.router) app.include_router(members.router) app.include_router(feature.router) + app.include_router(billing.router) app.include_router(configs.router) app.include_router(components.router) app.include_router(storage.router) diff --git a/src/keboola_agent_cli/server/dependencies.py b/src/keboola_agent_cli/server/dependencies.py index ca839da3..4d52a060 100644 --- a/src/keboola_agent_cli/server/dependencies.py +++ b/src/keboola_agent_cli/server/dependencies.py @@ -15,6 +15,7 @@ from ..config_store import ConfigStore from ..dev_portal_client import DeveloperPortalClient +from ..services.billing_service import BillingService from ..services.branch_service import BranchService from ..services.component_service import ComponentService from ..services.config_service import ConfigService @@ -128,6 +129,7 @@ class ServiceRegistry: token: TokenService = field(init=False) docs: DocsService = field(init=False) transformation: TransformationService = field(init=False) + billing: BillingService = field(init=False) def __post_init__(self) -> None: cs = self.config_store @@ -170,6 +172,7 @@ def __post_init__(self) -> None: self.token = TokenService(config_store=cs) self.docs = DocsService(config_store=cs) self.transformation = TransformationService(config_store=cs) + self.billing = BillingService(config_store=cs) def install_registry(app: FastAPI, registry: ServiceRegistry) -> None: diff --git a/src/keboola_agent_cli/server/routers/billing.py b/src/keboola_agent_cli/server/routers/billing.py new file mode 100644 index 00000000..c58b79a9 --- /dev/null +++ b/src/keboola_agent_cli/server/routers/billing.py @@ -0,0 +1,32 @@ +"""PAYG billing endpoints (credit balance). + +Read-only by design: the upstream billing service on ``billing.{stack}`` +exposes a ``POST /credits`` that triggers a REAL automatic top-up (real +money). This router -- and the client/service layers it delegates to -- +only ever issue GET requests. Do not add a write endpoint here. +""" + +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Query + +from ..dependencies import ServiceRegistry, get_registry + +router = APIRouter(prefix="/billing", tags=["billing"]) + + +@router.get("/credits", summary="PAYG credit balance across projects") +def credits( + project: list[str] | None = Query(None), + registry: ServiceRegistry = Depends(get_registry), +) -> dict[str, Any]: + """PAYG credit balance per project. Mirrors `kbagent billing credits`. + + Balance only -- purchase history and Stripe invoice IDs are not + reachable with a project token (issue #594) and have no endpoint here. + Read-only: the upstream billing service's `POST /credits` performs a + real-money automatic top-up and is deliberately not exposed. + """ + return registry.billing.get_credits(aliases=project) diff --git a/src/keboola_agent_cli/services/billing_service.py b/src/keboola_agent_cli/services/billing_service.py new file mode 100644 index 00000000..14a65a2c --- /dev/null +++ b/src/keboola_agent_cli/services/billing_service.py @@ -0,0 +1,178 @@ +"""Pay-As-You-Go credit balance discovery across one or many projects. + +Wraps ``KeboolaClient.get_credits`` (``GET /credits`` on the derived +``billing.{stack}`` host) with the same fan-out / per-project-error shape +every other multi-project service uses (see ``ScheduleService`` in +``schedule_service.py`` for the idiom this mirrors). + +MONEY GUARDRAIL: the billing service also exposes ``POST /credits``, which +triggers a REAL automatic top-up (real money charged to the project). This +service is **read-only by design** -- it only ever calls +``client.get_credits()`` (a GET). No method here issues, or ever should +issue, a POST to the billing host. See ``client/billing.py`` for the mixin +that enforces the same restriction one layer down. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from ..constants import MINUTES_PER_CREDIT, PAYG_FEATURE +from ..errors import ErrorCode, KeboolaApiError +from ..models import ProjectConfig, ProjectCredits +from .base import BaseService, project_error_entry + +logger = logging.getLogger(__name__) + +# KeboolaApiError codes that mean "we never got a real answer from the +# billing host" -- worth a friendlier message than the raw httpx error, +# because on a stack without PAYG the derived `billing.` host can be +# NXDOMAIN (see the has_feature gate below, which is what normally keeps a +# non-PAYG project from ever reaching this branch at all). +_UNREACHABLE_ERROR_CODES = frozenset( + {ErrorCode.CONNECTION_ERROR, ErrorCode.TIMEOUT, ErrorCode.RETRY_EXHAUSTED} +) + + +def _build_credit_row( + alias: str, project: ProjectConfig, credits_: ProjectCredits +) -> dict[str, Any]: + """Project a parsed ``ProjectCredits`` payload into the CLI-facing row. + + Derives minutes from credits (never the reverse -- the API's native unit + is credits) and ``total`` as ``consumed + remaining``. + + ``total`` is deliberately NOT called ``purchased``. It is a client-side + sum over the CURRENT balance, so it equals the amount purchased only if + every credit ever added is either still available or already consumed -- + it would silently mis-report against expired, revoked, or promo credits. + Naming it ``purchased`` would be actively misleading here, because credit + PURCHASES are the one thing this command cannot see: those records live + behind ``/pay-as-you-go/billing/*``, which no project token can reach + (issue #594). Anyone reconciling against Stripe invoices must not treat + this figure as a purchase total. + """ + stats = credits_.stats + component_jobs_consumed = ( + stats.component_jobs.consumed if stats and stats.component_jobs else 0.0 + ) + workspace_jobs = [ + { + "workspace_type": job.workspace_type, + "warehouse_size": job.warehouse_size, + "consumed": job.consumed, + } + for job in (stats.workspace_jobs if stats else []) + ] + + return { + "project_alias": alias, + "project_id": project.project_id, + "consumed": credits_.consumed, + "remaining": credits_.remaining, + "total": credits_.consumed + credits_.remaining, + "consumed_minutes": credits_.consumed * MINUTES_PER_CREDIT, + "remaining_minutes": credits_.remaining * MINUTES_PER_CREDIT, + "component_jobs_consumed": component_jobs_consumed, + "workspace_jobs": workspace_jobs, + } + + +class BillingService(BaseService): + """Fleet-wide PAYG credit balance lookup. + + Read-only by construction -- see the module docstring's money guardrail. + Like the other multi-project services, per-project failures degrade + individually into the ``errors`` list rather than aborting the whole + fan-out. + """ + + def get_credits(self, aliases: list[str] | None = None) -> dict[str, Any]: + """Fetch the PAYG credit balance for one, many, or all projects. + + Args: + aliases: Project aliases to query. ``None`` / empty means every + registered project. + + Returns: + ``{"credits": [row, ...], "errors": [entry, ...]}``, both sorted + by ``project_alias`` for deterministic output. Each row has + ``project_alias``, ``project_id``, ``consumed``, ``remaining``, + ``total``, ``consumed_minutes``, ``remaining_minutes``, + ``component_jobs_consumed``, ``workspace_jobs``. + """ + projects = self.resolve_projects(aliases) + + def worker(alias: str, project: ProjectConfig) -> tuple[Any, ...]: + return self._fetch_project_credits(alias, project) + + successes, errors = self._run_parallel(projects, worker) + + credits_rows = [result[1] for result in successes] + credits_rows.sort(key=lambda row: row.get("project_alias", "")) + errors.sort(key=lambda e: e.get("project_alias", "")) + + return {"credits": credits_rows, "errors": errors} + + def _fetch_project_credits(self, alias: str, project: ProjectConfig) -> tuple[Any, ...]: + """Fetch + shape the PAYG balance for a single project. + + Order matters here, in this exact sequence: + + 1. Build the client. + 2. Feature-gate with ``client.has_feature(PAYG_FEATURE)`` BEFORE any + billing call. This is NOT a fallback triggered by a billing 4xx -- + it runs first, unconditionally. On a stack without PAYG, the + Storage API's own service index still advertises a + ``billing.`` host entry that simply does not resolve + (NXDOMAIN); calling it anyway would surface an opaque DNS/connect + failure instead of the actual, actionable reason ("this project + doesn't have PAYG"). Checking the feature flag first turns that + into a clear ``PAYG_NOT_AVAILABLE`` error before the network call + is ever attempted. + 3. ``client.get_credits()``, parsed through ``ProjectCredits`` + (tolerant model -- see ``models.py``) and projected into the row. + + A connection/DNS failure that still slips through step 3 (e.g. a + transient host that resolves but refuses the connection) is + re-worded to say the billing host is unreachable on this stack, + rather than surfacing a bare httpx/stack-trace message. + """ + client = self._client_factory(project.stack_url, project.token) + try: + if not client.has_feature(PAYG_FEATURE): + return ( + alias, + { + "project_alias": alias, + "error_code": str(ErrorCode.PAYG_NOT_AVAILABLE), + "message": ( + f"Project does not have the '{PAYG_FEATURE}' feature enabled. " + "PAYG credit balances only exist for pay-as-you-go projects; " + "ask a Keboola admin to enable it if this project should have one." + ), + }, + ) + + raw = client.get_credits() + parsed = ProjectCredits.model_validate(raw) + row = _build_credit_row(alias, project, parsed) + return (alias, row, True) + except KeboolaApiError as exc: + message = exc.message + if exc.error_code in _UNREACHABLE_ERROR_CODES: + message = ( + f"Could not reach the billing service on stack {project.stack_url!r}; " + f"the PAYG credit balance is unavailable right now. Original error: {exc.message}" + ) + logger.debug("get_credits failed for project '%s': %s", alias, exc) + return (alias, project_error_entry(alias, exc, message=message)) + except Exception as exc: + logger.debug("Unexpected error fetching credits for project '%s': %s", alias, exc) + return (alias, project_error_entry(alias, exc)) + finally: + client.close() + + +__all__ = ["BillingService"] diff --git a/tests/test_billing_cli.py b/tests/test_billing_cli.py new file mode 100644 index 00000000..7f3f07eb --- /dev/null +++ b/tests/test_billing_cli.py @@ -0,0 +1,155 @@ +"""Tests for `kbagent billing credits` via CliRunner. + +Mirrors tests/test_schedule_cli.py's structure: patch ConfigStore + the +service class used inside `keboola_agent_cli.cli`, invoke through the real +Typer app, and assert on JSON envelope / human-mode rendering / exit codes. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +from typer.testing import CliRunner + +from keboola_agent_cli.cli import app +from keboola_agent_cli.config_store import ConfigStore +from keboola_agent_cli.errors import ConfigError +from keboola_agent_cli.models import ProjectConfig + +runner = CliRunner() +TEST_TOKEN = "999-token-abc" + + +def _setup_config(config_dir: Path, projects: dict[str, dict] | None = None) -> ConfigStore: + store = ConfigStore(config_dir=config_dir) + if projects: + for alias, info in projects.items(): + store.add_project( + alias, + ProjectConfig( + stack_url=info.get("stack_url", "https://connection.keboola.com"), + token=info.get("token", TEST_TOKEN), + project_name=info.get("project_name", alias), + project_id=info.get("project_id", 1234), + ), + ) + return store + + +def _run(args: list[str], store: ConfigStore, mock_service: MagicMock) -> Any: + with ( + patch("keboola_agent_cli.cli.ConfigStore") as MockStore, + patch("keboola_agent_cli.cli.BillingService") as MockBS, + ): + MockStore.return_value = store + MockBS.return_value = mock_service + return runner.invoke(app, args) + + +def _credit_row(alias: str = "prod") -> dict[str, Any]: + return { + "project_alias": alias, + "project_id": 1234, + "consumed": 100.5, + "remaining": 25.5, + "total": 126.0, + "consumed_minutes": 6030.0, + "remaining_minutes": 1530.0, + "component_jobs_consumed": 95.25, + "workspace_jobs": [ + {"workspace_type": "sandbox-sql", "warehouse_size": "small", "consumed": 5.0}, + ], + } + + +class TestBillingCreditsCli: + def test_json_output_emits_envelope_verbatim(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + mock_service = MagicMock() + envelope = {"credits": [_credit_row()], "errors": []} + mock_service.get_credits.return_value = envelope + result = _run(["--json", "billing", "credits", "--project", "prod"], store, mock_service) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["data"] == envelope + + def test_human_mode_renders_table_with_balance_and_minutes(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = {"credits": [_credit_row()], "errors": []} + result = _run(["billing", "credits", "--project", "prod"], store, mock_service) + assert result.exit_code == 0, result.output + assert "prod" in result.output + assert "25.50" in result.output # remaining + assert "100.50" in result.output # consumed + assert "126.00" in result.output # total + assert "1530" in result.output # remaining minutes + + def test_project_flag_repeatable_forwarded_as_aliases(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"a": {}, "b": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = {"credits": [], "errors": []} + result = _run( + ["--json", "billing", "credits", "--project", "a", "--project", "b"], + store, + mock_service, + ) + assert result.exit_code == 0, result.output + mock_service.get_credits.assert_called_once_with(aliases=["a", "b"]) + + def test_no_project_flag_forwards_none(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"a": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = {"credits": [], "errors": []} + result = _run(["--json", "billing", "credits"], store, mock_service) + assert result.exit_code == 0, result.output + mock_service.get_credits.assert_called_once_with(aliases=None) + + def test_per_project_errors_surface_as_warnings_exit_0(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"a": {}, "b": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = { + "credits": [_credit_row("a")], + "errors": [ + { + "project_alias": "b", + "error_code": "PAYG_NOT_AVAILABLE", + "message": "Project does not have the 'pay-as-you-go' feature enabled.", + } + ], + } + result = _run(["billing", "credits"], store, mock_service) + assert result.exit_code == 0, result.output + assert "b" in result.output + assert "PAYG_NOT_AVAILABLE" in result.output or "pay-as-you-go" in result.output + + def test_empty_result_prints_no_payg_projects_line(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = {"credits": [], "errors": []} + result = _run(["billing", "credits", "--project", "prod"], store, mock_service) + assert result.exit_code == 0, result.output + assert "No PAYG projects found." in result.output + + def test_config_error_exits_5(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + mock_service = MagicMock() + mock_service.get_credits.side_effect = ConfigError("No projects") + result = _run(["--json", "billing", "credits"], store, mock_service) + assert result.exit_code == 5 + + def test_deny_writes_still_permits_read(self, tmp_path: Path) -> None: + store = _setup_config(tmp_path / "cfg", {"prod": {}}) + mock_service = MagicMock() + mock_service.get_credits.return_value = {"credits": [_credit_row()], "errors": []} + result = _run( + ["--deny-writes", "--json", "billing", "credits", "--project", "prod"], + store, + mock_service, + ) + assert result.exit_code == 0, result.output + data = json.loads(result.output) + assert data["data"]["credits"][0]["project_alias"] == "prod" diff --git a/tests/test_billing_client.py b/tests/test_billing_client.py new file mode 100644 index 00000000..98fd9215 --- /dev/null +++ b/tests/test_billing_client.py @@ -0,0 +1,247 @@ +"""Tests for the billing (PAYG credits) L3 client -- issue #594. + +Covers: +- Host derivation: connection. -> billing., mirroring the + sync-actions / stream sibling-host pattern (test_component_sync_action.py, + test_stream_client.py). +- `KeboolaClient.get_credits()` issuing a GET to the derived billing host and + returning the parsed JSON verbatim. +- The verbatim live payload from the billing contract round-tripping through + `ProjectCredits`, including `stats.workspaceJobs` arriving as a LIST (the + live API's actual shape, which diverges from the public docs' object shape). +- `ProjectCredits` tolerance: missing `stats`, `stats.workspaceJobs` absent, + and unknown extra top-level keys must never raise. +- A regression guard on the money rule: `POST /credits` triggers a REAL + automatic top-up, so the mixin must expose no public method that could + issue a POST to the billing host. +""" + +from __future__ import annotations + +import inspect + +import pytest + +from keboola_agent_cli.client import KeboolaClient +from keboola_agent_cli.client._core import _CoreClient +from keboola_agent_cli.client.billing import _BillingMixin +from keboola_agent_cli.models import ( + CreditStats, + ProjectCredits, + WorkspaceJobCredits, +) + +TEST_TOKEN = "901-55555-fakeTestTokenDoNotUseXXXXXXXX" +STACK_URL = "https://connection.north-europe.azure.keboola.com" +BILLING_URL = "https://billing.north-europe.azure.keboola.com" + +# Verbatim live payload from the billing contract (north-europe.azure). +LIVE_PAYLOAD = { + "consumed": 100.5, + "stats": { + "componentJobs": {"consumed": 95.25}, + "workspaceJobs": [ + {"workspaceType": "sandbox-sql", "warehouseSize": "small", "consumed": 5.0}, + {"workspaceType": "writer", "warehouseSize": "small", "consumed": 0.25}, + ], + }, + "remaining": 25.5, +} + + +class TestDeriveBillingUrl: + """The control-plane base URL is connection. -> billing..""" + + def test_north_europe_azure_stack(self) -> None: + assert KeboolaClient._derive_service_url(STACK_URL, "billing") == BILLING_URL + + def test_us_stack(self) -> None: + assert ( + KeboolaClient._derive_service_url("https://connection.keboola.com", "billing") + == "https://billing.keboola.com" + ) + + def test_gcp_stack(self) -> None: + assert ( + KeboolaClient._derive_service_url( + "https://connection.us-east4.gcp.keboola.com", "billing" + ) + == "https://billing.us-east4.gcp.keboola.com" + ) + + +class TestGetCredits: + def test_get_credits_issues_get_to_billing_host(self, httpx_mock) -> None: + httpx_mock.add_response( + url=f"{BILLING_URL}/credits", + method="GET", + json=LIVE_PAYLOAD, + status_code=200, + ) + + with KeboolaClient(stack_url=STACK_URL, token=TEST_TOKEN) as client: + result = client.get_credits() + + assert result == LIVE_PAYLOAD + requests = httpx_mock.get_requests() + assert len(requests) == 1 + assert requests[0].method == "GET" + assert str(requests[0].url) == f"{BILLING_URL}/credits" + # Sub-client inherits the Storage API token header from the main client. + assert requests[0].headers["X-StorageApi-Token"] == TEST_TOKEN + + def test_get_credits_returns_raw_json_verbatim(self, httpx_mock) -> None: + """No shaping happens in the client layer -- the dict is returned as-is.""" + payload = {"consumed": 1.0, "remaining": 2.0, "unknownField": "passthrough"} + httpx_mock.add_response(url=f"{BILLING_URL}/credits", method="GET", json=payload) + + with KeboolaClient(stack_url=STACK_URL, token=TEST_TOKEN) as client: + result = client.get_credits() + + assert result == payload + + +class TestProjectCreditsParsing: + """`models.ProjectCredits` must tolerate the live API's real shape.""" + + def test_parses_verbatim_live_payload(self) -> None: + credits = ProjectCredits.model_validate(LIVE_PAYLOAD) + + assert credits.consumed == 100.5 + assert credits.remaining == 25.5 + assert credits.stats is not None + assert isinstance(credits.stats, CreditStats) + assert credits.stats.component_jobs is not None + assert credits.stats.component_jobs.consumed == 95.25 + + # workspaceJobs is a LIST of two entries in the live payload -- the + # public docs show an object, but the tolerant model parses the array. + assert isinstance(credits.stats.workspace_jobs, list) + assert len(credits.stats.workspace_jobs) == 2 + first, second = credits.stats.workspace_jobs + assert isinstance(first, WorkspaceJobCredits) + assert first.workspace_type == "sandbox-sql" + assert first.warehouse_size == "small" + assert first.consumed == 5.0 + assert second.workspace_type == "writer" + assert second.consumed == 0.25 + + def test_tolerates_missing_stats(self) -> None: + credits = ProjectCredits.model_validate({"consumed": 10.0, "remaining": 5.0}) + assert credits.stats is None + assert credits.consumed == 10.0 + assert credits.remaining == 5.0 + + def test_tolerates_stats_without_workspace_jobs(self) -> None: + credits = ProjectCredits.model_validate( + { + "consumed": 10.0, + "remaining": 5.0, + "stats": {"componentJobs": {"consumed": 10.0}}, + } + ) + assert credits.stats is not None + assert credits.stats.workspace_jobs == [] + assert credits.stats.component_jobs is not None + assert credits.stats.component_jobs.consumed == 10.0 + + def test_tolerates_stats_none(self) -> None: + credits = ProjectCredits.model_validate({"consumed": 10.0, "remaining": 5.0, "stats": None}) + assert credits.stats is None + + def test_tolerates_unknown_extra_top_level_keys(self) -> None: + credits = ProjectCredits.model_validate( + { + "consumed": 10.0, + "remaining": 5.0, + "purchased": 15.0, + "currency": "EUR", + "someFutureField": {"nested": True}, + } + ) + assert credits.consumed == 10.0 + assert credits.remaining == 5.0 + + def test_tolerates_completely_empty_payload(self) -> None: + credits = ProjectCredits.model_validate({}) + assert credits.consumed == 0.0 + assert credits.remaining == 0.0 + assert credits.stats is None + + def test_tolerates_unknown_extra_keys_on_nested_models(self) -> None: + credits = ProjectCredits.model_validate( + { + "consumed": 1.0, + "remaining": 2.0, + "stats": { + "componentJobs": {"consumed": 1.0, "extraField": "x"}, + "workspaceJobs": [ + { + "workspaceType": "sandbox-sql", + "warehouseSize": "small", + "consumed": 1.0, + "extraField": "y", + } + ], + "extraStatsField": "z", + }, + } + ) + assert credits.stats is not None + assert credits.stats.workspace_jobs[0].workspace_type == "sandbox-sql" + + +class TestNoTopUpHelper: + """Regression guard: POST /credits triggers a REAL automatic top-up. + + `_BillingMixin` must expose exactly one public method (`get_credits`) and + must never issue a POST request anywhere in its own source -- both are + checked so an unnoticed future addition of a "top up" / "purchase" helper + fails this test loudly. + """ + + def test_only_get_credits_is_defined_on_the_mixin(self) -> None: + own_public_methods = [ + name + for name, value in vars(_BillingMixin).items() + if not name.startswith("_") and callable(value) + ] + assert own_public_methods == ["get_credits"] + + def test_billing_module_source_never_issues_a_post(self) -> None: + import keboola_agent_cli.client.billing as billing_module + + source = inspect.getsource(billing_module) + assert '"POST"' not in source + assert "'POST'" not in source + + def test_get_credits_never_sends_a_post_request(self, httpx_mock) -> None: + httpx_mock.add_response(url=f"{BILLING_URL}/credits", method="GET", json=LIVE_PAYLOAD) + + with KeboolaClient(stack_url=STACK_URL, token=TEST_TOKEN) as client: + client.get_credits() + + for request in httpx_mock.get_requests(): + assert request.method != "POST" + + def test_billing_dispatcher_takes_no_http_method(self) -> None: + """The signature itself, not just this module's source, forbids a POST. + + The source-scan above only covers `client/billing.py`. A future caller + elsewhere could reach the dispatcher directly, so the dispatcher takes + no `method` argument at all -- unlike its `_queue_request` / + `_sync_actions_request` siblings. Pinned here because reintroducing a + `method` parameter would silently reopen the real-money path. + """ + assert not hasattr(_CoreClient, "_billing_request"), ( + "_billing_request is back: a caller can now pass method='POST' to the " + "billing host, which triggers a real-money automatic top-up." + ) + params = list(inspect.signature(_CoreClient._billing_get).parameters) + assert params == ["self", "path", "kwargs"], ( + f"_billing_get must stay (self, path, **kwargs) with the verb hardcoded; got {params}" + ) + + +if __name__ == "__main__": + pytest.main([__file__, "-v"]) diff --git a/tests/test_billing_service.py b/tests/test_billing_service.py new file mode 100644 index 00000000..6f1019b4 --- /dev/null +++ b/tests/test_billing_service.py @@ -0,0 +1,364 @@ +"""Unit tests for BillingService. + +Tests the business logic in isolation using mocked KeboolaClient instances. +Covers: + +- ``get_credits`` happy path against the verbatim live payload from issue #594. +- The PAYG feature gate short-circuiting before any billing call. +- Mixed multi-project fan-out (success + non-PAYG + API error). +- ``ConfigError`` propagation for an unknown alias. +- Deterministic ordering of both ``credits`` and ``errors``. +- Tolerant parsing of a payload missing ``stats`` entirely. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest + +from keboola_agent_cli.errors import ConfigError, ErrorCode, KeboolaApiError +from keboola_agent_cli.services.billing_service import BillingService + +# --------------------------------------------------------------------------- +# Fixtures / helpers +# --------------------------------------------------------------------------- + +# Fake tokens follow the repo convention: "901--". +_TOKEN_A = "901-storage-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +_TOKEN_B = "901-storage-bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +_TOKEN_C = "901-storage-cccccccccccccccccccccccccccccccc" + +# The exact live payload from the billing contract (issue #594). +LIVE_PAYLOAD = { + "consumed": 100.5, + "stats": { + "componentJobs": {"consumed": 95.25}, + "workspaceJobs": [ + {"workspaceType": "sandbox-sql", "warehouseSize": "small", "consumed": 5.0}, + {"workspaceType": "writer", "warehouseSize": "small", "consumed": 0.25}, + ], + }, + "remaining": 25.5, +} + + +def _mock_config_store(projects: dict) -> MagicMock: + """Build a config-store double mirroring test_schedule_service.py's helper. + + Each ``projects`` value is a dict with ``url``, ``token``, and optionally + ``project_id`` -- explicitly set (rather than left as an auto-generated + ``MagicMock``) so row assertions against ``project_id`` are meaningful. + """ + cs = MagicMock() + config = MagicMock() + config.projects = { + alias: MagicMock( + stack_url=v["url"], + token=v["token"], + active_branch_id=None, + project_id=v.get("project_id"), + ) + for alias, v in projects.items() + } + config.max_parallel_workers = 10 + cs.load.return_value = config + cs.get_project.side_effect = lambda alias: config.projects.get(alias) + return cs + + +def _make_service(mock_client: MagicMock, projects: dict | None = None) -> BillingService: + if projects is None: + projects = { + "prod": {"url": "https://connection.keboola.com", "token": _TOKEN_A, "project_id": 123} + } + cs = _mock_config_store(projects) + return BillingService(config_store=cs, client_factory=lambda url, tok: mock_client) + + +# --------------------------------------------------------------------------- +# Happy path +# --------------------------------------------------------------------------- + + +class TestGetCreditsHappyPath: + def test_live_payload_produces_exact_row(self) -> None: + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = LIVE_PAYLOAD + + service = _make_service(client) + result = service.get_credits(aliases=["prod"]) + + assert result["errors"] == [] + assert len(result["credits"]) == 1 + row = result["credits"][0] + + assert row["project_alias"] == "prod" + assert row["project_id"] == 123 + assert row["consumed"] == 100.5 + assert row["remaining"] == 25.5 + assert row["total"] == 126.0 # 100.5 + 25.5 + assert row["consumed_minutes"] == 100.5 * 60 + assert row["remaining_minutes"] == 25.5 * 60 + assert row["component_jobs_consumed"] == 95.25 + assert row["workspace_jobs"] == [ + {"workspace_type": "sandbox-sql", "warehouse_size": "small", "consumed": 5.0}, + {"workspace_type": "writer", "warehouse_size": "small", "consumed": 0.25}, + ] + + def test_feature_gate_checked_before_get_credits(self) -> None: + """has_feature must be called, and checked, before get_credits fires.""" + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = LIVE_PAYLOAD + + service = _make_service(client) + service.get_credits(aliases=["prod"]) + + client.has_feature.assert_called_once_with("pay-as-you-go") + client.get_credits.assert_called_once() + + def test_client_closed_on_success(self) -> None: + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = LIVE_PAYLOAD + + service = _make_service(client) + service.get_credits(aliases=["prod"]) + + client.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Non-PAYG feature gate +# --------------------------------------------------------------------------- + + +class TestPaygFeatureGate: + def test_missing_feature_short_circuits(self) -> None: + client = MagicMock() + client.has_feature.return_value = False + + service = _make_service(client) + result = service.get_credits(aliases=["prod"]) + + assert result["credits"] == [] + assert len(result["errors"]) == 1 + entry = result["errors"][0] + assert entry["project_alias"] == "prod" + assert entry["error_code"] == str(ErrorCode.PAYG_NOT_AVAILABLE) + assert "pay-as-you-go" in entry["message"] + + def test_get_credits_never_called_when_gate_fails(self) -> None: + """The whole point of the gate: never touch the (possibly NXDOMAIN) billing host.""" + client = MagicMock() + client.has_feature.return_value = False + + service = _make_service(client) + service.get_credits(aliases=["prod"]) + + client.get_credits.assert_not_called() + + def test_client_closed_even_when_gate_fails(self) -> None: + client = MagicMock() + client.has_feature.return_value = False + + service = _make_service(client) + service.get_credits(aliases=["prod"]) + + client.close.assert_called_once() + + +# --------------------------------------------------------------------------- +# Mixed multi-project fan-out +# --------------------------------------------------------------------------- + + +class TestMixedFanOut: + def _client_factory(self): + """Return a (url, token) -> client factory keyed by token. + + ``a`` succeeds, ``b`` is non-PAYG, ``c`` raises KeboolaApiError from + the billing call itself. + """ + client_a = MagicMock() + client_a.has_feature.return_value = True + client_a.get_credits.return_value = LIVE_PAYLOAD + + client_b = MagicMock() + client_b.has_feature.return_value = False + + client_c = MagicMock() + client_c.has_feature.return_value = True + client_c.get_credits.side_effect = KeboolaApiError( + message="Internal server error", + status_code=500, + error_code="API_ERROR", + retryable=False, + ) + + by_token = {_TOKEN_A: client_a, _TOKEN_B: client_b, _TOKEN_C: client_c} + clients = {"a": client_a, "b": client_b, "c": client_c} + + def factory(url: str, token: str) -> MagicMock: + return by_token[token] + + return factory, clients + + def test_three_projects_all_outcomes_present(self) -> None: + factory, clients = self._client_factory() + cs = _mock_config_store( + { + "a": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 1}, + "b": {"url": "https://k.com", "token": _TOKEN_B, "project_id": 2}, + "c": {"url": "https://k.com", "token": _TOKEN_C, "project_id": 3}, + } + ) + service = BillingService(config_store=cs, client_factory=factory) + result = service.get_credits() + + # Success row for "a" still returned. + assert len(result["credits"]) == 1 + assert result["credits"][0]["project_alias"] == "a" + assert result["credits"][0]["consumed"] == 100.5 + + # Both "b" (non-PAYG) and "c" (API error) degrade to error entries; + # neither one aborts the fan-out. + assert len(result["errors"]) == 2 + error_aliases = {e["project_alias"] for e in result["errors"]} + assert error_aliases == {"b", "c"} + + b_entry = next(e for e in result["errors"] if e["project_alias"] == "b") + assert b_entry["error_code"] == str(ErrorCode.PAYG_NOT_AVAILABLE) + + c_entry = next(e for e in result["errors"] if e["project_alias"] == "c") + assert c_entry["error_code"] == "API_ERROR" + + # Every client is closed regardless of outcome. + for client in clients.values(): + client.close.assert_called_once() + + def test_connection_error_message_mentions_unreachable(self) -> None: + """A connection/DNS failure that slips past the gate gets a clear message.""" + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.side_effect = KeboolaApiError( + message="Cannot connect to https://billing.example.com (token: ***)", + status_code=0, + error_code=ErrorCode.CONNECTION_ERROR, + retryable=True, + ) + service = _make_service(client) + result = service.get_credits(aliases=["prod"]) + + assert result["credits"] == [] + entry = result["errors"][0] + assert entry["error_code"] == str(ErrorCode.CONNECTION_ERROR) + assert "could not reach" in entry["message"].lower() + assert "billing" in entry["message"].lower() + + +# --------------------------------------------------------------------------- +# Unknown alias +# --------------------------------------------------------------------------- + + +class TestUnknownAlias: + def test_unknown_alias_raises_config_error(self) -> None: + client = MagicMock() + service = _make_service(client) + with pytest.raises(ConfigError): + service.get_credits(aliases=["ghost"]) + + def test_unknown_alias_does_not_become_a_per_project_error(self) -> None: + """ConfigError from resolve_projects must propagate, not be swallowed.""" + client = MagicMock() + service = _make_service(client) + with pytest.raises(ConfigError): + service.get_credits(aliases=["prod", "ghost"]) + # No billing call should have been attempted for either alias -- + # resolution happens before the fan-out starts. + client.has_feature.assert_not_called() + client.get_credits.assert_not_called() + + +# --------------------------------------------------------------------------- +# Deterministic ordering +# --------------------------------------------------------------------------- + + +class TestDeterministicOrdering: + def test_credits_sorted_by_alias(self) -> None: + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = LIVE_PAYLOAD + + cs = _mock_config_store( + { + "zeta": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 1}, + "alpha": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 2}, + "mid": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 3}, + } + ) + service = BillingService(config_store=cs, client_factory=lambda url, tok: client) + result = service.get_credits() + + aliases = [row["project_alias"] for row in result["credits"]] + assert aliases == sorted(aliases) + assert aliases == ["alpha", "mid", "zeta"] + + def test_errors_sorted_by_alias(self) -> None: + client = MagicMock() + client.has_feature.return_value = False + + cs = _mock_config_store( + { + "zeta": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 1}, + "alpha": {"url": "https://k.com", "token": _TOKEN_A, "project_id": 2}, + } + ) + service = BillingService(config_store=cs, client_factory=lambda url, tok: client) + result = service.get_credits() + + aliases = [e["project_alias"] for e in result["errors"]] + assert aliases == ["alpha", "zeta"] + + +# --------------------------------------------------------------------------- +# Tolerant parsing: missing ``stats`` +# --------------------------------------------------------------------------- + + +class TestMissingStats: + def test_payload_without_stats_zeroes_breakdown(self) -> None: + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = {"consumed": 10.0, "remaining": 5.0} + + service = _make_service(client) + result = service.get_credits(aliases=["prod"]) + + assert result["errors"] == [] + row = result["credits"][0] + assert row["consumed"] == 10.0 + assert row["remaining"] == 5.0 + assert row["total"] == 15.0 + assert row["component_jobs_consumed"] == 0.0 + assert row["workspace_jobs"] == [] + + def test_completely_empty_payload_does_not_raise(self) -> None: + client = MagicMock() + client.has_feature.return_value = True + client.get_credits.return_value = {} + + service = _make_service(client) + result = service.get_credits(aliases=["prod"]) + + assert result["errors"] == [] + row = result["credits"][0] + assert row["consumed"] == 0.0 + assert row["remaining"] == 0.0 + assert row["total"] == 0.0 + assert row["component_jobs_consumed"] == 0.0 + assert row["workspace_jobs"] == [] diff --git a/tests/test_e2e.py b/tests/test_e2e.py index 09e33a56..fb44139e 100644 --- a/tests/test_e2e.py +++ b/tests/test_e2e.py @@ -5321,6 +5321,103 @@ def test_tool_call_get_buckets(self) -> None: assert result.exit_code == 0 +# --------------------------------------------------------------------------- +# Billing / PAYG credit balance (issue #594) +# --------------------------------------------------------------------------- + + +@skip_without_credentials +@pytest.mark.e2e +class TestE2EBillingCredits: + """End-to-end test for `kbagent billing credits`. + + The E2E project(s) used in CI are NOT pay-as-you-go enabled, so the + honest contract to assert here is graceful degradation, not a populated + balance: `--json` must return a well-formed `{"credits": [...], + "errors": [...]}` envelope, exit 0, and the non-PAYG project must show + up as a `PAYG_NOT_AVAILABLE` entry in `errors` rather than crashing or + surfacing an opaque billing-host connection failure. The success-path + row shape is only asserted when a PAYG project is actually present in + the envelope, so this test stays meaningful (and starts covering the + happy path) the day a PAYG project is added to the E2E fixtures -- + without needing to be rewritten then. + """ + + @pytest.fixture(autouse=True) + def setup(self, tmp_path: Path) -> None: + self.token = os.environ[ENV_TOKEN] + raw_url = os.environ.get(ENV_URL, "connection.keboola.com") + self.url = raw_url if raw_url.startswith("https://") else f"https://{raw_url}" + self.alias = f"{RUN_ID}-billing" + self.config_dir = tmp_path / "config" + self.config_dir.mkdir() + + result = _invoke( + self.config_dir, + [ + "--json", + "project", + "add", + "--project", + self.alias, + "--url", + self.url, + "--token", + self.token, + ], + ) + assert result.exit_code == 0, f"project add failed: {result.output}" + + def _run(self, *args: str) -> Any: + return _invoke(self.config_dir, ["--json", *args]) + + def test_billing_credits_returns_well_formed_envelope(self) -> None: + """--json always returns {"credits": [...], "errors": [...]}, exit 0. + + A non-PAYG project degrades to a PAYG_NOT_AVAILABLE error entry + instead of failing the command -- billing is a read, and one + project lacking the feature must never abort the whole run. + """ + result = self._run("billing", "credits", "--project", self.alias) + assert result.exit_code == 0, result.output + data = json.loads(result.output)["data"] + assert "credits" in data + assert "errors" in data + assert isinstance(data["credits"], list) + assert isinstance(data["errors"], list) + + # This project is expected to be non-PAYG in the E2E fixtures. If it + # is, it must show up as an actionable error, not a crash or a bare + # connection failure against a possibly-NXDOMAIN billing host. + non_payg_errors = [e for e in data["errors"] if e.get("project_alias") == self.alias] + payg_rows = [c for c in data["credits"] if c.get("project_alias") == self.alias] + + if payg_rows: + # A PAYG project showed up -- assert the full success-path row shape. + row = payg_rows[0] + for key in ( + "project_alias", + "project_id", + "consumed", + "remaining", + "total", + "consumed_minutes", + "remaining_minutes", + "component_jobs_consumed", + "workspace_jobs", + ): + assert key in row, f"missing key {key!r} in PAYG credit row: {row}" + assert row["total"] == row["consumed"] + row["remaining"] + assert row["remaining_minutes"] == row["remaining"] * 60 + else: + # Graceful-degradation path: the project must be reported as + # non-PAYG, never silently dropped from both lists. + assert non_payg_errors, ( + f"project {self.alias!r} missing from both credits and errors: {data}" + ) + assert non_payg_errors[0]["error_code"] == "PAYG_NOT_AVAILABLE" + + # --------------------------------------------------------------------------- # Job run variable values resolution # --------------------------------------------------------------------------- diff --git a/tests/test_server_router_calls.py b/tests/test_server_router_calls.py index 57a7fa43..56ca3bbd 100644 --- a/tests/test_server_router_calls.py +++ b/tests/test_server_router_calls.py @@ -1204,6 +1204,85 @@ def test_docs_query_requires_bearer_auth(tmp_path: Path) -> None: docs_svc.ask_docs.assert_not_called() +# --------------------------------------------------------------------------- +# billing.py GET /billing/credits +# Service: billing.get_credits(aliases=...) (mirrors `kbagent billing credits`) +# --------------------------------------------------------------------------- + + +def test_billing_credits_passes_none_aliases_when_no_project_given(tmp_path: Path) -> None: + """GET /billing/credits with no `project` query param must call get_credits(aliases=None).""" + billing_svc = MagicMock() + billing_svc.get_credits.return_value = {"credits": [], "errors": []} + registry = _mock_registry(billing=billing_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get("/billing/credits", headers=AUTH) + + assert res.status_code == 200, res.text + billing_svc.get_credits.assert_called_once_with(aliases=None) + + +def test_billing_credits_repeated_project_param_forwards_alias_list(tmp_path: Path) -> None: + """Repeated `?project=a&project=b` must forward aliases=["a", "b"].""" + billing_svc = MagicMock() + billing_svc.get_credits.return_value = {"credits": [], "errors": []} + registry = _mock_registry(billing=billing_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get("/billing/credits", headers=AUTH, params={"project": ["a", "b"]}) + + assert res.status_code == 200, res.text + billing_svc.get_credits.assert_called_once_with(aliases=["a", "b"]) + + +def test_billing_credits_returns_service_envelope_unchanged(tmp_path: Path) -> None: + """The router must return the service's {"credits": ..., "errors": ...} dict verbatim.""" + billing_svc = MagicMock() + envelope = { + "credits": [ + { + "project_alias": "prod", + "project_id": 123, + "consumed": 100.5, + "remaining": 25.5, + "total": 126.0, + "consumed_minutes": 6030.0, + "remaining_minutes": 1530.0, + "component_jobs_consumed": 95.25, + "workspace_jobs": [ + {"workspace_type": "sandbox-sql", "warehouse_size": "small", "consumed": 5.0} + ], + } + ], + "errors": [], + } + billing_svc.get_credits.return_value = envelope + registry = _mock_registry(billing=billing_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get("/billing/credits", headers=AUTH) + + assert res.status_code == 200, res.text + assert res.json() == envelope + + +def test_billing_credits_requires_bearer_auth(tmp_path: Path) -> None: + """GET /billing/credits without an Authorization header must be rejected.""" + billing_svc = MagicMock() + registry = _mock_registry(billing=billing_svc) + app = _make_app_with_registry(tmp_path, registry) + + with TestClient(app) as client: + res = client.get("/billing/credits") # no auth header + + assert res.status_code == 401, res.text + billing_svc.get_credits.assert_not_called() + + # --------------------------------------------------------------------------- # configs.py GET /configs/examples/{component_id} # Service: component.get_config_examples(alias=..., component_id=...) diff --git a/uv.lock b/uv.lock index 4f63a6ff..c08b7997 100644 --- a/uv.lock +++ b/uv.lock @@ -590,7 +590,7 @@ wheels = [ [[package]] name = "keboola-cli" -version = "0.84.1" +version = "0.84.2" source = { editable = "." } dependencies = [ { name = "croniter" },