diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index c8e7d1f..ad3c3e9 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -74,13 +74,26 @@ def getvalue(self) -> str: def _run_import_with_toggle(fn) -> str: - """Run fn() capturing stdout. On a TTY, Ctrl+o toggles live output.""" + """Run fn() capturing stdout. On a TTY, Ctrl+o toggles live output. + + If fn() raises for any reason, all buffered output is flushed to the + terminal before the exception propagates — no silent failures. + """ real_stdout = sys.stdout capture = _ToggleableCapture(real_stdout) if not (_TTY_SUPPORT and sys.stdin.isatty()): - with contextlib.redirect_stdout(capture): - fn() + success = False + try: + with contextlib.redirect_stdout(capture): + fn() + success = True + finally: + if not success: + buffered = capture.getvalue() + if buffered: + real_stdout.write(buffered) + real_stdout.flush() return capture.getvalue() done = threading.Event() @@ -112,9 +125,11 @@ def _listen() -> None: t = threading.Thread(target=_listen, daemon=True) t.start() + success = False try: with contextlib.redirect_stdout(capture): fn() + success = True finally: done.set() t.join(timeout=0.5) @@ -122,6 +137,11 @@ def _listen() -> None: _termios.tcsetattr(fd, _termios.TCSADRAIN, old_settings) except Exception: pass + if not success: + buffered = capture.getvalue() + if buffered: + real_stdout.write(buffered) + real_stdout.flush() return capture.getvalue() @@ -175,6 +195,53 @@ def _get_readme(tag: str, path: str | None = None) -> str | None: return None +def _has_post_install(tag: str, path: str | None = None) -> bool: + """Return True if this solution has a post-install setup.py.""" + try: + (_solutions_root(path) / tag / "setup.py").read_bytes() + return True + except Exception: + return False + + +def _load_setup_module(solution_tag: str, solutions_dir: str | None = None): + """Load a solution's setup.py module. Returns the module or None if not found.""" + import importlib.util + + root = _solutions_root(solutions_dir) + try: + with as_file(root / solution_tag / "setup.py") as setup_path: + spec = importlib.util.spec_from_file_location(f"{solution_tag}_setup", setup_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + except FileNotFoundError: + return None + + +def _get_setup_description(solution_tag: str, solutions_dir: str | None = None) -> str: + """Return the SETUP_DESCRIPTION from a solution's setup.py, or a generic fallback.""" + module = _load_setup_module(solution_tag, solutions_dir) + if module: + return getattr(module, "SETUP_DESCRIPTION", "This solution includes a post-install setup script.") + return "This solution includes a post-install setup script." + + +def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None, ctx=None, no_prompt: bool = False) -> None: + """Find and invoke the solution's setup.py main() function.""" + module = _load_setup_module(solution_tag, solutions_dir) + if module is None: + typer.echo("No post-install setup available for this solution.") + return + kwargs = {} + if ctx and ctx.obj and "client" in ctx.obj: + client = ctx.obj["client"] + kwargs["cortex_api_key"] = client.api_key + kwargs["cortex_base_url"] = client.base_url + kwargs["no_prompt"] = no_prompt + module.main(**kwargs) + + def _extract_first_codeblock(text: str) -> str | None: """Return content of the first fenced code block.""" m = re.search(r"```[^\n]*\n(.*?)```", text, re.DOTALL) @@ -538,17 +605,6 @@ def _show_next_steps(readme: str) -> None: section = re.sub(r'\[([^\]]+)\]\(([^)]+)\)', r'\1 (\2)', section) console.print() console.print(Markdown(section)) - console.print() - console.print( - "[magenta]Planned for Q4 2027:[/magenta] CQL metadata traversal will enable scorecard rules " - "across relationship chains — for example, a Vulnerability Scorecard checking that no " - "deployed service-version has open Snyk issues:" - ) - console.print( - " [dim]entity.destinations(relationshipType = \"environments\", depth = 3)\n" - " .filter((d) => d.type == \"service-version\")\n" - " .all((sv) => sv.snyk.issues == 0)[/dim]" - ) def _post_install_menu( @@ -604,6 +660,12 @@ def install( ctx: typer.Context, solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"), no_prompt: bool = typer.Option(False, "--no-prompt", help="Skip the post-install interactive menu"), + skip_post_install_setup: bool = typer.Option( + False, + "--skip-post-install-setup", + help="Skip the post-install setup script prompt", + is_flag=True, + ), ): """Install a solution.""" solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None @@ -643,6 +705,20 @@ def _do_import() -> None: else: typer.echo(output) + # Post-install setup hook — runs before the informational menu + if not no_prompt and not skip_post_install_setup and _has_post_install(solution, solutions_dir): + state_file = Path.home() / ".cortex" / "solutions" / f"{solution}.json" + if state_file.exists(): + typer.echo(f"\nRetrieving previous responses from: {state_file}") + desc = _get_setup_description(solution, solutions_dir) + typer.echo(f"\n{desc}") + if typer.confirm("Run setup now?", default=True): + _run_post_install_script(solution, solutions_dir=solutions_dir, ctx=ctx) + else: + typer.echo(f"\nRun setup later with: cortex solutions post-install -s {solution}") + elif skip_post_install_setup and _has_post_install(solution, solutions_dir): + typer.echo(f"\nRun setup later with: cortex solutions post-install -s {solution}") + if not no_prompt: readme = _get_readme(solution, solutions_dir) if readme: @@ -660,6 +736,27 @@ def _do_import() -> None: _post_install_menu(readme, import_report=output, entity_tags=entity_tags, ui_url=ui_url) +@app.command(name="post-install") +def post_install( + ctx: typer.Context, + solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"), + no_prompt: bool = typer.Option( + False, + "--no-prompt", + "-N", + help="Use saved answers from ~/.cortex/solutions/.json without prompting.", + ), +): + """Run post-install setup for a solution.""" + solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None + if solution not in _list_solution_tags(solutions_dir): + avail = ", ".join(_list_solution_tags(solutions_dir)) + typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}") + raise typer.Exit(1) + ctx.obj["client"] = _build_client(ctx) + _run_post_install_script(solution, solutions_dir=solutions_dir, ctx=ctx, no_prompt=no_prompt) + + @app.command() def uninstall( ctx: typer.Context, diff --git a/cortexapps_cli/solutions/_lib/__init__.py b/cortexapps_cli/solutions/_lib/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cortexapps_cli/solutions/_lib/setup_base.py b/cortexapps_cli/solutions/_lib/setup_base.py new file mode 100644 index 0000000..d0645d5 --- /dev/null +++ b/cortexapps_cli/solutions/_lib/setup_base.py @@ -0,0 +1,169 @@ +import getpass +import json +import os +import sys +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + + +class SolutionSetup(ABC): + """ + Base class for solution post-install setup scripts. + Subclasses define solution_tag, collect_prompts(), and steps(). + """ + + solution_tag: str # must be set by subclass + + def __init__(self, state_dir: Optional[Path] = None, no_prompt: bool = False): + self._no_prompt = no_prompt + self._secret_keys: set = set() + self._answers: dict = {} + + solutions_dir = state_dir or Path.home() / ".cortex" / "solutions" + solutions_dir.mkdir(parents=True, exist_ok=True) + self._state_file = solutions_dir / f"{self.solution_tag}.json" + + data = self._load_file() + self._answers = data.get("answers", {}) + self._state: dict = data.get("state", {}) + + self._migrate_old_state() + + def _load_file(self) -> dict: + if self._state_file.exists(): + try: + return json.loads(self._state_file.read_text()) + except (json.JSONDecodeError, OSError): + return {} + return {} + + def _save_file(self) -> None: + data = { + "answers": {k: v for k, v in self._answers.items() if k not in self._secret_keys}, + "state": self._state, + } + self._state_file.write_text(json.dumps(data, indent=2)) + + def _save_state(self) -> None: + self._save_file() + + def _migrate_old_state(self) -> None: + """Move state from the old flat ~/.cortex/setup-.json into the new file.""" + old_file = Path.home() / ".cortex" / f"setup-{self.solution_tag}.json" + if not old_file.exists(): + return + try: + old_data = json.loads(old_file.read_text()) + if old_data and not self._state: + self._state.update(old_data) + self._save_file() + old_file.unlink() + except Exception: + pass + + def prompt( + self, + key: str, + message: str, + env_var: Optional[str] = None, + default: Optional[str] = None, + secret: bool = False, + ) -> str: + """Prompt for a value. Uses saved answer or env var when available.""" + if secret: + self._secret_keys.add(key) + + # Non-secret: use saved answer when --no-prompt + if self._no_prompt and not secret and key in self._answers: + return self._answers[key] + + # Non-secret: saved answer takes precedence over any derived default + if not secret and key in self._answers: + default = self._answers[key] + + if env_var: + env_val = os.environ.get(env_var) + if env_val: + masked = "********" if secret else env_val + if self._no_prompt or self.confirm(f"{message} [{masked} from {env_var}]", default=True): + self._answers[key] = env_val + return env_val + + # Secrets in --no-prompt mode still need a prompt if no env var provided + if self._no_prompt and secret and key not in self._answers: + print(f" (secret required — no env var set for {key})", file=sys.stderr) + + prompt_str = message + if default: + prompt_str += f" [{default}]" + prompt_str += ": " + + if secret: + value = getpass.getpass(prompt_str).strip() + else: + value = input(prompt_str).strip() + if not value: + value = default or "" + self._answers[key] = value + return value + + def confirm(self, message: str, default: bool = True) -> bool: + """Y|N confirmation prompt. Auto-accepts default when --no-prompt.""" + if self._no_prompt: + return default + hint = "[Y/n]" if default else "[y/N]" + response = input(f"{message} {hint}: ").strip().lower() + if not response: + return default + return response in ("y", "yes") + + def already_done(self, key: str) -> bool: + """Return True if this step was previously completed.""" + return self._state.get(key, False) + + def mark_done(self, key: str) -> None: + """Mark a step as completed in the persistent state file.""" + self._state[key] = True + self._save_file() + + @abstractmethod + def collect_prompts(self) -> None: + """Collect all user inputs upfront before executing steps.""" + + @abstractmethod + def steps(self) -> list[tuple[str, callable]]: + """Return ordered list of (label, callable) tuples.""" + + def post_steps(self) -> None: + """Optional hook called after all steps complete. Override in subclass.""" + + def run(self) -> None: + """Collect prompts then execute steps with progress display.""" + if self._no_prompt and self._answers: + saved = {k: v for k, v in self._answers.items() if k not in self._secret_keys} + if saved: + print("Using saved configuration:") + for k, v in saved.items(): + print(f" {k}: {v}") + print() + + self.collect_prompts() + self._save_file() + + print() + step_list = self.steps() + total = len(step_list) + for i, (label, fn) in enumerate(step_list, 1): + try: + detail = fn() + print(f"[{i}/{total}] {label}... \u2713") + if detail: + lines = [detail] if isinstance(detail, str) else detail + for line in lines: + print(f" {line}") + print() + except Exception as e: + print(f"[{i}/{total}] {label}... \u2717 {e}", file=sys.stderr) + raise SystemExit(1) + self.post_steps() diff --git a/cortexapps_cli/solutions/github-actions-deploy/README.md b/cortexapps_cli/solutions/github-actions-deploy/README.md new file mode 100644 index 0000000..75407b4 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/README.md @@ -0,0 +1,97 @@ +--- +name: GitHub Actions Deploy Tracking +description: Track deployments from GitHub Actions in Cortex, with a deploy health scorecard measuring delivery cadence. +--- + +# GitHub Actions Deploy Tracking + +Trigger deploys from Cortex, track them as they run in GitHub Actions, and surface deploy health back in your service catalog. + +``` + ┌─────────────────────────────────┐ + │ Cortex Catalog │ + │ │ + │ github-actions-demo (service) │ + │ ├── x-cortex-git.github │ + │ │ repository: owner/repo │ + │ └── Scorecard: Deploy Health │ + │ Bronze / Silver / Gold │ + └──────────────┬──────────────────┘ + │ + │ Run workflow from entity page + │ (or: cortex workflows run -t + │ github-actions-deploy + │ --scope ENTITY --entity ) + ▼ + ┌─────────────────────────────────┐ + │ Cortex Workflow │ + │ Solution: Add Cortex Deploy │ + │ from GitHub Actions │ + │ │ + │ 1. Read linked repo from │ + │ entity catalog config │ + │ 2. POST workflow_dispatch │ + │ to GitHub Actions │ + │ 3. Wait for callback │ + └──────────────┬──────────────────┘ + │ POST /dispatches (GitHub integration) + ▼ + ┌─────────────────────────────────┐ + │ GitHub Actions │ + │ cortex-deploy.yml │ + │ │ + │ job: build │ + │ └── run your deploy steps │ + │ │ + │ job: cortex-callback │ + │ ├── POST /deploys │◄── registers deploy event + │ │ (entity: github-actions- │ on the Cortex entity + │ │ demo) │ + │ └── POST callbackUrl ───────┼──► Cortex marks workflow + │ status: SUCCESS/FAILURE │ run complete + └─────────────────────────────────┘ +``` + +## What's Included + +- **Entity:** `github-actions-demo` service — a sample entity to receive deploy events +- **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency +- **GitHub Actions workflow:** `cortex-deploy.yml` — a two-job workflow (build → notify) seeded into your GitHub repo +- **Cortex workflow:** `github-actions-deploy` — reads the linked repo from the entity, triggers the GitHub Actions deploy, and waits for the result +- **Setup script:** Interactive wizard that creates and seeds a GitHub repo end-to-end + +## Quick Start + +1. Install the solution: + + ``` + cortex solutions install -s github-actions-deploy + ``` + +2. Follow the post-install setup prompts, or run later: + + ``` + cortex solutions post-install -s github-actions-deploy + ``` + +## How It Works + +The Cortex workflow reads `x-cortex-git.github.repository` from the entity's catalog config to determine which GitHub repo to deploy. It triggers `cortex-deploy.yml` via `workflow_dispatch` and waits asynchronously for a callback. + +GitHub Actions runs the build, then notifies Cortex twice on completion: +- **Deploy registration** (`POST /api/v1/catalog/{tag}/deploys`) — records the deploy event on the entity, feeding the Deploy Health scorecard +- **Workflow callback** — signals the Cortex workflow run as SUCCESS or FAILURE + +## After Installing + +If you ran the post-install setup, you're already done — it created the GitHub repo, seeded the workflow, set secrets, linked the entity, and triggered a test deploy. + +To roll the pattern out to your own services, add `cortex-deploy.yml` to any GitHub repo and link that repo to its Cortex entity. The same **Solution: Add Cortex Deploy from GitHub Actions** workflow will work across all of them — it reads the linked repo from the entity automatically. + +## Customizing for Production + +- Point the workflow at your real entity by replacing `github-actions-demo` with your service tag +- Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` secrets to your real repos +- The Deploy Health scorecard is scoped to `demo-github-actions-deploys` to avoid affecting your + existing services. To roll it out broadly, remove the group filter from the scorecard. + To opt in individual services, add the `demo-github-actions-deploys` group to them. diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-async-callback.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-async-callback.yml new file mode 100644 index 0000000..ab9aa97 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-async-callback.yml @@ -0,0 +1,39 @@ +name: Cortex Async Callback +description: Post a completion status back to a Cortex async workflow callback URL. +inputs: + cortex_api_key: + description: Cortex API key + required: true + cortex_callback_url: + description: Cortex async workflow callback URL + required: true + status: + description: Completion status — SUCCESS, FAILURE, or CANCELLED + required: true + message: + description: Optional status message shown in the Cortex workflow run + required: false + default: '' + sha: + description: Git commit SHA to include in callback output + required: false + default: '' + run_url: + description: GitHub Actions run URL to include in callback output + required: false + default: '' +runs: + using: composite + steps: + - name: Post callback + shell: bash + run: | + curl -s -f -X POST "${{ inputs.cortex_callback_url }}" \ + -H "Authorization: Bearer ${{ inputs.cortex_api_key }}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg status "${{ inputs.status }}" \ + --arg message "${{ inputs.message }}" \ + --arg sha "${{ inputs.sha }}" \ + --arg run_url "${{ inputs.run_url }}" \ + '{status: $status, message: $message, output: {sha: $sha, run_url: $run_url}}')" diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml new file mode 100644 index 0000000..9db5d6c --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml @@ -0,0 +1,51 @@ +name: Cortex Deploy + +on: + push: + branches: [main] + workflow_dispatch: + inputs: + cortex_callback_url: + description: 'Cortex async workflow callback URL (set automatically when triggered via Cortex workflow)' + required: false + default: '' + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: | + echo "Starting deployment..." + sleep 10 + for i in 1 2 3; do + sleep 5 + echo "Deployment progress: step $i/3" + done + echo "Build complete!" + + cortex-callback: + needs: build + runs-on: ubuntu-latest + if: always() + steps: + - name: Checkout (for local actions) + uses: actions/checkout@v4 + + - name: Register deploy in Cortex + if: needs.build.result == 'success' + uses: ./.github/actions/cortex-register-deploy + with: + cortex_api_key: ${{ secrets.CORTEX_API_KEY }} + cortex_base_url: ${{ secrets.CORTEX_BASE_URL }} + entity_tag: github-actions-demo + + - name: Cortex async callback + if: inputs.cortex_callback_url != '' + uses: ./.github/actions/cortex-async-callback + with: + cortex_api_key: ${{ secrets.CORTEX_API_KEY }} + cortex_callback_url: ${{ inputs.cortex_callback_url }} + status: ${{ needs.build.result == 'success' && 'SUCCESS' || needs.build.result == 'cancelled' && 'CANCELLED' || 'FAILURE' }} + sha: ${{ github.sha }} + run_url: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-register-deploy.yml b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-register-deploy.yml new file mode 100644 index 0000000..08c0e14 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-register-deploy.yml @@ -0,0 +1,51 @@ +name: Cortex Register Deploy +description: Register a deploy event on a Cortex entity. +inputs: + cortex_api_key: + description: Cortex API key + required: true + cortex_base_url: + description: Cortex base URL + required: false + default: https://api.getcortexapp.com + entity_tag: + description: Cortex entity tag to register the deploy against + required: true + environment: + description: Deployment environment + required: false + default: production + sha: + description: Git commit SHA (defaults to github.sha) + required: false + default: '' +runs: + using: composite + steps: + - name: Register deploy + shell: bash + run: | + SHA="${{ inputs.sha }}" + SHA="${SHA:-${{ github.sha }}}" + curl -s -f -X POST \ + "${{ inputs.cortex_base_url }}/api/v1/catalog/${{ inputs.entity_tag }}/deploys" \ + -H "Authorization: Bearer ${{ inputs.cortex_api_key }}" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg sha "$SHA" \ + --arg actor "${{ github.actor }}" \ + --arg branch "${{ github.ref_name }}" \ + --arg run_id "${{ github.run_id }}" \ + --arg run_url "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \ + --arg trigger "${{ github.event_name }}" \ + --arg env "${{ inputs.environment }}" \ + --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{ + sha: $sha, + timestamp: $timestamp, + environment: $env, + type: "DEPLOY", + title: ("Triggered by " + $actor), + deployer: {name: $actor}, + customData: {branch: $branch, runId: $run_id, runUrl: $run_url, trigger: $trigger} + }')" diff --git a/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml new file mode 100644 index 0000000..39a2070 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/_templates/github-actions-deploy.yaml @@ -0,0 +1,132 @@ +name: "Solution: Add Cortex Deploy from GitHub Actions" +tag: github-actions-deploy +description: | + Triggers the Cortex deploy workflow on GitHub Actions using the GitHub + repository already linked to this service. No inputs required — the + repo is read directly from the entity's catalog configuration. +isDraft: false +isRunnableViaApi: true +filter: + type: ENTITY +variables: +- slug: github-owner + type: STRING + defaultValue: "" + description: GitHub organization or username (derived from entity git config). +- slug: repo-name + type: STRING + defaultValue: "" + description: GitHub repository name (derived from entity git config). +- slug: app-url + type: STRING + defaultValue: "PLACEHOLDER_APP_URL" + description: Cortex app URL (set during installation). +runResponseTemplate: | + # GitHub Actions Deploy — Complete + + **Repo:** {{variables.github-owner}}/{{variables.repo-name}} + + **Run:** [View on GitHub]({{actions.trigger-deploy.outputs.result.run_url}}) + + **Cortex Deploys:** [see deploys for {{context.entity.tag}}]({{variables.app-url}}/admin/service/{{actions.get-entity-details.outputs.body.id}}/deploys) + + --- + + ## How this workflow works + + This Cortex workflow triggered a deploy in GitHub Actions and waited for it to finish. Here's what happened end-to-end: + + **1. Cortex read the linked repo** + + The workflow fetched this entity's catalog config and extracted `x-cortex-git.github.repository` to determine which GitHub repo to deploy — no manual input required. + + **2. Cortex triggered GitHub Actions** + + It called the GitHub API to dispatch `cortex-deploy.yml` via `workflow_dispatch`, passing a one-time callback URL as an input (`cortex_callback_url`). + + **3. GitHub Actions ran the deploy** + + `cortex-deploy.yml` runs two jobs: + + - **build** — your deploy steps (replace the `sleep` placeholder with your real build/deploy) + + - **cortex-callback** — runs after `build`, always, even on failure. It uses two local composite actions bundled in the repo: + + - **`.github/actions/cortex-register-deploy`** — POSTs a deploy event to the Cortex API, recording the SHA, actor, branch, and run URL against this entity. This feeds the Deploy Health scorecard. + + - **`.github/actions/cortex-async-callback`** — POSTs the final status (SUCCESS/FAILURE/CANCELLED) back to the callback URL, which is how Cortex knew this workflow run was done. + + **4. Cortex received the callback** + + When GitHub Actions posted to the callback URL, Cortex marked this workflow run complete and surfaced the result here. + + --- + + ## Adapting this to your own repos + + 1. Copy [`.github/actions/`](https://github.com/{{variables.github-owner}}/{{variables.repo-name}}/tree/main/.github/actions) into your repo + + 2. Add a `cortex-callback` job to your existing workflow that calls both actions + + 3. Set `CORTEX_API_KEY` and `CORTEX_BASE_URL` as repository secrets + + 4. Link the repo to your Cortex entity via `x-cortex-git.github.repository` +actions: +- name: Get entity details + slug: get-entity-details + schema: + type: HTTP_REQUEST + httpMethod: GET + url: "https://api.getcortexapp.com/api/v1/catalog/{{context.entity.tag}}" + headers: + Authorization: "Bearer {{&context.secrets.cortex_api_key}}" + Content-Type: application/json + integration: null + integrationAlias: null + outgoingActions: + - parse-repo + isRootAction: true +- name: Parse repo info + slug: parse-repo + schema: + expression: | + .actions."get-entity-details".outputs.body.git.repository as $repo | + if ($repo == null or $repo == "") then + error("No GitHub repository linked to this entity. Add one via the catalog.") + else + ($repo | split("/")) as $parts | + {owner: $parts[0], repo: $parts[1]} + end + type: JQ + outgoingActions: + - set-variables + isRootAction: false +- name: Set variables + slug: set-variables + schema: + variables: + - slug: github-owner + source: + path: actions.parse-repo.outputs.result.owner + type: REFERENCE + - slug: repo-name + source: + path: actions.parse-repo.outputs.result.repo + type: REFERENCE + type: SET_VARIABLES + outgoingActions: + - trigger-deploy + isRootAction: false +- name: Trigger GitHub Actions Deploy + slug: trigger-deploy + schema: + type: HTTP_REQUEST_ASYNC + httpMethod: POST + url: "/repos/{{variables.github-owner}}/{{variables.repo-name}}/actions/workflows/cortex-deploy.yml/dispatches" + integration: GitHub + integrationAlias: "PLACEHOLDER_INTEGRATION_ALIAS" + headers: {} + payload: '{"ref": "main", "inputs": {"cortex_callback_url": "{{callbackUrl}}"}}' + timeoutInSeconds: 120 + outgoingActions: [] + isRootAction: false diff --git a/cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml b/cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml new file mode 100644 index 0000000..57b9bd4 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml @@ -0,0 +1,9 @@ +openapi: "3.0.0" +info: + title: GitHub Actions Demo + x-cortex-tag: github-actions-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via GitHub Actions. + x-cortex-definition: {} + x-cortex-groups: + - demo-github-actions-deploys diff --git a/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml b/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml new file mode 100644 index 0000000..fc1f0a2 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml @@ -0,0 +1,51 @@ +tag: deploy-health +name: Deploy Health +description: Measures deployment cadence for services using GitHub Actions deploy tracking. Scoped to demo-github-actions-deploys group by default — remove the filter to apply to all services. +draft: false +notifications: + enabled: true + scoreDropNotificationsEnabled: true +exemptions: + enabled: true + autoApprove: false +evaluation: + window: 24 +filter: + kind: GENERIC + types: + include: + - service + query: hasGroup("demo-github-actions-deploys") +ladder: + name: Default Ladder + levels: + - name: Bronze + rank: 1 + description: Service has at least one recorded deployment in the last year. + color: "#CD7F32" + - name: Silver + rank: 2 + description: Service has deployed within the last 30 days. + color: "#C0C0C0" + - name: Gold + rank: 3 + description: Service has deployed within the last 7 days. + color: "#D7AC58" +rules: + - title: Has at least one deploy + description: At least one deployment has been recorded in the last year. + expression: deploys(lookback=duration("P1Y")).length > 0 + weight: 1 + level: Bronze + + - title: Deployed in the last 30 days + description: A deployment was recorded within the past 30 days. + expression: deploys(lookback=duration("P30D")).length > 0 + weight: 1 + level: Silver + + - title: Deployed in the last 7 days + description: A deployment was recorded within the past 7 days, indicating an active delivery cadence. + expression: deploys(lookback=duration("P7D")).length > 0 + weight: 1 + level: Gold diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py new file mode 100644 index 0000000..97bef43 --- /dev/null +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -0,0 +1,511 @@ +""" +Post-install setup script for the github-actions-deploy solution. +Creates and seeds a GitHub repo with the Cortex deploy workflow. +Run via: cortex solutions post-install -s github-actions-deploy +""" + +SETUP_DESCRIPTION = ( + "This solution includes a post-install setup script that will create a GitHub " + "repository, seed it with a GitHub workflow that will add a deploy to a Cortex entity, and configure the required secrets." +) +import base64 +import os +import sys +from pathlib import Path +import requests +from nacl import encoding, public + +try: + from cortexapps_cli.solutions._lib.setup_base import SolutionSetup +except ImportError: + sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + from _lib.setup_base import SolutionSetup + +GITHUB_API = "https://api.github.com" +TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy.yml" +WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "github-actions-deploy.yaml" + +# Local GitHub Actions seeded into the customer repo +_GH_ACTION_TEMPLATES = [ + ( + Path(__file__).parent / "_templates" / "cortex-async-callback.yml", + ".github/actions/cortex-async-callback/action.yml", + ), + ( + Path(__file__).parent / "_templates" / "cortex-register-deploy.yml", + ".github/actions/cortex-register-deploy/action.yml", + ), +] + + +def _hyperlink(url: str, text: str = None) -> str: + """Return an OSC 8 hyperlink for terminals that support it (iTerm2, etc.).""" + label = text if text is not None else url + return f"\033]8;;{url}\033\\{label}\033]8;;\033\\" + + +def _encrypt_secret(public_key_b64: str, secret_value: str) -> str: + """Encrypt a secret using the repo's libsodium public key.""" + pk = public.PublicKey(public_key_b64.encode("utf-8"), encoding.Base64Encoder()) + sealed_box = public.SealedBox(pk) + encrypted = sealed_box.encrypt(secret_value.encode("utf-8")) + return base64.b64encode(encrypted).decode("utf-8") + + +class GitHubActionsSetup(SolutionSetup): + solution_tag = "github-actions-deploy" + + def __init__(self, cortex_api_key: str = None, cortex_base_url: str = None, no_prompt: bool = False, **kwargs): + super().__init__(no_prompt=no_prompt, **kwargs) + self._session_api_key = cortex_api_key + self._session_base_url = cortex_base_url + + def _gh_headers(self) -> dict: + token = self._answers.get("github_token") or os.environ.get("GITHUB_TOKEN", "") + return { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + def _get_authenticated_user(self) -> str: + resp = requests.get(f"{GITHUB_API}/user", headers=self._gh_headers()) + resp.raise_for_status() + return resp.json()["login"] + + def _fetch_github_integrations(self) -> list: + """Fetch GitHub integrations configured in Cortex.""" + if not (self._session_api_key and self._session_base_url): + return [] + try: + resp = requests.get( + f"{self._session_base_url.rstrip('/')}/api/v1/github/configurations", + headers={"Authorization": f"Bearer {self._session_api_key}"}, + timeout=10, + ) + if resp.status_code == 200: + return resp.json().get("configurations", []) + except Exception: + pass + return [] + + def _select_github_integration(self, integrations: list) -> str: + """Present a numbered list and return the chosen alias.""" + integrations = sorted(integrations, key=lambda c: c.get("alias", "").lower()) + saved_alias = self._answers.get("github_integration_alias") + default_idx = next( + (i for i, c in enumerate(integrations) if c.get("alias") == saved_alias), + None, + ) or next( + (i for i, c in enumerate(integrations) if c.get("isDefault")), 0 + ) + print("\nGitHub integrations configured in Cortex:") + for i, cfg in enumerate(integrations): + marker = " *" if cfg.get("isDefault") else " " + type_label = cfg.get("type", "").replace("_", " ").title() + print(f" {marker}{i + 1}. {cfg['alias']} [{type_label}]") + print(" (* = default)") + + if self._no_prompt: + return integrations[default_idx]["alias"] + + while True: + choice = input(f"\nSelect integration [{default_idx + 1}]: ").strip() + if not choice: + return integrations[default_idx]["alias"] + try: + idx = int(choice) - 1 + if 0 <= idx < len(integrations): + return integrations[idx]["alias"] + except ValueError: + pass + print(f" Enter a number between 1 and {len(integrations)}") + + def collect_prompts(self) -> None: + # 1. Check for GitHub integrations — prefer those over a raw token + integrations = self._fetch_github_integrations() + + if integrations: + alias = self._select_github_integration(integrations) + self._answers["github_integration_alias"] = alias + # Token still needed for the setup steps (create repo, seed, set secrets). + # Pull from env var silently; no prompt when integration is configured. + env_token = os.environ.get("GITHUB_TOKEN", "") + if env_token: + self._answers["github_token"] = env_token + # If GITHUB_TOKEN is not set the setup steps will fail with a clear + # error — user can export GITHUB_TOKEN and re-run post-install. + else: + # No integrations configured + if self._session_api_key and self._session_base_url: + print("\nNo GitHub integration is configured in Cortex.") + if self.confirm("Set up a Personal Access Token (PAT) integration now?", default=True): + self.prompt("github_token", "GitHub Personal Access Token", env_var="GITHUB_TOKEN", secret=True) + self.prompt("github_integration_alias", "Integration alias", default="github-pat") + try: + self._create_github_pat_integration() + print(f" Integration '{self._answers['github_integration_alias']}' created \u2713") + except Exception as e: + print(f" Could not create integration: {e}", file=sys.stderr) + print(" Continuing without Cortex integration.", file=sys.stderr) + self._answers.pop("github_integration_alias", None) + else: + self.prompt("github_token", "GitHub token", env_var="GITHUB_TOKEN", secret=True) + else: + self.prompt("github_token", "GitHub token", env_var="GITHUB_TOKEN", secret=True) + + # 2. GitHub owner (derived from auth if token is available) + try: + default_owner = self._get_authenticated_user() + except Exception: + default_owner = None + + self.prompt("github_owner", "GitHub org or username", default=default_owner) + self.prompt("repo_name", "Repository name", default="cortex-deploy-demo") + + # 3. Cortex credentials from CLI session + if self._session_api_key: + if self.confirm("Use Cortex API key used by this CLI session?", default=True): + self._answers["cortex_api_key"] = self._session_api_key + else: + self.prompt("cortex_api_key", "Cortex API key", secret=True) + else: + self.prompt("cortex_api_key", "Cortex API key", env_var="CORTEX_API_KEY", secret=True) + + if self._session_base_url: + if self.confirm(f"Use Cortex base URL used by this CLI session [{self._session_base_url}]?", default=True): + self._answers["cortex_base_url"] = self._session_base_url + else: + self.prompt("cortex_base_url", "Cortex base URL", default=self._session_base_url) + else: + self.prompt( + "cortex_base_url", + "Cortex base URL", + env_var="CORTEX_BASE_URL", + default="https://api.getcortexapp.com", + ) + + def steps(self) -> list[tuple[str, callable]]: + steps = [ + ("Creating GitHub repository", self._create_repo), + ("Seeding Cortex deploy workflow", self._seed_workflow), + ("Seeding cortex-async-callback action", lambda: self._seed_file(*_GH_ACTION_TEMPLATES[0])), + ("Seeding cortex-register-deploy action", lambda: self._seed_file(*_GH_ACTION_TEMPLATES[1])), + ("Setting CORTEX_API_KEY secret", lambda: self._set_secret("CORTEX_API_KEY", self._answers["cortex_api_key"])), + ("Setting CORTEX_BASE_URL secret", lambda: self._set_secret("CORTEX_BASE_URL", self._answers["cortex_base_url"])), + ("Linking GitHub repository to entity", self._link_github_repo), + ] + if self._answers.get("github_integration_alias"): + steps.append(("Creating Cortex deploy workflow", self._import_cortex_workflow)) + return steps + + def post_steps(self) -> None: + print() + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + base_url = self._answers["cortex_base_url"].rstrip("/") + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + cortex_url = f"{app_url}/admin/resources?tag=github-actions-demo" + gh_url = f"https://github.com/{owner}/{repo}" + + workflow_tag = "github-actions-deploy" + entity_url = f"{app_url}/admin/resources?tag=github-actions-demo" + workflows_url = f"{app_url}/admin/workflows?activeTab=runs" + + if self._answers.get("github_integration_alias"): + print(f"\nTo trigger a deploy manually later:") + print(f" CLI: cortex workflows run -t {workflow_tag} --scope ENTITY --entity github-actions-demo") + print(f" UI: {_hyperlink(entity_url, 'github-actions-demo')} → Workflows tab → Solution: Add Cortex Deploy from GitHub Actions → Run") + + if self.confirm("\nTrigger a workflow run now?", default=True): + if self._answers.get("github_integration_alias"): + # Use Cortex async workflow — waits for GitHub Actions callback + print(f"\n Running: POST /api/v1/workflows/{workflow_tag}/runs") + try: + result = self._trigger_via_cortex_workflow() + status = result.get("status", "").upper() + if status == "COMPLETED": + gh_actions_url = f"https://github.com/{owner}/{repo}/actions" + print(f" Deploy complete \u2713") + print(f" {_hyperlink(gh_actions_url, 'View GitHub Actions runs')}") + self.mark_done("first_deploy") + else: + print(f" Workflow ended with status: {status}", file=sys.stderr) + except Exception as e: + print(f" Trigger failed: {e}", file=sys.stderr) + print(f" Re-trigger via: cortex solutions post-install -s {self.solution_tag}", file=sys.stderr) + else: + # No integration — trigger GitHub Actions directly + gh_dispatch_url = f"https://api.github.com/repos/{owner}/{repo}/actions/workflows/cortex-deploy.yml/dispatches" + print(f" Running: POST {gh_dispatch_url}") + try: + self._trigger_direct() + print(f" GitHub Actions workflow triggered \u2713") + print(f" {_hyperlink(f'https://github.com/{owner}/{repo}/actions', 'View GitHub Actions runs')}") + except Exception as e: + print(f" Trigger failed: {e}", file=sys.stderr) + + print(f"\nDone! Watch your deploy appear at:") + print(f" {_hyperlink(cortex_url)}") + print(f"\nGitHub repo: {_hyperlink(gh_url)}") + + def _create_repo(self) -> str: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + gh_url = f"https://github.com/{owner}/{repo}" + + check = requests.get(f"{GITHUB_API}/repos/{owner}/{repo}", headers=self._gh_headers()) + if check.status_code == 200: + return f"Already exists: {_hyperlink(gh_url)}" + if check.status_code != 404: + raise RuntimeError(f"Unexpected status checking repo existence: {check.status_code} {check.text}") + + user_login = self._get_authenticated_user() + url = f"{GITHUB_API}/user/repos" if owner == user_login else f"{GITHUB_API}/orgs/{owner}/repos" + + resp = requests.post( + url, + headers=self._gh_headers(), + json={ + "name": repo, + "description": "Cortex deploy tracking demo — created by cortex solutions post-install", + "private": False, + "auto_init": True, + }, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to create repo: {resp.status_code} {resp.text}") + return f"Created: {_hyperlink(gh_url)}" + + def _seed_file(self, template_path: Path, dest_path: str) -> str: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + content = template_path.read_text() + content_b64 = base64.b64encode(content.encode()).decode() + file_url = f"https://github.com/{owner}/{repo}/blob/main/{dest_path}" + + check = requests.get( + f"{GITHUB_API}/repos/{owner}/{repo}/contents/{dest_path}", + headers=self._gh_headers(), + ) + + payload = {"message": f"Add {dest_path}", "content": content_b64} + + if check.status_code == 200: + existing = check.json() + existing_content = base64.b64decode(existing["content"].replace("\n", "")).decode() + if existing_content == content: + return f"Already up to date: {_hyperlink(file_url)}" + payload["sha"] = existing["sha"] + action = "Updated" + else: + action = "Added" + + resp = requests.put( + f"{GITHUB_API}/repos/{owner}/{repo}/contents/{dest_path}", + headers=self._gh_headers(), + json=payload, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to seed {dest_path}: {resp.status_code} {resp.text}") + return f"{action}: {_hyperlink(file_url)}" + + def _seed_workflow(self) -> str: + return self._seed_file(TEMPLATE_PATH, ".github/workflows/cortex-deploy.yml") + + def _set_secret(self, secret_name: str, secret_value: str) -> str: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + secrets_url = f"https://github.com/{owner}/{repo}/settings/secrets/actions" + + key_resp = requests.get( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/secrets/public-key", + headers=self._gh_headers(), + ) + key_resp.raise_for_status() + key_data = key_resp.json() + + resp = requests.put( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/secrets/{secret_name}", + headers=self._gh_headers(), + json={ + "encrypted_value": _encrypt_secret(key_data["key"], secret_value), + "key_id": key_data["key_id"], + }, + ) + if resp.status_code not in (201, 204): + raise RuntimeError(f"Failed to set secret {secret_name}: {resp.status_code} {resp.text}") + action = "Created" if resp.status_code == 201 else "Updated" + return f"{action} GitHub Actions secret {secret_name}: {_hyperlink(secrets_url, 'View secrets')}" + + def _trigger_direct(self) -> None: + """Trigger the GitHub Actions workflow directly via the GitHub API.""" + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + resp = requests.post( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/workflows/cortex-deploy.yml/dispatches", + headers=self._gh_headers(), + json={"ref": "main"}, + ) + if resp.status_code != 204: + raise RuntimeError(f"Failed to trigger workflow: {resp.status_code} {resp.text}") + + def _create_github_pat_integration(self) -> None: + """Create a GitHub PAT integration in Cortex.""" + resp = requests.post( + f"{self._session_base_url.rstrip('/')}/api/v1/github/configurations/personal", + json={ + "alias": self._answers["github_integration_alias"], + "accessToken": self._answers["github_token"], + "isDefault": True, + }, + headers={"Authorization": f"Bearer {self._session_api_key}"}, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"{resp.status_code} {resp.text}") + + def _link_github_repo(self) -> list: + """PATCH the Cortex entity to link the GitHub repository so workflows are discovered.""" + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + base_url = self._answers["cortex_base_url"].rstrip("/") + api_key = self._answers["cortex_api_key"] + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + entity_url = f"{app_url}/admin/resources?tag=github-actions-demo" + + yaml_content = f"""\ +openapi: "3.0.0" +info: + title: GitHub Actions Demo + x-cortex-tag: github-actions-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via GitHub Actions. + x-cortex-definition: {{}} + x-cortex-groups: + - demo-github-actions-deploys + x-cortex-git: + github: + repository: "{owner}/{repo}" +""" + resp = requests.patch( + f"{base_url}/api/v1/open-api", + data=yaml_content.encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/openapi;charset=UTF-8", + }, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to link GitHub repo to entity: {resp.status_code} {resp.text}") + return [ + f"Patched entity github-actions-demo with:", + f" x-cortex-git:", + f" github:", + f' repository: "{owner}/{repo}"', + f"View entity: {_hyperlink(entity_url)}", + ] + + def _import_cortex_workflow(self) -> str: + """Import the Cortex trigger workflow with the selected GitHub integration alias.""" + base_url = self._answers["cortex_base_url"].rstrip("/") + api_key = self._answers["cortex_api_key"] + alias = self._answers["github_integration_alias"] + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + workflows_url = f"{app_url}/admin/workflows?activeTab=runs" + + yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace( + "PLACEHOLDER_INTEGRATION_ALIAS", alias + ).replace( + "PLACEHOLDER_APP_URL", app_url + ).replace( + "https://api.getcortexapp.com", base_url + ) + + resp = requests.post( + f"{base_url}/api/v1/workflows", + data=yaml_content.encode("utf-8"), + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/yaml", + }, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to import Cortex workflow: {resp.status_code} {resp.text}" + ) + action = "Created" if resp.status_code == 201 else "Updated" + return f"{action} workflow 'github-actions-deploy': {_hyperlink(workflows_url, 'View workflows')}" + + def _trigger_via_cortex_workflow(self) -> dict: + """Trigger the GitHub deploy via the Cortex async workflow and poll for completion.""" + import time + + base_url = self._answers["cortex_base_url"].rstrip("/") + api_key = self._answers["cortex_api_key"] + cortex_headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + workflow_tag = "github-actions-deploy" + + # Entity-scoped runs require entityId (not entityTag) — look it up first + entity_resp = requests.get( + f"{base_url}/api/v1/catalog/github-actions-demo", + headers=cortex_headers, + timeout=10, + ) + if entity_resp.status_code != 200: + raise RuntimeError(f"Failed to fetch entity: {entity_resp.status_code} {entity_resp.text}") + entity_id = entity_resp.json().get("id") + if not entity_id: + raise RuntimeError("Entity 'github-actions-demo' has no id field in catalog response") + + body = { + "scope": {"type": "ENTITY", "entityId": entity_id}, + } + resp = requests.post( + f"{base_url}/api/v1/workflows/{workflow_tag}/runs", + json=body, + headers=cortex_headers, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to start workflow run: {resp.status_code} {resp.text}") + + run_data = resp.json() + run_id = run_data.get("id") + if not run_id: + raise RuntimeError("No run ID returned from workflow start") + + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + workflows_url = f"{app_url}/admin/workflows?activeTab=runs" + + terminal = {"COMPLETED", "FAILED", "CANCELLED"} + start = time.time() + print(f" Waiting for {_hyperlink(workflows_url, 'Cortex workflow')}", end="", flush=True) + while time.time() - start < 300: + time.sleep(5) + r = requests.get( + f"{base_url}/api/v1/workflows/{workflow_tag}/runs/{run_id}", + headers=cortex_headers, + ) + r.raise_for_status() + status = r.json().get("status", "").upper() + print(".", end="", flush=True) + if status in terminal: + print() # newline after dots + result = r.json() + result["_run_id"] = run_id + return result + + raise TimeoutError("Timed out waiting for workflow to complete (5 min)") + + +def main(**kwargs): + GitHubActionsSetup(**kwargs).run() + + +if __name__ == "__main__": + main() diff --git a/docs/superpowers/plans/2026-08-10-github-actions-deploy-solution.md b/docs/superpowers/plans/2026-08-10-github-actions-deploy-solution.md new file mode 100644 index 0000000..cafbf93 --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-github-actions-deploy-solution.md @@ -0,0 +1,1123 @@ +# GitHub Actions Deploy Solution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a `github-actions-deploy` Cortex solution with a demo entity, deploy health scorecard, GitHub Actions workflow template, and an interactive post-install setup script that creates and seeds a GitHub repo end-to-end. + +**Architecture:** Four layers: (1) solution content files installed via existing backup import; (2) a shared `SolutionSetup` base class in `solutions/_lib/` for reusable setup infrastructure; (3) a solution-specific `setup.py` using the GitHub API to create/seed a repo and set secrets; (4) CLI additions — `--skip-post-install-setup` on `install` and a new `post-install` subcommand. + +**Tech Stack:** Python 3.11+, Typer, requests (existing), PyNaCl (new — for GitHub secret encryption) + +## Global Constraints + +- All commits include `Linear: CX-6` in body +- Branch: `cx-6-github-actions-deploy-solution` off `main` +- `_`-prefixed dirs already excluded from `_list_solution_tags` (line 155 of solutions.py) — no change needed +- `requests` is already a dependency — no change needed +- Only new dependency: `PyNaCl >= 1.5.0` +- Solution tag: `github-actions-deploy` +- Demo entity tag: `github-actions-demo` +- Demo group: `demo-github-actions-deploys` +- State file: `~/.cortex/setup-{solution_tag}.json` + +--- + +## File Map + +**Create:** +- `cortexapps_cli/solutions/github-actions-deploy/README.md` +- `cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml` +- `cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml` +- `cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml` +- `cortexapps_cli/solutions/github-actions-deploy/setup.py` +- `cortexapps_cli/solutions/_lib/__init__.py` +- `cortexapps_cli/solutions/_lib/setup_base.py` +- `tests/test_setup_base.py` +- `tests/test_github_actions_setup.py` +- `tests/test_solutions_postinstall.py` + +**Modify:** +- `pyproject.toml` — add PyNaCl dependency +- `cortexapps_cli/commands/solutions.py` — add `_has_post_install`, `_run_post_install_script`, `post_install` subcommand, `--skip-post-install-setup` on `install` + +--- + +### Task 1: Create Feature Branch + +- [ ] **Step 1: Create branch** + +```bash +git checkout -b cx-6-github-actions-deploy-solution +``` + +- [ ] **Step 2: Verify** + +```bash +git branch --show-current +``` +Expected: `cx-6-github-actions-deploy-solution` + +--- + +### Task 2: Solution Content Files + +**Files:** +- Create: `cortexapps_cli/solutions/github-actions-deploy/README.md` +- Create: `cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml` +- Create: `cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml` +- Create: `cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml` + +**Interfaces:** +- Produces: installable solution discoverable by `cortex solutions list` and `cortex solutions info -s github-actions-deploy` + +- [ ] **Step 1: Create README.md** + +`cortexapps_cli/solutions/github-actions-deploy/README.md`: +```markdown +--- +name: GitHub Actions Deploy Tracking +description: Track deployments from GitHub Actions in Cortex, with a deploy health scorecard measuring delivery cadence. +--- + +## What's Included + +- **Entity:** `github-actions-demo` service — a sample entity to receive deploy events +- **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency +- **GitHub Actions workflow:** A two-job workflow (build → deploy notification) to seed into a GitHub repo +- **Setup script:** Interactive wizard that creates and seeds a GitHub repo end-to-end + +## Quick Start + +1. Install the solution: + + ``` + cortex solutions install -s github-actions-deploy + ``` + +2. Follow the post-install setup prompts, or run later: + + ``` + cortex solutions post-install -s github-actions-deploy + ``` + +## How It Works + +The included GitHub Actions workflow fires a deploy event to Cortex after every successful build. +The `notify-cortex` job only runs if the `build` job succeeds, demonstrating conditional deploy tracking. + +## Customizing for Production + +- Point the workflow at your real entity by replacing `github-actions-demo` with your service tag +- Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` secrets to your real repos +- The Deploy Health scorecard is scoped to `demo-github-actions-deploys` to avoid affecting your + existing services. To roll it out broadly, remove the group filter from the scorecard. + To opt in individual services, add the `demo-github-actions-deploys` group to them. +``` + +- [ ] **Step 2: Create catalog entity** + +`cortexapps_cli/solutions/github-actions-deploy/catalog/github-actions-demo.yaml`: +```yaml +openapi: "3.0.0" +info: + title: GitHub Actions Demo + x-cortex-tag: github-actions-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via GitHub Actions. + x-cortex-definition: {} + x-cortex-groups: + - demo-github-actions-deploys +``` + +- [ ] **Step 3: Create scorecard** + +`cortexapps_cli/solutions/github-actions-deploy/scorecards/deploy-health.yaml`: +```yaml +tag: deploy-health +name: Deploy Health +description: Measures deployment cadence for services using GitHub Actions deploy tracking. Scoped to demo-github-actions-deploys group by default — remove the filter to apply to all services. +draft: false +notifications: + enabled: true + scoreDropNotificationsEnabled: true +exemptions: + enabled: true + autoApprove: false +evaluation: + window: 24 +filter: + kind: GENERIC + types: + include: + - service + query: hasGroup("demo-github-actions-deploys") +ladder: + name: Default Ladder + levels: + - name: Bronze + rank: 1 + description: Service has at least one recorded deployment. + color: "#CD7F32" + - name: Silver + rank: 2 + description: Service has deployed within the last 30 days. + color: "#C0C0C0" + - name: Gold + rank: 3 + description: Service has deployed within the last 7 days. + color: "#D7AC58" +rules: + - title: Has at least one deploy + description: At least one deployment event has been recorded for this service. + expression: deploys().count() > 0 + weight: 1 + level: Bronze + + - title: Deployed in the last 30 days + description: A deployment was recorded within the past 30 days. + expression: deploys(lookback=duration("P30D")).count() > 0 + weight: 1 + level: Silver + + - title: Deployed in the last 7 days + description: A deployment was recorded within the past 7 days, indicating an active delivery cadence. + expression: deploys(lookback=duration("P7D")).count() > 0 + weight: 1 + level: Gold +``` + +- [ ] **Step 4: Create GitHub Actions workflow template** + +`cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml`: +```yaml +name: Cortex Deploy + +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: echo "Hello, Cortex deploys!" + + notify-cortex: + needs: build + runs-on: ubuntu-latest + steps: + - name: Register deploy in Cortex + run: | + curl -s -f -X POST \ + "${{ secrets.CORTEX_BASE_URL }}/api/v1/catalog/github-actions-demo/deploys" \ + -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d '{ + "sha": "${{ github.sha }}", + "environment": "production", + "type": "DEPLOY", + "title": "Triggered by ${{ github.actor }}", + "deployer": { "name": "${{ github.actor }}" }, + "customData": { + "branch": "${{ github.ref_name }}", + "runId": "${{ github.run_id }}", + "runUrl": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "trigger": "${{ github.event_name }}" + } + }' +``` + +- [ ] **Step 5: Verify solution is discoverable** + +```bash +poetry run cortex solutions list +poetry run cortex solutions info -s github-actions-deploy +``` +Expected: `github-actions-deploy` appears in list; README renders with "What's Included" section. + +- [ ] **Step 6: Commit** + +```bash +git add cortexapps_cli/solutions/github-actions-deploy/ +git commit -m "$(cat <<'EOF' +add: github-actions-deploy solution content files + +Catalog entity, deploy health scorecard, GitHub Actions workflow template, +and README for the GitHub Actions deploy tracking solution. + +Linear: CX-6 + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +--- + +### Task 3: SolutionSetup Base Class + +**Files:** +- Create: `cortexapps_cli/solutions/_lib/__init__.py` +- Create: `cortexapps_cli/solutions/_lib/setup_base.py` +- Create: `tests/test_setup_base.py` + +**Interfaces:** +- Produces: + - `SolutionSetup` importable from `cortexapps_cli.solutions._lib.setup_base` + - `SolutionSetup.solution_tag: str` — set by subclass + - `SolutionSetup.prompt(key, message, env_var=None, default=None, secret=False) -> str` + - `SolutionSetup.confirm(message, default=True) -> bool` + - `SolutionSetup.already_done(key) -> bool` + - `SolutionSetup.mark_done(key) -> None` + - `SolutionSetup.collect_prompts() -> None` — abstract + - `SolutionSetup.steps() -> list[tuple[str, callable]]` — abstract + - `SolutionSetup.post_steps() -> None` — optional hook, default no-op + - `SolutionSetup.run() -> None` + +- [ ] **Step 1: Write failing tests** + +`tests/test_setup_base.py`: +```python +import json +import pytest +from pathlib import Path +from unittest.mock import patch +from cortexapps_cli.solutions._lib.setup_base import SolutionSetup + + +class ConcreteSetup(SolutionSetup): + solution_tag = "test-solution" + steps_called = [] + + def collect_prompts(self): + self._answers["name"] = self.prompt("name", "Your name", default="Alice") + + def steps(self): + return [("Do thing", lambda: ConcreteSetup.steps_called.append(True))] + + +def test_prompt_uses_env_var(tmp_path, monkeypatch): + monkeypatch.setenv("MY_VAR", "from-env") + setup = ConcreteSetup(state_dir=tmp_path) + result = setup.prompt("key", "Enter value", env_var="MY_VAR") + assert result == "from-env" + + +def test_prompt_uses_default_on_empty_input(tmp_path, monkeypatch): + monkeypatch.delenv("MY_VAR", raising=False) + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + result = setup.prompt("key", "Enter value", default="default-val") + assert result == "default-val" + + +def test_prompt_uses_user_input(tmp_path, monkeypatch): + monkeypatch.delenv("MY_VAR", raising=False) + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="user-value"): + result = setup.prompt("key", "Enter value", default="default-val") + assert result == "user-value" + + +def test_confirm_returns_true_for_y(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="y"): + assert setup.confirm("Do it?") is True + + +def test_confirm_returns_false_for_n(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="n"): + assert setup.confirm("Do it?") is False + + +def test_confirm_uses_default_on_empty(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + assert setup.confirm("Do it?", default=True) is True + with patch("builtins.input", return_value=""): + assert setup.confirm("Do it?", default=False) is False + + +def test_already_done_false_initially(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + assert setup.already_done("step1") is False + + +def test_mark_done_persists(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + setup.mark_done("step1") + assert setup.already_done("step1") is True + + +def test_mark_done_persists_across_instances(tmp_path): + ConcreteSetup(state_dir=tmp_path).mark_done("step1") + assert ConcreteSetup(state_dir=tmp_path).already_done("step1") is True + + +def test_state_file_path(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + assert setup._state_file == tmp_path / "setup-test-solution.json" + + +def test_post_steps_called_after_steps(tmp_path): + post_called = [] + + class SetupWithPost(ConcreteSetup): + def post_steps(self): + post_called.append(True) + + setup = SetupWithPost(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + setup.run() + assert post_called == [True] +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +poetry run pytest tests/test_setup_base.py -v +``` +Expected: `ModuleNotFoundError` for `cortexapps_cli.solutions._lib.setup_base` + +- [ ] **Step 3: Create `_lib/__init__.py`** + +`cortexapps_cli/solutions/_lib/__init__.py` — empty file. + +- [ ] **Step 4: Implement `setup_base.py`** + +`cortexapps_cli/solutions/_lib/setup_base.py`: +```python +import json +import os +import sys +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + + +class SolutionSetup(ABC): + """ + Base class for solution post-install setup scripts. + Subclasses define solution_tag, collect_prompts(), and steps(). + """ + + solution_tag: str # must be set by subclass + + def __init__(self, state_dir: Optional[Path] = None): + self._answers: dict = {} + state_dir = state_dir or Path.home() / ".cortex" + state_dir.mkdir(parents=True, exist_ok=True) + self._state_file = state_dir / f"setup-{self.solution_tag}.json" + self._state: dict = self._load_state() + + def _load_state(self) -> dict: + if self._state_file.exists(): + try: + return json.loads(self._state_file.read_text()) + except (json.JSONDecodeError, OSError): + return {} + return {} + + def _save_state(self) -> None: + self._state_file.write_text(json.dumps(self._state, indent=2)) + + def prompt( + self, + key: str, + message: str, + env_var: Optional[str] = None, + default: Optional[str] = None, + secret: bool = False, + ) -> str: + """Prompt for a value. Uses env var if set, then prompts with optional default.""" + if env_var: + env_val = os.environ.get(env_var) + if env_val: + masked = "********" if secret else env_val + print(f"{message} [{masked} from {env_var}]") + self._answers[key] = env_val + return env_val + + prompt_str = message + if default: + prompt_str += f" [{default}]" + prompt_str += ": " + + value = input(prompt_str).strip() + if not value: + value = default or "" + self._answers[key] = value + return value + + def confirm(self, message: str, default: bool = True) -> bool: + """Y|N confirmation prompt.""" + hint = "[Y/n]" if default else "[y/N]" + response = input(f"{message} {hint}: ").strip().lower() + if not response: + return default + return response in ("y", "yes") + + def already_done(self, key: str) -> bool: + """Return True if this step was previously completed.""" + return self._state.get(key, False) + + def mark_done(self, key: str) -> None: + """Mark a step as completed in the persistent state file.""" + self._state[key] = True + self._save_state() + + @abstractmethod + def collect_prompts(self) -> None: + """Collect all user inputs upfront before executing steps.""" + + @abstractmethod + def steps(self) -> list[tuple[str, callable]]: + """Return ordered list of (label, callable) tuples.""" + + def post_steps(self) -> None: + """Optional hook called after all steps complete. Override in subclass.""" + + def run(self) -> None: + """Collect prompts then execute steps with progress display.""" + self.collect_prompts() + print() + step_list = self.steps() + total = len(step_list) + for i, (label, fn) in enumerate(step_list, 1): + try: + fn() + print(f"[{i}/{total}] {label}... \u2713") + except Exception as e: + print(f"[{i}/{total}] {label}... \u2717 {e}", file=sys.stderr) + raise SystemExit(1) + self.post_steps() +``` + +- [ ] **Step 5: Run tests — verify they pass** + +```bash +poetry run pytest tests/test_setup_base.py -v +``` +Expected: all PASS + +- [ ] **Step 6: Commit** + +```bash +git add cortexapps_cli/solutions/_lib/ tests/test_setup_base.py +git commit -m "$(cat <<'EOF' +add: SolutionSetup base class for reusable post-install setup scripts + +Provides prompt collection with env var fallback, Y/N confirmation, +idempotency state tracking via ~/.cortex/setup-{solution}.json, step +execution with progress display, and post_steps() hook for subclasses. + +Linear: CX-6 + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +--- + +### Task 4: Add PyNaCl Dependency + +**Files:** +- Modify: `pyproject.toml` + +- [ ] **Step 1: Add to `[tool.poetry.dependencies]` in `pyproject.toml`** + +```toml +PyNaCl = ">=1.5.0" +``` + +- [ ] **Step 2: Install** + +```bash +poetry install +``` + +- [ ] **Step 3: Verify** + +```bash +poetry run python -c "from nacl import encoding, public; print('PyNaCl OK')" +``` +Expected: `PyNaCl OK` + +- [ ] **Step 4: Commit** + +```bash +git add pyproject.toml poetry.lock +git commit -m "$(cat <<'EOF' +add: PyNaCl dependency for GitHub secret encryption + +Required by github-actions-deploy setup script to encrypt secrets +before storing them via the GitHub API (libsodium sealed box). + +Linear: CX-6 + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +--- + +### Task 5: GitHub Actions Deploy Setup Script + +**Files:** +- Create: `cortexapps_cli/solutions/github-actions-deploy/setup.py` +- Create: `tests/test_github_actions_setup.py` + +**Interfaces:** +- Consumes: `SolutionSetup` from `cortexapps_cli.solutions._lib.setup_base` +- Produces: `GitHubActionsSetup` class and `main()` callable by `cortex solutions post-install` + +- [ ] **Step 1: Write failing tests** + +`tests/test_github_actions_setup.py`: +```python +import base64 +import importlib.util +import json +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + + +def load_setup_module(): + spec = importlib.util.spec_from_file_location( + "github_actions_setup", + "cortexapps_cli/solutions/github-actions-deploy/setup.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def mod(): + return load_setup_module() + + +@pytest.fixture +def setup(mod, tmp_path): + instance = mod.GitHubActionsSetup(state_dir=tmp_path) + instance._answers = { + "github_token": "ghp_test", + "github_owner": "test-org", + "repo_name": "cortex-deploy-demo", + "cortex_api_key": "crt_testkey", + "cortex_base_url": "https://api.getcortexapp.com", + } + return instance + + +def test_get_authenticated_user(setup): + resp = MagicMock(status_code=200) + resp.json.return_value = {"login": "test-user"} + with patch("requests.get", return_value=resp): + assert setup._get_authenticated_user() == "test-user" + + +def test_create_repo_skips_if_exists(setup): + resp = MagicMock(status_code=200) + with patch("requests.get", return_value=resp) as mock_get, \ + patch("requests.post") as mock_post: + setup._create_repo() + mock_get.assert_called_once() + mock_post.assert_not_called() + + +def test_create_repo_creates_when_missing(setup): + user_resp = MagicMock(status_code=200) + user_resp.json.return_value = {"login": "test-org"} + check_resp = MagicMock(status_code=404) + post_resp = MagicMock(status_code=201) + post_resp.json.return_value = {"html_url": "https://github.com/test-org/cortex-deploy-demo"} + + get_responses = [check_resp, user_resp] + with patch("requests.get", side_effect=get_responses), \ + patch("requests.post", return_value=post_resp) as mock_post: + setup._create_repo() + mock_post.assert_called_once() + + +def test_seed_workflow_skips_if_unchanged(setup, tmp_path): + # Read the actual template to simulate matching content + template_path = Path("cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml") + content = template_path.read_text() + content_b64 = base64.b64encode(content.encode()).decode() + + resp = MagicMock(status_code=200) + resp.json.return_value = {"content": content_b64, "sha": "abc123"} + + with patch("requests.get", return_value=resp), \ + patch("requests.put") as mock_put: + setup._seed_workflow() + mock_put.assert_not_called() + + +def test_seed_workflow_creates_when_missing(setup): + get_resp = MagicMock(status_code=404) + put_resp = MagicMock(status_code=201) + put_resp.json.return_value = {"content": {"sha": "abc123"}} + + with patch("requests.get", return_value=get_resp), \ + patch("requests.put", return_value=put_resp) as mock_put: + setup._seed_workflow() + mock_put.assert_called_once() + + +def test_set_secret(setup): + # 32-byte key for valid libsodium public key + dummy_key = base64.b64encode(b"\x00" * 32).decode() + key_resp = MagicMock(status_code=200) + key_resp.json.return_value = {"key_id": "key123", "key": dummy_key} + key_resp.raise_for_status = MagicMock() + + put_resp = MagicMock(status_code=204) + + with patch("requests.get", return_value=key_resp), \ + patch("requests.put", return_value=put_resp) as mock_put: + setup._set_secret("CORTEX_API_KEY", "crt_testkey") + + mock_put.assert_called_once() + call_json = mock_put.call_args.kwargs["json"] + assert "encrypted_value" in call_json + assert call_json["key_id"] == "key123" + + +def test_trigger_workflow(setup): + resp = MagicMock(status_code=204) + with patch("requests.post", return_value=resp) as mock_post: + setup._trigger_workflow() + url = mock_post.call_args.args[0] + assert "dispatches" in url + assert mock_post.call_args.kwargs["json"] == {"ref": "main"} + + +def test_main_callable(mod): + assert callable(mod.main) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +poetry run pytest tests/test_github_actions_setup.py -v +``` +Expected: failures (setup.py missing) + +- [ ] **Step 3: Implement `setup.py`** + +`cortexapps_cli/solutions/github-actions-deploy/setup.py`: +```python +""" +Post-install setup script for the github-actions-deploy solution. +Creates and seeds a GitHub repo with the Cortex deploy workflow. +Run via: cortex solutions post-install -s github-actions-deploy +""" +import base64 +import sys +from pathlib import Path +from typing import Optional + +import requests +from nacl import encoding, public + +try: + from cortexapps_cli.solutions._lib.setup_base import SolutionSetup +except ImportError: + sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + from _lib.setup_base import SolutionSetup + +GITHUB_API = "https://api.github.com" +TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy.yml" + + +def _encrypt_secret(public_key_b64: str, secret_value: str) -> str: + """Encrypt a secret using the repo's libsodium public key.""" + pk = public.PublicKey(public_key_b64.encode("utf-8"), encoding.Base64Encoder()) + sealed_box = public.SealedBox(pk) + encrypted = sealed_box.encrypt(secret_value.encode("utf-8")) + return base64.b64encode(encrypted).decode("utf-8") + + +class GitHubActionsSetup(SolutionSetup): + solution_tag = "github-actions-deploy" + + def _gh_headers(self) -> dict: + return { + "Authorization": f"Bearer {self._answers['github_token']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + def _get_authenticated_user(self) -> str: + resp = requests.get(f"{GITHUB_API}/user", headers=self._gh_headers()) + resp.raise_for_status() + return resp.json()["login"] + + def collect_prompts(self) -> None: + self.prompt("github_token", "GitHub token", env_var="GITHUB_TOKEN", secret=True) + + try: + default_owner = self._get_authenticated_user() + except Exception: + default_owner = None + + self.prompt("github_owner", "GitHub org or username", default=default_owner) + self.prompt("repo_name", "Repository name", default="cortex-deploy-demo") + self.prompt("cortex_api_key", "Cortex API key", env_var="CORTEX_API_KEY", secret=True) + self.prompt( + "cortex_base_url", + "Cortex base URL", + env_var="CORTEX_BASE_URL", + default="https://api.getcortexapp.com", + ) + + def steps(self) -> list[tuple[str, callable]]: + return [ + ("Creating GitHub repository", self._create_repo), + ("Seeding Cortex deploy workflow", self._seed_workflow), + ("Setting CORTEX_API_KEY secret", lambda: self._set_secret("CORTEX_API_KEY", self._answers["cortex_api_key"])), + ("Setting CORTEX_BASE_URL secret", lambda: self._set_secret("CORTEX_BASE_URL", self._answers["cortex_base_url"])), + ] + + def post_steps(self) -> None: + print() + if self.confirm("Ready to trigger your first workflow run?", default=True): + try: + self._trigger_workflow() + print(f"[5/5] Triggering workflow... \u2713") + except Exception as e: + print(f"Trigger failed: {e}", file=sys.stderr) + raise SystemExit(1) + + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + base_url = self._answers["cortex_base_url"].rstrip("/") + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + print(f"\nDone! Watch your first deploy appear at:") + print(f" {app_url}/catalog/github-actions-demo") + print(f"\nGitHub repo: https://github.com/{owner}/{repo}") + + def _create_repo(self) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + + check = requests.get(f"{GITHUB_API}/repos/{owner}/{repo}", headers=self._gh_headers()) + if check.status_code == 200: + return # already exists + + user_login = self._get_authenticated_user() + url = f"{GITHUB_API}/user/repos" if owner == user_login else f"{GITHUB_API}/orgs/{owner}/repos" + + resp = requests.post( + url, + headers=self._gh_headers(), + json={ + "name": repo, + "description": "Cortex deploy tracking demo — created by cortex solutions post-install", + "private": False, + "auto_init": True, + }, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to create repo: {resp.status_code} {resp.text}") + + def _seed_workflow(self) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + path = ".github/workflows/cortex-deploy.yml" + content = TEMPLATE_PATH.read_text() + content_b64 = base64.b64encode(content.encode()).decode() + + check = requests.get( + f"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}", + headers=self._gh_headers(), + ) + + payload = {"message": "Add Cortex deploy notification workflow", "content": content_b64} + + if check.status_code == 200: + existing = check.json() + existing_content = base64.b64decode(existing["content"].replace("\n", "")).decode() + if existing_content == content: + return # unchanged + payload["sha"] = existing["sha"] + + resp = requests.put( + f"{GITHUB_API}/repos/{owner}/{repo}/contents/{path}", + headers=self._gh_headers(), + json=payload, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to seed workflow: {resp.status_code} {resp.text}") + + def _set_secret(self, secret_name: str, secret_value: str) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + + key_resp = requests.get( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/secrets/public-key", + headers=self._gh_headers(), + ) + key_resp.raise_for_status() + key_data = key_resp.json() + + resp = requests.put( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/secrets/{secret_name}", + headers=self._gh_headers(), + json={ + "encrypted_value": _encrypt_secret(key_data["key"], secret_value), + "key_id": key_data["key_id"], + }, + ) + if resp.status_code not in (201, 204): + raise RuntimeError(f"Failed to set secret {secret_name}: {resp.status_code} {resp.text}") + + def _trigger_workflow(self) -> None: + owner = self._answers["github_owner"] + repo = self._answers["repo_name"] + + resp = requests.post( + f"{GITHUB_API}/repos/{owner}/{repo}/actions/workflows/cortex-deploy.yml/dispatches", + headers=self._gh_headers(), + json={"ref": "main"}, + ) + if resp.status_code != 204: + raise RuntimeError(f"Failed to trigger workflow: {resp.status_code} {resp.text}") + + +def main(): + GitHubActionsSetup().run() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +poetry run pytest tests/test_github_actions_setup.py -v +``` +Expected: all PASS + +- [ ] **Step 5: Commit** + +```bash +git add cortexapps_cli/solutions/github-actions-deploy/setup.py tests/test_github_actions_setup.py +git commit -m "$(cat <<'EOF' +add: github-actions-deploy post-install setup script + +Interactive wizard that creates a GitHub repo, seeds the Cortex deploy +workflow, sets CORTEX_API_KEY and CORTEX_BASE_URL secrets, and optionally +triggers the first workflow run. All steps are idempotent. + +Linear: CX-6 + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +--- + +### Task 6: CLI Integration — `post-install` Subcommand + Install Hook + +**Files:** +- Modify: `cortexapps_cli/commands/solutions.py` +- Create: `tests/test_solutions_postinstall.py` + +**Interfaces:** +- Produces: + - `cortex solutions post-install -s github-actions-deploy` → runs setup script + - `cortex solutions post-install -s ai-agents` → "No post-install setup available" + - `cortex solutions install -s github-actions-deploy` → prompts for post-install after import + - `cortex solutions install -s github-actions-deploy --skip-post-install-setup` → skips prompt + +- [ ] **Step 1: Write failing tests** + +`tests/test_solutions_postinstall.py`: +```python +import pytest +from unittest.mock import patch, MagicMock +from typer.testing import CliRunner +from cortexapps_cli.cli import app + +runner = CliRunner() + + +def test_post_install_no_setup_for_ai_agents(): + result = runner.invoke(app, ["solutions", "post-install", "-s", "ai-agents"]) + assert result.exit_code == 0 + assert "No post-install setup available" in result.output + + +def test_post_install_unknown_solution(): + result = runner.invoke(app, ["solutions", "post-install", "-s", "nonexistent-xyz"]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + +def test_post_install_calls_run_for_github_actions(): + with patch("cortexapps_cli.commands.solutions._run_post_install_script") as mock_run: + runner.invoke(app, ["solutions", "post-install", "-s", "github-actions-deploy"]) + mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None) +``` + +- [ ] **Step 2: Run tests — verify they fail** + +```bash +poetry run pytest tests/test_solutions_postinstall.py -v +``` +Expected: failures (`post-install` subcommand doesn't exist yet) + +- [ ] **Step 3: Add `_has_post_install` and `_run_post_install_script` helpers to `solutions.py`** + +Add after the existing `_get_readme` function (after line ~177): + +```python +def _has_post_install(tag: str, path: str | None = None) -> bool: + """Return True if this solution has a post-install setup.py.""" + try: + (_solutions_root(path) / tag / "setup.py").read_bytes() + return True + except Exception: + return False + + +def _run_post_install_script(solution_tag: str, solutions_dir: str | None = None) -> None: + """Find and invoke the solution's setup.py main() function.""" + import importlib.util + + root = _solutions_root(solutions_dir) + try: + with as_file(root / solution_tag / "setup.py") as setup_path: + if not setup_path.exists(): + typer.echo("No post-install setup available for this solution.") + return + spec = importlib.util.spec_from_file_location(f"{solution_tag}_setup", setup_path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + module.main() + except FileNotFoundError: + typer.echo("No post-install setup available for this solution.") +``` + +- [ ] **Step 4: Add `post_install` subcommand to `solutions.py`** + +Add after the `install` command (after line ~661): + +```python +@app.command(name="post-install") +def post_install( + ctx: typer.Context, + solution: str = typer.Option(..., "--solution", "-s", help="Solution tag"), +): + """Run post-install setup for a solution.""" + solutions_dir = ctx.obj.get("solutions_dir") if ctx.obj else None + if solution not in _list_solution_tags(solutions_dir): + avail = ", ".join(_list_solution_tags(solutions_dir)) + typer.echo(f"Error: Solution '{solution}' not found. Available: {avail}") + raise typer.Exit(1) + _run_post_install_script(solution, solutions_dir=solutions_dir) +``` + +- [ ] **Step 5: Add `--skip-post-install-setup` to `install` and inject post-install hook** + +In the `install` command signature (line ~603), add the new option: +```python +skip_post_install_setup: bool = typer.Option( + False, + "--skip-post-install-setup", + help="Skip the post-install setup script prompt", + is_flag=True, +), +``` + +After line 644 (end of import report display), before the `if not no_prompt:` block, insert: +```python + # Post-install setup hook — runs before the informational menu + if not no_prompt and not skip_post_install_setup and _has_post_install(solution, solutions_dir): + typer.echo("\nThis solution includes a post-install setup script.") + if typer.confirm("Run setup now?", default=True): + _run_post_install_script(solution, solutions_dir=solutions_dir) + else: + typer.echo(f"\nRun setup later with: cortex solutions post-install -s {solution}") + elif skip_post_install_setup and _has_post_install(solution, solutions_dir): + typer.echo(f"\nRun setup later with: cortex solutions post-install -s {solution}") +``` + +- [ ] **Step 6: Run tests — verify they pass** + +```bash +poetry run pytest tests/test_solutions_postinstall.py -v +``` +Expected: all PASS + +- [ ] **Step 7: Run existing solutions tests for regressions** + +```bash +poetry run pytest tests/test_solutions.py -v +``` +Expected: all PASS + +- [ ] **Step 8: Smoke test CLI** + +```bash +poetry run cortex solutions list +poetry run cortex solutions info -s github-actions-deploy +poetry run cortex solutions post-install -s ai-agents +``` +Expected: `github-actions-deploy` in list; README renders; "No post-install setup available" for ai-agents. + +- [ ] **Step 9: Commit** + +```bash +git add cortexapps_cli/commands/solutions.py tests/test_solutions_postinstall.py +git commit -m "$(cat <<'EOF' +feat: add solutions post-install subcommand and install hook + +- New `cortex solutions post-install -s ` subcommand +- `cortex solutions install` prompts for post-install setup when setup.py present +- `--skip-post-install-setup` flag bypasses the prompt +- Helper functions _has_post_install and _run_post_install_script for reuse + +Linear: CX-6 + +Co-Authored-By: Claude Sonnet 4.6 +EOF +)" +``` + +--- + +## Self-Review Checklist + +| Spec Requirement | Task | +|---|---| +| Solution content files (entity, scorecard, template, README) | Task 2 | +| Entity with `demo-github-actions-deploys` group | Task 2 | +| Scorecard scoped to group with production note | Task 2 | +| `_templates/` convention for external files | Task 2 | +| `SolutionSetup` base class | Task 3 | +| `_lib/` shared directory, excluded from solutions list | Task 3 (already filtered) | +| Two-job GH workflow with `needs: build` | Task 2 | +| customData with branch/runId/runUrl/trigger | Task 2 | +| CORTEX_API_KEY + CORTEX_BASE_URL secrets | Tasks 2, 5 | +| Prompt for GH token, owner (derived), repo, API key, base URL | Task 5 | +| Idempotency per step via API checks | Task 5 | +| "Ready to trigger?" confirm via `post_steps()` | Tasks 3, 5 | +| PyNaCl for secret encryption | Task 4 | +| `cortex solutions post-install -s ` | Task 6 | +| `--skip-post-install-setup` flag | Task 6 | +| Post-install prompt in `install` flow | Task 6 | +| "No post-install setup available" for other solutions | Task 6 | +| "Run later" message when skipped | Task 6 | +| Reusable base class pattern for future solutions | Task 3 | diff --git a/docs/superpowers/specs/2026-08-10-github-actions-deploy-solution-design.md b/docs/superpowers/specs/2026-08-10-github-actions-deploy-solution-design.md new file mode 100644 index 0000000..acfafeb --- /dev/null +++ b/docs/superpowers/specs/2026-08-10-github-actions-deploy-solution-design.md @@ -0,0 +1,277 @@ +# GitHub Actions Deploy Solution Design + +**Date:** 2026-08-10 +**Linear:** [CX-6](https://linear.app/cortexio/issue/CX-6/deploys-github-actions) + +## Overview + +A Cortex solution that demonstrates deploy tracking via GitHub Actions. Serves both pre-sales (polished demo, end-to-end in minutes) and post-sales (real-world template customers adapt for production). + +--- + +## Solution Structure + +``` +cortexapps_cli/solutions/github-actions-deploy/ +├── README.md +├── catalog/ +│ └── github-actions-demo.yaml +├── scorecards/ +│ └── deploy-health.yaml +├── _templates/ +│ └── cortex-deploy.yml # GH Actions workflow seeded into user's repo +└── setup.py # Post-install setup script + +cortexapps_cli/solutions/_lib/ +└── setup_base.py # Shared SolutionSetup base class +``` + +### Key Conventions +- `_templates/` — files destined for a user's external repo, not their Cortex workspace +- `_lib/` — shared infrastructure not installed into Cortex +- Entity creation handled by backup import format (same as all other solutions) +- `setup.py` presence signals to `cortex solutions install` that post-install setup is available + +--- + +## Entity: `github-actions-demo` + +A standard service entity scoped to the demo via a group tag: + +```yaml +openapi: "3.0.0" +info: + title: GitHub Actions Demo + x-cortex-tag: github-actions-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via GitHub Actions. + x-cortex-definition: {} + x-cortex-groups: + - demo-github-actions-deploys +``` + +--- + +## GitHub Actions Workflow (`_templates/cortex-deploy.yml`) + +Two jobs with explicit dependency — deploy notification only fires if build succeeds: + +```yaml +on: + push: + branches: [main] + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Build + run: echo "Hello, Cortex deploys!" + + notify-cortex: + needs: build + runs-on: ubuntu-latest + steps: + - name: Register deploy in Cortex + run: | + curl -s -X POST \ + "${{ secrets.CORTEX_BASE_URL }}/api/v1/catalog/github-actions-demo/deploys" \ + -H "Authorization: Bearer ${{ secrets.CORTEX_API_KEY }}" \ + -H "Content-Type: application/json" \ + -d '{ + "sha": "${{ github.sha }}", + "environment": "production", + "type": "DEPLOY", + "title": "Triggered by ${{ github.actor }}", + "deployer": { "name": "${{ github.actor }}" }, + "customData": { + "branch": "${{ github.ref_name }}", + "runId": "${{ github.run_id }}", + "runUrl": "${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}", + "trigger": "${{ github.event_name }}" + } + }' +``` + +Two GitHub Actions secrets required: `CORTEX_API_KEY`, `CORTEX_BASE_URL`. + +--- + +## Scorecard: Deploy Health + +Scoped to the `demo-github-actions-deploys` group to avoid affecting existing services. + +```yaml +tag: deploy-health +name: Deploy Health +description: Measures deployment cadence. Scoped to demo-github-actions-deploys group by default. +filter: + kind: GENERIC + types: + include: + - service + query: hasGroup("demo-github-actions-deploys") +ladder: + levels: + - name: Bronze + rank: 1 + color: "#CD7F32" + - name: Silver + rank: 2 + color: "#C0C0C0" + - name: Gold + rank: 3 + color: "#D7AC58" +rules: + - title: Has at least one deploy + expression: deploys().count() > 0 + level: Bronze + + - title: Deployed in the last 30 days + expression: deploys(lookback=duration("P30D")).count() > 0 + level: Silver + + - title: Deployed in the last 7 days + expression: deploys(lookback=duration("P7D")).count() > 0 + level: Gold +``` + +**Production note (in README):** Remove the group filter to apply the scorecard to all services. Customers can also add `demo-github-actions-deploys` to any existing service to opt it in. + +--- + +## Setup Infrastructure + +### Shared Base Class (`solutions/_lib/setup_base.py`) + +```python +class SolutionSetup: + def steps(self) -> list[tuple[str, callable]]: + """Subclass returns ordered list of (label, fn) tuples.""" + ... + + def prompt(self, key, message, env_var=None, default=None, secret=False): + """Prompt with env var fallback. Masks secrets. Caches answers.""" + ... + + def confirm(self, message) -> bool: + """Y|N prompt, returns bool.""" + ... + + def already_done(self, key) -> bool: + """Check idempotency state from ~/.cortex/setup-.json.""" + ... + + def mark_done(self, key): + """Persist step completion to state file.""" + ... + + def run(self): + """Collect prompts, then execute steps with progress display and error handling.""" + ... +``` + +State file: `~/.cortex/setup-.json` — keyed per step, so re-runs skip completed steps and retry failed ones. + +### Solution Setup Script (`github-actions-deploy/setup.py`) + +Subclasses `SolutionSetup`. Prompts collected upfront: + +``` +GitHub token (or set GITHUB_TOKEN): ******** +GitHub org or username []: +Repository name [cortex-deploy-demo]: +Cortex API key (or set CORTEX_API_KEY): ******** +Cortex base URL (or set CORTEX_BASE_URL): https://api.getcortexapp.com +``` + +GitHub org/username defaults to the authenticated user derived via `GET https://api.github.com/user`. Supports org override for company repos. + +Step execution: + +``` +[1/5] Creating GitHub repository... ✓ (skips if already exists) +[2/5] Seeding Cortex deploy workflow... ✓ (skips if already seeded) +[3/5] Setting CORTEX_API_KEY secret... ✓ +[4/5] Setting CORTEX_BASE_URL secret... ✓ + +Ready to trigger your first workflow run? [Y/n]: Y +[5/5] Triggering workflow... ✓ + +Done! Watch your first deploy appear at: + https://.getcortexapp.com/catalog/github-actions-demo +``` + +All steps are idempotent: +- Repo creation: `GET /repos/{owner}/{repo}` first, skip if 200 +- Workflow seeding: check file SHA, skip if content unchanged +- Secrets: always safe to overwrite +- Workflow trigger: only on explicit Y confirmation + +--- + +## CLI Integration + +### `cortex solutions install` + +After backup import completes, if a `setup.py` exists in the solution: + +``` +This solution includes a GitHub Actions setup script. +Configure GitHub Actions now? [Y/n]: +``` + +- **Y** → runs setup script +- **N** → prints: `Run setup later with: cortex solutions post-install -s github-actions-deploy` +- **`--skip-post-install-setup`** flag → skips prompt entirely, same "run later" note + +### New: `cortex solutions post-install -s ` + +Invokes the solution's `setup.py` directly. Solutions without a `setup.py` return: +`No post-install setup available for this solution.` + +--- + +## README (Solution Browser + Reference) + +```markdown +--- +name: GitHub Actions Deploy Tracking +description: Track deployments from GitHub Actions in Cortex, with a deploy health + scorecard measuring delivery cadence. +--- + +## What's Included +- **Entity:** `github-actions-demo` service +- **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency +- **GitHub Actions workflow:** Two-job workflow (build → deploy notification) +- **Setup script:** Interactive wizard that creates and seeds a GitHub repo end-to-end + +## Quick Start +1. Install the solution: + cortex solutions install -s github-actions-deploy + +2. Follow the post-install setup prompts, or run later: + cortex solutions post-install -s github-actions-deploy + +## How It Works +The included GitHub Actions workflow fires a deploy event to Cortex after every +successful build. The `notify-cortex` job only runs if the `build` job succeeds, +demonstrating conditional deploy tracking. + +## Customizing for Production +- Point the workflow at your real entity by replacing `github-actions-demo` with your service tag +- Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` secrets to your real repos +- The Deploy Health scorecard is scoped to `demo-github-actions-deploys` to avoid affecting + your existing services. To roll it out broadly, remove the group filter from the scorecard. + To opt in individual services, add the `demo-github-actions-deploys` group to them. +``` + +--- + +## What's Not In Scope + +- No Cortex workflow (in-app) — the GitHub Actions workflow is the demo artifact +- No plugin/visualization — deploys surface natively in the Cortex entity page +- No custom entity type — uses standard `service` type for broad scorecard applicability diff --git a/internal/Justfile b/internal/Justfile index 1a5854e..75f2717 100644 --- a/internal/Justfile +++ b/internal/Justfile @@ -323,6 +323,161 @@ axon-setup: echo "Done. Running containers:" docker compose -f axon/compose.yaml ps +# Configure an axon relay integration end-to-end (prompts for credentials, configures Cortex, starts relay) +# Usage: just axon-configure jira +axon-configure integration: + #!/usr/bin/env bash + set -euo pipefail + + if [ ! -f .env ]; then + cp .env.example .env 2>/dev/null || touch .env + fi + + # Prompt helper: reads a value, optionally silent, with a default + prompt_var() { + local NAME="$1" LABEL="$2" HELP="$3" DEFAULT="${4:-}" SILENT="${5:-}" + source .env 2>/dev/null || true + local CURRENT="${!NAME:-}" + if [ -n "$CURRENT" ]; then + echo "$NAME already set, skipping." + return + fi + echo "" + echo "$NAME ($LABEL)" + [ -n "$HELP" ] && echo " $HELP" + [ -n "$DEFAULT" ] && echo " Press Enter to use default: $DEFAULT" + while true; do + printf ": " + if [ "${SILENT:-}" = "silent" ]; then + read -rs VALUE; echo "" + else + read -r VALUE + fi + VALUE="${VALUE:-$DEFAULT}" + if [ -n "$VALUE" ]; then + # Update existing line or append + if grep -q "^${NAME}=" .env 2>/dev/null; then + sed -i '' "s|^${NAME}=.*|${NAME}=\"${VALUE}\"|" .env + else + echo "${NAME}=\"${VALUE}\"" >> .env + fi + export "$NAME=$VALUE" + break + fi + echo " Value cannot be empty. Try again." + done + } + + INTEGRATION="{{integration}}" + + case "$INTEGRATION" in + jira) + echo "" + echo "========================================" + echo " Jira Axon Relay Setup" + echo "========================================" + echo "" + echo "This recipe will:" + echo " 1. Collect your Jira credentials and save them to .env" + echo " 2. Configure the Jira integration in your Cortex workspace via the API" + echo " 3. Start the Jira relay container" + echo "" + echo "--- Don't have a Jira account yet? ---" + echo "" + echo " Atlassian offers a free Jira Cloud tier (up to 10 users):" + echo " 1. Sign up at: https://www.atlassian.com/software/jira/free" + echo " 2. Choose a subdomain (e.g. 'acme' → acme.atlassian.net)" + echo " 3. Create a Jira Software project" + echo "" + echo " Then create an API token:" + echo " 1. Go to: https://id.atlassian.com/manage-profile/security/api-tokens" + echo " 2. Click 'Create API token', name it (e.g. 'cortex-axon')" + echo " 3. Copy the token value — it won't be shown again" + echo "" + printf "Press Enter when ready..." + read -r + echo "" + + ./scripts/ensure-env.sh \ + "CORTEX_API_KEY|Cortex API key|Get from Cortex Settings > API Keys." + source .env + + prompt_var "JIRA_SUBDOMAIN" \ + "Jira Cloud subdomain" \ + "The part before .atlassian.net in your Jira URL. Example: for https://acme.atlassian.net, enter: acme" + + prompt_var "JIRA_USERNAME" \ + "Jira email" \ + "Your Atlassian account email address." + + prompt_var "JIRA_TOKEN" \ + "Jira API token" \ + "From https://id.atlassian.com/manage-profile/security/api-tokens" \ + "" "silent" + + source .env + + # Derive JIRA_API from subdomain and save it + JIRA_API="https://${JIRA_SUBDOMAIN}.atlassian.net" + if grep -q "^JIRA_API=" .env 2>/dev/null; then + sed -i '' "s|^JIRA_API=.*|JIRA_API=\"${JIRA_API}\"|" .env + else + echo "JIRA_API=\"${JIRA_API}\"" >> .env + fi + export JIRA_API + + # Compute and save JIRA_AUTH_TOKEN (base64 of email:token, used by accept.json) + JIRA_AUTH_TOKEN=$(printf '%s:%s' "${JIRA_USERNAME}" "${JIRA_TOKEN}" | base64) + if grep -q "^JIRA_AUTH_TOKEN=" .env 2>/dev/null; then + sed -i '' "s|^JIRA_AUTH_TOKEN=.*|JIRA_AUTH_TOKEN=\"${JIRA_AUTH_TOKEN}\"|" .env + else + echo "JIRA_AUTH_TOKEN=\"${JIRA_AUTH_TOKEN}\"" >> .env + fi + + # Integration alias (must match -a flag in compose.yaml) + echo "" + echo "Integration alias in Cortex (default: jira-relay):" + echo " This must match the -a flag in compose.yaml. Change only if you have a reason." + printf ": " + read -r JIRA_ALIAS + JIRA_ALIAS="${JIRA_ALIAS:-jira-relay}" + + # Configure the Jira integration in Cortex + echo "" + echo "Configuring Jira integration in Cortex (alias: $JIRA_ALIAS)..." + JIRA_CONFIG=$(printf '{"type":"CLOUD_BASIC","alias":"%s","subdomain":"%s","email":"%s","apiToken":"%s","baseUrl":"ATLASSIAN","isDefault":true}' \ + "$JIRA_ALIAS" "$JIRA_SUBDOMAIN" "$JIRA_USERNAME" "$JIRA_TOKEN") + + if poetry run cortex integrations jira add -f- <<< "$JIRA_CONFIG"; then + echo "Cortex integration configured successfully." + else + echo "" + echo "Warning: Cortex API call failed (integration may already exist or credentials may be wrong)." + echo "You can configure it manually at: Settings > Integrations > Jira" + echo "" + printf "Continue and start the relay anyway? [y/N]: " + read -r CONTINUE + [[ "$CONTINUE" =~ ^[Yy]$ ]] || exit 1 + fi + + # Start the relay container + echo "" + echo "Starting Jira relay container..." + docker compose -f axon/compose.yaml up -d jira + echo "" + echo "Done!" + echo " Relay alias: $JIRA_ALIAS" + echo " Jira URL: $JIRA_API" + echo "" + echo "Run 'just axon-status' to check relay status." + ;; + *) + echo "Unknown integration: $INTEGRATION" + echo "Available: jira" + exit 1 + ;; + esac + # Show status of axon relay containers axon-status: docker compose -f axon/compose.yaml ps -a diff --git a/poetry.lock b/poetry.lock index 736b113..0701c2d 100644 --- a/poetry.lock +++ b/poetry.lock @@ -24,6 +24,120 @@ files = [ {file = "certifi-2025.7.14.tar.gz", hash = "sha256:8ea99dbdfaaf2ba2f9bac77b9249ef62ec5218e7c2b2e903378ed5fccf765995"}, ] +[[package]] +name = "cffi" +version = "2.1.1" +description = "Foreign Function Interface for Python calling C code." +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\"" +files = [ + {file = "cffi-2.1.1-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:baed1e86cc735622097354b9d1281406caf42ff42a886d29faa8e8d1630333be"}, + {file = "cffi-2.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ca82be1a1d406ecfe1d25dc16cb33488e5a16bf4438c9fb590484ea29d92478b"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:42e2f76b9455f5a9a844f770bf3e200ed3da0e15f5df3db9c31fe80b04b3d004"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5a59cc1c4442bc3d5c703bf720b51138d0bfc173618807c9ee2490a7541dd3d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:9f8d177621de5cb38ee3e731eda45d421db093ec0739f46a5594babda7987a98"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:75f80557d1389eddbd0de2681f6a390a0c5338c31ddaa821381c203fc3fd50d9"}, + {file = "cffi-2.1.1-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:194cffa889098ced9976c3fc6340305e43f6303657d298da55366907c05c22d6"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5bb4e7ea95dcd6a014a6fef62e62467d67d8e582326443f3d68e71d6320a9fcf"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:3d22a20b1fb1632cc72c22f95f7b0d2961c3e1c235f245ba4c606c4771035659"}, + {file = "cffi-2.1.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:1dea0e4d7d4f11f619fe8c1d76caf49e24405b4b5743c0e3be16a500ecd930c9"}, + {file = "cffi-2.1.1-cp310-cp310-win32.whl", hash = "sha256:7ce713ace7c0e4520535b42b77eaa742c16dab813978064913e5a3cf82973b41"}, + {file = "cffi-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:a48d62ab9d6f4f98c983223a547af44be6ca3691074c31cecced6facd3ba2dc1"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12"}, + {file = "cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af"}, + {file = "cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a"}, + {file = "cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa"}, + {file = "cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3"}, + {file = "cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0"}, + {file = "cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0"}, + {file = "cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e"}, + {file = "cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517"}, + {file = "cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735"}, + {file = "cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e"}, + {file = "cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a"}, + {file = "cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e"}, + {file = "cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6"}, + {file = "cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3"}, + {file = "cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b"}, + {file = "cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7"}, + {file = "cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac"}, + {file = "cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d"}, + {file = "cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c"}, + {file = "cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54"}, + {file = "cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03"}, + {file = "cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527"}, + {file = "cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13"}, + {file = "cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c"}, + {file = "cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48"}, + {file = "cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3"}, + {file = "cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29"}, + {file = "cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e"}, + {file = "cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f"}, + {file = "cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4"}, + {file = "cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e"}, + {file = "cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d"}, + {file = "cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4"}, + {file = "cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779"}, + {file = "cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688"}, + {file = "cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7"}, + {file = "cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac"}, + {file = "cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960"}, + {file = "cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc"}, + {file = "cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231"}, + {file = "cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94"}, + {file = "cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5"}, + {file = "cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66"}, + {file = "cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3"}, + {file = "cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692"}, + {file = "cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be"}, +] + +[package.dependencies] +pycparser = {version = "*", markers = "implementation_name != \"PyPy\""} + [[package]] name = "charset-normalizer" version = "3.4.2" @@ -531,6 +645,19 @@ files = [ dev = ["pre-commit", "tox"] testing = ["coverage", "pytest", "pytest-benchmark"] +[[package]] +name = "pycparser" +version = "3.0" +description = "C parser in Python" +optional = false +python-versions = ">=3.10" +groups = ["main"] +markers = "platform_python_implementation != \"PyPy\" and implementation_name != \"PyPy\"" +files = [ + {file = "pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992"}, + {file = "pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29"}, +] + [[package]] name = "pyee" version = "13.0.1" @@ -564,6 +691,48 @@ files = [ [package.extras] windows-terminal = ["colorama (>=0.4.6)"] +[[package]] +name = "pynacl" +version = "1.6.2" +description = "Python binding to the Networking and Cryptography (NaCl) library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "pynacl-1.6.2-cp314-cp314t-macosx_10_10_universal2.whl", hash = "sha256:622d7b07cc5c02c666795792931b50c91f3ce3c2649762efb1ef0d5684c81594"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d071c6a9a4c94d79eb665db4ce5cedc537faf74f2355e4d502591d850d3913c0"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fe9847ca47d287af41e82be1dd5e23023d3c31a951da134121ab02e42ac218c9"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:04316d1fc625d860b6c162fff704eb8426b1a8bcd3abacea11142cbd99a6b574"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44081faff368d6c5553ccf55322ef2819abb40e25afaec7e740f159f74813634"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a9f9932d8d2811ce1a8ffa79dcbdf3970e7355b5c8eb0c1a881a57e7f7d96e88"}, + {file = "pynacl-1.6.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:bc4a36b28dd72fb4845e5d8f9760610588a96d5a51f01d84d8c6ff9849968c14"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bffb6d0f6becacb6526f8f42adfb5efb26337056ee0831fb9a7044d1a964444"}, + {file = "pynacl-1.6.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2fef529ef3ee487ad8113d287a593fa26f48ee3620d92ecc6f1d09ea38e0709b"}, + {file = "pynacl-1.6.2-cp314-cp314t-win32.whl", hash = "sha256:a84bf1c20339d06dc0c85d9aea9637a24f718f375d861b2668b2f9f96fa51145"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_amd64.whl", hash = "sha256:320ef68a41c87547c91a8b58903c9caa641ab01e8512ce291085b5fe2fcb7590"}, + {file = "pynacl-1.6.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d29bfe37e20e015a7d8b23cfc8bd6aa7909c92a1b8f41ee416bbb3e79ef182b2"}, + {file = "pynacl-1.6.2-cp38-abi3-macosx_10_10_universal2.whl", hash = "sha256:c949ea47e4206af7c8f604b8278093b674f7c79ed0d4719cc836902bf4517465"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8845c0631c0be43abdd865511c41eab235e0be69c81dc66a50911594198679b0"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22de65bb9010a725b0dac248f353bb072969c94fa8d6b1f34b87d7953cf7bbe4"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:46065496ab748469cdd999246d17e301b2c24ae2fdf739132e580a0e94c94a87"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a66d6fb6ae7661c58995f9c6435bda2b1e68b54b598a6a10247bfcdadac996c"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:26bfcd00dcf2cf160f122186af731ae30ab120c18e8375684ec2670dccd28130"}, + {file = "pynacl-1.6.2-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:c8a231e36ec2cab018c4ad4358c386e36eede0319a0c41fed24f840b1dac59f6"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:68be3a09455743ff9505491220b64440ced8973fe930f270c8e07ccfa25b1f9e"}, + {file = "pynacl-1.6.2-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:8b097553b380236d51ed11356c953bf8ce36a29a3e596e934ecabe76c985a577"}, + {file = "pynacl-1.6.2-cp38-abi3-win32.whl", hash = "sha256:5811c72b473b2f38f7e2a3dc4f8642e3a3e9b5e7317266e4ced1fba85cae41aa"}, + {file = "pynacl-1.6.2-cp38-abi3-win_amd64.whl", hash = "sha256:62985f233210dee6548c223301b6c25440852e13d59a8b81490203c3227c5ba0"}, + {file = "pynacl-1.6.2-cp38-abi3-win_arm64.whl", hash = "sha256:834a43af110f743a754448463e8fd61259cd4ab5bbedcf70f9dabad1d28a394c"}, + {file = "pynacl-1.6.2.tar.gz", hash = "sha256:018494d6d696ae03c7e656e5e74cdfd8ea1326962cc401bcf018f1ed8436811c"}, +] + +[package.dependencies] +cffi = {version = ">=2.0.0", markers = "platform_python_implementation != \"PyPy\" and python_version >= \"3.9\""} + +[package.extras] +docs = ["sphinx (<7)", "sphinx_rtd_theme"] +tests = ["hypothesis (>=3.27.0)", "pytest (>=7.4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] + [[package]] name = "pyotp" version = "2.9.0" @@ -949,4 +1118,4 @@ zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""] [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "1d38560a601329ebf7335f055ebbb9629a94868da642e24d8bcb40dde535acd0" +content-hash = "a33f8151f2743a1985772b305babc1a056f9bc397278e90e53f4ec8ec134e163" diff --git a/pyproject.toml b/pyproject.toml index e0de63c..008ef74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,6 +22,7 @@ pyyaml = ">= 6.0.1, < 7" urllib3 = ">= 2.7.0" typer = ">=0.15,<1.0" typing_extensions = ">=3.7.4.3" +PyNaCl = ">=1.5.0" [tool.poetry.scripts] cortex = "cortexapps_cli.cli:app" diff --git a/tests/test_github_actions_setup.py b/tests/test_github_actions_setup.py new file mode 100644 index 0000000..fa83ef3 --- /dev/null +++ b/tests/test_github_actions_setup.py @@ -0,0 +1,133 @@ +import base64 +import importlib.util +import json +import pytest +from pathlib import Path +from unittest.mock import patch, MagicMock + + +def load_setup_module(): + spec = importlib.util.spec_from_file_location( + "github_actions_setup", + "cortexapps_cli/solutions/github-actions-deploy/setup.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +@pytest.fixture +def mod(): + return load_setup_module() + + +@pytest.fixture +def setup(mod, tmp_path): + instance = mod.GitHubActionsSetup(state_dir=tmp_path) + instance._answers = { + "github_token": "ghp_test", + "github_owner": "test-org", + "repo_name": "cortex-deploy-demo", + "cortex_api_key": "crt_testkey", + "cortex_base_url": "https://api.getcortexapp.com", + } + return instance + + +def test_get_authenticated_user(setup): + resp = MagicMock(status_code=200) + resp.json.return_value = {"login": "test-user"} + with patch("requests.get", return_value=resp): + assert setup._get_authenticated_user() == "test-user" + + +def test_create_repo_skips_if_exists(setup): + resp = MagicMock(status_code=200) + with patch("requests.get", return_value=resp) as mock_get, \ + patch("requests.post") as mock_post: + setup._create_repo() + mock_get.assert_called_once() + mock_post.assert_not_called() + + +def test_create_repo_creates_when_missing(setup): + user_resp = MagicMock(status_code=200) + user_resp.json.return_value = {"login": "test-org"} + check_resp = MagicMock(status_code=404) + post_resp = MagicMock(status_code=201) + post_resp.json.return_value = {"html_url": "https://github.com/test-org/cortex-deploy-demo"} + + get_responses = [check_resp, user_resp] + with patch("requests.get", side_effect=get_responses), \ + patch("requests.post", return_value=post_resp) as mock_post: + setup._create_repo() + mock_post.assert_called_once() + + +def test_create_repo_raises_on_unexpected_status(setup): + resp = MagicMock(status_code=403) + resp.text = "Forbidden" + with patch("requests.get", return_value=resp), \ + patch("requests.post") as mock_post: + with pytest.raises(RuntimeError, match="Unexpected status checking repo existence: 403"): + setup._create_repo() + mock_post.assert_not_called() + + +def test_seed_workflow_skips_if_unchanged(setup, tmp_path): + # Read the actual template to simulate matching content + template_path = Path("cortexapps_cli/solutions/github-actions-deploy/_templates/cortex-deploy.yml") + content = template_path.read_text() + content_b64 = base64.b64encode(content.encode()).decode() + + resp = MagicMock(status_code=200) + resp.json.return_value = {"content": content_b64, "sha": "abc123"} + + with patch("requests.get", return_value=resp), \ + patch("requests.put") as mock_put: + setup._seed_workflow() + mock_put.assert_not_called() + + +def test_seed_workflow_creates_when_missing(setup): + get_resp = MagicMock(status_code=404) + put_resp = MagicMock(status_code=201) + put_resp.json.return_value = {"content": {"sha": "abc123"}} + + with patch("requests.get", return_value=get_resp), \ + patch("requests.put", return_value=put_resp) as mock_put: + setup._seed_workflow() + mock_put.assert_called_once() + + +def test_set_secret(setup): + # Valid Curve25519 public key (generated via nacl.public.PrivateKey.generate()) + from nacl.public import PrivateKey + dummy_key = base64.b64encode(bytes(PrivateKey.generate().public_key)).decode() + key_resp = MagicMock(status_code=200) + key_resp.json.return_value = {"key_id": "key123", "key": dummy_key} + key_resp.raise_for_status = MagicMock() + + put_resp = MagicMock(status_code=204) + + with patch("requests.get", return_value=key_resp), \ + patch("requests.put", return_value=put_resp) as mock_put: + setup._set_secret("CORTEX_API_KEY", "crt_testkey") + + mock_put.assert_called_once() + call_json = mock_put.call_args.kwargs["json"] + assert "encrypted_value" in call_json + assert call_json["key_id"] == "key123" + + +def test_trigger_workflow(setup): + resp = MagicMock(status_code=204) + with patch("requests.post", return_value=resp) as mock_post: + setup._trigger_workflow() + url = mock_post.call_args.args[0] + assert "dispatches" in url + assert mock_post.call_args.kwargs["json"] == {"ref": "main"} + + +def test_main_callable(mod): + assert callable(mod.main) diff --git a/tests/test_setup_base.py b/tests/test_setup_base.py new file mode 100644 index 0000000..e88d8d3 --- /dev/null +++ b/tests/test_setup_base.py @@ -0,0 +1,93 @@ +import json +import pytest +from pathlib import Path +from unittest.mock import patch +from cortexapps_cli.solutions._lib.setup_base import SolutionSetup + + +class ConcreteSetup(SolutionSetup): + solution_tag = "test-solution" + steps_called = [] + + def collect_prompts(self): + self._answers["name"] = self.prompt("name", "Your name", default="Alice") + + def steps(self): + return [("Do thing", lambda: ConcreteSetup.steps_called.append(True))] + + +def test_prompt_uses_env_var(tmp_path, monkeypatch): + monkeypatch.setenv("MY_VAR", "from-env") + setup = ConcreteSetup(state_dir=tmp_path) + result = setup.prompt("key", "Enter value", env_var="MY_VAR") + assert result == "from-env" + + +def test_prompt_uses_default_on_empty_input(tmp_path, monkeypatch): + monkeypatch.delenv("MY_VAR", raising=False) + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + result = setup.prompt("key", "Enter value", default="default-val") + assert result == "default-val" + + +def test_prompt_uses_user_input(tmp_path, monkeypatch): + monkeypatch.delenv("MY_VAR", raising=False) + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="user-value"): + result = setup.prompt("key", "Enter value", default="default-val") + assert result == "user-value" + + +def test_confirm_returns_true_for_y(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="y"): + assert setup.confirm("Do it?") is True + + +def test_confirm_returns_false_for_n(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value="n"): + assert setup.confirm("Do it?") is False + + +def test_confirm_uses_default_on_empty(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + assert setup.confirm("Do it?", default=True) is True + with patch("builtins.input", return_value=""): + assert setup.confirm("Do it?", default=False) is False + + +def test_already_done_false_initially(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + assert setup.already_done("step1") is False + + +def test_mark_done_persists(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + setup.mark_done("step1") + assert setup.already_done("step1") is True + + +def test_mark_done_persists_across_instances(tmp_path): + ConcreteSetup(state_dir=tmp_path).mark_done("step1") + assert ConcreteSetup(state_dir=tmp_path).already_done("step1") is True + + +def test_state_file_path(tmp_path): + setup = ConcreteSetup(state_dir=tmp_path) + assert setup._state_file == tmp_path / "setup-test-solution.json" + + +def test_post_steps_called_after_steps(tmp_path): + post_called = [] + + class SetupWithPost(ConcreteSetup): + def post_steps(self): + post_called.append(True) + + setup = SetupWithPost(state_dir=tmp_path) + with patch("builtins.input", return_value=""): + setup.run() + assert post_called == [True] diff --git a/tests/test_solutions_postinstall.py b/tests/test_solutions_postinstall.py new file mode 100644 index 0000000..5c71552 --- /dev/null +++ b/tests/test_solutions_postinstall.py @@ -0,0 +1,55 @@ +import pytest +from unittest.mock import patch, MagicMock +from typer.testing import CliRunner +from cortexapps_cli.cli import app + +runner = CliRunner() + + +def test_post_install_no_setup_for_ai_agents(): + result = runner.invoke(app, ["solutions", "post-install", "-s", "ai-agents"]) + assert result.exit_code == 0 + assert "No post-install setup available" in result.output + + +def test_post_install_unknown_solution(): + result = runner.invoke(app, ["solutions", "post-install", "-s", "nonexistent-xyz"]) + assert result.exit_code != 0 + assert "not found" in result.output.lower() + + +def test_post_install_calls_run_for_github_actions(): + with patch("cortexapps_cli.commands.solutions._run_post_install_script") as mock_run: + runner.invoke(app, ["solutions", "post-install", "-s", "github-actions-deploy"]) + mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None) + + +def test_install_skip_post_install_setup_flag_skips_script(): + """--skip-post-install-setup prints 'Run setup later' and does NOT call the script.""" + with patch("cortexapps_cli.commands.solutions._has_post_install", return_value=True), \ + patch("cortexapps_cli.commands.solutions._run_post_install_script") as mock_run, \ + patch("cortexapps_cli.commands.solutions._build_client", return_value=MagicMock()), \ + patch("cortexapps_cli.commands.backup.import_tenant"): + result = runner.invoke( + app, + ["-k", "fake", "solutions", "install", "-s", "github-actions-deploy", + "--skip-post-install-setup", "--no-prompt"], + ) + assert "Run setup later with: cortex solutions post-install -s github-actions-deploy" in result.output + mock_run.assert_not_called() + + +def test_install_prompts_and_runs_post_install_on_yes(): + """Without --skip-post-install-setup, answering 'y' at the prompt calls the script.""" + with patch("cortexapps_cli.commands.solutions._has_post_install", return_value=True), \ + patch("cortexapps_cli.commands.solutions._run_post_install_script") as mock_run, \ + patch("cortexapps_cli.commands.solutions._build_client", return_value=MagicMock()), \ + patch("cortexapps_cli.commands.backup.import_tenant"), \ + patch("cortexapps_cli.commands.solutions._post_install_menu"): + result = runner.invoke( + app, + ["-k", "fake", "solutions", "install", "-s", "github-actions-deploy"], + input="y\n", + ) + assert "This solution includes a post-install setup script." in result.output + mock_run.assert_called_once_with("github-actions-deploy", solutions_dir=None)