From 7f2c89ad093e15c00d758b506f849db034ddfe70 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 13:43:35 -0700 Subject: [PATCH 1/8] Add harness-deploy solution Mirrors the github-actions-deploy solution pattern for Harness pipelines: - Entity + scorecard (deploy health, Bronze/Silver/Gold) - Cortex async workflow template (HTTP_REQUEST_ASYNC with Harness integration) - Harness pipeline YAML template (registers deploy + async callback) - setup.py wizard: selects Harness integration alias, imports workflow Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/harness-deploy/README.md | 40 +++ .../_templates/cortex-deploy-pipeline.yaml | 145 ++++++++++ .../_templates/trigger-harness-deploy.yaml | 39 +++ .../harness-deploy/catalog/harness-demo.yaml | 9 + .../scorecards/deploy-health.yaml | 51 ++++ .../solutions/harness-deploy/setup.py | 250 ++++++++++++++++++ 6 files changed, 534 insertions(+) create mode 100644 cortexapps_cli/solutions/harness-deploy/README.md create mode 100644 cortexapps_cli/solutions/harness-deploy/_templates/cortex-deploy-pipeline.yaml create mode 100644 cortexapps_cli/solutions/harness-deploy/_templates/trigger-harness-deploy.yaml create mode 100644 cortexapps_cli/solutions/harness-deploy/catalog/harness-demo.yaml create mode 100644 cortexapps_cli/solutions/harness-deploy/scorecards/deploy-health.yaml create mode 100644 cortexapps_cli/solutions/harness-deploy/setup.py diff --git a/cortexapps_cli/solutions/harness-deploy/README.md b/cortexapps_cli/solutions/harness-deploy/README.md new file mode 100644 index 0000000..fa0aaa9 --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/README.md @@ -0,0 +1,40 @@ +--- +name: Harness Deploy Tracking +description: Track deployments from Harness pipelines in Cortex, with a deploy health scorecard measuring delivery cadence. +--- + +## What's Included + +- **Entity:** `harness-demo` service — a sample entity to receive deploy events +- **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency +- **Harness pipeline:** A sample pipeline YAML (with callback + deploy registration) to import into Harness +- **Cortex workflow:** Async trigger that fires a Harness pipeline and waits for it to report back +- **Setup script:** Interactive wizard that wires everything together end-to-end + +## Quick Start + +1. Install the solution: + + ``` + cortex solutions install -s harness-deploy + ``` + +2. Follow the post-install setup prompts, or run later: + + ``` + cortex solutions post-install -s harness-deploy + ``` + +## How It Works + +The included Harness pipeline YAML registers a deploy event to Cortex after each run and calls back +to the Cortex async workflow to surface the result directly in the Cortex UI. + +## Customizing for Production + +- Point the workflow at your real entity by replacing `harness-demo` with your service tag in the + pipeline's `CORTEX_ENTITY_TAG` variable +- Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` as Harness pipeline variables or secrets +- The Deploy Health scorecard is scoped to `demo-harness-deploys` to avoid affecting your existing + services. To roll it out broadly, remove the group filter. To opt in individual services, add the + `demo-harness-deploys` group to them. diff --git a/cortexapps_cli/solutions/harness-deploy/_templates/cortex-deploy-pipeline.yaml b/cortexapps_cli/solutions/harness-deploy/_templates/cortex-deploy-pipeline.yaml new file mode 100644 index 0000000..6113fad --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/_templates/cortex-deploy-pipeline.yaml @@ -0,0 +1,145 @@ +pipeline: + name: Cortex Deploy + identifier: cortex_deploy + orgIdentifier: default + projectIdentifier: default_project + description: "Sample Harness pipeline that registers a deploy event in Cortex and reports back to Cortex async workflows." + variables: + - name: callback_url + type: String + description: Cortex async callback URL (set automatically when triggered via Cortex workflow) + required: false + default: "" + value: "<+input>" + - name: cortex_entity_tag + type: String + description: Cortex entity tag to register the deploy against + required: false + default: "harness-demo" + value: "<+input>" + stages: + - stage: + name: Build + identifier: build + type: Custom + spec: + execution: + steps: + - step: + name: Build + identifier: build_step + type: ShellScript + spec: + shell: Bash + executionTarget: {} + source: + type: Inline + spec: + script: echo "Building..." + environmentVariables: [] + outputVariables: [] + timeout: 10m + + - stage: + name: Notify Cortex + identifier: notify_cortex + type: Custom + spec: + execution: + steps: + - step: + name: Register Deploy + identifier: register_deploy + type: ShellScript + spec: + shell: Bash + executionTarget: {} + source: + type: Inline + spec: + script: | + curl -s -f -X POST \ + "$CORTEX_BASE_URL/api/v1/catalog/$CORTEX_ENTITY_TAG/deploys" \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$(jq -n \ + --arg sha "<+pipeline.executionId>" \ + --arg run_id "<+pipeline.sequenceId>" \ + --arg run_url "https://app.harness.io/ng/account/$HARNESS_ACCOUNT_ID/cd/orgs/<+pipeline.orgIdentifier>/projects/<+pipeline.projectIdentifier>/pipelines/<+pipeline.identifier>/executions/<+pipeline.executionId>/pipeline" \ + --arg actor "<+pipeline.triggeredBy.name>" \ + --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + '{ + sha: $sha, + timestamp: $timestamp, + environment: "production", + type: "DEPLOY", + title: ("Triggered by " + $actor), + deployer: {name: $actor}, + customData: {executionId: $sha, sequenceId: $run_id, executionUrl: $run_url} + }')" || true + environmentVariables: + - name: CORTEX_API_KEY + type: Secret + value: cortex_api_key + - name: CORTEX_BASE_URL + type: String + value: "https://api.getcortexapp.com" + - name: CORTEX_ENTITY_TAG + type: String + value: "<+pipeline.variables.cortex_entity_tag>" + - name: HARNESS_ACCOUNT_ID + type: String + value: "<+account.identifier>" + outputVariables: [] + timeout: 5m + + - step: + name: Callback to Cortex + identifier: cortex_callback + type: ShellScript + spec: + shell: Bash + executionTarget: {} + source: + type: Inline + spec: + script: | + if [ -z "$CALLBACK_URL" ]; then + echo "No callback URL set, skipping" + exit 0 + fi + + EXECUTION_URL="https://app.harness.io/ng/account/$HARNESS_ACCOUNT_ID/cd/orgs/<+pipeline.orgIdentifier>/projects/<+pipeline.projectIdentifier>/pipelines/<+pipeline.identifier>/executions/<+pipeline.executionId>/pipeline" + + HTTP_STATUS=$(curl -s -o /tmp/cb_response.txt -w "%{http_code}" -X POST "$CALLBACK_URL" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -d "$(jq -n \ + --arg exec_id "<+pipeline.executionId>" \ + --arg seq_id "<+pipeline.sequenceId>" \ + --arg name "<+pipeline.name>" \ + --arg url "$EXECUTION_URL" \ + '{ + status: "SUCCESS", + message: "Pipeline completed successfully", + response: {execution_id: $exec_id, execution_number: $seq_id, pipeline_name: $name, pipeline_url: $url} + }')") + + echo "Callback HTTP status: $HTTP_STATUS" + cat /tmp/cb_response.txt + if [ "$HTTP_STATUS" != "200" ]; then + echo "ERROR: Callback failed with HTTP $HTTP_STATUS" + exit 1 + fi + environmentVariables: + - name: CALLBACK_URL + type: String + value: "<+pipeline.variables.callback_url>" + - name: CORTEX_API_KEY + type: Secret + value: cortex_api_key + - name: HARNESS_ACCOUNT_ID + type: String + value: "<+account.identifier>" + outputVariables: [] + timeout: 10m diff --git a/cortexapps_cli/solutions/harness-deploy/_templates/trigger-harness-deploy.yaml b/cortexapps_cli/solutions/harness-deploy/_templates/trigger-harness-deploy.yaml new file mode 100644 index 0000000..852e729 --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/_templates/trigger-harness-deploy.yaml @@ -0,0 +1,39 @@ +name: Trigger Harness Deploy +tag: harness-trigger-deploy +description: | + Triggers a Harness pipeline and waits for it to report completion back to Cortex, + registering a deploy event on the target entity. +isDraft: false +isRunnableViaApi: true +filter: + type: GLOBAL +variables: + - slug: harness-org + type: STRING + - slug: harness-project + type: STRING + - slug: harness-pipeline + type: STRING + - slug: entity-tag + type: STRING +runResponseTemplate: | + # Harness Pipeline Deploy + + Deploy triggered for pipeline **{{variables.harness-pipeline}}** in + `{{variables.harness-org}}/{{variables.harness-project}}`. + + [View pipeline runs in Harness](https://app.harness.io/ng/account/PLACEHOLDER_HARNESS_ACCOUNT_ID/cd/orgs/{{variables.harness-org}}/projects/{{variables.harness-project}}/pipelines/{{variables.harness-pipeline}}/executions) +actions: + - name: Trigger Harness Pipeline + slug: trigger-deploy + schema: + type: HTTP_REQUEST_ASYNC + httpMethod: POST + url: "/v1/orgs/{{variables.harness-org}}/projects/{{variables.harness-project}}/pipelines/{{variables.harness-pipeline}}/execute?notes=Submitted+by+{{context.initiatedBy.email}}" + integration: Harness + integrationAlias: "PLACEHOLDER_INTEGRATION_ALIAS" + headers: {} + payload: "{\"inputs_yaml\": \"pipeline:\\n identifier: {{variables.harness-pipeline}}\\n variables:\\n - name: callback_url\\n type: String\\n value: \\\"{{{callbackUrl}}}\\\"\\n - name: cortex_entity_tag\\n type: String\\n value: \\\"{{variables.entity-tag}}\\\"\"}" + timeoutInSeconds: 300 + outgoingActions: [] + isRootAction: true diff --git a/cortexapps_cli/solutions/harness-deploy/catalog/harness-demo.yaml b/cortexapps_cli/solutions/harness-deploy/catalog/harness-demo.yaml new file mode 100644 index 0000000..2d4cbf5 --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/catalog/harness-demo.yaml @@ -0,0 +1,9 @@ +openapi: "3.0.0" +info: + title: Harness Demo + x-cortex-tag: harness-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via Harness pipelines. + x-cortex-definition: {} + x-cortex-groups: + - demo-harness-deploys diff --git a/cortexapps_cli/solutions/harness-deploy/scorecards/deploy-health.yaml b/cortexapps_cli/solutions/harness-deploy/scorecards/deploy-health.yaml new file mode 100644 index 0000000..788b382 --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/scorecards/deploy-health.yaml @@ -0,0 +1,51 @@ +tag: harness-deploy-health +name: Harness Deploy Health +description: Measures deployment cadence for services using Harness deploy tracking. Scoped to demo-harness-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-harness-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/harness-deploy/setup.py b/cortexapps_cli/solutions/harness-deploy/setup.py new file mode 100644 index 0000000..2fc5182 --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/setup.py @@ -0,0 +1,250 @@ +""" +Post-install setup script for the harness-deploy solution. +Wires up Harness credentials, imports the Cortex async workflow, and optionally triggers a test run. +Run via: cortex solutions post-install -s harness-deploy +""" + +SETUP_DESCRIPTION = ( + "This solution includes a post-install setup script that will configure your Harness " + "integration in Cortex, import the trigger workflow, and optionally fire a test deploy." +) + +import sys +import time +from pathlib import Path + +import requests + +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 + +WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "trigger-harness-deploy.yaml" +PIPELINE_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy-pipeline.yaml" + + +def _hyperlink(url: str, text: str = None) -> str: + label = text if text is not None else url + return f"\033]8;;{url}\033\\{label}\033]8;;\033\\" + + +class HarnessDeploySetup(SolutionSetup): + solution_tag = "harness-deploy" + + def __init__(self, cortex_api_key: str = None, cortex_base_url: str = None, **kwargs): + super().__init__(**kwargs) + self._session_api_key = cortex_api_key + self._session_base_url = cortex_base_url + + def _cortex_headers(self) -> dict: + api_key = self._answers.get("cortex_api_key") or self._session_api_key or "" + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + def _fetch_harness_integrations(self) -> list: + if not (self._session_api_key and self._session_base_url): + return [] + try: + resp = requests.get( + f"{self._session_base_url.rstrip('/')}/api/v1/harness/configurations", + headers={"Authorization": f"Bearer {self._session_api_key}"}, + timeout=10, + ) + if resp.status_code == 200: + data = resp.json() + # Configurations may be a list or wrapped in a key + if isinstance(data, list): + return data + return data.get("configurations", data.get("items", [])) + except Exception: + pass + return [] + + def _select_harness_integration(self, integrations: list) -> str: + integrations = sorted(integrations, key=lambda c: c.get("alias", "").lower()) + default_idx = next( + (i for i, c in enumerate(integrations) if c.get("isDefault")), 0 + ) + print("\nHarness integrations configured in Cortex:") + for i, cfg in enumerate(integrations): + marker = " *" if cfg.get("isDefault") else " " + print(f" {marker}{i + 1}. {cfg['alias']}") + print(" (* = default)") + + 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. Harness integration alias + integrations = self._fetch_harness_integrations() + if integrations: + alias = self._select_harness_integration(integrations) + self._answers["harness_integration_alias"] = alias + else: + if self._session_api_key and self._session_base_url: + print("\nNo Harness integration is configured in Cortex.") + print("Configure one at: Settings → Integrations → Harness") + print("Then re-run: cortex solutions post-install -s harness-deploy") + sys.exit(0) + self.prompt("harness_integration_alias", "Harness integration alias", default="default") + + # 2. Harness pipeline coordinates + self.prompt("harness_org", "Harness org identifier", default="default") + self.prompt("harness_project", "Harness project identifier", default="default_project") + self.prompt("harness_pipeline", "Harness pipeline identifier (the pipeline to trigger)") + + # 3. Cortex entity to record deploys against + self.prompt("entity_tag", "Cortex entity tag to record deploys against", default="harness-demo") + + # 4. Cortex credentials + if self._session_api_key: + if self.confirm("Use current Cortex API key?", 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 current Cortex base URL [{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]]: + return [ + ("Importing Cortex trigger workflow", self._import_cortex_workflow), + ] + + def post_steps(self) -> None: + base_url = self._answers["cortex_base_url"].rstrip("/") + app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url + entity_tag = self._answers["entity_tag"] + cortex_url = f"{app_url}/admin/resources?tag={entity_tag}" + pipeline_template_path = PIPELINE_TEMPLATE_PATH + + print() + print("Next step: import the Harness pipeline template into your Harness project.") + print(f" Pipeline YAML: {pipeline_template_path}") + print() + print(" In Harness: Pipelines → Import Pipeline → paste or upload the YAML above.") + print(" Add a 'cortex_api_key' secret to your Harness project for the callback to authenticate.") + print() + + if self.confirm("Trigger a test workflow run now?", default=True): + print(" Starting Cortex workflow run (waiting for Harness pipeline to complete)...") + try: + result = self._trigger_via_cortex_workflow() + status = result.get("status", "").upper() + if status == "COMPLETED": + print(" Deploy complete \u2713") + print(f" {_hyperlink(cortex_url, 'View entity in Cortex')}") + 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) + + print(f"\nDone! Watch your deploy appear at:") + print(f" {_hyperlink(cortex_url)}") + + def _import_cortex_workflow(self) -> None: + base_url = self._answers["cortex_base_url"].rstrip("/") + api_key = self._answers["cortex_api_key"] + alias = self._answers["harness_integration_alias"] + + yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace( + "PLACEHOLDER_INTEGRATION_ALIAS", alias + ) + + 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}" + ) + + def _trigger_via_cortex_workflow(self) -> dict: + 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 = "harness-trigger-deploy" + + body = { + "scope": {"type": "GLOBAL"}, + "initialContext": { + "harness-org": self._answers["harness_org"], + "harness-project": self._answers["harness_project"], + "harness-pipeline": self._answers["harness_pipeline"], + "entity-tag": self._answers["entity_tag"], + }, + } + 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_id = resp.json().get("id") + if not run_id: + raise RuntimeError("No run ID returned from workflow start") + + terminal = {"COMPLETED", "FAILED", "CANCELLED"} + start = time.time() + dots = 0 + while time.time() - start < 360: + 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() + dots += 1 + print(f"\r Waiting for Harness pipeline{'.' * (dots % 4)} ", end="", flush=True) + if status in terminal: + print() + return r.json() + + raise TimeoutError("Timed out waiting for workflow to complete (6 min)") + + +def main(**kwargs): + HarnessDeploySetup(**kwargs).run() + + +if __name__ == "__main__": + main() From e88d54f12cdd5a09797683fe59dbf15ed3793870 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 14:01:43 -0700 Subject: [PATCH 2/8] harness-deploy: create pipeline and secret via Harness API Instead of telling the user to paste YAML into Harness, setup.py now: - Prompts for a Harness API key - Creates the cortex_deploy pipeline via POST /v1/orgs/.../pipelines - Creates the cortex_api_key secret via POST /ng/api/v2/secrets/text - Skips both steps if the resource already exists (idempotent) Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/harness-deploy/setup.py | 206 +++++++++++++++--- 1 file changed, 174 insertions(+), 32 deletions(-) diff --git a/cortexapps_cli/solutions/harness-deploy/setup.py b/cortexapps_cli/solutions/harness-deploy/setup.py index 2fc5182..9463c81 100644 --- a/cortexapps_cli/solutions/harness-deploy/setup.py +++ b/cortexapps_cli/solutions/harness-deploy/setup.py @@ -1,12 +1,14 @@ """ Post-install setup script for the harness-deploy solution. -Wires up Harness credentials, imports the Cortex async workflow, and optionally triggers a test run. +Wires up Harness credentials, creates the pipeline and secret in Harness, +imports the Cortex async workflow, and optionally triggers a test run. Run via: cortex solutions post-install -s harness-deploy """ SETUP_DESCRIPTION = ( "This solution includes a post-install setup script that will configure your Harness " - "integration in Cortex, import the trigger workflow, and optionally fire a test deploy." + "integration in Cortex, create the deploy pipeline and cortex_api_key secret in Harness, " + "import the Cortex trigger workflow, and optionally fire a test deploy." ) import sys @@ -24,6 +26,8 @@ WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "trigger-harness-deploy.yaml" PIPELINE_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy-pipeline.yaml" +HARNESS_APP_HOST = "https://app.harness.io" + def _hyperlink(url: str, text: str = None) -> str: label = text if text is not None else url @@ -38,12 +42,27 @@ def __init__(self, cortex_api_key: str = None, cortex_base_url: str = None, **kw self._session_api_key = cortex_api_key self._session_base_url = cortex_base_url - def _cortex_headers(self) -> dict: - api_key = self._answers.get("cortex_api_key") or self._session_api_key or "" - return { - "Authorization": f"Bearer {api_key}", - "Content-Type": "application/json", - } + # ── Harness API helpers ──────────────────────────────────────────────── + + def _harness_headers(self) -> dict: + return {"x-api-key": self._answers["harness_api_key"]} + + def _harness_base(self) -> str: + return self._answers.get("harness_host", HARNESS_APP_HOST).rstrip("/") + + def _harness_account(self) -> str: + return self._answers["harness_account_id"] + + def _fetch_harness_account_id(self) -> str | None: + """Derive account ID from the first Harness configuration registered in Cortex.""" + integrations = self._fetch_harness_integrations() + for cfg in integrations: + acct = cfg.get("accountId") or cfg.get("account_id") + if acct: + return acct + return None + + # ── Cortex API helpers ───────────────────────────────────────────────── def _fetch_harness_integrations(self) -> list: if not (self._session_api_key and self._session_base_url): @@ -56,7 +75,6 @@ def _fetch_harness_integrations(self) -> list: ) if resp.status_code == 200: data = resp.json() - # Configurations may be a list or wrapped in a key if isinstance(data, list): return data return data.get("configurations", data.get("items", [])) @@ -64,7 +82,8 @@ def _fetch_harness_integrations(self) -> list: pass return [] - def _select_harness_integration(self, integrations: list) -> str: + def _select_harness_integration(self, integrations: list) -> tuple[str, dict]: + """Return (alias, config_dict) for the chosen integration.""" integrations = sorted(integrations, key=lambda c: c.get("alias", "").lower()) default_idx = next( (i for i, c in enumerate(integrations) if c.get("isDefault")), 0 @@ -78,21 +97,30 @@ def _select_harness_integration(self, integrations: list) -> str: 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 + idx = default_idx + else: + try: + idx = int(choice) - 1 + except ValueError: + idx = -1 + if 0 <= idx < len(integrations): + cfg = integrations[idx] + return cfg["alias"], cfg print(f" Enter a number between 1 and {len(integrations)}") + # ── Prompts ──────────────────────────────────────────────────────────── + def collect_prompts(self) -> None: - # 1. Harness integration alias + # 1. Harness integration alias (from Cortex config) integrations = self._fetch_harness_integrations() if integrations: - alias = self._select_harness_integration(integrations) + alias, cfg = self._select_harness_integration(integrations) self._answers["harness_integration_alias"] = alias + # Capture account ID and host if the config exposes them + if cfg.get("accountId"): + self._answers["harness_account_id"] = cfg["accountId"] + if cfg.get("host"): + self._answers["harness_host"] = cfg["host"].rstrip("/") else: if self._session_api_key and self._session_base_url: print("\nNo Harness integration is configured in Cortex.") @@ -101,15 +129,32 @@ def collect_prompts(self) -> None: sys.exit(0) self.prompt("harness_integration_alias", "Harness integration alias", default="default") - # 2. Harness pipeline coordinates + # 2. Harness API key (for creating pipeline + secret directly in Harness) + self.prompt( + "harness_api_key", + "Harness API key (for creating the pipeline and secret in your project)", + env_var="HARNESS_API_KEY", + secret=True, + ) + + # 3. Account ID (needed for Harness secret API — try to derive, else prompt) + if not self._answers.get("harness_account_id"): + derived = self._fetch_harness_account_id() + self.prompt("harness_account_id", "Harness account ID", default=derived) + + # 4. Pipeline coordinates self.prompt("harness_org", "Harness org identifier", default="default") self.prompt("harness_project", "Harness project identifier", default="default_project") - self.prompt("harness_pipeline", "Harness pipeline identifier (the pipeline to trigger)") + self.prompt( + "harness_pipeline", + "Harness pipeline identifier (will be created if it doesn't exist)", + default="cortex_deploy", + ) - # 3. Cortex entity to record deploys against + # 5. Cortex entity to record deploys against self.prompt("entity_tag", "Cortex entity tag to record deploys against", default="harness-demo") - # 4. Cortex credentials + # 6. Cortex credentials if self._session_api_key: if self.confirm("Use current Cortex API key?", default=True): self._answers["cortex_api_key"] = self._session_api_key @@ -131,8 +176,12 @@ def collect_prompts(self) -> None: default="https://api.getcortexapp.com", ) + # ── Steps ────────────────────────────────────────────────────────────── + def steps(self) -> list[tuple[str, callable]]: return [ + ("Creating Harness pipeline", self._create_harness_pipeline), + ("Creating cortex_api_key secret in Harness", self._create_harness_secret), ("Importing Cortex trigger workflow", self._import_cortex_workflow), ] @@ -141,16 +190,15 @@ def post_steps(self) -> None: app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url entity_tag = self._answers["entity_tag"] cortex_url = f"{app_url}/admin/resources?tag={entity_tag}" - pipeline_template_path = PIPELINE_TEMPLATE_PATH - print() - print("Next step: import the Harness pipeline template into your Harness project.") - print(f" Pipeline YAML: {pipeline_template_path}") - print() - print(" In Harness: Pipelines → Import Pipeline → paste or upload the YAML above.") - print(" Add a 'cortex_api_key' secret to your Harness project for the callback to authenticate.") - print() + harness_pipeline_url = ( + f"{self._harness_base()}/ng/account/{self._harness_account()}" + f"/cd/orgs/{self._answers['harness_org']}" + f"/projects/{self._answers['harness_project']}" + f"/pipelines/{self._answers['harness_pipeline']}/executions" + ) + print() if self.confirm("Trigger a test workflow run now?", default=True): print(" Starting Cortex workflow run (waiting for Harness pipeline to complete)...") try: @@ -158,7 +206,7 @@ def post_steps(self) -> None: status = result.get("status", "").upper() if status == "COMPLETED": print(" Deploy complete \u2713") - print(f" {_hyperlink(cortex_url, 'View entity in Cortex')}") + print(f" {_hyperlink(harness_pipeline_url, 'View pipeline runs in Harness')}") self.mark_done("first_deploy") else: print(f" Workflow ended with status: {status}", file=sys.stderr) @@ -168,6 +216,98 @@ def post_steps(self) -> None: print(f"\nDone! Watch your deploy appear at:") print(f" {_hyperlink(cortex_url)}") + print(f"\nHarness pipeline: {_hyperlink(harness_pipeline_url)}") + + # ── Harness pipeline creation ────────────────────────────────────────── + + def _create_harness_pipeline(self) -> None: + org = self._answers["harness_org"] + project = self._answers["harness_project"] + pipeline_id = self._answers["harness_pipeline"] + base = self._harness_base() + + # Check whether the pipeline already exists + check = requests.get( + f"{base}/v1/orgs/{org}/projects/{project}/pipelines/{pipeline_id}", + headers=self._harness_headers(), + timeout=10, + ) + if check.status_code == 200: + return # already exists — leave it alone + + # Build pipeline YAML from template, substituting the pipeline identifier + pipeline_yaml = ( + PIPELINE_TEMPLATE_PATH.read_text() + .replace("identifier: cortex_deploy", f"identifier: {pipeline_id}") + .replace("name: Cortex Deploy", f"name: Cortex Deploy") + ) + + resp = requests.post( + f"{base}/v1/orgs/{org}/projects/{project}/pipelines", + headers={**self._harness_headers(), "Content-Type": "application/yaml"}, + data=pipeline_yaml.encode("utf-8"), + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to create Harness pipeline: {resp.status_code} {resp.text}" + ) + + # ── Harness secret creation ──────────────────────────────────────────── + + def _create_harness_secret(self) -> None: + """Create a cortex_api_key text secret in the Harness project.""" + account_id = self._harness_account() + org = self._answers["harness_org"] + project = self._answers["harness_project"] + base = self._harness_base() + cortex_key = self._answers["cortex_api_key"] + + # Check if secret already exists + check = requests.get( + f"{base}/ng/api/v2/secrets/cortex_api_key", + params={ + "accountIdentifier": account_id, + "orgIdentifier": org, + "projectIdentifier": project, + }, + headers=self._harness_headers(), + timeout=10, + ) + if check.status_code == 200: + return # already exists + + payload = { + "secret": { + "type": "SecretText", + "name": "cortex_api_key", + "identifier": "cortex_api_key", + "orgIdentifier": org, + "projectIdentifier": project, + "spec": { + "secretManagerIdentifier": "harnessSecretManager", + "valueType": "Inline", + "value": cortex_key, + }, + } + } + resp = requests.post( + f"{base}/ng/api/v2/secrets/text", + params={ + "accountIdentifier": account_id, + "orgIdentifier": org, + "projectIdentifier": project, + }, + headers=self._harness_headers(), + json=payload, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to create Harness secret: {resp.status_code} {resp.text}" + ) + + # ── Cortex workflow import ───────────────────────────────────────────── def _import_cortex_workflow(self) -> None: base_url = self._answers["cortex_base_url"].rstrip("/") @@ -192,6 +332,8 @@ def _import_cortex_workflow(self) -> None: f"Failed to import Cortex workflow: {resp.status_code} {resp.text}" ) + # ── Cortex workflow trigger ──────────────────────────────────────────── + def _trigger_via_cortex_workflow(self) -> dict: base_url = self._answers["cortex_base_url"].rstrip("/") api_key = self._answers["cortex_api_key"] From a792ab651e85296ce839630aa9d734e06224c558 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 14:12:09 -0700 Subject: [PATCH 3/8] harness-deploy: add CLI harness integration commands + auto-configure in setup - Add cortex integrations harness subcommand with full CRUD: add, get, list, get-default, update, delete, delete-all, validate, validate-by-alias - setup.py now creates the Cortex Harness integration automatically when none exists (prompts for alias, API key, account ID, optional host, then calls POST /api/v1/harness/configuration) - Captured credentials are reused for the Harness pipeline/secret steps so the user is never asked for the same thing twice Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/integrations.py | 2 + .../commands/integrations_commands/harness.py | 160 ++++++++++++++++++ .../solutions/harness-deploy/setup.py | 71 ++++++-- 3 files changed, 217 insertions(+), 16 deletions(-) create mode 100644 cortexapps_cli/commands/integrations_commands/harness.py diff --git a/cortexapps_cli/commands/integrations.py b/cortexapps_cli/commands/integrations.py index 0586928..3d57650 100644 --- a/cortexapps_cli/commands/integrations.py +++ b/cortexapps_cli/commands/integrations.py @@ -23,6 +23,7 @@ import cortexapps_cli.commands.integrations_commands.firehydrant as firehydrant import cortexapps_cli.commands.integrations_commands.github as github import cortexapps_cli.commands.integrations_commands.gitlab as gitlab +import cortexapps_cli.commands.integrations_commands.harness as harness import cortexapps_cli.commands.integrations_commands.incidentio as incidentio import cortexapps_cli.commands.integrations_commands.instana as instana import cortexapps_cli.commands.integrations_commands.jenkins as jenkins @@ -72,6 +73,7 @@ app.add_typer(firehydrant.app, name="firehydrant") app.add_typer(github.app, name="github") app.add_typer(gitlab.app, name="gitlab") +app.add_typer(harness.app, name="harness") app.add_typer(incidentio.app, name="incidentio") app.add_typer(instana.app, name="instana") app.add_typer(jenkins.app, name="jenkins") diff --git a/cortexapps_cli/commands/integrations_commands/harness.py b/cortexapps_cli/commands/integrations_commands/harness.py new file mode 100644 index 0000000..f89af67 --- /dev/null +++ b/cortexapps_cli/commands/integrations_commands/harness.py @@ -0,0 +1,160 @@ +import json +from rich import print_json +import typer +from typing_extensions import Annotated + +app = typer.Typer(help="Harness commands", no_args_is_help=True) + + +@app.command() +def add( + ctx: typer.Context, + alias: str = typer.Option(..., "--alias", "-a", help="Alias for this configuration"), + api_key: str = typer.Option(..., "--api-key", "-k", help="Harness API key"), + account_id: str = typer.Option(..., "--account-id", "-id", help="Harness account ID"), + host: str = typer.Option(None, "--host", "-h", help="Harness host URL (optional; defaults to https://app.harness.io)"), + is_default: bool = typer.Option(False, "--is-default", "-i", help="Set as the default configuration"), + file_input: Annotated[typer.FileText, typer.Option("--file", "-f", help="JSON file containing configuration; use - for stdin")] = None, +): + """ + Add a Harness configuration + """ + client = ctx.obj["client"] + + if file_input: + if alias or api_key or account_id or host or is_default: + raise typer.BadParameter("When providing a configuration file, do not specify any other attributes") + data = json.loads("".join([line for line in file_input])) + else: + data = { + "alias": alias, + "apiKey": api_key, + "accountId": account_id, + "isDefault": is_default, + } + if host is not None: + data["host"] = host + + r = client.post("api/v1/harness/configuration", data=data) + print_json(data=r) + + +@app.command() +def get( + ctx: typer.Context, + alias: str = typer.Option(..., "--alias", "-a", help="Alias of the configuration to retrieve"), +): + """ + Get a single Harness configuration by alias + """ + client = ctx.obj["client"] + r = client.get(f"api/v1/harness/configuration/{alias}") + print_json(data=r) + + +@app.command() +def list( + ctx: typer.Context, +): + """ + List all Harness configurations + """ + client = ctx.obj["client"] + r = client.get("api/v1/harness/configurations") + print_json(data=r) + + +@app.command() +def get_default( + ctx: typer.Context, +): + """ + Get the default Harness configuration + """ + client = ctx.obj["client"] + r = client.get("api/v1/harness/default-configuration") + print_json(data=r) + + +@app.command() +def update( + ctx: typer.Context, + alias: str = typer.Option(..., "--alias", "-a", help="Alias of the configuration to update"), + api_key: str = typer.Option(None, "--api-key", "-k", help="New Harness API key"), + account_id: str = typer.Option(None, "--account-id", "-id", help="New Harness account ID"), + host: str = typer.Option(None, "--host", "-h", help="New Harness host URL"), + is_default: bool = typer.Option(None, "--is-default", "-i", help="Set as the default configuration"), + file_input: Annotated[typer.FileText, typer.Option("--file", "-f", help="JSON file containing update fields; use - for stdin")] = None, +): + """ + Update a Harness configuration + """ + client = ctx.obj["client"] + + if file_input: + if alias or api_key or account_id or host or is_default is not None: + raise typer.BadParameter("When providing a configuration file, do not specify any other attributes") + data = json.loads("".join([line for line in file_input])) + else: + data = {} + if api_key is not None: + data["apiKey"] = api_key + if account_id is not None: + data["accountId"] = account_id + if host is not None: + data["host"] = host + if is_default is not None: + data["isDefault"] = is_default + + r = client.put(f"api/v1/harness/configuration/{alias}", data=data) + print_json(data=r) + + +@app.command() +def delete( + ctx: typer.Context, + alias: str = typer.Option(..., "--alias", "-a", help="Alias of the configuration to delete"), +): + """ + Delete a single Harness configuration + """ + client = ctx.obj["client"] + r = client.delete(f"api/v1/harness/configuration/{alias}") + print_json(data=r) + + +@app.command() +def delete_all( + ctx: typer.Context, +): + """ + Delete all Harness configurations + """ + client = ctx.obj["client"] + r = client.delete("api/v1/harness/configurations") + print_json(data=r) + + +@app.command() +def validate( + ctx: typer.Context, + alias: str = typer.Option(..., "--alias", "-a", help="Alias of the configuration to validate"), +): + """ + Validate a single Harness configuration + """ + client = ctx.obj["client"] + r = client.post(f"api/v1/harness/configuration/validate/{alias}") + print_json(data=r) + + +@app.command() +def validate_all( + ctx: typer.Context, +): + """ + Validate all Harness configurations + """ + client = ctx.obj["client"] + r = client.post("api/v1/harness/configuration/validate") + print_json(data=r) diff --git a/cortexapps_cli/solutions/harness-deploy/setup.py b/cortexapps_cli/solutions/harness-deploy/setup.py index 9463c81..fcf9909 100644 --- a/cortexapps_cli/solutions/harness-deploy/setup.py +++ b/cortexapps_cli/solutions/harness-deploy/setup.py @@ -110,8 +110,50 @@ def _select_harness_integration(self, integrations: list) -> tuple[str, dict]: # ── Prompts ──────────────────────────────────────────────────────────── + def _create_cortex_harness_integration(self) -> str: + """Prompt for Harness credentials and register them in Cortex. Returns the alias.""" + base_url = (self._session_base_url or "https://api.getcortexapp.com").rstrip("/") + api_key = self._session_api_key or self._answers.get("cortex_api_key", "") + + print("\nNo Harness integration is configured in Cortex. Let's set one up.") + self.prompt("harness_integration_alias", "Integration alias", default="default") + self.prompt( + "harness_api_key", + "Harness API key", + env_var="HARNESS_API_KEY", + secret=True, + ) + self.prompt("harness_account_id", "Harness account ID") + self.prompt( + "harness_host", + "Harness host URL (leave blank for https://app.harness.io)", + default="", + ) + + payload = { + "alias": self._answers["harness_integration_alias"], + "apiKey": self._answers["harness_api_key"], + "accountId": self._answers["harness_account_id"], + "isDefault": True, + } + if self._answers.get("harness_host"): + payload["host"] = self._answers["harness_host"] + + resp = requests.post( + f"{base_url}/api/v1/harness/configuration", + json=payload, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to create Harness integration in Cortex: {resp.status_code} {resp.text}" + ) + print(f" Harness integration '{self._answers['harness_integration_alias']}' created \u2713") + return self._answers["harness_integration_alias"] + def collect_prompts(self) -> None: - # 1. Harness integration alias (from Cortex config) + # 1. Harness integration alias (from Cortex config, or create one) integrations = self._fetch_harness_integrations() if integrations: alias, cfg = self._select_harness_integration(integrations) @@ -122,22 +164,19 @@ def collect_prompts(self) -> None: if cfg.get("host"): self._answers["harness_host"] = cfg["host"].rstrip("/") else: - if self._session_api_key and self._session_base_url: - print("\nNo Harness integration is configured in Cortex.") - print("Configure one at: Settings → Integrations → Harness") - print("Then re-run: cortex solutions post-install -s harness-deploy") - sys.exit(0) - self.prompt("harness_integration_alias", "Harness integration alias", default="default") - - # 2. Harness API key (for creating pipeline + secret directly in Harness) - self.prompt( - "harness_api_key", - "Harness API key (for creating the pipeline and secret in your project)", - env_var="HARNESS_API_KEY", - secret=True, - ) + alias = self._create_cortex_harness_integration() + self._answers["harness_integration_alias"] = alias + + # 2. Harness API key — only prompt if we didn't already collect it above + if not self._answers.get("harness_api_key"): + self.prompt( + "harness_api_key", + "Harness API key (for creating the pipeline and secret in your project)", + env_var="HARNESS_API_KEY", + secret=True, + ) - # 3. Account ID (needed for Harness secret API — try to derive, else prompt) + # 3. Account ID — only prompt if not already captured from the integration config if not self._answers.get("harness_account_id"): derived = self._fetch_harness_account_id() self.prompt("harness_account_id", "Harness account ID", default=derived) From 2a417004eca86e79ef3be7eed227d62736c88a1b Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 14:15:52 -0700 Subject: [PATCH 4/8] solutions: replace 'resources' with 'entities' in user-facing output 'Resource' is retired Cortex terminology. All CLI messages now say 'entity' / 'entities' consistently. Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/commands/solutions.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cortexapps_cli/commands/solutions.py b/cortexapps_cli/commands/solutions.py index f3d2901..a467713 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -399,10 +399,10 @@ def _run_uninstall(client, path: Path, yes: bool) -> None: total = sum(len(v) for v in resources.values()) if total == 0: - typer.echo("No resources found to remove.") + typer.echo("No entities found to remove.") return - typer.echo("\nThis will remove the following resources:") + typer.echo("\nThis will remove the following entities:") for kind in ("workflows", "scorecards", "plugins", "catalog", "entity-relationship-types", "entity-types"): count = len(resources[kind]) if count: @@ -711,7 +711,7 @@ def _do_import() -> None: typer.echo(failed_m.group(0)) typer.echo(f"\n {total_imported} imported, {total_failed} failed") else: - typer.echo(f" {total_imported} resources imported") + typer.echo(f" {total_imported} entities imported") else: typer.echo(output) From a2f28a41b1c3f217adca99daf75f24ce5d8341b1 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 14:17:30 -0700 Subject: [PATCH 5/8] harness-deploy: fix empty harness_host falling through to blank URL _harness_base() was using .get(..., default) which doesn't catch an empty-string value set when the user accepts the blank host prompt. Use `or` so any falsy value falls back to HARNESS_APP_HOST. Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/harness-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/harness-deploy/setup.py b/cortexapps_cli/solutions/harness-deploy/setup.py index fcf9909..ef22334 100644 --- a/cortexapps_cli/solutions/harness-deploy/setup.py +++ b/cortexapps_cli/solutions/harness-deploy/setup.py @@ -48,7 +48,7 @@ def _harness_headers(self) -> dict: return {"x-api-key": self._answers["harness_api_key"]} def _harness_base(self) -> str: - return self._answers.get("harness_host", HARNESS_APP_HOST).rstrip("/") + return (self._answers.get("harness_host") or HARNESS_APP_HOST).rstrip("/") def _harness_account(self) -> str: return self._answers["harness_account_id"] From 09de362aa6635b5e2f631f28617e7cc8df77bfd4 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 14:27:53 -0700 Subject: [PATCH 6/8] harness-deploy: fix pipeline create API request format Harness v1 POST /pipelines expects JSON with the YAML embedded as a string field, not raw YAML as the body. The error was: Unrecognized field "pipeline" (class PipelineCreateRequestBody) Fix: send Content-Type: application/json with {identifier, name, yaml}. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/harness-deploy/setup.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/cortexapps_cli/solutions/harness-deploy/setup.py b/cortexapps_cli/solutions/harness-deploy/setup.py index ef22334..01c35d9 100644 --- a/cortexapps_cli/solutions/harness-deploy/setup.py +++ b/cortexapps_cli/solutions/harness-deploy/setup.py @@ -228,7 +228,7 @@ def post_steps(self) -> None: base_url = self._answers["cortex_base_url"].rstrip("/") app_url = base_url.replace("api.", "app.", 1) if "api." in base_url else base_url entity_tag = self._answers["entity_tag"] - cortex_url = f"{app_url}/admin/resources?tag={entity_tag}" + cortex_url = f"{app_url}/admin/resources?tag={entity_tag}" # /admin/resources is the correct URL path (entity terminology in UI) harness_pipeline_url = ( f"{self._harness_base()}/ng/account/{self._harness_account()}" @@ -275,16 +275,19 @@ def _create_harness_pipeline(self) -> None: return # already exists — leave it alone # Build pipeline YAML from template, substituting the pipeline identifier - pipeline_yaml = ( - PIPELINE_TEMPLATE_PATH.read_text() - .replace("identifier: cortex_deploy", f"identifier: {pipeline_id}") - .replace("name: Cortex Deploy", f"name: Cortex Deploy") + pipeline_yaml = PIPELINE_TEMPLATE_PATH.read_text().replace( + "identifier: cortex_deploy", f"identifier: {pipeline_id}" ) + # Harness v1 pipeline API expects JSON with the YAML embedded as a string resp = requests.post( f"{base}/v1/orgs/{org}/projects/{project}/pipelines", - headers={**self._harness_headers(), "Content-Type": "application/yaml"}, - data=pipeline_yaml.encode("utf-8"), + headers={**self._harness_headers(), "Content-Type": "application/json"}, + json={ + "identifier": pipeline_id, + "name": "Cortex Deploy", + "yaml": pipeline_yaml, + }, timeout=15, ) if resp.status_code not in (200, 201): From 4a0d920707992bd041ba8d39e04689f34677e140 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Wed, 12 Aug 2026 14:43:46 -0700 Subject: [PATCH 7/8] harness-deploy: fix pipeline create request body (live-tested) Two fixes found by testing against the actual Harness API: - Field name is pipeline_yaml, not yaml or pipelineYaml - YAML name must match the name field in the JSON body Verified 201 response with a test pipeline (subsequently deleted). Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/harness-deploy/setup.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/cortexapps_cli/solutions/harness-deploy/setup.py b/cortexapps_cli/solutions/harness-deploy/setup.py index 01c35d9..c34156e 100644 --- a/cortexapps_cli/solutions/harness-deploy/setup.py +++ b/cortexapps_cli/solutions/harness-deploy/setup.py @@ -274,19 +274,22 @@ def _create_harness_pipeline(self) -> None: if check.status_code == 200: return # already exists — leave it alone - # Build pipeline YAML from template, substituting the pipeline identifier - pipeline_yaml = PIPELINE_TEMPLATE_PATH.read_text().replace( - "identifier: cortex_deploy", f"identifier: {pipeline_id}" + pipeline_name = "Cortex Deploy" + # Build pipeline YAML from template — name in YAML must match name in JSON body + pipeline_yaml = ( + PIPELINE_TEMPLATE_PATH.read_text() + .replace("identifier: cortex_deploy", f"identifier: {pipeline_id}") + .replace("name: Cortex Deploy\n", f"name: {pipeline_name}\n") ) - # Harness v1 pipeline API expects JSON with the YAML embedded as a string + # Harness v1 pipeline API: JSON body with pipeline_yaml containing the full YAML resp = requests.post( f"{base}/v1/orgs/{org}/projects/{project}/pipelines", headers={**self._harness_headers(), "Content-Type": "application/json"}, json={ "identifier": pipeline_id, - "name": "Cortex Deploy", - "yaml": pipeline_yaml, + "name": pipeline_name, + "pipeline_yaml": pipeline_yaml, }, timeout=15, ) From a19dd5b4e0be36c926d017734598b6e35fb02a8b Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 13 Aug 2026 16:27:21 -0700 Subject: [PATCH 8/8] harness-deploy: complete solution with entity-scoped workflow and two stage templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Split single stage template into cortex_record_deploy + cortex_async_callback (mirrors GitHub Actions composite action pattern) - Pipeline now has 3 stages: Build → Record Deploy in Cortex → Callback to Cortex - Build stage sends 3 intermediate UPDATE callbacks with progress % before terminal callback - Workflow changed from GLOBAL to ENTITY scope; renamed to "Solution: Trigger Harness Deploy" - Harness coordinates stored in entity custom metadata (x-cortex-custom-metadata.harness) and read at runtime via GET /custom-data/harness — no manual inputs required - setup.py writes custom metadata via PATCH /api/v1/open-api after collecting answers - Workflow run failure shows link to Cortex Workflow runs page for investigation - README and run response template updated to reflect new architecture - ASCII diagram fixed (alignment + 3-stage pipeline) Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/_lib/setup_base.py | 40 ++-- .../solutions/github-actions-deploy/setup.py | 15 +- .../solutions/harness-deploy/README.md | 92 +++++++- .../cortex-async-callback-template.yaml | 59 ++++++ .../_templates/cortex-deploy-pipeline.yaml | 127 ++++------- .../cortex-record-deploy-template.yaml | 55 +++++ .../_templates/trigger-harness-deploy.yaml | 182 ++++++++++++++-- .../harness-deploy/catalog/harness-demo.yaml | 5 + .../solutions/harness-deploy/setup.py | 197 +++++++++++++----- 9 files changed, 580 insertions(+), 192 deletions(-) create mode 100644 cortexapps_cli/solutions/harness-deploy/_templates/cortex-async-callback-template.yaml create mode 100644 cortexapps_cli/solutions/harness-deploy/_templates/cortex-record-deploy-template.yaml diff --git a/cortexapps_cli/solutions/_lib/setup_base.py b/cortexapps_cli/solutions/_lib/setup_base.py index d0645d5..a74cef3 100644 --- a/cortexapps_cli/solutions/_lib/setup_base.py +++ b/cortexapps_cli/solutions/_lib/setup_base.py @@ -17,6 +17,7 @@ class SolutionSetup(ABC): def __init__(self, state_dir: Optional[Path] = None, no_prompt: bool = False): self._no_prompt = no_prompt + self._save_answers = True self._secret_keys: set = set() self._answers: dict = {} @@ -39,10 +40,12 @@ def _load_file(self) -> dict: 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, - } + saved_answers = ( + {k: v for k, v in self._answers.items() if k not in self._secret_keys} + if self._save_answers + else {} + ) + data = {"answers": saved_answers, "state": self._state} self._state_file.write_text(json.dumps(data, indent=2)) def _save_state(self) -> None: @@ -69,23 +72,29 @@ def prompt( env_var: Optional[str] = None, default: Optional[str] = None, secret: bool = False, + hidden: bool = False, ) -> str: - """Prompt for a value. Uses saved answer or env var when available.""" + """Prompt for a value. Uses saved answer or env var when available. + + secret=True — mask input AND exclude from saved JSON + hidden=True — mask input only; value is still saved to JSON + """ + use_getpass = secret or hidden if secret: self._secret_keys.add(key) - # Non-secret: use saved answer when --no-prompt + # Use saved answer when --no-prompt (works for both plain and hidden keys) 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 + # 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 + masked = "********" if use_getpass 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 @@ -94,12 +103,15 @@ def prompt( 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 use_getpass and default: + hint = f"********{default[-4:]}" + elif default: + hint = default + else: + hint = None + prompt_str = f"{message} [{hint}]: " if hint else f"{message}: " - if secret: + if use_getpass: value = getpass.getpass(prompt_str).strip() else: value = input(prompt_str).strip() @@ -147,6 +159,8 @@ def run(self) -> None: for k, v in saved.items(): print(f" {k}: {v}") print() + elif not self._no_prompt: + self._save_answers = self.confirm(f"Save answers for future runs to {self._state_file}", default=True) self.collect_prompts() self._save_file() diff --git a/cortexapps_cli/solutions/github-actions-deploy/setup.py b/cortexapps_cli/solutions/github-actions-deploy/setup.py index 97bef43..d6f601a 100644 --- a/cortexapps_cli/solutions/github-actions-deploy/setup.py +++ b/cortexapps_cli/solutions/github-actions-deploy/setup.py @@ -163,20 +163,17 @@ def collect_prompts(self) -> 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 + # 3. Cortex credentials — use CLI session silently; only prompt when running standalone + self._secret_keys.add("cortex_api_key") 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) + self._answers["cortex_api_key"] = self._session_api_key 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) + 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", diff --git a/cortexapps_cli/solutions/harness-deploy/README.md b/cortexapps_cli/solutions/harness-deploy/README.md index fa0aaa9..dee017e 100644 --- a/cortexapps_cli/solutions/harness-deploy/README.md +++ b/cortexapps_cli/solutions/harness-deploy/README.md @@ -3,12 +3,62 @@ name: Harness Deploy Tracking description: Track deployments from Harness pipelines in Cortex, with a deploy health scorecard measuring delivery cadence. --- +# Harness Deploy Tracking + +Trigger deploys from Cortex, track them as they run in Harness, and surface deploy health back in your service catalog. + +``` + ┌─────────────────────────────────┐ + │ Cortex Catalog │ + │ │ + │ harness-demo (service) │ + │ └── Scorecard: Deploy Health │ + │ Bronze / Silver / Gold │ + └──────────────┬──────────────────┘ + │ + │ Run workflow from entity page + │ (or: cortex workflows run -t + │ harness-trigger-deploy + │ --scope ENTITY --entity ) + ▼ + ┌─────────────────────────────────┐ + │ Cortex Workflow │ + │ Trigger Harness Deploy │ + │ │ + │ 1. Read Harness config from │ + │ entity custom metadata │ + │ 2. POST /execute to Harness │ + │ pipeline via integration │ + │ 3. Pass callback URL as │ + │ pipeline variable │ + │ 4. Wait for callback │ + └──────────────┬──────────────────┘ + │ POST /execute (Harness integration) + ▼ + ┌─────────────────────────────────┐ + │ Harness Pipeline │ + │ cortex-deploy │ + │ │ + │ stage: Build │ + │ └── run your deploy steps │ + │ │ + │ stage: Record Deploy in Cortex │ + │ └── POST /deploys ◄────┼── registers deploy event + │ (entity: harness-demo) │ on the Cortex entity + │ │ + │ stage: Callback to Cortex │ + │ └── POST callbackUrl ───────►│ Cortex marks workflow + │ status: SUCCESS/FAILURE │ run complete + └─────────────────────────────────┘ +``` + ## What's Included - **Entity:** `harness-demo` service — a sample entity to receive deploy events - **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency -- **Harness pipeline:** A sample pipeline YAML (with callback + deploy registration) to import into Harness -- **Cortex workflow:** Async trigger that fires a Harness pipeline and waits for it to report back +- **Harness stage templates:** `cortex_record_deploy` and `cortex_async_callback` — reusable Stage Templates that register the deploy event and call back to Cortex; reference them from any pipeline +- **Harness pipeline:** `cortex-deploy` — a three-stage demo pipeline (Build → Record Deploy in Cortex → Callback to Cortex) created in your Harness project +- **Cortex workflow:** `harness-trigger-deploy` — reads Harness coordinates from entity custom metadata, triggers the pipeline via the Harness integration, and waits for the result - **Setup script:** Interactive wizard that wires everything together end-to-end ## Quick Start @@ -27,14 +77,36 @@ description: Track deployments from Harness pipelines in Cortex, with a deploy h ## How It Works -The included Harness pipeline YAML registers a deploy event to Cortex after each run and calls back -to the Cortex async workflow to surface the result directly in the Cortex UI. +The Cortex workflow triggers `cortex-deploy` via the Harness integration, passing a `callback_url` as a pipeline variable. Cortex waits asynchronously for the pipeline to report back. + +Harness 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 configured the Harness integration, created the pipeline and `cortex_api_key` secret in Harness, imported the Cortex workflow, and triggered a test deploy. + +To roll the pattern out to your own services: + +1. Add both **Cortex Record Deploy** and **Cortex Async Callback** stage templates to any existing Harness pipeline (each needs only the `cortex_api_key` secret and its one pipeline variable) + +2. Add a `x-cortex-custom-metadata` block to your entity's catalog YAML with your Harness coordinates: + + ```yaml + x-cortex-custom-metadata: + harness: + org: your-org + project: your-project + pipeline: your-pipeline-id + ``` + +3. Run the **Solution: Trigger Harness Deploy** workflow from the entity page — it reads the Harness coordinates from the entity's custom metadata automatically, with no manual inputs required + +> **Note:** The custom metadata approach is a stand-in for a native Harness integration that is in development. Once released, Harness coordinates will likely be read directly from the integration config rather than custom metadata. ## Customizing for Production -- Point the workflow at your real entity by replacing `harness-demo` with your service tag in the - pipeline's `CORTEX_ENTITY_TAG` variable -- Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` as Harness pipeline variables or secrets -- The Deploy Health scorecard is scoped to `demo-harness-deploys` to avoid affecting your existing - services. To roll it out broadly, remove the group filter. To opt in individual services, add the - `demo-harness-deploys` group to them. +- Point the workflow at your real entity by replacing `harness-demo` with your service tag +- Add the `cortex_api_key` secret to your real Harness projects +- The Deploy Health scorecard is scoped to `demo-harness-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-harness-deploys` group to them. diff --git a/cortexapps_cli/solutions/harness-deploy/_templates/cortex-async-callback-template.yaml b/cortexapps_cli/solutions/harness-deploy/_templates/cortex-async-callback-template.yaml new file mode 100644 index 0000000..792aa3e --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/_templates/cortex-async-callback-template.yaml @@ -0,0 +1,59 @@ +template: + name: Cortex Async Callback + identifier: cortex_async_callback + versionLabel: "1.0" + type: Stage + orgIdentifier: default + projectIdentifier: default_project + spec: + type: Custom + spec: + execution: + steps: + - step: + name: Callback to Cortex + identifier: cortex_callback + type: ShellScript + spec: + shell: Bash + executionTarget: {} + source: + type: Inline + spec: + script: | + if [ -z "$CALLBACK_URL" ]; then + echo "No callback URL set, skipping" + exit 0 + fi + + EXECUTION_URL="https://app.harness.io/ng/account/$HARNESS_ACCOUNT_ID/cd/orgs/<+pipeline.orgIdentifier>/projects/<+pipeline.projectIdentifier>/pipelines/<+pipeline.identifier>/executions/<+pipeline.executionId>/pipeline" + + PAYLOAD="{\"status\":\"SUCCESS\",\"message\":\"Pipeline completed successfully\",\"response\":{\"execution_id\":\"<+pipeline.executionId>\",\"execution_number\":\"<+pipeline.sequenceId>\",\"pipeline_name\":\"<+pipeline.name>\",\"pipeline_url\":\"$EXECUTION_URL\"}}" + + HTTP_STATUS=$(curl -s -o /tmp/cb_response.txt -w "%{http_code}" -X POST "$CALLBACK_URL" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -d "$PAYLOAD") + + echo "Callback HTTP status: $HTTP_STATUS" + cat /tmp/cb_response.txt + if [ "$HTTP_STATUS" != "200" ]; then + echo "ERROR: Callback failed with HTTP $HTTP_STATUS" + exit 1 + fi + environmentVariables: + - name: CALLBACK_URL + type: String + value: "<+stage.variables.callback_url>" + - name: CORTEX_API_KEY + type: Secret + value: cortex_api_key + - name: HARNESS_ACCOUNT_ID + type: String + value: "<+account.identifier>" + outputVariables: [] + timeout: 10m + variables: + - name: callback_url + type: String + value: <+input> diff --git a/cortexapps_cli/solutions/harness-deploy/_templates/cortex-deploy-pipeline.yaml b/cortexapps_cli/solutions/harness-deploy/_templates/cortex-deploy-pipeline.yaml index 6113fad..b2f8c65 100644 --- a/cortexapps_cli/solutions/harness-deploy/_templates/cortex-deploy-pipeline.yaml +++ b/cortexapps_cli/solutions/harness-deploy/_templates/cortex-deploy-pipeline.yaml @@ -39,63 +39,9 @@ pipeline: environmentVariables: [] outputVariables: [] timeout: 10m - - - stage: - name: Notify Cortex - identifier: notify_cortex - type: Custom - spec: - execution: - steps: - - step: - name: Register Deploy - identifier: register_deploy - type: ShellScript - spec: - shell: Bash - executionTarget: {} - source: - type: Inline - spec: - script: | - curl -s -f -X POST \ - "$CORTEX_BASE_URL/api/v1/catalog/$CORTEX_ENTITY_TAG/deploys" \ - -H "Authorization: Bearer $CORTEX_API_KEY" \ - -H "Content-Type: application/json" \ - -d "$(jq -n \ - --arg sha "<+pipeline.executionId>" \ - --arg run_id "<+pipeline.sequenceId>" \ - --arg run_url "https://app.harness.io/ng/account/$HARNESS_ACCOUNT_ID/cd/orgs/<+pipeline.orgIdentifier>/projects/<+pipeline.projectIdentifier>/pipelines/<+pipeline.identifier>/executions/<+pipeline.executionId>/pipeline" \ - --arg actor "<+pipeline.triggeredBy.name>" \ - --arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ - '{ - sha: $sha, - timestamp: $timestamp, - environment: "production", - type: "DEPLOY", - title: ("Triggered by " + $actor), - deployer: {name: $actor}, - customData: {executionId: $sha, sequenceId: $run_id, executionUrl: $run_url} - }')" || true - environmentVariables: - - name: CORTEX_API_KEY - type: Secret - value: cortex_api_key - - name: CORTEX_BASE_URL - type: String - value: "https://api.getcortexapp.com" - - name: CORTEX_ENTITY_TAG - type: String - value: "<+pipeline.variables.cortex_entity_tag>" - - name: HARNESS_ACCOUNT_ID - type: String - value: "<+account.identifier>" - outputVariables: [] - timeout: 5m - - step: - name: Callback to Cortex - identifier: cortex_callback + name: Deploy Progress + identifier: deploy_progress type: ShellScript spec: shell: Bash @@ -104,33 +50,29 @@ pipeline: type: Inline spec: script: | + # Sends intermediate UPDATE callbacks to the Cortex workflow, + # demonstrating that the callback URL can be called multiple times + # before the terminal SUCCESS/FAILURE. if [ -z "$CALLBACK_URL" ]; then - echo "No callback URL set, skipping" + echo "No callback URL set, skipping progress updates" exit 0 fi - EXECUTION_URL="https://app.harness.io/ng/account/$HARNESS_ACCOUNT_ID/cd/orgs/<+pipeline.orgIdentifier>/projects/<+pipeline.projectIdentifier>/pipelines/<+pipeline.identifier>/executions/<+pipeline.executionId>/pipeline" + TOTAL_SECONDS=15 + INTERVAL=5 + ITERATIONS=$((TOTAL_SECONDS / INTERVAL)) - HTTP_STATUS=$(curl -s -o /tmp/cb_response.txt -w "%{http_code}" -X POST "$CALLBACK_URL" \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer $CORTEX_API_KEY" \ - -d "$(jq -n \ - --arg exec_id "<+pipeline.executionId>" \ - --arg seq_id "<+pipeline.sequenceId>" \ - --arg name "<+pipeline.name>" \ - --arg url "$EXECUTION_URL" \ - '{ - status: "SUCCESS", - message: "Pipeline completed successfully", - response: {execution_id: $exec_id, execution_number: $seq_id, pipeline_name: $name, pipeline_url: $url} - }')") + for i in $(seq 1 $ITERATIONS); do + sleep $INTERVAL + PROGRESS=$((i * 100 / ITERATIONS)) - echo "Callback HTTP status: $HTTP_STATUS" - cat /tmp/cb_response.txt - if [ "$HTTP_STATUS" != "200" ]; then - echo "ERROR: Callback failed with HTTP $HTTP_STATUS" - exit 1 - fi + curl -s -X POST "$CALLBACK_URL" \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -H "Content-Type: application/json" \ + -d "{\"status\":\"UPDATE\",\"message\":\"Job still running – progress update.\",\"response\":{\"progress\":\"${PROGRESS}%\",\"details\":\"Deploy in progress (${i}/${ITERATIONS} intervals completed)\"}}" || true + + echo "Progress update ${i}/${ITERATIONS} (${PROGRESS}%) sent" + done environmentVariables: - name: CALLBACK_URL type: String @@ -138,8 +80,31 @@ pipeline: - name: CORTEX_API_KEY type: Secret value: cortex_api_key - - name: HARNESS_ACCOUNT_ID - type: String - value: "<+account.identifier>" outputVariables: [] - timeout: 10m + timeout: 5m + + - stage: + name: Record Deploy in Cortex + identifier: record_deploy_stage + template: + templateRef: cortex_record_deploy + versionLabel: "1.0" + templateInputs: + type: Custom + variables: + - name: cortex_entity_tag + type: String + value: "<+pipeline.variables.cortex_entity_tag>" + + - stage: + name: Callback to Cortex + identifier: callback_stage + template: + templateRef: cortex_async_callback + versionLabel: "1.0" + templateInputs: + type: Custom + variables: + - name: callback_url + type: String + value: "<+pipeline.variables.callback_url>" diff --git a/cortexapps_cli/solutions/harness-deploy/_templates/cortex-record-deploy-template.yaml b/cortexapps_cli/solutions/harness-deploy/_templates/cortex-record-deploy-template.yaml new file mode 100644 index 0000000..4095d5a --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/_templates/cortex-record-deploy-template.yaml @@ -0,0 +1,55 @@ +template: + name: Cortex Record Deploy + identifier: cortex_record_deploy + versionLabel: "1.0" + type: Stage + orgIdentifier: default + projectIdentifier: default_project + spec: + type: Custom + spec: + execution: + steps: + - step: + name: Record Deploy + identifier: record_deploy + type: ShellScript + spec: + shell: Bash + executionTarget: {} + source: + type: Inline + spec: + script: | + RUN_URL="https://app.harness.io/ng/account/$HARNESS_ACCOUNT_ID/cd/orgs/<+pipeline.orgIdentifier>/projects/<+pipeline.projectIdentifier>/pipelines/<+pipeline.identifier>/executions/<+pipeline.executionId>/pipeline" + TIMESTAMP=$(date -u +%Y-%m-%dT%H:%M:%SZ) + ACTOR="<+pipeline.triggeredBy.name>" + EXEC_ID="<+pipeline.executionId>" + SEQ_ID="<+pipeline.sequenceId>" + + PAYLOAD="{\"sha\":\"$EXEC_ID\",\"timestamp\":\"$TIMESTAMP\",\"environment\":\"production\",\"type\":\"DEPLOY\",\"title\":\"Triggered by $ACTOR\",\"deployer\":{\"name\":\"$ACTOR\"},\"customData\":{\"executionId\":\"$EXEC_ID\",\"sequenceId\":\"$SEQ_ID\",\"executionUrl\":\"$RUN_URL\"}}" + + curl -s -f -X POST \ + "$CORTEX_BASE_URL/api/v1/catalog/$CORTEX_ENTITY_TAG/deploys" \ + -H "Authorization: Bearer $CORTEX_API_KEY" \ + -H "Content-Type: application/json" \ + -d "$PAYLOAD" || true + environmentVariables: + - name: CORTEX_API_KEY + type: Secret + value: cortex_api_key + - name: CORTEX_BASE_URL + type: String + value: "https://api.getcortexapp.com" + - name: CORTEX_ENTITY_TAG + type: String + value: "<+stage.variables.cortex_entity_tag>" + - name: HARNESS_ACCOUNT_ID + type: String + value: "<+account.identifier>" + outputVariables: [] + timeout: 5m + variables: + - name: cortex_entity_tag + type: String + value: <+input> diff --git a/cortexapps_cli/solutions/harness-deploy/_templates/trigger-harness-deploy.yaml b/cortexapps_cli/solutions/harness-deploy/_templates/trigger-harness-deploy.yaml index 852e729..bc19ec4 100644 --- a/cortexapps_cli/solutions/harness-deploy/_templates/trigger-harness-deploy.yaml +++ b/cortexapps_cli/solutions/harness-deploy/_templates/trigger-harness-deploy.yaml @@ -1,39 +1,177 @@ -name: Trigger Harness Deploy +name: "Solution: Trigger Harness Deploy" tag: harness-trigger-deploy description: | Triggers a Harness pipeline and waits for it to report completion back to Cortex, - registering a deploy event on the target entity. + registering a deploy event on the target entity. No inputs required — Harness + coordinates (org, project, pipeline) are read from the entity's custom metadata. + + Note: custom metadata is used today as a stand-in for a native Harness integration. + Once a native integration is available in Cortex, the entity lookup step will be replaced. isDraft: false isRunnableViaApi: true filter: - type: GLOBAL + type: ENTITY variables: - slug: harness-org type: STRING + defaultValue: "" - slug: harness-project type: STRING + defaultValue: "" - slug: harness-pipeline type: STRING - - slug: entity-tag - type: STRING + defaultValue: "" runResponseTemplate: | - # Harness Pipeline Deploy + # Harness Deploy — Complete + + **Pipeline:** [{{variables.harness-pipeline}} in {{variables.harness-org}}/{{variables.harness-project}}](https://app.harness.io/ng/account/PLACEHOLDER_HARNESS_ACCOUNT_ID/cd/orgs/{{variables.harness-org}}/projects/{{variables.harness-project}}/pipelines/{{variables.harness-pipeline}}/executions) + + **Cortex Deploys:** [see deploys for {{context.entity.tag}}](https://app.getcortexapp.com/admin/resources?tag={{context.entity.tag}}) + + --- + + ## How this workflow works + + This Cortex workflow triggered a deploy in Harness and waited for it to finish. Here's what happened end-to-end: + + **1. Cortex read the entity's Harness configuration** + + The workflow fetched this entity's catalog config and extracted `x-cortex-custom-metadata.harness` to determine which Harness org, project, and pipeline to deploy — no manual input required. + + **2. Cortex triggered the Harness pipeline** + + It called the Harness API to execute `{{variables.harness-pipeline}}` via the Harness integration, passing a one-time callback URL as a pipeline variable (`callback_url`). + + **3. Harness ran the pipeline** + + `{{variables.harness-pipeline}}` runs three stages: + + - **Build** — your deploy steps (replace the placeholder `echo "Building..."` with your real build/deploy) + + - **Record Deploy in Cortex** — POSTs a deploy event to the Cortex API using the `cortex_record_deploy` stage template, recording the execution ID, actor, and run URL against `{{context.entity.tag}}`. This feeds the Deploy Health scorecard. + + - **Callback to Cortex** — POSTs the final status (SUCCESS/FAILURE) back to the callback URL using the `cortex_async_callback` stage template, which is how Cortex knew this workflow run was done. + + **4. Cortex received the callback** + + When the Harness pipeline posted to the callback URL, Cortex marked this workflow run complete and surfaced the result here. + + --- + + ## The Harness pipeline YAML + + ```yaml + pipeline: + name: Cortex Deploy + identifier: {{variables.harness-pipeline}} + variables: + - name: callback_url + type: String + value: "<+input>" # injected by Cortex at trigger time + - name: cortex_entity_tag + type: String + value: "<+input>" # set to {{context.entity.tag}} + stages: + - stage: + name: Build + type: Custom + # ── replace this step with your real deploy ── + steps: + - step: + type: ShellScript + spec: + script: echo "Building..." + - stage: + name: Record Deploy in Cortex + template: + templateRef: cortex_record_deploy + versionLabel: "1.0" + templateInputs: + type: Custom + variables: + - name: cortex_entity_tag + value: "<+pipeline.variables.cortex_entity_tag>" + - stage: + name: Callback to Cortex + template: + templateRef: cortex_async_callback + versionLabel: "1.0" + templateInputs: + type: Custom + variables: + - name: callback_url + value: "<+pipeline.variables.callback_url>" + ``` + + --- + + ## Adapting this to your own pipelines + + 1. Replace the `echo "Building..."` step in the Build stage with your real deploy steps - Deploy triggered for pipeline **{{variables.harness-pipeline}}** in - `{{variables.harness-org}}/{{variables.harness-project}}`. + 2. Add both **Cortex Record Deploy** and **Cortex Async Callback** stage templates to any existing Harness pipeline — each only needs the `cortex_api_key` secret plus its one pipeline variable - [View pipeline runs in Harness](https://app.harness.io/ng/account/PLACEHOLDER_HARNESS_ACCOUNT_ID/cd/orgs/{{variables.harness-org}}/projects/{{variables.harness-project}}/pipelines/{{variables.harness-pipeline}}/executions) + 3. Add `x-cortex-custom-metadata.harness` to your entity's catalog YAML with `org`, `project`, and `pipeline` fields pointing at your real pipeline actions: - - name: Trigger Harness Pipeline - slug: trigger-deploy - schema: - type: HTTP_REQUEST_ASYNC - httpMethod: POST - url: "/v1/orgs/{{variables.harness-org}}/projects/{{variables.harness-project}}/pipelines/{{variables.harness-pipeline}}/execute?notes=Submitted+by+{{context.initiatedBy.email}}" - integration: Harness - integrationAlias: "PLACEHOLDER_INTEGRATION_ALIAS" - headers: {} - payload: "{\"inputs_yaml\": \"pipeline:\\n identifier: {{variables.harness-pipeline}}\\n variables:\\n - name: callback_url\\n type: String\\n value: \\\"{{{callbackUrl}}}\\\"\\n - name: cortex_entity_tag\\n type: String\\n value: \\\"{{variables.entity-tag}}\\\"\"}" - timeoutInSeconds: 300 - outgoingActions: [] - isRootAction: true +- name: Get Harness config + slug: get-harness-config + schema: + type: HTTP_REQUEST + httpMethod: GET + url: "https://api.getcortexapp.com/api/v1/catalog/{{context.entity.tag}}/custom-data/harness" + headers: + Authorization: "Bearer {{&context.secrets.cortex_api_key}}" + Content-Type: application/json + integration: null + integrationAlias: null + outgoingActions: + - parse-harness-config + isRootAction: true +- name: Parse Harness config + slug: parse-harness-config + schema: + type: JQ + expression: | + .actions."get-harness-config".outputs.body.value as $h | + if ($h == null or $h.org == null) then + error("No Harness configuration found. Add x-cortex-custom-metadata.harness with org, project, and pipeline to this entity.") + else + {org: $h.org, project: $h.project, pipeline: $h.pipeline} + end + outgoingActions: + - set-variables + isRootAction: false +- name: Set variables + slug: set-variables + schema: + type: SET_VARIABLES + variables: + - slug: harness-org + source: + path: actions.parse-harness-config.outputs.result.org + type: REFERENCE + - slug: harness-project + source: + path: actions.parse-harness-config.outputs.result.project + type: REFERENCE + - slug: harness-pipeline + source: + path: actions.parse-harness-config.outputs.result.pipeline + type: REFERENCE + outgoingActions: + - trigger-deploy + isRootAction: false +- name: Trigger Harness Pipeline + slug: trigger-deploy + schema: + type: HTTP_REQUEST_ASYNC + httpMethod: POST + url: "/v1/orgs/{{variables.harness-org}}/projects/{{variables.harness-project}}/pipelines/{{variables.harness-pipeline}}/execute?notes=Submitted+by+{{context.initiatedBy.email}}" + integration: Harness + integrationAlias: "PLACEHOLDER_INTEGRATION_ALIAS" + headers: + Content-Type: application/json + payload: "{\"inputs_yaml\": \"pipeline:\\n identifier: {{variables.harness-pipeline}}\\n variables:\\n - name: callback_url\\n type: String\\n value: \\\"{{{callbackUrl}}}\\\"\\n - name: cortex_entity_tag\\n type: String\\n value: \\\"{{context.entity.tag}}\\\"\"}" + timeoutInSeconds: 300 + outgoingActions: [] + isRootAction: false diff --git a/cortexapps_cli/solutions/harness-deploy/catalog/harness-demo.yaml b/cortexapps_cli/solutions/harness-deploy/catalog/harness-demo.yaml index 2d4cbf5..4fa1de9 100644 --- a/cortexapps_cli/solutions/harness-deploy/catalog/harness-demo.yaml +++ b/cortexapps_cli/solutions/harness-deploy/catalog/harness-demo.yaml @@ -7,3 +7,8 @@ info: x-cortex-definition: {} x-cortex-groups: - demo-harness-deploys + x-cortex-custom-metadata: + harness: + org: PLACEHOLDER_HARNESS_ORG + project: PLACEHOLDER_HARNESS_PROJECT + pipeline: PLACEHOLDER_HARNESS_PIPELINE diff --git a/cortexapps_cli/solutions/harness-deploy/setup.py b/cortexapps_cli/solutions/harness-deploy/setup.py index c34156e..f2d3f20 100644 --- a/cortexapps_cli/solutions/harness-deploy/setup.py +++ b/cortexapps_cli/solutions/harness-deploy/setup.py @@ -25,6 +25,8 @@ WORKFLOW_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "trigger-harness-deploy.yaml" PIPELINE_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-deploy-pipeline.yaml" +RECORD_DEPLOY_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-record-deploy-template.yaml" +CALLBACK_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "cortex-async-callback-template.yaml" HARNESS_APP_HOST = "https://app.harness.io" @@ -121,7 +123,7 @@ def _create_cortex_harness_integration(self) -> str: "harness_api_key", "Harness API key", env_var="HARNESS_API_KEY", - secret=True, + hidden=True, ) self.prompt("harness_account_id", "Harness account ID") self.prompt( @@ -154,18 +156,21 @@ def _create_cortex_harness_integration(self) -> str: def collect_prompts(self) -> None: # 1. Harness integration alias (from Cortex config, or create one) - integrations = self._fetch_harness_integrations() - if integrations: - alias, cfg = self._select_harness_integration(integrations) - self._answers["harness_integration_alias"] = alias - # Capture account ID and host if the config exposes them - if cfg.get("accountId"): - self._answers["harness_account_id"] = cfg["accountId"] - if cfg.get("host"): - self._answers["harness_host"] = cfg["host"].rstrip("/") + if self._no_prompt and self._answers.get("harness_integration_alias"): + pass # use saved alias — no need to re-fetch or re-select else: - alias = self._create_cortex_harness_integration() - self._answers["harness_integration_alias"] = alias + integrations = self._fetch_harness_integrations() + if integrations: + alias, cfg = self._select_harness_integration(integrations) + self._answers["harness_integration_alias"] = alias + # Capture account ID and host if the config exposes them + if cfg.get("accountId"): + self._answers["harness_account_id"] = cfg["accountId"] + if cfg.get("host"): + self._answers["harness_host"] = cfg["host"].rstrip("/") + else: + alias = self._create_cortex_harness_integration() + self._answers["harness_integration_alias"] = alias # 2. Harness API key — only prompt if we didn't already collect it above if not self._answers.get("harness_api_key"): @@ -173,7 +178,7 @@ def collect_prompts(self) -> None: "harness_api_key", "Harness API key (for creating the pipeline and secret in your project)", env_var="HARNESS_API_KEY", - secret=True, + hidden=True, ) # 3. Account ID — only prompt if not already captured from the integration config @@ -193,20 +198,15 @@ def collect_prompts(self) -> None: # 5. Cortex entity to record deploys against self.prompt("entity_tag", "Cortex entity tag to record deploys against", default="harness-demo") - # 6. Cortex credentials + # 6. Cortex credentials — use CLI session silently; only prompt when running standalone + self._secret_keys.add("cortex_api_key") if self._session_api_key: - if self.confirm("Use current Cortex API key?", default=True): - self._answers["cortex_api_key"] = self._session_api_key - else: - self.prompt("cortex_api_key", "Cortex API key", secret=True) + self._answers["cortex_api_key"] = self._session_api_key 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 current Cortex base URL [{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) + self._answers["cortex_base_url"] = self._session_base_url else: self.prompt( "cortex_base_url", @@ -219,8 +219,11 @@ def collect_prompts(self) -> None: def steps(self) -> list[tuple[str, callable]]: return [ + ("Creating Cortex Record Deploy stage template", self._create_record_deploy_template), + ("Creating Cortex Async Callback stage template", self._create_async_callback_template), ("Creating Harness pipeline", self._create_harness_pipeline), ("Creating cortex_api_key secret in Harness", self._create_harness_secret), + ("Writing Harness config to entity custom metadata", self._write_entity_custom_metadata), ("Importing Cortex trigger workflow", self._import_cortex_workflow), ] @@ -237,18 +240,28 @@ def post_steps(self) -> None: f"/pipelines/{self._answers['harness_pipeline']}/executions" ) - print() + workflow_tag = "harness-trigger-deploy" + workflows_url = f"{app_url}/admin/workflows" + entity_url = cortex_url + + print(f"\nTo trigger a deploy manually later:") + print(f" CLI: cortex workflows run -t {workflow_tag} --scope ENTITY --entity {entity_tag}") + print(f" UI: {_hyperlink(entity_url, entity_tag)} \u2192 Workflows tab \u2192 Solution: Trigger Harness Deploy \u2192 Run") + + print(f"\n{_hyperlink(workflows_url, 'View workflows in Cortex')}") if self.confirm("Trigger a test workflow run now?", default=True): print(" Starting Cortex workflow run (waiting for Harness pipeline to complete)...") try: result = self._trigger_via_cortex_workflow() status = result.get("status", "").upper() if status == "COMPLETED": - print(" Deploy complete \u2713") + print(f" Workflow run complete \u2713") print(f" {_hyperlink(harness_pipeline_url, 'View pipeline runs in Harness')}") + self._confirm_deploy_recorded(base_url, entity_tag, cortex_url) self.mark_done("first_deploy") else: - print(f" Workflow ended with status: {status}", file=sys.stderr) + print(f" Workflow run ended with status: {status}", file=sys.stderr) + print(f" Check {_hyperlink(workflows_url, 'Cortex Workflow runs')} to investigate the cause of the failure.", 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) @@ -257,46 +270,90 @@ def post_steps(self) -> None: print(f" {_hyperlink(cortex_url)}") print(f"\nHarness pipeline: {_hyperlink(harness_pipeline_url)}") + def _confirm_deploy_recorded(self, base_url: str, entity_tag: str, entity_url: str) -> None: + """Verify the deploy was written to Cortex and print a confirmation hyperlink.""" + api_key = self._answers["cortex_api_key"] + try: + resp = requests.get( + f"{base_url}/api/v1/catalog/{entity_tag}/deploys", + headers={"Authorization": f"Bearer {api_key}"}, + params={"pageSize": 1}, + timeout=10, + ) + if resp.status_code == 200: + data = resp.json() + deploys = data if isinstance(data, list) else data.get("deploys", []) + if deploys: + print(f" Deploy recorded on entity \u2713 {_hyperlink(entity_url, entity_tag)}") + return + except Exception: + pass + print(f" Deploy may still be indexing — check {_hyperlink(entity_url, entity_tag)}") + # ── Harness pipeline creation ────────────────────────────────────────── - def _create_harness_pipeline(self) -> None: + def _create_stage_template(self, identifier: str, name: str, template_path) -> None: org = self._answers["harness_org"] project = self._answers["harness_project"] - pipeline_id = self._answers["harness_pipeline"] base = self._harness_base() + headers = {**self._harness_headers(), "Content-Type": "application/json"} - # Check whether the pipeline already exists - check = requests.get( - f"{base}/v1/orgs/{org}/projects/{project}/pipelines/{pipeline_id}", + template_yaml = ( + template_path.read_text() + .replace("orgIdentifier: default", f"orgIdentifier: {org}") + .replace("projectIdentifier: default_project", f"projectIdentifier: {project}") + ) + body = { + "identifier": identifier, + "name": name, + "version_label": "1.0", + "template_yaml": template_yaml, + } + url_base = f"{base}/v1/orgs/{org}/projects/{project}/templates" + + exists = requests.get( + f"{url_base}/{identifier}", + params={"version_label": "1.0"}, headers=self._harness_headers(), timeout=10, ) - if check.status_code == 200: + if exists.status_code == 200: return # already exists — leave it alone + resp = requests.post(url_base, headers=headers, json=body, timeout=15) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to create stage template '{name}': {resp.status_code} {resp.text}") + + def _create_record_deploy_template(self) -> None: + self._create_stage_template("cortex_record_deploy", "Cortex Record Deploy", RECORD_DEPLOY_TEMPLATE_PATH) + + def _create_async_callback_template(self) -> None: + self._create_stage_template("cortex_async_callback", "Cortex Async Callback", CALLBACK_TEMPLATE_PATH) + + def _create_harness_pipeline(self) -> None: + org = self._answers["harness_org"] + project = self._answers["harness_project"] + pipeline_id = self._answers["harness_pipeline"] + base = self._harness_base() pipeline_name = "Cortex Deploy" - # Build pipeline YAML from template — name in YAML must match name in JSON body pipeline_yaml = ( PIPELINE_TEMPLATE_PATH.read_text() .replace("identifier: cortex_deploy", f"identifier: {pipeline_id}") .replace("name: Cortex Deploy\n", f"name: {pipeline_name}\n") ) - - # Harness v1 pipeline API: JSON body with pipeline_yaml containing the full YAML - resp = requests.post( - f"{base}/v1/orgs/{org}/projects/{project}/pipelines", - headers={**self._harness_headers(), "Content-Type": "application/json"}, - json={ - "identifier": pipeline_id, - "name": pipeline_name, - "pipeline_yaml": pipeline_yaml, - }, - timeout=15, - ) - if resp.status_code not in (200, 201): - raise RuntimeError( - f"Failed to create Harness pipeline: {resp.status_code} {resp.text}" - ) + body = {"identifier": pipeline_id, "name": pipeline_name, "pipeline_yaml": pipeline_yaml} + headers = {**self._harness_headers(), "Content-Type": "application/json"} + url_base = f"{base}/v1/orgs/{org}/projects/{project}/pipelines" + + exists = requests.get(f"{url_base}/{pipeline_id}", headers=self._harness_headers(), timeout=10) + if exists.status_code == 200: + resp = requests.put(f"{url_base}/{pipeline_id}", headers=headers, json=body, timeout=15) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to update Harness pipeline: {resp.status_code} {resp.text}") + else: + resp = requests.post(url_base, headers=headers, json=body, timeout=15) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to create Harness pipeline: {resp.status_code} {resp.text}") # ── Harness secret creation ──────────────────────────────────────────── @@ -352,6 +409,35 @@ def _create_harness_secret(self) -> None: f"Failed to create Harness secret: {resp.status_code} {resp.text}" ) + # ── Cortex entity custom metadata ───────────────────────────────────── + + def _write_entity_custom_metadata(self) -> None: + """Patch the entity YAML with Harness coordinates in x-cortex-custom-metadata.""" + base_url = self._answers["cortex_base_url"].rstrip("/") + entity_tag = self._answers["entity_tag"] + yaml_content = f"""\ +openapi: "3.0.0" +info: + title: Harness Demo + x-cortex-tag: {entity_tag} + x-cortex-custom-metadata: + harness: + org: "{self._answers['harness_org']}" + project: "{self._answers['harness_project']}" + pipeline: "{self._answers['harness_pipeline']}" +""" + resp = requests.patch( + f"{base_url}/api/v1/open-api", + data=yaml_content.encode("utf-8"), + headers={ + "Authorization": f"Bearer {self._answers['cortex_api_key']}", + "Content-Type": "application/openapi;charset=UTF-8", + }, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError(f"Failed to write entity custom metadata: {resp.status_code} {resp.text}") + # ── Cortex workflow import ───────────────────────────────────────────── def _import_cortex_workflow(self) -> None: @@ -359,8 +445,10 @@ def _import_cortex_workflow(self) -> None: api_key = self._answers["cortex_api_key"] alias = self._answers["harness_integration_alias"] - yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace( - "PLACEHOLDER_INTEGRATION_ALIAS", alias + yaml_content = ( + WORKFLOW_TEMPLATE_PATH.read_text() + .replace("PLACEHOLDER_INTEGRATION_ALIAS", alias) + .replace("PLACEHOLDER_HARNESS_ACCOUNT_ID", self._answers.get("harness_account_id", "")) ) resp = requests.post( @@ -389,13 +477,8 @@ def _trigger_via_cortex_workflow(self) -> dict: workflow_tag = "harness-trigger-deploy" body = { - "scope": {"type": "GLOBAL"}, - "initialContext": { - "harness-org": self._answers["harness_org"], - "harness-project": self._answers["harness_project"], - "harness-pipeline": self._answers["harness_pipeline"], - "entity-tag": self._answers["entity_tag"], - }, + "scope": {"type": "ENTITY", "entityId": self._answers["entity_tag"]}, + "initialContext": {}, } resp = requests.post( f"{base_url}/api/v1/workflows/{workflow_tag}/runs",