From 7a63bfed2e51cc51d71d6608042aff19befc81fe Mon Sep 17 00:00:00 2001 From: GitHub Actions Date: Wed, 5 Nov 2025 00:36:38 +0000 Subject: [PATCH 1/8] chore: update HISTORY.md for main --- HISTORY.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/HISTORY.md b/HISTORY.md index 0f3a9e8..14ba25d 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,17 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). +## [1.3.0](https://github.com/cortexapps/cli/releases/tag/1.3.0) - 2025-11-05 + +[Compare with 1.2.0](https://github.com/cortexapps/cli/compare/1.2.0...1.3.0) + +### Fixed + +- fix: add retry logic for scorecard create to handle active evaluations ([cc40b55](https://github.com/cortexapps/cli/commit/cc40b55ed9ef5af4146360b5a879afc6dc67fe06) by Jeff Schnitter). +- fix: use json.dump instead of Rich print for file writing ([c66c2fe](https://github.com/cortexapps/cli/commit/c66c2fe438cc95f8343fbd4ba3cecae605c435ea) by Jeff Schnitter). +- fix: ensure export/import output is in alphabetical order ([9055f78](https://github.com/cortexapps/cli/commit/9055f78cc4e1136da20e4e42883ff3c0f248825b) by Jeff Schnitter). +- fix: ensure CORTEX_BASE_URL is available in publish workflow ([743579d](https://github.com/cortexapps/cli/commit/743579d760e900da693696df2841e7b710b08d39) by Jeff Schnitter). + ## [1.2.0](https://github.com/cortexapps/cli/releases/tag/1.2.0) - 2025-11-04 [Compare with 1.1.0](https://github.com/cortexapps/cli/compare/1.1.0...1.2.0) From 8879fcfa7ee30a73f023e8bbef7d799808493319 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 5 Nov 2025 07:52:34 -0800 Subject: [PATCH 2/8] perf: optimize test scheduling with --dist loadfile for 25% faster test runs (#157) --- Justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Justfile b/Justfile index 2dca61a..de6a855 100644 --- a/Justfile +++ b/Justfile @@ -13,7 +13,7 @@ _setup: # Run all tests test-all: _setup test-import - {{pytest}} -n auto -m "not setup" --html=report.html --self-contained-html --cov=cortexapps_cli --cov-append --cov-report term-missing tests + {{pytest}} -n auto --dist loadfile -m "not setup" --html=report.html --self-contained-html --cov=cortexapps_cli --cov-append --cov-report term-missing tests # Run all tests serially - helpful to see if any tests seem to be hanging _test-all-individual: test-import From 8c1ba4fcc0d106dacbc595ecc13a95cd6995fd8d Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 5 Nov 2025 10:01:36 -0800 Subject: [PATCH 3/8] refactor: separate trigger-evaluation test to avoid scorecard evaluation race conditions - Create dedicated cli-test-evaluation-scorecard for trigger-evaluation testing - Remove retry logic complexity from test_scorecards() and test_scorecards_drafts() - Add new test_scorecard_trigger_evaluation() that creates/deletes its own scorecard - Eliminates race condition where import triggers evaluation conflicting with tests --- .../cli-test-evaluation-scorecard.yaml | 21 +++++++++ tests/test_scorecards.py | 46 +++++++------------ 2 files changed, 37 insertions(+), 30 deletions(-) create mode 100644 data/import/scorecards/cli-test-evaluation-scorecard.yaml diff --git a/data/import/scorecards/cli-test-evaluation-scorecard.yaml b/data/import/scorecards/cli-test-evaluation-scorecard.yaml new file mode 100644 index 0000000..2524796 --- /dev/null +++ b/data/import/scorecards/cli-test-evaluation-scorecard.yaml @@ -0,0 +1,21 @@ +tag: cli-test-evaluation-scorecard +name: CLI Test Evaluation Scorecard +description: Used to test Cortex CLI trigger-evaluation command +draft: false +ladder: + name: Default Ladder + levels: + - name: You Made It + rank: 1 + description: "My boring description" + color: 7cf376 +rules: +- title: Has Custom Data + expression: custom("testField") != null + weight: 1 + level: You Made It + filter: + category: SERVICE +filter: + query: 'entity.tag() == "cli-test-service"' + category: SERVICE diff --git a/tests/test_scorecards.py b/tests/test_scorecards.py index 801f556..f19ac48 100644 --- a/tests/test_scorecards.py +++ b/tests/test_scorecards.py @@ -11,18 +11,7 @@ def _get_rule(title): return rule_id[0] def test_scorecards(): - # Retry scorecard create in case there's an active evaluation - # (can happen if test_import.py just triggered an evaluation) - max_retries = 3 - for attempt in range(max_retries): - try: - cli(["scorecards", "create", "-f", "data/import/scorecards/cli-test-scorecard.yaml"]) - break - except Exception as e: - if "500" in str(e) and attempt < max_retries - 1: - time.sleep(2 ** attempt) # Exponential backoff: 1s, 2s - continue - raise + cli(["scorecards", "create", "-f", "data/import/scorecards/cli-test-scorecard.yaml"]) response = cli(["scorecards", "list"]) assert any(scorecard['tag'] == 'cli-test-scorecard' for scorecard in response['scorecards']), "Should find scorecard with tag cli-test-scorecard" @@ -39,33 +28,30 @@ def test_scorecards(): # cannot rely on a scorecard evaluation being complete, so not performing any validation cli(["scorecards", "next-steps", "-s", "cli-test-scorecard", "-t", "cli-test-service"]) - # Test trigger-evaluation command (accepts both success and 409 Already evaluating) - response = cli(["scorecards", "trigger-evaluation", "-s", "cli-test-scorecard", "-e", "cli-test-service"], return_type=ReturnType.STDOUT) - assert ("Scorecard evaluation triggered successfully" in response or "Already evaluating scorecard" in response), \ - "Should receive success message or 409 Already evaluating error" - # cannot rely on a scorecard evaluation being complete, so not performing any validation #response = cli(["scorecards", "scores", "-s", "cli-test-scorecard", "-t", "cli-test-service"]) #assert response['scorecardTag'] == "cli-test-scorecard", "Should get valid response that include cli-test-scorecard" - + # # Not sure if we can run this cli right away. Newly-created Scorecard might not be evaluated yet. # # 2024-05-06, additionally now blocked by CET-8882 # # cli(["scorecards", "scores", "-t", "cli-test-scorecard", "-e", "cli-test-service"]) # # cli(["scorecards", "scores", "-t", "cli-test-scorecard"]) - + +def test_scorecard_trigger_evaluation(): + # Create a dedicated scorecard for trigger-evaluation testing to avoid conflicts with import + cli(["scorecards", "create", "-f", "data/import/scorecards/cli-test-evaluation-scorecard.yaml"]) + + # Test trigger-evaluation command (accepts both success and 409 Already evaluating) + response = cli(["scorecards", "trigger-evaluation", "-s", "cli-test-evaluation-scorecard", "-e", "cli-test-service"], return_type=ReturnType.STDOUT) + assert ("Scorecard evaluation triggered successfully" in response or "Already evaluating scorecard" in response), \ + "Should receive success message or 409 Already evaluating error" + + # Clean up + cli(["scorecards", "delete", "-s", "cli-test-evaluation-scorecard"]) + def test_scorecards_drafts(): - # Retry scorecard create in case there's an active evaluation - max_retries = 3 - for attempt in range(max_retries): - try: - cli(["scorecards", "create", "-f", "data/import/scorecards/cli-test-draft-scorecard.yaml"]) - break - except Exception as e: - if "500" in str(e) and attempt < max_retries - 1: - time.sleep(2 ** attempt) # Exponential backoff: 1s, 2s - continue - raise + cli(["scorecards", "create", "-f", "data/import/scorecards/cli-test-draft-scorecard.yaml"]) response = cli(["scorecards", "list", "-s"]) assert any(scorecard['tag'] == 'cli-test-draft-scorecard' for scorecard in response['scorecards']) From 3e09a81e22ea3aed35ee780c605f108bf176b305 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 5 Nov 2025 11:17:59 -0800 Subject: [PATCH 4/8] refactor: remove unnecessary mock decorator from _get_rule helper function The helper function doesn't need its own environment patching since it's called from fixtures that already have their own @mock.patch.dict decorators. --- tests/test_scorecards.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/tests/test_scorecards.py b/tests/test_scorecards.py index f19ac48..7b5c991 100644 --- a/tests/test_scorecards.py +++ b/tests/test_scorecards.py @@ -4,7 +4,6 @@ # Get rule id to be used in exemption tests. # TODO: check for and revoke any PENDING exemptions. -@mock.patch.dict(os.environ, {"CORTEX_API_KEY": os.environ['CORTEX_API_KEY']}) def _get_rule(title): response = cli(["scorecards", "get", "-s", "cli-test-scorecard"]) rule_id = [rule['identifier'] for rule in response['scorecard']['rules'] if rule['title'] == title] @@ -66,7 +65,10 @@ def test_scorecards_drafts(): # testing assumes no tenanted data, so this condition needs to be created as part of the test # # - there is no public API to force evaluation of a scorecard; can look into possibility of using -# an internal endpoint for this +# an internal endpoint for this +# +# Nov 2025 - there is a public API to force evaluation of a scorecard for an entity, but there is +# not a way to determine when the evaluation completes. # # - could create a scorecard as part of the test and wait for it to complete, but completion time for # evaluating a scorecard is non-deterministic and, as experienced with query API tests, completion @@ -82,6 +84,7 @@ def test_scorecards_drafts(): # So this is how we'll roll for now . . . # - Automated tests currently run in known tenants that have the 'cli-test-scorecard' in an evaluated state. # - So we can semi-reliably count on an evaluated scorecard to exist. +# - However, we should be cleaning up test data after tests run which would invalidate these assumptions. @pytest.fixture(scope='session') @mock.patch.dict(os.environ, {"CORTEX_API_KEY": os.environ['CORTEX_API_KEY_VIEWER']}) From c03fa2280ab86fa6b0945dbff1097a67670d39b3 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 5 Nov 2025 11:25:09 -0800 Subject: [PATCH 5/8] Revert "perf: optimize test scheduling with --dist loadfile for 25% faster test runs (#157)" This reverts commit 8879fcfa7ee30a73f023e8bbef7d799808493319. The --dist loadfile optimization caused race conditions between tests that share resources (e.g., test_custom_events_uuid and test_custom_events_list both operate on custom events and can interfere with each other when run in parallel by file). Reliability > speed. Better to have tests take 40s with no race conditions than 30s with intermittent failures. --- Justfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Justfile b/Justfile index de6a855..2dca61a 100644 --- a/Justfile +++ b/Justfile @@ -13,7 +13,7 @@ _setup: # Run all tests test-all: _setup test-import - {{pytest}} -n auto --dist loadfile -m "not setup" --html=report.html --self-contained-html --cov=cortexapps_cli --cov-append --cov-report term-missing tests + {{pytest}} -n auto -m "not setup" --html=report.html --self-contained-html --cov=cortexapps_cli --cov-append --cov-report term-missing tests # Run all tests serially - helpful to see if any tests seem to be hanging _test-all-individual: test-import From f36aae22f56317cde70a6a9df56b097edb6a6117 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 5 Nov 2025 11:30:02 -0800 Subject: [PATCH 6/8] perf: rename test_deploys.py to test_000_deploys.py for early scheduling Pytest collects tests alphabetically by filename. With pytest-xdist --dist load, tests collected earlier are more likely to be scheduled first. Since test_deploys is the longest-running test (~19s), scheduling it early maximizes parallel efficiency with 12 workers. This is our general strategy: prefix slow tests with numbers (000, 001, etc.) to control scheduling order without introducing race conditions like --dist loadfile. --- tests/{test_deploys.py => test_000_deploys.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/{test_deploys.py => test_000_deploys.py} (100%) diff --git a/tests/test_deploys.py b/tests/test_000_deploys.py similarity index 100% rename from tests/test_deploys.py rename to tests/test_000_deploys.py From 87ee819ff004d1298554b8e433a516a228255d9a Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 5 Nov 2025 13:02:15 -0800 Subject: [PATCH 7/8] feat: add support for Cortex Secrets API Add complete CLI support for managing secrets via the Cortex Secrets API: - cortex secrets list: List secrets (with optional entity tag filter) - cortex secrets get: Get secret by alias - cortex secrets create: Create new secret - cortex secrets update: Update existing secret - cortex secrets delete: Delete secret All commands support entity tags as required by the API. Tests skip gracefully if API key lacks secrets permissions. Also fixes HISTORY.md generation by using Angular convention in git-changelog, which properly recognizes feat:, fix:, and perf: commit types instead of only recognizing the basic convention (add:, fix:, change:, remove:). Closes #158 --- .github/workflows/publish.yml | 2 +- cortexapps_cli/cli.py | 2 + cortexapps_cli/commands/secrets.py | 105 +++++++++++++++++++++++++++++ data/run-time/secret-create.json | 5 ++ data/run-time/secret-update.json | 4 ++ tests/test_secrets.py | 42 ++++++++++++ 6 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 cortexapps_cli/commands/secrets.py create mode 100644 data/run-time/secret-create.json create mode 100644 data/run-time/secret-update.json create mode 100644 tests/test_secrets.py diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 5d88da0..8ef0dc3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -53,7 +53,7 @@ jobs: - name: Generate HISTORY.md run: | - git-changelog > HISTORY.md + git-changelog -c angular > HISTORY.md cat HISTORY.md - name: Commit and Push diff --git a/cortexapps_cli/cli.py b/cortexapps_cli/cli.py index 03d471d..40b511f 100755 --- a/cortexapps_cli/cli.py +++ b/cortexapps_cli/cli.py @@ -36,6 +36,7 @@ import cortexapps_cli.commands.rest as rest import cortexapps_cli.commands.scim as scim import cortexapps_cli.commands.scorecards as scorecards +import cortexapps_cli.commands.secrets as secrets import cortexapps_cli.commands.teams as teams import cortexapps_cli.commands.workflows as workflows @@ -70,6 +71,7 @@ app.add_typer(rest.app, name="rest") app.add_typer(scim.app, name="scim") app.add_typer(scorecards.app, name="scorecards") +app.add_typer(secrets.app, name="secrets") app.add_typer(teams.app, name="teams") app.add_typer(workflows.app, name="workflows") diff --git a/cortexapps_cli/commands/secrets.py b/cortexapps_cli/commands/secrets.py new file mode 100644 index 0000000..53162fc --- /dev/null +++ b/cortexapps_cli/commands/secrets.py @@ -0,0 +1,105 @@ +import typer +import json +from typing_extensions import Annotated +from cortexapps_cli.utils import print_output_with_context +from cortexapps_cli.command_options import ListCommandOptions + +app = typer.Typer( + help="Secrets commands", + no_args_is_help=True +) + +@app.command() +def list( + ctx: typer.Context, + 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 secrets + """ + 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'] = [ + "ID=id", + "Name=name", + "Tag=tag", + ] + + # remove any params that are None + params = {k: v for k, v in params.items() if v is not None} + + if page is None: + r = client.fetch("api/v1/secrets", params=params) + else: + r = client.get("api/v1/secrets", params=params) + print_output_with_context(ctx, r) + +@app.command() +def get( + ctx: typer.Context, + tag_or_id: str = typer.Option(..., "--tag-or-id", "-t", help="Secret tag or ID"), +): + """ + Get a secret by tag or ID + """ + client = ctx.obj["client"] + r = client.get(f"api/v1/secrets/{tag_or_id}") + print_output_with_context(ctx, r) + +@app.command() +def create( + ctx: typer.Context, + file_input: Annotated[typer.FileText, typer.Option("--file", "-f", help="File containing secret definition (name, secret, tag); can be passed as stdin with -, example: -f-")] = ..., +): + """ + Create a secret + + Provide a JSON file with the secret definition including required fields: + - name: human-readable label for the secret + - secret: the actual secret value + - tag: unique identifier for the secret + """ + client = ctx.obj["client"] + data = json.loads("".join([line for line in file_input])) + r = client.post("api/v1/secrets", data=data) + print_output_with_context(ctx, r) + +@app.command() +def update( + ctx: typer.Context, + tag_or_id: str = typer.Option(..., "--tag-or-id", "-t", help="Secret tag or ID"), + file_input: Annotated[typer.FileText, typer.Option("--file", "-f", help="File containing fields to update (name, secret); can be passed as stdin with -, example: -f-")] = ..., +): + """ + Update a secret + + Provide a JSON file with the fields to update (name and/or secret are optional). + """ + client = ctx.obj["client"] + data = json.loads("".join([line for line in file_input])) + r = client.put(f"api/v1/secrets/{tag_or_id}", data=data) + print_output_with_context(ctx, r) + +@app.command() +def delete( + ctx: typer.Context, + tag_or_id: str = typer.Option(..., "--tag-or-id", "-t", help="Secret tag or ID"), +): + """ + Delete a secret + """ + client = ctx.obj["client"] + client.delete(f"api/v1/secrets/{tag_or_id}") diff --git a/data/run-time/secret-create.json b/data/run-time/secret-create.json new file mode 100644 index 0000000..de26a97 --- /dev/null +++ b/data/run-time/secret-create.json @@ -0,0 +1,5 @@ +{ + "tag": "cli-test-secret", + "name": "CLI Test Secret", + "secret": "test-secret-value-12345" +} diff --git a/data/run-time/secret-update.json b/data/run-time/secret-update.json new file mode 100644 index 0000000..e28781b --- /dev/null +++ b/data/run-time/secret-update.json @@ -0,0 +1,4 @@ +{ + "name": "Updated CLI Test Secret", + "secret": "updated-secret-value-67890" +} diff --git a/tests/test_secrets.py b/tests/test_secrets.py new file mode 100644 index 0000000..9b8eb5b --- /dev/null +++ b/tests/test_secrets.py @@ -0,0 +1,42 @@ +from tests.helpers.utils import * +import pytest + +def test(): + # Skip test if API key doesn't have secrets permissions + # The Secrets API may require special permissions or may not be available in all environments + try: + # Try to list secrets first to check if we have permission + response = cli(["secrets", "list"], return_type=ReturnType.RAW) + if response.exit_code != 0 and "403" in response.stdout: + pytest.skip("API key does not have permission to access Secrets API") + except Exception as e: + if "403" in str(e) or "Forbidden" in str(e): + pytest.skip("API key does not have permission to access Secrets API") + + # Create a secret + response = cli(["secrets", "create", "-f", "data/run-time/secret-create.json"]) + assert response['tag'] == 'cli-test-secret', "Should create secret with tag cli-test-secret" + assert response['name'] == 'CLI Test Secret', "Should have correct name" + + # List secrets and verify it exists + response = cli(["secrets", "list"]) + assert any(secret['tag'] == 'cli-test-secret' for secret in response['secrets']), "Should find secret with tag cli-test-secret" + + # Get the secret + response = cli(["secrets", "get", "-t", "cli-test-secret"]) + assert response['tag'] == 'cli-test-secret', "Should get secret with correct tag" + assert response['name'] == 'CLI Test Secret', "Should have correct name" + + # Update the secret + cli(["secrets", "update", "-t", "cli-test-secret", "-f", "data/run-time/secret-update.json"]) + + # Verify the update + response = cli(["secrets", "get", "-t", "cli-test-secret"]) + assert response['name'] == 'Updated CLI Test Secret', "Should have updated name" + + # Delete the secret + cli(["secrets", "delete", "-t", "cli-test-secret"]) + + # Verify deletion by checking list + response = cli(["secrets", "list"]) + assert not any(secret['tag'] == 'cli-test-secret' for secret in response['secrets']), "Should not find deleted secret" From b6ad08cf2a3bb1535528be5c1d9def97e641109d Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 5 Nov 2025 16:53:24 -0800 Subject: [PATCH 8/8] fix: update secret test to use valid tag format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Change test secret tag from 'cli-test-secret' to 'cli_test_secret' to comply with API validation that only allows alphanumeric and underscore characters. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- data/run-time/secret-create.json | 2 +- tests/test_secrets.py | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/data/run-time/secret-create.json b/data/run-time/secret-create.json index de26a97..f4e803e 100644 --- a/data/run-time/secret-create.json +++ b/data/run-time/secret-create.json @@ -1,5 +1,5 @@ { - "tag": "cli-test-secret", + "tag": "cli_test_secret", "name": "CLI Test Secret", "secret": "test-secret-value-12345" } diff --git a/tests/test_secrets.py b/tests/test_secrets.py index 9b8eb5b..6145c21 100644 --- a/tests/test_secrets.py +++ b/tests/test_secrets.py @@ -15,28 +15,28 @@ def test(): # Create a secret response = cli(["secrets", "create", "-f", "data/run-time/secret-create.json"]) - assert response['tag'] == 'cli-test-secret', "Should create secret with tag cli-test-secret" + assert response['tag'] == 'cli_test_secret', "Should create secret with tag cli_test_secret" assert response['name'] == 'CLI Test Secret', "Should have correct name" # List secrets and verify it exists response = cli(["secrets", "list"]) - assert any(secret['tag'] == 'cli-test-secret' for secret in response['secrets']), "Should find secret with tag cli-test-secret" + assert any(secret['tag'] == 'cli_test_secret' for secret in response['secrets']), "Should find secret with tag cli_test_secret" # Get the secret - response = cli(["secrets", "get", "-t", "cli-test-secret"]) - assert response['tag'] == 'cli-test-secret', "Should get secret with correct tag" + response = cli(["secrets", "get", "-t", "cli_test_secret"]) + assert response['tag'] == 'cli_test_secret', "Should get secret with correct tag" assert response['name'] == 'CLI Test Secret', "Should have correct name" # Update the secret - cli(["secrets", "update", "-t", "cli-test-secret", "-f", "data/run-time/secret-update.json"]) + cli(["secrets", "update", "-t", "cli_test_secret", "-f", "data/run-time/secret-update.json"]) # Verify the update - response = cli(["secrets", "get", "-t", "cli-test-secret"]) + response = cli(["secrets", "get", "-t", "cli_test_secret"]) assert response['name'] == 'Updated CLI Test Secret', "Should have updated name" # Delete the secret - cli(["secrets", "delete", "-t", "cli-test-secret"]) + cli(["secrets", "delete", "-t", "cli_test_secret"]) # Verify deletion by checking list response = cli(["secrets", "list"]) - assert not any(secret['tag'] == 'cli-test-secret' for secret in response['secrets']), "Should not find deleted secret" + assert not any(secret['tag'] == 'cli_test_secret' for secret in response['secrets']), "Should not find deleted secret"