From 88b942c2370a5294e60c2dca87d5bada5461c3b9 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 14 Aug 2026 16:08:50 -0700 Subject: [PATCH 01/55] docs: add jenkins-deploy solution design spec --- ...26-08-14-jenkins-deploy-solution-design.md | 225 ++++++++++++++++++ 1 file changed, 225 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-14-jenkins-deploy-solution-design.md diff --git a/docs/superpowers/specs/2026-08-14-jenkins-deploy-solution-design.md b/docs/superpowers/specs/2026-08-14-jenkins-deploy-solution-design.md new file mode 100644 index 00000000..5be5e634 --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-jenkins-deploy-solution-design.md @@ -0,0 +1,225 @@ +# Jenkins Deploy Solution Design + +**Date:** 2026-08-14 +**Status:** Approved + +## Overview + +A Cortex CLI solution (`jenkins-deploy`) that enables customers to track Jenkins pipeline deploys in Cortex. Mirrors the `github-actions-deploy` and `harness-deploy` solutions in structure and async callback pattern. Includes optional GitHub Codespaces provisioning of a Jenkins instance via the GitHub REST API so customers can demo/explore without a pre-existing Jenkins setup. + +--- + +## Solution Components + +``` +cortexapps_cli/solutions/jenkins-deploy/ + setup.py + README.md + _templates/ + Jenkinsfile # Groovy pipeline seeded into Jenkins + trigger-jenkins-deploy.yaml # Cortex workflow definition + catalog/ + jenkins-demo.yaml # Sample Cortex service entity + scorecards/ + deploy-health.yaml # Deploy health scorecard + +.devcontainer/jenkins/ + devcontainer.json # GitHub Codespaces devcontainer definition + docker-compose.yml # Jenkins container + jenkins.yaml # JCasC — pre-configured, no setup wizard +``` + +--- + +## Setup Flow (`setup.py`) + +Extends `SolutionSetup` base class, following the same pattern as `HarnessDeploySetup`. + +### Prompts (collected via `collect_prompts`) + +1. Cortex entity tag +2. Cortex API key (secret) +3. Cortex base URL +4. "Spin up Jenkins in a GitHub Codespace? [Y/n]" + - **If Y:** GitHub PAT (secret, `codespace` scope required) + - **If N:** Jenkins URL, Jenkins username, Jenkins API token (secret) + +### Steps (executed via `steps`) + +**Codespace path (Y):** +1. `POST /repos/cortexapps/cli/codespaces` with `devcontainer_path: .devcontainer/jenkins/devcontainer.json` +2. Poll `GET /user/codespaces/{name}` until `state == "Available"` (with timeout/backoff) +3. `PATCH /user/codespaces/{name}/ports/8080/visibility` → `"public"` +4. Derive Jenkins URL: `https://{codespace_name}-8080.app.github.dev` +5. Use `admin` as Jenkins username; password is fixed in JCasC (prompted before Codespace creation and passed as environment variable, or auto-generated and printed) + +**Both paths continue:** +6. Seed Jenkins job via `POST {jenkins_url}/createItem?name=cortex-deploy` with XML job config (wrapping the Jenkinsfile as a Pipeline-from-SCM or inline script) +7. Add credentials to Jenkins credential store via REST API: + - `CORTEX_API_KEY` (secret text) + - `CORTEX_BASE_URL` (secret text) +8. Write Jenkins coordinates to entity custom metadata via Cortex API: + ```json + { "jenkins": { "url": "...", "job": "cortex-deploy", "username": "admin", "token": "..." } } + ``` +9. Import Cortex workflow (`trigger-jenkins-deploy`) +10. _(Optional)_ Trigger test deploy and poll for deploy registration in Cortex + +### State Persistence +Answers saved to `~/.cortex/solutions/jenkins-deploy.json` via base class. Secrets (PAT, API token, Cortex API key) are not persisted. + +--- + +## Cortex Workflow (`trigger-jenkins-deploy.yaml`) + +Three actions, same structure as `trigger-harness-deploy.yaml`: + +### Action 1 — Get Jenkins config +- Type: HTTP GET +- Endpoint: `GET /api/v1/catalog/{tag}/custom-data/jenkins` +- Extracts Jenkins coordinates from entity custom metadata + +### Action 2 — Parse config +- Type: JQ expression +- Extracts: `url`, `job`, `username`, `token` from custom metadata response + +### Action 3 — Trigger Jenkins build +- Type: `HTTP_REQUEST_ASYNC` +- Endpoint: `POST {url}/job/{job}/buildWithParameters` +- Auth: HTTP Basic (`username:token`, Base64-encoded, passed as `Authorization` header) +- Parameters: `callback_url` (Cortex async callback URL), `cortex_entity_tag` +- Jenkins credentials stored in entity custom metadata (simplest approach; no Cortex HTTP integration required) + +--- + +## Jenkinsfile (Groovy Pipeline) + +``` +pipeline { + agent any + parameters { + string(name: 'callback_url', defaultValue: '', description: 'Cortex async callback URL') + string(name: 'cortex_entity_tag', defaultValue: '', description: 'Cortex entity tag') + } + environment { + CORTEX_API_KEY = credentials('CORTEX_API_KEY') + CORTEX_BASE_URL = credentials('CORTEX_BASE_URL') + } + stages { + stage('Build') { + steps { + echo 'Placeholder for real deploy steps' + } + } + stage('Record Deploy') { + steps { + // POST to /api/v1/catalog/{tag}/deploys + // Captures: BUILD_URL, BUILD_ID, GIT_COMMIT, executor username, timestamp + } + } + } + post { + always { + // POST to callback_url with SUCCESS or FAILURE status + // Includes: BUILD_URL, BUILD_ID, pipeline name + } + } +} +``` + +Key points: +- `callback_url` and `cortex_entity_tag` injected as build parameters by the Cortex workflow +- Credentials fetched from Jenkins credential store (not hardcoded) +- `post { always { ... } }` ensures callback fires even on build failure +- Uses `curl` for HTTP calls (available in the Jenkins container) + +--- + +## Catalog Entity (`jenkins-demo.yaml`) + +```yaml +openapi: 3.0.1 +info: + title: Jenkins Demo + x-cortex-tag: jenkins-demo + x-cortex-type: service + x-cortex-groups: + - demo-jenkins-deploys + x-cortex-custom-metadata: + jenkins: + url: "" # filled in by setup.py + job: cortex-deploy + username: admin + token: "" # filled in by setup.py +``` + +--- + +## Scorecard (`deploy-health.yaml`) + +Tag: `jenkins-deploy-health` +Scoped to group: `demo-jenkins-deploys` +Three-level ladder identical to other deploy solutions: + +- **Bronze:** 1+ deploys in last year +- **Silver:** 1+ deploys in last 30 days +- **Gold:** 1+ deploys in last 7 days + +--- + +## Devcontainer + +### `devcontainer.json` +- Base image: `mcr.microsoft.com/devcontainers/base:ubuntu` +- Uses Docker Compose (`docker-compose.yml`) +- `postCreateCommand`: waits for Jenkins to be healthy (polls `/login`) +- Pre-installed tools: `cortex` CLI (via pip), `curl`, `jq` +- Forwarded port: 8080 (Jenkins UI) + +### `docker-compose.yml` +- Service: `jenkins` using `jenkins/jenkins:lts` official image +- Mounts JCasC config file +- Sets env vars: `JAVA_OPTS=-Djenkins.install.runSetupWizard=false`, `CASC_JENKINS_CONFIG=/var/jenkins_home/casc_configs/jenkins.yaml` +- Plugins pre-installed: `pipeline`, `http_request`, `credentials`, `git`, `configuration-as-code`, `workflow-aggregator` + +### `jenkins.yaml` (JCasC) +- Admin user: `admin` / password from env var `JENKINS_ADMIN_PASSWORD` (prompted during setup, passed at Codespace creation via `machine.env`) +- Security realm: local (username/password) +- Authorization: logged-in users can do anything (demo simplicity) +- Setup wizard: disabled via `JAVA_OPTS` + +--- + +## Credentials Storage Design + +Jenkins credentials for the Cortex workflow to authenticate when triggering builds are stored in **entity custom metadata** (`x-cortex-custom-metadata.jenkins.token`). This avoids requiring a Cortex HTTP integration and keeps the setup self-contained. The token is a Jenkins API token (not the user password). + +This is intentionally a demo-friendly tradeoff. The README will note that production deployments should use a Cortex HTTP integration with credential vaulting. + +--- + +## Error Handling + +- **Codespace creation:** timeout after N minutes with a clear message; surface GitHub API error responses +- **Jenkins not ready:** poll `/login` with exponential backoff before attempting job creation +- **Job already exists:** `createItem` returns 400 — handle with `--replace-existing` behavior (delete + recreate) +- **Test deploy:** poll Cortex deploys endpoint for up to 5 minutes; report timeout gracefully + +--- + +## Testing + +- `tests/test_jenkins.py` following the pattern of `test_catalog.py` +- Requires `JENKINS_URL`, `JENKINS_USERNAME`, `JENKINS_API_TOKEN` env vars (pointing at a real or Codespace Jenkins) +- Tests: install solution, trigger deploy, verify deploy registered in Cortex, verify scorecard evaluates correctly +- Mark serial: setup/teardown affect shared Jenkins state + +--- + +## README + +Covers: +- Architecture diagram: `Cortex Workflow → Jenkins API → Jenkinsfile → Cortex Deploys API + Callback` +- Two setup paths (Codespace vs. existing Jenkins) +- How to roll out to real services (replace placeholder Build stage, point workflow at real job) +- Production note: use Cortex HTTP integration instead of custom metadata for credentials From db0e37c33c799a61610e0482879866059e178ac4 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 14 Aug 2026 16:18:12 -0700 Subject: [PATCH 02/55] docs: add jenkins-deploy implementation plan --- .../plans/2026-08-14-jenkins-deploy.md | 1560 +++++++++++++++++ 1 file changed, 1560 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-14-jenkins-deploy.md diff --git a/docs/superpowers/plans/2026-08-14-jenkins-deploy.md b/docs/superpowers/plans/2026-08-14-jenkins-deploy.md new file mode 100644 index 00000000..816c980b --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-jenkins-deploy.md @@ -0,0 +1,1560 @@ +# Jenkins Deploy Solution Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a `jenkins-deploy` Cortex CLI solution that seeds a Jenkins pipeline, records deploys in Cortex via the deploys API, and optionally provisions a Jenkins instance in GitHub Codespaces via the GitHub REST API. + +**Architecture:** Mirrors the `harness-deploy` solution structure — `setup.py` extends `SolutionSetup`, prompts for credentials, seeds a Jenkins job via the Jenkins REST API, writes Jenkins coordinates to entity custom metadata, and imports a Cortex workflow that triggers builds via HTTP. A `.devcontainer/jenkins/` directory in the repo root enables one-click Jenkins provisioning in GitHub Codespaces via `gh` or the GitHub API. + +**Tech Stack:** Python 3.11+, `requests`, Jenkins REST API, GitHub Codespaces REST API, Jenkins Configuration as Code (JCasC), Groovy (Jenkinsfile), Cortex Workflows YAML + +**Spec:** `docs/superpowers/specs/2026-08-14-jenkins-deploy-solution-design.md` + +## Global Constraints + +- Python 3.11+ (match project minimum from `pyproject.toml`) +- Follow `SolutionSetup` base class contract exactly: `solution_tag`, `collect_prompts()`, `steps()`, `post_steps()` +- All secrets use `secret=True` in `self.prompt()` — never persisted to JSON +- Jenkins admin credentials for demo: username `admin`, password `cortex-demo` (fixed in JCasC) +- Jenkins job name: `cortex-deploy` (default, user-overridable) +- Entity custom metadata key: `jenkins` with fields `url`, `job`, `username`, `token` +- Cortex workflow tag: `jenkins-trigger-deploy` +- Solution tag: `jenkins-deploy` +- Scorecard tag: `jenkins-deploy-health`, group filter: `demo-jenkins-deploys` +- No native Cortex Jenkins integration — Cortex workflow uses direct HTTP with Basic auth from custom metadata +- State file: `~/.cortex/solutions/jenkins-deploy.json` (handled by base class) +- Test pattern: `importlib.util.spec_from_file_location` to load `setup.py`, mock `requests.*` +- Test file: `tests/test_jenkins_deploy_setup.py` + +--- + +## File Map + +**Create:** +- `cortexapps_cli/solutions/jenkins-deploy/setup.py` — main setup class +- `cortexapps_cli/solutions/jenkins-deploy/README.md` — usage and adaptation guide +- `cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile` — Groovy pipeline template +- `cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml` — Cortex workflow +- `cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml` — sample entity +- `cortexapps_cli/solutions/jenkins-deploy/scorecards/deploy-health.yaml` — deploy health scorecard +- `.devcontainer/jenkins/devcontainer.json` — Codespaces devcontainer config +- `.devcontainer/jenkins/Dockerfile` — custom Jenkins image with pre-installed plugins +- `.devcontainer/jenkins/docker-compose.yml` — Jenkins + devcontainer services +- `.devcontainer/jenkins/jenkins.yaml` — JCasC config (no setup wizard, admin/cortex-demo) +- `tests/test_jenkins_deploy_setup.py` — unit tests + +**Do not modify any existing files.** + +--- + +### Task 1: Static solution files (catalog, scorecard, scaffold) + +**Files:** +- Create: `cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml` +- Create: `cortexapps_cli/solutions/jenkins-deploy/scorecards/deploy-health.yaml` +- Test: `tests/test_jenkins_deploy_setup.py` (initial scaffold only) + +**Interfaces:** +- Produces: `jenkins-demo` entity with `x-cortex-custom-metadata.jenkins` placeholder block; `jenkins-deploy-health` scorecard scoped to `demo-jenkins-deploys` + +- [ ] **Step 1: Create the catalog entity** + +```yaml +# cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml +openapi: "3.0.0" +info: + title: Jenkins Demo + x-cortex-tag: jenkins-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via Jenkins pipelines. + x-cortex-definition: {} + x-cortex-groups: + - demo-jenkins-deploys + x-cortex-custom-metadata: + jenkins: + url: PLACEHOLDER_JENKINS_URL + job: cortex-deploy + username: admin + token: PLACEHOLDER_JENKINS_TOKEN +``` + +- [ ] **Step 2: Create the scorecard** + +```yaml +# cortexapps_cli/solutions/jenkins-deploy/scorecards/deploy-health.yaml +tag: jenkins-deploy-health +name: Jenkins Deploy Health +description: Measures deployment cadence for services using Jenkins deploy tracking. Scoped to demo-jenkins-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-jenkins-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 +``` + +- [ ] **Step 3: Write a minimal test scaffold and verify it runs** + +```python +# tests/test_jenkins_deploy_setup.py +import importlib.util +import pytest +from pathlib import Path + + +def load_setup_module(): + spec = importlib.util.spec_from_file_location( + "jenkins_deploy_setup", + "cortexapps_cli/solutions/jenkins-deploy/setup.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_catalog_yaml_is_valid(): + import yaml + path = Path("cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml") + data = yaml.safe_load(path.read_text()) + assert data["info"]["x-cortex-tag"] == "jenkins-demo" + meta = data["info"]["x-cortex-custom-metadata"]["jenkins"] + assert "url" in meta + assert "job" in meta + assert "username" in meta + assert "token" in meta + + +def test_scorecard_yaml_is_valid(): + import yaml + path = Path("cortexapps_cli/solutions/jenkins-deploy/scorecards/deploy-health.yaml") + data = yaml.safe_load(path.read_text()) + assert data["tag"] == "jenkins-deploy-health" + assert len(data["rules"]) == 3 + levels = {r["level"] for r in data["rules"]} + assert levels == {"Bronze", "Silver", "Gold"} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +```bash +cd /path/to/worktree +poetry run pytest tests/test_jenkins_deploy_setup.py::test_catalog_yaml_is_valid tests/test_jenkins_deploy_setup.py::test_scorecard_yaml_is_valid -v +``` + +Expected: PASS (the setup.py import will fail — that's OK, these tests don't import it yet) + +- [ ] **Step 5: Commit** + +```bash +git add cortexapps_cli/solutions/jenkins-deploy/catalog/ \ + cortexapps_cli/solutions/jenkins-deploy/scorecards/ \ + tests/test_jenkins_deploy_setup.py +git commit -m "feat: add jenkins-deploy catalog entity and scorecard" +``` + +--- + +### Task 2: Jenkinsfile template + +**Files:** +- Create: `cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile` +- Test: `tests/test_jenkins_deploy_setup.py` (add tests) + +**Interfaces:** +- Produces: Groovy pipeline with `callback_url` and `cortex_entity_tag` string parameters; stages Build, Record Deploy; `post { always }` callback block +- Consumes: Jenkins credentials `CORTEX_API_KEY` (secret text), `CORTEX_BASE_URL` (secret text) + +- [ ] **Step 1: Write the Jenkinsfile** + +```groovy +// cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile +pipeline { + agent any + parameters { + string(name: 'callback_url', defaultValue: '', description: 'Cortex async callback URL') + string(name: 'cortex_entity_tag', defaultValue: '', description: 'Cortex entity tag') + } + environment { + CORTEX_API_KEY = credentials('CORTEX_API_KEY') + CORTEX_BASE_URL = credentials('CORTEX_BASE_URL') + } + stages { + stage('Build') { + steps { + echo 'Placeholder for real deploy steps' + // Replace this echo with your actual build and deploy commands + } + } + stage('Record Deploy') { + steps { + script { + def timestamp = sh(script: 'date -u +%Y-%m-%dT%H:%M:%SZ', returnStdout: true).trim() + def payload = """{"sha":"${env.BUILD_NUMBER}","timestamp":"${timestamp}","environment":"production","type":"DEPLOY","title":"Build #${env.BUILD_NUMBER}","deployer":{"name":"Jenkins"},"customData":{"buildUrl":"${env.BUILD_URL}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" + sh """ + curl -s -f -X POST \\ + "\${CORTEX_BASE_URL}/api/v1/catalog/${params.cortex_entity_tag}/deploys" \\ + -H "Authorization: Bearer \${CORTEX_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '${payload}' || true + """ + } + } + } + } + post { + always { + script { + if (params.callback_url) { + def status = currentBuild.currentResult == 'SUCCESS' ? 'SUCCESS' : 'FAILURE' + def payload = """{"status":"${status}","message":"Jenkins pipeline ${status.toLowerCase()}","response":{"buildUrl":"${env.BUILD_URL}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" + withEnv(["CALLBACK_URL=${params.callback_url}"]) { + sh """ + curl -s -X POST "\$CALLBACK_URL" \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer \${CORTEX_API_KEY}" \\ + -d '${payload}' || true + """ + } + } + } + } + } +} +``` + +- [ ] **Step 2: Add test for Jenkinsfile structure** + +Add to `tests/test_jenkins_deploy_setup.py`: + +```python +def test_jenkinsfile_has_required_elements(): + path = Path("cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile") + content = path.read_text() + assert "callback_url" in content + assert "cortex_entity_tag" in content + assert "CORTEX_API_KEY" in content + assert "CORTEX_BASE_URL" in content + assert "/deploys" in content + assert "post {" in content + assert "always {" in content + assert "CALLBACK_URL" in content +``` + +- [ ] **Step 3: Run the test** + +```bash +poetry run pytest tests/test_jenkins_deploy_setup.py::test_jenkinsfile_has_required_elements -v +``` + +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile \ + tests/test_jenkins_deploy_setup.py +git commit -m "feat: add Jenkinsfile template with Cortex deploy recording and async callback" +``` + +--- + +### Task 3: Cortex workflow template + +**Files:** +- Create: `cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml` +- Test: `tests/test_jenkins_deploy_setup.py` (add tests) + +**Interfaces:** +- Produces: Cortex workflow YAML with four actions: GET custom data, JQ parse (extracts url/job/auth), SET_VARIABLES, HTTP_REQUEST_ASYNC to Jenkins +- Consumes: entity custom metadata `jenkins.url`, `jenkins.job`, `jenkins.username`, `jenkins.token` + +- [ ] **Step 1: Write the workflow YAML** + +```yaml +# cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml +name: "Solution: Trigger Jenkins Deploy" +tag: jenkins-trigger-deploy +description: | + Triggers a Jenkins pipeline and waits for it to report completion back to Cortex, + registering a deploy event on the target entity. No inputs required — Jenkins + coordinates (url, job, username, token) are read from the entity's custom metadata. +isDraft: false +isRunnableViaApi: true +filter: + type: ENTITY +variables: + - slug: jenkins-url + type: STRING + defaultValue: "" + - slug: jenkins-job + type: STRING + defaultValue: "" + - slug: jenkins-auth + type: STRING + defaultValue: "" +runResponseTemplate: | + # Jenkins Deploy — Complete + + **Job:** [{{variables.jenkins-job}}]({{variables.jenkins-url}}/job/{{variables.jenkins-job}}) + + **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 Jenkins and waited for it to finish. + + **1. Cortex read the entity's Jenkins configuration** + + The workflow fetched `x-cortex-custom-metadata.jenkins` from this entity to determine + which Jenkins instance and job to trigger — no manual input required. + + **2. Cortex triggered the Jenkins build** + + It called the Jenkins `buildWithParameters` API, passing a one-time callback URL as + the `callback_url` build parameter and the entity tag as `cortex_entity_tag`. + + **3. Jenkins ran the pipeline** + + The `cortex-deploy` pipeline runs two stages: + + - **Build** — your deploy steps (replace `echo "Placeholder"` with your real commands) + + - **Record Deploy** — POSTs a deploy event to `/api/v1/catalog/{tag}/deploys`, recording + build number, URL, and job name. This feeds the Deploy Health scorecard. + + The `post { always { ... } }` block POSTs the final status (SUCCESS/FAILURE) to the + callback URL — which is how Cortex knows the workflow run is done. + + **4. Cortex received the callback** + + When Jenkins posted to the callback URL, Cortex marked this workflow run complete. + + --- + + ## Adapting this to your own pipelines + + 1. Replace `echo 'Placeholder for real deploy steps'` in the Build stage with your actual deploy commands + + 2. Add the Record Deploy and callback steps to any existing Jenkinsfile — they only need + the `CORTEX_API_KEY` and `CORTEX_BASE_URL` credentials plus the two build parameters + + 3. Add `x-cortex-custom-metadata.jenkins` to your entity's catalog YAML with + `url`, `job`, `username`, and `token` fields pointing at your real Jenkins job +actions: +- name: Get Jenkins config + slug: get-jenkins-config + schema: + type: HTTP_REQUEST + httpMethod: GET + url: "https://api.getcortexapp.com/api/v1/catalog/{{context.entity.tag}}/custom-data/jenkins" + headers: + Authorization: "Bearer {{&context.secrets.cortex_api_key}}" + Content-Type: application/json + integration: null + integrationAlias: null + outgoingActions: + - parse-jenkins-config + isRootAction: true +- name: Parse Jenkins config + slug: parse-jenkins-config + schema: + type: JQ + expression: | + .actions."get-jenkins-config".outputs.body.value as $j | + if ($j == null or $j.url == null) then + error("No Jenkins configuration found. Add x-cortex-custom-metadata.jenkins with url, job, username, and token to this entity.") + else + { + url: $j.url, + job: $j.job, + auth: ("Basic " + (($j.username + ":" + $j.token) | @base64)) + } + end + outgoingActions: + - set-variables + isRootAction: false +- name: Set variables + slug: set-variables + schema: + type: SET_VARIABLES + variables: + - slug: jenkins-url + source: + path: actions.parse-jenkins-config.outputs.result.url + type: REFERENCE + - slug: jenkins-job + source: + path: actions.parse-jenkins-config.outputs.result.job + type: REFERENCE + - slug: jenkins-auth + source: + path: actions.parse-jenkins-config.outputs.result.auth + type: REFERENCE + outgoingActions: + - trigger-deploy + isRootAction: false +- name: Trigger Jenkins Build + slug: trigger-deploy + schema: + type: HTTP_REQUEST_ASYNC + httpMethod: POST + url: "{{variables.jenkins-url}}/job/{{variables.jenkins-job}}/buildWithParameters?callback_url={{{callbackUrl}}}&cortex_entity_tag={{context.entity.tag}}" + integration: null + integrationAlias: null + headers: + Authorization: "{{variables.jenkins-auth}}" + Content-Type: application/x-www-form-urlencoded + timeoutInSeconds: 300 + outgoingActions: [] + isRootAction: false +``` + +- [ ] **Step 2: Add test for workflow YAML structure** + +Add to `tests/test_jenkins_deploy_setup.py`: + +```python +def test_workflow_yaml_is_valid(): + import yaml + path = Path("cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml") + data = yaml.safe_load(path.read_text()) + assert data["tag"] == "jenkins-trigger-deploy" + slugs = {a["slug"] for a in data["actions"]} + assert slugs == {"get-jenkins-config", "parse-jenkins-config", "set-variables", "trigger-deploy"} + root_actions = [a for a in data["actions"] if a["isRootAction"]] + assert len(root_actions) == 1 + async_action = next(a for a in data["actions"] if a["slug"] == "trigger-deploy") + assert async_action["schema"]["type"] == "HTTP_REQUEST_ASYNC" + assert "buildWithParameters" in async_action["schema"]["url"] + assert "@base64" in data["actions"][1]["schema"]["expression"] +``` + +- [ ] **Step 3: Run the test** + +```bash +poetry run pytest tests/test_jenkins_deploy_setup.py::test_workflow_yaml_is_valid -v +``` + +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml \ + tests/test_jenkins_deploy_setup.py +git commit -m "feat: add Cortex workflow template for triggering Jenkins deploys" +``` + +--- + +### Task 4: Devcontainer files + +**Files:** +- Create: `.devcontainer/jenkins/Dockerfile` +- Create: `.devcontainer/jenkins/docker-compose.yml` +- Create: `.devcontainer/jenkins/jenkins.yaml` +- Create: `.devcontainer/jenkins/devcontainer.json` + +No unit tests — these are infrastructure config files verified by Codespace smoke test in README. + +**Interfaces:** +- Produces: A Codespace devcontainer that boots Jenkins at port 8080 with admin/cortex-demo credentials, no setup wizard, required plugins pre-installed + +- [ ] **Step 1: Write the Dockerfile** + +Jenkins LTS with required plugins pre-installed via `jenkins-plugin-cli`: + +```dockerfile +# .devcontainer/jenkins/Dockerfile +FROM jenkins/jenkins:lts-jdk17 + +USER root +RUN apt-get update && apt-get install -y curl jq && rm -rf /var/lib/apt/lists/* +USER jenkins + +# Pre-install plugins: pipeline, credentials, JCasC, Plain Credentials binding +RUN jenkins-plugin-cli --plugins \ + workflow-aggregator \ + pipeline-model-definition \ + configuration-as-code \ + plain-credentials \ + credentials-binding \ + git \ + http_request \ + build-user-vars-plugin + +ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false" +ENV CASC_JENKINS_CONFIG="/var/jenkins_home/casc_configs/jenkins.yaml" +``` + +- [ ] **Step 2: Write the JCasC config** + +```yaml +# .devcontainer/jenkins/jenkins.yaml +jenkins: + numExecutors: 2 + securityRealm: + local: + allowsSignup: false + users: + - id: "admin" + password: "cortex-demo" + authorizationStrategy: + loggedInUsersCanDoAnything: + allowAnonymousRead: false + remotingSecurity: + enabled: true +unclassified: + location: + url: "" +``` + +- [ ] **Step 3: Write docker-compose.yml** + +Two services: `jenkins` (the Jenkins server) and `devcontainer` (the VS Code workspace): + +```yaml +# .devcontainer/jenkins/docker-compose.yml +version: "3.8" +services: + devcontainer: + image: mcr.microsoft.com/devcontainers/base:ubuntu-22.04 + volumes: + - ../..:/workspace:cached + command: sleep infinity + + jenkins: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + volumes: + - ./jenkins.yaml:/var/jenkins_home/casc_configs/jenkins.yaml + - jenkins_home:/var/jenkins_home + +volumes: + jenkins_home: +``` + +- [ ] **Step 4: Write devcontainer.json** + +```json +{ + "name": "Cortex Jenkins Demo", + "dockerComposeFile": "docker-compose.yml", + "service": "devcontainer", + "workspaceFolder": "/workspace", + "postCreateCommand": "pip install cortexapps-cli && bash -c 'until curl -s -o /dev/null -w \"%{http_code}\" http://jenkins:8080/login | grep -q 200; do echo \"Waiting for Jenkins...\"; sleep 5; done; echo \"Jenkins is ready at port 8080\"'", + "forwardPorts": [8080], + "portsAttributes": { + "8080": { + "label": "Jenkins UI", + "onAutoForward": "notify" + } + }, + "remoteUser": "vscode" +} +``` + +- [ ] **Step 5: Commit** + +```bash +git add .devcontainer/jenkins/ +git commit -m "feat: add Jenkins devcontainer for GitHub Codespaces" +``` + +--- + +### Task 5: setup.py — class scaffold + prompts + Codespace API helpers + +**Files:** +- Create: `cortexapps_cli/solutions/jenkins-deploy/setup.py` (partial — prompts + Codespace helpers only) +- Test: `tests/test_jenkins_deploy_setup.py` (add tests) + +**Interfaces:** +- Produces: + - `JenkinsDeploySetup(state_dir, cortex_api_key, cortex_base_url, no_prompt)` class + - `collect_prompts()` — sets `_answers` keys: `cortex_api_key`, `cortex_base_url`, `entity_tag`, `use_codespace`, `github_pat`, `jenkins_url`, `jenkins_username`, `jenkins_token`, `jenkins_job` + - `_create_codespace() -> str` — returns codespace name + - `_wait_for_codespace(name: str) -> None` — polls until `state == "Available"` + - `_expose_jenkins_port(name: str) -> str` — makes port 8080 public, returns Jenkins URL +- Consumes: `SolutionSetup` base class from `cortexapps_cli/solutions/_lib/setup_base.py` + +- [ ] **Step 1: Write failing tests for Codespace helpers** + +Add to `tests/test_jenkins_deploy_setup.py`: + +```python +@pytest.fixture +def mod(): + return load_setup_module() + +@pytest.fixture +def setup(mod, tmp_path): + instance = mod.JenkinsDeploySetup(state_dir=tmp_path) + instance._answers = { + "github_pat": "ghp_test", + "jenkins_url": "http://jenkins.example.com:8080", + "jenkins_username": "admin", + "jenkins_token": "cortex-demo", + "jenkins_job": "cortex-deploy", + "entity_tag": "jenkins-demo", + "cortex_api_key": "crt_testkey", + "cortex_base_url": "https://api.getcortexapp.com", + } + return instance + +def test_create_codespace_returns_name(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=201) + resp.json.return_value = {"name": "cortexapps-cli-abc123"} + with patch("requests.post", return_value=resp): + name = setup._create_codespace() + assert name == "cortexapps-cli-abc123" + +def test_create_codespace_raises_on_failure(setup): + from unittest.mock import patch, MagicMock + import pytest + resp = MagicMock(status_code=422) + resp.text = "Unprocessable Entity" + with patch("requests.post", return_value=resp): + with pytest.raises(RuntimeError, match="Failed to create Codespace"): + setup._create_codespace() + +def test_expose_jenkins_port_returns_url(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=200) + with patch("requests.patch", return_value=resp): + url = setup._expose_jenkins_port("my-codespace-abc") + assert url == "https://my-codespace-abc-8080.app.github.dev" + +def test_wait_for_codespace_polls_until_available(setup): + from unittest.mock import patch, MagicMock + pending = MagicMock(status_code=200) + pending.json.return_value = {"state": "Starting"} + ready = MagicMock(status_code=200) + ready.json.return_value = {"state": "Available"} + with patch("requests.get", side_effect=[pending, ready]), \ + patch("time.sleep"): + setup._wait_for_codespace("my-codespace-abc") # should not raise +``` + +- [ ] **Step 2: Run tests to see them fail** + +```bash +poetry run pytest tests/test_jenkins_deploy_setup.py::test_create_codespace_returns_name -v +``` + +Expected: FAIL with `ModuleNotFoundError` or `AttributeError` (setup.py doesn't exist yet) + +- [ ] **Step 3: Write setup.py with class scaffold, prompts, and Codespace helpers** + +```python +# cortexapps_cli/solutions/jenkins-deploy/setup.py +""" +Post-install setup script for the jenkins-deploy solution. +Wires up Jenkins credentials, creates the pipeline job in Jenkins, +imports the Cortex async workflow, and optionally triggers a test run. +Run via: cortex solutions post-install -s jenkins-deploy +""" + +SETUP_DESCRIPTION = ( + "This solution includes a setup script that will configure your Jenkins " + "job in Cortex, create the deploy pipeline in Jenkins, 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-jenkins-deploy.yaml" +JENKINSFILE_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "Jenkinsfile" + +GITHUB_API = "https://api.github.com" +CODESPACE_REPO = "cortexapps/cli" +DEVCONTAINER_PATH = ".devcontainer/jenkins/devcontainer.json" +JENKINS_PORT = 8080 +JENKINS_DEFAULT_USERNAME = "admin" +JENKINS_DEFAULT_TOKEN = "cortex-demo" + + +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 JenkinsDeploySetup(SolutionSetup): + solution_tag = "jenkins-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 + + # ── GitHub Codespaces API helpers ────────────────────────────────────── + + def _gh_headers(self) -> dict: + return { + "Authorization": f"Bearer {self._answers['github_pat']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + def _create_codespace(self) -> str: + """Create a Codespace from this repo's Jenkins devcontainer. Returns codespace name.""" + resp = requests.post( + f"{GITHUB_API}/repos/{CODESPACE_REPO}/codespaces", + headers=self._gh_headers(), + json={ + "ref": "main", + "devcontainer_path": DEVCONTAINER_PATH, + "machine": "basicLinux32gb", + }, + timeout=30, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to create Codespace: {resp.status_code} {resp.text}" + ) + name = resp.json()["name"] + print(f" Codespace '{name}' provisioning...") + return name + + def _wait_for_codespace(self, name: str, timeout_secs: int = 300) -> None: + """Poll until the Codespace state is Available.""" + terminal_states = {"Available", "Failed", "Deleted"} + start = time.time() + dots = 0 + while time.time() - start < timeout_secs: + time.sleep(10) + resp = requests.get( + f"{GITHUB_API}/user/codespaces/{name}", + headers=self._gh_headers(), + timeout=15, + ) + resp.raise_for_status() + state = resp.json().get("state", "") + dots += 1 + print(f"\r Waiting for Codespace{'.' * (dots % 4)} ", end="", flush=True) + if state == "Available": + print() + return + if state in terminal_states: + raise RuntimeError(f"Codespace ended in unexpected state: {state}") + raise TimeoutError(f"Codespace did not become Available within {timeout_secs}s") + + def _expose_jenkins_port(self, name: str) -> str: + """Make Codespace port 8080 public. Returns the public Jenkins URL.""" + resp = requests.patch( + f"{GITHUB_API}/user/codespaces/{name}/ports/{JENKINS_PORT}/visibility", + headers=self._gh_headers(), + json={"visibility": "public"}, + timeout=15, + ) + if resp.status_code not in (200, 204): + raise RuntimeError( + f"Failed to expose Jenkins port: {resp.status_code} {resp.text}" + ) + return f"https://{name}-{JENKINS_PORT}.app.github.dev" + + # ── Prompts ──────────────────────────────────────────────────────────── + + def collect_prompts(self) -> None: + # Cortex entity + self.prompt("entity_tag", "Cortex entity tag to record deploys against", default="jenkins-demo") + + # Cortex credentials + 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", + ) + + # Jenkins source: Codespace or existing instance + use_codespace = self.confirm( + "Spin up a Jenkins instance in GitHub Codespaces?", default=True + ) + self._answers["use_codespace"] = use_codespace + + if use_codespace: + self.prompt( + "github_pat", + "GitHub Personal Access Token (needs 'codespace' scope)", + env_var="GITHUB_PAT", + secret=True, + ) + # Jenkins URL is determined after Codespace creation (in steps) + self._answers["jenkins_username"] = JENKINS_DEFAULT_USERNAME + self._answers["jenkins_token"] = JENKINS_DEFAULT_TOKEN + self._answers.setdefault("jenkins_job", "cortex-deploy") + else: + self.prompt("jenkins_url", "Jenkins base URL (e.g. https://jenkins.example.com)") + self.prompt("jenkins_username", "Jenkins username", default="admin") + self.prompt("jenkins_token", "Jenkins API token or password", secret=True) + self.prompt("jenkins_job", "Jenkins job name (will be created if missing)", default="cortex-deploy") + + +def main(**kwargs): + JenkinsDeploySetup(**kwargs).run() + + +if __name__ == "__main__": + main() +``` + +- [ ] **Step 4: Run the Codespace helper tests** + +```bash +poetry run pytest tests/test_jenkins_deploy_setup.py::test_create_codespace_returns_name \ + tests/test_jenkins_deploy_setup.py::test_create_codespace_raises_on_failure \ + tests/test_jenkins_deploy_setup.py::test_expose_jenkins_port_returns_url \ + tests/test_jenkins_deploy_setup.py::test_wait_for_codespace_polls_until_available -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add cortexapps_cli/solutions/jenkins-deploy/setup.py \ + tests/test_jenkins_deploy_setup.py +git commit -m "feat: add JenkinsDeploySetup class with prompts and Codespace provisioning" +``` + +--- + +### Task 6: setup.py — Jenkins API helpers + +**Files:** +- Modify: `cortexapps_cli/solutions/jenkins-deploy/setup.py` (add Jenkins helpers) +- Test: `tests/test_jenkins_deploy_setup.py` (add tests) + +**Interfaces:** +- Produces: + - `_jenkins_auth() -> tuple[str, str]` — returns `(username, token)` for `requests` auth param + - `_wait_for_jenkins() -> None` — polls `GET {url}/login` until HTTP 200 + - `_create_jenkins_job() -> None` — POST to `/createItem` with XML wrapping the Jenkinsfile; skips if job exists + - `_add_jenkins_credential(credential_id: str, secret: str, description: str) -> None` — POST to credentials API; skips if exists +- Consumes: `_answers["jenkins_url"]`, `_answers["jenkins_username"]`, `_answers["jenkins_token"]`, `_answers["jenkins_job"]`, `JENKINSFILE_TEMPLATE_PATH` + +- [ ] **Step 1: Write failing tests** + +Add to `tests/test_jenkins_deploy_setup.py`: + +```python +def test_jenkins_auth(setup): + assert setup._jenkins_auth() == ("admin", "cortex-demo") + +def test_wait_for_jenkins_polls_until_200(setup): + from unittest.mock import patch, MagicMock + fail = MagicMock(status_code=503) + ok = MagicMock(status_code=200) + with patch("requests.get", side_effect=[fail, ok]), \ + patch("time.sleep"): + setup._wait_for_jenkins() # should not raise + +def test_create_jenkins_job_skips_if_exists(setup): + from unittest.mock import patch, MagicMock + exists_resp = MagicMock(status_code=200) + with patch("requests.get", return_value=exists_resp) as mock_get, \ + patch("requests.post") as mock_post: + setup._create_jenkins_job() + mock_get.assert_called_once() + mock_post.assert_not_called() + +def test_create_jenkins_job_creates_when_missing(setup): + from unittest.mock import patch, MagicMock + missing_resp = MagicMock(status_code=404) + created_resp = MagicMock(status_code=200) + with patch("requests.get", return_value=missing_resp), \ + patch("requests.post", return_value=created_resp) as mock_post: + setup._create_jenkins_job() + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + assert "application/xml" in call_kwargs.kwargs.get("headers", {}).get("Content-Type", "") + +def test_add_jenkins_credential_skips_if_exists(setup): + from unittest.mock import patch, MagicMock + exists_resp = MagicMock(status_code=200) + with patch("requests.get", return_value=exists_resp) as mock_get, \ + patch("requests.post") as mock_post: + setup._add_jenkins_credential("CORTEX_API_KEY", "secret", "Cortex API key") + mock_get.assert_called_once() + mock_post.assert_not_called() + +def test_add_jenkins_credential_creates_when_missing(setup): + from unittest.mock import patch, MagicMock + missing_resp = MagicMock(status_code=404) + created_resp = MagicMock(status_code=200) + with patch("requests.get", return_value=missing_resp), \ + patch("requests.post", return_value=created_resp) as mock_post: + setup._add_jenkins_credential("CORTEX_API_KEY", "secret", "Cortex API key") + mock_post.assert_called_once() +``` + +- [ ] **Step 2: Run tests to see them fail** + +```bash +poetry run pytest tests/test_jenkins_deploy_setup.py::test_jenkins_auth \ + tests/test_jenkins_deploy_setup.py::test_create_jenkins_job_skips_if_exists -v +``` + +Expected: FAIL with `AttributeError` (methods not yet implemented) + +- [ ] **Step 3: Add Jenkins helpers to setup.py** + +Add these methods to the `JenkinsDeploySetup` class, after `collect_prompts`: + +```python + # ── Jenkins API helpers ──────────────────────────────────────────────── + + def _jenkins_url(self) -> str: + return self._answers["jenkins_url"].rstrip("/") + + def _jenkins_auth(self) -> tuple: + return (self._answers["jenkins_username"], self._answers["jenkins_token"]) + + def _get_job_xml(self) -> str: + """Return Jenkins job config.xml with the Jenkinsfile embedded in CDATA.""" + jenkinsfile = JENKINSFILE_TEMPLATE_PATH.read_text() + return f"""\ + + + Cortex Deploy Pipeline — records deploys in Cortex and posts async callback + false + + + + + callback_url + + Cortex async callback URL + false + + + cortex_entity_tag + + Cortex entity tag + false + + + + + + + true + + + false +""" + + def _wait_for_jenkins(self, timeout_secs: int = 120) -> None: + """Poll Jenkins /login until it returns HTTP 200.""" + url = f"{self._jenkins_url()}/login" + start = time.time() + dots = 0 + while time.time() - start < timeout_secs: + try: + resp = requests.get(url, timeout=5) + if resp.status_code == 200: + return + except requests.exceptions.RequestException: + pass + time.sleep(5) + dots += 1 + print(f"\r Waiting for Jenkins{'.' * (dots % 4)} ", end="", flush=True) + raise TimeoutError(f"Jenkins did not respond within {timeout_secs}s at {url}") + + def _create_jenkins_job(self) -> None: + """Create the cortex-deploy pipeline job in Jenkins. Skips if already exists.""" + job_name = self._answers["jenkins_job"] + base = self._jenkins_url() + auth = self._jenkins_auth() + + # Check if job exists + check = requests.get( + f"{base}/job/{job_name}/api/json", + auth=auth, + timeout=10, + ) + if check.status_code == 200: + return # already exists — skip + + xml = self._get_job_xml() + resp = requests.post( + f"{base}/createItem", + params={"name": job_name}, + auth=auth, + headers={"Content-Type": "application/xml"}, + data=xml.encode("utf-8"), + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to create Jenkins job '{job_name}': {resp.status_code} {resp.text}" + ) + + def _add_jenkins_credential(self, credential_id: str, secret: str, description: str) -> None: + """Add a secret-text credential to the Jenkins global credential store. Skips if exists.""" + import json as _json + base = self._jenkins_url() + auth = self._jenkins_auth() + + # Check if credential exists + check = requests.get( + f"{base}/credentials/store/system/domain/_/credential/{credential_id}/api/json", + auth=auth, + timeout=10, + ) + if check.status_code == 200: + return # already exists + + payload = { + "": "0", + "credentials": { + "scope": "GLOBAL", + "id": credential_id, + "secret": secret, + "description": description, + "$class": "org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl", + }, + } + resp = requests.post( + f"{base}/credentials/store/system/domain/_/createCredentials", + auth=auth, + data={"json": _json.dumps(payload)}, + timeout=15, + ) + # Jenkins returns 200 or 302 on success + if resp.status_code not in (200, 201, 302): + raise RuntimeError( + f"Failed to create Jenkins credential '{credential_id}': {resp.status_code} {resp.text}" + ) +``` + +- [ ] **Step 4: Run all Jenkins helper tests** + +```bash +poetry run pytest tests/test_jenkins_deploy_setup.py::test_jenkins_auth \ + tests/test_jenkins_deploy_setup.py::test_wait_for_jenkins_polls_until_200 \ + tests/test_jenkins_deploy_setup.py::test_create_jenkins_job_skips_if_exists \ + tests/test_jenkins_deploy_setup.py::test_create_jenkins_job_creates_when_missing \ + tests/test_jenkins_deploy_setup.py::test_add_jenkins_credential_skips_if_exists \ + tests/test_jenkins_deploy_setup.py::test_add_jenkins_credential_creates_when_missing -v +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add cortexapps_cli/solutions/jenkins-deploy/setup.py \ + tests/test_jenkins_deploy_setup.py +git commit -m "feat: add Jenkins job and credential creation helpers to JenkinsDeploySetup" +``` + +--- + +### Task 7: setup.py — Cortex steps, steps(), and post_steps() + +**Files:** +- Modify: `cortexapps_cli/solutions/jenkins-deploy/setup.py` (add Cortex steps + steps() + post_steps()) +- Test: `tests/test_jenkins_deploy_setup.py` (add tests) + +**Interfaces:** +- Produces: + - `_provision_codespace() -> str` — orchestrates create + wait + expose; sets `_answers["jenkins_url"]`; returns detail string + - `_write_entity_custom_metadata() -> None` — PATCH to Cortex `/api/v1/open-api` + - `_import_cortex_workflow() -> None` — POST to Cortex `/api/v1/workflows` + - `_trigger_via_cortex_workflow() -> dict` — POST run + poll to terminal state + - `steps() -> list[tuple[str, callable]]` — ordered step list + - `post_steps() -> None` — summary + optional test trigger +- Consumes: all `_answers` keys set in Tasks 5 and 6 + +- [ ] **Step 1: Write failing tests** + +Add to `tests/test_jenkins_deploy_setup.py`: + +```python +def test_write_entity_custom_metadata(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=200) + with patch("requests.patch", return_value=resp) as mock_patch: + setup._write_entity_custom_metadata() + mock_patch.assert_called_once() + call_kwargs = mock_patch.call_args + assert "open-api" in call_kwargs.args[0] + body = call_kwargs.kwargs["data"].decode() + assert "jenkins-demo" in body + assert "jenkins_url" not in body # the value, not the key + assert "http://jenkins.example.com:8080" in body + assert "cortex-deploy" in body + +def test_import_cortex_workflow(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=201) + with patch("requests.post", return_value=resp) as mock_post: + setup._import_cortex_workflow() + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + assert "workflows" in call_kwargs.args[0] + assert call_kwargs.kwargs["headers"]["Content-Type"] == "application/yaml" + +def test_steps_returns_expected_list(setup): + setup._answers["use_codespace"] = False + step_labels = [label for label, _ in setup.steps()] + assert "Creating Jenkins deploy job" in step_labels + assert "Adding CORTEX_API_KEY credential to Jenkins" in step_labels + assert "Adding CORTEX_BASE_URL credential to Jenkins" in step_labels + assert "Writing Jenkins config to entity custom metadata" in step_labels + assert "Importing Cortex trigger workflow" in step_labels + +def test_steps_includes_codespace_when_enabled(setup): + setup._answers["use_codespace"] = True + step_labels = [label for label, _ in setup.steps()] + assert "Provisioning Jenkins in GitHub Codespaces" in step_labels + +def test_main_callable(mod): + assert callable(mod.main) +``` + +- [ ] **Step 2: Run tests to see them fail** + +```bash +poetry run pytest tests/test_jenkins_deploy_setup.py::test_write_entity_custom_metadata \ + tests/test_jenkins_deploy_setup.py::test_steps_returns_expected_list -v +``` + +Expected: FAIL with `AttributeError` + +- [ ] **Step 3: Add Cortex steps to setup.py** + +Add these methods to `JenkinsDeploySetup`, then add `steps()` and `post_steps()`: + +```python + # ── Codespace orchestration ──────────────────────────────────────────── + + def _provision_codespace(self) -> str: + """Create Codespace, wait for it to be ready, expose port, set jenkins_url.""" + name = self._create_codespace() + self._wait_for_codespace(name) + url = self._expose_jenkins_port(name) + self._answers["jenkins_url"] = url + return f"Jenkins URL: {_hyperlink(url)}" + + # ── Cortex entity custom metadata ───────────────────────────────────── + + def _write_entity_custom_metadata(self) -> None: + """Patch the entity YAML with Jenkins 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: Jenkins Demo + x-cortex-tag: {entity_tag} + x-cortex-custom-metadata: + jenkins: + url: "{self._answers['jenkins_url']}" + job: "{self._answers['jenkins_job']}" + username: "{self._answers['jenkins_username']}" + token: "{self._answers['jenkins_token']}" +""" + 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"] + yaml_content = WORKFLOW_TEMPLATE_PATH.read_text() + + 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"] + workflow_tag = "jenkins-trigger-deploy" + cortex_headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + 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, + timeout=15, + ) + 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, + timeout=10, + ) + r.raise_for_status() + status = r.json().get("status", "").upper() + dots += 1 + print(f"\r Waiting for Jenkins 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)") + + # ── Steps ────────────────────────────────────────────────────────────── + + def steps(self) -> list[tuple[str, callable]]: + step_list = [] + if self._answers.get("use_codespace"): + step_list.append(("Provisioning Jenkins in GitHub Codespaces", self._provision_codespace)) + step_list.append(("Waiting for Jenkins to be ready", self._wait_for_jenkins)) + step_list += [ + ("Creating Jenkins deploy job", self._create_jenkins_job), + ("Adding CORTEX_API_KEY credential to Jenkins", lambda: self._add_jenkins_credential( + "CORTEX_API_KEY", self._answers["cortex_api_key"], "Cortex API key" + )), + ("Adding CORTEX_BASE_URL credential to Jenkins", lambda: self._add_jenkins_credential( + "CORTEX_BASE_URL", self._answers["cortex_base_url"], "Cortex base URL" + )), + ("Writing Jenkins config to entity custom metadata", self._write_entity_custom_metadata), + ("Importing Cortex trigger workflow", self._import_cortex_workflow), + ] + return step_list + + 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"] + workflow_tag = "jenkins-trigger-deploy" + + entity_url = f"{app_url}/admin/resources?tag={entity_tag}" + workflows_url = f"{app_url}/admin/workflows" + jenkins_url = self._answers.get("jenkins_url", "") + jenkins_job = self._answers.get("jenkins_job", "cortex-deploy") + jenkins_job_url = f"{jenkins_url}/job/{jenkins_job}" if jenkins_url else "" + + 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)} → Workflows tab → Solution: Trigger Jenkins Deploy → Run") + + print(f"\n{_hyperlink(workflows_url, 'View workflows in Cortex')}") + if jenkins_job_url: + print(f"{_hyperlink(jenkins_job_url, 'View Jenkins job')}") + + if self.confirm("Trigger a test workflow run now?", default=True): + print(" Starting Cortex workflow run (waiting for Jenkins pipeline to complete)...") + try: + result = self._trigger_via_cortex_workflow() + status = result.get("status", "").upper() + if status == "COMPLETED": + print(" Workflow run complete ✓") + self._confirm_deploy_recorded(base_url, entity_tag, entity_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.", 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(entity_url)}") + if jenkins_job_url: + print(f"\nJenkins job: {_hyperlink(jenkins_job_url)}") + + def _confirm_deploy_recorded(self, base_url: str, entity_tag: str, entity_url: str) -> None: + 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 ✓ {_hyperlink(entity_url, entity_tag)}") + return + except Exception: + pass + print(f" Deploy may still be indexing — check {_hyperlink(entity_url, entity_tag)}") +``` + +- [ ] **Step 4: Run all new tests** + +```bash +poetry run pytest tests/test_jenkins_deploy_setup.py::test_write_entity_custom_metadata \ + tests/test_jenkins_deploy_setup.py::test_import_cortex_workflow \ + tests/test_jenkins_deploy_setup.py::test_steps_returns_expected_list \ + tests/test_jenkins_deploy_setup.py::test_steps_includes_codespace_when_enabled \ + tests/test_jenkins_deploy_setup.py::test_main_callable -v +``` + +Expected: PASS + +- [ ] **Step 5: Run the full test suite for this file** + +```bash +poetry run pytest tests/test_jenkins_deploy_setup.py -v +``` + +Expected: All tests PASS + +- [ ] **Step 6: Commit** + +```bash +git add cortexapps_cli/solutions/jenkins-deploy/setup.py \ + tests/test_jenkins_deploy_setup.py +git commit -m "feat: add Cortex integration steps and orchestration to JenkinsDeploySetup" +``` + +--- + +### Task 8: README + +**Files:** +- Create: `cortexapps_cli/solutions/jenkins-deploy/README.md` + +No unit tests — documentation file. + +- [ ] **Step 1: Write the README** + +```markdown +# Jenkins Deploy Solution + +Track Jenkins pipeline deploys in Cortex. Each deploy is recorded via the Cortex Deploys API +and surfaces in your entity's deploy history and on the Deploy Health scorecard. + +## How it works + +``` +Cortex Workflow → Jenkins buildWithParameters API → Jenkinsfile runs + → Stage: Record Deploy → POST /api/v1/catalog/{tag}/deploys + → post { always } → POST callback_url (SUCCESS or FAILURE) + → Cortex marks workflow run complete +``` + +The workflow uses an async callback pattern: Cortex triggers Jenkins and waits for Jenkins +to POST back when the pipeline finishes. Jenkins coordinates (URL, job, credentials) are +stored in the entity's `x-cortex-custom-metadata.jenkins` block. + +## Setup + +```bash +cortex solutions install -s jenkins-deploy +``` + +The setup script will: + +1. Ask whether to provision Jenkins in GitHub Codespaces (recommended for first-time demo) +2. Create the `cortex-deploy` pipeline job in Jenkins +3. Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` as Jenkins credentials +4. Write Jenkins coordinates to the entity's custom metadata +5. Import the **Trigger Jenkins Deploy** Cortex workflow +6. Optionally trigger a test deploy + +### GitHub Codespaces path + +Select **Y** when prompted. You'll need a GitHub Personal Access Token with `codespace` scope. + +A Codespace is provisioned from `.devcontainer/jenkins/` in this repository. Jenkins boots +with admin credentials (`admin` / `cortex-demo`) pre-configured via Jenkins Configuration +as Code (no setup wizard). Port 8080 is made publicly accessible so the Cortex workflow +can reach it. + +### Existing Jenkins path + +Select **N** when prompted. You'll need: +- Jenkins URL (e.g. `https://jenkins.example.com`) +- Jenkins username and API token (or password) + +The account needs permission to create jobs and credentials. + +## Triggering a deploy + +**Via CLI:** +```bash +cortex workflows run -t jenkins-trigger-deploy --scope ENTITY --entity jenkins-demo +``` + +**Via UI:** Open the entity in Cortex → Workflows tab → **Solution: Trigger Jenkins Deploy** → Run + +## Rolling out to your own services + +1. **Adapt the Jenkinsfile** — replace `echo 'Placeholder for real deploy steps'` in the + Build stage with your real build and deploy commands. The Record Deploy stage and + `post { always }` callback block can be added to any existing pipeline. + +2. **Add Jenkins credentials** — the pipeline requires `CORTEX_API_KEY` and `CORTEX_BASE_URL` + secret-text credentials in your Jenkins instance. + +3. **Add custom metadata** to your entity's catalog YAML: + +```yaml +x-cortex-custom-metadata: + jenkins: + url: "https://jenkins.example.com" + job: "your-pipeline-name" + username: "your-username" + token: "your-api-token" +``` + +4. The **Trigger Jenkins Deploy** workflow will pick up the new entity automatically — + no workflow changes needed. + +## Production note + +This solution stores Jenkins credentials in entity custom metadata for demo simplicity. +In production, use a Cortex HTTP integration with credential vaulting instead, and +restrict the Jenkins user to the minimum permissions needed (Build: Execute). + +## Deploy Health scorecard + +The **Jenkins Deploy Health** scorecard (`jenkins-deploy-health`) measures deploy cadence +for services in the `demo-jenkins-deploys` group: + +| Level | Requirement | +|--------|------------------------------| +| Bronze | 1+ deploy in the last year | +| Silver | 1+ deploy in the last 30 days | +| Gold | 1+ deploy in the last 7 days | + +Remove the `hasGroup("demo-jenkins-deploys")` filter to apply to all services. +``` + +- [ ] **Step 2: Commit** + +```bash +git add cortexapps_cli/solutions/jenkins-deploy/README.md +git commit -m "docs: add jenkins-deploy solution README" +``` + +--- + +## Self-Review Checklist + +After all tasks are complete, run this verification: + +```bash +# All tests pass +poetry run pytest tests/test_jenkins_deploy_setup.py -v + +# YAML files are valid +python -c "import yaml; yaml.safe_load(open('cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml'))" +python -c "import yaml; yaml.safe_load(open('cortexapps_cli/solutions/jenkins-deploy/scorecards/deploy-health.yaml'))" +python -c "import yaml; yaml.safe_load(open('cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml'))" + +# Setup module loads cleanly +python -c "import importlib.util; spec = importlib.util.spec_from_file_location('s', 'cortexapps_cli/solutions/jenkins-deploy/setup.py'); m = importlib.util.module_from_spec(spec); spec.loader.exec_module(m); print('OK')" + +# All solution files present +ls cortexapps_cli/solutions/jenkins-deploy/ +ls .devcontainer/jenkins/ +``` From 25452d70735c7303bdfa12acae0ac311f8baff9b Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 14 Aug 2026 16:21:21 -0700 Subject: [PATCH 03/55] feat: add jenkins-deploy catalog entity and scorecard --- .../jenkins-deploy/catalog/jenkins-demo.yaml | 15 ++++++ .../scorecards/deploy-health.yaml | 51 +++++++++++++++++++ tests/test_jenkins_deploy_setup.py | 35 +++++++++++++ 3 files changed, 101 insertions(+) create mode 100644 cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml create mode 100644 cortexapps_cli/solutions/jenkins-deploy/scorecards/deploy-health.yaml create mode 100644 tests/test_jenkins_deploy_setup.py diff --git a/cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml b/cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml new file mode 100644 index 00000000..d73f1867 --- /dev/null +++ b/cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml @@ -0,0 +1,15 @@ +openapi: "3.0.0" +info: + title: Jenkins Demo + x-cortex-tag: jenkins-demo + x-cortex-type: service + x-cortex-description: Sample service for demonstrating deploy tracking via Jenkins pipelines. + x-cortex-definition: {} + x-cortex-groups: + - demo-jenkins-deploys + x-cortex-custom-metadata: + jenkins: + url: PLACEHOLDER_JENKINS_URL + job: cortex-deploy + username: admin + token: PLACEHOLDER_JENKINS_TOKEN diff --git a/cortexapps_cli/solutions/jenkins-deploy/scorecards/deploy-health.yaml b/cortexapps_cli/solutions/jenkins-deploy/scorecards/deploy-health.yaml new file mode 100644 index 00000000..8ceaf379 --- /dev/null +++ b/cortexapps_cli/solutions/jenkins-deploy/scorecards/deploy-health.yaml @@ -0,0 +1,51 @@ +tag: jenkins-deploy-health +name: Jenkins Deploy Health +description: Measures deployment cadence for services using Jenkins deploy tracking. Scoped to demo-jenkins-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-jenkins-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/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py new file mode 100644 index 00000000..32c3af1c --- /dev/null +++ b/tests/test_jenkins_deploy_setup.py @@ -0,0 +1,35 @@ +import importlib.util +import pytest +from pathlib import Path + + +def load_setup_module(): + spec = importlib.util.spec_from_file_location( + "jenkins_deploy_setup", + "cortexapps_cli/solutions/jenkins-deploy/setup.py", + ) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_catalog_yaml_is_valid(): + import yaml + path = Path("cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml") + data = yaml.safe_load(path.read_text()) + assert data["info"]["x-cortex-tag"] == "jenkins-demo" + meta = data["info"]["x-cortex-custom-metadata"]["jenkins"] + assert "url" in meta + assert "job" in meta + assert "username" in meta + assert "token" in meta + + +def test_scorecard_yaml_is_valid(): + import yaml + path = Path("cortexapps_cli/solutions/jenkins-deploy/scorecards/deploy-health.yaml") + data = yaml.safe_load(path.read_text()) + assert data["tag"] == "jenkins-deploy-health" + assert len(data["rules"]) == 3 + levels = {r["level"] for r in data["rules"]} + assert levels == {"Bronze", "Silver", "Gold"} From 26e7746e72be7909f50945b123116c76dc1fa5eb Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 14 Aug 2026 16:23:06 -0700 Subject: [PATCH 04/55] feat: add Jenkinsfile template with Cortex deploy recording and async callback --- .../jenkins-deploy/_templates/Jenkinsfile | 52 +++++++++++++++++++ tests/test_jenkins_deploy_setup.py | 13 +++++ 2 files changed, 65 insertions(+) create mode 100644 cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile b/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile new file mode 100644 index 00000000..9ae41e16 --- /dev/null +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile @@ -0,0 +1,52 @@ +pipeline { + agent any + parameters { + string(name: 'callback_url', defaultValue: '', description: 'Cortex async callback URL') + string(name: 'cortex_entity_tag', defaultValue: '', description: 'Cortex entity tag') + } + environment { + CORTEX_API_KEY = credentials('CORTEX_API_KEY') + CORTEX_BASE_URL = credentials('CORTEX_BASE_URL') + } + stages { + stage('Build') { + steps { + echo 'Placeholder for real deploy steps' + // Replace this echo with your actual build and deploy commands + } + } + stage('Record Deploy') { + steps { + script { + def timestamp = sh(script: 'date -u +%Y-%m-%dT%H:%M:%SZ', returnStdout: true).trim() + def payload = """{"sha":"${env.BUILD_NUMBER}","timestamp":"${timestamp}","environment":"production","type":"DEPLOY","title":"Build #${env.BUILD_NUMBER}","deployer":{"name":"Jenkins"},"customData":{"buildUrl":"${env.BUILD_URL}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" + sh """ + curl -s -f -X POST \\ + "\${CORTEX_BASE_URL}/api/v1/catalog/${params.cortex_entity_tag}/deploys" \\ + -H "Authorization: Bearer \${CORTEX_API_KEY}" \\ + -H "Content-Type: application/json" \\ + -d '${payload}' || true + """ + } + } + } + } + post { + always { + script { + if (params.callback_url) { + def status = currentBuild.currentResult == 'SUCCESS' ? 'SUCCESS' : 'FAILURE' + def payload = """{"status":"${status}","message":"Jenkins pipeline ${status.toLowerCase()}","response":{"buildUrl":"${env.BUILD_URL}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" + withEnv(["CALLBACK_URL=${params.callback_url}"]) { + sh """ + curl -s -X POST "\$CALLBACK_URL" \\ + -H "Content-Type: application/json" \\ + -H "Authorization: Bearer \${CORTEX_API_KEY}" \\ + -d '${payload}' || true + """ + } + } + } + } + } +} diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 32c3af1c..670a0c62 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -33,3 +33,16 @@ def test_scorecard_yaml_is_valid(): assert len(data["rules"]) == 3 levels = {r["level"] for r in data["rules"]} assert levels == {"Bronze", "Silver", "Gold"} + + +def test_jenkinsfile_has_required_elements(): + path = Path("cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile") + content = path.read_text() + assert "callback_url" in content + assert "cortex_entity_tag" in content + assert "CORTEX_API_KEY" in content + assert "CORTEX_BASE_URL" in content + assert "/deploys" in content + assert "post {" in content + assert "always {" in content + assert "CALLBACK_URL" in content From cdf4b4a8a49539714c762a5040ed29bcf7f45188 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 14 Aug 2026 16:25:04 -0700 Subject: [PATCH 05/55] feat: add Cortex workflow template for triggering Jenkins deploys --- .../_templates/trigger-jenkins-deploy.yaml | 137 ++++++++++++++++++ tests/test_jenkins_deploy_setup.py | 15 ++ 2 files changed, 152 insertions(+) create mode 100644 cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml new file mode 100644 index 00000000..6c166177 --- /dev/null +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml @@ -0,0 +1,137 @@ +name: "Solution: Trigger Jenkins Deploy" +tag: jenkins-trigger-deploy +description: | + Triggers a Jenkins pipeline and waits for it to report completion back to Cortex, + registering a deploy event on the target entity. No inputs required — Jenkins + coordinates (url, job, username, token) are read from the entity's custom metadata. +isDraft: false +isRunnableViaApi: true +filter: + type: ENTITY +variables: + - slug: jenkins-url + type: STRING + defaultValue: "" + - slug: jenkins-job + type: STRING + defaultValue: "" + - slug: jenkins-auth + type: STRING + defaultValue: "" +runResponseTemplate: | + # Jenkins Deploy — Complete + + **Job:** [{{variables.jenkins-job}}]({{variables.jenkins-url}}/job/{{variables.jenkins-job}}) + + **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 Jenkins and waited for it to finish. + + **1. Cortex read the entity's Jenkins configuration** + + The workflow fetched `x-cortex-custom-metadata.jenkins` from this entity to determine + which Jenkins instance and job to trigger — no manual input required. + + **2. Cortex triggered the Jenkins build** + + It called the Jenkins `buildWithParameters` API, passing a one-time callback URL as + the `callback_url` build parameter and the entity tag as `cortex_entity_tag`. + + **3. Jenkins ran the pipeline** + + The `cortex-deploy` pipeline runs two stages: + + - **Build** — your deploy steps (replace `echo "Placeholder"` with your real commands) + + - **Record Deploy** — POSTs a deploy event to `/api/v1/catalog/{tag}/deploys`, recording + build number, URL, and job name. This feeds the Deploy Health scorecard. + + The `post { always { ... } }` block POSTs the final status (SUCCESS/FAILURE) to the + callback URL — which is how Cortex knows the workflow run is done. + + **4. Cortex received the callback** + + When Jenkins posted to the callback URL, Cortex marked this workflow run complete. + + --- + + ## Adapting this to your own pipelines + + 1. Replace `echo 'Placeholder for real deploy steps'` in the Build stage with your actual deploy commands + + 2. Add the Record Deploy and callback steps to any existing Jenkinsfile — they only need + the `CORTEX_API_KEY` and `CORTEX_BASE_URL` credentials plus the two build parameters + + 3. Add `x-cortex-custom-metadata.jenkins` to your entity's catalog YAML with + `url`, `job`, `username`, and `token` fields pointing at your real Jenkins job +actions: +- name: Get Jenkins config + slug: get-jenkins-config + schema: + type: HTTP_REQUEST + httpMethod: GET + url: "https://api.getcortexapp.com/api/v1/catalog/{{context.entity.tag}}/custom-data/jenkins" + headers: + Authorization: "Bearer {{&context.secrets.cortex_api_key}}" + Content-Type: application/json + integration: null + integrationAlias: null + outgoingActions: + - parse-jenkins-config + isRootAction: true +- name: Parse Jenkins config + slug: parse-jenkins-config + schema: + type: JQ + expression: | + .actions."get-jenkins-config".outputs.body.value as $j | + if ($j == null or $j.url == null) then + error("No Jenkins configuration found. Add x-cortex-custom-metadata.jenkins with url, job, username, and token to this entity.") + else + { + url: $j.url, + job: $j.job, + auth: ("Basic " + (($j.username + ":" + $j.token) | @base64)) + } + end + outgoingActions: + - set-variables + isRootAction: false +- name: Set variables + slug: set-variables + schema: + type: SET_VARIABLES + variables: + - slug: jenkins-url + source: + path: actions.parse-jenkins-config.outputs.result.url + type: REFERENCE + - slug: jenkins-job + source: + path: actions.parse-jenkins-config.outputs.result.job + type: REFERENCE + - slug: jenkins-auth + source: + path: actions.parse-jenkins-config.outputs.result.auth + type: REFERENCE + outgoingActions: + - trigger-deploy + isRootAction: false +- name: Trigger Jenkins Build + slug: trigger-deploy + schema: + type: HTTP_REQUEST_ASYNC + httpMethod: POST + url: "{{variables.jenkins-url}}/job/{{variables.jenkins-job}}/buildWithParameters?callback_url={{{callbackUrl}}}&cortex_entity_tag={{context.entity.tag}}" + integration: null + integrationAlias: null + headers: + Authorization: "{{variables.jenkins-auth}}" + Content-Type: application/x-www-form-urlencoded + timeoutInSeconds: 300 + outgoingActions: [] + isRootAction: false diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 670a0c62..ddab47f7 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -46,3 +46,18 @@ def test_jenkinsfile_has_required_elements(): assert "post {" in content assert "always {" in content assert "CALLBACK_URL" in content + + +def test_workflow_yaml_is_valid(): + import yaml + path = Path("cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml") + data = yaml.safe_load(path.read_text()) + assert data["tag"] == "jenkins-trigger-deploy" + slugs = {a["slug"] for a in data["actions"]} + assert slugs == {"get-jenkins-config", "parse-jenkins-config", "set-variables", "trigger-deploy"} + root_actions = [a for a in data["actions"] if a["isRootAction"]] + assert len(root_actions) == 1 + async_action = next(a for a in data["actions"] if a["slug"] == "trigger-deploy") + assert async_action["schema"]["type"] == "HTTP_REQUEST_ASYNC" + assert "buildWithParameters" in async_action["schema"]["url"] + assert "@base64" in data["actions"][1]["schema"]["expression"] From 00c2d52b45448b0a7a2a9c24490138aa653cd437 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 14 Aug 2026 16:26:42 -0700 Subject: [PATCH 06/55] feat: add Jenkins devcontainer for GitHub Codespaces --- .devcontainer/jenkins/Dockerfile | 19 +++++++++++++++++++ .devcontainer/jenkins/devcontainer.json | 15 +++++++++++++++ .devcontainer/jenkins/docker-compose.yml | 20 ++++++++++++++++++++ .devcontainer/jenkins/jenkins.yaml | 16 ++++++++++++++++ 4 files changed, 70 insertions(+) create mode 100644 .devcontainer/jenkins/Dockerfile create mode 100644 .devcontainer/jenkins/devcontainer.json create mode 100644 .devcontainer/jenkins/docker-compose.yml create mode 100644 .devcontainer/jenkins/jenkins.yaml diff --git a/.devcontainer/jenkins/Dockerfile b/.devcontainer/jenkins/Dockerfile new file mode 100644 index 00000000..931653c6 --- /dev/null +++ b/.devcontainer/jenkins/Dockerfile @@ -0,0 +1,19 @@ +FROM jenkins/jenkins:lts-jdk17 + +USER root +RUN apt-get update && apt-get install -y curl jq && rm -rf /var/lib/apt/lists/* +USER jenkins + +# Pre-install plugins: pipeline, credentials, JCasC, Plain Credentials binding +RUN jenkins-plugin-cli --plugins \ + workflow-aggregator \ + pipeline-model-definition \ + configuration-as-code \ + plain-credentials \ + credentials-binding \ + git \ + http_request \ + build-user-vars-plugin + +ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false" +ENV CASC_JENKINS_CONFIG="/var/jenkins_home/casc_configs/jenkins.yaml" diff --git a/.devcontainer/jenkins/devcontainer.json b/.devcontainer/jenkins/devcontainer.json new file mode 100644 index 00000000..bb151806 --- /dev/null +++ b/.devcontainer/jenkins/devcontainer.json @@ -0,0 +1,15 @@ +{ + "name": "Cortex Jenkins Demo", + "dockerComposeFile": "docker-compose.yml", + "service": "devcontainer", + "workspaceFolder": "/workspace", + "postCreateCommand": "pip install cortexapps-cli && bash -c 'until curl -s -o /dev/null -w \"%{http_code}\" http://jenkins:8080/login | grep -q 200; do echo \"Waiting for Jenkins...\"; sleep 5; done; echo \"Jenkins is ready at port 8080\"'", + "forwardPorts": [8080], + "portsAttributes": { + "8080": { + "label": "Jenkins UI", + "onAutoForward": "notify" + } + }, + "remoteUser": "vscode" +} diff --git a/.devcontainer/jenkins/docker-compose.yml b/.devcontainer/jenkins/docker-compose.yml new file mode 100644 index 00000000..19d81c1e --- /dev/null +++ b/.devcontainer/jenkins/docker-compose.yml @@ -0,0 +1,20 @@ +version: "3.8" +services: + devcontainer: + image: mcr.microsoft.com/devcontainers/base:ubuntu-22.04 + volumes: + - ../..:/workspace:cached + command: sleep infinity + + jenkins: + build: + context: . + dockerfile: Dockerfile + ports: + - "8080:8080" + volumes: + - ./jenkins.yaml:/var/jenkins_home/casc_configs/jenkins.yaml + - jenkins_home:/var/jenkins_home + +volumes: + jenkins_home: diff --git a/.devcontainer/jenkins/jenkins.yaml b/.devcontainer/jenkins/jenkins.yaml new file mode 100644 index 00000000..0b518ad5 --- /dev/null +++ b/.devcontainer/jenkins/jenkins.yaml @@ -0,0 +1,16 @@ +jenkins: + numExecutors: 2 + securityRealm: + local: + allowsSignup: false + users: + - id: "admin" + password: "cortex-demo" + authorizationStrategy: + loggedInUsersCanDoAnything: + allowAnonymousRead: false + remotingSecurity: + enabled: true +unclassified: + location: + url: "" From 1e2b594671388dbf2ae0e8fbb4201e48f0f87504 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 14 Aug 2026 16:29:24 -0700 Subject: [PATCH 07/55] feat: add JenkinsDeploySetup class with prompts and Codespace provisioning --- .../solutions/jenkins-deploy/setup.py | 173 ++++++++++++++++++ tests/test_jenkins_deploy_setup.py | 59 ++++++ 2 files changed, 232 insertions(+) create mode 100644 cortexapps_cli/solutions/jenkins-deploy/setup.py diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py new file mode 100644 index 00000000..c2a4605e --- /dev/null +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -0,0 +1,173 @@ +""" +Post-install setup script for the jenkins-deploy solution. +Wires up Jenkins credentials, creates the pipeline job in Jenkins, +imports the Cortex async workflow, and optionally triggers a test run. +Run via: cortex solutions post-install -s jenkins-deploy +""" + +SETUP_DESCRIPTION = ( + "This solution includes a setup script that will configure your Jenkins " + "job in Cortex, create the deploy pipeline in Jenkins, 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-jenkins-deploy.yaml" +JENKINSFILE_TEMPLATE_PATH = Path(__file__).parent / "_templates" / "Jenkinsfile" + +GITHUB_API = "https://api.github.com" +CODESPACE_REPO = "cortexapps/cli" +DEVCONTAINER_PATH = ".devcontainer/jenkins/devcontainer.json" +JENKINS_PORT = 8080 +JENKINS_DEFAULT_USERNAME = "admin" +JENKINS_DEFAULT_TOKEN = "cortex-demo" + + +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 JenkinsDeploySetup(SolutionSetup): + solution_tag = "jenkins-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 + + # ── GitHub Codespaces API helpers ────────────────────────────────────── + + def _gh_headers(self) -> dict: + return { + "Authorization": f"Bearer {self._answers['github_pat']}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + } + + def _create_codespace(self) -> str: + """Create a Codespace from this repo's Jenkins devcontainer. Returns codespace name.""" + resp = requests.post( + f"{GITHUB_API}/repos/{CODESPACE_REPO}/codespaces", + headers=self._gh_headers(), + json={ + "ref": "main", + "devcontainer_path": DEVCONTAINER_PATH, + "machine": "basicLinux32gb", + }, + timeout=30, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to create Codespace: {resp.status_code} {resp.text}" + ) + name = resp.json()["name"] + print(f" Codespace '{name}' provisioning...") + return name + + def _wait_for_codespace(self, name: str, timeout_secs: int = 300) -> None: + """Poll until the Codespace state is Available.""" + terminal_states = {"Available", "Failed", "Deleted"} + start = time.time() + dots = 0 + while time.time() - start < timeout_secs: + time.sleep(10) + resp = requests.get( + f"{GITHUB_API}/user/codespaces/{name}", + headers=self._gh_headers(), + timeout=15, + ) + resp.raise_for_status() + state = resp.json().get("state", "") + dots += 1 + print(f"\r Waiting for Codespace{'.' * (dots % 4)} ", end="", flush=True) + if state == "Available": + print() + return + if state in terminal_states: + raise RuntimeError(f"Codespace ended in unexpected state: {state}") + raise TimeoutError(f"Codespace did not become Available within {timeout_secs}s") + + def _expose_jenkins_port(self, name: str) -> str: + """Make Codespace port 8080 public. Returns the public Jenkins URL.""" + resp = requests.patch( + f"{GITHUB_API}/user/codespaces/{name}/ports/{JENKINS_PORT}/visibility", + headers=self._gh_headers(), + json={"visibility": "public"}, + timeout=15, + ) + if resp.status_code not in (200, 204): + raise RuntimeError( + f"Failed to expose Jenkins port: {resp.status_code} {resp.text}" + ) + return f"https://{name}-{JENKINS_PORT}.app.github.dev" + + # ── Prompts ──────────────────────────────────────────────────────────── + + def collect_prompts(self) -> None: + # Cortex entity + self.prompt("entity_tag", "Cortex entity tag to record deploys against", default="jenkins-demo") + + # Cortex credentials + 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", + ) + + # Jenkins source: Codespace or existing instance + use_codespace = self.confirm( + "Spin up a Jenkins instance in GitHub Codespaces?", default=True + ) + self._answers["use_codespace"] = use_codespace + + if use_codespace: + self.prompt( + "github_pat", + "GitHub Personal Access Token (needs 'codespace' scope)", + env_var="GITHUB_PAT", + secret=True, + ) + # Jenkins URL is determined after Codespace creation (in steps) + self._answers["jenkins_username"] = JENKINS_DEFAULT_USERNAME + self._answers["jenkins_token"] = JENKINS_DEFAULT_TOKEN + self._answers.setdefault("jenkins_job", "cortex-deploy") + else: + self.prompt("jenkins_url", "Jenkins base URL (e.g. https://jenkins.example.com)") + self.prompt("jenkins_username", "Jenkins username", default="admin") + self.prompt("jenkins_token", "Jenkins API token or password", secret=True) + self.prompt("jenkins_job", "Jenkins job name (will be created if missing)", default="cortex-deploy") + + # ── Steps ────────────────────────────────────────────────────────────── + + def steps(self) -> list[tuple[str, callable]]: + return [] + + +def main(**kwargs): + JenkinsDeploySetup(**kwargs).run() + + +if __name__ == "__main__": + main() diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index ddab47f7..aff46847 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -61,3 +61,62 @@ def test_workflow_yaml_is_valid(): assert async_action["schema"]["type"] == "HTTP_REQUEST_ASYNC" assert "buildWithParameters" in async_action["schema"]["url"] assert "@base64" in data["actions"][1]["schema"]["expression"] + + +@pytest.fixture +def mod(): + return load_setup_module() + + +@pytest.fixture +def setup(mod, tmp_path): + instance = mod.JenkinsDeploySetup(state_dir=tmp_path) + instance._answers = { + "github_pat": "ghp_test", + "jenkins_url": "http://jenkins.example.com:8080", + "jenkins_username": "admin", + "jenkins_token": "cortex-demo", + "jenkins_job": "cortex-deploy", + "entity_tag": "jenkins-demo", + "cortex_api_key": "crt_testkey", + "cortex_base_url": "https://api.getcortexapp.com", + } + return instance + + +def test_create_codespace_returns_name(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=201) + resp.json.return_value = {"name": "cortexapps-cli-abc123"} + with patch("requests.post", return_value=resp): + name = setup._create_codespace() + assert name == "cortexapps-cli-abc123" + + +def test_create_codespace_raises_on_failure(setup): + from unittest.mock import patch, MagicMock + import pytest + resp = MagicMock(status_code=422) + resp.text = "Unprocessable Entity" + with patch("requests.post", return_value=resp): + with pytest.raises(RuntimeError, match="Failed to create Codespace"): + setup._create_codespace() + + +def test_expose_jenkins_port_returns_url(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=200) + with patch("requests.patch", return_value=resp): + url = setup._expose_jenkins_port("my-codespace-abc") + assert url == "https://my-codespace-abc-8080.app.github.dev" + + +def test_wait_for_codespace_polls_until_available(setup): + from unittest.mock import patch, MagicMock + pending = MagicMock(status_code=200) + pending.json.return_value = {"state": "Starting"} + ready = MagicMock(status_code=200) + ready.json.return_value = {"state": "Available"} + with patch("requests.get", side_effect=[pending, ready]), \ + patch("time.sleep"): + setup._wait_for_codespace("my-codespace-abc") # should not raise From bedc73f2dfe6f7b3c26dab4a64559f08dcee88df Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 14 Aug 2026 16:32:17 -0700 Subject: [PATCH 08/55] feat: add Jenkins job and credential creation helpers to JenkinsDeploySetup --- .../solutions/jenkins-deploy/setup.py | 125 ++++++++++++++++++ tests/test_jenkins_deploy_setup.py | 55 ++++++++ 2 files changed, 180 insertions(+) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index c2a4605e..6b00c13e 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -159,6 +159,131 @@ def collect_prompts(self) -> None: self.prompt("jenkins_token", "Jenkins API token or password", secret=True) self.prompt("jenkins_job", "Jenkins job name (will be created if missing)", default="cortex-deploy") + # ── Jenkins API helpers ──────────────────────────────────────────────── + + def _jenkins_url(self) -> str: + return self._answers["jenkins_url"].rstrip("/") + + def _jenkins_auth(self) -> tuple: + return (self._answers["jenkins_username"], self._answers["jenkins_token"]) + + def _get_job_xml(self) -> str: + """Return Jenkins job config.xml with the Jenkinsfile embedded in CDATA.""" + jenkinsfile = JENKINSFILE_TEMPLATE_PATH.read_text() + return f"""\ + + + Cortex Deploy Pipeline — records deploys in Cortex and posts async callback + false + + + + + callback_url + + Cortex async callback URL + false + + + cortex_entity_tag + + Cortex entity tag + false + + + + + + + true + + + false +""" + + def _wait_for_jenkins(self, timeout_secs: int = 120) -> None: + """Poll Jenkins /login until it returns HTTP 200.""" + url = f"{self._jenkins_url()}/login" + start = time.time() + dots = 0 + while time.time() - start < timeout_secs: + try: + resp = requests.get(url, timeout=5) + if resp.status_code == 200: + return + except requests.exceptions.RequestException: + pass + time.sleep(5) + dots += 1 + print(f"\r Waiting for Jenkins{'.' * (dots % 4)} ", end="", flush=True) + raise TimeoutError(f"Jenkins did not respond within {timeout_secs}s at {url}") + + def _create_jenkins_job(self) -> None: + """Create the cortex-deploy pipeline job in Jenkins. Skips if already exists.""" + job_name = self._answers["jenkins_job"] + base = self._jenkins_url() + auth = self._jenkins_auth() + + # Check if job exists + check = requests.get( + f"{base}/job/{job_name}/api/json", + auth=auth, + timeout=10, + ) + if check.status_code == 200: + return # already exists — skip + + xml = self._get_job_xml() + resp = requests.post( + f"{base}/createItem", + params={"name": job_name}, + auth=auth, + headers={"Content-Type": "application/xml"}, + data=xml.encode("utf-8"), + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to create Jenkins job '{job_name}': {resp.status_code} {resp.text}" + ) + + def _add_jenkins_credential(self, credential_id: str, secret: str, description: str) -> None: + """Add a secret-text credential to the Jenkins global credential store. Skips if exists.""" + import json as _json + base = self._jenkins_url() + auth = self._jenkins_auth() + + # Check if credential exists + check = requests.get( + f"{base}/credentials/store/system/domain/_/credential/{credential_id}/api/json", + auth=auth, + timeout=10, + ) + if check.status_code == 200: + return # already exists + + payload = { + "": "0", + "credentials": { + "scope": "GLOBAL", + "id": credential_id, + "secret": secret, + "description": description, + "$class": "org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl", + }, + } + resp = requests.post( + f"{base}/credentials/store/system/domain/_/createCredentials", + auth=auth, + data={"json": _json.dumps(payload)}, + timeout=15, + ) + # Jenkins returns 200 or 302 on success + if resp.status_code not in (200, 201, 302): + raise RuntimeError( + f"Failed to create Jenkins credential '{credential_id}': {resp.status_code} {resp.text}" + ) + # ── Steps ────────────────────────────────────────────────────────────── def steps(self) -> list[tuple[str, callable]]: diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index aff46847..c03324b5 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -120,3 +120,58 @@ def test_wait_for_codespace_polls_until_available(setup): with patch("requests.get", side_effect=[pending, ready]), \ patch("time.sleep"): setup._wait_for_codespace("my-codespace-abc") # should not raise + + +def test_jenkins_auth(setup): + assert setup._jenkins_auth() == ("admin", "cortex-demo") + + +def test_wait_for_jenkins_polls_until_200(setup): + from unittest.mock import patch, MagicMock + fail = MagicMock(status_code=503) + ok = MagicMock(status_code=200) + with patch("requests.get", side_effect=[fail, ok]), \ + patch("time.sleep"): + setup._wait_for_jenkins() # should not raise + + +def test_create_jenkins_job_skips_if_exists(setup): + from unittest.mock import patch, MagicMock + exists_resp = MagicMock(status_code=200) + with patch("requests.get", return_value=exists_resp) as mock_get, \ + patch("requests.post") as mock_post: + setup._create_jenkins_job() + mock_get.assert_called_once() + mock_post.assert_not_called() + + +def test_create_jenkins_job_creates_when_missing(setup): + from unittest.mock import patch, MagicMock + missing_resp = MagicMock(status_code=404) + created_resp = MagicMock(status_code=200) + with patch("requests.get", return_value=missing_resp), \ + patch("requests.post", return_value=created_resp) as mock_post: + setup._create_jenkins_job() + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + assert "application/xml" in call_kwargs.kwargs.get("headers", {}).get("Content-Type", "") + + +def test_add_jenkins_credential_skips_if_exists(setup): + from unittest.mock import patch, MagicMock + exists_resp = MagicMock(status_code=200) + with patch("requests.get", return_value=exists_resp) as mock_get, \ + patch("requests.post") as mock_post: + setup._add_jenkins_credential("CORTEX_API_KEY", "secret", "Cortex API key") + mock_get.assert_called_once() + mock_post.assert_not_called() + + +def test_add_jenkins_credential_creates_when_missing(setup): + from unittest.mock import patch, MagicMock + missing_resp = MagicMock(status_code=404) + created_resp = MagicMock(status_code=200) + with patch("requests.get", return_value=missing_resp), \ + patch("requests.post", return_value=created_resp) as mock_post: + setup._add_jenkins_credential("CORTEX_API_KEY", "secret", "Cortex API key") + mock_post.assert_called_once() From 2235df33e1d4cea6239b4878fcfad87f8bd2699b Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 14 Aug 2026 16:35:18 -0700 Subject: [PATCH 09/55] feat: add Cortex integration steps and orchestration to JenkinsDeploySetup --- .../solutions/jenkins-deploy/setup.py | 187 +++++++++++++++++- tests/test_jenkins_deploy_setup.py | 46 +++++ 2 files changed, 232 insertions(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 6b00c13e..80f49f61 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -284,10 +284,195 @@ def _add_jenkins_credential(self, credential_id: str, secret: str, description: f"Failed to create Jenkins credential '{credential_id}': {resp.status_code} {resp.text}" ) + # ── Codespace orchestration ──────────────────────────────────────────── + + def _provision_codespace(self) -> str: + """Create Codespace, wait for it to be ready, expose port, set jenkins_url.""" + name = self._create_codespace() + self._wait_for_codespace(name) + url = self._expose_jenkins_port(name) + self._answers["jenkins_url"] = url + return f"Jenkins URL: {_hyperlink(url)}" + + # ── Cortex entity custom metadata ───────────────────────────────────── + + def _write_entity_custom_metadata(self) -> None: + """Patch the entity YAML with Jenkins 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: Jenkins Demo + x-cortex-tag: {entity_tag} + x-cortex-custom-metadata: + jenkins: + url: "{self._answers['jenkins_url']}" + job: "{self._answers['jenkins_job']}" + username: "{self._answers['jenkins_username']}" + token: "{self._answers['jenkins_token']}" +""" + 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"] + yaml_content = WORKFLOW_TEMPLATE_PATH.read_text() + + 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"] + workflow_tag = "jenkins-trigger-deploy" + cortex_headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + 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, + timeout=15, + ) + 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, + timeout=10, + ) + r.raise_for_status() + status = r.json().get("status", "").upper() + dots += 1 + print(f"\r Waiting for Jenkins 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)") + # ── Steps ────────────────────────────────────────────────────────────── def steps(self) -> list[tuple[str, callable]]: - return [] + step_list = [] + if self._answers.get("use_codespace"): + step_list.append(("Provisioning Jenkins in GitHub Codespaces", self._provision_codespace)) + step_list.append(("Waiting for Jenkins to be ready", self._wait_for_jenkins)) + step_list += [ + ("Creating Jenkins deploy job", self._create_jenkins_job), + ("Adding CORTEX_API_KEY credential to Jenkins", lambda: self._add_jenkins_credential( + "CORTEX_API_KEY", self._answers["cortex_api_key"], "Cortex API key" + )), + ("Adding CORTEX_BASE_URL credential to Jenkins", lambda: self._add_jenkins_credential( + "CORTEX_BASE_URL", self._answers["cortex_base_url"], "Cortex base URL" + )), + ("Writing Jenkins config to entity custom metadata", self._write_entity_custom_metadata), + ("Importing Cortex trigger workflow", self._import_cortex_workflow), + ] + return step_list + + 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"] + workflow_tag = "jenkins-trigger-deploy" + + entity_url = f"{app_url}/admin/resources?tag={entity_tag}" + workflows_url = f"{app_url}/admin/workflows" + jenkins_url = self._answers.get("jenkins_url", "") + jenkins_job = self._answers.get("jenkins_job", "cortex-deploy") + jenkins_job_url = f"{jenkins_url}/job/{jenkins_job}" if jenkins_url else "" + + 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)} → Workflows tab → Solution: Trigger Jenkins Deploy → Run") + + print(f"\n{_hyperlink(workflows_url, 'View workflows in Cortex')}") + if jenkins_job_url: + print(f"{_hyperlink(jenkins_job_url, 'View Jenkins job')}") + + if self.confirm("Trigger a test workflow run now?", default=True): + print(" Starting Cortex workflow run (waiting for Jenkins pipeline to complete)...") + try: + result = self._trigger_via_cortex_workflow() + status = result.get("status", "").upper() + if status == "COMPLETED": + print(" Workflow run complete ✓") + self._confirm_deploy_recorded(base_url, entity_tag, entity_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.", 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(entity_url)}") + if jenkins_job_url: + print(f"\nJenkins job: {_hyperlink(jenkins_job_url)}") + + def _confirm_deploy_recorded(self, base_url: str, entity_tag: str, entity_url: str) -> None: + 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 ✓ {_hyperlink(entity_url, entity_tag)}") + return + except Exception: + pass + print(f" Deploy may still be indexing — check {_hyperlink(entity_url, entity_tag)}") def main(**kwargs): diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index c03324b5..6ec0928c 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -175,3 +175,49 @@ def test_add_jenkins_credential_creates_when_missing(setup): patch("requests.post", return_value=created_resp) as mock_post: setup._add_jenkins_credential("CORTEX_API_KEY", "secret", "Cortex API key") mock_post.assert_called_once() + + +def test_write_entity_custom_metadata(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=200) + with patch("requests.patch", return_value=resp) as mock_patch: + setup._write_entity_custom_metadata() + mock_patch.assert_called_once() + call_kwargs = mock_patch.call_args + assert "open-api" in call_kwargs.args[0] + body = call_kwargs.kwargs["data"].decode() + assert "jenkins-demo" in body + assert "jenkins_url" not in body # the value, not the key + assert "http://jenkins.example.com:8080" in body + assert "cortex-deploy" in body + + +def test_import_cortex_workflow(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=201) + with patch("requests.post", return_value=resp) as mock_post: + setup._import_cortex_workflow() + mock_post.assert_called_once() + call_kwargs = mock_post.call_args + assert "workflows" in call_kwargs.args[0] + assert call_kwargs.kwargs["headers"]["Content-Type"] == "application/yaml" + + +def test_steps_returns_expected_list(setup): + setup._answers["use_codespace"] = False + step_labels = [label for label, _ in setup.steps()] + assert "Creating Jenkins deploy job" in step_labels + assert "Adding CORTEX_API_KEY credential to Jenkins" in step_labels + assert "Adding CORTEX_BASE_URL credential to Jenkins" in step_labels + assert "Writing Jenkins config to entity custom metadata" in step_labels + assert "Importing Cortex trigger workflow" in step_labels + + +def test_steps_includes_codespace_when_enabled(setup): + setup._answers["use_codespace"] = True + step_labels = [label for label, _ in setup.steps()] + assert "Provisioning Jenkins in GitHub Codespaces" in step_labels + + +def test_main_callable(mod): + assert callable(mod.main) From 055be147843eada776fecdc50b23e5f0acb24deb Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 14 Aug 2026 16:37:22 -0700 Subject: [PATCH 10/55] docs: add jenkins-deploy solution README --- .../solutions/jenkins-deploy/README.md | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 cortexapps_cli/solutions/jenkins-deploy/README.md diff --git a/cortexapps_cli/solutions/jenkins-deploy/README.md b/cortexapps_cli/solutions/jenkins-deploy/README.md new file mode 100644 index 00000000..5fc0f7ee --- /dev/null +++ b/cortexapps_cli/solutions/jenkins-deploy/README.md @@ -0,0 +1,100 @@ +# Jenkins Deploy Solution + +Track Jenkins pipeline deploys in Cortex. Each deploy is recorded via the Cortex Deploys API +and surfaces in your entity's deploy history and on the Deploy Health scorecard. + +## How it works + +``` +Cortex Workflow → Jenkins buildWithParameters API → Jenkinsfile runs + → Stage: Record Deploy → POST /api/v1/catalog/{tag}/deploys + → post { always } → POST callback_url (SUCCESS or FAILURE) + → Cortex marks workflow run complete +``` + +The workflow uses an async callback pattern: Cortex triggers Jenkins and waits for Jenkins +to POST back when the pipeline finishes. Jenkins coordinates (URL, job, credentials) are +stored in the entity's `x-cortex-custom-metadata.jenkins` block. + +## Setup + +```bash +cortex solutions install -s jenkins-deploy +``` + +The setup script will: + +1. Ask whether to provision Jenkins in GitHub Codespaces (recommended for first-time demo) +2. Create the `cortex-deploy` pipeline job in Jenkins +3. Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` as Jenkins credentials +4. Write Jenkins coordinates to the entity's custom metadata +5. Import the **Trigger Jenkins Deploy** Cortex workflow +6. Optionally trigger a test deploy + +### GitHub Codespaces path + +Select **Y** when prompted. You'll need a GitHub Personal Access Token with `codespace` scope. + +A Codespace is provisioned from `.devcontainer/jenkins/` in this repository. Jenkins boots +with admin credentials (`admin` / `cortex-demo`) pre-configured via Jenkins Configuration +as Code (no setup wizard). Port 8080 is made publicly accessible so the Cortex workflow +can reach it. + +### Existing Jenkins path + +Select **N** when prompted. You'll need: +- Jenkins URL (e.g. `https://jenkins.example.com`) +- Jenkins username and API token (or password) + +The account needs permission to create jobs and credentials. + +## Triggering a deploy + +**Via CLI:** +```bash +cortex workflows run -t jenkins-trigger-deploy --scope ENTITY --entity jenkins-demo +``` + +**Via UI:** Open the entity in Cortex → Workflows tab → **Solution: Trigger Jenkins Deploy** → Run + +## Rolling out to your own services + +1. **Adapt the Jenkinsfile** — replace `echo 'Placeholder for real deploy steps'` in the + Build stage with your real build and deploy commands. The Record Deploy stage and + `post { always }` callback block can be added to any existing pipeline. + +2. **Add Jenkins credentials** — the pipeline requires `CORTEX_API_KEY` and `CORTEX_BASE_URL` + secret-text credentials in your Jenkins instance. + +3. **Add custom metadata** to your entity's catalog YAML: + +```yaml +x-cortex-custom-metadata: + jenkins: + url: "https://jenkins.example.com" + job: "your-pipeline-name" + username: "your-username" + token: "your-api-token" +``` + +4. The **Trigger Jenkins Deploy** workflow will pick up the new entity automatically — + no workflow changes needed. + +## Production note + +This solution stores Jenkins credentials in entity custom metadata for demo simplicity. +In production, use a Cortex HTTP integration with credential vaulting instead, and +restrict the Jenkins user to the minimum permissions needed (Build: Execute). + +## Deploy Health scorecard + +The **Jenkins Deploy Health** scorecard (`jenkins-deploy-health`) measures deploy cadence +for services in the `demo-jenkins-deploys` group: + +| Level | Requirement | +|--------|------------------------------| +| Bronze | 1+ deploy in the last year | +| Silver | 1+ deploy in the last 30 days | +| Gold | 1+ deploy in the last 7 days | + +Remove the `hasGroup("demo-jenkins-deploys")` filter to apply to all services. From b1d7e7cb69e897ab2ece55a2243074ebccd9213d Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 10:23:58 -0700 Subject: [PATCH 11/55] feat: add Codespace lifecycle management with delete-on-teardown Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/setup.py | 50 +++++++++++++++++++ tests/test_jenkins_deploy_setup.py | 31 ++++++++++++ 2 files changed, 81 insertions(+) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 80f49f61..d039a2d4 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -113,6 +113,20 @@ def _expose_jenkins_port(self, name: str) -> str: ) return f"https://{name}-{JENKINS_PORT}.app.github.dev" + def _delete_codespace(self, name: str) -> None: + """Delete a Codespace by name and clear it from state.""" + resp = requests.delete( + f"{GITHUB_API}/user/codespaces/{name}", + headers=self._gh_headers(), + timeout=15, + ) + if resp.status_code not in (200, 202, 204): + raise RuntimeError( + f"Failed to delete Codespace '{name}': {resp.status_code} {resp.text}" + ) + self._state.pop("codespace_name", None) + self._save_state() + # ── Prompts ──────────────────────────────────────────────────────────── def collect_prompts(self) -> None: @@ -289,6 +303,8 @@ def _add_jenkins_credential(self, credential_id: str, secret: str, description: def _provision_codespace(self) -> str: """Create Codespace, wait for it to be ready, expose port, set jenkins_url.""" name = self._create_codespace() + self._state["codespace_name"] = name + self._save_state() self._wait_for_codespace(name) url = self._expose_jenkins_port(name) self._answers["jenkins_url"] = url @@ -415,6 +431,17 @@ def steps(self) -> list[tuple[str, callable]]: return step_list def post_steps(self) -> None: + # Offer to clean up a Codespace left running from a previous setup run + saved_codespace = self._state.get("codespace_name") + if saved_codespace and not self._answers.get("use_codespace"): + print(f"\nA Codespace from a previous run is still running ({saved_codespace}).") + if self.confirm("Delete it?", default=True): + try: + self._delete_codespace(saved_codespace) + print(f" Codespace '{saved_codespace}' deleted ✓") + except Exception as e: + print(f" Failed to delete Codespace: {e}", file=sys.stderr) + 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"] @@ -455,6 +482,29 @@ def post_steps(self) -> None: if jenkins_job_url: print(f"\nJenkins job: {_hyperlink(jenkins_job_url)}") + # Codespace lifecycle: keep or delete + if self._answers.get("use_codespace") and self._state.get("codespace_name"): + codespace_name = self._state["codespace_name"] + print() + if not self.confirm("Keep the Codespace running?", default=False): + print(f" Deleting Codespace '{codespace_name}'...") + try: + self._delete_codespace(codespace_name) + print(" Codespace deleted ✓") + except Exception as e: + print(f" Failed to delete Codespace: {e}", file=sys.stderr) + else: + print(""" +⚠️ WARNING: Your Codespace is still running and will accrue compute charges + (~$0.18/core-hour after your free monthly allowance of 120 core-hours). + GitHub auto-stops after 30 min of inactivity, but does NOT delete it. + + To delete it later, run: + cortex solutions post-install -s jenkins-deploy + + Or delete it directly at: https://github.com/codespaces +""") + def _confirm_deploy_recorded(self, base_url: str, entity_tag: str, entity_url: str) -> None: api_key = self._answers["cortex_api_key"] try: diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 6ec0928c..5b310db1 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -221,3 +221,34 @@ def test_steps_includes_codespace_when_enabled(setup): def test_main_callable(mod): assert callable(mod.main) + + +def test_delete_codespace_succeeds(setup): + from unittest.mock import patch, MagicMock + setup._state["codespace_name"] = "my-cs-abc" + resp = MagicMock(status_code=204) + with patch("requests.delete", return_value=resp): + setup._delete_codespace("my-cs-abc") + assert "codespace_name" not in setup._state + + +def test_delete_codespace_raises_on_failure(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=422) + resp.text = "Error" + with patch("requests.delete", return_value=resp): + with pytest.raises(RuntimeError, match="Failed to delete Codespace"): + setup._delete_codespace("my-cs-abc") + + +def test_provision_codespace_stores_name_in_state(setup): + from unittest.mock import patch, MagicMock + create_resp = MagicMock(status_code=201) + create_resp.json.return_value = {"name": "my-cs-abc"} + patch_resp = MagicMock(status_code=200) + with patch("requests.post", return_value=create_resp), \ + patch("requests.get", return_value=MagicMock(status_code=200, json=lambda: {"state": "Available"})), \ + patch("requests.patch", return_value=patch_resp), \ + patch("time.sleep"): + setup._provision_codespace() + assert setup._state.get("codespace_name") == "my-cs-abc" From 878d7b1c8b62a3371ceef03909e02f158ae613bd Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 10:30:27 -0700 Subject: [PATCH 12/55] docs: update jenkins-deploy README to match github-actions/harness format MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add YAML frontmatter (name, description) - Add ASCII art data model diagram showing Catalog → Workflow → Jenkins → deploys/callback flow - Restructure into What's Included, Quick Start, How It Works, After Installing, Customizing for Production sections - Align Jenkinsfile deploy payload title to "Triggered by Jenkins" (consistent with other solutions) Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/README.md | 167 ++++++++++-------- .../jenkins-deploy/_templates/Jenkinsfile | 2 +- 2 files changed, 92 insertions(+), 77 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/README.md b/cortexapps_cli/solutions/jenkins-deploy/README.md index 5fc0f7ee..19548a6b 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/README.md +++ b/cortexapps_cli/solutions/jenkins-deploy/README.md @@ -1,100 +1,115 @@ -# Jenkins Deploy Solution +--- +name: Jenkins Deploy Tracking +description: Track deployments from Jenkins pipelines in Cortex, with a deploy health scorecard measuring delivery cadence. +--- -Track Jenkins pipeline deploys in Cortex. Each deploy is recorded via the Cortex Deploys API -and surfaces in your entity's deploy history and on the Deploy Health scorecard. +# Jenkins Deploy Tracking -## How it works +Trigger deploys from Cortex, track them as they run in Jenkins, and surface deploy health back in your service catalog. ``` -Cortex Workflow → Jenkins buildWithParameters API → Jenkinsfile runs - → Stage: Record Deploy → POST /api/v1/catalog/{tag}/deploys - → post { always } → POST callback_url (SUCCESS or FAILURE) - → Cortex marks workflow run complete + ┌─────────────────────────────────┐ + │ Cortex Catalog │ + │ │ + │ jenkins-demo (service) │ + │ ├── x-cortex-custom-metadata │ + │ │ jenkins: │ + │ │ url / job / │ + │ │ username / token │ + │ └── Scorecard: Deploy Health │ + │ Bronze / Silver / Gold │ + └──────────────┬──────────────────┘ + │ + │ Run workflow from entity page + │ (or: cortex workflows run -t + │ jenkins-trigger-deploy + │ --scope ENTITY --entity ) + ▼ + ┌─────────────────────────────────┐ + │ Cortex Workflow │ + │ Trigger Jenkins Deploy │ + │ │ + │ 1. Read Jenkins config from │ + │ entity custom metadata │ + │ 2. POST /buildWithParameters │ + │ to Jenkins via HTTP │ + │ 3. Pass callback URL as │ + │ pipeline parameter │ + │ 4. Wait for callback │ + └──────────────┬──────────────────┘ + │ POST /buildWithParameters (HTTP + Basic auth) + ▼ + ┌─────────────────────────────────┐ + │ Jenkins Pipeline │ + │ cortex-deploy │ + │ │ + │ stage: Build │ + │ └── run your deploy steps │ + │ │ + │ stage: Record Deploy in Cortex │ + │ └── POST /deploys ◄────┼── registers deploy event + │ (entity: jenkins-demo) │ on the Cortex entity + │ │ + │ post { always } │ + │ └── POST callbackUrl ───────►│ Cortex marks workflow + │ status: SUCCESS/FAILURE │ run complete + └─────────────────────────────────┘ ``` -The workflow uses an async callback pattern: Cortex triggers Jenkins and waits for Jenkins -to POST back when the pipeline finishes. Jenkins coordinates (URL, job, credentials) are -stored in the entity's `x-cortex-custom-metadata.jenkins` block. +## What's Included -## Setup +- **Entity:** `jenkins-demo` service — a sample entity to receive deploy events +- **Scorecard:** Deploy Health — Bronze/Silver/Gold based on deploy frequency +- **Jenkinsfile:** `cortex-deploy` — a two-stage pipeline (Build → Record Deploy) with an async callback to Cortex; drop it into any existing Jenkins job +- **Cortex workflow:** `jenkins-trigger-deploy` — reads Jenkins coordinates from entity custom metadata, triggers the pipeline via HTTP, and waits for the result +- **Setup script:** Interactive wizard that wires everything together end-to-end; optionally provisions Jenkins in GitHub Codespaces for a zero-install demo -```bash -cortex solutions install -s jenkins-deploy -``` - -The setup script will: - -1. Ask whether to provision Jenkins in GitHub Codespaces (recommended for first-time demo) -2. Create the `cortex-deploy` pipeline job in Jenkins -3. Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` as Jenkins credentials -4. Write Jenkins coordinates to the entity's custom metadata -5. Import the **Trigger Jenkins Deploy** Cortex workflow -6. Optionally trigger a test deploy - -### GitHub Codespaces path - -Select **Y** when prompted. You'll need a GitHub Personal Access Token with `codespace` scope. +## Quick Start -A Codespace is provisioned from `.devcontainer/jenkins/` in this repository. Jenkins boots -with admin credentials (`admin` / `cortex-demo`) pre-configured via Jenkins Configuration -as Code (no setup wizard). Port 8080 is made publicly accessible so the Cortex workflow -can reach it. +1. Install the solution: -### Existing Jenkins path + ``` + cortex solutions install -s jenkins-deploy + ``` -Select **N** when prompted. You'll need: -- Jenkins URL (e.g. `https://jenkins.example.com`) -- Jenkins username and API token (or password) +2. Follow the post-install setup prompts, or run later: -The account needs permission to create jobs and credentials. + ``` + cortex solutions post-install -s jenkins-deploy + ``` -## Triggering a deploy +## How It Works -**Via CLI:** -```bash -cortex workflows run -t jenkins-trigger-deploy --scope ENTITY --entity jenkins-demo -``` - -**Via UI:** Open the entity in Cortex → Workflows tab → **Solution: Trigger Jenkins Deploy** → Run - -## Rolling out to your own services +The Cortex workflow reads Jenkins coordinates from `x-cortex-custom-metadata.jenkins` on the entity, then triggers `cortex-deploy` via `buildWithParameters`, passing a `callback_url` as a pipeline parameter. Cortex waits asynchronously for the pipeline to report back. -1. **Adapt the Jenkinsfile** — replace `echo 'Placeholder for real deploy steps'` in the - Build stage with your real build and deploy commands. The Record Deploy stage and - `post { always }` callback block can be added to any existing pipeline. +Jenkins 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 -2. **Add Jenkins credentials** — the pipeline requires `CORTEX_API_KEY` and `CORTEX_BASE_URL` - secret-text credentials in your Jenkins instance. +## After Installing -3. **Add custom metadata** to your entity's catalog YAML: - -```yaml -x-cortex-custom-metadata: - jenkins: - url: "https://jenkins.example.com" - job: "your-pipeline-name" - username: "your-username" - token: "your-api-token" -``` +If you ran the post-install setup, you're already done — it created the Jenkins job, added credentials, wrote Jenkins coordinates to the entity's custom metadata, imported the Cortex workflow, and triggered a test deploy. -4. The **Trigger Jenkins Deploy** workflow will pick up the new entity automatically — - no workflow changes needed. +To roll the pattern out to your own services: -## Production note +1. Add the `cortex-deploy` **Jenkinsfile** stages to any existing Jenkins pipeline (needs only `CORTEX_API_KEY` and `CORTEX_BASE_URL` secret-text credentials) -This solution stores Jenkins credentials in entity custom metadata for demo simplicity. -In production, use a Cortex HTTP integration with credential vaulting instead, and -restrict the Jenkins user to the minimum permissions needed (Build: Execute). +2. Add a `x-cortex-custom-metadata` block to your entity's catalog YAML with your Jenkins coordinates: -## Deploy Health scorecard + ```yaml + x-cortex-custom-metadata: + jenkins: + url: "https://jenkins.example.com" + job: "your-pipeline-name" + username: "your-username" + token: "your-api-token" + ``` -The **Jenkins Deploy Health** scorecard (`jenkins-deploy-health`) measures deploy cadence -for services in the `demo-jenkins-deploys` group: +3. Run the **Solution: Trigger Jenkins Deploy** workflow from the entity page — it reads the Jenkins coordinates from the entity's custom metadata automatically, with no manual inputs required -| Level | Requirement | -|--------|------------------------------| -| Bronze | 1+ deploy in the last year | -| Silver | 1+ deploy in the last 30 days | -| Gold | 1+ deploy in the last 7 days | +## Customizing for Production -Remove the `hasGroup("demo-jenkins-deploys")` filter to apply to all services. +- Point the workflow at your real entity by replacing `jenkins-demo` with your service tag +- Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` secret-text credentials to your real Jenkins instances +- The Deploy Health scorecard is scoped to `demo-jenkins-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-jenkins-deploys` group to them. +- In production, store Jenkins credentials in a Cortex HTTP integration with credential vaulting rather than entity custom metadata diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile b/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile index 9ae41e16..8ca64fb2 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile @@ -19,7 +19,7 @@ pipeline { steps { script { def timestamp = sh(script: 'date -u +%Y-%m-%dT%H:%M:%SZ', returnStdout: true).trim() - def payload = """{"sha":"${env.BUILD_NUMBER}","timestamp":"${timestamp}","environment":"production","type":"DEPLOY","title":"Build #${env.BUILD_NUMBER}","deployer":{"name":"Jenkins"},"customData":{"buildUrl":"${env.BUILD_URL}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" + def payload = """{"sha":"${env.BUILD_NUMBER}","timestamp":"${timestamp}","environment":"production","type":"DEPLOY","title":"Triggered by Jenkins","deployer":{"name":"Jenkins"},"customData":{"buildUrl":"${env.BUILD_URL}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" sh """ curl -s -f -X POST \\ "\${CORTEX_BASE_URL}/api/v1/catalog/${params.cortex_entity_tag}/deploys" \\ From bdaed51d41304a911f7382971193c3a790be26eb Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 11:12:23 -0700 Subject: [PATCH 13/55] docs: add GitHub Codespaces to jenkins-deploy README diagram Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/README.md | 36 +++++++++++-------- 1 file changed, 21 insertions(+), 15 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/README.md b/cortexapps_cli/solutions/jenkins-deploy/README.md index 19548a6b..3f4ca1a7 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/README.md +++ b/cortexapps_cli/solutions/jenkins-deploy/README.md @@ -39,21 +39,27 @@ Trigger deploys from Cortex, track them as they run in Jenkins, and surface depl └──────────────┬──────────────────┘ │ POST /buildWithParameters (HTTP + Basic auth) ▼ - ┌─────────────────────────────────┐ - │ Jenkins Pipeline │ - │ cortex-deploy │ - │ │ - │ stage: Build │ - │ └── run your deploy steps │ - │ │ - │ stage: Record Deploy in Cortex │ - │ └── POST /deploys ◄────┼── registers deploy event - │ (entity: jenkins-demo) │ on the Cortex entity - │ │ - │ post { always } │ - │ └── POST callbackUrl ───────►│ Cortex marks workflow - │ status: SUCCESS/FAILURE │ run complete - └─────────────────────────────────┘ + ┌─────────────────────────────────────────────────────────┐ + │ GitHub Codespaces (optional — provisioned by setup) │ + │ port 8080 exposed publicly for demo │ + │ │ + │ ┌─────────────────────────────────┐ │ + │ │ Jenkins Pipeline │ │ + │ │ cortex-deploy │ │ + │ │ │ │ + │ │ stage: Build │ │ + │ │ └── run your deploy steps │ │ + │ │ │ │ + │ │ stage: Record Deploy in Cortex │ │ + │ │ └── POST /deploys ◄────┼── registers deploy │ + │ │ (entity: jenkins-demo) │ event on entity │ + │ │ │ │ + │ │ post { always } │ │ + │ │ └── POST callbackUrl ───────►│ Cortex marks │ + │ │ status: SUCCESS/FAILURE │ workflow done │ + │ └─────────────────────────────────┘ │ + └─────────────────────────────────────────────────────────┘ + (or point to your own Jenkins instance — Codespaces not required) ``` ## What's Included From 07837704265addb912933c229311f19e6f11c65f Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 12:56:15 -0700 Subject: [PATCH 14/55] docs: fix diagram right-padding alignment in jenkins-deploy README Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/README.md | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/README.md b/cortexapps_cli/solutions/jenkins-deploy/README.md index 3f4ca1a7..ea509a29 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/README.md +++ b/cortexapps_cli/solutions/jenkins-deploy/README.md @@ -43,21 +43,21 @@ Trigger deploys from Cortex, track them as they run in Jenkins, and surface depl │ GitHub Codespaces (optional — provisioned by setup) │ │ port 8080 exposed publicly for demo │ │ │ - │ ┌─────────────────────────────────┐ │ - │ │ Jenkins Pipeline │ │ - │ │ cortex-deploy │ │ - │ │ │ │ - │ │ stage: Build │ │ - │ │ └── run your deploy steps │ │ - │ │ │ │ - │ │ stage: Record Deploy in Cortex │ │ + │ ┌─────────────────────────────────┐ │ + │ │ Jenkins Pipeline │ │ + │ │ cortex-deploy │ │ + │ │ │ │ + │ │ stage: Build │ │ + │ │ └── run your deploy steps │ │ + │ │ │ │ + │ │ stage: Record Deploy in Cortex │ │ │ │ └── POST /deploys ◄────┼── registers deploy │ │ │ (entity: jenkins-demo) │ event on entity │ - │ │ │ │ - │ │ post { always } │ │ - │ │ └── POST callbackUrl ───────►│ Cortex marks │ - │ │ status: SUCCESS/FAILURE │ workflow done │ - │ └─────────────────────────────────┘ │ + │ │ │ │ + │ │ post { always } │ │ + │ │ └── POST callbackUrl ───────►│ Cortex marks │ + │ │ status: SUCCESS/FAILURE │ workflow done │ + │ └─────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ (or point to your own Jenkins instance — Codespaces not required) ``` From 3657bd470a7dcd506a7761de14cf5a731f3266d1 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 13:00:13 -0700 Subject: [PATCH 15/55] chore: use worktree-jenkins-deploy branch for Codespace (temp, revert to main before merge) Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/jenkins-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index d039a2d4..367d7466 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -62,7 +62,7 @@ def _create_codespace(self) -> str: f"{GITHUB_API}/repos/{CODESPACE_REPO}/codespaces", headers=self._gh_headers(), json={ - "ref": "main", + "ref": "worktree-jenkins-deploy", "devcontainer_path": DEVCONTAINER_PATH, "machine": "basicLinux32gb", }, From 829c38fb3c90ae31e63dbc80e69ed8493c6e8913 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 13:06:51 -0700 Subject: [PATCH 16/55] fix: register Codespace port via API before setting visibility; warn about public Jenkins requirement - _expose_jenkins_port now POSTs to /ports to register port 8080 with GitHub's API before PATCHing visibility (devcontainer forwardPorts only works with a connected client, not the REST API) - Add clear warning when user opts out of Codespaces: Jenkins must be publicly reachable (Cortex triggers it via HTTP, Jenkins POSTs callback back to Cortex) - Update test for the new two-step port registration Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/setup.py | 21 ++++++++++++++++++- tests/test_jenkins_deploy_setup.py | 6 ++++-- 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 367d7466..3d04139b 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -100,7 +100,20 @@ def _wait_for_codespace(self, name: str, timeout_secs: int = 300) -> None: raise TimeoutError(f"Codespace did not become Available within {timeout_secs}s") def _expose_jenkins_port(self, name: str) -> str: - """Make Codespace port 8080 public. Returns the public Jenkins URL.""" + """Register port 8080 via the API, make it public, return the public URL.""" + # Register the port with GitHub's API (devcontainer forwardPorts only activates + # when a client connects; the REST API needs an explicit POST first). + post_resp = requests.post( + f"{GITHUB_API}/user/codespaces/{name}/ports", + headers=self._gh_headers(), + json={"port": JENKINS_PORT}, + timeout=15, + ) + if post_resp.status_code not in (200, 201, 409): # 409 = already registered + raise RuntimeError( + f"Failed to register Jenkins port: {post_resp.status_code} {post_resp.text}" + ) + resp = requests.patch( f"{GITHUB_API}/user/codespaces/{name}/ports/{JENKINS_PORT}/visibility", headers=self._gh_headers(), @@ -168,6 +181,12 @@ def collect_prompts(self) -> None: self._answers["jenkins_token"] = JENKINS_DEFAULT_TOKEN self._answers.setdefault("jenkins_job", "cortex-deploy") else: + print( + "\n⚠️ Your Jenkins instance must be publicly reachable from the internet.\n" + " Cortex will POST to Jenkins to trigger builds, and Jenkins will POST\n" + " back to Cortex when each build finishes. A Jenkins behind a firewall\n" + " or on localhost will not work with this workflow.\n" + ) self.prompt("jenkins_url", "Jenkins base URL (e.g. https://jenkins.example.com)") self.prompt("jenkins_username", "Jenkins username", default="admin") self.prompt("jenkins_token", "Jenkins API token or password", secret=True) diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 5b310db1..54bd4013 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -105,8 +105,10 @@ def test_create_codespace_raises_on_failure(setup): def test_expose_jenkins_port_returns_url(setup): from unittest.mock import patch, MagicMock - resp = MagicMock(status_code=200) - with patch("requests.patch", return_value=resp): + post_resp = MagicMock(status_code=201) + patch_resp = MagicMock(status_code=200) + with patch("requests.post", return_value=post_resp), \ + patch("requests.patch", return_value=patch_resp): url = setup._expose_jenkins_port("my-codespace-abc") assert url == "https://my-codespace-abc-8080.app.github.dev" From 275a52b22af73c1e7ffbddba2dfee01f483619c9 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 13:23:54 -0700 Subject: [PATCH 17/55] fix: reuse existing Codespace on re-run instead of provisioning a second one Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/jenkins-deploy/setup.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 3d04139b..277c3722 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -320,10 +320,15 @@ def _add_jenkins_credential(self, credential_id: str, secret: str, description: # ── Codespace orchestration ──────────────────────────────────────────── def _provision_codespace(self) -> str: - """Create Codespace, wait for it to be ready, expose port, set jenkins_url.""" - name = self._create_codespace() - self._state["codespace_name"] = name - self._save_state() + """Create Codespace (or reuse existing), wait for it to be ready, expose port, set jenkins_url.""" + existing = self._state.get("codespace_name") + if existing: + print(f" Reusing existing Codespace '{existing}'") + name = existing + else: + name = self._create_codespace() + self._state["codespace_name"] = name + self._save_state() self._wait_for_codespace(name) url = self._expose_jenkins_port(name) self._answers["jenkins_url"] = url From 36f787ba0400ffa7af3aaf7932b822a1848b488a Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 13:26:15 -0700 Subject: [PATCH 18/55] fix: verify Codespace identity before reusing on re-run Add _verify_codespace_identity() which calls GET /user/codespaces/{name} and confirms the repo (cortexapps/cli) and devcontainer path (.devcontainer/jenkins/devcontainer.json) match before trusting the saved name. If the Codespace was deleted or belongs to something else, clear state and provision a fresh one. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/setup.py | 29 +++++++++++++++++-- tests/test_jenkins_deploy_setup.py | 29 +++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 277c3722..ddf86444 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -319,12 +319,37 @@ def _add_jenkins_credential(self, credential_id: str, secret: str, description: # ── Codespace orchestration ──────────────────────────────────────────── + def _verify_codespace_identity(self, name: str) -> bool: + """Return True if the Codespace belongs to this solution (correct repo + devcontainer).""" + resp = requests.get( + f"{GITHUB_API}/user/codespaces/{name}", + headers=self._gh_headers(), + timeout=15, + ) + if resp.status_code == 404: + return False + resp.raise_for_status() + data = resp.json() + repo_match = data.get("repository", {}).get("full_name") == CODESPACE_REPO + container_match = data.get("devcontainer_path") == DEVCONTAINER_PATH + return repo_match and container_match + def _provision_codespace(self) -> str: """Create Codespace (or reuse existing), wait for it to be ready, expose port, set jenkins_url.""" existing = self._state.get("codespace_name") if existing: - print(f" Reusing existing Codespace '{existing}'") - name = existing + if self._verify_codespace_identity(existing): + print(f" Reusing existing Codespace '{existing}'") + name = existing + else: + print( + f" ⚠️ Saved Codespace '{existing}' no longer exists or is not a Jenkins " + f"Codespace — creating a new one." + ) + self._state.pop("codespace_name", None) + name = self._create_codespace() + self._state["codespace_name"] = name + self._save_state() else: name = self._create_codespace() self._state["codespace_name"] = name diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 54bd4013..8422571f 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -113,6 +113,35 @@ def test_expose_jenkins_port_returns_url(setup): assert url == "https://my-codespace-abc-8080.app.github.dev" +def test_verify_codespace_identity_returns_true_for_matching(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=200) + resp.json.return_value = { + "repository": {"full_name": "cortexapps/cli"}, + "devcontainer_path": ".devcontainer/jenkins/devcontainer.json", + } + with patch("requests.get", return_value=resp): + assert setup._verify_codespace_identity("my-cs") is True + + +def test_verify_codespace_identity_returns_false_for_wrong_devcontainer(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=200) + resp.json.return_value = { + "repository": {"full_name": "cortexapps/cli"}, + "devcontainer_path": ".devcontainer/other/devcontainer.json", + } + with patch("requests.get", return_value=resp): + assert setup._verify_codespace_identity("my-cs") is False + + +def test_verify_codespace_identity_returns_false_if_deleted(setup): + from unittest.mock import patch, MagicMock + resp = MagicMock(status_code=404) + with patch("requests.get", return_value=resp): + assert setup._verify_codespace_identity("my-cs") is False + + def test_wait_for_codespace_polls_until_available(setup): from unittest.mock import patch, MagicMock pending = MagicMock(status_code=200) From c9188afd1b61c361c7d0609b17ac8167a1e8ab25 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 13:31:16 -0700 Subject: [PATCH 19/55] chore: add debug logging to _expose_jenkins_port --- .../solutions/jenkins-deploy/setup.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index ddf86444..e5e5554c 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -101,25 +101,45 @@ def _wait_for_codespace(self, name: str, timeout_secs: int = 300) -> None: def _expose_jenkins_port(self, name: str) -> str: """Register port 8080 via the API, make it public, return the public URL.""" + # Debug: dump Codespace state and known ports before touching anything + cs_resp = requests.get( + f"{GITHUB_API}/user/codespaces/{name}", + headers=self._gh_headers(), timeout=15, + ) + print(f"\n [debug] Codespace state: {cs_resp.json().get('state')} " + f"machine={cs_resp.json().get('machine', {}).get('name')}") + + ports_resp = requests.get( + f"{GITHUB_API}/user/codespaces/{name}/ports", + headers=self._gh_headers(), timeout=15, + ) + print(f" [debug] GET /ports → {ports_resp.status_code}: {ports_resp.text[:300]}") + # Register the port with GitHub's API (devcontainer forwardPorts only activates # when a client connects; the REST API needs an explicit POST first). + post_url = f"{GITHUB_API}/user/codespaces/{name}/ports" + print(f" [debug] POST {post_url} {{port: {JENKINS_PORT}}}") post_resp = requests.post( - f"{GITHUB_API}/user/codespaces/{name}/ports", + post_url, headers=self._gh_headers(), json={"port": JENKINS_PORT}, timeout=15, ) + print(f" [debug] POST /ports → {post_resp.status_code}: {post_resp.text[:300]}") if post_resp.status_code not in (200, 201, 409): # 409 = already registered raise RuntimeError( f"Failed to register Jenkins port: {post_resp.status_code} {post_resp.text}" ) + patch_url = f"{GITHUB_API}/user/codespaces/{name}/ports/{JENKINS_PORT}/visibility" + print(f" [debug] PATCH {patch_url}") resp = requests.patch( - f"{GITHUB_API}/user/codespaces/{name}/ports/{JENKINS_PORT}/visibility", + patch_url, headers=self._gh_headers(), json={"visibility": "public"}, timeout=15, ) + print(f" [debug] PATCH /visibility → {resp.status_code}: {resp.text[:300]}") if resp.status_code not in (200, 204): raise RuntimeError( f"Failed to expose Jenkins port: {resp.status_code} {resp.text}" From 5ffe405c3f409581b461532514f4ce2f588020f8 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 13:35:49 -0700 Subject: [PATCH 20/55] fix: save GitHub PAT between runs (hidden=True instead of secret=True) --- cortexapps_cli/solutions/jenkins-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index e5e5554c..44c464be 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -194,7 +194,7 @@ def collect_prompts(self) -> None: "github_pat", "GitHub Personal Access Token (needs 'codespace' scope)", env_var="GITHUB_PAT", - secret=True, + hidden=True, ) # Jenkins URL is determined after Codespace creation (in steps) self._answers["jenkins_username"] = JENKINS_DEFAULT_USERNAME From 1e4215d2fd13e64a2c6fe5617935509b6c627060 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 13:39:48 -0700 Subject: [PATCH 21/55] fix: add context text before Codespace/existing Jenkins prompt --- cortexapps_cli/solutions/jenkins-deploy/setup.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 44c464be..c11068b0 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -184,6 +184,14 @@ def collect_prompts(self) -> None: ) # Jenkins source: Codespace or existing instance + print( + "\nJenkins source:\n" + " Y — provision a fresh Jenkins instance in GitHub Codespaces (recommended for demo).\n" + " Requires a GitHub PAT with 'codespace' scope. Jenkins will be pre-configured\n" + " and its port made publicly accessible for Cortex to reach.\n" + " N — use an existing Jenkins instance. You will be prompted for its URL and\n" + " credentials. The instance must be publicly reachable from the internet.\n" + ) use_codespace = self.confirm( "Spin up a Jenkins instance in GitHub Codespaces?", default=True ) From 7e4493cf6045fbd8fdf038daf7fe6bf7292b34e7 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 13:55:45 -0700 Subject: [PATCH 22/55] fix: set port 8080 public via devcontainer.json instead of ports REST API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GitHub ports REST API (GET/POST/PATCH /ports) requires the Codespace code server to be running, which only happens when a client actively connects. Since we provision via REST only, that API always returns 404. Fix: add "visibility": "public" to portsAttributes in devcontainer.json — GitHub applies this at build time, making port 8080 public before any client connects. _expose_jenkins_port now just returns the deterministic URL (https://{name}-8080.app.github.dev) with no API calls. Co-Authored-By: Claude Sonnet 4.6 --- .devcontainer/jenkins/devcontainer.json | 1 + .../solutions/jenkins-deploy/setup.py | 47 ++----------------- 2 files changed, 5 insertions(+), 43 deletions(-) diff --git a/.devcontainer/jenkins/devcontainer.json b/.devcontainer/jenkins/devcontainer.json index bb151806..0ce37a21 100644 --- a/.devcontainer/jenkins/devcontainer.json +++ b/.devcontainer/jenkins/devcontainer.json @@ -8,6 +8,7 @@ "portsAttributes": { "8080": { "label": "Jenkins UI", + "visibility": "public", "onAutoForward": "notify" } }, diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index c11068b0..13e5a98c 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -100,50 +100,11 @@ def _wait_for_codespace(self, name: str, timeout_secs: int = 300) -> None: raise TimeoutError(f"Codespace did not become Available within {timeout_secs}s") def _expose_jenkins_port(self, name: str) -> str: - """Register port 8080 via the API, make it public, return the public URL.""" - # Debug: dump Codespace state and known ports before touching anything - cs_resp = requests.get( - f"{GITHUB_API}/user/codespaces/{name}", - headers=self._gh_headers(), timeout=15, - ) - print(f"\n [debug] Codespace state: {cs_resp.json().get('state')} " - f"machine={cs_resp.json().get('machine', {}).get('name')}") + """Return the public Jenkins URL for this Codespace. - ports_resp = requests.get( - f"{GITHUB_API}/user/codespaces/{name}/ports", - headers=self._gh_headers(), timeout=15, - ) - print(f" [debug] GET /ports → {ports_resp.status_code}: {ports_resp.text[:300]}") - - # Register the port with GitHub's API (devcontainer forwardPorts only activates - # when a client connects; the REST API needs an explicit POST first). - post_url = f"{GITHUB_API}/user/codespaces/{name}/ports" - print(f" [debug] POST {post_url} {{port: {JENKINS_PORT}}}") - post_resp = requests.post( - post_url, - headers=self._gh_headers(), - json={"port": JENKINS_PORT}, - timeout=15, - ) - print(f" [debug] POST /ports → {post_resp.status_code}: {post_resp.text[:300]}") - if post_resp.status_code not in (200, 201, 409): # 409 = already registered - raise RuntimeError( - f"Failed to register Jenkins port: {post_resp.status_code} {post_resp.text}" - ) - - patch_url = f"{GITHUB_API}/user/codespaces/{name}/ports/{JENKINS_PORT}/visibility" - print(f" [debug] PATCH {patch_url}") - resp = requests.patch( - patch_url, - headers=self._gh_headers(), - json={"visibility": "public"}, - timeout=15, - ) - print(f" [debug] PATCH /visibility → {resp.status_code}: {resp.text[:300]}") - if resp.status_code not in (200, 204): - raise RuntimeError( - f"Failed to expose Jenkins port: {resp.status_code} {resp.text}" - ) + Port visibility is set to public via devcontainer.json portsAttributes, + applied at Codespace build time — no runtime API call needed. + """ return f"https://{name}-{JENKINS_PORT}.app.github.dev" def _delete_codespace(self, name: str) -> None: From 6aff0dcc48fdf370187d96a056d066add4de2ce3 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 14:15:10 -0700 Subject: [PATCH 23/55] fix: replace Codespace identity heuristic with Jenkins connectivity probe Instead of checking repo+devcontainer_path (which can't detect stale builds or wrong-purpose Codespaces), probe whether Jenkins is actually responding at the public URL. If Jenkins answers HTTP 200, the Codespace is usable; if not (deleted, stopped, built from old config), provision a fresh one. This is the only check that actually matters. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/setup.py | 43 ++++++++----------- tests/test_jenkins_deploy_setup.py | 28 +++++------- 2 files changed, 29 insertions(+), 42 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 13e5a98c..a6ceae9b 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -308,41 +308,36 @@ def _add_jenkins_credential(self, credential_id: str, secret: str, description: # ── Codespace orchestration ──────────────────────────────────────────── - def _verify_codespace_identity(self, name: str) -> bool: - """Return True if the Codespace belongs to this solution (correct repo + devcontainer).""" - resp = requests.get( - f"{GITHUB_API}/user/codespaces/{name}", - headers=self._gh_headers(), - timeout=15, - ) - if resp.status_code == 404: + def _jenkins_reachable(self, name: str) -> bool: + """Return True if Jenkins is responding at the Codespace's public URL.""" + url = f"https://{name}-{JENKINS_PORT}.app.github.dev/login" + try: + resp = requests.get(url, timeout=10) + return resp.status_code == 200 + except requests.exceptions.RequestException: return False - resp.raise_for_status() - data = resp.json() - repo_match = data.get("repository", {}).get("full_name") == CODESPACE_REPO - container_match = data.get("devcontainer_path") == DEVCONTAINER_PATH - return repo_match and container_match def _provision_codespace(self) -> str: """Create Codespace (or reuse existing), wait for it to be ready, expose port, set jenkins_url.""" existing = self._state.get("codespace_name") if existing: - if self._verify_codespace_identity(existing): - print(f" Reusing existing Codespace '{existing}'") - name = existing + print(f" Checking existing Codespace '{existing}'...") + if self._jenkins_reachable(existing): + print(f" Jenkins is up — reusing Codespace '{existing}'") + url = self._expose_jenkins_port(existing) + self._answers["jenkins_url"] = url + return f"Jenkins URL: {_hyperlink(url)}" else: print( - f" ⚠️ Saved Codespace '{existing}' no longer exists or is not a Jenkins " - f"Codespace — creating a new one." + f" ⚠️ Jenkins not reachable on '{existing}' " + f"(deleted, stopped, or built from stale config) — creating a new one." ) self._state.pop("codespace_name", None) - name = self._create_codespace() - self._state["codespace_name"] = name self._save_state() - else: - name = self._create_codespace() - self._state["codespace_name"] = name - self._save_state() + + name = self._create_codespace() + self._state["codespace_name"] = name + self._save_state() self._wait_for_codespace(name) url = self._expose_jenkins_port(name) self._answers["jenkins_url"] = url diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 8422571f..dfc4f820 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -113,33 +113,25 @@ def test_expose_jenkins_port_returns_url(setup): assert url == "https://my-codespace-abc-8080.app.github.dev" -def test_verify_codespace_identity_returns_true_for_matching(setup): +def test_jenkins_reachable_returns_true_on_200(setup): from unittest.mock import patch, MagicMock resp = MagicMock(status_code=200) - resp.json.return_value = { - "repository": {"full_name": "cortexapps/cli"}, - "devcontainer_path": ".devcontainer/jenkins/devcontainer.json", - } with patch("requests.get", return_value=resp): - assert setup._verify_codespace_identity("my-cs") is True + assert setup._jenkins_reachable("my-cs") is True -def test_verify_codespace_identity_returns_false_for_wrong_devcontainer(setup): +def test_jenkins_reachable_returns_false_on_non_200(setup): from unittest.mock import patch, MagicMock - resp = MagicMock(status_code=200) - resp.json.return_value = { - "repository": {"full_name": "cortexapps/cli"}, - "devcontainer_path": ".devcontainer/other/devcontainer.json", - } + resp = MagicMock(status_code=404) with patch("requests.get", return_value=resp): - assert setup._verify_codespace_identity("my-cs") is False + assert setup._jenkins_reachable("my-cs") is False -def test_verify_codespace_identity_returns_false_if_deleted(setup): - from unittest.mock import patch, MagicMock - resp = MagicMock(status_code=404) - with patch("requests.get", return_value=resp): - assert setup._verify_codespace_identity("my-cs") is False +def test_jenkins_reachable_returns_false_on_connection_error(setup): + import requests as req + from unittest.mock import patch + with patch("requests.get", side_effect=req.exceptions.ConnectionError): + assert setup._jenkins_reachable("my-cs") is False def test_wait_for_codespace_polls_until_available(setup): From 4db07b389d30cd7852f4837a8de97a6cf282709c Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 14:17:09 -0700 Subject: [PATCH 24/55] fix: use state file as ownership record; ask user before reusing Codespace Replace connectivity probe (which could match unrelated Codespaces) with the correct approach: the state file is the source of truth. If codespace_name is saved, this script created it. Verify only that it still exists in GitHub (non-404), then ask the user: reuse or provision fresh (with optional delete). Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/setup.py | 54 ++++++++++--------- tests/test_jenkins_deploy_setup.py | 15 ++---- 2 files changed, 34 insertions(+), 35 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index a6ceae9b..e212d24e 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -308,36 +308,42 @@ def _add_jenkins_credential(self, credential_id: str, secret: str, description: # ── Codespace orchestration ──────────────────────────────────────────── - def _jenkins_reachable(self, name: str) -> bool: - """Return True if Jenkins is responding at the Codespace's public URL.""" - url = f"https://{name}-{JENKINS_PORT}.app.github.dev/login" - try: - resp = requests.get(url, timeout=10) - return resp.status_code == 200 - except requests.exceptions.RequestException: - return False + def _codespace_exists(self, name: str) -> bool: + """Return True if the Codespace still exists in GitHub.""" + resp = requests.get( + f"{GITHUB_API}/user/codespaces/{name}", + headers=self._gh_headers(), + timeout=15, + ) + return resp.status_code != 404 def _provision_codespace(self) -> str: - """Create Codespace (or reuse existing), wait for it to be ready, expose port, set jenkins_url.""" + """Create Codespace (or reuse one previously created by this script), set jenkins_url.""" existing = self._state.get("codespace_name") - if existing: - print(f" Checking existing Codespace '{existing}'...") - if self._jenkins_reachable(existing): - print(f" Jenkins is up — reusing Codespace '{existing}'") - url = self._expose_jenkins_port(existing) - self._answers["jenkins_url"] = url - return f"Jenkins URL: {_hyperlink(url)}" + if existing and self._codespace_exists(existing): + if self.confirm( + f"Reuse existing Codespace '{existing}' (created by a previous setup run)?", + default=True, + ): + name = existing else: - print( - f" ⚠️ Jenkins not reachable on '{existing}' " - f"(deleted, stopped, or built from stale config) — creating a new one." - ) - self._state.pop("codespace_name", None) + if self.confirm(f"Delete '{existing}'?", default=False): + try: + self._delete_codespace(existing) + print(f" Codespace '{existing}' deleted ✓") + except Exception as e: + print(f" Failed to delete Codespace: {e}", file=sys.stderr) + name = self._create_codespace() + self._state["codespace_name"] = name self._save_state() + else: + if existing: + print(f" Saved Codespace '{existing}' no longer exists — creating a new one.") + self._state.pop("codespace_name", None) + name = self._create_codespace() + self._state["codespace_name"] = name + self._save_state() - name = self._create_codespace() - self._state["codespace_name"] = name - self._save_state() self._wait_for_codespace(name) url = self._expose_jenkins_port(name) self._answers["jenkins_url"] = url diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index dfc4f820..024b3a4c 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -113,25 +113,18 @@ def test_expose_jenkins_port_returns_url(setup): assert url == "https://my-codespace-abc-8080.app.github.dev" -def test_jenkins_reachable_returns_true_on_200(setup): +def test_codespace_exists_returns_true_on_200(setup): from unittest.mock import patch, MagicMock resp = MagicMock(status_code=200) with patch("requests.get", return_value=resp): - assert setup._jenkins_reachable("my-cs") is True + assert setup._codespace_exists("my-cs") is True -def test_jenkins_reachable_returns_false_on_non_200(setup): +def test_codespace_exists_returns_false_on_404(setup): from unittest.mock import patch, MagicMock resp = MagicMock(status_code=404) with patch("requests.get", return_value=resp): - assert setup._jenkins_reachable("my-cs") is False - - -def test_jenkins_reachable_returns_false_on_connection_error(setup): - import requests as req - from unittest.mock import patch - with patch("requests.get", side_effect=req.exceptions.ConnectionError): - assert setup._jenkins_reachable("my-cs") is False + assert setup._codespace_exists("my-cs") is False def test_wait_for_codespace_polls_until_available(setup): From 4ad5fc98c71eeccc5ecd6ebb8abb45372fcfb239 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Mon, 17 Aug 2026 14:34:04 -0700 Subject: [PATCH 25/55] fix: substitute real Jenkins URL into workflow at import time Cortex validates workflow URLs at import and rejects template variables as the URL host. Since the Jenkins base URL is known at setup time, substitute it directly into the workflow YAML before posting. The job name still resolves from entity custom metadata at runtime via {{variables.jenkins-job}}. Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/trigger-jenkins-deploy.yaml | 12 ++---------- cortexapps_cli/solutions/jenkins-deploy/setup.py | 3 ++- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml index 6c166177..54445164 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml @@ -9,9 +9,6 @@ isRunnableViaApi: true filter: type: ENTITY variables: - - slug: jenkins-url - type: STRING - defaultValue: "" - slug: jenkins-job type: STRING defaultValue: "" @@ -89,11 +86,10 @@ actions: type: JQ expression: | .actions."get-jenkins-config".outputs.body.value as $j | - if ($j == null or $j.url == null) then + if ($j == null or $j.job == null) then error("No Jenkins configuration found. Add x-cortex-custom-metadata.jenkins with url, job, username, and token to this entity.") else { - url: $j.url, job: $j.job, auth: ("Basic " + (($j.username + ":" + $j.token) | @base64)) } @@ -106,10 +102,6 @@ actions: schema: type: SET_VARIABLES variables: - - slug: jenkins-url - source: - path: actions.parse-jenkins-config.outputs.result.url - type: REFERENCE - slug: jenkins-job source: path: actions.parse-jenkins-config.outputs.result.job @@ -126,7 +118,7 @@ actions: schema: type: HTTP_REQUEST_ASYNC httpMethod: POST - url: "{{variables.jenkins-url}}/job/{{variables.jenkins-job}}/buildWithParameters?callback_url={{{callbackUrl}}}&cortex_entity_tag={{context.entity.tag}}" + url: "JENKINS_BASE_URL/job/{{variables.jenkins-job}}/buildWithParameters?callback_url={{{callbackUrl}}}&cortex_entity_tag={{context.entity.tag}}" integration: null integrationAlias: null headers: diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index e212d24e..5ef46aa5 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -386,7 +386,8 @@ def _write_entity_custom_metadata(self) -> None: def _import_cortex_workflow(self) -> None: base_url = self._answers["cortex_base_url"].rstrip("/") api_key = self._answers["cortex_api_key"] - yaml_content = WORKFLOW_TEMPLATE_PATH.read_text() + jenkins_url = self._answers["jenkins_url"].rstrip("/") + yaml_content = WORKFLOW_TEMPLATE_PATH.read_text().replace("JENKINS_BASE_URL", jenkins_url) resp = requests.post( f"{base_url}/api/v1/workflows", From f2dd7cf0233d98b28e4198bca69cf2c6aec4755c Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Thu, 27 Aug 2026 15:34:45 -0700 Subject: [PATCH 26/55] fix: set Jenkins admin password via Groovy init script; improve setup robustness - Replace JCasC user password config (which doesn't reliably override the Jenkins Docker image's initial random admin password) with a Groovy init script that calls fromPlainPassword() directly at startup - Remove securityRealm and authorizationStrategy from jenkins.yaml so JCasC doesn't recreate the security realm after the init script runs - Add two-phase _wait_for_jenkins: phase 1 waits for /login, phase 2 waits for /crumbIssuer/api/json to confirm default credentials are accepted - Fix Codespace ref: revert worktree-jenkins-deploy -> main (devcontainer lives on this branch) - Add payload: " " to workflow HTTP action for Jenkins POST compatibility - Add CSRF session support (_jenkins_session) and Groovy runner (_run_groovy) - Add _set_jenkins_admin_password step: generate random passphrase + API token - Expose Jenkins port via gh CLI subprocess for reused Codespaces - Update tests throughout Co-Authored-By: Claude Sonnet 4.6 --- .devcontainer/jenkins/Dockerfile | 4 + .../jenkins/init-admin-password.groovy | 27 +++ .devcontainer/jenkins/jenkins.yaml | 9 - .../_templates/trigger-jenkins-deploy.yaml | 1 + .../solutions/jenkins-deploy/setup.py | 217 +++++++++++++++--- tests/test_jenkins_deploy_setup.py | 145 ++++++++++-- 6 files changed, 340 insertions(+), 63 deletions(-) create mode 100644 .devcontainer/jenkins/init-admin-password.groovy diff --git a/.devcontainer/jenkins/Dockerfile b/.devcontainer/jenkins/Dockerfile index 931653c6..5d2342d4 100644 --- a/.devcontainer/jenkins/Dockerfile +++ b/.devcontainer/jenkins/Dockerfile @@ -17,3 +17,7 @@ RUN jenkins-plugin-cli --plugins \ ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false" ENV CASC_JENKINS_CONFIG="/var/jenkins_home/casc_configs/jenkins.yaml" + +# Groovy init script to set admin password — JCasC password: field does not reliably +# override the Jenkins initial admin password in the Docker image. +COPY init-admin-password.groovy /usr/share/jenkins/ref/init.groovy.d/ diff --git a/.devcontainer/jenkins/init-admin-password.groovy b/.devcontainer/jenkins/init-admin-password.groovy new file mode 100644 index 00000000..503ab701 --- /dev/null +++ b/.devcontainer/jenkins/init-admin-password.groovy @@ -0,0 +1,27 @@ +import hudson.model.User +import hudson.security.FullControlOnceLoggedInAuthorizationStrategy +import hudson.security.HudsonPrivateSecurityRealm +import jenkins.model.Jenkins + +// Configure security here — NOT in jenkins.yaml — to avoid JCasC recreating the security +// realm (which would discard the user set up below). JCasC runs after init scripts, so +// any securityRealm / authorizationStrategy in jenkins.yaml would overwrite this. + +def instance = Jenkins.getInstance() + +def realm = new HudsonPrivateSecurityRealm(false) +instance.setSecurityRealm(realm) + +// Get or create the admin user and set a known password. +// User.get(id) creates the user object if it doesn't exist; addProperty overwrites +// any existing HudsonPrivateSecurityRealm.Details (the password property). +def user = User.get("admin") +def details = HudsonPrivateSecurityRealm.Details.fromPlainPassword("cortex-demo") +user.addProperty(details) +user.save() + +def strategy = new FullControlOnceLoggedInAuthorizationStrategy() +strategy.setAllowAnonymousRead(false) +instance.setAuthorizationStrategy(strategy) + +instance.save() diff --git a/.devcontainer/jenkins/jenkins.yaml b/.devcontainer/jenkins/jenkins.yaml index 0b518ad5..4a70ff61 100644 --- a/.devcontainer/jenkins/jenkins.yaml +++ b/.devcontainer/jenkins/jenkins.yaml @@ -1,14 +1,5 @@ jenkins: numExecutors: 2 - securityRealm: - local: - allowsSignup: false - users: - - id: "admin" - password: "cortex-demo" - authorizationStrategy: - loggedInUsersCanDoAnything: - allowAnonymousRead: false remotingSecurity: enabled: true unclassified: diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml index 54445164..747bb297 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml @@ -124,6 +124,7 @@ actions: headers: Authorization: "{{variables.jenkins-auth}}" Content-Type: application/x-www-form-urlencoded + payload: " " timeoutInSeconds: 300 outgoingActions: [] isRootAction: false diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 5ef46aa5..f37dadbc 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -11,6 +11,8 @@ "trigger workflow, and optionally fire a test deploy." ) +import secrets +import subprocess import sys import time from pathlib import Path @@ -33,6 +35,45 @@ JENKINS_DEFAULT_USERNAME = "admin" JENKINS_DEFAULT_TOKEN = "cortex-demo" +_PASSPHRASE_WORDS = [ + "amber", "anchor", "apple", "arrow", "atlas", "azure", "badge", "banjo", + "baron", "beach", "birch", "blade", "blaze", "bloom", "brace", "brine", + "brook", "cedar", "chain", "chalk", "chart", "chase", "chief", "chime", + "civic", "clamp", "cliff", "cloak", "cloud", "clove", "cobra", "comet", + "coral", "crane", "crisp", "crown", "curve", "cycle", "daisy", "delta", + "depot", "derby", "digit", "diver", "dowel", "draft", "drake", "drift", + "drill", "drums", "dunes", "eagle", "ebony", "ember", "envoy", "fable", + "flair", "flank", "flare", "flask", "fleet", "flint", "flock", "flute", + "forge", "frond", "frost", "gavel", "geyser", "glide", "glint", "globe", + "gloss", "glove", "golem", "grace", "grain", "grand", "grasp", "grove", + "guild", "gusto", "hatch", "haven", "hazel", "helix", "heron", "hinge", + "holly", "honey", "honor", "hound", "hover", "igloo", "inlet", "ivory", + "jade", "jaguar", "jazz", "jewel", "joust", "judge", "jumbo", "karma", + "kayak", "kelp", "knoll", "lance", "lapis", "laser", "latch", "lemon", + "lemur", "level", "light", "lilac", "linen", "lodge", "lotus", "lucid", + "lunar", "lyric", "magma", "mango", "manor", "maple", "marsh", "mason", + "maxim", "merit", "micro", "mirth", "molar", "moose", "mossy", "mount", + "mural", "niche", "noble", "notch", "novel", "oaken", "ocean", "ochre", + "olive", "onyx", "optic", "orbit", "otter", "oxide", "ozone", "panda", + "panel", "patch", "pearl", "pedal", "perch", "phase", "pilot", "pinch", + "pixel", "plaza", "plumb", "plume", "polar", "poppy", "prism", "probe", + "prowl", "proxy", "pulse", "quail", "quest", "quota", "radar", "radix", + "rally", "raven", "realm", "relay", "ridge", "rivet", "robin", "rocky", + "rogue", "rouge", "rover", "royal", "rustic", "sable", "salvo", "sandy", + "sauce", "scale", "scout", "serum", "shade", "shaft", "shark", "sheen", + "shell", "shift", "sigma", "silky", "silver", "slate", "sleek", "sleet", + "slope", "snowy", "solar", "solid", "sonic", "spark", "spear", "spire", + "spore", "spray", "squad", "stalk", "stamp", "stark", "steam", "steel", + "stern", "stoic", "stone", "storm", "stout", "strut", "suede", "sugar", + "surge", "swamp", "sword", "syrup", "talon", "taper", "tapir", "tempo", + "terra", "thorn", "tiger", "titan", "tonic", "topaz", "torch", "totem", + "trawl", "trend", "trout", "truce", "tunic", "turbo", "twine", "ultra", + "umber", "unity", "upper", "valor", "valve", "vapor", "vault", "venom", + "verge", "vigor", "viola", "viper", "visor", "vista", "vocal", "vogue", + "walnut", "wedge", "wheat", "whirl", "wield", "winch", "witty", "woven", + "xenon", "yacht", "yield", "zebra", "zephyr", "zippy", +] + def _hyperlink(url: str, text: str = None) -> str: label = text if text is not None else url @@ -100,11 +141,20 @@ def _wait_for_codespace(self, name: str, timeout_secs: int = 300) -> None: raise TimeoutError(f"Codespace did not become Available within {timeout_secs}s") def _expose_jenkins_port(self, name: str) -> str: - """Return the public Jenkins URL for this Codespace. + """Ensure the Jenkins port is publicly accessible and return its URL. - Port visibility is set to public via devcontainer.json portsAttributes, - applied at Codespace build time — no runtime API call needed. + Port visibility is set at build time via devcontainer.json portsAttributes. + For reused Codespaces this may need to be set explicitly via gh CLI. """ + try: + subprocess.run( + ["gh", "codespace", "ports", "visibility", + f"{JENKINS_PORT}:public", "-c", name], + check=True, + capture_output=True, + ) + except (subprocess.CalledProcessError, FileNotFoundError): + pass # gh unavailable or failed; devcontainer.json visibility applies for new Codespaces return f"https://{name}-{JENKINS_PORT}.app.github.dev" def _delete_codespace(self, name: str) -> None: @@ -147,9 +197,10 @@ def collect_prompts(self) -> None: # Jenkins source: Codespace or existing instance print( "\nJenkins source:\n" - " Y — provision a fresh Jenkins instance in GitHub Codespaces (recommended for demo).\n" + " Y — provision Jenkins in GitHub Codespaces (recommended for demo).\n" " Requires a GitHub PAT with 'codespace' scope. Jenkins will be pre-configured\n" " and its port made publicly accessible for Cortex to reach.\n" + " If a Codespace from a previous run of this command exists, it will be reused.\n" " N — use an existing Jenkins instance. You will be prompted for its URL and\n" " credentials. The instance must be publicly reachable from the internet.\n" ) @@ -223,43 +274,137 @@ def _get_job_xml(self) -> str: false """ - def _wait_for_jenkins(self, timeout_secs: int = 120) -> None: - """Poll Jenkins /login until it returns HTTP 200.""" - url = f"{self._jenkins_url()}/login" + def _wait_for_jenkins(self, timeout_secs: int = 180) -> None: + """Poll Jenkins until the UI is up AND the default credentials are accepted. + + Two-phase wait: + 1. /login returns 200 (Jenkins is up) + 2. /me/api/json with default credentials returns 200 (JCasC has applied) + """ + base = self._jenkins_url() start = time.time() dots = 0 + + # Phase 1: wait for /login while time.time() - start < timeout_secs: try: - resp = requests.get(url, timeout=5) + resp = requests.get(f"{base}/login", timeout=5) if resp.status_code == 200: - return + break except requests.exceptions.RequestException: pass time.sleep(5) dots += 1 print(f"\r Waiting for Jenkins{'.' * (dots % 4)} ", end="", flush=True) - raise TimeoutError(f"Jenkins did not respond within {timeout_secs}s at {url}") + else: + raise TimeoutError(f"Jenkins did not respond within {timeout_secs}s at {base}") + + # Phase 2: wait for the init script to apply (default credentials accepted). + # Use /crumbIssuer/api/json — it requires authentication, unlike /me/api/json + # which returns 200 for anonymous users. + auth = (JENKINS_DEFAULT_USERNAME, JENKINS_DEFAULT_TOKEN) + while time.time() - start < timeout_secs: + try: + resp = requests.get(f"{base}/crumbIssuer/api/json", auth=auth, timeout=5) + if resp.status_code == 200: + return + except requests.exceptions.RequestException: + pass + time.sleep(5) + dots += 1 + print(f"\r Waiting for Jenkins config{'.' * (dots % 4)} ", end="", flush=True) + raise TimeoutError(f"Jenkins credentials not accepted within {timeout_secs}s") + + def _generate_passphrase(self) -> str: + """Return a random 4-word hyphen-joined passphrase, e.g. 'coral-ember-ridge-titan'.""" + return "-".join(secrets.choice(_PASSPHRASE_WORDS) for _ in range(4)) + + def _jenkins_session(self, auth: tuple = None) -> requests.Session: + """Return an authenticated requests.Session with the Jenkins CSRF crumb pre-set.""" + session = requests.Session() + session.auth = auth or self._jenkins_auth() + crumb_resp = session.get(f"{self._jenkins_url()}/crumbIssuer/api/json", timeout=10) + if crumb_resp.status_code == 200: + try: + data = crumb_resp.json() + session.headers[data["crumbRequestField"]] = data["crumb"] + except (ValueError, KeyError): + pass + return session + + def _run_groovy(self, session: requests.Session, script: str) -> str: + """POST a Groovy script to Jenkins Script Console and return stdout. Raises on failure.""" + resp = session.post( + f"{self._jenkins_url()}/scriptText", + data={"script": script}, + timeout=15, + ) + if resp.status_code != 200 or "Exception" in resp.text: + raise RuntimeError( + f"Jenkins Script Console error: {resp.status_code} {resp.text[:200]}" + ) + return resp.text.strip() + + def _set_jenkins_admin_password(self) -> None: + """Set a random admin password and generate an API token for programmatic access. + + Stores the API token (not the password) as jenkins_token — API tokens bypass + Jenkins CSRF protection, so Cortex workflow calls don't need a session cookie. + + If already done in a previous run (stored in state), restores and skips. + """ + saved = self._state.get("jenkins_passphrase") + if saved: + self._answers["jenkins_token"] = self._state.get("jenkins_api_token", saved) + print(f" Jenkins admin credentials already configured (from previous run)") + print(f" Password: {saved}") + return + + passphrase = self._generate_passphrase() + session = self._jenkins_session(auth=(JENKINS_DEFAULT_USERNAME, JENKINS_DEFAULT_TOKEN)) + + # Change login password + self._run_groovy(session, ( + "def user = hudson.model.User.get('admin', false)\n" + "def prop = hudson.security.HudsonPrivateSecurityRealm.Details" + f".fromPlainPassword('{passphrase}')\n" + "user.addProperty(prop)\n" + "user.save()" + )) + + # Generate an API token — these bypass CSRF, so Cortex can call Jenkins without a session + api_token = self._run_groovy(session, ( + "import jenkins.security.ApiTokenProperty\n" + "def user = jenkins.model.Jenkins.instance.getUser('admin')\n" + "def prop = user.getProperty(ApiTokenProperty.class)\n" + "def result = prop.tokenStore.generateNewToken('cortex')\n" + "user.save()\n" + "println result.plainValue" + )) + if not api_token: + raise RuntimeError("Failed to generate Jenkins API token: empty response") + + self._answers["jenkins_token"] = api_token + self._state["jenkins_passphrase"] = passphrase + self._state["jenkins_api_token"] = api_token + self._save_state() + print(f" Jenkins admin password: {passphrase}") + print(f" Jenkins API token: {api_token}") def _create_jenkins_job(self) -> None: """Create the cortex-deploy pipeline job in Jenkins. Skips if already exists.""" job_name = self._answers["jenkins_job"] base = self._jenkins_url() - auth = self._jenkins_auth() + session = self._jenkins_session() - # Check if job exists - check = requests.get( - f"{base}/job/{job_name}/api/json", - auth=auth, - timeout=10, - ) + check = session.get(f"{base}/job/{job_name}/api/json", timeout=10) if check.status_code == 200: return # already exists — skip xml = self._get_job_xml() - resp = requests.post( + resp = session.post( f"{base}/createItem", params={"name": job_name}, - auth=auth, headers={"Content-Type": "application/xml"}, data=xml.encode("utf-8"), timeout=15, @@ -273,12 +418,10 @@ def _add_jenkins_credential(self, credential_id: str, secret: str, description: """Add a secret-text credential to the Jenkins global credential store. Skips if exists.""" import json as _json base = self._jenkins_url() - auth = self._jenkins_auth() + session = self._jenkins_session() - # Check if credential exists - check = requests.get( + check = session.get( f"{base}/credentials/store/system/domain/_/credential/{credential_id}/api/json", - auth=auth, timeout=10, ) if check.status_code == 200: @@ -294,9 +437,8 @@ def _add_jenkins_credential(self, credential_id: str, secret: str, description: "$class": "org.jenkinsci.plugins.plaincredentials.impl.StringCredentialsImpl", }, } - resp = requests.post( + resp = session.post( f"{base}/credentials/store/system/domain/_/createCredentials", - auth=auth, data={"json": _json.dumps(payload)}, timeout=15, ) @@ -317,12 +459,18 @@ def _codespace_exists(self, name: str) -> bool: ) return resp.status_code != 404 + def _record_new_codespace(self, name: str) -> None: + """Persist a newly created Codespace and reset any Jenkins state tied to the old one.""" + self._state["codespace_name"] = name + self._state.pop("jenkins_passphrase", None) # new Jenkins instance, clear stale password + self._save_state() + def _provision_codespace(self) -> str: """Create Codespace (or reuse one previously created by this script), set jenkins_url.""" existing = self._state.get("codespace_name") if existing and self._codespace_exists(existing): if self.confirm( - f"Reuse existing Codespace '{existing}' (created by a previous setup run)?", + f"Reuse existing Codespace '{existing}'?", default=True, ): name = existing @@ -334,15 +482,13 @@ def _provision_codespace(self) -> str: except Exception as e: print(f" Failed to delete Codespace: {e}", file=sys.stderr) name = self._create_codespace() - self._state["codespace_name"] = name - self._save_state() + self._record_new_codespace(name) else: if existing: print(f" Saved Codespace '{existing}' no longer exists — creating a new one.") self._state.pop("codespace_name", None) name = self._create_codespace() - self._state["codespace_name"] = name - self._save_state() + self._record_new_codespace(name) self._wait_for_codespace(name) url = self._expose_jenkins_port(name) @@ -457,6 +603,7 @@ def steps(self) -> list[tuple[str, callable]]: if self._answers.get("use_codespace"): step_list.append(("Provisioning Jenkins in GitHub Codespaces", self._provision_codespace)) step_list.append(("Waiting for Jenkins to be ready", self._wait_for_jenkins)) + step_list.append(("Setting random Jenkins admin password", self._set_jenkins_admin_password)) step_list += [ ("Creating Jenkins deploy job", self._create_jenkins_job), ("Adding CORTEX_API_KEY credential to Jenkins", lambda: self._add_jenkins_credential( @@ -493,6 +640,16 @@ def post_steps(self) -> None: jenkins_job = self._answers.get("jenkins_job", "cortex-deploy") jenkins_job_url = f"{jenkins_url}/job/{jenkins_job}" if jenkins_url else "" + if self._answers.get("use_codespace") and jenkins_url: + passphrase = self._state.get("jenkins_passphrase", "") + api_token = self._state.get("jenkins_api_token", "") + print(f"\nJenkins admin credentials (browser login):") + print(f" URL: {_hyperlink(jenkins_url)}") + print(f" Username: admin") + print(f" Password: {passphrase}") + if api_token: + print(f" API token (used by Cortex): {api_token}") + 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)} → Workflows tab → Solution: Trigger Jenkins Deploy → Run") diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 024b3a4c..116f1f59 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -104,11 +104,17 @@ def test_create_codespace_raises_on_failure(setup): def test_expose_jenkins_port_returns_url(setup): - from unittest.mock import patch, MagicMock - post_resp = MagicMock(status_code=201) - patch_resp = MagicMock(status_code=200) - with patch("requests.post", return_value=post_resp), \ - patch("requests.patch", return_value=patch_resp): + from unittest.mock import patch + with patch("subprocess.run") as mock_run: + url = setup._expose_jenkins_port("my-codespace-abc") + mock_run.assert_called_once() + assert url == "https://my-codespace-abc-8080.app.github.dev" + + +def test_expose_jenkins_port_continues_if_gh_unavailable(setup): + from unittest.mock import patch + import subprocess + with patch("subprocess.run", side_effect=FileNotFoundError): url = setup._expose_jenkins_port("my-codespace-abc") assert url == "https://my-codespace-abc-8080.app.github.dev" @@ -146,51 +152,53 @@ def test_wait_for_jenkins_polls_until_200(setup): from unittest.mock import patch, MagicMock fail = MagicMock(status_code=503) ok = MagicMock(status_code=200) - with patch("requests.get", side_effect=[fail, ok]), \ + # Phase 1: /login fails once then succeeds; phase 2: /crumbIssuer/api/json succeeds + with patch("requests.get", side_effect=[fail, ok, ok]), \ patch("time.sleep"): setup._wait_for_jenkins() # should not raise def test_create_jenkins_job_skips_if_exists(setup): from unittest.mock import patch, MagicMock - exists_resp = MagicMock(status_code=200) - with patch("requests.get", return_value=exists_resp) as mock_get, \ - patch("requests.post") as mock_post: + session = MagicMock() + session.get.return_value = MagicMock(status_code=200) + with patch.object(setup, "_jenkins_session", return_value=session): setup._create_jenkins_job() - mock_get.assert_called_once() - mock_post.assert_not_called() + session.post.assert_not_called() def test_create_jenkins_job_creates_when_missing(setup): from unittest.mock import patch, MagicMock - missing_resp = MagicMock(status_code=404) - created_resp = MagicMock(status_code=200) - with patch("requests.get", return_value=missing_resp), \ - patch("requests.post", return_value=created_resp) as mock_post: + session = MagicMock() + session.get.return_value = MagicMock(status_code=404) + session.post.return_value = MagicMock(status_code=200) + with patch.object(setup, "_jenkins_session", return_value=session): setup._create_jenkins_job() - mock_post.assert_called_once() - call_kwargs = mock_post.call_args + session.post.assert_called_once() + call_kwargs = session.post.call_args assert "application/xml" in call_kwargs.kwargs.get("headers", {}).get("Content-Type", "") def test_add_jenkins_credential_skips_if_exists(setup): from unittest.mock import patch, MagicMock exists_resp = MagicMock(status_code=200) - with patch("requests.get", return_value=exists_resp) as mock_get, \ - patch("requests.post") as mock_post: + session = MagicMock() + session.get.return_value = exists_resp + with patch.object(setup, "_jenkins_session", return_value=session): setup._add_jenkins_credential("CORTEX_API_KEY", "secret", "Cortex API key") - mock_get.assert_called_once() - mock_post.assert_not_called() + session.post.assert_not_called() def test_add_jenkins_credential_creates_when_missing(setup): from unittest.mock import patch, MagicMock missing_resp = MagicMock(status_code=404) created_resp = MagicMock(status_code=200) - with patch("requests.get", return_value=missing_resp), \ - patch("requests.post", return_value=created_resp) as mock_post: + session = MagicMock() + session.get.return_value = missing_resp + session.post.return_value = created_resp + with patch.object(setup, "_jenkins_session", return_value=session): setup._add_jenkins_credential("CORTEX_API_KEY", "secret", "Cortex API key") - mock_post.assert_called_once() + session.post.assert_called_once() def test_write_entity_custom_metadata(setup): @@ -233,6 +241,95 @@ def test_steps_includes_codespace_when_enabled(setup): setup._answers["use_codespace"] = True step_labels = [label for label, _ in setup.steps()] assert "Provisioning Jenkins in GitHub Codespaces" in step_labels + assert "Setting random Jenkins admin password" in step_labels + + +def test_generate_passphrase_format(setup): + passphrase = setup._generate_passphrase() + parts = passphrase.split("-") + assert len(parts) == 4 + assert all(len(p) > 0 for p in parts) + + +def test_set_jenkins_admin_password_updates_token(setup): + from unittest.mock import patch, MagicMock + session = MagicMock() + # First post = password change (empty output), second = API token generation + session.post.side_effect = [ + MagicMock(status_code=200, text=""), + MagicMock(status_code=200, text="11abc1234567890abcdef"), + ] + with patch.object(setup, "_jenkins_session", return_value=session), \ + patch.object(setup, "_save_state"): + setup._set_jenkins_admin_password() + assert setup._answers["jenkins_token"] == "11abc1234567890abcdef" + assert setup._state["jenkins_api_token"] == "11abc1234567890abcdef" + assert "-" in setup._state["jenkins_passphrase"] + + +def test_set_jenkins_admin_password_skips_if_already_set(setup): + setup._state["jenkins_passphrase"] = "coral-ember-ridge-titan" + setup._state["jenkins_api_token"] = "11abc1234567890abcdef" + from unittest.mock import patch + with patch.object(setup, "_jenkins_session") as mock_session: + setup._set_jenkins_admin_password() + mock_session.assert_not_called() + assert setup._answers["jenkins_token"] == "11abc1234567890abcdef" + + +def test_set_jenkins_admin_password_raises_on_failure(setup): + from unittest.mock import patch, MagicMock + session = MagicMock() + session.post.return_value = MagicMock(status_code=500, text="Internal Server Error") + with patch.object(setup, "_jenkins_session", return_value=session): + with pytest.raises(RuntimeError, match="Jenkins Script Console error"): + setup._set_jenkins_admin_password() + + +def test_set_jenkins_admin_password_raises_on_exception_in_output(setup): + from unittest.mock import patch, MagicMock + session = MagicMock() + session.post.return_value = MagicMock(status_code=200, text="groovy.lang.MissingMethodException: ...") + with patch.object(setup, "_jenkins_session", return_value=session): + with pytest.raises(RuntimeError, match="Jenkins Script Console error"): + setup._set_jenkins_admin_password() + + +def test_run_groovy_returns_output(setup): + from unittest.mock import MagicMock + session = MagicMock() + session.post.return_value = MagicMock(status_code=200, text=" hello world ") + result = setup._run_groovy(session, "println 'hello world'") + assert result == "hello world" + + +def test_run_groovy_raises_on_error(setup): + from unittest.mock import MagicMock + session = MagicMock() + session.post.return_value = MagicMock(status_code=200, text="groovy.lang.MissingPropertyException") + with pytest.raises(RuntimeError, match="Jenkins Script Console error"): + setup._run_groovy(session, "bad script") + + +def test_jenkins_session_sets_crumb_header(setup): + from unittest.mock import patch, MagicMock + crumb_resp = MagicMock(status_code=200) + crumb_resp.json.return_value = {"crumbRequestField": "Jenkins-Crumb", "crumb": "abc123"} + mock_session = MagicMock() + mock_session.get.return_value = crumb_resp + with patch("requests.Session", return_value=mock_session): + session = setup._jenkins_session() + mock_session.headers.__setitem__.assert_called_with("Jenkins-Crumb", "abc123") + + +def test_jenkins_session_skips_crumb_when_disabled(setup): + from unittest.mock import patch, MagicMock + crumb_resp = MagicMock(status_code=404) + mock_session = MagicMock() + mock_session.get.return_value = crumb_resp + with patch("requests.Session", return_value=mock_session): + session = setup._jenkins_session() + mock_session.headers.__setitem__.assert_not_called() def test_main_callable(mod): From 5600b6773b7c75d3082a3b45a4a508a5506f89a5 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 09:01:56 -0700 Subject: [PATCH 27/55] fix: replace Script Console with REST API for Jenkins token generation The Jenkins Script Console (/scriptText) consistently returned 401 despite valid credentials passing the crumb check. Switch to the standard Jenkins REST API endpoint for API token generation which doesn't require Script Console access. Falls back to the default password if token generation fails. Also clear jenkins_api_token from state on new Codespace provision so stale tokens from previous runs don't interfere. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/setup.py | 79 +++++++++---------- tests/test_jenkins_deploy_setup.py | 42 +++++----- 2 files changed, 62 insertions(+), 59 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index f37dadbc..782428b4 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -345,51 +345,49 @@ def _run_groovy(self, session: requests.Session, script: str) -> str: ) return resp.text.strip() - def _set_jenkins_admin_password(self) -> None: - """Set a random admin password and generate an API token for programmatic access. + def _generate_api_token(self) -> str: + """Generate a Jenkins API token via the REST API (no Script Console needed). + + Returns the token value on success, or the default password as fallback. + """ + session = self._jenkins_session(auth=(JENKINS_DEFAULT_USERNAME, JENKINS_DEFAULT_TOKEN)) + resp = session.post( + f"{self._jenkins_url()}/user/{JENKINS_DEFAULT_USERNAME}" + "/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken", + data={"newTokenName": "cortex"}, + timeout=15, + ) + if resp.status_code == 200: + try: + token = resp.json()["data"]["tokenValue"] + if token: + return token + except (ValueError, KeyError): + pass + # Fallback: use the default password directly (works for Basic Auth too) + return JENKINS_DEFAULT_TOKEN - Stores the API token (not the password) as jenkins_token — API tokens bypass - Jenkins CSRF protection, so Cortex workflow calls don't need a session cookie. + def _set_jenkins_admin_password(self) -> None: + """Generate a Jenkins API token for Cortex to use. - If already done in a previous run (stored in state), restores and skips. + Uses the Jenkins REST API (not the Script Console) to create an API token + for the admin user. Falls back to the default password if token generation + fails. Skips if already done in a previous run for this Codespace. """ - saved = self._state.get("jenkins_passphrase") + saved = self._state.get("jenkins_api_token") if saved: - self._answers["jenkins_token"] = self._state.get("jenkins_api_token", saved) - print(f" Jenkins admin credentials already configured (from previous run)") - print(f" Password: {saved}") + self._answers["jenkins_token"] = saved + print(f" Jenkins API token already configured (from previous run)") return - passphrase = self._generate_passphrase() - session = self._jenkins_session(auth=(JENKINS_DEFAULT_USERNAME, JENKINS_DEFAULT_TOKEN)) - - # Change login password - self._run_groovy(session, ( - "def user = hudson.model.User.get('admin', false)\n" - "def prop = hudson.security.HudsonPrivateSecurityRealm.Details" - f".fromPlainPassword('{passphrase}')\n" - "user.addProperty(prop)\n" - "user.save()" - )) - - # Generate an API token — these bypass CSRF, so Cortex can call Jenkins without a session - api_token = self._run_groovy(session, ( - "import jenkins.security.ApiTokenProperty\n" - "def user = jenkins.model.Jenkins.instance.getUser('admin')\n" - "def prop = user.getProperty(ApiTokenProperty.class)\n" - "def result = prop.tokenStore.generateNewToken('cortex')\n" - "user.save()\n" - "println result.plainValue" - )) - if not api_token: - raise RuntimeError("Failed to generate Jenkins API token: empty response") - - self._answers["jenkins_token"] = api_token - self._state["jenkins_passphrase"] = passphrase - self._state["jenkins_api_token"] = api_token + token = self._generate_api_token() + self._answers["jenkins_token"] = token + self._state["jenkins_api_token"] = token self._save_state() - print(f" Jenkins admin password: {passphrase}") - print(f" Jenkins API token: {api_token}") + if token == JENKINS_DEFAULT_TOKEN: + print(f" Jenkins credentials: {JENKINS_DEFAULT_USERNAME} / {JENKINS_DEFAULT_TOKEN}") + else: + print(f" Jenkins API token generated for Cortex") def _create_jenkins_job(self) -> None: """Create the cortex-deploy pipeline job in Jenkins. Skips if already exists.""" @@ -460,9 +458,10 @@ def _codespace_exists(self, name: str) -> bool: return resp.status_code != 404 def _record_new_codespace(self, name: str) -> None: - """Persist a newly created Codespace and reset any Jenkins state tied to the old one.""" + """Persist a newly created Codespace and reset all Jenkins state tied to the old one.""" self._state["codespace_name"] = name - self._state.pop("jenkins_passphrase", None) # new Jenkins instance, clear stale password + for key in ("jenkins_passphrase", "jenkins_api_token"): + self._state.pop(key, None) self._save_state() def _provision_codespace(self) -> str: diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 116f1f59..7f076e7b 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -253,46 +253,50 @@ def test_generate_passphrase_format(setup): def test_set_jenkins_admin_password_updates_token(setup): from unittest.mock import patch, MagicMock - session = MagicMock() - # First post = password change (empty output), second = API token generation - session.post.side_effect = [ - MagicMock(status_code=200, text=""), - MagicMock(status_code=200, text="11abc1234567890abcdef"), - ] - with patch.object(setup, "_jenkins_session", return_value=session), \ + token_resp = MagicMock(status_code=200) + token_resp.json.return_value = {"data": {"tokenValue": "11abc1234567890abcdef"}} + with patch.object(setup, "_generate_api_token", return_value="11abc1234567890abcdef"), \ patch.object(setup, "_save_state"): setup._set_jenkins_admin_password() assert setup._answers["jenkins_token"] == "11abc1234567890abcdef" assert setup._state["jenkins_api_token"] == "11abc1234567890abcdef" - assert "-" in setup._state["jenkins_passphrase"] def test_set_jenkins_admin_password_skips_if_already_set(setup): - setup._state["jenkins_passphrase"] = "coral-ember-ridge-titan" setup._state["jenkins_api_token"] = "11abc1234567890abcdef" from unittest.mock import patch - with patch.object(setup, "_jenkins_session") as mock_session: + with patch.object(setup, "_generate_api_token") as mock_gen: setup._set_jenkins_admin_password() - mock_session.assert_not_called() + mock_gen.assert_not_called() assert setup._answers["jenkins_token"] == "11abc1234567890abcdef" -def test_set_jenkins_admin_password_raises_on_failure(setup): +def test_set_jenkins_admin_password_falls_back_to_default(setup): + from unittest.mock import patch + with patch.object(setup, "_generate_api_token", return_value="cortex-demo"), \ + patch.object(setup, "_save_state"): + setup._set_jenkins_admin_password() + assert setup._answers["jenkins_token"] == "cortex-demo" + + +def test_generate_api_token_returns_token(setup): from unittest.mock import patch, MagicMock session = MagicMock() - session.post.return_value = MagicMock(status_code=500, text="Internal Server Error") + token_resp = MagicMock(status_code=200) + token_resp.json.return_value = {"data": {"tokenValue": "11abc1234567890abcdef"}} + session.post.return_value = token_resp with patch.object(setup, "_jenkins_session", return_value=session): - with pytest.raises(RuntimeError, match="Jenkins Script Console error"): - setup._set_jenkins_admin_password() + token = setup._generate_api_token() + assert token == "11abc1234567890abcdef" -def test_set_jenkins_admin_password_raises_on_exception_in_output(setup): +def test_generate_api_token_falls_back_on_failure(setup): from unittest.mock import patch, MagicMock session = MagicMock() - session.post.return_value = MagicMock(status_code=200, text="groovy.lang.MissingMethodException: ...") + session.post.return_value = MagicMock(status_code=401) with patch.object(setup, "_jenkins_session", return_value=session): - with pytest.raises(RuntimeError, match="Jenkins Script Console error"): - setup._set_jenkins_admin_password() + token = setup._generate_api_token() + assert token == "cortex-demo" # JENKINS_DEFAULT_TOKEN fallback def test_run_groovy_returns_output(setup): From 2f49764ac184bb56e071c162b4a8a1750b0caba0 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 09:24:51 -0700 Subject: [PATCH 28/55] fix: make Jenkins unsecured/no-CSRF for demo; remove auth from workflow trigger The Codespace proxy strips Authorization headers on POST requests, causing 401 on both the setup script and the Cortex workflow trigger. Fix for demo: - Init script: set AuthorizationStrategy.UNSECURED + disable CSRF issuer so all requests (including anonymous POST from Cortex) are permitted without auth - Workflow: remove Authorization header from trigger-deploy action; Jenkins no longer requires it - Workflow: remove auth construction from parse-jenkins-config JQ expression; entity metadata no longer needs username/token fields - _wait_for_jenkins phase 2: check /api/json (works with unsecured Jenkins) instead of /crumbIssuer/api/json (returns 404 when CSRF disabled) - Summary: show cortex-demo password explicitly; note Jenkins is open for demo - Entity custom metadata: remove username/token (not needed for triggering) Co-Authored-By: Claude Sonnet 4.6 --- .../jenkins/init-admin-password.groovy | 23 ++++++++------ .../_templates/trigger-jenkins-deploy.yaml | 31 ++++++------------- .../solutions/jenkins-deploy/setup.py | 23 +++++--------- tests/test_jenkins_deploy_setup.py | 4 +-- 4 files changed, 32 insertions(+), 49 deletions(-) diff --git a/.devcontainer/jenkins/init-admin-password.groovy b/.devcontainer/jenkins/init-admin-password.groovy index 503ab701..e8af953a 100644 --- a/.devcontainer/jenkins/init-admin-password.groovy +++ b/.devcontainer/jenkins/init-admin-password.groovy @@ -1,27 +1,30 @@ import hudson.model.User -import hudson.security.FullControlOnceLoggedInAuthorizationStrategy +import hudson.security.AuthorizationStrategy import hudson.security.HudsonPrivateSecurityRealm import jenkins.model.Jenkins -// Configure security here — NOT in jenkins.yaml — to avoid JCasC recreating the security -// realm (which would discard the user set up below). JCasC runs after init scripts, so -// any securityRealm / authorizationStrategy in jenkins.yaml would overwrite this. +// Demo Codespace setup. +// +// Security model: Jenkins is fully unsecured (no auth required) with CSRF disabled. +// The Codespace URL (long random string) is the only access control — appropriate +// for a short-lived demo instance. The admin user is still created with a known +// password so the Jenkins UI can be accessed interactively. def instance = Jenkins.getInstance() def realm = new HudsonPrivateSecurityRealm(false) instance.setSecurityRealm(realm) -// Get or create the admin user and set a known password. -// User.get(id) creates the user object if it doesn't exist; addProperty overwrites -// any existing HudsonPrivateSecurityRealm.Details (the password property). def user = User.get("admin") def details = HudsonPrivateSecurityRealm.Details.fromPlainPassword("cortex-demo") user.addProperty(details) user.save() -def strategy = new FullControlOnceLoggedInAuthorizationStrategy() -strategy.setAllowAnonymousRead(false) -instance.setAuthorizationStrategy(strategy) +// Unsecured: all requests (including anonymous POST from Cortex) are permitted. +// This bypasses the Codespace proxy stripping Authorization headers on POST. +instance.setAuthorizationStrategy(AuthorizationStrategy.UNSECURED) + +// Disable CSRF so POST requests from Cortex don't need a crumb. +instance.setCrumbIssuer(null) instance.save() diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml index 747bb297..3a727788 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml @@ -3,7 +3,7 @@ tag: jenkins-trigger-deploy description: | Triggers a Jenkins pipeline and waits for it to report completion back to Cortex, registering a deploy event on the target entity. No inputs required — Jenkins - coordinates (url, job, username, token) are read from the entity's custom metadata. + coordinates (url, job) are read from the entity's custom metadata. isDraft: false isRunnableViaApi: true filter: @@ -12,13 +12,10 @@ variables: - slug: jenkins-job type: STRING defaultValue: "" - - slug: jenkins-auth - type: STRING - defaultValue: "" runResponseTemplate: | # Jenkins Deploy — Complete - **Job:** [{{variables.jenkins-job}}]({{variables.jenkins-url}}/job/{{variables.jenkins-job}}) + **Job:** [{{variables.jenkins-job}}](JENKINS_BASE_URL/job/{{variables.jenkins-job}}) **Cortex Deploys:** [see deploys for {{context.entity.tag}}](https://app.getcortexapp.com/admin/resources?tag={{context.entity.tag}}) @@ -26,17 +23,15 @@ runResponseTemplate: | ## How this workflow works - This Cortex workflow triggered a deploy in Jenkins and waited for it to finish. - **1. Cortex read the entity's Jenkins configuration** - The workflow fetched `x-cortex-custom-metadata.jenkins` from this entity to determine - which Jenkins instance and job to trigger — no manual input required. + The workflow fetched `x-cortex-custom-metadata.jenkins` from this entity to find + the Jenkins URL and job name — no manual input required. **2. Cortex triggered the Jenkins build** - It called the Jenkins `buildWithParameters` API, passing a one-time callback URL as - the `callback_url` build parameter and the entity tag as `cortex_entity_tag`. + It POSTed to the Jenkins `buildWithParameters` API, passing a one-time callback URL + and the entity tag as build parameters. **3. Jenkins ran the pipeline** @@ -64,7 +59,7 @@ runResponseTemplate: | the `CORTEX_API_KEY` and `CORTEX_BASE_URL` credentials plus the two build parameters 3. Add `x-cortex-custom-metadata.jenkins` to your entity's catalog YAML with - `url`, `job`, `username`, and `token` fields pointing at your real Jenkins job + `url` and `job` fields pointing at your real Jenkins job actions: - name: Get Jenkins config slug: get-jenkins-config @@ -87,12 +82,9 @@ actions: expression: | .actions."get-jenkins-config".outputs.body.value as $j | if ($j == null or $j.job == null) then - error("No Jenkins configuration found. Add x-cortex-custom-metadata.jenkins with url, job, username, and token to this entity.") + error("No Jenkins configuration found. Add x-cortex-custom-metadata.jenkins with url and job to this entity.") else - { - job: $j.job, - auth: ("Basic " + (($j.username + ":" + $j.token) | @base64)) - } + { job: $j.job } end outgoingActions: - set-variables @@ -106,10 +98,6 @@ actions: source: path: actions.parse-jenkins-config.outputs.result.job type: REFERENCE - - slug: jenkins-auth - source: - path: actions.parse-jenkins-config.outputs.result.auth - type: REFERENCE outgoingActions: - trigger-deploy isRootAction: false @@ -122,7 +110,6 @@ actions: integration: null integrationAlias: null headers: - Authorization: "{{variables.jenkins-auth}}" Content-Type: application/x-www-form-urlencoded payload: " " timeoutInSeconds: 300 diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 782428b4..6752dc49 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -299,13 +299,11 @@ def _wait_for_jenkins(self, timeout_secs: int = 180) -> None: else: raise TimeoutError(f"Jenkins did not respond within {timeout_secs}s at {base}") - # Phase 2: wait for the init script to apply (default credentials accepted). - # Use /crumbIssuer/api/json — it requires authentication, unlike /me/api/json - # which returns 200 for anonymous users. - auth = (JENKINS_DEFAULT_USERNAME, JENKINS_DEFAULT_TOKEN) + # Phase 2: wait for Jenkins to finish initializing (init scripts applied). + # /api/json returns 200 once Jenkins is fully up and accepting API requests. while time.time() - start < timeout_secs: try: - resp = requests.get(f"{base}/crumbIssuer/api/json", auth=auth, timeout=5) + resp = requests.get(f"{base}/api/json", timeout=5) if resp.status_code == 200: return except requests.exceptions.RequestException: @@ -313,7 +311,7 @@ def _wait_for_jenkins(self, timeout_secs: int = 180) -> None: time.sleep(5) dots += 1 print(f"\r Waiting for Jenkins config{'.' * (dots % 4)} ", end="", flush=True) - raise TimeoutError(f"Jenkins credentials not accepted within {timeout_secs}s") + raise TimeoutError(f"Jenkins did not finish initializing within {timeout_secs}s") def _generate_passphrase(self) -> str: """Return a random 4-word hyphen-joined passphrase, e.g. 'coral-ember-ridge-titan'.""" @@ -509,8 +507,6 @@ def _write_entity_custom_metadata(self) -> None: jenkins: url: "{self._answers['jenkins_url']}" job: "{self._answers['jenkins_job']}" - username: "{self._answers['jenkins_username']}" - token: "{self._answers['jenkins_token']}" """ resp = requests.patch( f"{base_url}/api/v1/open-api", @@ -640,14 +636,11 @@ def post_steps(self) -> None: jenkins_job_url = f"{jenkins_url}/job/{jenkins_job}" if jenkins_url else "" if self._answers.get("use_codespace") and jenkins_url: - passphrase = self._state.get("jenkins_passphrase", "") - api_token = self._state.get("jenkins_api_token", "") - print(f"\nJenkins admin credentials (browser login):") + print(f"\nJenkins (browser login):") print(f" URL: {_hyperlink(jenkins_url)}") - print(f" Username: admin") - print(f" Password: {passphrase}") - if api_token: - print(f" API token (used by Cortex): {api_token}") + print(f" Username: {JENKINS_DEFAULT_USERNAME}") + print(f" Password: {JENKINS_DEFAULT_TOKEN}") + print(f" Note: Jenkins is open for demo — no login required to trigger builds") print(f"\nTo trigger a deploy manually later:") print(f" CLI: cortex workflows run -t {workflow_tag} --scope ENTITY --entity {entity_tag}") diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 7f076e7b..0885e5f5 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -60,7 +60,7 @@ def test_workflow_yaml_is_valid(): async_action = next(a for a in data["actions"] if a["slug"] == "trigger-deploy") assert async_action["schema"]["type"] == "HTTP_REQUEST_ASYNC" assert "buildWithParameters" in async_action["schema"]["url"] - assert "@base64" in data["actions"][1]["schema"]["expression"] + assert "job" in data["actions"][1]["schema"]["expression"] @pytest.fixture @@ -152,7 +152,7 @@ def test_wait_for_jenkins_polls_until_200(setup): from unittest.mock import patch, MagicMock fail = MagicMock(status_code=503) ok = MagicMock(status_code=200) - # Phase 1: /login fails once then succeeds; phase 2: /crumbIssuer/api/json succeeds + # Phase 1: /login fails once then succeeds; phase 2: /api/json succeeds immediately with patch("requests.get", side_effect=[fail, ok, ok]), \ patch("time.sleep"): setup._wait_for_jenkins() # should not raise From 0d7c60af2484d3f804a318bf868376fcecefc64b Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 09:38:15 -0700 Subject: [PATCH 29/55] fix: resolve Jenkins callback and BUILD_URL issues in deploy pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Move callback_url and cortex_entity_tag from URL query string to POST body in trigger-jenkins-deploy.yaml — Jenkins ignores query string params on POST to buildWithParameters, so they were never received by the pipeline - Fix Jenkinsfile callback curl: drop withEnv wrapper, use Groovy variable directly; remove || true so failures surface; add -f flag for HTTP errors - Add BUILD_URL fallback in Jenkinsfile: env.BUILD_URL ?: JENKINS_URL+job path, fixing null buildUrl when Jenkins root URL is not configured - Add _configure_jenkins_root_url step to setup.py: posts to Script Console (works with Unsecured Jenkins) to set location.url so BUILD_URL is populated - Update tests for new step and renamed callbackUrl variable Co-Authored-By: Claude Sonnet 4.6 --- .../jenkins-deploy/_templates/Jenkinsfile | 20 +++++++++---------- .../_templates/trigger-jenkins-deploy.yaml | 8 ++++---- .../solutions/jenkins-deploy/setup.py | 14 +++++++++++++ tests/test_jenkins_deploy_setup.py | 17 +++++++++++++++- 4 files changed, 44 insertions(+), 15 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile b/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile index 8ca64fb2..0452e930 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile @@ -19,7 +19,8 @@ pipeline { steps { script { def timestamp = sh(script: 'date -u +%Y-%m-%dT%H:%M:%SZ', returnStdout: true).trim() - def payload = """{"sha":"${env.BUILD_NUMBER}","timestamp":"${timestamp}","environment":"production","type":"DEPLOY","title":"Triggered by Jenkins","deployer":{"name":"Jenkins"},"customData":{"buildUrl":"${env.BUILD_URL}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" + def buildUrl = env.BUILD_URL ?: "${env.JENKINS_URL}job/${env.JOB_NAME}/${env.BUILD_NUMBER}/" + def payload = """{"sha":"${env.BUILD_NUMBER}","timestamp":"${timestamp}","environment":"production","type":"DEPLOY","title":"Triggered by Jenkins","deployer":{"name":"Jenkins"},"customData":{"buildUrl":"${buildUrl}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" sh """ curl -s -f -X POST \\ "\${CORTEX_BASE_URL}/api/v1/catalog/${params.cortex_entity_tag}/deploys" \\ @@ -36,15 +37,14 @@ pipeline { script { if (params.callback_url) { def status = currentBuild.currentResult == 'SUCCESS' ? 'SUCCESS' : 'FAILURE' - def payload = """{"status":"${status}","message":"Jenkins pipeline ${status.toLowerCase()}","response":{"buildUrl":"${env.BUILD_URL}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" - withEnv(["CALLBACK_URL=${params.callback_url}"]) { - sh """ - curl -s -X POST "\$CALLBACK_URL" \\ - -H "Content-Type: application/json" \\ - -H "Authorization: Bearer \${CORTEX_API_KEY}" \\ - -d '${payload}' || true - """ - } + def buildUrl = env.BUILD_URL ?: "${env.JENKINS_URL}job/${env.JOB_NAME}/${env.BUILD_NUMBER}/" + def payload = """{"status":"${status}","message":"Jenkins pipeline ${status.toLowerCase()}","response":{"buildUrl":"${buildUrl}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" + def callbackUrl = params.callback_url + sh """ + curl -s -f -X POST '${callbackUrl}' \\ + -H 'Content-Type: application/json' \\ + -d '${payload}' + """ } } } diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml index 3a727788..f07bd3f6 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml @@ -30,8 +30,8 @@ runResponseTemplate: | **2. Cortex triggered the Jenkins build** - It POSTed to the Jenkins `buildWithParameters` API, passing a one-time callback URL - and the entity tag as build parameters. + It POSTed to the Jenkins `buildWithParameters` API with the callback URL and entity + tag in the POST body so Jenkins receives them as build parameters. **3. Jenkins ran the pipeline** @@ -106,12 +106,12 @@ actions: schema: type: HTTP_REQUEST_ASYNC httpMethod: POST - url: "JENKINS_BASE_URL/job/{{variables.jenkins-job}}/buildWithParameters?callback_url={{{callbackUrl}}}&cortex_entity_tag={{context.entity.tag}}" + url: "JENKINS_BASE_URL/job/{{variables.jenkins-job}}/buildWithParameters" integration: null integrationAlias: null headers: Content-Type: application/x-www-form-urlencoded - payload: " " + payload: "callback_url={{{callbackUrl}}}&cortex_entity_tag={{context.entity.tag}}" timeoutInSeconds: 300 outgoingActions: [] isRootAction: false diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 6752dc49..e08aa1f3 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -387,6 +387,19 @@ def _set_jenkins_admin_password(self) -> None: else: print(f" Jenkins API token generated for Cortex") + def _configure_jenkins_root_url(self) -> None: + """Set Jenkins root URL via Script Console so env.BUILD_URL is populated in builds.""" + jenkins_url = self._jenkins_url() + script = ( + "import jenkins.model.JenkinsLocationConfiguration\n" + "def config = JenkinsLocationConfiguration.get()\n" + f'config.setUrl("{jenkins_url}/")\n' + "config.save()\n" + 'println "ok"\n' + ) + session = self._jenkins_session() + self._run_groovy(session, script) + def _create_jenkins_job(self) -> None: """Create the cortex-deploy pipeline job in Jenkins. Skips if already exists.""" job_name = self._answers["jenkins_job"] @@ -599,6 +612,7 @@ def steps(self) -> list[tuple[str, callable]]: step_list.append(("Provisioning Jenkins in GitHub Codespaces", self._provision_codespace)) step_list.append(("Waiting for Jenkins to be ready", self._wait_for_jenkins)) step_list.append(("Setting random Jenkins admin password", self._set_jenkins_admin_password)) + step_list.append(("Configuring Jenkins root URL", self._configure_jenkins_root_url)) step_list += [ ("Creating Jenkins deploy job", self._create_jenkins_job), ("Adding CORTEX_API_KEY credential to Jenkins", lambda: self._add_jenkins_credential( diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 0885e5f5..67bc9b0d 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -45,7 +45,7 @@ def test_jenkinsfile_has_required_elements(): assert "/deploys" in content assert "post {" in content assert "always {" in content - assert "CALLBACK_URL" in content + assert "callbackUrl" in content def test_workflow_yaml_is_valid(): @@ -242,6 +242,21 @@ def test_steps_includes_codespace_when_enabled(setup): step_labels = [label for label, _ in setup.steps()] assert "Provisioning Jenkins in GitHub Codespaces" in step_labels assert "Setting random Jenkins admin password" in step_labels + assert "Configuring Jenkins root URL" in step_labels + + +def test_configure_jenkins_root_url(setup): + from unittest.mock import patch, MagicMock + session = MagicMock() + session.post.return_value = MagicMock(status_code=200, text="ok") + with patch.object(setup, "_jenkins_session", return_value=session): + setup._configure_jenkins_root_url() + session.post.assert_called_once() + call_kwargs = session.post.call_args + script = call_kwargs.kwargs.get("data", {}).get("script", "") or call_kwargs.args[1] if len(call_kwargs.args) > 1 else "" + # Check via the data kwarg + data = call_kwargs.kwargs.get("data", {}) + assert "JenkinsLocationConfiguration" in data.get("script", "") def test_generate_passphrase_format(setup): From 2ea25e63dbabca8f1e44c9da298a0a3b77c7d59e Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 10:01:34 -0700 Subject: [PATCH 30/55] fix: add Authorization header to Jenkins callback curl Cortex requires the API key on the callback POST. Also uses shell variable (\${CORTEX_API_KEY}) instead of Groovy interpolation to avoid the credentials masking warning. Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile | 1 + 1 file changed, 1 insertion(+) diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile b/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile index 0452e930..bd8aa1d8 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile @@ -43,6 +43,7 @@ pipeline { sh """ curl -s -f -X POST '${callbackUrl}' \\ -H 'Content-Type: application/json' \\ + -H "Authorization: Bearer \${CORTEX_API_KEY}" \\ -d '${payload}' """ } From 169315bcef2623bcae7c6d8472c463199b4845bd Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 10:09:09 -0700 Subject: [PATCH 31/55] fix: always update Jenkins job config and reduce workflow timeout - _create_jenkins_job now updates config.xml when the job already exists, so Jenkinsfile changes are always applied on re-runs - Reduce workflow timeoutInSeconds from 300 to 60 - Reduce setup.py poll timeout from 6 min to 2 min to match Co-Authored-By: Claude Sonnet 4.6 --- .../_templates/trigger-jenkins-deploy.yaml | 2 +- .../solutions/jenkins-deploy/setup.py | 21 ++++++++++++++----- tests/test_jenkins_deploy_setup.py | 7 +++++-- 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml index f07bd3f6..f64fad3a 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml @@ -112,6 +112,6 @@ actions: headers: Content-Type: application/x-www-form-urlencoded payload: "callback_url={{{callbackUrl}}}&cortex_entity_tag={{context.entity.tag}}" - timeoutInSeconds: 300 + timeoutInSeconds: 60 outgoingActions: [] isRootAction: false diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index e08aa1f3..ee4e1d78 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -401,16 +401,27 @@ def _configure_jenkins_root_url(self) -> None: self._run_groovy(session, script) def _create_jenkins_job(self) -> None: - """Create the cortex-deploy pipeline job in Jenkins. Skips if already exists.""" + """Create or update the cortex-deploy pipeline job in Jenkins.""" job_name = self._answers["jenkins_job"] base = self._jenkins_url() session = self._jenkins_session() + xml = self._get_job_xml() check = session.get(f"{base}/job/{job_name}/api/json", timeout=10) if check.status_code == 200: - return # already exists — skip + # Job exists — update its config so the latest Jenkinsfile is always applied + resp = session.post( + f"{base}/job/{job_name}/config.xml", + headers={"Content-Type": "application/xml"}, + data=xml.encode("utf-8"), + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to update Jenkins job '{job_name}': {resp.status_code} {resp.text}" + ) + return - xml = self._get_job_xml() resp = session.post( f"{base}/createItem", params={"name": job_name}, @@ -588,7 +599,7 @@ def _trigger_via_cortex_workflow(self) -> dict: terminal = {"COMPLETED", "FAILED", "CANCELLED"} start = time.time() dots = 0 - while time.time() - start < 360: + while time.time() - start < 120: time.sleep(5) r = requests.get( f"{base_url}/api/v1/workflows/{workflow_tag}/runs/{run_id}", @@ -602,7 +613,7 @@ def _trigger_via_cortex_workflow(self) -> dict: if status in terminal: print() return r.json() - raise TimeoutError("Timed out waiting for workflow to complete (6 min)") + raise TimeoutError("Timed out waiting for workflow to complete (2 min)") # ── Steps ────────────────────────────────────────────────────────────── diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 67bc9b0d..2c80c5bd 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -158,13 +158,16 @@ def test_wait_for_jenkins_polls_until_200(setup): setup._wait_for_jenkins() # should not raise -def test_create_jenkins_job_skips_if_exists(setup): +def test_create_jenkins_job_updates_if_exists(setup): from unittest.mock import patch, MagicMock session = MagicMock() session.get.return_value = MagicMock(status_code=200) + session.post.return_value = MagicMock(status_code=200) with patch.object(setup, "_jenkins_session", return_value=session): setup._create_jenkins_job() - session.post.assert_not_called() + session.post.assert_called_once() + call_url = session.post.call_args.args[0] + assert "config.xml" in call_url def test_create_jenkins_job_creates_when_missing(setup): From e74b1bf25062e09cf58236687380f962427d72a7 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 10:16:59 -0700 Subject: [PATCH 32/55] fix: remove plugin version attrs from job XML and soften update failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove plugin="..." attributes from flow-definition XML — Jenkins is strict about version matching on config.xml updates and returns 500 when the installed version differs from what's in the XML - Downgrade update failure from RuntimeError to a printed warning so setup continues rather than aborting Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/jenkins-deploy/setup.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index ee4e1d78..59c17f61 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -245,7 +245,7 @@ def _get_job_xml(self) -> str: jenkinsfile = JENKINSFILE_TEMPLATE_PATH.read_text() return f"""\ - + Cortex Deploy Pipeline — records deploys in Cortex and posts async callback false @@ -266,7 +266,7 @@ def _get_job_xml(self) -> str: - + true @@ -417,8 +417,10 @@ def _create_jenkins_job(self) -> None: timeout=15, ) if resp.status_code not in (200, 201): - raise RuntimeError( - f"Failed to update Jenkins job '{job_name}': {resp.status_code} {resp.text}" + print( + f" Warning: could not update Jenkinsfile automatically " + f"({resp.status_code}). To apply the latest Jenkinsfile, " + f"delete the '{job_name}' job in Jenkins and re-run setup." ) return From c0c5d696acfa74040bb42a100e2df716f0c246e7 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 10:37:25 -0700 Subject: [PATCH 33/55] fix: patch existing job's script CDATA instead of replacing full config.xml Full config.xml replacement returns 500 because Jenkins validates plugin version attributes in the submitted XML against what's installed. Instead, fetch the live config.xml and use regex to replace only the " + patched = re.sub( + r"", + new_cdata, + get_resp.text, + flags=re.DOTALL, + ) + if patched == get_resp.text: + return # no change needed + resp = session.post( + f"{base}/job/{job_name}/config.xml", + headers={"Content-Type": "application/xml"}, + data=patched.encode("utf-8"), + timeout=15, + ) + if resp.status_code not in (200, 201): + print( + f" Warning: could not update Jenkinsfile ({resp.status_code}). " + f"Delete '{job_name}' in Jenkins and re-run setup to apply the latest Jenkinsfile." + ) + def _configure_jenkins_root_url(self) -> None: """Set Jenkins root URL via Script Console so env.BUILD_URL is populated in builds.""" jenkins_url = self._jenkins_url() @@ -409,19 +445,9 @@ def _create_jenkins_job(self) -> None: check = session.get(f"{base}/job/{job_name}/api/json", timeout=10) if check.status_code == 200: - # Job exists — update its config so the latest Jenkinsfile is always applied - resp = session.post( - f"{base}/job/{job_name}/config.xml", - headers={"Content-Type": "application/xml"}, - data=xml.encode("utf-8"), - timeout=15, - ) - if resp.status_code not in (200, 201): - print( - f" Warning: could not update Jenkinsfile automatically " - f"({resp.status_code}). To apply the latest Jenkinsfile, " - f"delete the '{job_name}' job in Jenkins and re-run setup." - ) + # Job exists — patch just the " + session.get.side_effect = [ + MagicMock(status_code=200), # api/json check + MagicMock(status_code=200, text=existing_xml), # config.xml fetch + ] session.post.return_value = MagicMock(status_code=200) with patch.object(setup, "_jenkins_session", return_value=session): setup._create_jenkins_job() From 50067aef22ea66befe98bc21ffad769c9c4231d0 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 10:47:24 -0700 Subject: [PATCH 34/55] fix: remove credentials from entity custom metadata, use Cortex secrets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jenkins username/token must never live in catalog YAML (plain text, version controlled). Remove them from catalog/jenkins-demo.yaml and all documentation. For secured Jenkins, users create a Cortex secret jenkins_auth = base64(user:token) and add an Authorization header to the workflow action — credentials stay in Cortex secret storage, never in the entity definition. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/README.md | 22 ++++++++++++++----- .../_templates/trigger-jenkins-deploy.yaml | 5 ++++- .../jenkins-deploy/catalog/jenkins-demo.yaml | 2 -- tests/test_jenkins_deploy_setup.py | 4 ++-- 4 files changed, 22 insertions(+), 11 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/README.md b/cortexapps_cli/solutions/jenkins-deploy/README.md index ea509a29..302ee1c2 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/README.md +++ b/cortexapps_cli/solutions/jenkins-deploy/README.md @@ -14,8 +14,7 @@ Trigger deploys from Cortex, track them as they run in Jenkins, and surface depl │ jenkins-demo (service) │ │ ├── x-cortex-custom-metadata │ │ │ jenkins: │ - │ │ url / job / │ - │ │ username / token │ + │ │ url / job │ │ └── Scorecard: Deploy Health │ │ Bronze / Silver / Gold │ └──────────────┬──────────────────┘ @@ -107,15 +106,26 @@ To roll the pattern out to your own services: jenkins: url: "https://jenkins.example.com" job: "your-pipeline-name" - username: "your-username" - token: "your-api-token" ``` -3. Run the **Solution: Trigger Jenkins Deploy** workflow from the entity page — it reads the Jenkins coordinates from the entity's custom metadata automatically, with no manual inputs required +3. If your Jenkins instance requires authentication, create a **Cortex secret** named `jenkins_auth` whose value is your Jenkins credentials base64-encoded: + + ```bash + echo -n "your-username:your-api-token" | base64 + ``` + + Then add an `Authorization` header to the **Trigger Jenkins Build** action in the imported workflow: + + ```yaml + headers: + Content-Type: application/x-www-form-urlencoded + Authorization: "Basic {{&context.secrets.jenkins_auth}}" + ``` + +4. Run the **Solution: Trigger Jenkins Deploy** workflow from the entity page — it reads the Jenkins coordinates from the entity's custom metadata automatically, with no manual inputs required ## Customizing for Production - Point the workflow at your real entity by replacing `jenkins-demo` with your service tag - Add `CORTEX_API_KEY` and `CORTEX_BASE_URL` secret-text credentials to your real Jenkins instances - The Deploy Health scorecard is scoped to `demo-jenkins-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-jenkins-deploys` group to them. -- In production, store Jenkins credentials in a Cortex HTTP integration with credential vaulting rather than entity custom metadata diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml index f64fad3a..6cbeca2a 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml @@ -59,7 +59,10 @@ runResponseTemplate: | the `CORTEX_API_KEY` and `CORTEX_BASE_URL` credentials plus the two build parameters 3. Add `x-cortex-custom-metadata.jenkins` to your entity's catalog YAML with - `url` and `job` fields pointing at your real Jenkins job + `url` and `job` fields — no credentials here. If your Jenkins requires auth, + create a Cortex secret `jenkins_auth = base64(user:token)` and add + `Authorization: "Basic {{&context.secrets.jenkins_auth}}"` to the workflow's + Trigger Jenkins Build action. actions: - name: Get Jenkins config slug: get-jenkins-config diff --git a/cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml b/cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml index d73f1867..444cdb57 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml +++ b/cortexapps_cli/solutions/jenkins-deploy/catalog/jenkins-demo.yaml @@ -11,5 +11,3 @@ info: jenkins: url: PLACEHOLDER_JENKINS_URL job: cortex-deploy - username: admin - token: PLACEHOLDER_JENKINS_TOKEN diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index 77223ae3..fe154f4a 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -21,8 +21,8 @@ def test_catalog_yaml_is_valid(): meta = data["info"]["x-cortex-custom-metadata"]["jenkins"] assert "url" in meta assert "job" in meta - assert "username" in meta - assert "token" in meta + assert "username" not in meta + assert "token" not in meta def test_scorecard_yaml_is_valid(): From 5576bf02703fcbe735350aa65f2966e5e6252de1 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 10:51:19 -0700 Subject: [PATCH 35/55] fix: move buildUrl to top-level url field in deploy payload The Cortex deploys API uses the top-level url field to hyperlink the deploy event in the UI. Moving buildUrl from customData to url makes each Jenkins build directly clickable from the Cortex deploy history. Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile b/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile index bd8aa1d8..9de6f830 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/Jenkinsfile @@ -20,7 +20,7 @@ pipeline { script { def timestamp = sh(script: 'date -u +%Y-%m-%dT%H:%M:%SZ', returnStdout: true).trim() def buildUrl = env.BUILD_URL ?: "${env.JENKINS_URL}job/${env.JOB_NAME}/${env.BUILD_NUMBER}/" - def payload = """{"sha":"${env.BUILD_NUMBER}","timestamp":"${timestamp}","environment":"production","type":"DEPLOY","title":"Triggered by Jenkins","deployer":{"name":"Jenkins"},"customData":{"buildUrl":"${buildUrl}","buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" + def payload = """{"sha":"${env.BUILD_NUMBER}","timestamp":"${timestamp}","environment":"production","type":"DEPLOY","title":"Triggered by Jenkins","url":"${buildUrl}","deployer":{"name":"Jenkins"},"customData":{"buildNumber":"${env.BUILD_NUMBER}","jobName":"${env.JOB_NAME}"}}""" sh """ curl -s -f -X POST \\ "\${CORTEX_BASE_URL}/api/v1/catalog/${params.cortex_entity_tag}/deploys" \\ From 5bd7d5c98a5a62126519dd82f40dc587d1f2282b Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 11:38:01 -0700 Subject: [PATCH 36/55] chore: clarify test prompt to say Cortex Workflow run Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/jenkins-deploy/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 3c314a33..d5085f42 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -703,7 +703,7 @@ def post_steps(self) -> None: if jenkins_job_url: print(f"{_hyperlink(jenkins_job_url, 'View Jenkins job')}") - if self.confirm("Trigger a test workflow run now?", default=True): + if self.confirm("Trigger a test Cortex Workflow run now?", default=True): print(" Starting Cortex workflow run (waiting for Jenkins pipeline to complete)...") try: result = self._trigger_via_cortex_workflow() From f9a704e646770292dce03573aa79662297495c09 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 11:39:06 -0700 Subject: [PATCH 37/55] chore: remove redundant deploy confirmation check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If the workflow COMPLETED, the deploy is already recorded — the API check and its confusing fallback message are unnecessary. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/setup.py | 20 ------------------- 1 file changed, 20 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index d5085f42..7a3e43f9 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -710,7 +710,6 @@ def post_steps(self) -> None: status = result.get("status", "").upper() if status == "COMPLETED": print(" Workflow run complete ✓") - self._confirm_deploy_recorded(base_url, entity_tag, entity_url) self.mark_done("first_deploy") else: print(f" Workflow run ended with status: {status}", file=sys.stderr) @@ -747,25 +746,6 @@ def post_steps(self) -> None: Or delete it directly at: https://github.com/codespaces """) - def _confirm_deploy_recorded(self, base_url: str, entity_tag: str, entity_url: str) -> None: - 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 ✓ {_hyperlink(entity_url, entity_tag)}") - return - except Exception: - pass - print(f" Deploy may still be indexing — check {_hyperlink(entity_url, entity_tag)}") - def main(**kwargs): JenkinsDeploySetup(**kwargs).run() From 2b920368fb465d976b380721bd53dd4463bdf4bb Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 11:40:26 -0700 Subject: [PATCH 38/55] fix: remove & from Cortex secrets template syntax Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/jenkins-deploy/README.md | 2 +- .../jenkins-deploy/_templates/trigger-jenkins-deploy.yaml | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/README.md b/cortexapps_cli/solutions/jenkins-deploy/README.md index 302ee1c2..69b8e45a 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/README.md +++ b/cortexapps_cli/solutions/jenkins-deploy/README.md @@ -119,7 +119,7 @@ To roll the pattern out to your own services: ```yaml headers: Content-Type: application/x-www-form-urlencoded - Authorization: "Basic {{&context.secrets.jenkins_auth}}" + Authorization: "Basic {{context.secrets.jenkins_auth}}" ``` 4. Run the **Solution: Trigger Jenkins Deploy** workflow from the entity page — it reads the Jenkins coordinates from the entity's custom metadata automatically, with no manual inputs required diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml index 6cbeca2a..cc1e3be8 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml @@ -61,7 +61,7 @@ runResponseTemplate: | 3. Add `x-cortex-custom-metadata.jenkins` to your entity's catalog YAML with `url` and `job` fields — no credentials here. If your Jenkins requires auth, create a Cortex secret `jenkins_auth = base64(user:token)` and add - `Authorization: "Basic {{&context.secrets.jenkins_auth}}"` to the workflow's + `Authorization: "Basic {{context.secrets.jenkins_auth}}"` to the workflow's Trigger Jenkins Build action. actions: - name: Get Jenkins config @@ -71,7 +71,7 @@ actions: httpMethod: GET url: "https://api.getcortexapp.com/api/v1/catalog/{{context.entity.tag}}/custom-data/jenkins" headers: - Authorization: "Bearer {{&context.secrets.cortex_api_key}}" + Authorization: "Bearer {{context.secrets.cortex_api_key}}" Content-Type: application/json integration: null integrationAlias: null From 329e231cf60e0007f8388603de15efe39ef22446 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 11:41:38 -0700 Subject: [PATCH 39/55] docs: show cortex secrets create for Jenkins auth in Next Steps Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/jenkins-deploy/README.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/README.md b/cortexapps_cli/solutions/jenkins-deploy/README.md index 69b8e45a..08abbca6 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/README.md +++ b/cortexapps_cli/solutions/jenkins-deploy/README.md @@ -108,10 +108,16 @@ To roll the pattern out to your own services: job: "your-pipeline-name" ``` -3. If your Jenkins instance requires authentication, create a **Cortex secret** named `jenkins_auth` whose value is your Jenkins credentials base64-encoded: +3. If your Jenkins instance requires authentication, create a **Cortex secret** with your Jenkins credentials: ```bash - echo -n "your-username:your-api-token" | base64 + cortex secrets create -f - < Date: Fri, 28 Aug 2026 11:45:24 -0700 Subject: [PATCH 40/55] feat: add jenkins_auth Cortex secret to workflow and setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Authorization: Basic {{context.secrets.jenkins_auth}} header to the workflow's trigger-deploy action so Jenkins auth works out of the box - Add _create_cortex_jenkins_secret setup step that base64-encodes the user's Jenkins credentials and upserts them as the jenkins_auth secret in Cortex — no manual secret creation needed after setup - Simplify README Next Steps: just create the secret, no workflow editing Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/README.md | 10 +----- .../_templates/trigger-jenkins-deploy.yaml | 1 + .../solutions/jenkins-deploy/setup.py | 33 +++++++++++++++++++ tests/test_jenkins_deploy_setup.py | 26 +++++++++++++++ 4 files changed, 61 insertions(+), 9 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/README.md b/cortexapps_cli/solutions/jenkins-deploy/README.md index 08abbca6..67c25efb 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/README.md +++ b/cortexapps_cli/solutions/jenkins-deploy/README.md @@ -108,7 +108,7 @@ To roll the pattern out to your own services: job: "your-pipeline-name" ``` -3. If your Jenkins instance requires authentication, create a **Cortex secret** with your Jenkins credentials: +3. Create a **Cortex secret** with your Jenkins credentials — the workflow's trigger action already includes the `Authorization` header and will use it automatically: ```bash cortex secrets create -f - < None: f"Failed to write entity custom metadata: {resp.status_code} {resp.text}" ) + # ── Cortex secret ───────────────────────────────────────────────────── + + def _create_cortex_jenkins_secret(self) -> None: + """Create or update the jenkins_auth Cortex secret with base64(username:token).""" + import base64 as _base64 + username = self._answers["jenkins_username"] + token = self._answers["jenkins_token"] + encoded = _base64.b64encode(f"{username}:{token}".encode()).decode() + base_url = self._answers["cortex_base_url"].rstrip("/") + api_key = self._answers["cortex_api_key"] + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + resp = requests.post( + f"{base_url}/api/v1/secrets", + json={"name": "Jenkins Auth", "tag": "jenkins_auth", "secret": encoded}, + headers=headers, + timeout=15, + ) + if resp.status_code == 409: + resp = requests.put( + f"{base_url}/api/v1/secrets/jenkins_auth", + json={"name": "Jenkins Auth", "secret": encoded}, + headers=headers, + timeout=15, + ) + if resp.status_code not in (200, 201): + raise RuntimeError( + f"Failed to create Cortex secret 'jenkins_auth': {resp.status_code} {resp.text}" + ) + # ── Cortex workflow import ───────────────────────────────────────────── def _import_cortex_workflow(self) -> None: @@ -661,6 +693,7 @@ def steps(self) -> list[tuple[str, callable]]: "CORTEX_BASE_URL", self._answers["cortex_base_url"], "Cortex base URL" )), ("Writing Jenkins config to entity custom metadata", self._write_entity_custom_metadata), + ("Creating jenkins_auth Cortex secret", self._create_cortex_jenkins_secret), ("Importing Cortex trigger workflow", self._import_cortex_workflow), ] return step_list diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index fe154f4a..ba8f81ba 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -60,6 +60,7 @@ def test_workflow_yaml_is_valid(): async_action = next(a for a in data["actions"] if a["slug"] == "trigger-deploy") assert async_action["schema"]["type"] == "HTTP_REQUEST_ASYNC" assert "buildWithParameters" in async_action["schema"]["url"] + assert "jenkins_auth" in async_action["schema"]["headers"].get("Authorization", "") assert "job" in data["actions"][1]["schema"]["expression"] @@ -224,6 +225,30 @@ def test_write_entity_custom_metadata(setup): assert "cortex-deploy" in body +def test_create_cortex_jenkins_secret(setup): + from unittest.mock import patch, MagicMock + import base64 + resp = MagicMock(status_code=201) + with patch("requests.post", return_value=resp) as mock_post: + setup._create_cortex_jenkins_secret() + mock_post.assert_called_once() + body = mock_post.call_args.kwargs["json"] + assert body["tag"] == "jenkins_auth" + expected = base64.b64encode(b"admin:cortex-demo").decode() + assert body["secret"] == expected + + +def test_create_cortex_jenkins_secret_updates_if_exists(setup): + from unittest.mock import patch, MagicMock + conflict = MagicMock(status_code=409) + updated = MagicMock(status_code=200) + with patch("requests.post", return_value=conflict), \ + patch("requests.put", return_value=updated) as mock_put: + setup._create_cortex_jenkins_secret() + mock_put.assert_called_once() + assert "jenkins_auth" in mock_put.call_args.args[0] + + def test_import_cortex_workflow(setup): from unittest.mock import patch, MagicMock resp = MagicMock(status_code=201) @@ -242,6 +267,7 @@ def test_steps_returns_expected_list(setup): assert "Adding CORTEX_API_KEY credential to Jenkins" in step_labels assert "Adding CORTEX_BASE_URL credential to Jenkins" in step_labels assert "Writing Jenkins config to entity custom metadata" in step_labels + assert "Creating jenkins_auth Cortex secret" in step_labels assert "Importing Cortex trigger workflow" in step_labels From 91c321eb5089a59e132474cbf03a19c6fa77fab3 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 11:45:41 -0700 Subject: [PATCH 41/55] =?UTF-8?q?docs:=20drop=20unnecessary=20auth=20cavea?= =?UTF-8?q?t=20=E2=80=94=20Jenkins=20always=20requires=20auth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/jenkins-deploy/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/README.md b/cortexapps_cli/solutions/jenkins-deploy/README.md index 67c25efb..1f29becb 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/README.md +++ b/cortexapps_cli/solutions/jenkins-deploy/README.md @@ -108,7 +108,7 @@ To roll the pattern out to your own services: job: "your-pipeline-name" ``` -3. Create a **Cortex secret** with your Jenkins credentials — the workflow's trigger action already includes the `Authorization` header and will use it automatically: +3. Create a **Cortex secret** with your Jenkins credentials: ```bash cortex secrets create -f - < Date: Fri, 28 Aug 2026 11:53:32 -0700 Subject: [PATCH 42/55] fix: remove auth header from jenkins trigger workflow for Unsecured demo Sending credentials to AuthorizationStrategy.UNSECURED Jenkins causes 401 when the API token is stale (e.g. after a Codespace restart). Anonymous POST works fine. The jenkins_auth Cortex secret is still created by setup for production users who add the header themselves. Co-Authored-By: Claude Sonnet 4.6 --- .../jenkins-deploy/_templates/trigger-jenkins-deploy.yaml | 1 - tests/test_jenkins_deploy_setup.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml index b2b10c11..cc1e3be8 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml @@ -114,7 +114,6 @@ actions: integrationAlias: null headers: Content-Type: application/x-www-form-urlencoded - Authorization: "Basic {{context.secrets.jenkins_auth}}" payload: "callback_url={{{callbackUrl}}}&cortex_entity_tag={{context.entity.tag}}" timeoutInSeconds: 60 outgoingActions: [] diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index ba8f81ba..cb22b470 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -60,7 +60,7 @@ def test_workflow_yaml_is_valid(): async_action = next(a for a in data["actions"] if a["slug"] == "trigger-deploy") assert async_action["schema"]["type"] == "HTTP_REQUEST_ASYNC" assert "buildWithParameters" in async_action["schema"]["url"] - assert "jenkins_auth" in async_action["schema"]["headers"].get("Authorization", "") + assert "Authorization" not in async_action["schema"]["headers"] assert "job" in data["actions"][1]["schema"]["expression"] From 75be774a756eb3b17fcb41977a2feffe534aa4aa Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 11:59:07 -0700 Subject: [PATCH 43/55] fix: restore auth header in jenkins trigger workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Setup creates/updates the jenkins_auth Cortex secret with a fresh token before importing the workflow, so the sequence is correct. Production Jenkins tokens persist across restarts. Sending credentials in the workflow makes adoption zero-friction — users only need to create the secret. Co-Authored-By: Claude Sonnet 4.6 --- .../jenkins-deploy/_templates/trigger-jenkins-deploy.yaml | 1 + tests/test_jenkins_deploy_setup.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml index cc1e3be8..b2b10c11 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml +++ b/cortexapps_cli/solutions/jenkins-deploy/_templates/trigger-jenkins-deploy.yaml @@ -114,6 +114,7 @@ actions: integrationAlias: null headers: Content-Type: application/x-www-form-urlencoded + Authorization: "Basic {{context.secrets.jenkins_auth}}" payload: "callback_url={{{callbackUrl}}}&cortex_entity_tag={{context.entity.tag}}" timeoutInSeconds: 60 outgoingActions: [] diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index cb22b470..ba8f81ba 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -60,7 +60,7 @@ def test_workflow_yaml_is_valid(): async_action = next(a for a in data["actions"] if a["slug"] == "trigger-deploy") assert async_action["schema"]["type"] == "HTTP_REQUEST_ASYNC" assert "buildWithParameters" in async_action["schema"]["url"] - assert "Authorization" not in async_action["schema"]["headers"] + assert "jenkins_auth" in async_action["schema"]["headers"].get("Authorization", "") assert "job" in data["actions"][1]["schema"]["expression"] From b066cd571404659193e07a4d83f59dc78a0bc42e Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 12:44:29 -0700 Subject: [PATCH 44/55] fix: handle 400 as conflict when creating jenkins_auth Cortex secret The secrets API returns 400 (not 409) when a secret with that tag already exists. Fall through to PUT on both 400 and 409. Co-Authored-By: Claude Sonnet 4.6 --- cortexapps_cli/solutions/jenkins-deploy/setup.py | 2 +- tests/test_jenkins_deploy_setup.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index ea36926b..0b1b7657 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -594,7 +594,7 @@ def _create_cortex_jenkins_secret(self) -> None: headers=headers, timeout=15, ) - if resp.status_code == 409: + if resp.status_code in (400, 409): resp = requests.put( f"{base_url}/api/v1/secrets/jenkins_auth", json={"name": "Jenkins Auth", "secret": encoded}, diff --git a/tests/test_jenkins_deploy_setup.py b/tests/test_jenkins_deploy_setup.py index ba8f81ba..fe35807c 100644 --- a/tests/test_jenkins_deploy_setup.py +++ b/tests/test_jenkins_deploy_setup.py @@ -249,6 +249,17 @@ def test_create_cortex_jenkins_secret_updates_if_exists(setup): assert "jenkins_auth" in mock_put.call_args.args[0] +def test_create_cortex_jenkins_secret_updates_on_400(setup): + from unittest.mock import patch, MagicMock + conflict = MagicMock(status_code=400) + updated = MagicMock(status_code=200) + with patch("requests.post", return_value=conflict), \ + patch("requests.put", return_value=updated) as mock_put: + setup._create_cortex_jenkins_secret() + mock_put.assert_called_once() + assert "jenkins_auth" in mock_put.call_args.args[0] + + def test_import_cortex_workflow(setup): from unittest.mock import patch, MagicMock resp = MagicMock(status_code=201) From 495ee31d06ec693d046f743875c49373fdf68460 Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 13:50:24 -0700 Subject: [PATCH 45/55] fix: print action-level details when workflow run fails Instead of just showing FAILED and redirecting to the UI, surface the failed action slug, error message, HTTP status, and response body inline so setup failures are diagnosable without opening the browser. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/setup.py | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 0b1b7657..4c675822 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -80,6 +80,29 @@ def _hyperlink(url: str, text: str = None) -> str: return f"\033]8;;{url}\033\\{label}\033]8;;\033\\" +def _print_workflow_failure(run: dict) -> None: + """Print action-level failure details from a workflow run response.""" + actions = run.get("actions", []) + for action in actions: + slug = action.get("slug", "?") + status = (action.get("status") or "").upper() + if status in ("FAILED", "ERROR"): + error = action.get("error") or action.get("errorMessage") or "" + outputs = action.get("outputs") or {} + http_status = outputs.get("statusCode") or outputs.get("status_code") or "" + body = outputs.get("body") or outputs.get("response") or "" + parts = [f" Failed action: {slug}"] + if error: + parts.append(f" Error: {error}") + if http_status: + parts.append(f" HTTP status: {http_status}") + if body: + body_str = str(body)[:300] + parts.append(f" Response: {body_str}") + for line in parts: + print(line, file=sys.stderr) + + class JenkinsDeploySetup(SolutionSetup): solution_tag = "jenkins-deploy" @@ -746,6 +769,7 @@ def post_steps(self) -> None: self.mark_done("first_deploy") else: print(f" Workflow run ended with status: {status}", file=sys.stderr) + _print_workflow_failure(result) print(f" Check {_hyperlink(workflows_url, 'Cortex Workflow runs')} to investigate.", file=sys.stderr) except Exception as e: print(f" Trigger failed: {e}", file=sys.stderr) From d6f224e01032085e8f18d53c9692b8803bb40ddb Mon Sep 17 00:00:00 2001 From: Jeff Schnitter Date: Fri, 28 Aug 2026 13:58:56 -0700 Subject: [PATCH 46/55] fix: use default password instead of API token for jenkins_auth secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jenkins API tokens don't survive Codespace restarts — the cached token in state becomes invalid on any new Codespace, causing 401 on workflow trigger. Use admin:cortex-demo (the stable default password) directly. Jenkins accepts password-based Basic auth in all security modes. Remove the _generate_api_token method and clear any stale cached token from state. Co-Authored-By: Claude Sonnet 4.6 --- .../solutions/jenkins-deploy/setup.py | 46 ++++-------------- tests/test_jenkins_deploy_setup.py | 48 ++----------------- 2 files changed, 13 insertions(+), 81 deletions(-) diff --git a/cortexapps_cli/solutions/jenkins-deploy/setup.py b/cortexapps_cli/solutions/jenkins-deploy/setup.py index 4c675822..f8590742 100644 --- a/cortexapps_cli/solutions/jenkins-deploy/setup.py +++ b/cortexapps_cli/solutions/jenkins-deploy/setup.py @@ -366,49 +366,19 @@ def _run_groovy(self, session: requests.Session, script: str) -> str: ) return resp.text.strip() - def _generate_api_token(self) -> str: - """Generate a Jenkins API token via the REST API (no Script Console needed). - - Returns the token value on success, or the default password as fallback. - """ - session = self._jenkins_session(auth=(JENKINS_DEFAULT_USERNAME, JENKINS_DEFAULT_TOKEN)) - resp = session.post( - f"{self._jenkins_url()}/user/{JENKINS_DEFAULT_USERNAME}" - "/descriptorByName/jenkins.security.ApiTokenProperty/generateNewToken", - data={"newTokenName": "cortex"}, - timeout=15, - ) - if resp.status_code == 200: - try: - token = resp.json()["data"]["tokenValue"] - if token: - return token - except (ValueError, KeyError): - pass - # Fallback: use the default password directly (works for Basic Auth too) - return JENKINS_DEFAULT_TOKEN def _set_jenkins_admin_password(self) -> None: - """Generate a Jenkins API token for Cortex to use. + """Set Jenkins credentials for Cortex to use. - Uses the Jenkins REST API (not the Script Console) to create an API token - for the admin user. Falls back to the default password if token generation - fails. Skips if already done in a previous run for this Codespace. + Uses the default admin password directly — more reliable than API tokens, + which don't survive Codespace restarts. Clears any cached API token from + previous runs to avoid stale-token 401s. """ - saved = self._state.get("jenkins_api_token") - if saved: - self._answers["jenkins_token"] = saved - print(f" Jenkins API token already configured (from previous run)") - return - - token = self._generate_api_token() - self._answers["jenkins_token"] = token - self._state["jenkins_api_token"] = token + self._state.pop("jenkins_api_token", None) self._save_state() - if token == JENKINS_DEFAULT_TOKEN: - print(f" Jenkins credentials: {JENKINS_DEFAULT_USERNAME} / {JENKINS_DEFAULT_TOKEN}") - else: - print(f" Jenkins API token generated for Cortex") + self._answers["jenkins_username"] = JENKINS_DEFAULT_USERNAME + self._answers["jenkins_token"] = JENKINS_DEFAULT_TOKEN + print(f" Jenkins credentials: {JENKINS_DEFAULT_USERNAME} / {JENKINS_DEFAULT_TOKEN}") def _update_jenkins_job_script(self, session: requests.Session, job_name: str, base: str) -> None: """Patch the " + # Match regardless of whether Jenkins stored it with CDATA, + # plain text, or with surrounding whitespace. patched = re.sub( - r"", + r"", new_cdata, get_resp.text, flags=re.DOTALL, ) if patched == get_resp.text: - return # no change needed + print( + f" Warning: could not locate