Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
42cb80c
docs: add GitHub Actions deploy solution design spec
jeff-schnitter Aug 10, 2026
da1da6d
docs: add GitHub Actions deploy solution implementation plan
jeff-schnitter Aug 10, 2026
69072e2
add: github-actions-deploy solution content files
jeff-schnitter Aug 11, 2026
ff780be
add: SolutionSetup base class for reusable post-install setup scripts
jeff-schnitter Aug 11, 2026
4890a31
add: PyNaCl dependency for GitHub secret encryption
jeff-schnitter Aug 11, 2026
5f90136
add: github-actions-deploy post-install setup script
jeff-schnitter Aug 11, 2026
ee5332e
fix: enforce 404-only repo creation, drop unused import, use public k…
jeff-schnitter Aug 11, 2026
35fbce9
feat: add solutions post-install subcommand and install hook
jeff-schnitter Aug 11, 2026
a2d41d2
fix: address reviewer issues in solutions post-install
jeff-schnitter Aug 11, 2026
d61c35d
fix: use customMetrics expressions in deploy-health scorecard
jeff-schnitter Aug 11, 2026
c9681cf
fix: improve post-install setup UX for CX-6
jeff-schnitter Aug 11, 2026
e0a4bb9
fix: correct Cortex app URL pattern in post-install output for CX-6
jeff-schnitter Aug 11, 2026
22ec396
feat: add Cortex async workflow for GitHub Actions deploy trigger for…
jeff-schnitter Aug 11, 2026
4a7f9e9
fix: flush captured import output to terminal on failure for CX-6
jeff-schnitter Aug 11, 2026
dc58067
fix: use hyphens in workflow variable slugs for CX-6
jeff-schnitter Aug 11, 2026
548b0d0
fix: pass workflow variables directly in initialContext for CX-6
jeff-schnitter Aug 11, 2026
723cb85
feat: use Cortex GitHub integration creds for async workflow trigger …
jeff-schnitter Aug 11, 2026
15250fb
fix: correct HTTP_REQUEST_ASYNC integration syntax for CX-6
jeff-schnitter Aug 11, 2026
5723ecd
fix: remove empty headers from workflow action for CX-6
jeff-schnitter Aug 11, 2026
4030392
fix: add Authorization header to Cortex callback step for CX-6
jeff-schnitter Aug 11, 2026
3c0ac85
fix: correct Cortex workflow import and async callback for GitHub Act…
jeff-schnitter Aug 11, 2026
9bebb21
fix: correct post_steps result extraction and step numbering
jeff-schnitter Aug 11, 2026
f641b52
fix: reflect actual job status in Cortex callback and surface run dat…
jeff-schnitter Aug 12, 2026
92d853d
fix: remove deploy-count custom metric, drive scorecard from deploys(…
jeff-schnitter Aug 12, 2026
413ac51
fix: change workflow trigger prompt to not assume it is the first run
jeff-schnitter Aug 12, 2026
14e319e
fix: use jq for callback JSON, needs.build.result for status, correct…
jeff-schnitter Aug 12, 2026
7887ffb
feat: add IN_PROGRESS intermediate callbacks before terminal callback
jeff-schnitter Aug 12, 2026
d52a414
fix: remove IN_PROGRESS callbacks (rejected by Cortex), reduce timeou…
jeff-schnitter Aug 12, 2026
9b1717e
fix: sort GitHub integrations alphabetically by alias
jeff-schnitter Aug 12, 2026
310a003
fix: remove 'first' from deploy completion message
jeff-schnitter Aug 12, 2026
222f99a
feat: link GitHub repo to Cortex entity during post-install setup
jeff-schnitter Aug 12, 2026
cdea066
fix: add required timestamp field to deploys API call
jeff-schnitter Aug 12, 2026
8ef5ee0
fix: use PATCH not PUT for entity git link (405 Method Not Allowed)
jeff-schnitter Aug 12, 2026
55127c6
fix: use application/openapi;charset=UTF-8 content type for entity PATCH
jeff-schnitter Aug 12, 2026
929ba4c
fix: simplify runResponseTemplate to use only variables
jeff-schnitter Aug 12, 2026
5de4a6e
feat: embed cortex-deploy.yml in Cortex workflow run response template
jeff-schnitter Aug 12, 2026
43793af
feat: offer to create PAT GitHub integration when none is configured
jeff-schnitter Aug 12, 2026
41b0bfe
fix: substitute github-owner and repo-name defaults into Cortex workf…
jeff-schnitter Aug 12, 2026
9cdb333
feat: add UI/API branch to Cortex workflow for manual vs API invocation
jeff-schnitter Aug 12, 2026
f79b9f1
fix: set joiningAction to null in trigger-github-deploy workflow
jeff-schnitter Aug 12, 2026
c7bc805
fix: restructure workflow so trigger-deploy is outside the branch
jeff-schnitter Aug 12, 2026
d1398c5
fix: add SET_VARIABLES step to write UI inputs back to workflow varia…
jeff-schnitter Aug 12, 2026
3e86e92
chore: add realistic build delay to cortex-deploy.yml
jeff-schnitter Aug 12, 2026
88e65a3
feat: add entity-aware deploy workflow and incremental GH callbacks
jeff-schnitter Aug 12, 2026
55bf031
chore: clarify workflow trigger prompt and show run URL
jeff-schnitter Aug 13, 2026
1e2d4c2
chore: show per-step detail output in github-actions-deploy setup
jeff-schnitter Aug 13, 2026
528e295
feat: persist solution answers to ~/.cortex/solutions/ with --no-prom…
jeff-schnitter Aug 13, 2026
b1943bf
chore: use saved answers as defaults in interactive prompts
jeff-schnitter Aug 13, 2026
ee486f7
chore: add blank line after each setup step for readability
jeff-schnitter Aug 13, 2026
5bf7e32
chore: update workflow runs URL to include activeTab=runs query param
jeff-schnitter Aug 13, 2026
e438b36
chore: rename 'Importing' to 'Creating' in workflow step labels
jeff-schnitter Aug 13, 2026
d3c54af
docs: document both Cortex workflows and recommend Deploy from Entity
jeff-schnitter Aug 13, 2026
44fbac0
chore: fix saved answers not surfacing as defaults on re-run
jeff-schnitter Aug 13, 2026
08eb93c
chore: use Deploy from Entity workflow for trigger; update manual ins…
jeff-schnitter Aug 13, 2026
75453db
chore: show entity tag in UI hyperlink text
jeff-schnitter Aug 13, 2026
44e62ab
chore: consolidate to single deploy workflow; fix entity-scoped run
jeff-schnitter Aug 13, 2026
ef2c2bf
chore: fix CLI command and hyperlink text in post-steps output
jeff-schnitter Aug 13, 2026
d3b5ac4
chore: rename workflow to 'Solution: Add Cortex Deploy from GitHub Ac…
jeff-schnitter Aug 13, 2026
c6ca7b1
docs: add ASCII flow diagram and clean up README
jeff-schnitter Aug 13, 2026
d0714cf
chore: fix workflow name in post-steps UI instructions
jeff-schnitter Aug 13, 2026
3c61d24
chore: remove hardcoded CQL blurb from next steps; add After Installi…
jeff-schnitter Aug 13, 2026
862852b
feat: extract local GitHub Actions for callback and deploy registration
jeff-schnitter Aug 13, 2026
caa6f1b
chore: rewrite runResponseTemplate with deploy details and educationa…
jeff-schnitter Aug 13, 2026
18fe7cb
chore: show saved config path before setup prompt; fix --no-prompt sk…
jeff-schnitter Aug 13, 2026
5ae71bf
chore: fix runResponseTemplate newlines, add deploys link, app-url va…
jeff-schnitter Aug 13, 2026
827457e
chore: link directly to workflow run after deploy (not just runs list)
jeff-schnitter Aug 13, 2026
5c463c6
chore: fix Justfile indentation syntax error in axon-configure recipe
jeff-schnitter Aug 13, 2026
831dfe9
chore: print workflow run link immediately after POST, before polling
jeff-schnitter Aug 13, 2026
27f33b7
chore: update Deploys link label in runResponseTemplate
jeff-schnitter Aug 13, 2026
765c2c7
chore: fix SHA path in runResponseTemplate — result.sha not result.ou…
jeff-schnitter Aug 13, 2026
851280a
chore: add newline before POST line in workflow trigger output
jeff-schnitter Aug 13, 2026
d164ac2
chore: fix workflow run URL — use numeric id not cid
jeff-schnitter Aug 13, 2026
ad7e432
chore: fetch workflow numeric id for run URL instead of relying on ru…
jeff-schnitter Aug 13, 2026
1443f57
chore: fix deploys URL to use action output cid; remove SHA from runR…
jeff-schnitter Aug 13, 2026
b75f5a3
chore: link .github/actions/ to seeded repo in runResponseTemplate ad…
jeff-schnitter Aug 13, 2026
9d771dc
chore: link directly to specific workflow after import using numeric id
jeff-schnitter Aug 13, 2026
4a1b16a
chore: revert workflow run URL to runs list — numeric id not availabl…
jeff-schnitter Aug 13, 2026
5151e2a
chore: fix waiting message and add workflow runs link after POST
jeff-schnitter Aug 13, 2026
15fd46b
chore: hyperlink Cortex workflow in waiting line; remove redundant ru…
jeff-schnitter Aug 13, 2026
8a66f8f
chore: clarify setup description wording
jeff-schnitter Aug 13, 2026
8ba40ff
chore: clarify API key and base URL confirmation prompts
jeff-schnitter Aug 13, 2026
80e1e4e
chore: fix deploys URL — entity body uses id not cid
jeff-schnitter Aug 13, 2026
afd8365
chore: rename notify-cortex job to cortex-callback in workflow and docs
jeff-schnitter Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 111 additions & 14 deletions cortexapps_cli/commands/solutions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -112,16 +125,23 @@ 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)
try:
_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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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/<tag>.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,
Expand Down
Empty file.
169 changes: 169 additions & 0 deletions cortexapps_cli/solutions/_lib/setup_base.py
Original file line number Diff line number Diff line change
@@ -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-<tag>.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()
Loading
Loading