Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0d99232
perf: optimize test scheduling with --dist loadfile for 25% faster te…
jeff-schnitter Nov 5, 2025
32c9f45
feat: add support for Cortex Secrets API (#161)
jeff-schnitter Nov 6, 2025
6fc38bb
feat: add entity relationships API support with optimized backup/rest…
jeff-schnitter Nov 6, 2025
e54dca3
fix: add client-side rate limiting and make tests idempotent (#165) #…
jeff-schnitter Nov 13, 2025
283fc3b
Merge branch 'main' into staging
jeff-schnitter Nov 13, 2025
5741d35
fix: remove rate limiter initialization log message (#168)
jeff-schnitter Nov 14, 2025
7b20357
Merge branch 'main' into staging
jeff-schnitter Nov 14, 2025
8e58ea0
fix: change default logging level from INFO to WARNING
jeff-schnitter Nov 18, 2025
b095f88
Merge branch '170-default-logging-none' into staging
jeff-schnitter Nov 18, 2025
4165886
fix: initialize results and failed_count before directory check in im…
jeff-schnitter Nov 18, 2025
5be1748
Merge branch '171-import-partial-export' into staging
jeff-schnitter Nov 18, 2025
b292e67
fix: only retry on 429 rate limit errors, not 5xx server errors
jeff-schnitter Nov 18, 2025
052796e
Merge branch '172-retry-only-429' into staging
jeff-schnitter Nov 18, 2025
470664d
Revert: Undo direct merges to staging (#176)
jeff-schnitter Nov 19, 2025
d7e6963
fix: change default logging level from INFO to WARNING (#177)
jeff-schnitter Nov 19, 2025
b5f0c5a
fix: initialize results and failed_count before directory check in im…
jeff-schnitter Nov 19, 2025
a037024
fix: only retry on 429 rate limit errors, not 5xx server errors (#179)
jeff-schnitter Nov 19, 2025
65b2c2d
Add tests for iconTag parameter in entity-types create (#183)
jeff-schnitter Jan 10, 2026
fffe6b8
Fix backup import/export error handling (#185)
jeff-schnitter Jan 10, 2026
7799716
Merge branch 'main' into staging
jeff-schnitter Jan 12, 2026
a54f13a
fix: correct New Relic integration commands to match API (#192)
jeff-schnitter Jan 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 18 additions & 6 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 23 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,18 @@ Use the GitHub-recommended format: `<issue-number>-<short-description>`

### 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`)
Expand All @@ -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
Expand Down
13 changes: 13 additions & 0 deletions HISTORY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

<!-- insertion marker -->
## [1.9.0](https://github.com/cortexapps/cli/releases/tag/1.9.0) - 2026-01-12

<small>[Compare with 1.8.0](https://github.com/cortexapps/cli/compare/1.8.0...1.9.0)</small>

## [1.8.0](https://github.com/cortexapps/cli/releases/tag/1.8.0) - 2026-01-12

<small>[Compare with 1.7.0](https://github.com/cortexapps/cli/compare/1.7.0...1.8.0)</small>

### 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

<small>[Compare with 1.6.0](https://github.com/cortexapps/cli/compare/1.6.0...1.7.0)</small>
Expand All @@ -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

<small>[Compare with 1.4.0](https://github.com/cortexapps/cli/compare/1.4.0...1.5.0)</small>
Expand Down
4 changes: 4 additions & 0 deletions cortexapps_cli/commands/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
80 changes: 59 additions & 21 deletions cortexapps_cli/commands/integrations_commands/newrelic.py
Original file line number Diff line number Diff line change
@@ -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,
):
Expand All @@ -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"),
Expand All @@ -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"),
Expand All @@ -98,15 +122,29 @@ 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
"""

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(
Expand All @@ -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"),
Expand All @@ -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"),
Expand All @@ -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()
Expand All @@ -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)
25 changes: 23 additions & 2 deletions cortexapps_cli/cortex_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,15 +149,36 @@ 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')
error_str = f'[red][bold]HTTP Error {status}[/bold][/red]: {message} - {details} [dim](Request ID: {request_id})[/dim]'
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
Expand Down
1 change: 1 addition & 0 deletions data/import/entity-types/cli-test.json
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
7 changes: 7 additions & 0 deletions data/run-time/entity-type-invalid-icon.json
Original file line number Diff line number Diff line change
@@ -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"
}
40 changes: 40 additions & 0 deletions tests/test_backup.py
Original file line number Diff line number Diff line change
@@ -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"
4 changes: 2 additions & 2 deletions tests/test_config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Loading