Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/test-pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,4 +83,4 @@ jobs:
- name: Auto-merge PR
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh pr merge ${{ github.event.pull_request.number }} --merge --repo ${{ github.repository }}
run: gh pr merge ${{ github.event.pull_request.number }} --merge --auto --repo ${{ github.repository }}
183 changes: 149 additions & 34 deletions cortexapps_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import tomllib
import configparser
import logging
import webbrowser
import requests

from cortexapps_cli.cortex_client import CortexClient

Expand Down Expand Up @@ -43,43 +45,23 @@
import cortexapps_cli.commands.users as users
import cortexapps_cli.commands.workflows as workflows

app = typer.Typer(
class _SortedTyper(typer.Typer):
"""Typer subclass that sorts all commands alphabetically in --help output."""
def __call__(self, *args, **kwargs):
import typer.main as _tm
click_app = _tm.get_command(self)
click_app.commands = dict(sorted(click_app.commands.items()))
return click_app(*args, **kwargs)

app = _SortedTyper(
no_args_is_help=True,
rich_markup_mode="rich",
context_settings={"help_option_names": ["-h", "--help"]}
)

# add subcommands
app.add_typer(api_keys.app, name="api-keys")
app.add_typer(audit_logs.app, name="audit-logs")
app.add_typer(backup.app, name="backup")
app.add_typer(catalog.app, name="catalog")
app.add_typer(custom_data.app, name="custom-data")
app.add_typer(custom_events.app, name="custom-events")
app.add_typer(custom_metrics.app, name="custom-metrics")
app.add_typer(dependencies.app, name="dependencies")
app.add_typer(deploys.app, name="deploys")
app.add_typer(discovery_audit.app, name="discovery-audit")
app.add_typer(docs.app, name="docs")
app.add_typer(entity_types.app, name="entity-types")
app.add_typer(entity_relationship_types.app, name="entity-relationship-types")
app.add_typer(entity_relationships.app, name="entity-relationships")
app.add_typer(gitops_logs.app, name="gitops-logs")
app.add_typer(groups.app, name="groups")
app.add_typer(initiatives.app, name="initiatives")
app.add_typer(integrations.app, name="integrations")
app.add_typer(ip_allowlist.app, name="ip-allowlist")
app.add_typer(on_call.app, name="on-call")
app.add_typer(packages.app, name="packages")
app.add_typer(plugins.app, name="plugins")
app.add_typer(queries.app, name="queries")
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(users.app, name="users")
app.add_typer(workflows.app, name="workflows")
# Commands and subcommands are registered below in alphabetical order.
# Direct commands (login, version) are registered via app.command() calls
# interleaved with add_typer() calls so they appear alphabetically in --help.

# global options
@app.callback()
Expand All @@ -95,6 +77,14 @@ def global_callback(
if not ctx.obj:
ctx.obj = {}

# Store config file path and tenant for use by the login command
ctx.obj["config_file"] = config_file
ctx.obj["tenant"] = tenant

# login command handles its own auth setup
if ctx.invoked_subcommand == "login":
return

numeric_level = getattr(logging, log_level.upper(), None)
if not isinstance(numeric_level, int):
raise ValueError(f"Invalid log level: {log_level}")
Expand Down Expand Up @@ -140,11 +130,102 @@ def global_callback(

ctx.obj["client"] = CortexClient(api_key, tenant, numeric_level, url, rate_limit)

@app.command()
DEFAULT_API_URL = "https://api.getcortexapp.com"
DEFAULT_UI_URL = "https://app.getcortexapp.com"
DEFAULT_UI_KEYS_PATH = "/admin/settings/personal-access-tokens"

def login(
ctx: typer.Context,
open_browser: bool = typer.Option(True, "--open-browser/--no-open-browser", help="Open browser to Cortex login page"),
force: bool = typer.Option(False, "--force", "-f", help="Overwrite existing tenant config without prompting"),
):
"""
Log in to Cortex and save credentials to the config file
"""
config_file = ctx.obj.get("config_file")

# Prompt for workspace name — this is both the Cortex workspace slug and the local config alias
default_tenant = ctx.obj.get("tenant", "default")
tenant = typer.prompt("Workspace name", default=default_tenant).strip()

# Prompt for base URL
url = typer.prompt("Base URL", default=DEFAULT_API_URL).strip('"\' /')

# Check existing config for this tenant
config = configparser.ConfigParser()
if os.path.isfile(config_file):
config.read(config_file)
if tenant in config and not force:
if not typer.confirm(f"Workspace '{tenant}' already exists in config. Overwrite?"):
raise typer.Abort()

# Derive UI URL, prefilling the workspace name on the login page
if url == DEFAULT_API_URL:
ui_base = DEFAULT_UI_URL
else:
ui_base = url.replace("://api.", "://app.")

pat_url = f"{ui_base}{DEFAULT_UI_KEYS_PATH}?tenantCode={tenant}"

if open_browser:
typer.echo(f"\nOpening browser to the Personal Access Tokens page for workspace '{tenant}'.")
typer.echo(f"Create or copy a personal access token, then come back here and paste it below.\n")
webbrowser.open(pat_url)
else:
typer.echo(f"\nGo to {pat_url} to create or copy a personal access token, then paste it below.\n")

# Prompt for API key (hidden input)
api_key = typer.prompt("API key", hide_input=True).strip('"\' ')

# Validate the key with a lightweight API call
typer.echo("Validating API key...", nl=False)
try:
resp = requests.get(
f"{url}/api/v1/catalog",
headers={
"Authorization": f"Bearer {api_key}",
"User-Agent": "cortexapps-cli/login",
},
params={"pageSize": 1},
timeout=10,
)
if resp.status_code == 401:
typer.echo(" invalid.")
typer.echo("Error: API key was not accepted (HTTP 401). Please check the key and try again.")
raise typer.Exit(1)
elif not resp.ok:
typer.echo(f" failed (HTTP {resp.status_code}).")
typer.echo(f"Error: Unexpected response from {url}. Please check the URL and try again.")
raise typer.Exit(1)
else:
typer.echo(" OK.")
except requests.exceptions.ConnectionError:
typer.echo(" connection failed.")
typer.echo(f"Error: Could not connect to {url}. Please check the URL and try again.")
raise typer.Exit(1)
except requests.exceptions.Timeout:
typer.echo(" timed out.")
typer.echo(f"Error: Request to {url} timed out. Please check the URL and try again.")
raise typer.Exit(1)

# Write / update config
os.makedirs(os.path.dirname(config_file), exist_ok=True)
config[tenant] = {"api_key": api_key}
if url != DEFAULT_API_URL:
config[tenant]["base_url"] = url

with open(config_file, "w") as f:
config.write(f)

tenant_flag = f" -t {tenant}" if tenant != "default" else ""
typer.echo(f"\nCredentials saved to {config_file}")
typer.echo(f"You can now run: cortex{tenant_flag} catalog list")


def version():

"""
Show the version and exit.
Show the version and exit
"""
try:
with open("pyproject.toml", "rb") as f:
Expand All @@ -154,5 +235,39 @@ def version():
version = importlib.metadata.version('cortexapps_cli')
print(version)

# Register all commands alphabetically so they appear in order in --help
app.add_typer(api_keys.app, name="api-keys")
app.add_typer(audit_logs.app, name="audit-logs")
app.add_typer(backup.app, name="backup")
app.add_typer(catalog.app, name="catalog")
app.add_typer(custom_data.app, name="custom-data")
app.add_typer(custom_events.app, name="custom-events")
app.add_typer(custom_metrics.app, name="custom-metrics")
app.add_typer(dependencies.app, name="dependencies")
app.add_typer(deploys.app, name="deploys")
app.add_typer(discovery_audit.app, name="discovery-audit")
app.add_typer(docs.app, name="docs")
app.add_typer(entity_relationship_types.app, name="entity-relationship-types")
app.add_typer(entity_relationships.app, name="entity-relationships")
app.add_typer(entity_types.app, name="entity-types")
app.add_typer(gitops_logs.app, name="gitops-logs")
app.add_typer(groups.app, name="groups")
app.add_typer(initiatives.app, name="initiatives")
app.add_typer(integrations.app, name="integrations")
app.add_typer(ip_allowlist.app, name="ip-allowlist")
app.command()(login)
app.add_typer(on_call.app, name="on-call")
app.add_typer(packages.app, name="packages")
app.add_typer(plugins.app, name="plugins")
app.add_typer(queries.app, name="queries")
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(users.app, name="users")
app.command()(version)
app.add_typer(workflows.app, name="workflows")

if __name__ == "__main__":
app()
6 changes: 3 additions & 3 deletions cortexapps_cli/commands/workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
import yaml

app = typer.Typer(
help="Workflows commands (Beta)",
help="Workflows commands",
no_args_is_help=True
)

Expand Down Expand Up @@ -156,7 +156,7 @@ def run(
timeout: int = typer.Option(300, "--timeout", help="Max seconds to wait when --wait is used"),
):
"""
(Beta) Run a workflow. API key must have the Run workflows permission.
Run a workflow. API key must have the Run workflows permission.
The workflow must have isRunnableViaApi set to true.
"""
import time as time_module
Expand Down Expand Up @@ -214,7 +214,7 @@ def get_run(
run_id: str = typer.Option(..., "--run-id", "-r", help="The run ID"),
):
"""
(Beta) Get details of a workflow run. API key must have the View workflow runs permission.
Get details of a workflow run. API key must have the View workflow runs permission.
"""
client = ctx.obj["client"]
r = client.get(f"api/v1/workflows/{tag}/runs/{run_id}")
Expand Down
Loading