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
13 changes: 13 additions & 0 deletions cortexapps_cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@
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.solutions as solutions
import cortexapps_cli.commands.teams as teams
import cortexapps_cli.commands.users as users
import cortexapps_cli.commands.workflows as workflows
Expand Down Expand Up @@ -85,6 +86,17 @@ def global_callback(
if ctx.invoked_subcommand == "login":
return

if ctx.invoked_subcommand == "solutions":
ctx.obj["_auth_params"] = {
"api_key": api_key,
"url": url,
"config_file": config_file,
"tenant": tenant,
"log_level": log_level,
"rate_limit": rate_limit,
}
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 @@ -264,6 +276,7 @@ def version():
app.add_typer(scim.app, name="scim")
app.add_typer(scorecards.app, name="scorecards")
app.add_typer(secrets.app, name="secrets")
app.add_typer(solutions.app, name="solutions")
app.add_typer(teams.app, name="teams")
app.add_typer(users.app, name="users")
app.command()(version)
Expand Down
190 changes: 190 additions & 0 deletions cortexapps_cli/commands/solutions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import configparser
import logging
import os
import re
from importlib.resources import as_file, files

import typer
import yaml
from rich.console import Console
from rich.markdown import Markdown
from rich.table import Table

from cortexapps_cli.cortex_client import CortexClient

app = typer.Typer(help="Solutions commands", no_args_is_help=True)
console = Console()


def _solutions_root():
return files("cortexapps_cli.solutions")


def _list_solution_tags() -> list[str]:
root = _solutions_root()
return sorted(
item.name
for item in root.iterdir()
if item.is_dir() and not item.name.startswith("_")
)


def _parse_frontmatter(content: str) -> dict:
"""Parse YAML frontmatter block from README content."""
match = re.match(r"^---\n(.*?)\n---\n", content, re.DOTALL)
if not match:
return {}
try:
return yaml.safe_load(match.group(1)) or {}
except yaml.YAMLError:
return {}


def _get_readme(tag: str) -> str | None:
"""Return README.md content for a solution tag, or None if not found."""
try:
return (_solutions_root() / tag / "README.md").read_text(encoding="utf-8")
except Exception:
return None


def _build_client(ctx: typer.Context) -> CortexClient:
"""Build a CortexClient from auth params stored by global_callback."""
params = ctx.obj.get("_auth_params", {})
api_key = params.get("api_key")
url = params.get("url")
config_file = params.get(
"config_file",
os.path.join(os.path.expanduser("~"), ".cortex", "config"),
)
tenant = params.get("tenant", "default")
log_level_str = params.get("log_level", "WARNING")
rate_limit = params.get("rate_limit")

if not os.path.isfile(config_file):
if not api_key:
typer.echo(
"Error: Authentication required. Run 'cortex login' first or set CORTEX_API_KEY."
)
raise typer.Exit(1)
else:
config = configparser.ConfigParser()
config.read(config_file)
if not api_key:
if tenant not in config:
typer.echo(
f"Error: Tenant '{tenant}' not found in config. Run 'cortex login' first."
)
raise typer.Exit(1)
api_key = config[tenant].get("api_key")
if not api_key:
typer.echo(
f"Error: No api_key found for tenant '{tenant}' in config. Run 'cortex login' first."
)
raise typer.Exit(1)
if not url:
url = config[tenant].get("base_url", "https://api.getcortexapp.com")

if not url:
url = "https://api.getcortexapp.com"

api_key = api_key.strip("\"' ")
url = url.strip("\"' /")

numeric_level = getattr(logging, log_level_str.upper(), logging.WARNING)
return CortexClient(api_key, tenant, numeric_level, url, rate_limit)


@app.command("list")
def list_solutions(ctx: typer.Context):
"""List all available solutions."""
tags = _list_solution_tags()
table = Table(title="Available Solutions")
table.add_column("Tag", style="cyan", no_wrap=True)
table.add_column("Name")
table.add_column("Description")
for tag in tags:
readme = _get_readme(tag)
if readme is None:
continue
fm = _parse_frontmatter(readme)
table.add_row(tag, fm.get("name", tag), fm.get("description", ""))
console.print(table)


def _print_readme(text: str) -> None:
"""Render README with left-justified headings and Rich Markdown body blocks."""
# Strip YAML frontmatter
body = re.sub(r"^---\n.*?\n---\n", "", text, flags=re.DOTALL).strip()

heading_styles = {"# ": "bold", "## ": "bold underline", "### ": "bold"}
pending: list[str] = []
in_code_block = False

def flush() -> None:
block = "\n".join(pending).strip()
if block:
console.print(Markdown(block))
pending.clear()

for line in body.split("\n"):
if line.startswith("```"):
if not in_code_block:
flush()
in_code_block = not in_code_block
continue

if in_code_block:
console.print(f" [cyan]{line}[/cyan]" if line else "")
continue

for prefix, style in heading_styles.items():
if line.startswith(prefix):
flush()
console.print(f"\n[{style}]{line[len(prefix):]}[/{style}]")
break
else:
pending.append(line)

flush()


@app.command()
def info(
ctx: typer.Context,
solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"),
):
"""Show README for a solution."""
readme = _get_readme(solution)
if readme is None:
avail = ", ".join(_list_solution_tags())
typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}")
raise typer.Exit(1)
_print_readme(readme)


@app.command()
def install(
ctx: typer.Context,
solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"),
force: bool = typer.Option(False, "--force", help="Recreate entities if they already exist"),
show_info: bool = typer.Option(True, "--info/--no-info", help="Show solution README after installing"),
):
"""Install a solution into the current Cortex workspace."""
if solution not in _list_solution_tags():
avail = ", ".join(_list_solution_tags())
typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}")
raise typer.Exit(1)

ctx.obj["client"] = _build_client(ctx)

import cortexapps_cli.commands.backup as backup

with as_file(_solutions_root() / solution) as solution_path:
backup.import_tenant(ctx, directory=str(solution_path), force=force)

if show_info:
readme = _get_readme(solution)
if readme:
console.print()
_print_readme(readme)
Empty file.
50 changes: 50 additions & 0 deletions cortexapps_cli/solutions/github-starter/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
name: GitHub Starter
description: Pre-configured scorecards for a GitHub-integrated Cortex workspace.
---

# GitHub Starter

This solution provides a starting point for teams using GitHub with Cortex.

## Overview

```
┌────────────────┐ GitHub integration ┌─────────────────┐
│ GitHub Repo │ ─────────────────────▶ │ Service Entity │
└────────────────┘ └────────┬────────┘
│ evaluated by
┌─────────────────────┐
│ GitHub Readiness │
│ Scorecard │
├─────────────────────┤
│ 🥉 Bronze │
│ git != null │
└─────────────────────┘
```

## What's Included

- **GitHub Readiness Scorecard** — checks that services have GitHub repositories configured

## Prerequisites

- GitHub integration enabled in your Cortex workspace

## Installation

```
cortex solutions install -s github-starter
```

To overwrite existing resources:

```
cortex solutions install -s github-starter --force
```

## After Installing

Run `cortex scorecards list` to confirm the scorecard was imported, then navigate
to the Scorecards page in the Cortex UI to see scores across your services.
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
tag: github-readiness
name: GitHub Readiness
description: Basic checks for GitHub integration readiness
ladder:
levels:
- name: Bronze
rank: 1
description: Basic GitHub configuration
color: "#CD7F32"
rules:
- title: Has GitHub repository
description: Service has a GitHub repository configured
expression: "git != null"
weight: 1
level: Bronze
57 changes: 57 additions & 0 deletions tests/test_solutions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
from tests.helpers.utils import cli, ReturnType


def test_solutions_help():
result = cli(["solutions", "--help"], return_type=ReturnType.RAW)
assert result.exit_code == 0
assert "solutions" in result.output.lower()


def test_solutions_list_shows_tag():
result = cli(["solutions", "list"], return_type=ReturnType.RAW)
assert result.exit_code == 0, result.output
assert "github-starter" in result.output


def test_solutions_list_shows_name():
result = cli(["solutions", "list"], return_type=ReturnType.RAW)
assert result.exit_code == 0, result.output
assert "GitHub Starter" in result.output


def test_solutions_list_shows_description():
result = cli(["solutions", "list"], return_type=ReturnType.RAW)
assert result.exit_code == 0, result.output
assert "GitHub-integrated" in result.output


def test_solutions_info_known_tag():
result = cli(["solutions", "info", "-s", "github-starter"], return_type=ReturnType.RAW)
assert result.exit_code == 0, result.output
assert "GitHub Starter" in result.output


def test_solutions_info_unknown_tag():
result = cli(["solutions", "info", "-s", "nonexistent-xyz-abc"], return_type=ReturnType.RAW)
assert result.exit_code == 1
assert "not found" in result.output.lower()


def test_solutions_install_unknown_tag():
# Unknown-tag check runs before auth check, so no credentials needed
result = cli(["solutions", "install", "-s", "nonexistent-xyz-abc"], return_type=ReturnType.RAW)
assert result.exit_code == 1
assert "not found" in result.output.lower()


def test_solutions_install_no_auth():
# Known tag, but no credentials configured — should fail with auth error
# This test only applies when no config file or CORTEX_API_KEY is present.
# Skip if the test environment has credentials set up.
import os
if os.path.isfile(os.path.join(os.path.expanduser("~"), ".cortex", "config")):
import pytest
pytest.skip("Skipping: credentials are configured in this environment")
result = cli(["solutions", "install", "-s", "github-starter"], return_type=ReturnType.RAW)
assert result.exit_code == 1
assert "authentication required" in result.output.lower()
Loading