diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 8ef0dc3..9bec499 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -66,18 +66,30 @@ jobs: git push fi + - name: Sync HISTORY.md to staging + run: | + # Sync the HISTORY.md update to staging to prevent merge conflicts + # when feature branches (created from main) are merged to staging + git fetch origin staging + git checkout staging + git checkout main -- HISTORY.md + if git diff --exit-code HISTORY.md; then + echo "staging HISTORY.md already up to date" + else + git add HISTORY.md + git commit -m "chore: sync HISTORY.md from main" + git push origin staging + fi + git checkout main + - name: Git details about version id: git-details - env: - GITHUB_CONTEXT: ${{ toJson(github) }} run: | version=$(git describe --tags --abbrev=0) echo "VERSION=${version}" >> $GITHUB_ENV echo "VERSION=${version}" >> $GITHUB_OUTPUT - pusher=$(echo "$GITHUB_CONTEXT" | jq -r ".event.pusher.name") - email=$(echo "$GITHUB_CONTEXT" | jq -r ".event.pusher.email") - echo "PUSHER=${pusher}" >> $GITHUB_OUTPUT - echo "EMAIL=${email}" >> $GITHUB_OUTPUT + echo "PUSHER=${{ github.event.pusher.name }}" >> $GITHUB_OUTPUT + echo "EMAIL=${{ github.event.pusher.email }}" >> $GITHUB_OUTPUT echo "URL=https://pypi.org/project/cortexapps-cli/${version}/" >> $GITHUB_OUTPUT - name: Publish diff --git a/CLAUDE.md b/CLAUDE.md index ec9250f..1cd9996 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -141,12 +141,18 @@ Use the GitHub-recommended format: `-` ### Release Workflow 1. Create feature branch for changes -2. Merge to `staging` branch for testing -3. Merge `staging` to `main` to trigger release -4. Version bumping: +2. Create PR to merge feature branch to `staging` for testing +3. Create PR to merge `staging` to `main` to trigger release: + ```bash + gh pr create --base main --head staging --title "Release X.Y.Z: Description #patch|#minor|#major" + ``` + - Include version number and brief description in title + - Use `#patch`, `#minor`, or `#major` in the title to control version bump + - List all changes in the PR body +4. Version bumping (based on hashtag in PR title or commit message): - Default: Patch version bump - - `#minor` in commit message: Minor version bump - - `#major` in commit message: Major version bump + - `#minor`: Minor version bump + - `#major`: Major version bump 5. Release publishes to: - PyPI - Docker Hub (`cortexapp/cli:VERSION` and `cortexapp/cli:latest`) @@ -171,6 +177,18 @@ Commits should be prefixed with: Only commits with these prefixes appear in the auto-generated `HISTORY.md`. +### HISTORY.md Merge Conflicts +The `HISTORY.md` file is auto-generated when `staging` is merged to `main`. This means: +- `main` always has the latest HISTORY.md +- `staging` lags behind until the next release +- Feature branches created from `main` have the updated history + +When merging feature branches to `staging`, conflicts in HISTORY.md are expected. Resolve by accepting the incoming version: +```bash +git checkout --theirs HISTORY.md +git add HISTORY.md +``` + ### GitHub Actions - **`publish.yml`**: Triggered on push to `main`, handles versioning and multi-platform publishing - **`test-pr.yml`**: Runs tests on pull requests diff --git a/HISTORY.md b/HISTORY.md index d6bce84..089da2e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -6,6 +6,18 @@ 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.9.0](https://github.com/cortexapps/cli/releases/tag/1.9.0) - 2026-01-12 + +[Compare with 1.8.0](https://github.com/cortexapps/cli/compare/1.8.0...1.9.0) + +## [1.8.0](https://github.com/cortexapps/cli/releases/tag/1.8.0) - 2026-01-12 + +[Compare with 1.7.0](https://github.com/cortexapps/cli/compare/1.7.0...1.8.0) + +### Bug Fixes + +- Update urllib3 to address CVE-2025-66418 and CVE-2025-66471 #patch (#188) ([4fba98b](https://github.com/cortexapps/cli/commit/4fba98bf12083faa030dfb84b2db325d55ae9afc) by Jeff Schnitter). + ## [1.7.0](https://github.com/cortexapps/cli/releases/tag/1.7.0) - 2025-11-19 [Compare with 1.6.0](https://github.com/cortexapps/cli/compare/1.6.0...1.7.0) @@ -18,6 +30,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - remove rate limiter initialization log message (#169) #patch ([015107a](https://github.com/cortexapps/cli/commit/015107aca15d5a4cf4eb746834bcbb7dac607e1d) by Jeff Schnitter). + ## [1.5.0](https://github.com/cortexapps/cli/releases/tag/1.5.0) - 2025-11-13 [Compare with 1.4.0](https://github.com/cortexapps/cli/compare/1.4.0...1.5.0) diff --git a/cortexapps_cli/commands/backup.py b/cortexapps_cli/commands/backup.py index 0b39592..2c4fe9d 100644 --- a/cortexapps_cli/commands/backup.py +++ b/cortexapps_cli/commands/backup.py @@ -698,3 +698,7 @@ def import_tenant( print(f"cortex scorecards create -f \"{file_path}\"") elif import_type == "workflows": print(f"cortex workflows create -f \"{file_path}\"") + + # Exit with non-zero code if any imports failed + if total_failed > 0: + raise typer.Exit(1) diff --git a/cortexapps_cli/commands/integrations_commands/newrelic.py b/cortexapps_cli/commands/integrations_commands/newrelic.py index ae0dd46..80d113f 100644 --- a/cortexapps_cli/commands/integrations_commands/newrelic.py +++ b/cortexapps_cli/commands/integrations_commands/newrelic.py @@ -1,16 +1,24 @@ +from enum import Enum import json from rich import print_json import typer from typing_extensions import Annotated +from cortexapps_cli.command_options import ListCommandOptions +from cortexapps_cli.utils import print_output_with_context app = typer.Typer(help="New Relic commands", no_args_is_help=True) -@app.command() +class Region(str, Enum): + US = "US" + EU = "EU" + +@app.command(no_args_is_help=True) def add( ctx: typer.Context, - alias: str = typer.Option(..., "--alias", "-a", help="Alias for this configuration"), - api_key: str = typer.Option(..., "--api-key", "-api", help="API key"), - host: str = typer.Option(None, "--host", "-h", help="Optional host name"), + alias: str = typer.Option(None, "--alias", "-a", help="Alias for this configuration"), + account_id: str = typer.Option(None, "--account-id", "-acc", help="New Relic account ID"), + personal_key: str = typer.Option(None, "--personal-key", "-pk", help="New Relic personal API key"), + region: Region = typer.Option(Region.US, "--region", "-r", help="Region (US or EU)"), is_default: bool = typer.Option(False, "--is-default", "-i", help="If this is the default configuration"), file_input: Annotated[typer.FileText, typer.Option("--file", "-f", help="JSON file containing configurations, if command line options not used; can be passed as stdin with -, example: -f-")] = None, ): @@ -21,40 +29,56 @@ def add( client = ctx.obj["client"] if file_input: - if alias or api_key or is_default or host: - raise typer.BadParameter("When providing a custom event definition file, do not specify any other custom event attributes") + if alias or account_id or personal_key: + raise typer.BadParameter("When providing a file, do not specify --alias, --account-id, or --personal-key") data = json.loads("".join([line for line in file_input])) else: + if not alias or not account_id or not personal_key: + raise typer.BadParameter("--alias, --account-id, and --personal-key are required when not using --file") + if not personal_key.startswith("NRAK"): + raise typer.BadParameter("--personal-key must start with 'NRAK'") data = { "alias": alias, - "apiKey": api_key, - "host": host, + "accountId": account_id, + "personalKey": personal_key, + "region": region.value, "isDefault": is_default, - } + } - # remove any data elements that are None - can only be is_default - data = {k: v for k, v in data.items() if v is not None} - r = client.post("api/v1/newrelic/configuration", data=data) print_json(data=r) -@app.command() +@app.command(no_args_is_help=True) def add_multiple( ctx: typer.Context, file_input: Annotated[typer.FileText, typer.Option("--file", "-f", help="JSON file containing configurations; can be passed as stdin with -, example: -f-")] = None, ): """ Add multiple configurations + + JSON file format: + \b + { + "configurations": [ + { + "accountId": 1, + "alias": "text", + "isDefault": true, + "personalKey": "text", + "region": "US" + } + ] + } """ client = ctx.obj["client"] data = json.loads("".join([line for line in file_input])) - r = client.put("api/v1/newrelic/configurations", data=data) + r = client.post("api/v1/newrelic/configurations", data=data) print_json(data=r) -@app.command() +@app.command(no_args_is_help=True) def delete( ctx: typer.Context, alias: str = typer.Option(..., "--alias", "-a", help="The alias of the configuration"), @@ -81,7 +105,7 @@ def delete_all( r = client.delete("api/v1/newrelic/configurations") print_json(data=r) -@app.command() +@app.command(no_args_is_help=True) def get( ctx: typer.Context, alias: str = typer.Option(..., "--alias", "-a", help="The alias of the configuration"), @@ -98,6 +122,12 @@ def get( @app.command() def list( ctx: typer.Context, + 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 = [], ): """ Get all configurations @@ -105,8 +135,16 @@ def list( client = ctx.obj["client"] + if (table_output or csv_output) and not ctx.params.get('columns'): + ctx.params['columns'] = [ + "Alias=alias", + "AccountId=accountId", + "Region=region", + "IsDefault=isDefault", + ] + r = client.get("api/v1/newrelic/configurations") - print_json(data=r) + print_output_with_context(ctx, r) @app.command() def get_default( @@ -122,7 +160,7 @@ def get_default( print_json(data=r) -@app.command() +@app.command(no_args_is_help=True) def update( ctx: typer.Context, alias: str = typer.Option(..., "--alias", "-a", help="The alias of the configuration"), @@ -142,7 +180,7 @@ def update( r = client.put("api/v1/newrelic/configuration/" + alias, data=data) print_json(data=r) -@app.command() +@app.command(no_args_is_help=True) def validate( ctx: typer.Context, alias: str = typer.Option(..., "--alias", "-a", help="The alias of the configuration"), @@ -153,7 +191,7 @@ def validate( client = ctx.obj["client"] - r = client.post("api/v1/newrelic/configurations/validate" + alias) + r = client.post("api/v1/newrelic/configuration/validate/" + alias) print_json(data=r) @app.command() @@ -166,5 +204,5 @@ def validate_all( client = ctx.obj["client"] - r = client.post("api/v1/newrelic/configurations") + r = client.post("api/v1/newrelic/configuration/validate") print_json(data=r) diff --git a/cortexapps_cli/cortex_client.py b/cortexapps_cli/cortex_client.py index 9692efd..5a7e349 100644 --- a/cortexapps_cli/cortex_client.py +++ b/cortexapps_cli/cortex_client.py @@ -149,6 +149,23 @@ def request(self, method, endpoint, params={}, headers={}, data=None, raw_body=F # try to parse the error message error = response.json() status = response.status_code + + # Check for validation error format with violations array + if 'violations' in error and isinstance(error['violations'], list): + print(f'[red][bold]HTTP Error {status}[/bold][/red]: Validation failed') + for violation in error['violations']: + title = violation.get('title', 'Validation Error') + description = violation.get('description', 'No description') + violation_type = violation.get('violationType', '') + pointer = violation.get('pointer', '') + print(f' [yellow]{title}[/yellow]: {description}') + if pointer: + print(f' [dim]Location: {pointer}[/dim]') + if violation_type: + print(f' [dim]Type: {violation_type}[/dim]') + raise typer.Exit(code=1) + + # Standard error format with message/details message = error.get('message', 'Unknown error') details = error.get('details', 'No details') request_id = error.get('requestId', 'No request ID') @@ -156,8 +173,12 @@ def request(self, method, endpoint, params={}, headers={}, data=None, raw_body=F print(error_str) raise typer.Exit(code=1) except json.JSONDecodeError: - # if we can't parse the error message, just raise the HTTP error - response.raise_for_status() + # if we can't parse the error message, print a clean error and exit + status = response.status_code + reason = response.reason or 'Unknown error' + error_str = f'[red][bold]HTTP Error {status}[/bold][/red]: {reason}' + print(error_str) + raise typer.Exit(code=1) if raw_response: return response diff --git a/data/import/entity-types/cli-test.json b/data/import/entity-types/cli-test.json index 132a29e..0628c4c 100644 --- a/data/import/entity-types/cli-test.json +++ b/data/import/entity-types/cli-test.json @@ -1,5 +1,6 @@ { "description": "This is a test entity type definition.", + "iconTag": "Cortex-builtin::Basketball", "name": "CLI Test With Empty Schema", "schema": {}, "type": "cli-test" diff --git a/data/run-time/entity-type-invalid-icon.json b/data/run-time/entity-type-invalid-icon.json new file mode 100644 index 0000000..7d37ddf --- /dev/null +++ b/data/run-time/entity-type-invalid-icon.json @@ -0,0 +1,7 @@ +{ + "description": "This is a test entity type definition with invalid icon.", + "iconTag": "invalidIcon", + "name": "CLI Test With Invalid Icon", + "schema": {}, + "type": "cli-test-invalid-icon" +} diff --git a/tests/test_backup.py b/tests/test_backup.py new file mode 100644 index 0000000..be9c138 --- /dev/null +++ b/tests/test_backup.py @@ -0,0 +1,40 @@ +from tests.helpers.utils import * +import os +import tempfile + +def test_backup_import_invalid_api_key(monkeypatch): + """ + Test that backup import exits with non-zero return code when API calls fail. + """ + monkeypatch.setenv("CORTEX_API_KEY", "invalidKey") + + # Create a temp directory with a catalog subdirectory and a simple yaml file + with tempfile.TemporaryDirectory() as tmpdir: + catalog_dir = os.path.join(tmpdir, "catalog") + os.makedirs(catalog_dir) + + # Create a minimal catalog entity file + entity_file = os.path.join(catalog_dir, "test-entity.yaml") + with open(entity_file, "w") as f: + f.write(""" +info: + x-cortex-tag: test-entity + title: Test Entity + x-cortex-type: service +""") + + result = cli(["backup", "import", "-d", tmpdir], return_type=ReturnType.RAW) + assert result.exit_code != 0, f"backup import should exit with non-zero code on failure, got exit_code={result.exit_code}" + + +def test_backup_export_invalid_api_key(monkeypatch): + """ + Test that backup export exits with non-zero return code and clean error message when API calls fail. + """ + monkeypatch.setenv("CORTEX_API_KEY", "invalidKey") + + with tempfile.TemporaryDirectory() as tmpdir: + result = cli(["backup", "export", "-d", tmpdir], return_type=ReturnType.RAW) + assert result.exit_code != 0, f"backup export should exit with non-zero code on failure, got exit_code={result.exit_code}" + assert "HTTP Error 401" in result.stdout, "Should show HTTP 401 error message" + assert "Traceback" not in result.stdout, "Should not show Python traceback" diff --git a/tests/test_config_file.py b/tests/test_config_file.py index f37cc81..1f86c97 100644 --- a/tests/test_config_file.py +++ b/tests/test_config_file.py @@ -31,12 +31,12 @@ def test_config_file_bad_api_key(monkeypatch, tmp_path): monkeypatch.setattr('sys.stdin', io.StringIO('y')) f = tmp_path / "test-config-bad-api-key.txt" response = cli(["-c", str(f), "-k", "invalidApiKey", "scorecards", "list"], return_type=ReturnType.RAW) - assert "401 Client Error: Unauthorized" in str(response), "should get Unauthorized error" + assert "HTTP Error 401" in response.stdout, "should get Unauthorized error" def test_environment_variable_invalid_key(monkeypatch): monkeypatch.setenv("CORTEX_API_KEY", "invalidKey") response = cli(["scorecards", "list"], return_type=ReturnType.RAW) - assert "401 Client Error: Unauthorized" in str(response), "should get Unauthorized error" + assert "HTTP Error 401" in response.stdout, "should get Unauthorized error" def test_config_file_bad_url(monkeypatch, tmp_path): monkeypatch.delenv("CORTEX_BASE_URL") diff --git a/tests/test_entity_types.py b/tests/test_entity_types.py index 8b2d65b..c5b59fd 100644 --- a/tests/test_entity_types.py +++ b/tests/test_entity_types.py @@ -12,6 +12,18 @@ def test_resource_definitions(capsys): response = cli(["entity-types", "list"]) assert any(definition['type'] == 'cli-test' for definition in response['definitions']), "Should find entity type named 'cli-test'" - cli(["entity-types", "get", "-t", "cli-test"]) + # Verify iconTag was set correctly + response = cli(["entity-types", "get", "-t", "cli-test"]) + assert response.get('iconTag') == "Cortex-builtin::Basketball", "iconTag should be set to Cortex-builtin::Basketball" cli(["entity-types", "update", "-t", "cli-test", "-f", "data/run-time/entity-type-update.json"]) + + +def test_resource_definitions_invalid_icon(): + # API does not reject invalid iconTag values - it uses a default icon instead + # This test verifies that behavior and will catch if the API changes to reject invalid icons + response = cli(["entity-types", "create", "-f", "data/run-time/entity-type-invalid-icon.json"], return_type=ReturnType.RAW) + assert response.exit_code == 0, "Creation should succeed even with invalid iconTag (API uses default icon)" + + # Clean up the test entity type + cli(["entity-types", "delete", "-t", "cli-test-invalid-icon"]) diff --git a/tests/test_integrations_newrelic.py b/tests/test_integrations_newrelic.py index b54e205..0af6040 100644 --- a/tests/test_integrations_newrelic.py +++ b/tests/test_integrations_newrelic.py @@ -10,7 +10,29 @@ def _dummy_file(tmp_path): @responses.activate def test_integrations_newrelic_add(): responses.add(responses.POST, os.getenv("CORTEX_BASE_URL") + "/api/v1/newrelic/configuration", json={}, status=200) - cli(["integrations", "newrelic", "add", "-a", "myAlias", "-h", "my.host.com", "--api-key", "123456", "-i"]) + cli(["integrations", "newrelic", "add", "-a", "myAlias", "--account-id", "12345", "--personal-key", "NRAK-123456", "-i"]) + +@responses.activate +def test_integrations_newrelic_add_with_region(): + responses.add(responses.POST, os.getenv("CORTEX_BASE_URL") + "/api/v1/newrelic/configuration", json={}, status=200) + cli(["integrations", "newrelic", "add", "-a", "myAlias", "-acc", "12345", "-pk", "NRAK-123456", "-r", "EU"]) + +def test_integrations_newrelic_add_invalid_key(): + result = cli(["integrations", "newrelic", "add", "-a", "myAlias", "--account-id", "12345", "--personal-key", "invalid-key"], return_type=ReturnType.RAW) + assert result.exit_code != 0 + assert "must start with 'NRAK'" in result.output + +def test_integrations_newrelic_add_missing_required(): + result = cli(["integrations", "newrelic", "add", "-a", "myAlias"], return_type=ReturnType.RAW) + assert result.exit_code != 0 + assert "are required" in result.output + +@responses.activate +def test_integrations_newrelic_add_with_file(tmp_path): + f = tmp_path / "test_integrations_newrelic_add.json" + f.write_text('{"alias": "test", "accountId": "12345", "personalKey": "NRAK-123", "region": "US"}') + responses.add(responses.POST, os.getenv("CORTEX_BASE_URL") + "/api/v1/newrelic/configuration", json={}, status=200) + cli(["integrations", "newrelic", "add", "-f", str(f)]) @responses.activate def test_integrations_newrelic_add_multiple(tmp_path): @@ -38,6 +60,13 @@ def test_integrations_newrelic_list(): responses.add(responses.GET, os.getenv("CORTEX_BASE_URL") + "/api/v1/newrelic/configurations", json={}, status=200) cli(["integrations", "newrelic", "list"]) +@responses.activate +def test_integrations_newrelic_list_table(): + responses.add(responses.GET, os.getenv("CORTEX_BASE_URL") + "/api/v1/newrelic/configurations", json={"configurations": [{"alias": "test", "accountId": "123", "region": "US", "isDefault": True}]}, status=200) + result = cli(["integrations", "newrelic", "list", "--table"], return_type=ReturnType.RAW) + assert "Alias" in result.output + assert "test" in result.output + @responses.activate def test_integrations_newrelic_get_default(): responses.add(responses.GET, os.getenv("CORTEX_BASE_URL") + "/api/v1/newrelic/default-configuration", json={}, status=200) diff --git a/tests/test_scim.py b/tests/test_scim.py index 3215747..ed5bda7 100644 --- a/tests/test_scim.py +++ b/tests/test_scim.py @@ -2,6 +2,7 @@ from urllib.error import HTTPError import pytest +@pytest.mark.skip(reason="Disabled until CET-23082 is resolved.") def test(): response = cli(["scim", "list"], ReturnType.STDOUT)