From 6746ae1e5a17ea724abd66a99aa064583b9fb954 Mon Sep 17 00:00:00 2001 From: Aaron Wirick Date: Wed, 19 Aug 2026 10:07:00 -0700 Subject: [PATCH] feat: add scaffolders commands for the public Scaffolder API Adds `cortex scaffolders list|get|create|update|delete` against /api/v1/scaffolders. The API is flag-gated server-side, so the tests skip when the tenant returns 403. Co-Authored-By: Claude Fable 5 --- cortexapps_cli/cli.py | 2 + cortexapps_cli/commands/scaffolders.py | 156 ++++++++++++++++++ .../cli-test-scaffolder-updated.yaml | 14 ++ .../scaffolders/cli-test-scaffolder.yaml | 14 ++ tests/test_scaffolders.py | 44 +++++ 5 files changed, 230 insertions(+) create mode 100644 cortexapps_cli/commands/scaffolders.py create mode 100644 data/import/scaffolders/cli-test-scaffolder-updated.yaml create mode 100644 data/import/scaffolders/cli-test-scaffolder.yaml create mode 100644 tests/test_scaffolders.py diff --git a/cortexapps_cli/cli.py b/cortexapps_cli/cli.py index 5f98f5c..634c9e8 100755 --- a/cortexapps_cli/cli.py +++ b/cortexapps_cli/cli.py @@ -39,6 +39,7 @@ import cortexapps_cli.commands.plugins as plugins import cortexapps_cli.commands.queries as queries import cortexapps_cli.commands.rest as rest +import cortexapps_cli.commands.scaffolders as scaffolders import cortexapps_cli.commands.scim as scim import cortexapps_cli.commands.scorecards as scorecards import cortexapps_cli.commands.secrets as secrets @@ -277,6 +278,7 @@ def version(): app.add_typer(plugins.app, name="plugins") app.add_typer(queries.app, name="queries") app.add_typer(rest.app, name="rest") +app.add_typer(scaffolders.app, name="scaffolders") app.add_typer(scim.app, name="scim") app.add_typer(scorecards.app, name="scorecards") app.add_typer(secrets.app, name="secrets") diff --git a/cortexapps_cli/commands/scaffolders.py b/cortexapps_cli/commands/scaffolders.py new file mode 100644 index 0000000..891ba51 --- /dev/null +++ b/cortexapps_cli/commands/scaffolders.py @@ -0,0 +1,156 @@ +from cortexapps_cli.command_options import CommandOptions +from cortexapps_cli.command_options import ListCommandOptions +from cortexapps_cli.utils import print_output_with_context, print_output +from typing_extensions import Annotated +import json +import typer +import yaml + +app = typer.Typer( + help="Scaffolder template commands", + no_args_is_help=True +) + +def _is_valid_yaml(filepath): + try: + yaml.safe_load(filepath) + filepath.seek(0) + return True + except yaml.YAMLError: + return False + +def _is_valid_json(filepath): + try: + json.load(filepath) + filepath.seek(0) + return True + except json.JSONDecodeError: + return False + +def _read_definition(file_input): + if _is_valid_json(file_input): + content_type = "application/json" + data = json.loads("".join([line for line in file_input])) + elif _is_valid_yaml(file_input): + data = file_input.read() + content_type = "application/yaml" + else: + raise typer.BadParameter("Input file is neither valid JSON nor YAML.") + return data, content_type + +@app.command() +def list( + ctx: typer.Context, + _print: CommandOptions._print = True, + page: ListCommandOptions.page = None, + page_size: ListCommandOptions.page_size = 250, + table_output: ListCommandOptions.table_output = False, + csv_output: ListCommandOptions.csv_output = False, + columns: ListCommandOptions.columns = [], + no_headers: ListCommandOptions.no_headers = False, + filters: ListCommandOptions.filters = [], + sort: ListCommandOptions.sort = [], +): + """ + List Scaffolder templates. + """ + + client = ctx.obj["client"] + + params = { + "page": page, + "pageSize": page_size + } + + if (table_output or csv_output) and not ctx.params.get('columns'): + ctx.params['columns'] = [ + "Tag=tag", + "Name=name", + "Description=description", + ] + + # remove any params that are None + params = {k: v for k, v in params.items() if v is not None} + + if page is None: + # if page is not specified, we want to fetch all pages + r = client.fetch("api/v1/scaffolders", params=params) + else: + # if page is specified, we want to fetch only that page + r = client.get("api/v1/scaffolders", params=params) + + if _print: + print_output_with_context(ctx, r) + else: + return(r) + +@app.command() +def get( + ctx: typer.Context, + tag: str = typer.Option(..., "--tag", "-t", help="The tag or unique, auto-generated Cortex ID of the Scaffolder template"), + yaml: bool = typer.Option(False, "--yaml", "-y", help="When true, returns the YAML representation of the template."), + _print: CommandOptions._print = True, +): + """ + Retrieve Scaffolder template by tag or Cortex ID. + """ + + client = ctx.obj["client"] + + if yaml: + headers={'Accept': 'application/yaml'} + else: + headers={'Accept': 'application/json'} + r = client.get("api/v1/scaffolders/" + tag, headers=headers) + + if _print: + if yaml: + print(r) + else: + print_output_with_context(ctx, r) + else: + return(r) + +@app.command() +def create( + ctx: typer.Context, + file_input: Annotated[typer.FileText, typer.Option(..., "--file", "-f", help="File containing the Scaffolder template definition; can be passed as stdin with -, example: -f-")], +): + """ + Create or update a Scaffolder template. API key must have the Configure Scaffolder permission. Note: If a Scaffolder template with the same tag already exists, it will be updated. + """ + + client = ctx.obj["client"] + + data, content_type = _read_definition(file_input) + r = client.post("api/v1/scaffolders", data=data, content_type=content_type) + print_output(r) + +@app.command() +def update( + ctx: typer.Context, + tag: Annotated[str, typer.Option(..., "--tag", "-t", help="The tag or unique, auto-generated Cortex ID of the Scaffolder template")], + file_input: Annotated[typer.FileText, typer.Option(..., "--file", "-f", help="File containing the Scaffolder template definition; can be passed as stdin with -, example: -f-")], +): + """ + Update a Scaffolder template by tag or Cortex ID. API key must have the Configure Scaffolder permission. + """ + + client = ctx.obj["client"] + + data, content_type = _read_definition(file_input) + r = client.put("api/v1/scaffolders/" + tag, data=data, content_type=content_type) + print_output(r) + +@app.command() +def delete( + ctx: typer.Context, + tag: str = typer.Option(..., "--tag", "-t", help="The tag or unique, auto-generated Cortex ID of the Scaffolder template"), +): + """ + Delete Scaffolder template by tag or Cortex ID. API key must have the Configure Scaffolder permission. + """ + + client = ctx.obj["client"] + + r = client.delete("api/v1/scaffolders/" + tag) diff --git a/data/import/scaffolders/cli-test-scaffolder-updated.yaml b/data/import/scaffolders/cli-test-scaffolder-updated.yaml new file mode 100644 index 0000000..655b762 --- /dev/null +++ b/data/import/scaffolders/cli-test-scaffolder-updated.yaml @@ -0,0 +1,14 @@ +tag: cli-test-scaffolder +name: CLI Test Scaffolder Updated +description: Created by the cortex-cli test suite; safe to delete. +type: COOKIECUTTER +repoLocator: + type: GITHUB + repoName: cli-test-scaffolder-template + org: cortextests + url: https://github.com/cortextests/cli-test-scaffolder-template +requireNewService: true +requirePullRequest: false +createYamlFile: true +showReadme: false +strictAliasUsage: false diff --git a/data/import/scaffolders/cli-test-scaffolder.yaml b/data/import/scaffolders/cli-test-scaffolder.yaml new file mode 100644 index 0000000..3a94f7e --- /dev/null +++ b/data/import/scaffolders/cli-test-scaffolder.yaml @@ -0,0 +1,14 @@ +tag: cli-test-scaffolder +name: CLI Test Scaffolder +description: Created by the cortex-cli test suite; safe to delete. +type: COOKIECUTTER +repoLocator: + type: GITHUB + repoName: cli-test-scaffolder-template + org: cortextests + url: https://github.com/cortextests/cli-test-scaffolder-template +requireNewService: true +requirePullRequest: false +createYamlFile: true +showReadme: false +strictAliasUsage: false diff --git a/tests/test_scaffolders.py b/tests/test_scaffolders.py new file mode 100644 index 0000000..5e85d1b --- /dev/null +++ b/tests/test_scaffolders.py @@ -0,0 +1,44 @@ +from tests.helpers.utils import * + +def _api_enabled(): + # The public Scaffolder API is gated by a feature flag; skip rather than + # fail when the test tenant does not have it enabled. + raw = cli(["scaffolders", "list"], return_type=ReturnType.RAW) + return raw.exit_code == 0 + +def test_list(): + if not _api_enabled(): + pytest.skip("Public Scaffolder API is not enabled for this tenant") + + response = cli(["scaffolders", "list"]) + assert "scaffolders" in response + +def test_crud(): + if not _api_enabled(): + pytest.skip("Public Scaffolder API is not enabled for this tenant") + + tag = "cli-test-scaffolder" + + # Creation validates the template repository through the tenant's git + # integration, so skip when the fixture repo is not reachable here. + raw = cli(["scaffolders", "create", "-f", "data/import/scaffolders/cli-test-scaffolder.yaml"], return_type=ReturnType.RAW) + if raw.exit_code != 0: + pytest.skip(f"Scaffolder create failed on this tenant (likely no git integration for the fixture repo): {raw.stdout}") + + try: + response = cli(["scaffolders", "list"]) + assert any(s['tag'] == tag for s in response['scaffolders']), f"Should find Scaffolder template with tag {tag}" + + response = cli(["scaffolders", "get", "-t", tag]) + assert response['tag'] == tag + + # Idempotent re-apply: the same definition upserts onto the same tag. + cli(["scaffolders", "create", "-f", "data/import/scaffolders/cli-test-scaffolder.yaml"]) + response = cli(["scaffolders", "get", "-t", tag]) + assert response['tag'] == tag + + cli(["scaffolders", "update", "-t", tag, "-f", "data/import/scaffolders/cli-test-scaffolder-updated.yaml"]) + response = cli(["scaffolders", "get", "-t", tag]) + assert response['name'] == "CLI Test Scaffolder Updated" + finally: + cli(["scaffolders", "delete", "-t", tag])