diff --git a/cortexapps_cli/cli.py b/cortexapps_cli/cli.py index cfdba3c..c186a0f 100755 --- a/cortexapps_cli/cli.py +++ b/cortexapps_cli/cli.py @@ -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 @@ -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}") @@ -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) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py new file mode 100644 index 0000000..a1c799e --- /dev/null +++ b/cortexapps_cli/commands/solutions.py @@ -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) diff --git a/cortexapps_cli/solutions/__init__.py b/cortexapps_cli/solutions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cortexapps_cli/solutions/github-starter/README.md b/cortexapps_cli/solutions/github-starter/README.md new file mode 100644 index 0000000..b6901e4 --- /dev/null +++ b/cortexapps_cli/solutions/github-starter/README.md @@ -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. diff --git a/cortexapps_cli/solutions/github-starter/scorecards/github-readiness.yaml b/cortexapps_cli/solutions/github-starter/scorecards/github-readiness.yaml new file mode 100644 index 0000000..05c58d3 --- /dev/null +++ b/cortexapps_cli/solutions/github-starter/scorecards/github-readiness.yaml @@ -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 diff --git a/tests/test_solutions.py b/tests/test_solutions.py new file mode 100644 index 0000000..6052bf8 --- /dev/null +++ b/tests/test_solutions.py @@ -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()