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 66e441b..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`) diff --git a/HISTORY.md b/HISTORY.md index 28f2e86..089da2e 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.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 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 abc014f..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') 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)