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/commands/solutions.py b/cortexapps_cli/commands/solutions.py index ad3c3e9..c452dd4 100644 --- a/cortexapps_cli/commands/solutions.py +++ b/cortexapps_cli/commands/solutions.py @@ -400,10 +400,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: @@ -701,7 +701,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) 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 new file mode 100644 index 0000000..dee017e --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/README.md @@ -0,0 +1,112 @@ +--- +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 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 + +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 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 +- 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 new file mode 100644 index 0000000..b2f8c65 --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/_templates/cortex-deploy-pipeline.yaml @@ -0,0 +1,110 @@ +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 + - step: + name: Deploy Progress + identifier: deploy_progress + type: ShellScript + spec: + shell: Bash + executionTarget: {} + source: + 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 progress updates" + exit 0 + fi + + TOTAL_SECONDS=15 + INTERVAL=5 + ITERATIONS=$((TOTAL_SECONDS / INTERVAL)) + + for i in $(seq 1 $ITERATIONS); do + sleep $INTERVAL + PROGRESS=$((i * 100 / ITERATIONS)) + + 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 + value: "<+pipeline.variables.callback_url>" + - name: CORTEX_API_KEY + type: Secret + value: cortex_api_key + outputVariables: [] + 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 new file mode 100644 index 0000000..bc19ec4 --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/_templates/trigger-harness-deploy.yaml @@ -0,0 +1,177 @@ +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. 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: ENTITY +variables: + - slug: harness-org + type: STRING + defaultValue: "" + - slug: harness-project + type: STRING + defaultValue: "" + - slug: harness-pipeline + type: STRING + defaultValue: "" +runResponseTemplate: | + # 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 + + 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 + + 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: 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 new file mode 100644 index 0000000..4fa1de9 --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/catalog/harness-demo.yaml @@ -0,0 +1,14 @@ +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 + 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/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..f2d3f20 --- /dev/null +++ b/cortexapps_cli/solutions/harness-deploy/setup.py @@ -0,0 +1,520 @@ +""" +Post-install setup script for the harness-deploy solution. +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, create the deploy pipeline and cortex_api_key secret in Harness, " + "import the Cortex 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" +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" + + +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 + + # ── 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") or 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): + 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() + if isinstance(data, list): + return data + return data.get("configurations", data.get("items", [])) + except Exception: + pass + return [] + + 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 + ) + 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: + 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 _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", + hidden=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, or create one) + if self._no_prompt and self._answers.get("harness_integration_alias"): + pass # use saved alias — no need to re-fetch or re-select + else: + 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"): + self.prompt( + "harness_api_key", + "Harness API key (for creating the pipeline and secret in your project)", + env_var="HARNESS_API_KEY", + hidden=True, + ) + + # 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) + + # 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 (will be created if it doesn't exist)", + default="cortex_deploy", + ) + + # 5. Cortex entity to record deploys against + self.prompt("entity_tag", "Cortex entity tag to record deploys against", default="harness-demo") + + # 6. Cortex credentials — use CLI session silently; only prompt when running standalone + self._secret_keys.add("cortex_api_key") + if self._session_api_key: + 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: + self._answers["cortex_base_url"] = self._session_base_url + else: + self.prompt( + "cortex_base_url", + "Cortex base URL", + env_var="CORTEX_BASE_URL", + default="https://api.getcortexapp.com", + ) + + # ── Steps ────────────────────────────────────────────────────────────── + + 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), + ] + + 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}" # /admin/resources is the correct URL path (entity terminology in UI) + + 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" + ) + + 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(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 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) + + print(f"\nDone! Watch your deploy appear at:") + 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_stage_template(self, identifier: str, name: str, template_path) -> None: + org = self._answers["harness_org"] + project = self._answers["harness_project"] + base = self._harness_base() + headers = {**self._harness_headers(), "Content-Type": "application/json"} + + 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 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" + 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") + ) + 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 ──────────────────────────────────────────── + + 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 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: + 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) + .replace("PLACEHOLDER_HARNESS_ACCOUNT_ID", self._answers.get("harness_account_id", "")) + ) + + 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}" + ) + + # ── 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"] + cortex_headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + workflow_tag = "harness-trigger-deploy" + + body = { + "scope": {"type": "ENTITY", "entityId": self._answers["entity_tag"]}, + "initialContext": {}, + } + 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()