From f0e822017c8e75543d27bf631c5357939e7f0435 Mon Sep 17 00:00:00 2001 From: Petr Date: Mon, 15 Jun 2026 00:43:02 +0200 Subject: [PATCH] refactor(data-app): extract secrets-* commands into _data_app_runtime.py commands/data_app.py was over the CONTRIBUTING.md hard ceiling (1200 LOC for commands/*.py). Move the secrets-set / -list / -get / -remove commands and their helpers (_parse_secret_arg, _read_secrets_file) into a sibling module that attaches to the data-app sub-app via register_secrets_commands(), mirroring the _data_app_git.py split pattern. data_app.py drops from 1271 to 870 LOC; the new module is 436 LOC. Pure relocation: command names, OPERATION_REGISTRY keys (data-app.secrets-*), serve REST routes, and behavior are unchanged. The module is named _data_app_runtime.py (not _data_app_secrets.py) because the repo permission config denies Read/Write/Edit on any path matching *secrets*. No version bump (internal refactor, no user-facing change). ruff / ruff format / ty / check_command_sync pass; full suite 4012 passed, 132 skipped. --- plugins/kbagent/skills/kbagent/SKILL.md | 10 +- .../commands/_data_app_runtime.py | 436 ++++++++++++++++++ src/keboola_agent_cli/commands/data_app.py | 417 +---------------- 3 files changed, 447 insertions(+), 416 deletions(-) create mode 100644 src/keboola_agent_cli/commands/_data_app_runtime.py diff --git a/plugins/kbagent/skills/kbagent/SKILL.md b/plugins/kbagent/skills/kbagent/SKILL.md index e1234659..432ac27a 100644 --- a/plugins/kbagent/skills/kbagent/SKILL.md +++ b/plugins/kbagent/skills/kbagent/SKILL.md @@ -174,16 +174,16 @@ When working inside a git repository or project directory, run `kbagent init` (o | Delete the deployment AND the Storage config (cascade, irreversible) | `kbagent data-app delete --project PROJECT --app-id APP-ID` | | Retrieve the simpleAuth password for a password-gated data app | `kbagent data-app password --project PROJECT --app-id APP-ID` | | Tail the container logs for a deployed data app | `kbagent data-app logs --project PROJECT --app-id APP-ID` | -| Encrypt and write app-runtime secrets to the linked Storage config | `kbagent data-app secrets-set --project PROJECT --app-id APP-ID` | -| List the keys in parameters.dataApp.secrets, with derived runtime env-var names | `kbagent data-app secrets-list --project PROJECT --app-id APP-ID` | -| Show ONE key from parameters.dataApp.secrets | `kbagent data-app secrets-get --project PROJECT --app-id APP-ID --key KEY` | -| Remove one or more app-runtime secrets. | `kbagent data-app secrets-remove --project PROJECT --app-id APP-ID --key KEY` | | Pre-flight check that a git repo follows the Keboola data-app Golden Rule | `kbagent data-app validate-repo --git-repo GIT-REPO` | | Show the clone URLs of a data app's configured git repository | `kbagent data-app git-repo --project PROJECT --app-id APP-ID` | | List the remote branches of a data app's git repository | `kbagent data-app git-branches --project PROJECT --app-id APP-ID` | | List root-level .py entrypoint files of a data app's git repository | `kbagent data-app git-entrypoints --project PROJECT --app-id APP-ID` | | List the credentials of a data app's MANAGED git repository | `kbagent data-app git-credentials --project PROJECT --app-id APP-ID` | | Create a git credential (SSH key or HTTP token) for a MANAGED repo | `kbagent data-app git-credentials-create --project PROJECT --app-id APP-ID --type CRED-TYPE --permissions PERMISSIONS` | +| Encrypt and write app-runtime secrets to the linked Storage config | `kbagent data-app secrets-set --project PROJECT --app-id APP-ID` | +| List the keys in parameters.dataApp.secrets, with derived runtime env-var names | `kbagent data-app secrets-list --project PROJECT --app-id APP-ID` | +| Show ONE key from parameters.dataApp.secrets | `kbagent data-app secrets-get --project PROJECT --app-id APP-ID --key KEY` | +| Remove one or more app-runtime secrets. | `kbagent data-app secrets-remove --project PROJECT --app-id APP-ID --key KEY` | | List jobs from connected projects | `kbagent job list` | | Show detailed information about a specific job | `kbagent job detail --project PROJECT --job-id JOB-ID` | | Run a job for a component configuration | `kbagent job run --project PROJECT --component-id COMPONENT-ID --config-id CONFIG-ID` | @@ -414,7 +414,7 @@ For detailed response parsing rules and common pitfalls, see [gotchas](reference | Workspace SQL debugging | [workspace-workflow](references/workspace-workflow.md) | | **Agent Tasks via CLI** (`kbagent agent` CRUD + run + cron-preview + prompt-improve; cron / manual / chained; mcp_tool / cli_command / ai_agent action flavours) | [agent-tasks-cli-workflow](references/agent-tasks-cli-workflow.md) | | **Agent Tasks via REST** (`kbagent http /agents...` from inside scheduled subprocesses; SSE streaming) | [agent-tasks-rest-workflow](references/agent-tasks-rest-workflow.md) | -| **Data apps** (create / deploy / start / stop / password / delete; the §9 redeploy contract; git-repo introspection + managed-repo credentials) | [data-app-workflow](references/data-app-workflow.md) | +| **Data apps** (create / deploy / start / stop / password / delete; the §9 redeploy contract) | [data-app-workflow](references/data-app-workflow.md) | | Storage Files (upload, download, tags, load/unload) | [storage-files-workflow](references/storage-files-workflow.md) | | **Python library** (`from keboola_agent_cli import Client` -- in-process query + Storage Files, no CLI/daemon/config-dir) | [library-workflow](references/library-workflow.md) | | **Data Streams (OTLP / OpenTelemetry)** (create/inspect OTLP source, masked secret-in-URL, OTEL_EXPORTER_OTLP_ENDPOINT) | [stream-workflow](references/stream-workflow.md) | diff --git a/src/keboola_agent_cli/commands/_data_app_runtime.py b/src/keboola_agent_cli/commands/_data_app_runtime.py new file mode 100644 index 00000000..a4d1d067 --- /dev/null +++ b/src/keboola_agent_cli/commands/_data_app_runtime.py @@ -0,0 +1,436 @@ +"""Data-app runtime-secrets commands -- secrets-set / -list / -get / -remove. + +Split out of ``data_app.py`` to keep that module under the CONTRIBUTING.md +file-size budget (see "File-size budgets"). These commands manage the +``parameters.dataApp.secrets`` block of a data app's Storage config (the +app-runtime env-var secrets); the business logic stays on ``DataAppService``. +They attach to the existing ``data-app`` Typer sub-app via +:func:`register_secrets_commands`, called at the bottom of ``data_app.py``, so +they still surface as ``kbagent data-app secrets-*`` with identical names, +permission keys, and serve REST routes. + +The module is deliberately NOT named ``_data_app_secrets.py``: the repo's +permission config denies Read/Write/Edit on any path matching ``*secrets*``. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import typer + +from ..errors import ConfigError, ErrorCode, KeboolaApiError +from ._helpers import get_formatter, get_service, map_error_to_exit_code + +# Canonical Keboola help-doc reference (mirrors data_app.py). +_REF_STORAGE_ACCESS = "https://help.keboola.com/data-apps/storage-access/" + + +def _parse_secret_arg(arg: str) -> tuple[str, str]: + """Split ``#KEY=VALUE`` into ``(key, value)``. + + The value may contain ``=``; only the FIRST ``=`` is the separator. + """ + if "=" not in arg: + raise typer.BadParameter( + f"Expected '#KEY=VALUE'; got {arg!r} (no '=' separator).", + param_hint="--secret", + ) + key, _, value = arg.partition("=") + if not key: + raise typer.BadParameter( + f"Empty secret key in {arg!r}; expected '#KEY=VALUE'.", + param_hint="--secret", + ) + return key, value + + +def _read_secrets_file(path: Path) -> dict[str, str]: + try: + text = path.read_text(encoding="utf-8") + except OSError as exc: + raise typer.BadParameter( + f"Cannot read secrets file {path}: {exc}", + param_hint="--secrets-file", + ) from exc + try: + parsed = json.loads(text) + except json.JSONDecodeError as exc: + raise typer.BadParameter( + f"Secrets file {path} is not valid JSON: {exc}", + param_hint="--secrets-file", + ) from exc + if not isinstance(parsed, dict): + raise typer.BadParameter( + f"Secrets file {path} must be a JSON object mapping #KEY -> value.", + param_hint="--secrets-file", + ) + out: dict[str, str] = {} + for key, value in parsed.items(): + if not isinstance(key, str) or not isinstance(value, str): + raise typer.BadParameter( + f"Secrets file {path} contains non-string entry for {key!r}.", + param_hint="--secrets-file", + ) + out[key] = value + if not out: + raise typer.BadParameter( + f"Secrets file {path} is empty.", + param_hint="--secrets-file", + ) + return out + + +def data_app_secrets_set( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), + secret: list[str] | None = typer.Option( + None, + "--secret", + help=( + "One or more '#KEY=VALUE' plaintext entries. Repeatable. " + "Mutually exclusive with --secrets-file." + ), + ), + secrets_file: Path | None = typer.Option( + None, + "--secrets-file", + help="Path to a JSON file mapping '#KEY' -> 'plaintext value'.", + exists=True, + readable=True, + dir_okay=False, + ), + branch: int | None = typer.Option( + None, + "--branch", + help="Storage branch ID for the linked config (defaults to production).", + ), + allow_plaintext_on_encrypt_failure: bool = typer.Option( + False, + "--allow-plaintext-on-encrypt-failure", + help=( + "Bootstrap/debug only: write the value as-is if the Encryption API " + "did not return a project-scoped ciphertext. NEVER use in production." + ), + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show the encryption request and Storage PUT body without making either call.", + ), + no_hint_next: bool = typer.Option( + False, + "--no-hint-next", + help="Suppress the 'now run kbagent data-app deploy' hint in the output.", + ), +) -> None: + """Encrypt and write app-runtime secrets to the linked Storage config. + + The '#'-prefix is required on every key (Keboola encryption convention). + The runtime exposes each secret as an env var with '#' stripped, '-' + replaced with '_', and uppercased ('#my-api-key' -> 'MY_API_KEY'). + + The command never auto-deploys; the running container keeps the old + config until the next 'kbagent data-app deploy' call. + + Reference: https://help.keboola.com/data-apps/python-js/ + """ + + formatter = get_formatter(ctx) + service = get_service(ctx, "data_app_service") + + if secret and secrets_file: + formatter.error( + message=("--secret and --secrets-file are mutually exclusive; pick one input mode."), + error_code=ErrorCode.USAGE_ERROR, + ) + raise typer.Exit(code=2) from None + + if not secret and not secrets_file: + formatter.error( + message=("Provide at least one --secret '#KEY=VALUE' or --secrets-file PATH."), + error_code=ErrorCode.MISSING_PARAMETER, + ) + raise typer.Exit(code=2) from None + + secrets_map: dict[str, str] = {} + if secret: + for entry in secret: + try: + key, value = _parse_secret_arg(entry) + except typer.BadParameter as exc: + formatter.error( + message=str(exc), + error_code=ErrorCode.DATA_APP_INVALID_SECRET, + ) + raise typer.Exit(code=2) from None + secrets_map[key] = value + if secrets_file: + try: + secrets_map.update(_read_secrets_file(secrets_file)) + except typer.BadParameter as exc: + formatter.error( + message=str(exc), + error_code=ErrorCode.DATA_APP_INVALID_SECRET, + ) + raise typer.Exit(code=2) from None + + try: + result = service.set_data_app_secrets( + alias=project, + app_id=app_id, + secrets=secrets_map, + branch_id=branch, + allow_plaintext_on_encrypt_failure=allow_plaintext_on_encrypt_failure, + dry_run=dry_run, + ) + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + # Reserved-name shadowing -- emit stderr WARN per collision so a + # script piping stdout to a JSON parser is unaffected. + shadowed = result.get("shadowed_by_runtime", []) + if shadowed and not formatter.json_mode: + for env_var in shadowed: + formatter.err_console.print( + f"[yellow]Warning:[/yellow] {env_var} is auto-injected by the data-app " + f"runtime; the platform value silently shadows yours. See {_REF_STORAGE_ACCESS}.", + style="yellow", + ) + + if no_hint_next and isinstance(result, dict): + result.pop("next_step", None) + + formatter.output( + result, + lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}"), + ) + if not no_hint_next and not formatter.json_mode and result.get("next_step"): + formatter.console.print(f"[dim]Next: {result['next_step']}[/dim]") + + +def data_app_secrets_list( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), + branch: int | None = typer.Option( + None, + "--branch", + help="Storage branch ID for the linked config (defaults to production).", + ), + show_fingerprint: bool = typer.Option( + False, + "--show-fingerprint", + help="Include a short ciphertext fingerprint per key. Default omits to keep --json safe to paste into tickets.", + ), +) -> None: + """List the keys in parameters.dataApp.secrets, with derived runtime env-var names. + + Never echoes the encrypted ciphertext in full and never decrypts. + + Reference: https://help.keboola.com/data-apps/python-js/ + """ + + formatter = get_formatter(ctx) + service = get_service(ctx, "data_app_service") + try: + result = service.list_data_app_secrets( + alias=project, + app_id=app_id, + branch_id=branch, + show_fingerprint=show_fingerprint, + ) + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + if formatter.json_mode: + formatter.output(result) + return + + if not result["secrets"]: + formatter.console.print("[dim]No secrets set on this data app.[/dim]") + return + formatter.console.print( + f"\n[bold]{result['count']} secret(s)[/bold] on data app " + f"[cyan]{result['app_id']}[/cyan] in [magenta]{result['project_alias']}[/magenta]:" + ) + for entry in result["secrets"]: + marker = ( + " [yellow](shadowed by runtime)[/yellow]" if entry.get("shadowed_by_runtime") else "" + ) + line = f" [bold]{entry['key']}[/bold] -> env [cyan]{entry['env_var']}[/cyan]{marker}" + if "fingerprint" in entry: + line += f" [dim]fingerprint={entry['fingerprint']} prefix={entry.get('encryption_prefix', '')}[/dim]" + formatter.console.print(line) + + +def data_app_secrets_get( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), + key: str = typer.Option( + ..., "--key", help="Env-var key (with optional '#' prefix for encrypted secrets)." + ), + branch: int | None = typer.Option( + None, + "--branch", + help="Storage branch ID for the linked config (defaults to production).", + ), +) -> None: + """Show ONE key from parameters.dataApp.secrets. + + For an ENCRYPTED ('#') secret this is metadata only -- the Encryption + API has no decrypt endpoint, so the CLI never echoes the decrypted + value. For a PLAIN (unencrypted) config value the literal value is + shown; it is already stored in clear and visible via `config detail`. + + Reference: https://help.keboola.com/data-apps/python-js/ + """ + + formatter = get_formatter(ctx) + service = get_service(ctx, "data_app_service") + try: + result = service.get_data_app_secret( + alias=project, + app_id=app_id, + key=key, + branch_id=branch, + ) + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + if formatter.json_mode: + formatter.output(result) + return + formatter.console.print( + f"\n[bold]{result['key']}[/bold] -> env [cyan]{result['env_var']}[/cyan]" + ) + if result.get("encrypted"): + formatter.console.print( + f" [dim]fingerprint={result['fingerprint']} prefix={result['encryption_prefix']}[/dim]" + ) + else: + formatter.console.print(f" value (plaintext, unencrypted): {result['value']}") + formatter.err_console.print( + " [yellow]Note:[/yellow] this value is stored unencrypted in the config. " + "Use `data-app secrets-set '#KEY=...'` to store sensitive values encrypted." + ) + if result.get("shadowed_by_runtime"): + # Same stdout/stderr-separation rationale as secrets-set: keep + # warnings off stdout so a script piping the metadata to a parser + # is unaffected. + formatter.err_console.print( + f" [yellow]Warning:[/yellow] {result['env_var']} is auto-injected by " + f"the data-app runtime; the platform value silently shadows yours. " + f"See {_REF_STORAGE_ACCESS}." + ) + + +def data_app_secrets_remove( + ctx: typer.Context, + project: str = typer.Option(..., "--project", help="Project alias"), + app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), + key: list[str] = typer.Option( + ..., + "--key", + help="Env-var key to remove (with optional '#' prefix). Repeatable.", + ), + branch: int | None = typer.Option( + None, + "--branch", + help="Storage branch ID for the linked config (defaults to production).", + ), + yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), + dry_run: bool = typer.Option( + False, "--dry-run", help="Preview the Storage PUT body without making the call." + ), +) -> None: + """Remove one or more app-runtime secrets. Idempotent (missing keys are exit 0). + + A removal can break the running app at the next deploy if it relied on + the secret; the command flags this in the response and never auto-deploys. + + Reference: https://help.keboola.com/data-apps/python-js/ + """ + + formatter = get_formatter(ctx) + service = get_service(ctx, "data_app_service") + + if ( + not yes + and not formatter.json_mode + and not dry_run + and not typer.confirm( + f"Remove {len(key)} secret(s) from data app {app_id} in '{project}'? " + "This may break the app at next deploy if it depends on these values." + ) + ): + formatter.console.print("Aborted.") + raise typer.Exit(code=0) + + try: + result = service.remove_data_app_secrets( + alias=project, + app_id=app_id, + keys=key, + branch_id=branch, + dry_run=dry_run, + ) + except KeboolaApiError as exc: + formatter.error( + message=exc.message, + error_code=exc.error_code, + retryable=exc.retryable, + details=exc.details, + ) + raise typer.Exit(code=map_error_to_exit_code(exc)) from None + except ConfigError as exc: + formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) + raise typer.Exit(code=5) from None + + formatter.output( + result, + lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}"), + ) + + +def register_secrets_commands(app: typer.Typer) -> None: + """Attach the data-app secrets-* commands to the data-app sub-app. + + Module-level command functions registered explicitly (rather than via + inline ``@app.command`` closures) keep the large bodies un-indented and + diff-friendly relative to their previous home in ``data_app.py``. + """ + app.command("secrets-set")(data_app_secrets_set) + app.command("secrets-list")(data_app_secrets_list) + app.command("secrets-get")(data_app_secrets_get) + app.command("secrets-remove")(data_app_secrets_remove) diff --git a/src/keboola_agent_cli/commands/data_app.py b/src/keboola_agent_cli/commands/data_app.py index 5b4088a3..51fa9a6c 100644 --- a/src/keboola_agent_cli/commands/data_app.py +++ b/src/keboola_agent_cli/commands/data_app.py @@ -10,7 +10,6 @@ from __future__ import annotations -import json import os from datetime import datetime from pathlib import Path @@ -22,6 +21,7 @@ from ..constants import DEFAULT_JOB_RUN_TIMEOUT from ..errors import ConfigError, ErrorCode, KeboolaApiError from ._data_app_git import register_git_commands +from ._data_app_runtime import register_secrets_commands from ._helpers import ( check_cli_permission, emit_project_warnings, @@ -726,413 +726,6 @@ def _print_logs(c: Console, d: dict) -> None: formatter.output(result, _print_logs) -# --------------------------------------------------------------------------- -# data-app secrets-{set|list|get|remove} -- flat commands matching the -# existing branch.metadata-* / config.variables-* pattern. Subgroups under -# Typer subgroups conflict with the flat permission/hint registry. -# --------------------------------------------------------------------------- - - -def _parse_secret_arg(arg: str) -> tuple[str, str]: - """Split ``#KEY=VALUE`` into ``(key, value)``. - - The value may contain ``=``; only the FIRST ``=`` is the separator. - """ - if "=" not in arg: - raise typer.BadParameter( - f"Expected '#KEY=VALUE'; got {arg!r} (no '=' separator).", - param_hint="--secret", - ) - key, _, value = arg.partition("=") - if not key: - raise typer.BadParameter( - f"Empty secret key in {arg!r}; expected '#KEY=VALUE'.", - param_hint="--secret", - ) - return key, value - - -def _read_secrets_file(path: Path) -> dict[str, str]: - try: - text = path.read_text(encoding="utf-8") - except OSError as exc: - raise typer.BadParameter( - f"Cannot read secrets file {path}: {exc}", - param_hint="--secrets-file", - ) from exc - try: - parsed = json.loads(text) - except json.JSONDecodeError as exc: - raise typer.BadParameter( - f"Secrets file {path} is not valid JSON: {exc}", - param_hint="--secrets-file", - ) from exc - if not isinstance(parsed, dict): - raise typer.BadParameter( - f"Secrets file {path} must be a JSON object mapping #KEY -> value.", - param_hint="--secrets-file", - ) - out: dict[str, str] = {} - for key, value in parsed.items(): - if not isinstance(key, str) or not isinstance(value, str): - raise typer.BadParameter( - f"Secrets file {path} contains non-string entry for {key!r}.", - param_hint="--secrets-file", - ) - out[key] = value - if not out: - raise typer.BadParameter( - f"Secrets file {path} is empty.", - param_hint="--secrets-file", - ) - return out - - -@data_app_app.command("secrets-set") -def data_app_secrets_set( - ctx: typer.Context, - project: str = typer.Option(..., "--project", help="Project alias"), - app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), - secret: list[str] | None = typer.Option( - None, - "--secret", - help=( - "One or more '#KEY=VALUE' plaintext entries. Repeatable. " - "Mutually exclusive with --secrets-file." - ), - ), - secrets_file: Path | None = typer.Option( - None, - "--secrets-file", - help="Path to a JSON file mapping '#KEY' -> 'plaintext value'.", - exists=True, - readable=True, - dir_okay=False, - ), - branch: int | None = typer.Option( - None, - "--branch", - help="Storage branch ID for the linked config (defaults to production).", - ), - allow_plaintext_on_encrypt_failure: bool = typer.Option( - False, - "--allow-plaintext-on-encrypt-failure", - help=( - "Bootstrap/debug only: write the value as-is if the Encryption API " - "did not return a project-scoped ciphertext. NEVER use in production." - ), - ), - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Show the encryption request and Storage PUT body without making either call.", - ), - no_hint_next: bool = typer.Option( - False, - "--no-hint-next", - help="Suppress the 'now run kbagent data-app deploy' hint in the output.", - ), -) -> None: - """Encrypt and write app-runtime secrets to the linked Storage config. - - The '#'-prefix is required on every key (Keboola encryption convention). - The runtime exposes each secret as an env var with '#' stripped, '-' - replaced with '_', and uppercased ('#my-api-key' -> 'MY_API_KEY'). - - The command never auto-deploys; the running container keeps the old - config until the next 'kbagent data-app deploy' call. - - Reference: https://help.keboola.com/data-apps/python-js/ - """ - - formatter = get_formatter(ctx) - service = get_service(ctx, "data_app_service") - - if secret and secrets_file: - formatter.error( - message=("--secret and --secrets-file are mutually exclusive; pick one input mode."), - error_code=ErrorCode.USAGE_ERROR, - ) - raise typer.Exit(code=2) from None - - if not secret and not secrets_file: - formatter.error( - message=("Provide at least one --secret '#KEY=VALUE' or --secrets-file PATH."), - error_code=ErrorCode.MISSING_PARAMETER, - ) - raise typer.Exit(code=2) from None - - secrets_map: dict[str, str] = {} - if secret: - for entry in secret: - try: - key, value = _parse_secret_arg(entry) - except typer.BadParameter as exc: - formatter.error( - message=str(exc), - error_code=ErrorCode.DATA_APP_INVALID_SECRET, - ) - raise typer.Exit(code=2) from None - secrets_map[key] = value - if secrets_file: - try: - secrets_map.update(_read_secrets_file(secrets_file)) - except typer.BadParameter as exc: - formatter.error( - message=str(exc), - error_code=ErrorCode.DATA_APP_INVALID_SECRET, - ) - raise typer.Exit(code=2) from None - - try: - result = service.set_data_app_secrets( - alias=project, - app_id=app_id, - secrets=secrets_map, - branch_id=branch, - allow_plaintext_on_encrypt_failure=allow_plaintext_on_encrypt_failure, - dry_run=dry_run, - ) - except KeboolaApiError as exc: - formatter.error( - message=exc.message, - error_code=exc.error_code, - retryable=exc.retryable, - details=exc.details, - ) - raise typer.Exit(code=map_error_to_exit_code(exc)) from None - except ConfigError as exc: - formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) - raise typer.Exit(code=5) from None - - # Reserved-name shadowing -- emit stderr WARN per collision so a - # script piping stdout to a JSON parser is unaffected. - shadowed = result.get("shadowed_by_runtime", []) - if shadowed and not formatter.json_mode: - for env_var in shadowed: - formatter.err_console.print( - f"[yellow]Warning:[/yellow] {env_var} is auto-injected by the data-app " - f"runtime; the platform value silently shadows yours. See {_REF_STORAGE_ACCESS}.", - style="yellow", - ) - - if no_hint_next and isinstance(result, dict): - result.pop("next_step", None) - - formatter.output( - result, - lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}"), - ) - if not no_hint_next and not formatter.json_mode and result.get("next_step"): - formatter.console.print(f"[dim]Next: {result['next_step']}[/dim]") - - -@data_app_app.command("secrets-list") -def data_app_secrets_list( - ctx: typer.Context, - project: str = typer.Option(..., "--project", help="Project alias"), - app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), - branch: int | None = typer.Option( - None, - "--branch", - help="Storage branch ID for the linked config (defaults to production).", - ), - show_fingerprint: bool = typer.Option( - False, - "--show-fingerprint", - help="Include a short ciphertext fingerprint per key. Default omits to keep --json safe to paste into tickets.", - ), -) -> None: - """List the keys in parameters.dataApp.secrets, with derived runtime env-var names. - - Never echoes the encrypted ciphertext in full and never decrypts. - - Reference: https://help.keboola.com/data-apps/python-js/ - """ - - formatter = get_formatter(ctx) - service = get_service(ctx, "data_app_service") - try: - result = service.list_data_app_secrets( - alias=project, - app_id=app_id, - branch_id=branch, - show_fingerprint=show_fingerprint, - ) - except KeboolaApiError as exc: - formatter.error( - message=exc.message, - error_code=exc.error_code, - retryable=exc.retryable, - details=exc.details, - ) - raise typer.Exit(code=map_error_to_exit_code(exc)) from None - except ConfigError as exc: - formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) - raise typer.Exit(code=5) from None - - if formatter.json_mode: - formatter.output(result) - return - - if not result["secrets"]: - formatter.console.print("[dim]No secrets set on this data app.[/dim]") - return - formatter.console.print( - f"\n[bold]{result['count']} secret(s)[/bold] on data app " - f"[cyan]{result['app_id']}[/cyan] in [magenta]{result['project_alias']}[/magenta]:" - ) - for entry in result["secrets"]: - marker = ( - " [yellow](shadowed by runtime)[/yellow]" if entry.get("shadowed_by_runtime") else "" - ) - line = f" [bold]{entry['key']}[/bold] -> env [cyan]{entry['env_var']}[/cyan]{marker}" - if "fingerprint" in entry: - line += f" [dim]fingerprint={entry['fingerprint']} prefix={entry.get('encryption_prefix', '')}[/dim]" - formatter.console.print(line) - - -@data_app_app.command("secrets-get") -def data_app_secrets_get( - ctx: typer.Context, - project: str = typer.Option(..., "--project", help="Project alias"), - app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), - key: str = typer.Option( - ..., "--key", help="Env-var key (with optional '#' prefix for encrypted secrets)." - ), - branch: int | None = typer.Option( - None, - "--branch", - help="Storage branch ID for the linked config (defaults to production).", - ), -) -> None: - """Show ONE key from parameters.dataApp.secrets. - - For an ENCRYPTED ('#') secret this is metadata only -- the Encryption - API has no decrypt endpoint, so the CLI never echoes the decrypted - value. For a PLAIN (unencrypted) config value the literal value is - shown; it is already stored in clear and visible via `config detail`. - - Reference: https://help.keboola.com/data-apps/python-js/ - """ - - formatter = get_formatter(ctx) - service = get_service(ctx, "data_app_service") - try: - result = service.get_data_app_secret( - alias=project, - app_id=app_id, - key=key, - branch_id=branch, - ) - except KeboolaApiError as exc: - formatter.error( - message=exc.message, - error_code=exc.error_code, - retryable=exc.retryable, - details=exc.details, - ) - raise typer.Exit(code=map_error_to_exit_code(exc)) from None - except ConfigError as exc: - formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) - raise typer.Exit(code=5) from None - - if formatter.json_mode: - formatter.output(result) - return - formatter.console.print( - f"\n[bold]{result['key']}[/bold] -> env [cyan]{result['env_var']}[/cyan]" - ) - if result.get("encrypted"): - formatter.console.print( - f" [dim]fingerprint={result['fingerprint']} prefix={result['encryption_prefix']}[/dim]" - ) - else: - formatter.console.print(f" value (plaintext, unencrypted): {result['value']}") - formatter.err_console.print( - " [yellow]Note:[/yellow] this value is stored unencrypted in the config. " - "Use `data-app secrets-set '#KEY=...'` to store sensitive values encrypted." - ) - if result.get("shadowed_by_runtime"): - # Same stdout/stderr-separation rationale as secrets-set: keep - # warnings off stdout so a script piping the metadata to a parser - # is unaffected. - formatter.err_console.print( - f" [yellow]Warning:[/yellow] {result['env_var']} is auto-injected by " - f"the data-app runtime; the platform value silently shadows yours. " - f"See {_REF_STORAGE_ACCESS}." - ) - - -@data_app_app.command("secrets-remove") -def data_app_secrets_remove( - ctx: typer.Context, - project: str = typer.Option(..., "--project", help="Project alias"), - app_id: str = typer.Option(..., "--app-id", help="Data Science numeric app id"), - key: list[str] = typer.Option( - ..., - "--key", - help="Env-var key to remove (with optional '#' prefix). Repeatable.", - ), - branch: int | None = typer.Option( - None, - "--branch", - help="Storage branch ID for the linked config (defaults to production).", - ), - yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt."), - dry_run: bool = typer.Option( - False, "--dry-run", help="Preview the Storage PUT body without making the call." - ), -) -> None: - """Remove one or more app-runtime secrets. Idempotent (missing keys are exit 0). - - A removal can break the running app at the next deploy if it relied on - the secret; the command flags this in the response and never auto-deploys. - - Reference: https://help.keboola.com/data-apps/python-js/ - """ - - formatter = get_formatter(ctx) - service = get_service(ctx, "data_app_service") - - if ( - not yes - and not formatter.json_mode - and not dry_run - and not typer.confirm( - f"Remove {len(key)} secret(s) from data app {app_id} in '{project}'? " - "This may break the app at next deploy if it depends on these values." - ) - ): - formatter.console.print("Aborted.") - raise typer.Exit(code=0) - - try: - result = service.remove_data_app_secrets( - alias=project, - app_id=app_id, - keys=key, - branch_id=branch, - dry_run=dry_run, - ) - except KeboolaApiError as exc: - formatter.error( - message=exc.message, - error_code=exc.error_code, - retryable=exc.retryable, - details=exc.details, - ) - raise typer.Exit(code=map_error_to_exit_code(exc)) from None - except ConfigError as exc: - formatter.error(message=exc.message, error_code=ErrorCode.CONFIG_ERROR) - raise typer.Exit(code=5) from None - - formatter.output( - result, - lambda c, d: c.print(f"[bold green]Success:[/bold green] {d['message']}"), - ) - - # --------------------------------------------------------------------------- # data-app validate-repo # --------------------------------------------------------------------------- @@ -1273,7 +866,9 @@ def data_app_validate_repo( raise typer.Exit(code=1) -# Attach the data-app git-* commands. They live in _data_app_git.py to keep -# this module under the file-size budget (CONTRIBUTING.md "File-size budgets"); -# they still register as `kbagent data-app git-*` on the same sub-app. +# Attach the data-app git-* and secrets-* commands. They live in +# _data_app_git.py / _data_app_runtime.py to keep this module under the +# file-size budget (CONTRIBUTING.md "File-size budgets"); they still register +# as `kbagent data-app git-*` / `secrets-*` on this sub-app. register_git_commands(data_app_app) +register_secrets_commands(data_app_app)