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/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)
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/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/data/run-time/secret-create.json b/data/run-time/secret-create.json
new file mode 100644
index 0000000..f4e803e
--- /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_deploys.py b/tests/test_000_deploys.py
similarity index 100%
rename from tests/test_deploys.py
rename to tests/test_000_deploys.py
diff --git a/tests/test_scorecards.py b/tests/test_scorecards.py
index 801f556..7b5c991 100644
--- a/tests/test_scorecards.py
+++ b/tests/test_scorecards.py
@@ -4,25 +4,13 @@
# 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]
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 +27,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'])
@@ -80,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
@@ -96,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']})
diff --git a/tests/test_secrets.py b/tests/test_secrets.py
new file mode 100644
index 0000000..6145c21
--- /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"