From 406869cffe2760da421b679e5c4530640e5cdf96 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Wed, 6 May 2026 12:10:27 -0500 Subject: [PATCH 01/47] jenkins: add Chainguard Jenkins demo with first sample pipeline Self-contained Jenkins demo running entirely on cgr.dev/smalls.xyz images. The controller (jenkins:2-lts-jdk21-dev) is built with a fixed plugin set and the Chainguard docker-cli, talks to the host Docker daemon via mounted socket, and seeds pipeline jobs on startup via JCasC + job-dsl. Includes the first of the apps planned in PLAN.md: corretto-java17-maven, a Spring Boot console app whose pipeline builds on maven:3-jdk17-dev, smoke-tests on amazon-corretto-jre:17-dev, and archives the runnable JAR. Subsequent apps will land in follow-up commits. setup.sh creates a chainctl pull-token for cgr.dev/smalls.xyz pulls and writes a Docker config to .secrets/ (gitignored), avoiding any need for chainctl/cred helpers inside the Jenkins container. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/.gitignore | 2 + jenkins/Dockerfile.jenkins | 19 ++++ jenkins/PLAN.md | 57 ++++++++++++ jenkins/README.md | 90 +++++++++++++++++++ .../apps/corretto-java17-maven/Jenkinsfile | 53 +++++++++++ jenkins/apps/corretto-java17-maven/README.md | 28 ++++++ jenkins/apps/corretto-java17-maven/pom.xml | 45 ++++++++++ .../src/main/java/com/example/HelloApp.java | 29 ++++++ jenkins/docker-compose.yml | 36 ++++++++ jenkins/jenkins/casc/jenkins.yaml | 21 +++++ jenkins/jenkins/casc/jobs.groovy | 26 ++++++ jenkins/jenkins/plugins.txt | 8 ++ jenkins/setup.sh | 48 ++++++++++ 13 files changed, 462 insertions(+) create mode 100644 jenkins/.gitignore create mode 100644 jenkins/Dockerfile.jenkins create mode 100644 jenkins/PLAN.md create mode 100644 jenkins/README.md create mode 100644 jenkins/apps/corretto-java17-maven/Jenkinsfile create mode 100644 jenkins/apps/corretto-java17-maven/README.md create mode 100644 jenkins/apps/corretto-java17-maven/pom.xml create mode 100644 jenkins/apps/corretto-java17-maven/src/main/java/com/example/HelloApp.java create mode 100644 jenkins/docker-compose.yml create mode 100644 jenkins/jenkins/casc/jenkins.yaml create mode 100644 jenkins/jenkins/casc/jobs.groovy create mode 100644 jenkins/jenkins/plugins.txt create mode 100755 jenkins/setup.sh diff --git a/jenkins/.gitignore b/jenkins/.gitignore new file mode 100644 index 00000000..e0a7d4e7 --- /dev/null +++ b/jenkins/.gitignore @@ -0,0 +1,2 @@ +# Pull-token-derived Docker config — generated by setup.sh, must not be committed. +.secrets/ diff --git a/jenkins/Dockerfile.jenkins b/jenkins/Dockerfile.jenkins new file mode 100644 index 00000000..6bff7571 --- /dev/null +++ b/jenkins/Dockerfile.jenkins @@ -0,0 +1,19 @@ +# Multi-stage build: +# 1. docker-cli stage: source for the docker client binary +# 2. plugins stage: use the -dev jenkins variant (has shell) to run jenkins-plugin-cli +# 3. final stage: ship on the standard jenkins image with plugins baked in + +FROM cgr.dev/smalls.xyz/docker-cli:29 AS docker + +FROM cgr.dev/smalls.xyz/jenkins:2-lts-jdk21-dev AS plugins +USER 0 +COPY jenkins/plugins.txt /tmp/plugins.txt +RUN jenkins-plugin-cli --plugin-file /tmp/plugins.txt --verbose + +FROM cgr.dev/smalls.xyz/jenkins:2-lts-jdk21-dev +USER 0 +COPY --from=docker /usr/bin/docker /usr/local/bin/docker +COPY --from=plugins --chown=1000:1000 /usr/share/jenkins/ref/plugins/ /usr/share/jenkins/ref/plugins/ +USER 1000 +ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false" +ENV CASC_JENKINS_CONFIG=/var/jenkins_home/casc diff --git a/jenkins/PLAN.md b/jenkins/PLAN.md new file mode 100644 index 00000000..2426f7cd --- /dev/null +++ b/jenkins/PLAN.md @@ -0,0 +1,57 @@ +In this directory we will implement a simple Jenkins server demo with pipeline jobs to build applications on various languages (and various versions of them) and build tools. + +All images should be Chainguard sourced and pulled from the cgr.dev/smalls.xyz repository. + +Setup scripts should be provided to quickly stand up the Jenkins environment for future demonstrations. + +Create sub-directories for the various sample applications with Jenkins pipeline files in them as if they were seperate repos in a real-world scenario. + +## Sample applications to build (take these one at a time) +- For each, make a simple "hello world" type application for each that also outputs info about the runtime environment (i.e. language version, env vars, etc) +- Jenkins test stage for each should be simple smoke test, nothing fancy. +- Some pipelines will archive language artifacts, others OCI images, use jenkins archival plus any other remote archival destination mentioned per app. + +### Corretto Java 17 + Maven + - Use a Springboot app + - Build stage: maven:3-jdk17-dev image and + - Test stage: amazon-corretto-jre:17 image + - Artifact to archive is the springboot runnable jar + +### Adoptium Java 8 + Maven Web app + - Simple Jetty jsp page app + - build with the maven:3-jdk-8-dev image + - test on the adoptium-jre:adoptium-openjdk-8 + - Artifact to archive is the jetty runnable war + +### OpenJDK 21 + Gradle standalone jar app + - Executable jar that just prints out to stdout + - build with the jdk:openjdk-21-dev image + - test with the jre:openjre-21 image + - Artifact to archive is the runnable jar + +### Python 3.14 + uv app + - Simple flask web site + - Install flask with uv + - build with the python:3.14-dev image + - test with the python:3.14 image + - Artifact to archive is a new image based on python:3.14 image and pushed to ttl.sh/smalls-pytest:3-14 + +### Python 3.12+ pip app + - Simple django web site + - Install django with pip + - build with the python:3.12-dev image + - test with the python:3.12 image + - Artifact to archive is a new image based on python:3.12 image and pushed to ttl.sh/smalls-pytest:3-12 + +### NodeJs 21 + npm app + - Simple npm built application (include some kind of npm library for the example) + - build with the node:21-dev image + - test with the node:21 image + - Artifact to archive is a new image based on node:21 image and pushed to ttl.sh/smalls-nodetest:21 + +### NodeJs 25 + pnpm app (using slim variant) + - Simple npm built application (include some kind of npm library for the example) + - build with the node:25-dev image + - test with the node:25-slim image + - Artifact to archive is a new image based on node:25-slim image and pushed to ttl.sh/smalls-nodetest:25 + diff --git a/jenkins/README.md b/jenkins/README.md new file mode 100644 index 00000000..3d4a0c10 --- /dev/null +++ b/jenkins/README.md @@ -0,0 +1,90 @@ +# Chainguard Jenkins Demo + +A self-contained Jenkins server running on Chainguard images, with pipeline jobs that build and test sample applications using Chainguard `*-dev` build images and Chainguard runtime images. + +All Jenkins infrastructure and all build/test/runtime images come from `cgr.dev/smalls.xyz`. + +## How it works + +```mermaid +flowchart LR + user[User] + host[(Host Docker daemon
OrbStack / Docker Desktop)] + jenkins[Jenkins controller
cgr.dev/smalls.xyz/jenkins] + apps[(./apps/*
local sources)] + build[Build container
maven:3-jdk17-dev] + test[Test container
amazon-corretto-jre:17-dev] + + user -->|http :8080| jenkins + apps -.->|bind mount| jenkins + jenkins -->|/var/run/docker.sock| host + host --> build + host --> test +``` + +Jenkins is built from [Dockerfile.jenkins](Dockerfile.jenkins), which layers a fixed plugin set and the Chainguard `docker-cli` binary onto `cgr.dev/smalls.xyz/jenkins:2-lts-jdk21-dev` (the controller runs on JDK 21; sample apps targeting older JDKs build inside their own per-stage Chainguard images). On startup, [Jenkins Configuration as Code (JCasC)](jenkins/casc/jenkins.yaml) creates the admin user and seeds pipeline jobs from [jobs.groovy](jenkins/casc/jobs.groovy) by reading each app's `Jenkinsfile` from disk. + +The controller talks to the **host's Docker daemon** via the mounted `/var/run/docker.sock` (DooD pattern). When a pipeline stage uses `agent { docker { image '...' } }`, Jenkins asks the host to spawn the build container. To make this work cleanly, `JENKINS_HOME` is bind-mounted from `/tmp/cgjenkins-home` at the **same absolute path on host and container** — so when the controller hands the host a `-v :` flag, the workspace path actually exists on the host. + +Each sample application lives under [apps/](apps/) as if it were a separate repository. The controller's first pipeline stage (`Checkout`) copies the app sources from the bind-mounted `/sources/apps//` into the workspace. Subsequent stages stash/unstash to share artifacts. + +## Prerequisites + +- Docker (Docker Desktop, OrbStack, or Linux Docker engine) +- `chainctl` CLI, authenticated against an org with access to `cgr.dev/smalls.xyz/*` + +Verify auth: +```sh +chainctl auth status +``` + +## Quick start + +**Step 1 — Generate the registry pull token.** [setup.sh](setup.sh) calls `chainctl auth pull-token create` and writes a Docker config to `.secrets/docker-config.json` (gitignored). Re-run when the token expires (default TTL is 30 days). + +```sh +cd jenkins +./setup.sh +``` + +**Step 2 — Start Jenkins.** + +```sh +docker compose up -d --build +``` + +Wait ~30s for Jenkins to finish initial startup (watch with `docker compose logs -f jenkins`), then open and log in: + +- Username: `admin` +- Password: `admin` (override via `JENKINS_ADMIN_PASSWORD` env in [docker-compose.yml](docker-compose.yml)) + +You should see one job, **corretto-java17-maven**. Click it → **Build Now**. The build runs four stages: + +1. **Checkout** — copies sources from `/sources/apps/corretto-java17-maven/` into the build workspace. +2. **Build** — `cgr.dev/smalls.xyz/maven:3-jdk17-dev` runs `mvn package`, producing `target/app.jar`. +3. **Test** — `cgr.dev/smalls.xyz/amazon-corretto-jre:17-dev` runs the JAR as a smoke test. +4. **Archive** — Jenkins archives `target/app.jar`; download from the build's "Build Artifacts" link. + +A clean build takes ~25s once images are cached locally. + +## Adding another sample app + +1. Create a directory under [apps/](apps/), e.g. `apps/python314-flask/`. +2. Add the application source plus a `Jenkinsfile`. The pipeline's first stage should be a `Checkout` that does `cp -R /sources/apps//. .`. +3. **Important:** in every `agent { docker { image '...' } }` block, add `args '--entrypoint='` so Jenkins can run `cat` to keep the container alive (Chainguard images all have a baked-in ENTRYPOINT pointing at the main binary). +4. Append one block to the `apps` list in [jenkins/casc/jobs.groovy](jenkins/casc/jobs.groovy). +5. `docker compose restart jenkins` — JCasC re-runs the seed and the new job appears. + +## Teardown + +```sh +docker compose down +sudo rm -rf /tmp/cgjenkins-home # remove the persisted Jenkins home +rm -rf .secrets # remove the pull-token Docker config +``` + +## Notes + +- The Test stage uses the `-dev` variant of the runtime image (`amazon-corretto-jre:17-dev` rather than `:17`) because Jenkins' `docker { image ... }` agent invokes `sh` steps, which require a shell. The shell-less runtime image is still the production deployment target — the `-dev` variant is purely a CI convenience for driving the JVM under Jenkins. +- The DooD pattern means Jenkins effectively has root-equivalent access to the host machine via the Docker socket. This is acceptable for a local demo but not for production. +- See [PLAN.md](PLAN.md) for the full list of sample apps planned. Currently only the first (Corretto Java 17 + Maven) is implemented; the rest will be added one at a time. diff --git a/jenkins/apps/corretto-java17-maven/Jenkinsfile b/jenkins/apps/corretto-java17-maven/Jenkinsfile new file mode 100644 index 00000000..8dc7fa0b --- /dev/null +++ b/jenkins/apps/corretto-java17-maven/Jenkinsfile @@ -0,0 +1,53 @@ +pipeline { + agent none + options { timestamps() } + + stages { + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/corretto-java17-maven/. .' + stash name: 'src', includes: '**', excludes: 'target/**' + } + } + + stage('Build') { + agent { + docker { + image 'cgr.dev/smalls.xyz/maven:3-jdk17-dev' + // Chainguard images set ENTRYPOINT to the main binary; clear it so + // Jenkins' `cat` command keeps the container alive for `docker exec`. + args '--entrypoint=' + reuseNode true + } + } + steps { + unstash 'src' + sh 'mvn -B -ntp clean package -DskipTests' + stash name: 'app-jar', includes: 'target/app.jar' + } + } + + stage('Test') { + agent { + docker { + image 'cgr.dev/smalls.xyz/amazon-corretto-jre:17-dev' + args '--entrypoint=' + reuseNode true + } + } + steps { + unstash 'app-jar' + sh 'java -jar target/app.jar' + } + } + + stage('Archive') { + agent any + steps { + unstash 'app-jar' + archiveArtifacts artifacts: 'target/app.jar', fingerprint: true + } + } + } +} diff --git a/jenkins/apps/corretto-java17-maven/README.md b/jenkins/apps/corretto-java17-maven/README.md new file mode 100644 index 00000000..0db2fe83 --- /dev/null +++ b/jenkins/apps/corretto-java17-maven/README.md @@ -0,0 +1,28 @@ +# corretto-java17-maven + +Hello-world Spring Boot console app, built with Maven on Amazon Corretto JDK 17. + +## Pipeline images + +| Stage | Image | +|-------|-------| +| Build | `cgr.dev/smalls.xyz/maven:3-jdk17-dev` | +| Test | `cgr.dev/smalls.xyz/amazon-corretto-jre:17-dev` | + +The intended runtime / deploy target is `cgr.dev/smalls.xyz/amazon-corretto-jre:17` (shell-less). The `-dev` variant is used in the Test stage only because Jenkins' `docker { image ... }` agent runs `sh` steps that require a shell. + +## Artifact + +The Spring Boot fat JAR (`target/app.jar`) is archived to Jenkins. Download it from the build's "Build Artifacts" link in the UI. + +## Running locally + +To produce and run the JAR outside Jenkins: + +```sh +docker run --rm -v "$PWD":/work -w /work cgr.dev/smalls.xyz/maven:3-jdk17-dev \ + mvn -B -ntp clean package -DskipTests + +docker run --rm -v "$PWD":/work -w /work cgr.dev/smalls.xyz/amazon-corretto-jre:17-dev \ + java -jar target/app.jar +``` diff --git a/jenkins/apps/corretto-java17-maven/pom.xml b/jenkins/apps/corretto-java17-maven/pom.xml new file mode 100644 index 00000000..1e8f72d1 --- /dev/null +++ b/jenkins/apps/corretto-java17-maven/pom.xml @@ -0,0 +1,45 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + com.example + hello-corretto17 + 0.0.1-SNAPSHOT + jar + hello-corretto17 + Hello-world Spring Boot console app on Chainguard Corretto JDK 17 + + + 17 + 17 + 17 + UTF-8 + + + + + org.springframework.boot + spring-boot-starter + + + + + app + + + org.springframework.boot + spring-boot-maven-plugin + + + + diff --git a/jenkins/apps/corretto-java17-maven/src/main/java/com/example/HelloApp.java b/jenkins/apps/corretto-java17-maven/src/main/java/com/example/HelloApp.java new file mode 100644 index 00000000..9db2a3d2 --- /dev/null +++ b/jenkins/apps/corretto-java17-maven/src/main/java/com/example/HelloApp.java @@ -0,0 +1,29 @@ +package com.example; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class HelloApp implements CommandLineRunner { + + public static void main(String[] args) { + SpringApplication.run(HelloApp.class, args); + } + + @Override + public void run(String... args) { + System.out.println("Hello from Spring Boot on Chainguard"); + System.out.println("--- runtime info ---"); + System.out.println("java.version : " + System.getProperty("java.version")); + System.out.println("java.vendor : " + System.getProperty("java.vendor")); + System.out.println("java.runtime : " + System.getProperty("java.runtime.name")); + System.out.println("os.name : " + System.getProperty("os.name")); + System.out.println("os.arch : " + System.getProperty("os.arch")); + System.out.println("user.dir : " + System.getProperty("user.dir")); + System.out.println("--- env (selected) ---"); + for (String key : new String[]{"JAVA_HOME", "PATH", "HOSTNAME"}) { + System.out.println(key + " : " + System.getenv(key)); + } + } +} diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml new file mode 100644 index 00000000..820544f6 --- /dev/null +++ b/jenkins/docker-compose.yml @@ -0,0 +1,36 @@ +name: chainguard-jenkins-demo + +# Architecture: DooD (Docker-out-of-Docker) — Jenkins talks to the host's +# Docker daemon via the mounted socket. This is more reliable than DinD on +# macOS Docker Desktop / OrbStack, where dockerd's 15-second containerd-init +# timeout fails under nested virtualization. +# +# JENKINS_HOME is bind-mounted from /tmp/cgjenkins-home at the SAME PATH on +# both host and container. That way, when Jenkins asks the host daemon to +# `docker run -v :` for a build container, the path +# exists on the host and the build container sees the workspace files. + +services: + jenkins: + build: + context: . + dockerfile: Dockerfile.jenkins + environment: + JENKINS_HOME: /tmp/cgjenkins-home + CASC_JENKINS_CONFIG: /tmp/cgjenkins-home/casc + JENKINS_ADMIN_PASSWORD: admin + # Point the docker CLI at the pull-token config generated by setup.sh. + DOCKER_CONFIG: /tmp/cgjenkins-home/.docker + # The host docker socket is owned root:root inside the container; add the + # jenkins user (uid 1000) to GID 0 so it can read the socket. + group_add: + - "0" + ports: + - "8080:8080" + - "50000:50000" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + - /tmp/cgjenkins-home:/tmp/cgjenkins-home + - ./jenkins/casc:/tmp/cgjenkins-home/casc:ro + - ./apps:/sources/apps:ro + - ./.secrets/docker-config.json:/tmp/cgjenkins-home/.docker/config.json:ro diff --git a/jenkins/jenkins/casc/jenkins.yaml b/jenkins/jenkins/casc/jenkins.yaml new file mode 100644 index 00000000..2aa6490f --- /dev/null +++ b/jenkins/jenkins/casc/jenkins.yaml @@ -0,0 +1,21 @@ +jenkins: + systemMessage: "Chainguard Jenkins demo — pipelines build & test on cgr.dev/smalls.xyz images." + numExecutors: 2 + mode: NORMAL + scmCheckoutRetryCount: 0 + securityRealm: + local: + allowsSignup: false + users: + - id: "admin" + password: "${JENKINS_ADMIN_PASSWORD:-admin}" + authorizationStrategy: + loggedInUsersCanDoAnything: + allowAnonymousRead: false + +unclassified: + location: + url: "http://localhost:8080/" + +jobs: + - file: /tmp/cgjenkins-home/casc/jobs.groovy diff --git a/jenkins/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy new file mode 100644 index 00000000..9829a0e0 --- /dev/null +++ b/jenkins/jenkins/casc/jobs.groovy @@ -0,0 +1,26 @@ +// Job-DSL seed: one pipelineJob per sample app under /sources/apps. +// To add an app: drop it into apps// with a Jenkinsfile, then add a block here. +// +// The Jenkinsfile body is inlined into the job at seed time so we don't need an SCM. +// Each pipeline's first stage copies the app sources from /sources into the workspace +// (these come from the bind-mounted ./apps directory, available on both the controller +// and the DinD daemon at the same path). + +def apps = [ + [ + name: 'corretto-java17-maven', + description: 'Spring Boot built with Maven on Chainguard Corretto JDK 17 images', + ], +] + +apps.each { app -> + pipelineJob(app.name) { + description(app.description) + definition { + cps { + script(new File("/sources/apps/${app.name}/Jenkinsfile").text) + sandbox(true) + } + } + } +} diff --git a/jenkins/jenkins/plugins.txt b/jenkins/jenkins/plugins.txt new file mode 100644 index 00000000..fc9d4087 --- /dev/null +++ b/jenkins/jenkins/plugins.txt @@ -0,0 +1,8 @@ +configuration-as-code +job-dsl +workflow-aggregator +docker-workflow +pipeline-utility-steps +git +timestamper +ws-cleanup diff --git a/jenkins/setup.sh b/jenkins/setup.sh new file mode 100755 index 00000000..bd8c1913 --- /dev/null +++ b/jenkins/setup.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# One-time setup: generate a Chainguard pull token for the smalls.xyz org and +# write a Docker config.json that the Jenkins container will use to pull +# cgr.dev/smalls.xyz/* images. Re-run when the token expires. +set -euo pipefail + +cd "$(dirname "$0")" + +ORG="${CHAINGUARD_ORG:-smalls.xyz}" +TOKEN_NAME="${TOKEN_NAME:-jenkins-demo}" +TTL="${TTL:-720h}" +OUT_DIR="$(pwd)/.secrets" +OUT_FILE="${OUT_DIR}/docker-config.json" + +mkdir -p "$OUT_DIR" +chmod 700 "$OUT_DIR" + +echo "Creating pull token (parent=${ORG}, ttl=${TTL})..." +TOKEN_OUTPUT=$(chainctl auth pull-token create \ + --parent="$ORG" \ + --name="$TOKEN_NAME" \ + --ttl="$TTL") + +# The output contains: docker login "cgr.dev" --username "" --password "" +USERNAME=$(echo "$TOKEN_OUTPUT" | grep -oE -- '--username "[^"]+"' | head -1 | sed -E 's/--username "([^"]+)"/\1/') +PASSWORD=$(echo "$TOKEN_OUTPUT" | grep -oE -- '--password "[^"]+"' | head -1 | sed -E 's/--password "([^"]+)"/\1/') + +if [[ -z "$USERNAME" || -z "$PASSWORD" ]]; then + echo "ERROR: failed to parse token from chainctl output:" >&2 + echo "$TOKEN_OUTPUT" >&2 + exit 1 +fi + +AUTH_B64=$(printf '%s:%s' "$USERNAME" "$PASSWORD" | base64 | tr -d '\n') + +cat > "$OUT_FILE" < Date: Wed, 6 May 2026 12:52:08 -0500 Subject: [PATCH 02/47] jenkins: add Adoptium Java 8 + Jetty/JSP runnable WAR sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second sample app from PLAN.md: a JSP web page built with Maven on Adoptium JDK 8 (cgr.dev/smalls.xyz/maven:3-jdk8-dev) and smoke-tested on adoptium-jre:adoptium-openjdk-8-dev. The artifact is a self-contained runnable WAR — `java -jar app.war` boots embedded Jetty 9.4 and serves the JSP. A small com.example.Main launcher lives at the WAR root (placed there via maven-war-plugin webResources), uses only JDK classes to extract WEB-INF/lib jars to a temp dir, and reflectively constructs Jetty's Server/WebAppContext. AnnotationConfiguration is wired in so JettyJasperInitializer runs and installs the InstanceManager that Apache Jasper needs for JSP compile. The pipeline's Test stage launches the WAR, polls until the port is listening, fetches index.jsp via wget, and asserts the rendered HTML contains the expected greeting. Also adds apps/*/target/ to the local .gitignore so verifying pipelines locally doesn't dirty the working tree. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/.gitignore | 3 + jenkins/apps/adoptium-java8-jetty/Jenkinsfile | 70 +++++++++++++++ jenkins/apps/adoptium-java8-jetty/README.md | 27 ++++++ jenkins/apps/adoptium-java8-jetty/pom.xml | 82 +++++++++++++++++ .../src/main/java/com/example/Main.java | 87 +++++++++++++++++++ .../src/main/webapp/WEB-INF/web.xml | 11 +++ .../src/main/webapp/index.jsp | 20 +++++ jenkins/jenkins/casc/jobs.groovy | 4 + 8 files changed, 304 insertions(+) create mode 100644 jenkins/apps/adoptium-java8-jetty/Jenkinsfile create mode 100644 jenkins/apps/adoptium-java8-jetty/README.md create mode 100644 jenkins/apps/adoptium-java8-jetty/pom.xml create mode 100644 jenkins/apps/adoptium-java8-jetty/src/main/java/com/example/Main.java create mode 100644 jenkins/apps/adoptium-java8-jetty/src/main/webapp/WEB-INF/web.xml create mode 100644 jenkins/apps/adoptium-java8-jetty/src/main/webapp/index.jsp diff --git a/jenkins/.gitignore b/jenkins/.gitignore index e0a7d4e7..9e695ff4 100644 --- a/jenkins/.gitignore +++ b/jenkins/.gitignore @@ -1,2 +1,5 @@ # Pull-token-derived Docker config — generated by setup.sh, must not be committed. .secrets/ + +# Maven build outputs — produced when running pipelines locally for verification. +apps/*/target/ diff --git a/jenkins/apps/adoptium-java8-jetty/Jenkinsfile b/jenkins/apps/adoptium-java8-jetty/Jenkinsfile new file mode 100644 index 00000000..a427111e --- /dev/null +++ b/jenkins/apps/adoptium-java8-jetty/Jenkinsfile @@ -0,0 +1,70 @@ +pipeline { + agent none + options { timestamps() } + + stages { + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/adoptium-java8-jetty/. .' + stash name: 'src', includes: '**', excludes: 'target/**' + } + } + + stage('Build') { + agent { + docker { + image 'cgr.dev/smalls.xyz/maven:3-jdk8-dev' + args '--entrypoint=' + reuseNode true + } + } + steps { + unstash 'src' + sh 'mvn -B -ntp clean package' + stash name: 'app-war', includes: 'target/app.war' + } + } + + stage('Test') { + agent { + docker { + image 'cgr.dev/smalls.xyz/adoptium-jre:adoptium-openjdk-8-dev' + args '--entrypoint=' + reuseNode true + } + } + steps { + unstash 'app-war' + // Smoke test: launch the runnable WAR, wait for it to listen, fetch + // index.jsp, verify the page rendered, then shut it down. + sh ''' + set -eu + java -jar target/app.war > server.log 2>&1 & + PID=$! + trap "kill ${PID} 2>/dev/null || true" EXIT + for i in $(seq 1 30); do + if wget -q -O response.html http://127.0.0.1:8080/ 2>/dev/null; then + break + fi + sleep 1 + done + echo "--- response.html ---" + cat response.html + echo "--- server.log ---" + cat server.log + grep -q "Hello from Jetty on Chainguard" response.html + echo "Smoke test passed" + ''' + } + } + + stage('Archive') { + agent any + steps { + unstash 'app-war' + archiveArtifacts artifacts: 'target/app.war', fingerprint: true + } + } + } +} diff --git a/jenkins/apps/adoptium-java8-jetty/README.md b/jenkins/apps/adoptium-java8-jetty/README.md new file mode 100644 index 00000000..a9329de3 --- /dev/null +++ b/jenkins/apps/adoptium-java8-jetty/README.md @@ -0,0 +1,27 @@ +# adoptium-java8-jetty + +Hello-world Jetty/JSP web app, built with Maven on Adoptium JDK 8 and packaged as a self-executing WAR. + +## Pipeline images + +| Stage | Image | +|-------|-------| +| Build | `cgr.dev/smalls.xyz/maven:3-jdk8-dev` | +| Test | `cgr.dev/smalls.xyz/adoptium-jre:adoptium-openjdk-8-dev` | + +The intended runtime / deploy target is `cgr.dev/smalls.xyz/adoptium-jre:adoptium-openjdk-8` (shell-less). The `-dev` variant is used in the Test stage only because Jenkins' `docker { image ... }` agent invokes `sh` steps, which require a shell. + +## Artifact + +The runnable WAR (`target/app.war`) is archived to Jenkins. Run it with: + +```sh +java -jar app.war +# Visit http://localhost:8080/ +``` + +The WAR self-bootstraps: a small `com.example.Main` class lives at the WAR root and uses only JDK classes to extract `WEB-INF/lib/*.jar` to a temp dir, then reflectively boots an embedded Jetty 9.4 server pointed at the WAR itself. + +## Smoke test + +The Test stage starts the WAR, waits for it to listen on port 8080, fetches `index.jsp`, and asserts the rendered HTML contains the expected greeting. Then it shuts the server down. diff --git a/jenkins/apps/adoptium-java8-jetty/pom.xml b/jenkins/apps/adoptium-java8-jetty/pom.xml new file mode 100644 index 00000000..46ba3add --- /dev/null +++ b/jenkins/apps/adoptium-java8-jetty/pom.xml @@ -0,0 +1,82 @@ + + + 4.0.0 + + com.example + hello-jetty-jdk8 + 0.0.1-SNAPSHOT + war + hello-jetty-jdk8 + Hello-world Jetty/JSP runnable WAR built on Adoptium JDK 8 + + + 1.8 + 1.8 + UTF-8 + 9.4.58.v20250814 + + + + + + org.eclipse.jetty + jetty-server + ${jetty.version} + runtime + + + org.eclipse.jetty + jetty-webapp + ${jetty.version} + runtime + + + org.eclipse.jetty + apache-jsp + ${jetty.version} + runtime + + + + org.eclipse.jetty + jetty-annotations + ${jetty.version} + runtime + + + + + app + + + maven-war-plugin + 3.4.0 + + + + com.example.Main + + + + + + ${project.build.outputDirectory} + / + + com/example/Main.class + + + + + + + + diff --git a/jenkins/apps/adoptium-java8-jetty/src/main/java/com/example/Main.java b/jenkins/apps/adoptium-java8-jetty/src/main/java/com/example/Main.java new file mode 100644 index 00000000..e2bf40b8 --- /dev/null +++ b/jenkins/apps/adoptium-java8-jetty/src/main/java/com/example/Main.java @@ -0,0 +1,87 @@ +package com.example; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.net.URL; +import java.net.URLClassLoader; +import java.security.ProtectionDomain; +import java.util.ArrayList; +import java.util.Enumeration; +import java.util.List; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; + +/** + * Bootstraps an embedded Jetty server that serves this WAR file. + * Lives at the WAR root (not WEB-INF/classes) so that `java -jar app.war` finds it. + * Uses only JDK classes so it compiles without Jetty on the classpath; Jetty is + * loaded reflectively at runtime from WEB-INF/lib/ inside the WAR. + */ +public class Main { + public static void main(String[] args) throws Exception { + int port = Integer.parseInt(System.getenv().getOrDefault("PORT", "8080")); + + ProtectionDomain pd = Main.class.getProtectionDomain(); + File warFile = new File(pd.getCodeSource().getLocation().toURI()); + + File tmpLibDir = new File(System.getProperty("java.io.tmpdir"), + "jetty-war-libs-" + System.currentTimeMillis()); + if (!tmpLibDir.mkdirs()) { + throw new IllegalStateException("Could not create " + tmpLibDir); + } + + List libUrls = new ArrayList<>(); + try (JarFile jar = new JarFile(warFile)) { + Enumeration entries = jar.entries(); + while (entries.hasMoreElements()) { + JarEntry e = entries.nextElement(); + String name = e.getName(); + if (e.isDirectory() || !name.startsWith("WEB-INF/lib/") || !name.endsWith(".jar")) { + continue; + } + File out = new File(tmpLibDir, name.substring("WEB-INF/lib/".length())); + try (InputStream in = jar.getInputStream(e); + FileOutputStream o = new FileOutputStream(out)) { + byte[] buf = new byte[8192]; + int n; + while ((n = in.read(buf)) > 0) o.write(buf, 0, n); + } + libUrls.add(out.toURI().toURL()); + } + } + + URLClassLoader cl = new URLClassLoader( + libUrls.toArray(new URL[0]), + Main.class.getClassLoader()); + Thread.currentThread().setContextClassLoader(cl); + + Class serverCls = cl.loadClass("org.eclipse.jetty.server.Server"); + Class webappCls = cl.loadClass("org.eclipse.jetty.webapp.WebAppContext"); + Class handlerCls = cl.loadClass("org.eclipse.jetty.server.Handler"); + Class configCls = cl.loadClass("org.eclipse.jetty.webapp.Configuration"); + Class annotConfigCls = cl.loadClass("org.eclipse.jetty.annotations.AnnotationConfiguration"); + + Object server = serverCls.getConstructor(int.class).newInstance(port); + Object webapp = webappCls.getDeclaredConstructor().newInstance(); + webappCls.getMethod("setContextPath", String.class).invoke(webapp, "/"); + webappCls.getMethod("setWar", String.class).invoke(webapp, warFile.getAbsolutePath()); + // Allow JSPs to compile despite the unusual classloader hierarchy. + webappCls.getMethod("setParentLoaderPriority", boolean.class).invoke(webapp, true); + + // Install the default Jetty configurations plus AnnotationConfiguration. + // The latter triggers ServletContainerInitializer scanning so apache-jsp's + // JettyJasperInitializer runs and installs the InstanceManager Jasper needs. + String[] defaults = (String[]) webappCls.getMethod("getDefaultConfigurationClasses").invoke(webapp); + String[] withAnnotations = new String[defaults.length + 1]; + System.arraycopy(defaults, 0, withAnnotations, 0, defaults.length); + withAnnotations[defaults.length] = annotConfigCls.getName(); + webappCls.getMethod("setConfigurationClasses", String[].class) + .invoke(webapp, (Object) withAnnotations); + + serverCls.getMethod("setHandler", handlerCls).invoke(server, webapp); + serverCls.getMethod("start").invoke(server); + System.out.println("Jetty listening on http://0.0.0.0:" + port + "/"); + serverCls.getMethod("join").invoke(server); + } +} diff --git a/jenkins/apps/adoptium-java8-jetty/src/main/webapp/WEB-INF/web.xml b/jenkins/apps/adoptium-java8-jetty/src/main/webapp/WEB-INF/web.xml new file mode 100644 index 00000000..1b6cb325 --- /dev/null +++ b/jenkins/apps/adoptium-java8-jetty/src/main/webapp/WEB-INF/web.xml @@ -0,0 +1,11 @@ + + + Chainguard Jetty Demo + + index.jsp + + diff --git a/jenkins/apps/adoptium-java8-jetty/src/main/webapp/index.jsp b/jenkins/apps/adoptium-java8-jetty/src/main/webapp/index.jsp new file mode 100644 index 00000000..48498095 --- /dev/null +++ b/jenkins/apps/adoptium-java8-jetty/src/main/webapp/index.jsp @@ -0,0 +1,20 @@ +<%@ page contentType="text/html;charset=UTF-8" %> + + +Chainguard Jetty Demo + +

Hello from Jetty on Chainguard

+

Runtime info

+ + + + + + + + + + +
PropertyValue
java.version<%= System.getProperty("java.version") %>
java.vendor<%= System.getProperty("java.vendor") %>
java.runtime.name<%= System.getProperty("java.runtime.name") %>
os.name<%= System.getProperty("os.name") %>
os.arch<%= System.getProperty("os.arch") %>
servlet container<%= application.getServerInfo() %>
JAVA_HOME<%= System.getenv("JAVA_HOME") %>
HOSTNAME<%= System.getenv("HOSTNAME") %>
+ + diff --git a/jenkins/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy index 9829a0e0..fb0ff68c 100644 --- a/jenkins/jenkins/casc/jobs.groovy +++ b/jenkins/jenkins/casc/jobs.groovy @@ -11,6 +11,10 @@ def apps = [ name: 'corretto-java17-maven', description: 'Spring Boot built with Maven on Chainguard Corretto JDK 17 images', ], + [ + name: 'adoptium-java8-jetty', + description: 'Jetty/JSP runnable WAR built on Chainguard Adoptium JDK 8 images', + ], ] apps.each { app -> From f2c26435f338d79e39ca01d2d8eb4c20901a1253 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Wed, 6 May 2026 14:02:55 -0500 Subject: [PATCH 03/47] jenkins: add OpenJDK 21 + Gradle standalone JAR sample MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third sample app from PLAN.md: a hello-world standalone runnable JAR built with Gradle on Chainguard's plain JDK 21 image and smoke-tested on jre:openjdk-21-dev. Build image is jdk:openjdk-21-dev which ships only the JDK, so the app includes the Gradle wrapper (bootstrapped from the gradle:8-jdk21-dev image). The pipeline runs `./gradlew --no-daemon clean jar` and overrides GRADLE_USER_HOME to point inside $WORKSPACE — Jenkins runs build containers as uid 1000 with no writable HOME, which otherwise breaks the wrapper's distribution cache. The Test stage runs the JAR and asserts it prints the expected greeting plus a JDK 21 version string. Note that this image reports `java.vendor: wolfi` rather than `Chainguard` (unlike the Corretto and Adoptium variants), so the smoke test asserts on java.version instead. Also folds the user's "Future enhancements" notes into PLAN.md (config- urable org, optional Harbor pull-through, push artifacts to Harbor instead of ttl.sh) and adds Gradle build dirs to the local .gitignore. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/.gitignore | 4 + jenkins/PLAN.md | 5 + jenkins/apps/openjdk21-gradle/Jenkinsfile | 63 +++++ jenkins/apps/openjdk21-gradle/README.md | 26 ++ .../apps/openjdk21-gradle/build.gradle.kts | 25 ++ .../gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 43764 bytes .../gradle/wrapper/gradle-wrapper.properties | 7 + jenkins/apps/openjdk21-gradle/gradlew | 251 ++++++++++++++++++ jenkins/apps/openjdk21-gradle/gradlew.bat | 94 +++++++ .../apps/openjdk21-gradle/settings.gradle.kts | 1 + .../src/main/java/com/example/Hello.java | 17 ++ jenkins/jenkins/casc/jobs.groovy | 4 + 12 files changed, 497 insertions(+) create mode 100644 jenkins/apps/openjdk21-gradle/Jenkinsfile create mode 100644 jenkins/apps/openjdk21-gradle/README.md create mode 100644 jenkins/apps/openjdk21-gradle/build.gradle.kts create mode 100644 jenkins/apps/openjdk21-gradle/gradle/wrapper/gradle-wrapper.jar create mode 100644 jenkins/apps/openjdk21-gradle/gradle/wrapper/gradle-wrapper.properties create mode 100755 jenkins/apps/openjdk21-gradle/gradlew create mode 100644 jenkins/apps/openjdk21-gradle/gradlew.bat create mode 100644 jenkins/apps/openjdk21-gradle/settings.gradle.kts create mode 100644 jenkins/apps/openjdk21-gradle/src/main/java/com/example/Hello.java diff --git a/jenkins/.gitignore b/jenkins/.gitignore index 9e695ff4..3489425e 100644 --- a/jenkins/.gitignore +++ b/jenkins/.gitignore @@ -3,3 +3,7 @@ # Maven build outputs — produced when running pipelines locally for verification. apps/*/target/ + +# Gradle build outputs and local caches — produced when running pipelines locally. +apps/*/build/ +apps/*/.gradle/ diff --git a/jenkins/PLAN.md b/jenkins/PLAN.md index 2426f7cd..2cbc23b0 100644 --- a/jenkins/PLAN.md +++ b/jenkins/PLAN.md @@ -55,3 +55,8 @@ Create sub-directories for the various sample applications with Jenkins pipeline - test with the node:25-slim image - Artifact to archive is a new image based on node:25-slim image and pushed to ttl.sh/smalls-nodetest:25 +## Future enhancements +- Decouple the demo from the smalls.xyz hard-coded org, make that configurable +- Add a harbor image registry option as a pull-through-mirror +- Use that harbor server as a destination for image pushes instead of ttl.sh + diff --git a/jenkins/apps/openjdk21-gradle/Jenkinsfile b/jenkins/apps/openjdk21-gradle/Jenkinsfile new file mode 100644 index 00000000..b90bf01d --- /dev/null +++ b/jenkins/apps/openjdk21-gradle/Jenkinsfile @@ -0,0 +1,63 @@ +pipeline { + agent none + options { timestamps() } + + stages { + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/openjdk21-gradle/. .' + stash name: 'src', includes: '**', excludes: 'build/**, .gradle/**' + } + } + + stage('Build') { + agent { + docker { + image 'cgr.dev/smalls.xyz/jdk:openjdk-21-dev' + args '--entrypoint=' + reuseNode true + } + } + // Jenkins runs the agent container as uid 1000 with no writable HOME, so + // point Gradle's user home at the workspace where it has write access. + environment { + GRADLE_USER_HOME = "${WORKSPACE}/.gradle" + } + steps { + unstash 'src' + sh './gradlew --no-daemon clean jar' + stash name: 'app-jar', includes: 'build/libs/app.jar' + } + } + + stage('Test') { + agent { + docker { + image 'cgr.dev/smalls.xyz/jre:openjdk-21-dev' + args '--entrypoint=' + reuseNode true + } + } + steps { + unstash 'app-jar' + sh ''' + set -e + out=$(java -jar build/libs/app.jar) + echo "$out" + echo "$out" | grep -q "Hello from Gradle on Chainguard" + echo "$out" | grep -q "java.version : 21" + echo "Smoke test passed" + ''' + } + } + + stage('Archive') { + agent any + steps { + unstash 'app-jar' + archiveArtifacts artifacts: 'build/libs/app.jar', fingerprint: true + } + } + } +} diff --git a/jenkins/apps/openjdk21-gradle/README.md b/jenkins/apps/openjdk21-gradle/README.md new file mode 100644 index 00000000..8113aa26 --- /dev/null +++ b/jenkins/apps/openjdk21-gradle/README.md @@ -0,0 +1,26 @@ +# openjdk21-gradle + +Hello-world standalone runnable JAR built with Gradle on Chainguard's OpenJDK 21. + +## Pipeline images + +| Stage | Image | +|-------|-------| +| Build | `cgr.dev/smalls.xyz/jdk:openjdk-21-dev` | +| Test | `cgr.dev/smalls.xyz/jre:openjdk-21-dev` | + +The intended runtime / deploy target is `cgr.dev/smalls.xyz/jre:openjdk-21` (shell-less). The `-dev` variant is used in the Test stage only because Jenkins' `docker { image ... }` agent invokes `sh` steps, which require a shell. + +## Gradle setup + +The build image (`jdk:openjdk-21-dev`) ships only the JDK, not Gradle, so the project uses the **Gradle wrapper** (`gradlew` + `gradle/wrapper/`). On first invocation, `gradlew` downloads the Gradle distribution declared in `gradle/wrapper/gradle-wrapper.properties`. The pipeline runs `./gradlew --no-daemon clean jar` so the daemon doesn't outlive the build container. + +## Artifact + +The runnable JAR (`build/libs/app.jar`) is archived to Jenkins. Run it with: + +```sh +java -jar app.jar +``` + +It prints `Hello from Gradle on Chainguard` followed by JDK / OS info and a couple of selected env vars. diff --git a/jenkins/apps/openjdk21-gradle/build.gradle.kts b/jenkins/apps/openjdk21-gradle/build.gradle.kts new file mode 100644 index 00000000..3be49c57 --- /dev/null +++ b/jenkins/apps/openjdk21-gradle/build.gradle.kts @@ -0,0 +1,25 @@ +plugins { + application + java +} + +group = "com.example" +version = "0.0.1-SNAPSHOT" + +java { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } +} + +application { + mainClass.set("com.example.Hello") +} + +tasks.jar { + archiveBaseName.set("app") + archiveVersion.set("") + manifest { + attributes("Main-Class" to "com.example.Hello") + } +} diff --git a/jenkins/apps/openjdk21-gradle/gradle/wrapper/gradle-wrapper.jar b/jenkins/apps/openjdk21-gradle/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..1b33c55baabb587c669f562ae36f953de2481846 GIT binary patch literal 43764 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 '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/jenkins/apps/openjdk21-gradle/gradlew.bat b/jenkins/apps/openjdk21-gradle/gradlew.bat new file mode 100644 index 00000000..5eed7ee8 --- /dev/null +++ b/jenkins/apps/openjdk21-gradle/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/jenkins/apps/openjdk21-gradle/settings.gradle.kts b/jenkins/apps/openjdk21-gradle/settings.gradle.kts new file mode 100644 index 00000000..63bef84b --- /dev/null +++ b/jenkins/apps/openjdk21-gradle/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "hello-openjdk21-gradle" diff --git a/jenkins/apps/openjdk21-gradle/src/main/java/com/example/Hello.java b/jenkins/apps/openjdk21-gradle/src/main/java/com/example/Hello.java new file mode 100644 index 00000000..3a571b4d --- /dev/null +++ b/jenkins/apps/openjdk21-gradle/src/main/java/com/example/Hello.java @@ -0,0 +1,17 @@ +package com.example; + +public class Hello { + public static void main(String[] args) { + System.out.println("Hello from Gradle on Chainguard"); + System.out.println("--- runtime info ---"); + System.out.println("java.version : " + System.getProperty("java.version")); + System.out.println("java.vendor : " + System.getProperty("java.vendor")); + System.out.println("java.runtime : " + System.getProperty("java.runtime.name")); + System.out.println("os.name : " + System.getProperty("os.name")); + System.out.println("os.arch : " + System.getProperty("os.arch")); + System.out.println("--- env (selected) ---"); + for (String key : new String[]{"JAVA_HOME", "PATH", "HOSTNAME"}) { + System.out.println(key + " : " + System.getenv(key)); + } + } +} diff --git a/jenkins/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy index fb0ff68c..c2346aee 100644 --- a/jenkins/jenkins/casc/jobs.groovy +++ b/jenkins/jenkins/casc/jobs.groovy @@ -15,6 +15,10 @@ def apps = [ name: 'adoptium-java8-jetty', description: 'Jetty/JSP runnable WAR built on Chainguard Adoptium JDK 8 images', ], + [ + name: 'openjdk21-gradle', + description: 'Standalone runnable JAR built with Gradle on Chainguard OpenJDK 21 images', + ], ] apps.each { app -> From b522422f722e5609705324dae34b0c7f0db139bb Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Wed, 6 May 2026 14:15:40 -0500 Subject: [PATCH 04/47] jenkins: add Python 3.14 + uv Flask sample with OCI image push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fourth sample app from PLAN.md and the first that produces an OCI image artifact rather than a file. Pipeline builds a Flask app with `uv pip install` on cgr.dev/smalls.xyz/python:3.14-dev, multi-stage docker-builds a minimal image whose runtime layer is the shell-less python:3.14, and pushes to ttl.sh/smalls-pytest:3-14. The Test stage runs the just-built image with `docker run --entrypoint=python ... -c ""` for an in-process smoke test — no port mapping or cross-container networking required, which sidesteps the shell-less runtime image cleanly. Two non-obvious gotchas, both around the build user: - The Dockerfile's build stage adds `USER 0` because the chainguard python:3.14-dev defaults to uid 65532, which can't write to /usr/lib/python3.14/site-packages. - The Jenkinsfile's Build deps stage passes `args '--user 0 --entrypoint='` for the same reason — Jenkins' docker-workflow plugin always runs agent containers as -u 1000:1000. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/apps/python314-uv-flask/Dockerfile | 17 +++++ jenkins/apps/python314-uv-flask/Jenkinsfile | 74 +++++++++++++++++++ jenkins/apps/python314-uv-flask/README.md | 28 +++++++ jenkins/apps/python314-uv-flask/app.py | 31 ++++++++ .../apps/python314-uv-flask/pyproject.toml | 8 ++ jenkins/jenkins/casc/jobs.groovy | 4 + 6 files changed, 162 insertions(+) create mode 100644 jenkins/apps/python314-uv-flask/Dockerfile create mode 100644 jenkins/apps/python314-uv-flask/Jenkinsfile create mode 100644 jenkins/apps/python314-uv-flask/README.md create mode 100644 jenkins/apps/python314-uv-flask/app.py create mode 100644 jenkins/apps/python314-uv-flask/pyproject.toml diff --git a/jenkins/apps/python314-uv-flask/Dockerfile b/jenkins/apps/python314-uv-flask/Dockerfile new file mode 100644 index 00000000..9bb8aa25 --- /dev/null +++ b/jenkins/apps/python314-uv-flask/Dockerfile @@ -0,0 +1,17 @@ +# syntax=docker/dockerfile:1 +# Stage 1: install Flask into the system site-packages with uv (the -dev image +# ships uv pre-installed). Stage 2 then copies the populated site-packages +# directory into the shell-less runtime python image — same Python minor +# version on both sides keeps the packages compatible. +FROM cgr.dev/smalls.xyz/python:3.14-dev AS build +USER 0 +WORKDIR /app +COPY pyproject.toml ./ +RUN uv pip install --system --no-cache flask + +FROM cgr.dev/smalls.xyz/python:3.14 +COPY --from=build /usr/lib/python3.14/site-packages /usr/lib/python3.14/site-packages +COPY app.py /app/app.py +WORKDIR /app +EXPOSE 8080 +ENTRYPOINT ["python", "/app/app.py"] diff --git a/jenkins/apps/python314-uv-flask/Jenkinsfile b/jenkins/apps/python314-uv-flask/Jenkinsfile new file mode 100644 index 00000000..db7b7047 --- /dev/null +++ b/jenkins/apps/python314-uv-flask/Jenkinsfile @@ -0,0 +1,74 @@ +pipeline { + agent none + options { timestamps() } + + environment { + IMAGE = 'ttl.sh/smalls-pytest:3-14' + } + + stages { + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/python314-uv-flask/. .' + stash name: 'src', includes: '**' + } + } + + stage('Build deps') { + agent { + docker { + image 'cgr.dev/smalls.xyz/python:3.14-dev' + // Run as root: uv needs to write to /usr/lib/python3.14/site-packages + // which the default uid (65532) can't touch. + args '--user 0 --entrypoint=' + reuseNode true + } + } + steps { + unstash 'src' + // Sanity-check that the dependency declaration in pyproject.toml + // resolves cleanly under the build image. The runtime image's + // site-packages is populated separately by the Dockerfile. + sh 'uv pip install --system --no-cache flask' + sh 'python -c "import flask; print(\\"flask\\", flask.__version__)"' + } + } + + stage('Image build') { + agent any + steps { + unstash 'src' + sh 'docker build -t "$IMAGE" .' + } + } + + stage('Test') { + agent any + steps { + // Run the built runtime image (which is shell-less) using an in-process + // Flask test client — no port mapping or networking required. + sh ''' + set -e + out=$(docker run --rm --entrypoint=python -e PYTHONPATH=/app "$IMAGE" -c " +from app import app +c = app.test_client() +r = c.get('/') +assert r.status_code == 200, 'unexpected status: ' + str(r.status_code) +assert b'Hello from Flask on Chainguard' in r.data, 'greeting missing' +print('Smoke test passed') +") + echo "$out" + ''' + } + } + + stage('Push') { + agent any + steps { + sh 'docker push "$IMAGE"' + sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' + } + } + } +} diff --git a/jenkins/apps/python314-uv-flask/README.md b/jenkins/apps/python314-uv-flask/README.md new file mode 100644 index 00000000..61d8d8c4 --- /dev/null +++ b/jenkins/apps/python314-uv-flask/README.md @@ -0,0 +1,28 @@ +# python314-uv-flask + +Hello-world Flask web app on Chainguard's Python 3.14, with `uv` as the package manager. The pipeline's archived artifact is an OCI image pushed to `ttl.sh/smalls-pytest:3-14` — not a file checked into Jenkins' archive store. + +## Pipeline images + +| Stage | Image | +|-------------|-------| +| Build deps | `cgr.dev/smalls.xyz/python:3.14-dev` (ships `uv` pre-installed) | +| Image build | host docker daemon (multi-stage build) | +| Test | runs the just-built image | +| Push | `ttl.sh/smalls-pytest:3-14` | + +The runtime image (final stage of the Dockerfile) is the shell-less `cgr.dev/smalls.xyz/python:3.14`. Site-packages from the build stage are copied across — same Python minor version on both sides keeps the packages compatible. + +## Smoke test + +Because the runtime image has no shell, the Test stage runs the image with `--entrypoint=python` and an in-process Flask test client (`from app import app; app.test_client().get('/')`). This avoids any need to open a port, set up cross-container networking, or install HTTP tools in the runtime image. + +## Pull and run + +```sh +docker pull ttl.sh/smalls-pytest:3-14 +docker run --rm -p 8080:8080 ttl.sh/smalls-pytest:3-14 +# Visit http://localhost:8080/ +``` + +Note: ttl.sh tags expire (default 24 hours). Re-run the pipeline to refresh. diff --git a/jenkins/apps/python314-uv-flask/app.py b/jenkins/apps/python314-uv-flask/app.py new file mode 100644 index 00000000..d8ba1293 --- /dev/null +++ b/jenkins/apps/python314-uv-flask/app.py @@ -0,0 +1,31 @@ +import os +import platform +import sys +from flask import Flask, jsonify + +app = Flask(__name__) + + +@app.route("/") +def index(): + return ( + "

Hello from Flask on Chainguard

" + "

Runtime info

" + "" + f"" + f"" + f"" + f"" + f"" + f"" + "
propertyvalue
python.version{platform.python_version()}
python.implementation{platform.python_implementation()}
os.name{platform.system()}
os.arch{platform.machine()}
HOSTNAME{os.environ.get('HOSTNAME', '?')}
" + ) + + +@app.route("/healthz") +def healthz(): + return jsonify(status="ok", python=sys.version.split()[0]) + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=int(os.environ.get("PORT", "8080"))) diff --git a/jenkins/apps/python314-uv-flask/pyproject.toml b/jenkins/apps/python314-uv-flask/pyproject.toml new file mode 100644 index 00000000..ca96d574 --- /dev/null +++ b/jenkins/apps/python314-uv-flask/pyproject.toml @@ -0,0 +1,8 @@ +[project] +name = "hello-flask-py314" +version = "0.0.1" +description = "Hello-world Flask app on Chainguard Python 3.14" +requires-python = ">=3.14" +dependencies = [ + "flask>=3.1", +] diff --git a/jenkins/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy index c2346aee..6e8daf50 100644 --- a/jenkins/jenkins/casc/jobs.groovy +++ b/jenkins/jenkins/casc/jobs.groovy @@ -19,6 +19,10 @@ def apps = [ name: 'openjdk21-gradle', description: 'Standalone runnable JAR built with Gradle on Chainguard OpenJDK 21 images', ], + [ + name: 'python314-uv-flask', + description: 'Flask app on Chainguard Python 3.14 with uv; archived as OCI image to ttl.sh', + ], ] apps.each { app -> From 3947c279a699d25b1fa8c3118e6bf8a66799d85b Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Wed, 6 May 2026 14:20:27 -0500 Subject: [PATCH 05/47] jenkins: add Python 3.12 + pip + Django sample with OCI image push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth sample app from PLAN.md. Same shape as the python314-uv-flask pipeline (build deps in dev image, multi-stage docker build, in-process test client smoke test, push to ttl.sh) with two substitutions: pip for uv, and Django for Flask. The Django site is a single app.py using settings.configure() — keeps the demo focused on packaging+containerization rather than scaffolding. `python app.py` defaults to runserver 0.0.0.0:8080 --noreload. Pushed image: ttl.sh/smalls-pytest:3-12. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/apps/python312-pip-django/Dockerfile | 18 +++++ jenkins/apps/python312-pip-django/Jenkinsfile | 72 +++++++++++++++++++ jenkins/apps/python312-pip-django/README.md | 30 ++++++++ jenkins/apps/python312-pip-django/app.py | 49 +++++++++++++ .../python312-pip-django/requirements.txt | 1 + jenkins/jenkins/casc/jobs.groovy | 4 ++ 6 files changed, 174 insertions(+) create mode 100644 jenkins/apps/python312-pip-django/Dockerfile create mode 100644 jenkins/apps/python312-pip-django/Jenkinsfile create mode 100644 jenkins/apps/python312-pip-django/README.md create mode 100644 jenkins/apps/python312-pip-django/app.py create mode 100644 jenkins/apps/python312-pip-django/requirements.txt diff --git a/jenkins/apps/python312-pip-django/Dockerfile b/jenkins/apps/python312-pip-django/Dockerfile new file mode 100644 index 00000000..0b9c4508 --- /dev/null +++ b/jenkins/apps/python312-pip-django/Dockerfile @@ -0,0 +1,18 @@ +# syntax=docker/dockerfile:1 +# Stage 1: install Django into the system site-packages with pip. The default +# uid (65532) on python:3.12-dev can't write there, so switch to root for the +# install. Stage 2 then copies the populated site-packages into the shell-less +# python:3.12 runtime image — same Python minor version on both sides keeps +# the packages compatible. +FROM cgr.dev/smalls.xyz/python:3.12-dev AS build +USER 0 +WORKDIR /app +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +FROM cgr.dev/smalls.xyz/python:3.12 +COPY --from=build /usr/lib/python3.12/site-packages /usr/lib/python3.12/site-packages +COPY app.py /app/app.py +WORKDIR /app +EXPOSE 8080 +ENTRYPOINT ["python", "/app/app.py"] diff --git a/jenkins/apps/python312-pip-django/Jenkinsfile b/jenkins/apps/python312-pip-django/Jenkinsfile new file mode 100644 index 00000000..94079b95 --- /dev/null +++ b/jenkins/apps/python312-pip-django/Jenkinsfile @@ -0,0 +1,72 @@ +pipeline { + agent none + options { timestamps() } + + environment { + IMAGE = 'ttl.sh/smalls-pytest:3-12' + } + + stages { + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/python312-pip-django/. .' + stash name: 'src', includes: '**' + } + } + + stage('Build deps') { + agent { + docker { + image 'cgr.dev/smalls.xyz/python:3.12-dev' + // pip needs to write to /usr/lib/python3.12/site-packages, which the + // default uid (65532) can't touch. + args '--user 0 --entrypoint=' + reuseNode true + } + } + steps { + unstash 'src' + sh 'pip install --no-cache-dir -r requirements.txt' + sh 'python -c "import django; print(\\"django\\", django.get_version())"' + } + } + + stage('Image build') { + agent any + steps { + unstash 'src' + sh 'docker build -t "$IMAGE" .' + } + } + + stage('Test') { + agent any + steps { + // Run the built shell-less runtime image with --entrypoint=python and + // exercise the Django test client in-process — no need for ports or + // cross-container networking. + sh ''' + set -e + out=$(docker run --rm --entrypoint=python -e PYTHONPATH=/app "$IMAGE" -c " +import app # configures settings via settings.configure() +from django.test import Client +r = Client().get('/') +assert r.status_code == 200, 'unexpected status: ' + str(r.status_code) +assert b'Hello from Django on Chainguard' in r.content, 'greeting missing' +print('Smoke test passed') +") + echo "$out" + ''' + } + } + + stage('Push') { + agent any + steps { + sh 'docker push "$IMAGE"' + sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' + } + } + } +} diff --git a/jenkins/apps/python312-pip-django/README.md b/jenkins/apps/python312-pip-django/README.md new file mode 100644 index 00000000..5a507c16 --- /dev/null +++ b/jenkins/apps/python312-pip-django/README.md @@ -0,0 +1,30 @@ +# python312-pip-django + +Hello-world Django site on Chainguard's Python 3.12, with `pip` as the package manager. The pipeline's archived artifact is an OCI image pushed to `ttl.sh/smalls-pytest:3-12`. + +## Pipeline images + +| Stage | Image | +|-------------|-------| +| Build deps | `cgr.dev/smalls.xyz/python:3.12-dev` | +| Image build | host docker daemon (multi-stage build) | +| Test | runs the just-built image | +| Push | `ttl.sh/smalls-pytest:3-12` | + +## Single-file Django + +The whole site is one `app.py` — `settings.configure()` instead of a `settings.py`, one URL pattern, one view. This keeps the demo focused on packaging+containerization rather than Django project scaffolding. `python app.py` defaults to `runserver 0.0.0.0:8080 --noreload`. + +## Smoke test + +Same trick as the Flask sibling: the runtime image is shell-less, so the Test stage runs the image with `--entrypoint=python` and exercises the Django **test client** (`Client().get('/')`) in-process. No port exposure, no network plumbing. + +## Pull and run + +```sh +docker pull ttl.sh/smalls-pytest:3-12 +docker run --rm -p 8080:8080 ttl.sh/smalls-pytest:3-12 +# Visit http://localhost:8080/ +``` + +ttl.sh tags expire (default 24 hours). Re-run the pipeline to refresh. diff --git a/jenkins/apps/python312-pip-django/app.py b/jenkins/apps/python312-pip-django/app.py new file mode 100644 index 00000000..49e93e6e --- /dev/null +++ b/jenkins/apps/python312-pip-django/app.py @@ -0,0 +1,49 @@ +""" +Single-file Django demo app — keeps the project shape minimal for a hello-world +container. Run `python app.py` to serve on 0.0.0.0:8080 (Django's runserver). +""" +import os +import platform +import sys +from django.conf import settings +from django.core.management import execute_from_command_line +from django.http import HttpResponse +from django.urls import path + +settings.configure( + DEBUG=False, + SECRET_KEY="demo-not-secret", + ROOT_URLCONF=__name__, + ALLOWED_HOSTS=["*"], + INSTALLED_APPS=["django.contrib.contenttypes", "django.contrib.auth"], + MIDDLEWARE=[], + DATABASES={}, + USE_TZ=True, +) + + +def hello(request): + import django + body = ( + "

Hello from Django on Chainguard

" + "

Runtime info

" + "" + "" + f"" + f"" + f"" + f"" + f"" + f"" + "
propertyvalue
django.version{django.get_version()}
python.version{platform.python_version()}
python.implementation{platform.python_implementation()}
os.name{platform.system()}
os.arch{platform.machine()}
HOSTNAME{os.environ.get('HOSTNAME', '?')}
" + ) + return HttpResponse(body) + + +urlpatterns = [path("", hello)] + + +if __name__ == "__main__": + if len(sys.argv) == 1: + sys.argv.extend(["runserver", "0.0.0.0:8080", "--noreload"]) + execute_from_command_line(sys.argv) diff --git a/jenkins/apps/python312-pip-django/requirements.txt b/jenkins/apps/python312-pip-django/requirements.txt new file mode 100644 index 00000000..3ac41513 --- /dev/null +++ b/jenkins/apps/python312-pip-django/requirements.txt @@ -0,0 +1 @@ +Django>=5.2,<6.0 diff --git a/jenkins/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy index 6e8daf50..f8387955 100644 --- a/jenkins/jenkins/casc/jobs.groovy +++ b/jenkins/jenkins/casc/jobs.groovy @@ -23,6 +23,10 @@ def apps = [ name: 'python314-uv-flask', description: 'Flask app on Chainguard Python 3.14 with uv; archived as OCI image to ttl.sh', ], + [ + name: 'python312-pip-django', + description: 'Django site on Chainguard Python 3.12 with pip; archived as OCI image to ttl.sh', + ], ] apps.each { app -> From 15c7ace5e6b960cff99dede6180ea5860afb0d01 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Wed, 6 May 2026 14:32:48 -0500 Subject: [PATCH 06/47] jenkins: add Node 22 + npm + Express sample with OCI image push MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sixth sample app — substituted Node 22 LTS for the PLAN.md original "Node 21" since Node 21 is EOL and not in the smalls.xyz catalog. Same shape as the python OCI-image samples: Build deps stage on the dev image (sanity-checks the package.json), multi-stage docker build into the shell-less runtime image, in-process smoke test, push to ttl.sh/smalls-nodetest:22. The smoke test runs the runtime image with `--entrypoint=/usr/bin/node` and an inline `-e` script that boots the express app on an ephemeral loopback port, issues a self-`http.get('/')`, and asserts the response. No host port mapping or cross-container networking required. Sets HOME=$WORKSPACE in the Build deps stage — Jenkins runs the agent container as uid 1000 with no writable home, which otherwise breaks npm's cache (~/.npm). Same kind of fix used for Gradle. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/apps/node22-npm-express/Dockerfile | 16 ++++ jenkins/apps/node22-npm-express/Jenkinsfile | 89 ++++++++++++++++++++ jenkins/apps/node22-npm-express/README.md | 41 +++++++++ jenkins/apps/node22-npm-express/package.json | 14 +++ jenkins/apps/node22-npm-express/server.js | 35 ++++++++ jenkins/jenkins/casc/jobs.groovy | 4 + 6 files changed, 199 insertions(+) create mode 100644 jenkins/apps/node22-npm-express/Dockerfile create mode 100644 jenkins/apps/node22-npm-express/Jenkinsfile create mode 100644 jenkins/apps/node22-npm-express/README.md create mode 100644 jenkins/apps/node22-npm-express/package.json create mode 100644 jenkins/apps/node22-npm-express/server.js diff --git a/jenkins/apps/node22-npm-express/Dockerfile b/jenkins/apps/node22-npm-express/Dockerfile new file mode 100644 index 00000000..9fea17e5 --- /dev/null +++ b/jenkins/apps/node22-npm-express/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 +# Stage 1: install npm dependencies on the dev image, which ships npm. +# Stage 2 then copies the populated node_modules + app source into the +# shell-less runtime image (node:22), which has only the node binary. +FROM cgr.dev/smalls.xyz/node:22-dev AS build +WORKDIR /app +COPY package.json ./ +RUN npm install --omit=dev --no-audit --no-fund + +FROM cgr.dev/smalls.xyz/node:22 +WORKDIR /app +COPY --from=build /app/node_modules ./node_modules +COPY package.json server.js ./ +EXPOSE 8080 +# The base image's ENTRYPOINT is already /usr/bin/node — pass the script as CMD. +CMD ["server.js"] diff --git a/jenkins/apps/node22-npm-express/Jenkinsfile b/jenkins/apps/node22-npm-express/Jenkinsfile new file mode 100644 index 00000000..240dce15 --- /dev/null +++ b/jenkins/apps/node22-npm-express/Jenkinsfile @@ -0,0 +1,89 @@ +pipeline { + agent none + options { timestamps() } + + environment { + IMAGE = 'ttl.sh/smalls-nodetest:22' + } + + stages { + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/node22-npm-express/. .' + stash name: 'src', includes: '**' + } + } + + stage('Build deps') { + agent { + docker { + image 'cgr.dev/smalls.xyz/node:22-dev' + args '--entrypoint=' + reuseNode true + } + } + // Jenkins runs the agent container as uid 1000 with no writable HOME, so + // point npm's cache at the workspace where it has write access. + environment { + HOME = "${WORKSPACE}" + } + steps { + unstash 'src' + // Smoke-check the dependency declaration on the dev image. The runtime + // image's node_modules is populated separately by the Dockerfile. + sh 'npm install --no-audit --no-fund' + sh 'node -e "console.log(\\"express\\", require(\\"express/package.json\\").version)"' + } + } + + stage('Image build') { + agent any + steps { + unstash 'src' + sh 'docker build -t "$IMAGE" .' + } + } + + stage('Test') { + agent any + steps { + // Run the built shell-less runtime image with the entrypoint overridden + // to evaluate an inline test script: bind to an ephemeral port, hit /, + // assert the rendered HTML, exit. No host networking, no port mapping. + sh ''' + set -e + out=$(docker run --rm --entrypoint=/usr/bin/node "$IMAGE" -e " +const http = require('http'); +const app = require('/app/server.js'); +const server = app.listen(0, '127.0.0.1', () => { + const port = server.address().port; + http.get('http://127.0.0.1:' + port + '/', (res) => { + let body = ''; + res.on('data', (d) => body += d); + res.on('end', () => { + server.close(); + if (res.statusCode === 200 && body.includes('Hello from Node on Chainguard')) { + console.log('Smoke test passed'); + process.exit(0); + } + console.error('Smoke test FAILED status=' + res.statusCode); + process.exit(1); + }); + }).on('error', (e) => { console.error(e); process.exit(2); }); +}); +") + echo "$out" + ''' + } + } + + stage('Push') { + agent any + steps { + sh 'docker push "$IMAGE"' + sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' + } + } + } +} diff --git a/jenkins/apps/node22-npm-express/README.md b/jenkins/apps/node22-npm-express/README.md new file mode 100644 index 00000000..36b90654 --- /dev/null +++ b/jenkins/apps/node22-npm-express/README.md @@ -0,0 +1,41 @@ +# node22-npm-express + +Hello-world Express web app on Chainguard's Node 22, with `npm` as the package manager. Pipeline artifact is an OCI image pushed to `ttl.sh/smalls-nodetest:22`. + +> Note: PLAN.md originally called for Node 21, but that line is EOL and not in the smalls.xyz catalog. We use Node 22 LTS (the natural successor) instead. + +## Pipeline images + +| Stage | Image | +|-------------|-------| +| Build deps | `cgr.dev/smalls.xyz/node:22-dev` (ships `npm`) | +| Image build | host docker daemon (multi-stage build) | +| Test | runs the just-built image | +| Push | `ttl.sh/smalls-nodetest:22` | + +## npm libraries used + +- `express` (web server) +- `picocolors` (terminal coloring for the startup banner — included to satisfy PLAN.md's "include some kind of npm library" requirement with something visibly small and fun) + +## Smoke test + +The runtime image is shell-less (just `/usr/bin/node`), so the Test stage runs the image with `--entrypoint=/usr/bin/node` and an inline `-e` script that: + +1. `require()`s the express app +2. binds to an ephemeral loopback port +3. issues a self-`http.get('/')` +4. asserts status 200 + greeting present +5. exits + +This avoids opening any host port or routing across docker networks. + +## Pull and run + +```sh +docker pull ttl.sh/smalls-nodetest:22 +docker run --rm -p 8080:8080 ttl.sh/smalls-nodetest:22 +# Visit http://localhost:8080/ +``` + +ttl.sh tags expire (default 24 hours). Re-run the pipeline to refresh. diff --git a/jenkins/apps/node22-npm-express/package.json b/jenkins/apps/node22-npm-express/package.json new file mode 100644 index 00000000..411a2eb4 --- /dev/null +++ b/jenkins/apps/node22-npm-express/package.json @@ -0,0 +1,14 @@ +{ + "name": "hello-node22-express", + "version": "0.0.1", + "private": true, + "description": "Hello-world Express app on Chainguard Node 22", + "main": "server.js", + "engines": { + "node": ">=22" + }, + "dependencies": { + "express": "^5.1.0", + "picocolors": "^1.1.1" + } +} diff --git a/jenkins/apps/node22-npm-express/server.js b/jenkins/apps/node22-npm-express/server.js new file mode 100644 index 00000000..481e9eab --- /dev/null +++ b/jenkins/apps/node22-npm-express/server.js @@ -0,0 +1,35 @@ +const os = require('node:os'); +const process = require('node:process'); +const express = require('express'); +const pc = require('picocolors'); + +const app = express(); + +app.get('/', (_req, res) => { + res.type('html').send(` + +Chainguard Node Demo + +

Hello from Node on Chainguard

+

Runtime info

+ + + + + + + + +
propertyvalue
node.version${process.version}
express.version${require('express/package.json').version}
os.platform${process.platform}
os.arch${process.arch}
os.release${os.release()}
HOSTNAME${process.env.HOSTNAME || '?'}
+ +`); +}); + +if (require.main === module) { + const port = parseInt(process.env.PORT || '8080', 10); + app.listen(port, '0.0.0.0', () => { + console.log(pc.green(`Listening on http://0.0.0.0:${port}/`)); + }); +} + +module.exports = app; diff --git a/jenkins/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy index f8387955..75882836 100644 --- a/jenkins/jenkins/casc/jobs.groovy +++ b/jenkins/jenkins/casc/jobs.groovy @@ -27,6 +27,10 @@ def apps = [ name: 'python312-pip-django', description: 'Django site on Chainguard Python 3.12 with pip; archived as OCI image to ttl.sh', ], + [ + name: 'node22-npm-express', + description: 'Express app on Chainguard Node 22 with npm; archived as OCI image to ttl.sh', + ], ] apps.each { app -> From 7fc852689c8eaecd16e6202a22bcce874795b270 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Wed, 6 May 2026 14:38:06 -0500 Subject: [PATCH 07/47] jenkins: add Node 25 + pnpm + Express sample on node:25-slim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seventh and final sample app from PLAN.md. Same shape as the Node 22 sibling but with three substitutions: pnpm for npm, node:25-slim (smaller runtime variant) for node:22, and nanoid as the npm library. The slim variant has the same shape as the regular runtime image — shell-less, /usr/bin/node entrypoint, /app workdir — so the smoke test uses the same in-process self-request trick. Notes on pnpm: - The dev image (node:25-dev) ships pnpm 10.33 pre-installed, no install step needed. - `pnpm install --prod --frozen-lockfile` fails fast if pnpm-lock.yaml drifts from package.json — useful in CI. - pnpm's symlink-based node_modules layout survives the Dockerfile `COPY --from=build /app/node_modules` because all symlinks point inside that directory tree. Sets HOME=$WORKSPACE in the Build deps stage to give pnpm a writable store/cache, same fix used for npm in the Node 22 sample. Adds apps/*/node_modules/ and apps/*/.pnpm-store/ to the local .gitignore so local lockfile bootstraps don't leak install artifacts into commits. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/.gitignore | 4 + jenkins/apps/node25-pnpm-express/Dockerfile | 16 + jenkins/apps/node25-pnpm-express/Jenkinsfile | 84 +++ jenkins/apps/node25-pnpm-express/README.md | 36 ++ jenkins/apps/node25-pnpm-express/package.json | 14 + .../apps/node25-pnpm-express/pnpm-lock.yaml | 573 ++++++++++++++++++ jenkins/apps/node25-pnpm-express/server.js | 37 ++ jenkins/jenkins/casc/jobs.groovy | 4 + 8 files changed, 768 insertions(+) create mode 100644 jenkins/apps/node25-pnpm-express/Dockerfile create mode 100644 jenkins/apps/node25-pnpm-express/Jenkinsfile create mode 100644 jenkins/apps/node25-pnpm-express/README.md create mode 100644 jenkins/apps/node25-pnpm-express/package.json create mode 100644 jenkins/apps/node25-pnpm-express/pnpm-lock.yaml create mode 100644 jenkins/apps/node25-pnpm-express/server.js diff --git a/jenkins/.gitignore b/jenkins/.gitignore index 3489425e..c9ff9afe 100644 --- a/jenkins/.gitignore +++ b/jenkins/.gitignore @@ -7,3 +7,7 @@ apps/*/target/ # Gradle build outputs and local caches — produced when running pipelines locally. apps/*/build/ apps/*/.gradle/ + +# Node module dirs — produced when running npm/pnpm install locally. +apps/*/node_modules/ +apps/*/.pnpm-store/ diff --git a/jenkins/apps/node25-pnpm-express/Dockerfile b/jenkins/apps/node25-pnpm-express/Dockerfile new file mode 100644 index 00000000..86ade7c9 --- /dev/null +++ b/jenkins/apps/node25-pnpm-express/Dockerfile @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 +# Stage 1: install production deps with pnpm on the dev image. Stage 2 copies +# the populated node_modules into the slim runtime variant — node:25-slim has +# only the node binary (no shell, no npm/pnpm). +FROM cgr.dev/smalls.xyz/node:25-dev AS build +WORKDIR /app +COPY package.json pnpm-lock.yaml ./ +# --frozen-lockfile fails the build if the lockfile is out of sync with package.json +RUN pnpm install --prod --frozen-lockfile + +FROM cgr.dev/smalls.xyz/node:25-slim +WORKDIR /app +COPY --from=build /app/node_modules ./node_modules +COPY package.json server.js ./ +EXPOSE 8080 +CMD ["server.js"] diff --git a/jenkins/apps/node25-pnpm-express/Jenkinsfile b/jenkins/apps/node25-pnpm-express/Jenkinsfile new file mode 100644 index 00000000..37e445c8 --- /dev/null +++ b/jenkins/apps/node25-pnpm-express/Jenkinsfile @@ -0,0 +1,84 @@ +pipeline { + agent none + options { timestamps() } + + environment { + IMAGE = 'ttl.sh/smalls-nodetest:25' + } + + stages { + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/node25-pnpm-express/. .' + stash name: 'src', includes: '**' + } + } + + stage('Build deps') { + agent { + docker { + image 'cgr.dev/smalls.xyz/node:25-dev' + args '--entrypoint=' + reuseNode true + } + } + // Jenkins runs the agent container as uid 1000 with no writable HOME, so + // point pnpm's store/cache at the workspace where it has write access. + environment { + HOME = "${WORKSPACE}" + } + steps { + unstash 'src' + sh 'pnpm install --prod --frozen-lockfile' + sh 'node -e "console.log(\\"express\\", require(\\"express/package.json\\").version, \\"nanoid\\", require(\\"nanoid/package.json\\").version)"' + } + } + + stage('Image build') { + agent any + steps { + unstash 'src' + sh 'docker build -t "$IMAGE" .' + } + } + + stage('Test') { + agent any + steps { + sh ''' + set -e + out=$(docker run --rm --entrypoint=/usr/bin/node "$IMAGE" -e " +const http = require('http'); +const app = require('/app/server.js'); +const server = app.listen(0, '127.0.0.1', () => { + const port = server.address().port; + http.get('http://127.0.0.1:' + port + '/', (res) => { + let body = ''; + res.on('data', (d) => body += d); + res.on('end', () => { + server.close(); + if (res.statusCode === 200 && body.includes('Hello from Node on Chainguard (slim)')) { + console.log('Smoke test passed'); + process.exit(0); + } + console.error('Smoke test FAILED status=' + res.statusCode); + process.exit(1); + }); + }).on('error', (e) => { console.error(e); process.exit(2); }); +}); +") + echo "$out" + ''' + } + } + + stage('Push') { + agent any + steps { + sh 'docker push "$IMAGE"' + sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' + } + } + } +} diff --git a/jenkins/apps/node25-pnpm-express/README.md b/jenkins/apps/node25-pnpm-express/README.md new file mode 100644 index 00000000..3e3bcdf5 --- /dev/null +++ b/jenkins/apps/node25-pnpm-express/README.md @@ -0,0 +1,36 @@ +# node25-pnpm-express + +Hello-world Express web app on Chainguard's Node 25, with `pnpm` as the package manager and `node:25-slim` as the runtime variant. Pipeline artifact is an OCI image pushed to `ttl.sh/smalls-nodetest:25`. + +## Pipeline images + +| Stage | Image | +|-------------|-------| +| Build deps | `cgr.dev/smalls.xyz/node:25-dev` (ships pnpm 10.33) | +| Image build | host docker daemon (multi-stage build) | +| Test | runs the just-built image | +| Push | `ttl.sh/smalls-nodetest:25` | + +## Why pnpm + slim? + +- **pnpm**: alternative package manager that uses a content-addressable store and symlinks. `pnpm install --prod --frozen-lockfile` installs only production deps and fails fast if `pnpm-lock.yaml` is out of sync with `package.json` — useful for CI reproducibility. +- **slim**: PLAN.md called for the `node:25-slim` variant for the runtime layer. Same shell-less runtime as the regular tag but on a smaller base image. + +## npm libraries used + +- `express` (web server) +- `nanoid` (cryptographically random ID — used to generate a per-instance ID shown in the rendered HTML, satisfying PLAN.md's "include some kind of npm library" requirement) + +## Smoke test + +Same in-process self-request pattern as the Node 22 sibling — runs the runtime image with `--entrypoint=/usr/bin/node` and an inline `-e` script that boots the app on an ephemeral loopback port and asserts the rendered greeting. + +## Pull and run + +```sh +docker pull ttl.sh/smalls-nodetest:25 +docker run --rm -p 8080:8080 ttl.sh/smalls-nodetest:25 +# Visit http://localhost:8080/ +``` + +ttl.sh tags expire (default 24 hours). Re-run the pipeline to refresh. diff --git a/jenkins/apps/node25-pnpm-express/package.json b/jenkins/apps/node25-pnpm-express/package.json new file mode 100644 index 00000000..97d61220 --- /dev/null +++ b/jenkins/apps/node25-pnpm-express/package.json @@ -0,0 +1,14 @@ +{ + "name": "hello-node25-pnpm-express", + "version": "0.0.1", + "private": true, + "description": "Hello-world Express app on Chainguard Node 25 (slim) using pnpm", + "main": "server.js", + "engines": { + "node": ">=25" + }, + "dependencies": { + "express": "^5.1.0", + "nanoid": "^5.1.0" + } +} diff --git a/jenkins/apps/node25-pnpm-express/pnpm-lock.yaml b/jenkins/apps/node25-pnpm-express/pnpm-lock.yaml new file mode 100644 index 00000000..a27914e7 --- /dev/null +++ b/jenkins/apps/node25-pnpm-express/pnpm-lock.yaml @@ -0,0 +1,573 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + express: + specifier: ^5.1.0 + version: 5.2.1 + nanoid: + specifier: ^5.1.0 + version: 5.1.11 + +packages: + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.1: + resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + nanoid@5.1.11: + resolution: {integrity: sha512-v+KEsUv2ps74PaSKv0gHTxTCgMXOIfBEbaqa6w6ISIGC7ZsvHN4N9oJ8d4cmf0n5oTzQz2SLmThbQWhjd/8eKg==} + engines: {node: ^18 || >=20} + hasBin: true + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.1: + resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + type-is@2.0.1: + resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} + engines: {node: '>= 0.6'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + +snapshots: + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.1 + raw-body: 3.0.2 + type-is: 2.0.1 + transitivePeerDependencies: + - supports-color + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.1: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.1 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.1 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.1 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + ms@2.1.3: {} + + nanoid@5.1.11: {} + + negotiator@1.0.0: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + parseurl@1.3.3: {} + + path-to-regexp@8.4.2: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.1: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + statuses@2.0.2: {} + + toidentifier@1.0.1: {} + + type-is@2.0.1: + dependencies: + content-type: 1.0.5 + media-typer: 1.1.0 + mime-types: 3.0.2 + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + wrappy@1.0.2: {} diff --git a/jenkins/apps/node25-pnpm-express/server.js b/jenkins/apps/node25-pnpm-express/server.js new file mode 100644 index 00000000..6acb864f --- /dev/null +++ b/jenkins/apps/node25-pnpm-express/server.js @@ -0,0 +1,37 @@ +const os = require('node:os'); +const process = require('node:process'); +const express = require('express'); +const { nanoid } = require('nanoid'); + +const app = express(); +const instanceId = nanoid(10); + +app.get('/', (_req, res) => { + res.type('html').send(` + +Chainguard Node Demo (slim) + +

Hello from Node on Chainguard (slim)

+

Runtime info

+ + + + + + + + + +
propertyvalue
node.version${process.version}
express.version${require('express/package.json').version}
nanoid.instance${instanceId}
os.platform${process.platform}
os.arch${process.arch}
os.release${os.release()}
HOSTNAME${process.env.HOSTNAME || '?'}
+ +`); +}); + +if (require.main === module) { + const port = parseInt(process.env.PORT || '8080', 10); + app.listen(port, '0.0.0.0', () => { + console.log(`[${instanceId}] Listening on http://0.0.0.0:${port}/`); + }); +} + +module.exports = app; diff --git a/jenkins/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy index 75882836..5a7bc6e4 100644 --- a/jenkins/jenkins/casc/jobs.groovy +++ b/jenkins/jenkins/casc/jobs.groovy @@ -31,6 +31,10 @@ def apps = [ name: 'node22-npm-express', description: 'Express app on Chainguard Node 22 with npm; archived as OCI image to ttl.sh', ], + [ + name: 'node25-pnpm-express', + description: 'Express app on Chainguard Node 25 (slim runtime) with pnpm; archived as OCI image to ttl.sh', + ], ] apps.each { app -> From 09aafdb93543059d4dbbcbff6b48f27c67d94738 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Wed, 6 May 2026 14:56:50 -0500 Subject: [PATCH 08/47] jenkins: refresh README to reflect all 7 sample apps Adds a quick-reference table of every job (build/runtime images and artifact format), updates the architecture diagram to show ttl.sh and generic per-stage images, and writes a "Common gotchas" section that captures the recurring traps hit while building out the samples (Chainguard ENTRYPOINTs, -dev variants in test stages, uid-1000 HOME issues for npm/pnpm/gradle, uid-65532 site-packages writes for python). Also updates the "Adding another sample app" walkthrough to include the JCasC reload-via-API step that's needed after restart. Drops the stale "Currently only the first app is implemented" note. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/README.md | 68 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 51 insertions(+), 17 deletions(-) diff --git a/jenkins/README.md b/jenkins/README.md index 3d4a0c10..92baf987 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -1,25 +1,41 @@ # Chainguard Jenkins Demo -A self-contained Jenkins server running on Chainguard images, with pipeline jobs that build and test sample applications using Chainguard `*-dev` build images and Chainguard runtime images. +A self-contained Jenkins server running on Chainguard images, with seven pipeline jobs that build and test sample applications across Java, Python, and Node — using Chainguard `*-dev` build images and Chainguard runtime images throughout. All Jenkins infrastructure and all build/test/runtime images come from `cgr.dev/smalls.xyz`. +## Sample applications + +| Job name | Stack | Build image | Runtime image | Artifact | +|----------|-------|-------------|---------------|----------| +| [`corretto-java17-maven`](apps/corretto-java17-maven/) | Spring Boot console app, Maven | `maven:3-jdk17-dev` | `amazon-corretto-jre:17` | runnable JAR (Jenkins archive) | +| [`adoptium-java8-jetty`](apps/adoptium-java8-jetty/) | JSP web app, Maven, Jetty 9.4 | `maven:3-jdk8-dev` | `adoptium-jre:adoptium-openjdk-8` | runnable WAR (Jenkins archive) | +| [`openjdk21-gradle`](apps/openjdk21-gradle/) | CLI app, Gradle 8.14 | `jdk:openjdk-21-dev` | `jre:openjdk-21` | runnable JAR (Jenkins archive) | +| [`python314-uv-flask`](apps/python314-uv-flask/) | Flask web app, `uv` | `python:3.14-dev` | `python:3.14` | OCI image → `ttl.sh/smalls-pytest:3-14` | +| [`python312-pip-django`](apps/python312-pip-django/) | Django site, `pip` | `python:3.12-dev` | `python:3.12` | OCI image → `ttl.sh/smalls-pytest:3-12` | +| [`node22-npm-express`](apps/node22-npm-express/) | Express web app, `npm` | `node:22-dev` | `node:22` | OCI image → `ttl.sh/smalls-nodetest:22` | +| [`node25-pnpm-express`](apps/node25-pnpm-express/) | Express web app, `pnpm` | `node:25-dev` | `node:25-slim` | OCI image → `ttl.sh/smalls-nodetest:25` | + +(Java pipelines archive their build artifact directly into Jenkins. Python/Node pipelines build an OCI image and push it to [ttl.sh](https://ttl.sh/) — an anonymous-push registry where tags expire after 24h. Re-run a pipeline to refresh.) + ## How it works ```mermaid flowchart LR user[User] host[(Host Docker daemon
OrbStack / Docker Desktop)] - jenkins[Jenkins controller
cgr.dev/smalls.xyz/jenkins] + jenkins[Jenkins controller
cgr.dev/smalls.xyz/jenkins:2-lts-jdk21-dev] apps[(./apps/*
local sources)] - build[Build container
maven:3-jdk17-dev] - test[Test container
amazon-corretto-jre:17-dev] + build[Per-stage build container
maven / gradle / python:3.x-dev / node:N-dev] + test[Per-stage test container
jre / python:3.x / node:N or n-slim] + ttl["ttl.sh
(anonymous-push)"] user -->|http :8080| jenkins apps -.->|bind mount| jenkins jenkins -->|/var/run/docker.sock| host host --> build host --> test + jenkins -.->|docker push| ttl ``` Jenkins is built from [Dockerfile.jenkins](Dockerfile.jenkins), which layers a fixed plugin set and the Chainguard `docker-cli` binary onto `cgr.dev/smalls.xyz/jenkins:2-lts-jdk21-dev` (the controller runs on JDK 21; sample apps targeting older JDKs build inside their own per-stage Chainguard images). On startup, [Jenkins Configuration as Code (JCasC)](jenkins/casc/jenkins.yaml) creates the admin user and seeds pipeline jobs from [jobs.groovy](jenkins/casc/jobs.groovy) by reading each app's `Jenkinsfile` from disk. @@ -58,22 +74,41 @@ Wait ~30s for Jenkins to finish initial startup (watch with `docker compose logs - Username: `admin` - Password: `admin` (override via `JENKINS_ADMIN_PASSWORD` env in [docker-compose.yml](docker-compose.yml)) -You should see one job, **corretto-java17-maven**. Click it → **Build Now**. The build runs four stages: +You should see all seven jobs in the dashboard. Click any of them → **Build Now**. Each pipeline runs roughly the same shape: -1. **Checkout** — copies sources from `/sources/apps/corretto-java17-maven/` into the build workspace. -2. **Build** — `cgr.dev/smalls.xyz/maven:3-jdk17-dev` runs `mvn package`, producing `target/app.jar`. -3. **Test** — `cgr.dev/smalls.xyz/amazon-corretto-jre:17-dev` runs the JAR as a smoke test. -4. **Archive** — Jenkins archives `target/app.jar`; download from the build's "Build Artifacts" link. +1. **Checkout** — `cp -R /sources/apps//. .` from the bind-mounted source dir into the build workspace. +2. **Build (deps)** — install/compile in a Chainguard `*-dev` agent container. +3. **Test** — smoke-test in either the runtime image or its `-dev` variant (see [Common gotchas](#common-gotchas) below). +4. **Archive / Push** — for the Java pipelines, archive the JAR/WAR to Jenkins. For Python/Node, build a runtime OCI image and push to `ttl.sh`. -A clean build takes ~25s once images are cached locally. +A clean build takes 10s–40s once images are cached locally; the Gradle pipeline is slower on first run (~1m45s) because the Gradle wrapper has to download the Gradle distribution. ## Adding another sample app -1. Create a directory under [apps/](apps/), e.g. `apps/python314-flask/`. -2. Add the application source plus a `Jenkinsfile`. The pipeline's first stage should be a `Checkout` that does `cp -R /sources/apps//. .`. -3. **Important:** in every `agent { docker { image '...' } }` block, add `args '--entrypoint='` so Jenkins can run `cat` to keep the container alive (Chainguard images all have a baked-in ENTRYPOINT pointing at the main binary). -4. Append one block to the `apps` list in [jenkins/casc/jobs.groovy](jenkins/casc/jobs.groovy). -5. `docker compose restart jenkins` — JCasC re-runs the seed and the new job appears. +1. Create a directory under [apps/](apps/), e.g. `apps/my-new-app/`. +2. Add the application source plus a `Jenkinsfile`. Use the same shape as the existing ones — first stage should `cp -R /sources/apps//. .` and stash, subsequent stages unstash and run. +3. Append one block to the `apps` list in [jenkins/casc/jobs.groovy](jenkins/casc/jobs.groovy). +4. Restart and reload Jenkins so JCasC re-runs the seed and the new job is loaded: + ```sh + docker compose restart jenkins + curl -fsS -u admin:admin -X POST http://localhost:8080/reload \ + -H "Jenkins-Crumb: $(curl -fsS -u admin:admin -c /tmp/jc -b /tmp/jc \ + http://localhost:8080/crumbIssuer/api/json | jq -r .crumb)" + ``` + (The restart writes the new job's `config.xml` to disk via JCasC, but Jenkins also needs an explicit `/reload` to pick it up at runtime.) + +## Common gotchas + +These bit me while building out the seven samples — useful to know up front when adding more. + +- **Chainguard images all have an ENTRYPOINT.** Jenkins' `docker { image '...' }` agent runs `cat` to keep the container alive and then `docker exec`s commands into it. Without `args '--entrypoint='` Jenkins' `cat` becomes an arg to the image's entrypoint and the container exits immediately. Always add `args '--entrypoint='` to `agent { docker { } }` blocks. +- **Test stages use `-dev` variants instead of the shell-less runtime images** for the same `agent { docker { } }` reason — Jenkins' `sh` steps require a shell. The shell-less runtime image is still the production deployment target; the `-dev` variant is purely a CI convenience. + - For Python and Node OCI-image pipelines, the Test stage runs the runtime image directly via `docker run --entrypoint=python|node ...` with an inline test script — no shell needed and you genuinely test the production artifact. See [`python314-uv-flask/Jenkinsfile`](apps/python314-uv-flask/Jenkinsfile) for the pattern. +- **Build agents run as uid 1000 with no writable HOME.** Several tools that cache to `~/.something` need help: + - **Gradle**: `environment { GRADLE_USER_HOME = "${WORKSPACE}/.gradle" }` + - **npm / pnpm**: `environment { HOME = "${WORKSPACE}" }` +- **Chainguard's `python:3.x-dev` runs as uid 65532**, which can't write to the system site-packages. For pipelines that want to `pip install --system` or `uv pip install --system`, pass `args '--user 0 --entrypoint='` in the Jenkinsfile **and** add `USER 0` to the corresponding stage in the Dockerfile. +- **OCI-image pipelines push to ttl.sh** (anonymous, public, 24h TTL) so no registry auth is needed inside the pipeline. The Jenkins controller's docker config (mounted from `.secrets/docker-config.json`, generated by [setup.sh](setup.sh)) handles `cgr.dev/smalls.xyz` pulls; pushes to `ttl.sh` need no creds. ## Teardown @@ -85,6 +120,5 @@ rm -rf .secrets # remove the pull-token Docker config ## Notes -- The Test stage uses the `-dev` variant of the runtime image (`amazon-corretto-jre:17-dev` rather than `:17`) because Jenkins' `docker { image ... }` agent invokes `sh` steps, which require a shell. The shell-less runtime image is still the production deployment target — the `-dev` variant is purely a CI convenience for driving the JVM under Jenkins. - The DooD pattern means Jenkins effectively has root-equivalent access to the host machine via the Docker socket. This is acceptable for a local demo but not for production. -- See [PLAN.md](PLAN.md) for the full list of sample apps planned. Currently only the first (Corretto Java 17 + Maven) is implemented; the rest will be added one at a time. +- See [PLAN.md](PLAN.md) for the original sample-app spec and a list of future enhancements (configurable org, Harbor pull-through, push to Harbor instead of ttl.sh). From 927cce2b7ecc5151cda10e28d926776ddac3bc08 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Wed, 6 May 2026 15:27:46 -0500 Subject: [PATCH 09/47] jenkins: make the Chainguard org configurable via .env / CHAINGUARD_ORG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PLAN.md "Future enhancements" #1 — decouple the demo from the hard-coded smalls.xyz org so anyone can run it against their own Chainguard org (or the public chainguard catalog) without editing source files. Single knob: CHAINGUARD_ORG in jenkins/.env (gitignored, bootstrapped from the new .env.example, default smalls.xyz). docker-compose auto-loads .env and propagates the value four ways: * As a build ARG to Dockerfile.jenkins (controller image FROM lines) * As a runtime env var on the controller container * Through JCasC: ${CHAINGUARD_ORG} interpolation in systemMessage, plus a globalNodeProperties.envVars block so pipelines see it via env.CHAINGUARD_ORG (env vars set on the controller process do NOT auto-flow into pipeline env — JCasC bridge is required) * To setup.sh, which now sources .env so its chainctl pull-token call uses the same org as the rest of the stack Per-app changes follow the existing print-events/Dockerfile pattern: each app Dockerfile declares ARG CHAINGUARD_ORG=smalls.xyz and uses ${CHAINGUARD_ORG} in FROM lines; the corresponding Jenkinsfiles pass --build-arg CHAINGUARD_ORG="$CHAINGUARD_ORG" to docker build, and their `agent { docker { image '...' } }` blocks switch from single- to double-quoted strings interpolating ${env.CHAINGUARD_ORG}. Default of smalls.xyz preserves zero-friction continuity for the current demo flow. Out of scope: ttl.sh push tag prefixes (smalls-pytest, smalls-nodetest) — those go away with PLAN.md enhancement #3 (push to Harbor). Verified end-to-end: corretto-java17-maven and python314-uv-flask both pass with default org; controller image rebuild against CHAINGUARD_ORG=chainguard correctly resolves cgr.dev/chainguard/... in FROM lines. Build console output for build #5 of python pipeline shows the substitution flowing through every layer: + docker inspect cgr.dev/smalls.xyz/python:3.14-dev + docker build --build-arg 'CHAINGUARD_ORG=smalls.xyz' -t ... Step 1/12 : ARG CHAINGUARD_ORG=smalls.xyz Step 2/12 : FROM cgr.dev/${CHAINGUARD_ORG}/python:3.14-dev AS build Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/.env.example | 10 +++++++ jenkins/.gitignore | 3 ++ jenkins/Dockerfile.jenkins | 12 ++++++-- jenkins/README.md | 29 +++++++++++++++---- jenkins/apps/adoptium-java8-jetty/Jenkinsfile | 4 +-- jenkins/apps/adoptium-java8-jetty/README.md | 2 ++ .../apps/corretto-java17-maven/Jenkinsfile | 4 +-- jenkins/apps/corretto-java17-maven/README.md | 2 ++ jenkins/apps/node22-npm-express/Dockerfile | 6 ++-- jenkins/apps/node22-npm-express/Jenkinsfile | 4 +-- jenkins/apps/node22-npm-express/README.md | 4 ++- jenkins/apps/node25-pnpm-express/Dockerfile | 6 ++-- jenkins/apps/node25-pnpm-express/Jenkinsfile | 4 +-- jenkins/apps/node25-pnpm-express/README.md | 2 ++ jenkins/apps/openjdk21-gradle/Jenkinsfile | 4 +-- jenkins/apps/openjdk21-gradle/README.md | 2 ++ jenkins/apps/python312-pip-django/Dockerfile | 6 ++-- jenkins/apps/python312-pip-django/Jenkinsfile | 4 +-- jenkins/apps/python312-pip-django/README.md | 2 ++ jenkins/apps/python314-uv-flask/Dockerfile | 6 ++-- jenkins/apps/python314-uv-flask/Jenkinsfile | 4 +-- jenkins/apps/python314-uv-flask/README.md | 2 ++ jenkins/docker-compose.yml | 5 ++++ jenkins/jenkins/casc/jenkins.yaml | 9 +++++- jenkins/setup.sh | 14 +++++++-- 25 files changed, 115 insertions(+), 35 deletions(-) create mode 100644 jenkins/.env.example diff --git a/jenkins/.env.example b/jenkins/.env.example new file mode 100644 index 00000000..1d952d7c --- /dev/null +++ b/jenkins/.env.example @@ -0,0 +1,10 @@ +# Chainguard org under cgr.dev/ to pull images from. The setup script generates +# a pull token for this org; docker-compose passes it to the controller's image +# build and runtime env; pipelines reference it via env.CHAINGUARD_ORG; per-app +# Dockerfiles use it as a build ARG. +# +# Examples: +# CHAINGUARD_ORG=chainguard # the public Chainguard catalog +# CHAINGUARD_ORG=smalls.xyz # the demo's original org +# CHAINGUARD_ORG=your-org # any org you have chainctl access to +CHAINGUARD_ORG=smalls.xyz diff --git a/jenkins/.gitignore b/jenkins/.gitignore index c9ff9afe..aaeb0ce4 100644 --- a/jenkins/.gitignore +++ b/jenkins/.gitignore @@ -1,3 +1,6 @@ +# Local environment overrides (e.g. CHAINGUARD_ORG). Bootstrapped from .env.example. +.env + # Pull-token-derived Docker config — generated by setup.sh, must not be committed. .secrets/ diff --git a/jenkins/Dockerfile.jenkins b/jenkins/Dockerfile.jenkins index 6bff7571..a823667a 100644 --- a/jenkins/Dockerfile.jenkins +++ b/jenkins/Dockerfile.jenkins @@ -3,14 +3,20 @@ # 2. plugins stage: use the -dev jenkins variant (has shell) to run jenkins-plugin-cli # 3. final stage: ship on the standard jenkins image with plugins baked in -FROM cgr.dev/smalls.xyz/docker-cli:29 AS docker +# Configurable via build-arg from docker-compose; see jenkins/.env.example. +ARG CHAINGUARD_ORG=smalls.xyz -FROM cgr.dev/smalls.xyz/jenkins:2-lts-jdk21-dev AS plugins +ARG CHAINGUARD_ORG +FROM cgr.dev/${CHAINGUARD_ORG}/docker-cli:29 AS docker + +ARG CHAINGUARD_ORG +FROM cgr.dev/${CHAINGUARD_ORG}/jenkins:2-lts-jdk21-dev AS plugins USER 0 COPY jenkins/plugins.txt /tmp/plugins.txt RUN jenkins-plugin-cli --plugin-file /tmp/plugins.txt --verbose -FROM cgr.dev/smalls.xyz/jenkins:2-lts-jdk21-dev +ARG CHAINGUARD_ORG +FROM cgr.dev/${CHAINGUARD_ORG}/jenkins:2-lts-jdk21-dev USER 0 COPY --from=docker /usr/bin/docker /usr/local/bin/docker COPY --from=plugins --chown=1000:1000 /usr/share/jenkins/ref/plugins/ /usr/share/jenkins/ref/plugins/ diff --git a/jenkins/README.md b/jenkins/README.md index 92baf987..629ed665 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -2,7 +2,7 @@ A self-contained Jenkins server running on Chainguard images, with seven pipeline jobs that build and test sample applications across Java, Python, and Node — using Chainguard `*-dev` build images and Chainguard runtime images throughout. -All Jenkins infrastructure and all build/test/runtime images come from `cgr.dev/smalls.xyz`. +All Jenkins infrastructure and all build/test/runtime images come from `cgr.dev/` (default `smalls.xyz` — see [Configuration](#configuration)). ## Sample applications @@ -24,7 +24,7 @@ All Jenkins infrastructure and all build/test/runtime images come from `cgr.dev/ flowchart LR user[User] host[(Host Docker daemon
OrbStack / Docker Desktop)] - jenkins[Jenkins controller
cgr.dev/smalls.xyz/jenkins:2-lts-jdk21-dev] + jenkins[Jenkins controller
cgr.dev/$CHAINGUARD_ORG/jenkins:2-lts-jdk21-dev] apps[(./apps/*
local sources)] build[Per-stage build container
maven / gradle / python:3.x-dev / node:N-dev] test[Per-stage test container
jre / python:3.x / node:N or n-slim] @@ -38,7 +38,7 @@ flowchart LR jenkins -.->|docker push| ttl ``` -Jenkins is built from [Dockerfile.jenkins](Dockerfile.jenkins), which layers a fixed plugin set and the Chainguard `docker-cli` binary onto `cgr.dev/smalls.xyz/jenkins:2-lts-jdk21-dev` (the controller runs on JDK 21; sample apps targeting older JDKs build inside their own per-stage Chainguard images). On startup, [Jenkins Configuration as Code (JCasC)](jenkins/casc/jenkins.yaml) creates the admin user and seeds pipeline jobs from [jobs.groovy](jenkins/casc/jobs.groovy) by reading each app's `Jenkinsfile` from disk. +Jenkins is built from [Dockerfile.jenkins](Dockerfile.jenkins), which layers a fixed plugin set and the Chainguard `docker-cli` binary onto `cgr.dev/$CHAINGUARD_ORG/jenkins:2-lts-jdk21-dev` (the controller runs on JDK 21; sample apps targeting older JDKs build inside their own per-stage Chainguard images). On startup, [Jenkins Configuration as Code (JCasC)](jenkins/casc/jenkins.yaml) creates the admin user and seeds pipeline jobs from [jobs.groovy](jenkins/casc/jobs.groovy) by reading each app's `Jenkinsfile` from disk. The controller talks to the **host's Docker daemon** via the mounted `/var/run/docker.sock` (DooD pattern). When a pipeline stage uses `agent { docker { image '...' } }`, Jenkins asks the host to spawn the build container. To make this work cleanly, `JENKINS_HOME` is bind-mounted from `/tmp/cgjenkins-home` at the **same absolute path on host and container** — so when the controller hands the host a `-v :` flag, the workspace path actually exists on the host. @@ -47,16 +47,33 @@ Each sample application lives under [apps/](apps/) as if it were a separate repo ## Prerequisites - Docker (Docker Desktop, OrbStack, or Linux Docker engine) -- `chainctl` CLI, authenticated against an org with access to `cgr.dev/smalls.xyz/*` +- `chainctl` CLI, authenticated against an org with access to `cgr.dev//*` Verify auth: ```sh chainctl auth status ``` +## Configuration + +The demo defaults to pulling Chainguard images from `cgr.dev/smalls.xyz/`. To use a different org (e.g. the public `chainguard` catalog or your own org): + +```sh +cp .env.example .env +# edit CHAINGUARD_ORG in .env +``` + +`.env` is loaded automatically by `docker-compose`, propagated to the controller container as an env var, and consumed by Jenkinsfiles (via `env.CHAINGUARD_ORG`), the controller Dockerfile, and per-app Dockerfiles (via build ARG). `setup.sh` also sources `.env`, so the pull token is generated against the configured org. + +After changing the org, regenerate the pull token and rebuild: +```sh +./setup.sh +docker compose up -d --build +``` + ## Quick start -**Step 1 — Generate the registry pull token.** [setup.sh](setup.sh) calls `chainctl auth pull-token create` and writes a Docker config to `.secrets/docker-config.json` (gitignored). Re-run when the token expires (default TTL is 30 days). +**Step 1 — Generate the registry pull token.** [setup.sh](setup.sh) calls `chainctl auth pull-token create` for `$CHAINGUARD_ORG` (or `smalls.xyz` if unset) and writes a Docker config to `.secrets/docker-config.json` (gitignored). Re-run when the token expires (default TTL is 30 days). ```sh cd jenkins @@ -108,7 +125,7 @@ These bit me while building out the seven samples — useful to know up front wh - **Gradle**: `environment { GRADLE_USER_HOME = "${WORKSPACE}/.gradle" }` - **npm / pnpm**: `environment { HOME = "${WORKSPACE}" }` - **Chainguard's `python:3.x-dev` runs as uid 65532**, which can't write to the system site-packages. For pipelines that want to `pip install --system` or `uv pip install --system`, pass `args '--user 0 --entrypoint='` in the Jenkinsfile **and** add `USER 0` to the corresponding stage in the Dockerfile. -- **OCI-image pipelines push to ttl.sh** (anonymous, public, 24h TTL) so no registry auth is needed inside the pipeline. The Jenkins controller's docker config (mounted from `.secrets/docker-config.json`, generated by [setup.sh](setup.sh)) handles `cgr.dev/smalls.xyz` pulls; pushes to `ttl.sh` need no creds. +- **OCI-image pipelines push to ttl.sh** (anonymous, public, 24h TTL) so no registry auth is needed inside the pipeline. The Jenkins controller's docker config (mounted from `.secrets/docker-config.json`, generated by [setup.sh](setup.sh)) handles `cgr.dev/$CHAINGUARD_ORG` pulls; pushes to `ttl.sh` need no creds. ## Teardown diff --git a/jenkins/apps/adoptium-java8-jetty/Jenkinsfile b/jenkins/apps/adoptium-java8-jetty/Jenkinsfile index a427111e..6f8dd911 100644 --- a/jenkins/apps/adoptium-java8-jetty/Jenkinsfile +++ b/jenkins/apps/adoptium-java8-jetty/Jenkinsfile @@ -14,7 +14,7 @@ pipeline { stage('Build') { agent { docker { - image 'cgr.dev/smalls.xyz/maven:3-jdk8-dev' + image "cgr.dev/${env.CHAINGUARD_ORG}/maven:3-jdk8-dev" args '--entrypoint=' reuseNode true } @@ -29,7 +29,7 @@ pipeline { stage('Test') { agent { docker { - image 'cgr.dev/smalls.xyz/adoptium-jre:adoptium-openjdk-8-dev' + image "cgr.dev/${env.CHAINGUARD_ORG}/adoptium-jre:adoptium-openjdk-8-dev" args '--entrypoint=' reuseNode true } diff --git a/jenkins/apps/adoptium-java8-jetty/README.md b/jenkins/apps/adoptium-java8-jetty/README.md index a9329de3..1af56ae4 100644 --- a/jenkins/apps/adoptium-java8-jetty/README.md +++ b/jenkins/apps/adoptium-java8-jetty/README.md @@ -2,6 +2,8 @@ Hello-world Jetty/JSP web app, built with Maven on Adoptium JDK 8 and packaged as a self-executing WAR. +> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. + ## Pipeline images | Stage | Image | diff --git a/jenkins/apps/corretto-java17-maven/Jenkinsfile b/jenkins/apps/corretto-java17-maven/Jenkinsfile index 8dc7fa0b..c46af2b7 100644 --- a/jenkins/apps/corretto-java17-maven/Jenkinsfile +++ b/jenkins/apps/corretto-java17-maven/Jenkinsfile @@ -14,7 +14,7 @@ pipeline { stage('Build') { agent { docker { - image 'cgr.dev/smalls.xyz/maven:3-jdk17-dev' + image "cgr.dev/${env.CHAINGUARD_ORG}/maven:3-jdk17-dev" // Chainguard images set ENTRYPOINT to the main binary; clear it so // Jenkins' `cat` command keeps the container alive for `docker exec`. args '--entrypoint=' @@ -31,7 +31,7 @@ pipeline { stage('Test') { agent { docker { - image 'cgr.dev/smalls.xyz/amazon-corretto-jre:17-dev' + image "cgr.dev/${env.CHAINGUARD_ORG}/amazon-corretto-jre:17-dev" args '--entrypoint=' reuseNode true } diff --git a/jenkins/apps/corretto-java17-maven/README.md b/jenkins/apps/corretto-java17-maven/README.md index 0db2fe83..9387baef 100644 --- a/jenkins/apps/corretto-java17-maven/README.md +++ b/jenkins/apps/corretto-java17-maven/README.md @@ -2,6 +2,8 @@ Hello-world Spring Boot console app, built with Maven on Amazon Corretto JDK 17. +> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. + ## Pipeline images | Stage | Image | diff --git a/jenkins/apps/node22-npm-express/Dockerfile b/jenkins/apps/node22-npm-express/Dockerfile index 9fea17e5..42b29057 100644 --- a/jenkins/apps/node22-npm-express/Dockerfile +++ b/jenkins/apps/node22-npm-express/Dockerfile @@ -2,12 +2,14 @@ # Stage 1: install npm dependencies on the dev image, which ships npm. # Stage 2 then copies the populated node_modules + app source into the # shell-less runtime image (node:22), which has only the node binary. -FROM cgr.dev/smalls.xyz/node:22-dev AS build +ARG CHAINGUARD_ORG=smalls.xyz + +FROM cgr.dev/${CHAINGUARD_ORG}/node:22-dev AS build WORKDIR /app COPY package.json ./ RUN npm install --omit=dev --no-audit --no-fund -FROM cgr.dev/smalls.xyz/node:22 +FROM cgr.dev/${CHAINGUARD_ORG}/node:22 WORKDIR /app COPY --from=build /app/node_modules ./node_modules COPY package.json server.js ./ diff --git a/jenkins/apps/node22-npm-express/Jenkinsfile b/jenkins/apps/node22-npm-express/Jenkinsfile index 240dce15..3ecf06c9 100644 --- a/jenkins/apps/node22-npm-express/Jenkinsfile +++ b/jenkins/apps/node22-npm-express/Jenkinsfile @@ -18,7 +18,7 @@ pipeline { stage('Build deps') { agent { docker { - image 'cgr.dev/smalls.xyz/node:22-dev' + image "cgr.dev/${env.CHAINGUARD_ORG}/node:22-dev" args '--entrypoint=' reuseNode true } @@ -41,7 +41,7 @@ pipeline { agent any steps { unstash 'src' - sh 'docker build -t "$IMAGE" .' + sh 'docker build --build-arg CHAINGUARD_ORG="$CHAINGUARD_ORG" -t "$IMAGE" .' } } diff --git a/jenkins/apps/node22-npm-express/README.md b/jenkins/apps/node22-npm-express/README.md index 36b90654..13d820cd 100644 --- a/jenkins/apps/node22-npm-express/README.md +++ b/jenkins/apps/node22-npm-express/README.md @@ -2,7 +2,9 @@ Hello-world Express web app on Chainguard's Node 22, with `npm` as the package manager. Pipeline artifact is an OCI image pushed to `ttl.sh/smalls-nodetest:22`. -> Note: PLAN.md originally called for Node 21, but that line is EOL and not in the smalls.xyz catalog. We use Node 22 LTS (the natural successor) instead. +> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. + +> Note: PLAN.md originally called for Node 21, but that line is EOL and not in the catalog. We use Node 22 LTS (the natural successor) instead. ## Pipeline images diff --git a/jenkins/apps/node25-pnpm-express/Dockerfile b/jenkins/apps/node25-pnpm-express/Dockerfile index 86ade7c9..e7f6d1b6 100644 --- a/jenkins/apps/node25-pnpm-express/Dockerfile +++ b/jenkins/apps/node25-pnpm-express/Dockerfile @@ -2,13 +2,15 @@ # Stage 1: install production deps with pnpm on the dev image. Stage 2 copies # the populated node_modules into the slim runtime variant — node:25-slim has # only the node binary (no shell, no npm/pnpm). -FROM cgr.dev/smalls.xyz/node:25-dev AS build +ARG CHAINGUARD_ORG=smalls.xyz + +FROM cgr.dev/${CHAINGUARD_ORG}/node:25-dev AS build WORKDIR /app COPY package.json pnpm-lock.yaml ./ # --frozen-lockfile fails the build if the lockfile is out of sync with package.json RUN pnpm install --prod --frozen-lockfile -FROM cgr.dev/smalls.xyz/node:25-slim +FROM cgr.dev/${CHAINGUARD_ORG}/node:25-slim WORKDIR /app COPY --from=build /app/node_modules ./node_modules COPY package.json server.js ./ diff --git a/jenkins/apps/node25-pnpm-express/Jenkinsfile b/jenkins/apps/node25-pnpm-express/Jenkinsfile index 37e445c8..aa3c73a7 100644 --- a/jenkins/apps/node25-pnpm-express/Jenkinsfile +++ b/jenkins/apps/node25-pnpm-express/Jenkinsfile @@ -18,7 +18,7 @@ pipeline { stage('Build deps') { agent { docker { - image 'cgr.dev/smalls.xyz/node:25-dev' + image "cgr.dev/${env.CHAINGUARD_ORG}/node:25-dev" args '--entrypoint=' reuseNode true } @@ -39,7 +39,7 @@ pipeline { agent any steps { unstash 'src' - sh 'docker build -t "$IMAGE" .' + sh 'docker build --build-arg CHAINGUARD_ORG="$CHAINGUARD_ORG" -t "$IMAGE" .' } } diff --git a/jenkins/apps/node25-pnpm-express/README.md b/jenkins/apps/node25-pnpm-express/README.md index 3e3bcdf5..de98a322 100644 --- a/jenkins/apps/node25-pnpm-express/README.md +++ b/jenkins/apps/node25-pnpm-express/README.md @@ -2,6 +2,8 @@ Hello-world Express web app on Chainguard's Node 25, with `pnpm` as the package manager and `node:25-slim` as the runtime variant. Pipeline artifact is an OCI image pushed to `ttl.sh/smalls-nodetest:25`. +> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. + ## Pipeline images | Stage | Image | diff --git a/jenkins/apps/openjdk21-gradle/Jenkinsfile b/jenkins/apps/openjdk21-gradle/Jenkinsfile index b90bf01d..1fd95262 100644 --- a/jenkins/apps/openjdk21-gradle/Jenkinsfile +++ b/jenkins/apps/openjdk21-gradle/Jenkinsfile @@ -14,7 +14,7 @@ pipeline { stage('Build') { agent { docker { - image 'cgr.dev/smalls.xyz/jdk:openjdk-21-dev' + image "cgr.dev/${env.CHAINGUARD_ORG}/jdk:openjdk-21-dev" args '--entrypoint=' reuseNode true } @@ -34,7 +34,7 @@ pipeline { stage('Test') { agent { docker { - image 'cgr.dev/smalls.xyz/jre:openjdk-21-dev' + image "cgr.dev/${env.CHAINGUARD_ORG}/jre:openjdk-21-dev" args '--entrypoint=' reuseNode true } diff --git a/jenkins/apps/openjdk21-gradle/README.md b/jenkins/apps/openjdk21-gradle/README.md index 8113aa26..ee08c83c 100644 --- a/jenkins/apps/openjdk21-gradle/README.md +++ b/jenkins/apps/openjdk21-gradle/README.md @@ -2,6 +2,8 @@ Hello-world standalone runnable JAR built with Gradle on Chainguard's OpenJDK 21. +> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. + ## Pipeline images | Stage | Image | diff --git a/jenkins/apps/python312-pip-django/Dockerfile b/jenkins/apps/python312-pip-django/Dockerfile index 0b9c4508..7a51c2b4 100644 --- a/jenkins/apps/python312-pip-django/Dockerfile +++ b/jenkins/apps/python312-pip-django/Dockerfile @@ -4,13 +4,15 @@ # install. Stage 2 then copies the populated site-packages into the shell-less # python:3.12 runtime image — same Python minor version on both sides keeps # the packages compatible. -FROM cgr.dev/smalls.xyz/python:3.12-dev AS build +ARG CHAINGUARD_ORG=smalls.xyz + +FROM cgr.dev/${CHAINGUARD_ORG}/python:3.12-dev AS build USER 0 WORKDIR /app COPY requirements.txt ./ RUN pip install --no-cache-dir -r requirements.txt -FROM cgr.dev/smalls.xyz/python:3.12 +FROM cgr.dev/${CHAINGUARD_ORG}/python:3.12 COPY --from=build /usr/lib/python3.12/site-packages /usr/lib/python3.12/site-packages COPY app.py /app/app.py WORKDIR /app diff --git a/jenkins/apps/python312-pip-django/Jenkinsfile b/jenkins/apps/python312-pip-django/Jenkinsfile index 94079b95..f2741fca 100644 --- a/jenkins/apps/python312-pip-django/Jenkinsfile +++ b/jenkins/apps/python312-pip-django/Jenkinsfile @@ -18,7 +18,7 @@ pipeline { stage('Build deps') { agent { docker { - image 'cgr.dev/smalls.xyz/python:3.12-dev' + image "cgr.dev/${env.CHAINGUARD_ORG}/python:3.12-dev" // pip needs to write to /usr/lib/python3.12/site-packages, which the // default uid (65532) can't touch. args '--user 0 --entrypoint=' @@ -36,7 +36,7 @@ pipeline { agent any steps { unstash 'src' - sh 'docker build -t "$IMAGE" .' + sh 'docker build --build-arg CHAINGUARD_ORG="$CHAINGUARD_ORG" -t "$IMAGE" .' } } diff --git a/jenkins/apps/python312-pip-django/README.md b/jenkins/apps/python312-pip-django/README.md index 5a507c16..f4258e35 100644 --- a/jenkins/apps/python312-pip-django/README.md +++ b/jenkins/apps/python312-pip-django/README.md @@ -2,6 +2,8 @@ Hello-world Django site on Chainguard's Python 3.12, with `pip` as the package manager. The pipeline's archived artifact is an OCI image pushed to `ttl.sh/smalls-pytest:3-12`. +> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. + ## Pipeline images | Stage | Image | diff --git a/jenkins/apps/python314-uv-flask/Dockerfile b/jenkins/apps/python314-uv-flask/Dockerfile index 9bb8aa25..fa40e4df 100644 --- a/jenkins/apps/python314-uv-flask/Dockerfile +++ b/jenkins/apps/python314-uv-flask/Dockerfile @@ -3,13 +3,15 @@ # ships uv pre-installed). Stage 2 then copies the populated site-packages # directory into the shell-less runtime python image — same Python minor # version on both sides keeps the packages compatible. -FROM cgr.dev/smalls.xyz/python:3.14-dev AS build +ARG CHAINGUARD_ORG=smalls.xyz + +FROM cgr.dev/${CHAINGUARD_ORG}/python:3.14-dev AS build USER 0 WORKDIR /app COPY pyproject.toml ./ RUN uv pip install --system --no-cache flask -FROM cgr.dev/smalls.xyz/python:3.14 +FROM cgr.dev/${CHAINGUARD_ORG}/python:3.14 COPY --from=build /usr/lib/python3.14/site-packages /usr/lib/python3.14/site-packages COPY app.py /app/app.py WORKDIR /app diff --git a/jenkins/apps/python314-uv-flask/Jenkinsfile b/jenkins/apps/python314-uv-flask/Jenkinsfile index db7b7047..47ce60ad 100644 --- a/jenkins/apps/python314-uv-flask/Jenkinsfile +++ b/jenkins/apps/python314-uv-flask/Jenkinsfile @@ -18,7 +18,7 @@ pipeline { stage('Build deps') { agent { docker { - image 'cgr.dev/smalls.xyz/python:3.14-dev' + image "cgr.dev/${env.CHAINGUARD_ORG}/python:3.14-dev" // Run as root: uv needs to write to /usr/lib/python3.14/site-packages // which the default uid (65532) can't touch. args '--user 0 --entrypoint=' @@ -39,7 +39,7 @@ pipeline { agent any steps { unstash 'src' - sh 'docker build -t "$IMAGE" .' + sh 'docker build --build-arg CHAINGUARD_ORG="$CHAINGUARD_ORG" -t "$IMAGE" .' } } diff --git a/jenkins/apps/python314-uv-flask/README.md b/jenkins/apps/python314-uv-flask/README.md index 61d8d8c4..329ab346 100644 --- a/jenkins/apps/python314-uv-flask/README.md +++ b/jenkins/apps/python314-uv-flask/README.md @@ -2,6 +2,8 @@ Hello-world Flask web app on Chainguard's Python 3.14, with `uv` as the package manager. The pipeline's archived artifact is an OCI image pushed to `ttl.sh/smalls-pytest:3-14` — not a file checked into Jenkins' archive store. +> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. + ## Pipeline images | Stage | Image | diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml index 820544f6..b177bb54 100644 --- a/jenkins/docker-compose.yml +++ b/jenkins/docker-compose.yml @@ -15,7 +15,12 @@ services: build: context: . dockerfile: Dockerfile.jenkins + args: + CHAINGUARD_ORG: ${CHAINGUARD_ORG:-smalls.xyz} environment: + # Surface the configured org to pipelines (Jenkinsfiles read env.CHAINGUARD_ORG) + # and to JCasC (jenkins.yaml interpolates ${CHAINGUARD_ORG}). + CHAINGUARD_ORG: ${CHAINGUARD_ORG:-smalls.xyz} JENKINS_HOME: /tmp/cgjenkins-home CASC_JENKINS_CONFIG: /tmp/cgjenkins-home/casc JENKINS_ADMIN_PASSWORD: admin diff --git a/jenkins/jenkins/casc/jenkins.yaml b/jenkins/jenkins/casc/jenkins.yaml index 2aa6490f..453b1009 100644 --- a/jenkins/jenkins/casc/jenkins.yaml +++ b/jenkins/jenkins/casc/jenkins.yaml @@ -1,8 +1,15 @@ jenkins: - systemMessage: "Chainguard Jenkins demo — pipelines build & test on cgr.dev/smalls.xyz images." + systemMessage: "Chainguard Jenkins demo — pipelines build & test on cgr.dev/${CHAINGUARD_ORG}/ images." numExecutors: 2 mode: NORMAL scmCheckoutRetryCount: 0 + # Surface the controller-process CHAINGUARD_ORG env var into pipeline `env` + # so Jenkinsfiles can use ${env.CHAINGUARD_ORG} in agent docker image strings. + globalNodeProperties: + - envVars: + env: + - key: CHAINGUARD_ORG + value: ${CHAINGUARD_ORG} securityRealm: local: allowsSignup: false diff --git a/jenkins/setup.sh b/jenkins/setup.sh index bd8c1913..1d689d55 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -1,11 +1,21 @@ #!/usr/bin/env bash -# One-time setup: generate a Chainguard pull token for the smalls.xyz org and +# One-time setup: generate a Chainguard pull token for the configured Chainguard +# org (default smalls.xyz; override via .env or CHAINGUARD_ORG env var) and # write a Docker config.json that the Jenkins container will use to pull -# cgr.dev/smalls.xyz/* images. Re-run when the token expires. +# cgr.dev//* images. Re-run when the token expires. set -euo pipefail cd "$(dirname "$0")" +# Pick up CHAINGUARD_ORG from .env if present, so the script and docker-compose +# stay in sync without the user having to export the variable twice. +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +fi + ORG="${CHAINGUARD_ORG:-smalls.xyz}" TOKEN_NAME="${TOKEN_NAME:-jenkins-demo}" TTL="${TTL:-720h}" From 56fe512316c192602df3c80918eb3bb3562277de Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Wed, 6 May 2026 15:54:55 -0500 Subject: [PATCH 10/47] jenkins: replace hardcoded image strings with cgImage shared library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pipelines used to write the full cgr.dev//: path inline. Now they call cgImage('') (e.g. 'corretto-java17', 'python-3.14') from a Jenkins shared library that resolves the token to a Map of build/test/runtime image strings, with the segment coming from env.CHAINGUARD_ORG. Implementation: * shared-libraries/cg-images/vars/cgImage.groovy — the library entry point. Catalog of 7 tokens covering all current sample apps. To add a new token, append a row. * jenkins/casc/jenkins.yaml — globalLibraries config registering the library as `cgImages` with implicit: true (no @Library annotation needed in pipelines), retriever=legacySCM(filesystem(path:...)). * jenkins/plugins.txt — adds filesystem_scm so the library can load from a bind-mounted directory rather than a Git remote. * docker-compose.yml — bind-mounts ./shared-libraries into the controller (read-only) at /tmp/cgjenkins-home/shared-libraries. * Per-app Jenkinsfiles (×7) — `def img = cgImage('')` at the top of each, then `image img.build` / `image img.test` in agent docker blocks. Zero cgr.dev hardcodes left in apps/*/Jenkinsfile. * shared-libraries/cg-images/README.md — usage docs plus a caveats section covering live-reload behavior, syntax-error blast radius, and the implicit-loading vs explicit-@Library trade-off. Verified: corretto-java17-maven build #8 (11s) and python314-uv-flask build #6 (13s) both succeeded with the shared library resolving image strings correctly. Confirmed empirically that filesystem-SCM library edits propagate to the next pipeline run without a Jenkins restart (probe-marker test). Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/README.md | 4 +- jenkins/apps/adoptium-java8-jetty/Jenkinsfile | 6 ++- .../apps/corretto-java17-maven/Jenkinsfile | 7 ++- jenkins/apps/node22-npm-express/Jenkinsfile | 4 +- jenkins/apps/node25-pnpm-express/Jenkinsfile | 4 +- jenkins/apps/openjdk21-gradle/Jenkinsfile | 6 ++- jenkins/apps/python312-pip-django/Jenkinsfile | 4 +- jenkins/apps/python314-uv-flask/Jenkinsfile | 4 +- jenkins/docker-compose.yml | 1 + jenkins/jenkins/casc/jenkins.yaml | 11 ++++ jenkins/jenkins/plugins.txt | 4 ++ jenkins/shared-libraries/cg-images/README.md | 47 +++++++++++++++++ .../cg-images/vars/cgImage.groovy | 50 +++++++++++++++++++ 13 files changed, 141 insertions(+), 11 deletions(-) create mode 100644 jenkins/shared-libraries/cg-images/README.md create mode 100644 jenkins/shared-libraries/cg-images/vars/cgImage.groovy diff --git a/jenkins/README.md b/jenkins/README.md index 629ed665..064eca43 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -44,6 +44,8 @@ The controller talks to the **host's Docker daemon** via the mounted `/var/run/d Each sample application lives under [apps/](apps/) as if it were a separate repository. The controller's first pipeline stage (`Checkout`) copies the app sources from the bind-mounted `/sources/apps//` into the workspace. Subsequent stages stash/unstash to share artifacts. +Pipelines never hardcode image strings. Instead they call `cgImage('')` from the [cgImages shared library](shared-libraries/cg-images/) — e.g. `cgImage('corretto-java17').build` resolves to the right `cgr.dev//maven:3-jdk17-dev`. The library is auto-loaded by JCasC from a bind-mounted filesystem path, so adding/changing a token is a one-file edit and the next pipeline run picks it up automatically (no controller restart needed). See the library's [README](shared-libraries/cg-images/README.md#caveats) for a few caveats around editing it. + ## Prerequisites - Docker (Docker Desktop, OrbStack, or Linux Docker engine) @@ -103,7 +105,7 @@ A clean build takes 10s–40s once images are cached locally; the Gradle pipelin ## Adding another sample app 1. Create a directory under [apps/](apps/), e.g. `apps/my-new-app/`. -2. Add the application source plus a `Jenkinsfile`. Use the same shape as the existing ones — first stage should `cp -R /sources/apps//. .` and stash, subsequent stages unstash and run. +2. Add the application source plus a `Jenkinsfile`. Use the same shape as the existing ones — first stage should `cp -R /sources/apps//. .` and stash, subsequent stages unstash and run. Reference Chainguard images via `cgImage('')` (see [shared-libraries/cg-images](shared-libraries/cg-images/)); add a new token there if needed. 3. Append one block to the `apps` list in [jenkins/casc/jobs.groovy](jenkins/casc/jobs.groovy). 4. Restart and reload Jenkins so JCasC re-runs the seed and the new job is loaded: ```sh diff --git a/jenkins/apps/adoptium-java8-jetty/Jenkinsfile b/jenkins/apps/adoptium-java8-jetty/Jenkinsfile index 6f8dd911..534091b9 100644 --- a/jenkins/apps/adoptium-java8-jetty/Jenkinsfile +++ b/jenkins/apps/adoptium-java8-jetty/Jenkinsfile @@ -1,3 +1,5 @@ +def img = cgImage('adoptium-java8') + pipeline { agent none options { timestamps() } @@ -14,7 +16,7 @@ pipeline { stage('Build') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/maven:3-jdk8-dev" + image img.build args '--entrypoint=' reuseNode true } @@ -29,7 +31,7 @@ pipeline { stage('Test') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/adoptium-jre:adoptium-openjdk-8-dev" + image img.test args '--entrypoint=' reuseNode true } diff --git a/jenkins/apps/corretto-java17-maven/Jenkinsfile b/jenkins/apps/corretto-java17-maven/Jenkinsfile index c46af2b7..dc3299a9 100644 --- a/jenkins/apps/corretto-java17-maven/Jenkinsfile +++ b/jenkins/apps/corretto-java17-maven/Jenkinsfile @@ -1,3 +1,6 @@ +// cgImage is auto-loaded from the cgImages shared library (JCasC, implicit). +def img = cgImage('corretto-java17') + pipeline { agent none options { timestamps() } @@ -14,7 +17,7 @@ pipeline { stage('Build') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/maven:3-jdk17-dev" + image img.build // Chainguard images set ENTRYPOINT to the main binary; clear it so // Jenkins' `cat` command keeps the container alive for `docker exec`. args '--entrypoint=' @@ -31,7 +34,7 @@ pipeline { stage('Test') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/amazon-corretto-jre:17-dev" + image img.test args '--entrypoint=' reuseNode true } diff --git a/jenkins/apps/node22-npm-express/Jenkinsfile b/jenkins/apps/node22-npm-express/Jenkinsfile index 3ecf06c9..ad58f231 100644 --- a/jenkins/apps/node22-npm-express/Jenkinsfile +++ b/jenkins/apps/node22-npm-express/Jenkinsfile @@ -1,3 +1,5 @@ +def img = cgImage('node-22') + pipeline { agent none options { timestamps() } @@ -18,7 +20,7 @@ pipeline { stage('Build deps') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/node:22-dev" + image img.build args '--entrypoint=' reuseNode true } diff --git a/jenkins/apps/node25-pnpm-express/Jenkinsfile b/jenkins/apps/node25-pnpm-express/Jenkinsfile index aa3c73a7..46e08e88 100644 --- a/jenkins/apps/node25-pnpm-express/Jenkinsfile +++ b/jenkins/apps/node25-pnpm-express/Jenkinsfile @@ -1,3 +1,5 @@ +def img = cgImage('node-25') + pipeline { agent none options { timestamps() } @@ -18,7 +20,7 @@ pipeline { stage('Build deps') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/node:25-dev" + image img.build args '--entrypoint=' reuseNode true } diff --git a/jenkins/apps/openjdk21-gradle/Jenkinsfile b/jenkins/apps/openjdk21-gradle/Jenkinsfile index 1fd95262..33ced22d 100644 --- a/jenkins/apps/openjdk21-gradle/Jenkinsfile +++ b/jenkins/apps/openjdk21-gradle/Jenkinsfile @@ -1,3 +1,5 @@ +def img = cgImage('openjdk21') + pipeline { agent none options { timestamps() } @@ -14,7 +16,7 @@ pipeline { stage('Build') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/jdk:openjdk-21-dev" + image img.build args '--entrypoint=' reuseNode true } @@ -34,7 +36,7 @@ pipeline { stage('Test') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/jre:openjdk-21-dev" + image img.test args '--entrypoint=' reuseNode true } diff --git a/jenkins/apps/python312-pip-django/Jenkinsfile b/jenkins/apps/python312-pip-django/Jenkinsfile index f2741fca..9839fe9c 100644 --- a/jenkins/apps/python312-pip-django/Jenkinsfile +++ b/jenkins/apps/python312-pip-django/Jenkinsfile @@ -1,3 +1,5 @@ +def img = cgImage('python-3.12') + pipeline { agent none options { timestamps() } @@ -18,7 +20,7 @@ pipeline { stage('Build deps') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/python:3.12-dev" + image img.build // pip needs to write to /usr/lib/python3.12/site-packages, which the // default uid (65532) can't touch. args '--user 0 --entrypoint=' diff --git a/jenkins/apps/python314-uv-flask/Jenkinsfile b/jenkins/apps/python314-uv-flask/Jenkinsfile index 47ce60ad..72ebd8d8 100644 --- a/jenkins/apps/python314-uv-flask/Jenkinsfile +++ b/jenkins/apps/python314-uv-flask/Jenkinsfile @@ -1,3 +1,5 @@ +def img = cgImage('python-3.14') + pipeline { agent none options { timestamps() } @@ -18,7 +20,7 @@ pipeline { stage('Build deps') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/python:3.14-dev" + image img.build // Run as root: uv needs to write to /usr/lib/python3.14/site-packages // which the default uid (65532) can't touch. args '--user 0 --entrypoint=' diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml index b177bb54..b334fecf 100644 --- a/jenkins/docker-compose.yml +++ b/jenkins/docker-compose.yml @@ -38,4 +38,5 @@ services: - /tmp/cgjenkins-home:/tmp/cgjenkins-home - ./jenkins/casc:/tmp/cgjenkins-home/casc:ro - ./apps:/sources/apps:ro + - ./shared-libraries:/tmp/cgjenkins-home/shared-libraries:ro - ./.secrets/docker-config.json:/tmp/cgjenkins-home/.docker/config.json:ro diff --git a/jenkins/jenkins/casc/jenkins.yaml b/jenkins/jenkins/casc/jenkins.yaml index 453b1009..fbf420e4 100644 --- a/jenkins/jenkins/casc/jenkins.yaml +++ b/jenkins/jenkins/casc/jenkins.yaml @@ -23,6 +23,17 @@ jenkins: unclassified: location: url: "http://localhost:8080/" + globalLibraries: + libraries: + - name: cgImages + # Auto-loaded for every pipeline (no @Library annotation needed). + implicit: true + defaultVersion: master + retriever: + legacySCM: + scm: + filesystem: + path: /tmp/cgjenkins-home/shared-libraries/cg-images jobs: - file: /tmp/cgjenkins-home/casc/jobs.groovy diff --git a/jenkins/jenkins/plugins.txt b/jenkins/jenkins/plugins.txt index fc9d4087..fcf3001d 100644 --- a/jenkins/jenkins/plugins.txt +++ b/jenkins/jenkins/plugins.txt @@ -6,3 +6,7 @@ pipeline-utility-steps git timestamper ws-cleanup +# Filesystem SCM — lets Jenkins shared libraries load from a local directory +# instead of a remote Git repo. We use it to source the cg-images library +# from the bind-mounted ./shared-libraries dir. +filesystem_scm diff --git a/jenkins/shared-libraries/cg-images/README.md b/jenkins/shared-libraries/cg-images/README.md new file mode 100644 index 00000000..e7206f8b --- /dev/null +++ b/jenkins/shared-libraries/cg-images/README.md @@ -0,0 +1,47 @@ +# cgImages — Jenkins shared library + +Resolves logical Chainguard image tokens (e.g. `corretto-java17`, `python-3.14`) into the concrete `cgr.dev//:` strings that pipelines hand to `agent { docker { image '...' } }` blocks. The `` segment comes from `env.CHAINGUARD_ORG`, so org overrides flow through automatically. + +The library is auto-loaded into every pipeline via JCasC ([jenkins/casc/jenkins.yaml](../../jenkins/casc/jenkins.yaml) → `unclassified.globalLibraries.libraries`, sourced via the `filesystem_scm` plugin from this directory). No `@Library('cgImages')` annotation is required. + +## Usage + +```groovy +def img = cgImage('corretto-java17') + +pipeline { + agent none + stages { + stage('Build') { + agent { docker { image img.build; args '--entrypoint=' } } + ... + } + stage('Test') { + agent { docker { image img.test; args '--entrypoint=' } } + ... + } + } +} +``` + +The map returned by `cgImage()` has some subset of these keys: + +| Key | Meaning | +|-----------|---------| +| `build` | The `*-dev` variant used in the Build stage. | +| `test` | The `*-dev` variant used in the Test stage (Java apps that test via `agent docker`). | +| `runtime` | The shell-less production target (Python/Node OCI-image apps reference this from their Dockerfile). | + +## Adding a new token + +Append a row to the `catalog` map in [vars/cgImage.groovy](vars/cgImage.groovy). **No Jenkins restart required** — the next pipeline run picks up the change automatically (see [Caveats](#caveats) below). + +## Why this layout + +The `vars/` subdirectory is the standard Jenkins shared-library convention for "global variables / steps" — files named `vars/foo.groovy` become a callable `foo(...)` in every pipeline. `src/` is the corresponding location for class definitions; this library doesn't need any. + +## Caveats + +- **Edits to `cgImage.groovy` apply on the next pipeline run, no Jenkins restart needed.** This is because the retriever is `legacySCM` → `FSSCM` (filesystem_scm plugin) pointed at the bind-mounted source dir. Filesystem-SCM "checks out" the library by copying from the source path at the start of each build, so disk changes propagate immediately. The shared-library version label (`master` in our config) is a placeholder here; for filesystem sources it does not gate caching the way Git refspecs do. If we ever switched the retriever to a real Git source, semantics would change — Jenkins would cache the library by commit and only re-fetch when the version label resolves to a new commit, unless `Fresh clone per build` (the `clone: true` flag on `SCMRetriever`) is set. +- **A Groovy syntax error in `cgImage.groovy` fails every pipeline that uses it, immediately, with no graceful fallback.** The library is `implicit: true`, so all pipelines load it whether they call `cgImage(...)` or not. Worth a quick `groovyc` lint or trial run after substantive edits. +- **All pipelines see the same version of the library at any moment.** To test a change against a single pipeline without affecting the others, flip `implicit` to `false` in JCasC, restart Jenkins, then add an explicit `@Library('cgImages@') _` annotation to the pipeline you want to opt into a different version. (For the filesystem-SCM case, "branch" is just any value — there's no real version control. The annotation is mostly meaningful when the source is Git.) diff --git a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy new file mode 100644 index 00000000..af7ab13c --- /dev/null +++ b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy @@ -0,0 +1,50 @@ +// vars/cgImage.groovy +// +// Shared-library entry point. Resolves a logical token like "corretto-java17" +// or "python-3.14" into a Map of fully-qualified Chainguard image references +// for use in `agent { docker { image '...' } }` blocks. +// +// Auto-loaded for every pipeline via JCasC (unclassified.globalLibraries with +// implicit: true). Pipelines do not need an `@Library('cgImages')` annotation. +// +// Usage: +// +// def img = cgImage('corretto-java17') +// pipeline { +// stages { +// stage('Build') { +// agent { docker { image img.build; args '--entrypoint=' } } +// ... +// } +// stage('Test') { +// agent { docker { image img.test; args '--entrypoint=' } } +// ... +// } +// } +// } +// +// Each token's map has some subset of these keys: +// build — the *-dev variant used in the Build stage +// test — the *-dev variant used in the Test stage (Java apps) +// runtime — the shell-less production target (referenced from Dockerfiles +// that build OCI images for Python/Node apps) +// +// To add a new token: append a row to the catalog below. Pipelines pick up +// changes after a controller restart (JCasC re-applies the library config). + +def call(String token) { + def reg = "cgr.dev/${env.CHAINGUARD_ORG}" + def catalog = [ + 'corretto-java17': [build: 'maven:3-jdk17-dev', test: 'amazon-corretto-jre:17-dev'], + 'adoptium-java8': [build: 'maven:3-jdk8-dev', test: 'adoptium-jre:adoptium-openjdk-8-dev'], + 'openjdk21': [build: 'jdk:openjdk-21-dev', test: 'jre:openjdk-21-dev'], + 'python-3.14': [build: 'python:3.14-dev', runtime: 'python:3.14'], + 'python-3.12': [build: 'python:3.12-dev', runtime: 'python:3.12'], + 'node-22': [build: 'node:22-dev', runtime: 'node:22'], + 'node-25': [build: 'node:25-dev', runtime: 'node:25-slim'], + ] + if (!catalog.containsKey(token)) { + error("Unknown cgImage token '${token}'. Valid tokens: ${catalog.keySet().sort().join(', ')}") + } + return catalog[token].collectEntries { k, v -> [(k): "${reg}/${v}"] } +} From 42bb37f16f521a4e455fd0a4a108248a1813ed20 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Wed, 6 May 2026 16:03:51 -0500 Subject: [PATCH 11/47] jenkins: pin cgImage catalog entries to image digests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each catalog entry in vars/cgImage.groovy now uses repo:tag@sha256:... format. The tag is retained for human readability; the digest provides immutable identity so re-runs of a pipeline pull the same image bytes even if an upstream :dev tag is later repointed. Docker accepts the combined repo:tag@digest form natively. Adds refresh-digests.sh to re-resolve every catalog entry against the configured CHAINGUARD_ORG (read from ../../.env, falling back to smalls.xyz) and rewrite the digests in place. Use it when: - Adopting newer image versions (security patches, etc.) - Switching CHAINGUARD_ORG (digests are technically org-specific even when content is byte-identical, so re-pin on org change) - Adding a new catalog entry with no initial digest Sed-based rewrite — initial perl version eats the `@` sign because perl interpolates `@sha256` as an array variable in the replacement string. README adds a "pinned by digest" section, a refresh walkthrough, and two new caveats: - Digests are org-specific; re-pin when changing CHAINGUARD_ORG - Pins are frozen in time and don't auto-update; for production use digestabotctl/Renovate/etc. instead of leaving stale pins Verified end-to-end: corretto-java17-maven #11 (11s) and python314-uv-flask #8 (10s) both succeeded; build console shows cgr.dev/smalls.xyz/:@sha256: being inspected and pulled. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/shared-libraries/cg-images/README.md | 16 ++++- .../cg-images/refresh-digests.sh | 67 +++++++++++++++++++ .../cg-images/vars/cgImage.groovy | 43 +++++++++--- 3 files changed, 116 insertions(+), 10 deletions(-) create mode 100755 jenkins/shared-libraries/cg-images/refresh-digests.sh diff --git a/jenkins/shared-libraries/cg-images/README.md b/jenkins/shared-libraries/cg-images/README.md index e7206f8b..9f3568b7 100644 --- a/jenkins/shared-libraries/cg-images/README.md +++ b/jenkins/shared-libraries/cg-images/README.md @@ -32,9 +32,21 @@ The map returned by `cgImage()` has some subset of these keys: | `test` | The `*-dev` variant used in the Test stage (Java apps that test via `agent docker`). | | `runtime` | The shell-less production target (Python/Node OCI-image apps reference this from their Dockerfile). | +## Image references are pinned by digest + +Each entry uses `repo:tag@sha256:...` format. The tag is kept for human readability; the digest provides immutable identity so re-runs of a pipeline always pull the **same image bytes** even if the upstream `:dev` tag is later repointed to a newer build. This is the standard pattern for reproducible Chainguard image consumption. + +To refresh the digests (e.g. to pick up a security patch in the underlying image), run: + +```sh +./refresh-digests.sh +``` + +The script reads the current `repo:tag` portion of each pinned reference, calls `crane digest cgr.dev/$CHAINGUARD_ORG/:` for each, and rewrites the digest in place. Requires `crane`. It picks up `CHAINGUARD_ORG` from `../../.env` so refreshing matches the org the demo is configured for. + ## Adding a new token -Append a row to the `catalog` map in [vars/cgImage.groovy](vars/cgImage.groovy). **No Jenkins restart required** — the next pipeline run picks up the change automatically (see [Caveats](#caveats) below). +Append a row to the `catalog` map in [vars/cgImage.groovy](vars/cgImage.groovy) with the desired `repo:tag` and any digest (or none — leaving just `repo:tag` works too). Then run [refresh-digests.sh](refresh-digests.sh) to pin the entry to the current digest. **No Jenkins restart required** — the next pipeline run picks up the change automatically (see [Caveats](#caveats) below). ## Why this layout @@ -45,3 +57,5 @@ The `vars/` subdirectory is the standard Jenkins shared-library convention for " - **Edits to `cgImage.groovy` apply on the next pipeline run, no Jenkins restart needed.** This is because the retriever is `legacySCM` → `FSSCM` (filesystem_scm plugin) pointed at the bind-mounted source dir. Filesystem-SCM "checks out" the library by copying from the source path at the start of each build, so disk changes propagate immediately. The shared-library version label (`master` in our config) is a placeholder here; for filesystem sources it does not gate caching the way Git refspecs do. If we ever switched the retriever to a real Git source, semantics would change — Jenkins would cache the library by commit and only re-fetch when the version label resolves to a new commit, unless `Fresh clone per build` (the `clone: true` flag on `SCMRetriever`) is set. - **A Groovy syntax error in `cgImage.groovy` fails every pipeline that uses it, immediately, with no graceful fallback.** The library is `implicit: true`, so all pipelines load it whether they call `cgImage(...)` or not. Worth a quick `groovyc` lint or trial run after substantive edits. - **All pipelines see the same version of the library at any moment.** To test a change against a single pipeline without affecting the others, flip `implicit` to `false` in JCasC, restart Jenkins, then add an explicit `@Library('cgImages@') _` annotation to the pipeline you want to opt into a different version. (For the filesystem-SCM case, "branch" is just any value — there's no real version control. The annotation is mostly meaningful when the source is Git.) +- **Digest pins are org-specific.** `cgr.dev/smalls.xyz/maven:3-jdk17-dev` and `cgr.dev/chainguard/maven:3-jdk17-dev` typically resolve to the same content (Chainguard mirrors are byte-identical for shared images), but that's not guaranteed. If you switch `CHAINGUARD_ORG`, re-run `refresh-digests.sh` to verify and re-pin against the new org. A wrong digest fails the pull at build time with a clear error message. +- **Digest pins are also frozen in time.** Chainguard rebuilds these images regularly with the latest CVE fixes. Pinning gives you reproducibility but it does not auto-update — for the demo this is fine, but for a production setup you'd want a higher-cadence refresh (e.g. via [`digestabotctl`](../../../digestabotctl/) elsewhere in this repo, Renovate, or a scheduled CI job that re-runs `refresh-digests.sh`). diff --git a/jenkins/shared-libraries/cg-images/refresh-digests.sh b/jenkins/shared-libraries/cg-images/refresh-digests.sh new file mode 100755 index 00000000..6fd3fba3 --- /dev/null +++ b/jenkins/shared-libraries/cg-images/refresh-digests.sh @@ -0,0 +1,67 @@ +#!/usr/bin/env bash +# Re-resolve every image reference in vars/cgImage.groovy to its current digest, +# rewriting in place. Run this when: +# - You want to pick up newer image versions (security patches, etc.) +# - You added a new entry without a digest and want it pinned +# +# Requires: crane (https://github.com/google/go-containerregistry). +# Picks up CHAINGUARD_ORG from ../../.env so the digests match the org the +# rest of the demo is configured for. +set -euo pipefail + +cd "$(dirname "$0")" + +if [[ -z "${CHAINGUARD_ORG:-}" && -f ../../.env ]]; then + set -a + # shellcheck disable=SC1091 + source ../../.env + set +a +fi +ORG="${CHAINGUARD_ORG:-smalls.xyz}" + +CATALOG=vars/cgImage.groovy +echo "Refreshing digests in ${CATALOG} against cgr.dev/${ORG}/..." + +# Extract every single-quoted ":" or ":@sha256:..." entry +# from the catalog. We deduplicate by the repo:tag portion (everything before +# the optional @ digest) so multiple stale-digest entries collapse to one +# crane call. +mapfile -t pairs < <( + grep -oE "'[a-z][a-z0-9_.-]*:[a-zA-Z0-9._-]+(@sha256:[0-9a-f]{64})?'" "$CATALOG" \ + | tr -d "'" \ + | sort -u +) + +if (( ${#pairs[@]} == 0 )); then + echo "No image references found in ${CATALOG}." >&2 + exit 1 +fi + +# Build the unique set of repo:tag values to query. +declare -A reftags +for entry in "${pairs[@]}"; do + reftag="${entry%@*}" + reftags["$reftag"]=1 +done + +tmp=$(mktemp) +cp "$CATALOG" "$tmp" + +for reftag in "${!reftags[@]}"; do + printf ' %-50s ' "$reftag" + digest=$(crane digest "cgr.dev/${ORG}/${reftag}") + echo "$digest" + pinned="${reftag}@${digest}" + + # Replace any existing pinned form of this reftag (with any old digest) and + # any unpinned form, with the new pinned form. Use sed rather than perl + # because perl interpolates `@sha256` as an array variable in the + # replacement, eating the @ sign. + sed -i.bak -E "s|'${reftag}(@sha256:[0-9a-f]{64})?'|'${pinned}'|g" "$tmp" + rm -f "${tmp}.bak" +done + +mv "$tmp" "$CATALOG" +echo +echo "Done. Diff:" +git --no-pager diff -- "$CATALOG" || true diff --git a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy index af7ab13c..9bf2cd6a 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy @@ -29,19 +29,44 @@ // runtime — the shell-less production target (referenced from Dockerfiles // that build OCI images for Python/Node apps) // -// To add a new token: append a row to the catalog below. Pipelines pick up -// changes after a controller restart (JCasC re-applies the library config). +// Image references are pinned by digest (the `@sha256:...` suffix) so that +// re-runs of a pipeline always pull the same bytes even if the upstream +// `:dev` tag is later repointed. The tag is retained alongside the digest +// for human readability — Docker accepts `repo:tag@digest` natively. +// Refresh the digests with refresh-digests.sh when you want to pick up +// newer image versions. def call(String token) { def reg = "cgr.dev/${env.CHAINGUARD_ORG}" def catalog = [ - 'corretto-java17': [build: 'maven:3-jdk17-dev', test: 'amazon-corretto-jre:17-dev'], - 'adoptium-java8': [build: 'maven:3-jdk8-dev', test: 'adoptium-jre:adoptium-openjdk-8-dev'], - 'openjdk21': [build: 'jdk:openjdk-21-dev', test: 'jre:openjdk-21-dev'], - 'python-3.14': [build: 'python:3.14-dev', runtime: 'python:3.14'], - 'python-3.12': [build: 'python:3.12-dev', runtime: 'python:3.12'], - 'node-22': [build: 'node:22-dev', runtime: 'node:22'], - 'node-25': [build: 'node:25-dev', runtime: 'node:25-slim'], + 'corretto-java17': [ + build: 'maven:3-jdk17-dev@sha256:cd5d074f5f5989536cd320ff7c8e33ba345a11a113b2417234e16ec2045309bf', + test: 'amazon-corretto-jre:17-dev@sha256:38425cf65c9ca627c44089d1b98525f813d3081fe74633c54c7e581080d3112a', + ], + 'adoptium-java8': [ + build: 'maven:3-jdk8-dev@sha256:84acda5c8d76e33b24603b243e477130afbf8ff985c56fa99c923ab9da751495', + test: 'adoptium-jre:adoptium-openjdk-8-dev@sha256:6a35f6882e2402f241a1d0efe057993463a714a542520aaeff4f4e3d4d28eefe', + ], + 'openjdk21': [ + build: 'jdk:openjdk-21-dev@sha256:e300d1dd8eb0c98eb279ddf6fbda3174de8e2701877eabbf9aff994dd97118ec', + test: 'jre:openjdk-21-dev@sha256:d146b274db34dcd9e42cbaec74fa94efa89c2f020bac6fd815d4526027f7e926', + ], + 'python-3.14': [ + build: 'python:3.14-dev@sha256:af64d5af1a0fef64e4bfee8f49663628313f9d2de79083f19b8468f124d378c0', + runtime: 'python:3.14@sha256:7d66fd00301532cfffae8baf4a00f4e590d8bb0a6a1efe5a468a38aacaf970f1', + ], + 'python-3.12': [ + build: 'python:3.12-dev@sha256:e46278ee972ce5a066c10b3e78339d09e42ab480b3ebfb3d8b1937e90507f6a5', + runtime: 'python:3.12@sha256:0f0c12676d9e4cb87d20c7d88716003c914529f4793d1968c4fbc707ed504198', + ], + 'node-22': [ + build: 'node:22-dev@sha256:588335d09a93bf347108b5d686e3c6918c99c745cfe2853883890ee12a9db0ba', + runtime: 'node:22@sha256:593ea898047d547e02471f48f3e51cacf1bec07f2c5d57d7943edcde16109d59', + ], + 'node-25': [ + build: 'node:25-dev@sha256:b23770086e7e7eb49dcca3c7e2791ac517d2b3829cfc708121d79b8f2252b454', + runtime: 'node:25-slim@sha256:affd11bfb77c0d4cd5e87d0ab7b922f2833293d5c8e24e687323d14eb19136c6', + ], ] if (!catalog.containsKey(token)) { error("Unknown cgImage token '${token}'. Valid tokens: ${catalog.keySet().sort().join(', ')}") From 68066cbda281fe1163508e6784679b8fe85e5386 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 08:13:00 -0500 Subject: [PATCH 12/47] jenkins: add scheduled refresh-cgimages-digests job (every 4h) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new ops pipeline that runs refresh-digests.sh inside a Chainguard crane container every 4 hours, picking up upstream tag movements without manual intervention. The schedule (H H/4 * * *) randomizes the minute and starting hour to avoid registry stampedes. Implementation: * jenkins/ops/refresh-cgimages-digests/Jenkinsfile — uses `agent { docker { image cgr.dev/${CHAINGUARD_ORG}/crane:latest-dev } }` and lets the docker-workflow plugin's automatic --volumes-from inheritance pull in the controller's mounts. That gives us the pull-token docker config (for crane auth) and the rw shared- libraries dir (where the script writes) for free, no extra -v. * jenkins/casc/jobs.groovy — a separate ops-jobs block so this lives alongside the seven sample-app pipelines without polluting the `apps` list. * docker-compose.yml — flips ./shared-libraries from :ro to :rw so the spawned crane container can write back, plus adds ./ops:ro so the seed can load the Jenkinsfile from disk. The bind-mount-rewrite story: when the job rewrites vars/cgImage.groovy, the change shows up on the host filesystem immediately. The cgImages library is filesystem-SCM live-loaded, so the next sample-app pipeline run picks up the new digests with no Jenkins restart (verified empirically earlier in this branch). Two minor refresh-digests.sh fixes baked in: * sed-based digest substitution (perl interpolated `@sha256` as an array variable, eating the @ — bug fixed previously) * Skip the trailing `git diff` when running in an environment without git (the crane container) instead of falling through to git's help text Top-level README mentions the auto-refresh; ops/refresh-cgimages- digests/README.md covers the design and a few caveats (writes are visible on the host filesystem, digest refresh costs one HEAD per entry, schedule edits require a controller restart). Verified: build #2 SUCCESS in 9s, all 14 digests resolved via crane inside cgr.dev/smalls.xyz/crane:latest-dev, vars/cgImage.groovy rewritten in place. Several digests had drifted since yesterday's manual pin — exactly the case this job exists to handle. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/README.md | 4 +- jenkins/docker-compose.yml | 5 +- jenkins/jenkins/casc/jobs.groovy | 15 ++++++ .../ops/refresh-cgimages-digests/Jenkinsfile | 47 +++++++++++++++++++ .../ops/refresh-cgimages-digests/README.md | 27 +++++++++++ .../cg-images/refresh-digests.sh | 11 ++++- .../cg-images/vars/cgImage.groovy | 22 ++++----- 7 files changed, 116 insertions(+), 15 deletions(-) create mode 100644 jenkins/ops/refresh-cgimages-digests/Jenkinsfile create mode 100644 jenkins/ops/refresh-cgimages-digests/README.md diff --git a/jenkins/README.md b/jenkins/README.md index 064eca43..f668ef83 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -44,7 +44,9 @@ The controller talks to the **host's Docker daemon** via the mounted `/var/run/d Each sample application lives under [apps/](apps/) as if it were a separate repository. The controller's first pipeline stage (`Checkout`) copies the app sources from the bind-mounted `/sources/apps//` into the workspace. Subsequent stages stash/unstash to share artifacts. -Pipelines never hardcode image strings. Instead they call `cgImage('')` from the [cgImages shared library](shared-libraries/cg-images/) — e.g. `cgImage('corretto-java17').build` resolves to the right `cgr.dev//maven:3-jdk17-dev`. The library is auto-loaded by JCasC from a bind-mounted filesystem path, so adding/changing a token is a one-file edit and the next pipeline run picks it up automatically (no controller restart needed). See the library's [README](shared-libraries/cg-images/README.md#caveats) for a few caveats around editing it. +Pipelines never hardcode image strings. Instead they call `cgImage('')` from the [cgImages shared library](shared-libraries/cg-images/) — e.g. `cgImage('corretto-java17').build` resolves to the right `cgr.dev//maven:3-jdk17-dev@sha256:`. Catalog entries are pinned by digest for reproducibility. The library is auto-loaded by JCasC from a bind-mounted filesystem path, so adding/changing a token is a one-file edit and the next pipeline run picks it up automatically (no controller restart needed). See the library's [README](shared-libraries/cg-images/README.md#caveats) for caveats around editing it. + +A scheduled Jenkins job, [refresh-cgimages-digests](ops/refresh-cgimages-digests/), re-resolves every catalog entry against the registry every 4 hours so digest pins keep up with upstream tag movements automatically. ## Prerequisites diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml index b334fecf..7036abae 100644 --- a/jenkins/docker-compose.yml +++ b/jenkins/docker-compose.yml @@ -38,5 +38,8 @@ services: - /tmp/cgjenkins-home:/tmp/cgjenkins-home - ./jenkins/casc:/tmp/cgjenkins-home/casc:ro - ./apps:/sources/apps:ro - - ./shared-libraries:/tmp/cgjenkins-home/shared-libraries:ro + - ./ops:/sources/ops:ro + # shared-libraries is read-write so the refresh-cgimages-digests job + # can rewrite vars/cgImage.groovy in place when image tags repoint. + - ./shared-libraries:/tmp/cgjenkins-home/shared-libraries:rw - ./.secrets/docker-config.json:/tmp/cgjenkins-home/.docker/config.json:ro diff --git a/jenkins/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy index 5a7bc6e4..3e66740d 100644 --- a/jenkins/jenkins/casc/jobs.groovy +++ b/jenkins/jenkins/casc/jobs.groovy @@ -48,3 +48,18 @@ apps.each { app -> } } } + +// Ops jobs — internal Jenkins maintenance pipelines (not sample app builds). +pipelineJob('refresh-cgimages-digests') { + description('Re-resolves cgImages catalog digests every 4 hours so sample-app pipelines stay current with upstream tag movements.') + triggers { + // H H/4 * * * — every 4 hours, randomized per controller. + cron('H H/4 * * *') + } + definition { + cps { + script(new File('/sources/ops/refresh-cgimages-digests/Jenkinsfile').text) + sandbox(true) + } + } +} diff --git a/jenkins/ops/refresh-cgimages-digests/Jenkinsfile b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile new file mode 100644 index 00000000..17b9e373 --- /dev/null +++ b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile @@ -0,0 +1,47 @@ +// Scheduled job that re-resolves the digests in the cgImages shared library +// catalog every 4 hours. Runs the existing refresh-digests.sh inside a +// Chainguard crane container so the controller doesn't need crane baked in. +// +// The shared-libraries bind mount is :rw in docker-compose.yml — when this +// job rewrites vars/cgImage.groovy, the change is visible on the host +// filesystem (i.e. in the user's git checkout) and the next sample-app +// pipeline run picks up the new digests automatically (filesystem-SCM +// live-reload — see shared-libraries/cg-images/README.md#caveats). + +pipeline { + agent none + options { timestamps() } + + // The seed in jobs.groovy sets the trigger via Jobs DSL; the schedule is + // captured here as documentation. Editing this comment does not change + // when the job runs — edit jobs.groovy and restart the controller for that. + // + // Schedule: H H/4 * * * (every 4 hours, randomized per controller) + + stages { + stage('Refresh digests') { + agent { + docker { + image "cgr.dev/${env.CHAINGUARD_ORG}/crane:latest-dev" + // The agent inherits the controller's mounts via `--volumes-from` + // (Jenkins docker-workflow plugin default), so the shared-libraries + // tree (rw) and the pull-token docker config are already visible at + // /tmp/cgjenkins-home/... — no extra `-v` flags needed. + // -- entrypoint: clear so Jenkins' cat-keepalive works + // -- DOCKER_CONFIG: point crane at the inherited pull-token config + args '--entrypoint= -e DOCKER_CONFIG=/tmp/cgjenkins-home/.docker' + reuseNode true + } + } + steps { + sh '/tmp/cgjenkins-home/shared-libraries/cg-images/refresh-digests.sh' + } + } + } + + post { + success { + echo "Refresh complete. Any digest changes are picked up on the next sample-app pipeline run automatically." + } + } +} diff --git a/jenkins/ops/refresh-cgimages-digests/README.md b/jenkins/ops/refresh-cgimages-digests/README.md new file mode 100644 index 00000000..dba55f7e --- /dev/null +++ b/jenkins/ops/refresh-cgimages-digests/README.md @@ -0,0 +1,27 @@ +# refresh-cgimages-digests + +Scheduled Jenkins job that re-resolves every digest in the [cgImages shared-library catalog](../../shared-libraries/cg-images/vars/cgImage.groovy) every 4 hours, rewriting `repo:tag@sha256:...` entries in place when the upstream tags have moved. + +## How it works + +| Stage | Image | Purpose | +|-------|-------|---------| +| Refresh digests | `cgr.dev/${CHAINGUARD_ORG}/crane:latest-dev` | Runs [refresh-digests.sh](../../shared-libraries/cg-images/refresh-digests.sh) which calls `crane digest` for each entry. | + +The agent container gets: +- `DOCKER_CONFIG=/dockerconfig` pointed at the bind-mounted pull-token config (so `crane` can authenticate to `cgr.dev`) +- `/tmp/cgjenkins-home/shared-libraries` mounted **read-write** at `/sources` so the script can rewrite `vars/cgImage.groovy` + +When digests change, the next sample-app pipeline picks them up automatically — the cgImages library is live-loaded from the same bind-mounted dir on every build. No Jenkins restart needed. + +## Schedule + +`H H/4 * * *` — every 4 hours, with the minute and starting hour randomized per controller (the leading `H` spreads the load so multiple controllers don't all stampede the registry at exactly :00). + +To change the schedule, edit the `cron(...)` argument in [jenkins/casc/jobs.groovy](../../jenkins/casc/jobs.groovy) and restart the controller. To disable temporarily, comment out the `triggers` block — the job remains in the dashboard and can still be triggered manually. + +## Caveats + +- Writes to the shared-libraries dir are visible on the host filesystem (the bind mount is rw). If you have local edits to `vars/cgImage.groovy` that haven't been committed, this job will overwrite the digest fields. Adding/removing tokens from the catalog is fine — those edits live on lines the script doesn't touch. +- The job authenticates as the configured `CHAINGUARD_ORG`'s pull token. Digests for other orgs would need a separate auth path. +- Digest resolution against the registry costs one HEAD per entry (14 today). Cheap, but not free — don't run this every minute. diff --git a/jenkins/shared-libraries/cg-images/refresh-digests.sh b/jenkins/shared-libraries/cg-images/refresh-digests.sh index 6fd3fba3..e687f05d 100755 --- a/jenkins/shared-libraries/cg-images/refresh-digests.sh +++ b/jenkins/shared-libraries/cg-images/refresh-digests.sh @@ -63,5 +63,12 @@ done mv "$tmp" "$CATALOG" echo -echo "Done. Diff:" -git --no-pager diff -- "$CATALOG" || true +# Show a diff if git is available and we're inside a checkout (e.g. running +# from a developer's laptop); silently skip otherwise (e.g. inside the crane +# container the Jenkins job runs in, which has no git). +if command -v git >/dev/null 2>&1 && git -C "$(dirname "$CATALOG")" rev-parse --is-inside-work-tree >/dev/null 2>&1; then + echo "Done. Diff:" + git --no-pager diff -- "$CATALOG" || true +else + echo "Done." +fi diff --git a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy index 9bf2cd6a..68f08915 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy @@ -40,31 +40,31 @@ def call(String token) { def reg = "cgr.dev/${env.CHAINGUARD_ORG}" def catalog = [ 'corretto-java17': [ - build: 'maven:3-jdk17-dev@sha256:cd5d074f5f5989536cd320ff7c8e33ba345a11a113b2417234e16ec2045309bf', - test: 'amazon-corretto-jre:17-dev@sha256:38425cf65c9ca627c44089d1b98525f813d3081fe74633c54c7e581080d3112a', + build: 'maven:3-jdk17-dev@sha256:d95ab64eb7ce9b3016cc781c65025727b7287f048655ce2a33ec8b9500b4ba39', + test: 'amazon-corretto-jre:17-dev@sha256:d00bd8b357f0481d9c9c2778d69b74312f01d582b04df1e296a76ee74714e8be', ], 'adoptium-java8': [ - build: 'maven:3-jdk8-dev@sha256:84acda5c8d76e33b24603b243e477130afbf8ff985c56fa99c923ab9da751495', - test: 'adoptium-jre:adoptium-openjdk-8-dev@sha256:6a35f6882e2402f241a1d0efe057993463a714a542520aaeff4f4e3d4d28eefe', + build: 'maven:3-jdk8-dev@sha256:cad0490a5f41c922e884d3cc11a810234b8661fe8b2becbcdc6a2d92e2708a9e', + test: 'adoptium-jre:adoptium-openjdk-8-dev@sha256:f745cc893a988fd4065b21b318473315c6fab61607bc89d8fd656633d75fcd6d', ], 'openjdk21': [ - build: 'jdk:openjdk-21-dev@sha256:e300d1dd8eb0c98eb279ddf6fbda3174de8e2701877eabbf9aff994dd97118ec', - test: 'jre:openjdk-21-dev@sha256:d146b274db34dcd9e42cbaec74fa94efa89c2f020bac6fd815d4526027f7e926', + build: 'jdk:openjdk-21-dev@sha256:8fccfbd701fe79dc6c53bba92b7d4e3387bd6b140cd69fccbc74109da59a470c', + test: 'jre:openjdk-21-dev@sha256:5c7d9d0287cf9bd4c4d3df39f532a028a0f019d96b60a12862c2dc29d9c740fb', ], 'python-3.14': [ - build: 'python:3.14-dev@sha256:af64d5af1a0fef64e4bfee8f49663628313f9d2de79083f19b8468f124d378c0', + build: 'python:3.14-dev@sha256:3e01b7cc5c6ec2615546784c78e85ac6d1fefa5e07666515d8d32463952adc96', runtime: 'python:3.14@sha256:7d66fd00301532cfffae8baf4a00f4e590d8bb0a6a1efe5a468a38aacaf970f1', ], 'python-3.12': [ - build: 'python:3.12-dev@sha256:e46278ee972ce5a066c10b3e78339d09e42ab480b3ebfb3d8b1937e90507f6a5', + build: 'python:3.12-dev@sha256:c4205ab4b2e326c214f4d438c8cb7fffc093ffc95a4a1b67c40c8657b1851f47', runtime: 'python:3.12@sha256:0f0c12676d9e4cb87d20c7d88716003c914529f4793d1968c4fbc707ed504198', ], 'node-22': [ - build: 'node:22-dev@sha256:588335d09a93bf347108b5d686e3c6918c99c745cfe2853883890ee12a9db0ba', - runtime: 'node:22@sha256:593ea898047d547e02471f48f3e51cacf1bec07f2c5d57d7943edcde16109d59', + build: 'node:22-dev@sha256:4bc74862aec7fcfcf518e8606dbc0d7cdc294a1a062c6323a42bda40dd443969', + runtime: 'node:22@sha256:2ac85f61d02a044683bf5904183d2215acf167773c4fdf091ff657f4acfc84be', ], 'node-25': [ - build: 'node:25-dev@sha256:b23770086e7e7eb49dcca3c7e2791ac517d2b3829cfc708121d79b8f2252b454', + build: 'node:25-dev@sha256:42011912b30c4ed6320a7c48cbec1571886ab57915f54cd6ed616958ae5d9857', runtime: 'node:25-slim@sha256:affd11bfb77c0d4cd5e87d0ab7b922f2833293d5c8e24e687323d14eb19136c6', ], ] From 951fabd2d8b156e6a677f4cb464c553ec9de35a8 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 09:57:13 -0500 Subject: [PATCH 13/47] jenkins: replace pull-token auth with OIDC assumed identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Eliminates the long-lived pull-token (.secrets/docker-config.json) in favor of per-build short-lived chainctl sessions. Each pipeline opens with a new `Auth` stage that calls `cgLogin()` (a sibling shared- library var to cgImage), which exchanges a per-build OIDC token issued by Jenkins itself for a fresh ~30-min Chainguard session. Architecture: * Jenkins-as-OIDC-issuer via the new oidc-provider plugin. JCasC configures buildClaimTemplates to set sub to a fixed string (jenkins-cgimages-puller) and the credential's audience to Chainguard's https://issuer.enforce.dev. * Chainguard identity uses the `static` block — Jenkins' JWKS is uploaded directly at terraform-apply time. Chainguard's IAM never has to reach our local controller, so the demo stays self-contained (no cloudflared/ngrok). The trade-off: subject is fixed (single identity in audit logs), since `static` doesn't support subject_pattern. * Setup.sh refactor: polls Jenkins until it serves /oidc/jwks, fetches the JWKS into iac/jenkins-jwks.json, runs terraform apply against jenkins/iac/ (chainguard_identity + rolebinding to registry.pull), and writes the resulting UIDP into shared-libraries/cg-images/IDENTITY (gitignored). cgLogin reads the file at build time, so a Jenkins restart does NOT need to propagate it. Per-build flow: 1. Auth stage (agent any) calls cgLogin() — withCredentials wraps the oidc-provider plugin's per-build JWT, then chainctl auth login + chainctl auth configure-docker write a fresh docker config.json to $DOCKER_CONFIG. 2. Subsequent agent { docker { image cgImage(...).build } } stages reuse the freshly-written config to pull from cgr.dev. 3. Token expires after ~30min; covers any single build comfortably. New images required in smalls.xyz: chainctl (multi-stage-copied into the controller alongside docker-cli). Already added by the user. Several non-obvious wrinkles surfaced during build-out, all noted in either jenkins.yaml comments or the cgLogin.groovy comments: * jenkins.location.url is set to https://localhost:8080/ (lying about the scheme) so the OIDC `iss` claim is HTTPS — chainguard's static block validator requires HTTPS. The plugin still serves JWKS at the real http://localhost:8080/oidc/jwks; static-mode is offline-verification, the URL never has to resolve. * Don't override `issuer` per-credential — the plugin then refuses to serve JWKS on its default endpoint (Keys.java explicitly skips creds with custom issuers). * configure-docker creates a /usr/local/bin/docker-credential-cgr symlink. uid 1000 can't write there, so we pre-create the symlink in Dockerfile.jenkins as root. * configure-docker also needs --identity --identity-token to skip its fallback browser-login path. * The cgImages library config now uses `clone: true` so filesystem_scm checks out a fresh copy each build — without it, edits to cgLogin.groovy don't propagate. * After ANY restart, the oidc-provider plugin regenerates its signing key, invalidating the uploaded JWKS. setup.sh must be re-run after every controller restart. Documented in the README. Files dropped: the .secrets/docker-config.json bind-mount in docker-compose.yml is gone (no more pull tokens anywhere). Verified end-to-end: corretto-java17-maven build #23 (15s) succeeded — Auth stage logged in as the assumed identity, agent docker stages pulled cgr.dev/smalls.xyz/maven and amazon-corretto-jre using the short-lived chainctl session. python314-uv-flask #8 (10s) confirmed the OCI-image-push flow still works (push to ttl.sh is unaffected). Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/.env.example | 15 ++- jenkins/.gitignore | 3 + jenkins/Dockerfile.jenkins | 12 ++ jenkins/README.md | 35 ++++-- jenkins/apps/adoptium-java8-jetty/Jenkinsfile | 5 + .../apps/corretto-java17-maven/Jenkinsfile | 5 + jenkins/apps/node22-npm-express/Jenkinsfile | 5 + jenkins/apps/node25-pnpm-express/Jenkinsfile | 5 + jenkins/apps/openjdk21-gradle/Jenkinsfile | 5 + jenkins/apps/python312-pip-django/Jenkinsfile | 5 + jenkins/apps/python314-uv-flask/Jenkinsfile | 5 + jenkins/docker-compose.yml | 9 +- jenkins/iac/.gitignore | 10 ++ jenkins/iac/README.md | 32 +++++ jenkins/iac/main.tf | 47 ++++++++ jenkins/iac/outputs.tf | 4 + jenkins/iac/variables.tf | 23 ++++ jenkins/jenkins/casc/jenkins.yaml | 46 ++++++- jenkins/jenkins/plugins.txt | 5 + .../ops/refresh-cgimages-digests/Jenkinsfile | 5 + jenkins/setup.sh | 112 +++++++++++------- jenkins/shared-libraries/cg-images/README.md | 8 +- .../cg-images/vars/cgLogin.groovy | 50 ++++++++ 23 files changed, 390 insertions(+), 61 deletions(-) create mode 100644 jenkins/iac/.gitignore create mode 100644 jenkins/iac/README.md create mode 100644 jenkins/iac/main.tf create mode 100644 jenkins/iac/outputs.tf create mode 100644 jenkins/iac/variables.tf create mode 100644 jenkins/shared-libraries/cg-images/vars/cgLogin.groovy diff --git a/jenkins/.env.example b/jenkins/.env.example index 1d952d7c..89b516dd 100644 --- a/jenkins/.env.example +++ b/jenkins/.env.example @@ -1,10 +1,17 @@ -# Chainguard org under cgr.dev/ to pull images from. The setup script generates -# a pull token for this org; docker-compose passes it to the controller's image -# build and runtime env; pipelines reference it via env.CHAINGUARD_ORG; per-app -# Dockerfiles use it as a build ARG. +# Chainguard org under cgr.dev/ to pull images from. docker-compose passes +# it to the controller's image build and runtime env; pipelines reference it +# via env.CHAINGUARD_ORG; per-app Dockerfiles use it as a build ARG; setup.sh +# uses it when creating the Chainguard assumed identity via Terraform. # # Examples: # CHAINGUARD_ORG=chainguard # the public Chainguard catalog # CHAINGUARD_ORG=smalls.xyz # the demo's original org # CHAINGUARD_ORG=your-org # any org you have chainctl access to CHAINGUARD_ORG=smalls.xyz + +# Note: The Chainguard assumed identity UIDP is NOT stored here. setup.sh +# writes it to shared-libraries/cg-images/IDENTITY (filesystem-SCM live- +# loaded; cgLogin reads it at build time). Keeping it out of compose env +# avoids needing a Jenkins restart to propagate it, which would also +# regenerate the oidc-provider signing key and invalidate the uploaded +# Chainguard JWKS. diff --git a/jenkins/.gitignore b/jenkins/.gitignore index aaeb0ce4..87d220b1 100644 --- a/jenkins/.gitignore +++ b/jenkins/.gitignore @@ -4,6 +4,9 @@ # Pull-token-derived Docker config — generated by setup.sh, must not be committed. .secrets/ +# Identity UIDP file — generated by setup.sh, ephemeral, must not be committed. +shared-libraries/cg-images/IDENTITY + # Maven build outputs — produced when running pipelines locally for verification. apps/*/target/ diff --git a/jenkins/Dockerfile.jenkins b/jenkins/Dockerfile.jenkins index a823667a..a5018a7e 100644 --- a/jenkins/Dockerfile.jenkins +++ b/jenkins/Dockerfile.jenkins @@ -9,6 +9,11 @@ ARG CHAINGUARD_ORG=smalls.xyz ARG CHAINGUARD_ORG FROM cgr.dev/${CHAINGUARD_ORG}/docker-cli:29 AS docker +# chainctl binary, used by pipelines (via the cgLogin shared-library var) to +# exchange Jenkins-issued OIDC tokens for short-lived Chainguard sessions. +ARG CHAINGUARD_ORG +FROM cgr.dev/${CHAINGUARD_ORG}/chainctl:latest-dev AS chainctl + ARG CHAINGUARD_ORG FROM cgr.dev/${CHAINGUARD_ORG}/jenkins:2-lts-jdk21-dev AS plugins USER 0 @@ -19,7 +24,14 @@ ARG CHAINGUARD_ORG FROM cgr.dev/${CHAINGUARD_ORG}/jenkins:2-lts-jdk21-dev USER 0 COPY --from=docker /usr/bin/docker /usr/local/bin/docker +COPY --from=chainctl /usr/bin/chainctl /usr/local/bin/chainctl COPY --from=plugins --chown=1000:1000 /usr/share/jenkins/ref/plugins/ /usr/share/jenkins/ref/plugins/ +# `chainctl auth configure-docker` adds a credential helper for cgr.dev to +# the user's docker config.json — but the helper itself is a symlink in PATH +# called `docker-credential-cgr` that points back at chainctl. We pre-create +# it here as root so per-build chainctl runs (uid 1000) don't need write +# access to /usr/local/bin. +RUN ln -s /usr/local/bin/chainctl /usr/local/bin/docker-credential-cgr USER 1000 ENV JAVA_OPTS="-Djenkins.install.runSetupWizard=false" ENV CASC_JENKINS_CONFIG=/var/jenkins_home/casc diff --git a/jenkins/README.md b/jenkins/README.md index f668ef83..9971fad7 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -69,27 +69,36 @@ cp .env.example .env `.env` is loaded automatically by `docker-compose`, propagated to the controller container as an env var, and consumed by Jenkinsfiles (via `env.CHAINGUARD_ORG`), the controller Dockerfile, and per-app Dockerfiles (via build ARG). `setup.sh` also sources `.env`, so the pull token is generated against the configured org. -After changing the org, regenerate the pull token and rebuild: +After changing the org, re-run the bootstrap and rebuild — the Chainguard assumed identity is org-scoped, so the existing one becomes invalid for the new org: ```sh -./setup.sh docker compose up -d --build +./setup.sh ``` ## Quick start -**Step 1 — Generate the registry pull token.** [setup.sh](setup.sh) calls `chainctl auth pull-token create` for `$CHAINGUARD_ORG` (or `smalls.xyz` if unset) and writes a Docker config to `.secrets/docker-config.json` (gitignored). Re-run when the token expires (default TTL is 30 days). +The demo uses **OIDC assumed identity** auth — Jenkins itself signs short-lived JWTs per build, and pipelines exchange them for ~30-min Chainguard sessions via `chainctl auth login`. No long-lived pull tokens on disk. + +**Step 1 — Start Jenkins.** ```sh cd jenkins -./setup.sh +docker compose up -d --build ``` -**Step 2 — Start Jenkins.** +The controller image bakes in `chainctl` (multi-stage-copied from `cgr.dev/$CHAINGUARD_ORG/chainctl`) so pipelines can run it without an extra agent. + +**Step 2 — Bootstrap the Chainguard assumed identity.** [setup.sh](setup.sh) waits for Jenkins to come up, fetches its OIDC JWKS, then runs Terraform under [iac/](iac/) to create a `chainguard_identity` (`static` block, JWKS uploaded directly) and a `registry.pull` rolebinding. The identity's UIDP is written to `shared-libraries/cg-images/IDENTITY` (gitignored), which the `cgLogin` shared-library var reads at build time. ```sh -docker compose up -d --build +./setup.sh ``` +> **Important:** Re-run `setup.sh` after **any** of these: +> - First-time bootstrap. +> - Changing `CHAINGUARD_ORG` in `.env`. +> - **Any restart of the Jenkins controller** (`docker compose restart jenkins`, `docker compose up -d --force-recreate`, or `down`/`up`). The `oidc-provider` plugin regenerates the credential's RSA signing key on JCasC re-apply, which immediately invalidates the JWKS we previously uploaded to Chainguard. `setup.sh` re-fetches the new JWKS and re-applies Terraform; the identity object is updated in place (its UIDP changes, hence the per-restart re-write of the `IDENTITY` file). + Wait ~30s for Jenkins to finish initial startup (watch with `docker compose logs -f jenkins`), then open and log in: - Username: `admin` @@ -97,17 +106,18 @@ Wait ~30s for Jenkins to finish initial startup (watch with `docker compose logs You should see all seven jobs in the dashboard. Click any of them → **Build Now**. Each pipeline runs roughly the same shape: -1. **Checkout** — `cp -R /sources/apps//. .` from the bind-mounted source dir into the build workspace. -2. **Build (deps)** — install/compile in a Chainguard `*-dev` agent container. -3. **Test** — smoke-test in either the runtime image or its `-dev` variant (see [Common gotchas](#common-gotchas) below). -4. **Archive / Push** — for the Java pipelines, archive the JAR/WAR to Jenkins. For Python/Node, build a runtime OCI image and push to `ttl.sh`. +1. **Auth** — `cgLogin()` (a shared-library var) exchanges a fresh per-build Jenkins OIDC token for a short-lived Chainguard session, then writes a docker config that the rest of the build reuses. +2. **Checkout** — `cp -R /sources/apps//. .` from the bind-mounted source dir into the build workspace. +3. **Build (deps)** — install/compile in a Chainguard `*-dev` agent container. +4. **Test** — smoke-test in either the runtime image or its `-dev` variant (see [Common gotchas](#common-gotchas) below). +5. **Archive / Push** — for the Java pipelines, archive the JAR/WAR to Jenkins. For Python/Node, build a runtime OCI image and push to `ttl.sh`. A clean build takes 10s–40s once images are cached locally; the Gradle pipeline is slower on first run (~1m45s) because the Gradle wrapper has to download the Gradle distribution. ## Adding another sample app 1. Create a directory under [apps/](apps/), e.g. `apps/my-new-app/`. -2. Add the application source plus a `Jenkinsfile`. Use the same shape as the existing ones — first stage should `cp -R /sources/apps//. .` and stash, subsequent stages unstash and run. Reference Chainguard images via `cgImage('')` (see [shared-libraries/cg-images](shared-libraries/cg-images/)); add a new token there if needed. +2. Add the application source plus a `Jenkinsfile`. Use the same shape as the existing ones — first stage should be `Auth` (`steps { cgLogin() }`), then `Checkout` does `cp -R /sources/apps//. .` and stash, subsequent stages unstash and run. Reference Chainguard images via `cgImage('')` (see [shared-libraries/cg-images](shared-libraries/cg-images/)); add a new token there if needed. 3. Append one block to the `apps` list in [jenkins/casc/jobs.groovy](jenkins/casc/jobs.groovy). 4. Restart and reload Jenkins so JCasC re-runs the seed and the new job is loaded: ```sh @@ -129,7 +139,8 @@ These bit me while building out the seven samples — useful to know up front wh - **Gradle**: `environment { GRADLE_USER_HOME = "${WORKSPACE}/.gradle" }` - **npm / pnpm**: `environment { HOME = "${WORKSPACE}" }` - **Chainguard's `python:3.x-dev` runs as uid 65532**, which can't write to the system site-packages. For pipelines that want to `pip install --system` or `uv pip install --system`, pass `args '--user 0 --entrypoint='` in the Jenkinsfile **and** add `USER 0` to the corresponding stage in the Dockerfile. -- **OCI-image pipelines push to ttl.sh** (anonymous, public, 24h TTL) so no registry auth is needed inside the pipeline. The Jenkins controller's docker config (mounted from `.secrets/docker-config.json`, generated by [setup.sh](setup.sh)) handles `cgr.dev/$CHAINGUARD_ORG` pulls; pushes to `ttl.sh` need no creds. +- **OCI-image pipelines push to ttl.sh** (anonymous, public, 24h TTL) so no registry auth is needed for pushes. `cgr.dev/$CHAINGUARD_ORG` pulls are authorized by the per-build chainctl session that the `Auth` stage establishes; the resulting docker config lives at `$DOCKER_CONFIG` (in `/tmp/cgjenkins-home/.docker`) and is overwritten by each build. +- **The Auth stage must precede any `agent { docker { image '...' } }` stage**, because the docker-workflow plugin pulls the agent's image using whatever creds are in `$DOCKER_CONFIG` *at the start of that stage*. The current pipeline shape (`Auth` → `Checkout` → docker-agent stages) gets the ordering right; preserve it when adding new pipelines. ## Teardown diff --git a/jenkins/apps/adoptium-java8-jetty/Jenkinsfile b/jenkins/apps/adoptium-java8-jetty/Jenkinsfile index 534091b9..90d39e05 100644 --- a/jenkins/apps/adoptium-java8-jetty/Jenkinsfile +++ b/jenkins/apps/adoptium-java8-jetty/Jenkinsfile @@ -5,6 +5,11 @@ pipeline { options { timestamps() } stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + stage('Checkout') { agent any steps { diff --git a/jenkins/apps/corretto-java17-maven/Jenkinsfile b/jenkins/apps/corretto-java17-maven/Jenkinsfile index dc3299a9..02ce6cd9 100644 --- a/jenkins/apps/corretto-java17-maven/Jenkinsfile +++ b/jenkins/apps/corretto-java17-maven/Jenkinsfile @@ -6,6 +6,11 @@ pipeline { options { timestamps() } stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + stage('Checkout') { agent any steps { diff --git a/jenkins/apps/node22-npm-express/Jenkinsfile b/jenkins/apps/node22-npm-express/Jenkinsfile index ad58f231..3a231f30 100644 --- a/jenkins/apps/node22-npm-express/Jenkinsfile +++ b/jenkins/apps/node22-npm-express/Jenkinsfile @@ -9,6 +9,11 @@ pipeline { } stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + stage('Checkout') { agent any steps { diff --git a/jenkins/apps/node25-pnpm-express/Jenkinsfile b/jenkins/apps/node25-pnpm-express/Jenkinsfile index 46e08e88..84365228 100644 --- a/jenkins/apps/node25-pnpm-express/Jenkinsfile +++ b/jenkins/apps/node25-pnpm-express/Jenkinsfile @@ -9,6 +9,11 @@ pipeline { } stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + stage('Checkout') { agent any steps { diff --git a/jenkins/apps/openjdk21-gradle/Jenkinsfile b/jenkins/apps/openjdk21-gradle/Jenkinsfile index 33ced22d..4c844291 100644 --- a/jenkins/apps/openjdk21-gradle/Jenkinsfile +++ b/jenkins/apps/openjdk21-gradle/Jenkinsfile @@ -5,6 +5,11 @@ pipeline { options { timestamps() } stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + stage('Checkout') { agent any steps { diff --git a/jenkins/apps/python312-pip-django/Jenkinsfile b/jenkins/apps/python312-pip-django/Jenkinsfile index 9839fe9c..2df5de67 100644 --- a/jenkins/apps/python312-pip-django/Jenkinsfile +++ b/jenkins/apps/python312-pip-django/Jenkinsfile @@ -9,6 +9,11 @@ pipeline { } stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + stage('Checkout') { agent any steps { diff --git a/jenkins/apps/python314-uv-flask/Jenkinsfile b/jenkins/apps/python314-uv-flask/Jenkinsfile index 72ebd8d8..35038c25 100644 --- a/jenkins/apps/python314-uv-flask/Jenkinsfile +++ b/jenkins/apps/python314-uv-flask/Jenkinsfile @@ -9,6 +9,11 @@ pipeline { } stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + stage('Checkout') { agent any steps { diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml index 7036abae..4ecaa1b5 100644 --- a/jenkins/docker-compose.yml +++ b/jenkins/docker-compose.yml @@ -24,7 +24,9 @@ services: JENKINS_HOME: /tmp/cgjenkins-home CASC_JENKINS_CONFIG: /tmp/cgjenkins-home/casc JENKINS_ADMIN_PASSWORD: admin - # Point the docker CLI at the pull-token config generated by setup.sh. + # The docker CLI's config dir. Each pipeline's Auth stage runs + # `chainctl auth configure-docker` which writes a fresh, short-lived + # token here; subsequent agent docker steps reuse it. DOCKER_CONFIG: /tmp/cgjenkins-home/.docker # The host docker socket is owned root:root inside the container; add the # jenkins user (uid 1000) to GID 0 so it can read the socket. @@ -42,4 +44,7 @@ services: # shared-libraries is read-write so the refresh-cgimages-digests job # can rewrite vars/cgImage.groovy in place when image tags repoint. - ./shared-libraries:/tmp/cgjenkins-home/shared-libraries:rw - - ./.secrets/docker-config.json:/tmp/cgjenkins-home/.docker/config.json:ro + # NOTE: previous setups bind-mounted ./.secrets/docker-config.json here + # to seed a long-lived pull-token. With the OIDC assumed-identity flow + # each pipeline's Auth stage writes a fresh chainctl-issued config to + # DOCKER_CONFIG at build time, so no static config is needed. diff --git a/jenkins/iac/.gitignore b/jenkins/iac/.gitignore new file mode 100644 index 00000000..936e8931 --- /dev/null +++ b/jenkins/iac/.gitignore @@ -0,0 +1,10 @@ +# Terraform local state and provider plugin cache. +.terraform/ +.terraform.lock.hcl +*.tfstate +*.tfstate.* +*.tfplan + +# JWKS fetched from the running Jenkins controller by setup.sh — regenerated +# every bootstrap. Not a secret (public keys), but ephemeral. +jenkins-jwks.json diff --git a/jenkins/iac/README.md b/jenkins/iac/README.md new file mode 100644 index 00000000..c1b9d45b --- /dev/null +++ b/jenkins/iac/README.md @@ -0,0 +1,32 @@ +# jenkins/iac — Chainguard assumed-identity Terraform module + +Provisions the Chainguard IAM resources that let Jenkins authenticate to `cgr.dev` via OIDC token-exchange: + +- A `chainguard_identity` named `jenkins-cgimages-puller`, configured with a `static` block that holds Jenkins' OIDC issuer URL, the fixed subject `jenkins-cgimages-puller`, and Jenkins' uploaded JWKS. +- A `chainguard_rolebinding` granting that identity `registry.pull` on the parent group. + +## Why `static` instead of `claim_match` + +The two are mutually exclusive. `claim_match` supports subject patterns but requires Chainguard's IAM to fetch JWKS from a public issuer URL — not viable for a local-Compose demo. `static` lets us upload the JWKS at apply time, so verification is fully offline. Cost: a single fixed subject (no per-build subject granularity in audit logs); benefit: no tunneling required. + +## Usage + +This module is driven by [../setup.sh](../setup.sh), not run by hand. The bootstrap flow is: + +1. `docker compose up -d --build` — Jenkins starts and exposes its JWKS at `http://localhost:8080/oidc/jwks`. +2. `./setup.sh` — fetches that JWKS into `iac/jenkins-jwks.json`, runs `terraform apply`, captures the output `identity_uidp`, and writes it into `../.env` as `CHAINGUARD_IDENTITY=`. +3. `docker compose restart jenkins` — picks up the new env var so pipelines have access to it. + +Manual `terraform apply` works too (useful for debugging or org changes), but you must populate `jenkins-jwks.json` first. + +## Variables + +| Name | Default | What it controls | +|------|---------|------------------| +| `chainguard_group_name` | `smalls.xyz` | The parent group the identity lives under and the rolebinding's scope. | +| `jenkins_issuer_url` | `http://localhost:8080/oidc` | Must exactly match the `iss` claim Jenkins puts on its tokens. The `oidc-provider` plugin uses `/oidc` by default. | +| `jenkins_subject` | `jenkins-cgimages-puller` | Must exactly match the `sub` claim. Configured on the JCasC OIDC credential. | + +## Reused conventions + +- The resource pattern (`chainguard_identity` → `chainguard_rolebinding` to `registry.pull`) mirrors [../../image-copy-gcp/iac/main.tf](../../image-copy-gcp/iac/main.tf) — that module uses `claim_match` for Google's public OIDC; we use `static` for our local-only Jenkins. diff --git a/jenkins/iac/main.tf b/jenkins/iac/main.tf new file mode 100644 index 00000000..f09333d9 --- /dev/null +++ b/jenkins/iac/main.tf @@ -0,0 +1,47 @@ +terraform { + required_providers { + chainguard = { + source = "chainguard-dev/chainguard" + version = "~> 0.2" + } + } +} + +provider "chainguard" {} + +# Resolve the parent group (the same group that owns the smalls.xyz catalog). +data "chainguard_group" "parent" { + name = var.chainguard_group_name +} + +# Look up the registry.pull role. +data "chainguard_role" "puller" { + name = "registry.pull" +} + +# Identity that Jenkins assumes via OIDC token-exchange. Uses a `static` +# block so we upload Jenkins' JWKS at apply time — Chainguard's IAM never +# needs to fetch it from the controller (which is local-only, not publicly +# reachable). All builds present an OIDC token with subject jenkins_subject; +# the Jenkins oidc-provider plugin is configured in jenkins.yaml to set +# exactly that subject on every token it issues. +resource "chainguard_identity" "jenkins_puller" { + parent_id = data.chainguard_group.parent.id + name = "jenkins-cgimages-puller" + description = "Jenkins assumes this identity via OIDC token exchange to pull cgImages catalog images. JWKS uploaded statically; no public reachability required." + + static { + issuer = var.jenkins_issuer_url + subject = var.jenkins_subject + issuer_keys = file("${path.module}/jenkins-jwks.json") + expiration = var.identity_expiration + } +} + +# Grant the identity registry.pull on the parent group so it can pull any +# cgr.dev//: covered by the cgImages catalog. +resource "chainguard_rolebinding" "jenkins_puller_pulls" { + identity = chainguard_identity.jenkins_puller.id + group = data.chainguard_group.parent.id + role = data.chainguard_role.puller.items[0].id +} diff --git a/jenkins/iac/outputs.tf b/jenkins/iac/outputs.tf new file mode 100644 index 00000000..d05adbbb --- /dev/null +++ b/jenkins/iac/outputs.tf @@ -0,0 +1,4 @@ +output "identity_uidp" { + description = "UIDP of the Chainguard assumed identity. setup.sh writes this into ../.env as CHAINGUARD_IDENTITY." + value = chainguard_identity.jenkins_puller.id +} diff --git a/jenkins/iac/variables.tf b/jenkins/iac/variables.tf new file mode 100644 index 00000000..62cae880 --- /dev/null +++ b/jenkins/iac/variables.tf @@ -0,0 +1,23 @@ +variable "chainguard_group_name" { + description = "Chainguard parent group name (e.g. smalls.xyz). Must match CHAINGUARD_ORG used by the rest of the demo." + type = string + default = "smalls.xyz" +} + +variable "jenkins_issuer_url" { + description = "Issuer URL claim that Jenkins puts in OIDC tokens. Must be HTTPS (Chainguard provider validator) and must exactly match the iss claim Jenkins emits — which is jenkins.location.url + '/oidc'. The static block does offline JWKS verification, so the URL never has to resolve." + type = string + default = "https://localhost:8080/oidc" +} + +variable "identity_expiration" { + description = "RFC3339 timestamp at which the assumed identity stops accepting tokens. Must be in the future. Defaults to one year from a fixed-but-recent baseline; bump or rotate by running setup.sh again." + type = string + default = "2027-05-07T00:00:00Z" +} + +variable "jenkins_subject" { + description = "Subject claim that Jenkins puts in every OIDC token it issues to the cgr.dev credential. Configured in jenkins.yaml; must match here exactly." + type = string + default = "jenkins-cgimages-puller" +} diff --git a/jenkins/jenkins/casc/jenkins.yaml b/jenkins/jenkins/casc/jenkins.yaml index fbf420e4..1eda492e 100644 --- a/jenkins/jenkins/casc/jenkins.yaml +++ b/jenkins/jenkins/casc/jenkins.yaml @@ -20,9 +20,29 @@ jenkins: loggedInUsersCanDoAnything: allowAnonymousRead: false +security: + # Configure the Jenkins-as-OIDC-issuer plugin's claim templates. Override + # the default subject (which would be ${JOB_URL}) with a fixed string, + # because our chainguard_identity uses a `static` block which requires + # an exact subject match. The single literal subject means all builds + # appear as the same Chainguard identity, but each build still gets a + # freshly-signed short-lived JWT. + idToken: + buildClaimTemplates: + - name: sub + format: jenkins-cgimages-puller + type: "stringClaimType" + unclassified: location: - url: "http://localhost:8080/" + # jenkins.location.url drives the `iss` claim Jenkins puts in OIDC tokens + # via the oidc-provider plugin (no per-credential override). The + # chainguard_identity static block validator requires HTTPS, so we lie + # about the scheme here. The plugin still serves JWKS at the real + # http://localhost:8080/oidc/jwks (setup.sh fetches it from there); + # Chainguard verifies tokens offline against the uploaded JWKS by + # string-comparing the iss claim, so the URL never has to resolve. + url: "https://localhost:8080/" globalLibraries: libraries: - name: cgImages @@ -31,9 +51,33 @@ unclassified: defaultVersion: master retriever: legacySCM: + # `clone: true` forces a fresh checkout per build, otherwise + # filesystem_scm caches the library workspace and edits may + # not propagate to the next build. + clone: true scm: filesystem: path: /tmp/cgjenkins-home/shared-libraries/cg-images +credentials: + system: + domainCredentials: + - credentials: + # OIDC token issued per-build by Jenkins itself (oidc-provider plugin). + # cgLogin in the cgImages shared library pulls this via withCredentials + # and exchanges it for a short-lived chainctl session. + - idToken: + scope: GLOBAL + id: jenkins-cgr-oidc + description: "Per-build OIDC token for Chainguard assumed-identity exchange" + # Chainguard's STS issuer expects this audience. Same value used + # by sibling examples like image-copy-gcp/main.go:341. + audience: "https://issuer.enforce.dev" + # No `issuer:` override here — the plugin then uses + # jenkins.location.url (set above to https://localhost:8080/) + # AND serves JWKS at http://localhost:8080/oidc/jwks. With a + # custom issuer set per-credential, the plugin would refuse + # to serve JWKS at all (see plugin source: Keys.java). + jobs: - file: /tmp/cgjenkins-home/casc/jobs.groovy diff --git a/jenkins/jenkins/plugins.txt b/jenkins/jenkins/plugins.txt index fcf3001d..abf8c19b 100644 --- a/jenkins/jenkins/plugins.txt +++ b/jenkins/jenkins/plugins.txt @@ -10,3 +10,8 @@ ws-cleanup # instead of a remote Git repo. We use it to source the cg-images library # from the bind-mounted ./shared-libraries dir. filesystem_scm +# OIDC Provider — makes Jenkins itself an OpenID Connect issuer so pipelines +# can mint a short-lived JWT per build. The `withCredentials` step exposes the +# token as a string credential, which cgLogin then exchanges for a Chainguard +# chainctl session. +oidc-provider diff --git a/jenkins/ops/refresh-cgimages-digests/Jenkinsfile b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile index 17b9e373..6ce09312 100644 --- a/jenkins/ops/refresh-cgimages-digests/Jenkinsfile +++ b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile @@ -19,6 +19,11 @@ pipeline { // Schedule: H H/4 * * * (every 4 hours, randomized per controller) stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + stage('Refresh digests') { agent { docker { diff --git a/jenkins/setup.sh b/jenkins/setup.sh index 1d689d55..d052754e 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -1,13 +1,23 @@ #!/usr/bin/env bash -# One-time setup: generate a Chainguard pull token for the configured Chainguard -# org (default smalls.xyz; override via .env or CHAINGUARD_ORG env var) and -# write a Docker config.json that the Jenkins container will use to pull -# cgr.dev//* images. Re-run when the token expires. +# One-time setup for the Chainguard assumed-identity flow. +# +# Replaces the old long-lived pull-token approach. Now does: +# 1. Polls Jenkins until it's healthy and serving its OIDC discovery doc. +# 2. Fetches Jenkins' JWKS into iac/jenkins-jwks.json. +# 3. Runs `terraform apply` to create the Chainguard assumed identity +# (with the JWKS uploaded statically) and a registry.pull rolebinding. +# 4. Reads the identity UIDP from terraform output and writes it into .env +# as CHAINGUARD_IDENTITY=... +# 5. Restarts Jenkins so the new env var is visible to pipelines. +# +# Re-run this script if you change the Chainguard org, recreate the +# controller (which rotates Jenkins' OIDC signing key), or deliberately +# rotate the identity. set -euo pipefail cd "$(dirname "$0")" -# Pick up CHAINGUARD_ORG from .env if present, so the script and docker-compose +# Pick up CHAINGUARD_ORG from .env if present so the script and docker-compose # stay in sync without the user having to export the variable twice. if [[ -f .env ]]; then set -a @@ -17,42 +27,62 @@ if [[ -f .env ]]; then fi ORG="${CHAINGUARD_ORG:-smalls.xyz}" -TOKEN_NAME="${TOKEN_NAME:-jenkins-demo}" -TTL="${TTL:-720h}" -OUT_DIR="$(pwd)/.secrets" -OUT_FILE="${OUT_DIR}/docker-config.json" - -mkdir -p "$OUT_DIR" -chmod 700 "$OUT_DIR" - -echo "Creating pull token (parent=${ORG}, ttl=${TTL})..." -TOKEN_OUTPUT=$(chainctl auth pull-token create \ - --parent="$ORG" \ - --name="$TOKEN_NAME" \ - --ttl="$TTL") - -# The output contains: docker login "cgr.dev" --username "" --password "" -USERNAME=$(echo "$TOKEN_OUTPUT" | grep -oE -- '--username "[^"]+"' | head -1 | sed -E 's/--username "([^"]+)"/\1/') -PASSWORD=$(echo "$TOKEN_OUTPUT" | grep -oE -- '--password "[^"]+"' | head -1 | sed -E 's/--password "([^"]+)"/\1/') - -if [[ -z "$USERNAME" || -z "$PASSWORD" ]]; then - echo "ERROR: failed to parse token from chainctl output:" >&2 - echo "$TOKEN_OUTPUT" >&2 +JENKINS_URL="${JENKINS_URL:-http://localhost:8080}" +# Literal `iss` claim string Jenkins puts in OIDC tokens. Must match the +# scheme/host/port of jenkins.location.url in jenkins.yaml — currently +# https://localhost:8080/ (HTTPS to satisfy the chainguard_identity static +# block validator; static-mode is offline-verification only, the URL never +# has to resolve). +JENKINS_OIDC_ISSUER="${JENKINS_OIDC_ISSUER:-https://localhost:8080/oidc}" +JWKS_FILE="iac/jenkins-jwks.json" + +echo "==> Verifying Jenkins is up at ${JENKINS_URL}..." +for i in $(seq 1 60); do + if curl -fsS -o /dev/null "${JENKINS_URL}/login"; then + break + fi + if (( i == 60 )); then + echo "ERROR: Jenkins did not respond at ${JENKINS_URL}/login within 2 minutes." >&2 + echo "Did you run 'docker compose up -d --build' first?" >&2 + exit 1 + fi + sleep 2 +done + +echo "==> Fetching Jenkins JWKS to ${JWKS_FILE}..." +mkdir -p iac +curl -fsS "${JENKINS_URL}/oidc/jwks" > "${JWKS_FILE}" +if ! python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${JWKS_FILE}" >/dev/null 2>&1; then + echo "ERROR: ${JWKS_FILE} is not valid JSON. Got:" >&2 + cat "${JWKS_FILE}" >&2 exit 1 fi +echo " Fetched $(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(len(d.get("keys",[])))' "${JWKS_FILE}") signing key(s)." + +echo "==> Running terraform apply (chainguard_group=${ORG})..." +( cd iac + terraform init -input=false -upgrade + terraform apply -input=false -auto-approve \ + -var="chainguard_group_name=${ORG}" \ + -var="jenkins_issuer_url=${JENKINS_OIDC_ISSUER}" +) + +UIDP=$( cd iac && terraform output -raw identity_uidp ) +if [[ -z "$UIDP" ]]; then + echo "ERROR: terraform output identity_uidp was empty." >&2 + exit 1 +fi +echo " Created identity: ${UIDP}" + +echo "==> Writing identity UIDP to shared-libraries/cg-images/IDENTITY..." +# Pipelines read this file via cgLogin (filesystem-SCM live-loaded shared +# library) — no Jenkins restart required. We deliberately AVOID a restart +# here because restarting Jenkins regenerates the oidc-provider plugin's +# signing key, which would immediately invalidate the JWKS we just uploaded +# to Chainguard. +printf '%s\n' "${UIDP}" > shared-libraries/cg-images/IDENTITY -AUTH_B64=$(printf '%s:%s' "$USERNAME" "$PASSWORD" | base64 | tr -d '\n') - -cat > "$OUT_FILE" < Done. Bootstrap complete." +echo " Open ${JENKINS_URL} (admin/admin) and trigger any pipeline — its" +echo " Auth stage will exchange a fresh per-build OIDC token for a" +echo " short-lived chainctl session and continue from there." diff --git a/jenkins/shared-libraries/cg-images/README.md b/jenkins/shared-libraries/cg-images/README.md index 9f3568b7..a4aaabf7 100644 --- a/jenkins/shared-libraries/cg-images/README.md +++ b/jenkins/shared-libraries/cg-images/README.md @@ -12,6 +12,10 @@ def img = cgImage('corretto-java17') pipeline { agent none stages { + stage('Auth') { + agent any + steps { cgLogin() } // see vars/cgLogin.groovy + } stage('Build') { agent { docker { image img.build; args '--entrypoint=' } } ... @@ -24,6 +28,8 @@ pipeline { } ``` +The `Auth` stage runs `cgLogin` — a sibling shared-library var that exchanges a per-build Jenkins OIDC token for a short-lived Chainguard session and writes a fresh docker config to `$DOCKER_CONFIG`. It must precede any `agent { docker { } }` stage so the docker-workflow plugin picks up the new creds when it pulls the agent image. + The map returned by `cgImage()` has some subset of these keys: | Key | Meaning | @@ -54,7 +60,7 @@ The `vars/` subdirectory is the standard Jenkins shared-library convention for " ## Caveats -- **Edits to `cgImage.groovy` apply on the next pipeline run, no Jenkins restart needed.** This is because the retriever is `legacySCM` → `FSSCM` (filesystem_scm plugin) pointed at the bind-mounted source dir. Filesystem-SCM "checks out" the library by copying from the source path at the start of each build, so disk changes propagate immediately. The shared-library version label (`master` in our config) is a placeholder here; for filesystem sources it does not gate caching the way Git refspecs do. If we ever switched the retriever to a real Git source, semantics would change — Jenkins would cache the library by commit and only re-fetch when the version label resolves to a new commit, unless `Fresh clone per build` (the `clone: true` flag on `SCMRetriever`) is set. +- **Edits to `cgImage.groovy` (or `cgLogin.groovy`) apply on the next pipeline run, no Jenkins restart needed.** This is because the retriever is `legacySCM` → `FSSCM` (filesystem_scm plugin) pointed at the bind-mounted source dir, **with `clone: true`** (forces a fresh checkout per build). Without `clone: true` filesystem_scm caches the workspace and edits don't propagate. The shared-library version label (`master` in our config) is a placeholder here; for filesystem sources it does not gate caching the way Git refspecs do. If we ever switched the retriever to a real Git source, the cache key becomes the commit hash and `clone: true` would be costly — drop it then. - **A Groovy syntax error in `cgImage.groovy` fails every pipeline that uses it, immediately, with no graceful fallback.** The library is `implicit: true`, so all pipelines load it whether they call `cgImage(...)` or not. Worth a quick `groovyc` lint or trial run after substantive edits. - **All pipelines see the same version of the library at any moment.** To test a change against a single pipeline without affecting the others, flip `implicit` to `false` in JCasC, restart Jenkins, then add an explicit `@Library('cgImages@') _` annotation to the pipeline you want to opt into a different version. (For the filesystem-SCM case, "branch" is just any value — there's no real version control. The annotation is mostly meaningful when the source is Git.) - **Digest pins are org-specific.** `cgr.dev/smalls.xyz/maven:3-jdk17-dev` and `cgr.dev/chainguard/maven:3-jdk17-dev` typically resolve to the same content (Chainguard mirrors are byte-identical for shared images), but that's not guaranteed. If you switch `CHAINGUARD_ORG`, re-run `refresh-digests.sh` to verify and re-pin against the new org. A wrong digest fails the pull at build time with a clear error message. diff --git a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy new file mode 100644 index 00000000..58bbecd6 --- /dev/null +++ b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy @@ -0,0 +1,50 @@ +// vars/cgLogin.groovy +// +// Per-build chainctl login. Exchanges a Jenkins-issued OIDC token for a +// short-lived (~30 min) Chainguard session, then writes a docker config +// that the rest of the pipeline (and any spawned `agent docker` agents) +// can use to pull from cgr.dev. +// +// Usage — call from a stage on `agent any` (the controller) BEFORE any +// stage that uses `agent { docker { image cgImage(...).build } }`: +// +// stage('Auth') { +// agent any +// steps { cgLogin() } +// } +// +// Auto-loaded for every pipeline via the same JCasC globalLibraries +// config that loads cgImage. No `@Library` annotation needed. +// +// Required environment / config (set up by setup.sh + jenkins.yaml): +// - env.CHAINGUARD_IDENTITY UIDP of the assumed identity +// (set via JCasC globalNodeProperties) +// - credential 'jenkins-cgr-oidc' an oidcCredential issued by the +// oidc-provider plugin, audience cgr.dev +// - DOCKER_CONFIG path the controller's docker CLI reads +// (set in docker-compose.yml) + +def call() { + // Read the identity UIDP from a file rather than env.CHAINGUARD_IDENTITY. + // The file is rewritten by setup.sh after each `terraform apply`, and the + // shared-libraries dir is bind-mounted live into the controller, so a + // Jenkins restart is NOT required to pick up a new identity. (Restarting + // Jenkins would also regenerate its OIDC signing key and invalidate the + // JWKS we just uploaded — avoiding that is the whole reason this is a + // file lookup.) + def identity = readFile('/tmp/cgjenkins-home/shared-libraries/cg-images/IDENTITY').trim() + if (!identity) { + error('cgLogin: shared-libraries/cg-images/IDENTITY is empty — run setup.sh first') + } + withCredentials([string(credentialsId: 'jenkins-cgr-oidc', variable: 'OIDC_TOKEN')]) { + sh """ + set -eu + chainctl auth login --identity='${identity}' --identity-token=\"\$OIDC_TOKEN\" + # configure-docker needs --identity and --identity-token too, otherwise + # it falls through to the interactive browser flow after writing the + # credential helper config. + chainctl auth configure-docker --identity='${identity}' --identity-token=\"\$OIDC_TOKEN\" + echo 'cgLogin: authenticated as identity ${identity}' + """ + } +} From bad1bd6e2f6a8f312ea4e534334a2bfd4c5e81b5 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 10:12:05 -0500 Subject: [PATCH 14/47] =?UTF-8?q?jenkins:=20note=20OIDC=E2=86=94Harbor=20r?= =?UTF-8?q?elationship=20in=20PLAN.md=20future=20enhancements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captures the analysis of which parts of the OIDC assumed-identity work become redundant vs reusable when the Harbor pull-through and push- retarget enhancements (#2 + #3) land. Future-me will thank past-me. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/PLAN.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/jenkins/PLAN.md b/jenkins/PLAN.md index 2cbc23b0..48aadd1b 100644 --- a/jenkins/PLAN.md +++ b/jenkins/PLAN.md @@ -60,3 +60,17 @@ Create sub-directories for the various sample applications with Jenkins pipeline - Add a harbor image registry option as a pull-through-mirror - Use that harbor server as a destination for image pushes instead of ttl.sh +### Note: relationship between Harbor work and the OIDC assumed-identity implementation + +When Harbor lands in front of cgr.dev, the per-build `cgLogin → cgr.dev` flow becomes mostly redundant for *runtime pulls* — Jenkins talks to Harbor, Harbor talks to cgr.dev. The pieces shift like this: + +- **Jenkins → Harbor (pulls)**: anonymous if Harbor's proxy-cache project is public; otherwise reuse the OIDC plumbing with audience pointed at Harbor instead of `https://issuer.enforce.dev`. +- **Harbor → cgr.dev**: a long-lived secret (pull token or Chainguard assumed identity) sits in Harbor's proxy config — the long-lived-secret problem moves from Jenkins to Harbor rather than disappearing. +- **Jenkins → Harbor (pushes, this enhancement #3)**: needs auth; strongest case for keeping the OIDC infra, retargeted at Harbor. +- **Bootstrap (controller-image build)**: still needs *some* path to cgr.dev (Harbor isn't running yet at compose-build time). Either keep a one-shot bootstrap pull token or rely on host-side `chainctl auth configure-docker`. + +Net effect on the OIDC work: +- `oidc-provider` plugin + JCasC credential + `cgLogin`-style helper: **reused**, just with a different audience / target. +- `chainguard_identity` (`static` block) Terraform resource and the `chainctl auth login → cgr.dev` exchange: **gone** for runtime; `iac/` gets repurposed for Harbor-side IAM. +- `chainctl` baked into the controller: **gone** for runtime use, possibly still useful for one-off ops. + From bc81914483241eb2221463fb4677574476cc85d1 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 10:43:31 -0500 Subject: [PATCH 15/47] jenkins: add optional Harbor pull-through cache + push target (Modes B and C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements both halves of PLAN.md's Harbor enhancements as opt-in modes selected at setup.sh time. The user is asked three questions and the script dispatches to one of: Mode A — direct cgr.dev (current default) pulls: cgr.dev/ (via OIDC chainctl session, per-build) push: ttl.sh/ (anonymous, default; user can override) Mode B — Harbor pull-through, push to ttl.sh pulls: localhost/cgr-proxy/ (anonymous, Harbor proxy cache) push: ttl.sh/ (default; user can override) Mode C — Harbor for both pulls: localhost/cgr-proxy/ push: localhost/library (Harbor admin/Harbor12345 baked in by cgLogin) Harbor deployment is lifted (with light adaptations) from the chainguard-demo/cs-workshop "operations-track/harbor" tutorial: jenkins/harbor/ deploy.sh Adapted from the tutorial's deploy-harbor.sh. Env-var-driven (CHAINGUARD_ORG, PULL_USER, PULL_PASS) so setup.sh can call it non-interactively. Idempotent. teardown.sh `kind delete cluster --name jenkins-harbor`. kind/config.yaml Single-node kind cluster with host port 80/443 mappings for ingress. cg/helm/values.template Harbor Helm values, all images parameterized on $REGISTRY_URL = cgr.dev/$CHAINGUARD_ORG. cg/manifests/... ingress-nginx static manifest, parameterized. terraform/ Harbor IAM: registers cgr.dev as upstream and creates the public 'cgr-proxy' project. Dropped the tutorial's replication-mirror project; we don't use replication. Demo-side wiring: setup.sh (rewritten) Three interactive prompts; updates .env; rebuilds Jenkins; dispatches to either the OIDC bootstrap (Mode A) or harbor/deploy.sh (Modes B/C). Re-run to switch modes. docker-compose.yml New env vars passed to controller: PULL_REGISTRY, PUSH_REGISTRY, HARBOR_ENABLED. jenkins.yaml Same env vars exposed to pipelines via globalNodeProperties. cgImage.groovy reg = env.PULL_REGISTRY ?: cgr.dev/. cgLogin.groovy Branches by HARBOR_ENABLED + PUSH_REGISTRY: Mode A → OIDC chainctl flow (existing). Mode B → no-op (anonymous everywhere). Mode C → write Harbor admin creds for localhost in $DOCKER_CONFIG. apps/{python,node}*/ OCI-image Jenkinsfiles' IMAGE env now uses "${env.PUSH_REGISTRY}/:" (slash-separated; works for both ttl.sh and Harbor). Verified end-to-end against all three modes: Mode A — corretto-java17-maven #24 SUCCESS in 13s, python314-uv-flask #10 SUCCESS in 15s, push to ttl.sh/smalls/pytest:3-14. Mode B — corretto-java17-maven #26 SUCCESS in 14s, image refs in build console show pulls from localhost/cgr-proxy/smalls.xyz/... Mode C — python314-uv-flask #12 SUCCESS in 12s, push landed in Harbor's library project (confirmed via Harbor API: projects/library/repositories returns library/pytest with artifact_count=1). PLAN.md "Future enhancements" section updated — all three are done. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/.env.example | 27 +- jenkins/PLAN.md | 22 +- jenkins/README.md | 41 +- jenkins/apps/node22-npm-express/Jenkinsfile | 2 +- jenkins/apps/node25-pnpm-express/Jenkinsfile | 2 +- jenkins/apps/python312-pip-django/Jenkinsfile | 2 +- jenkins/apps/python314-uv-flask/Jenkinsfile | 5 +- jenkins/docker-compose.yml | 7 + jenkins/harbor/.gitignore | 14 + jenkins/harbor/README.md | 58 ++ jenkins/harbor/cg/helm/values.template | 51 ++ .../manifests/deploy-ingress-nginx.template | 685 ++++++++++++++++++ jenkins/harbor/deploy.sh | 97 +++ jenkins/harbor/kind/config.yaml | 11 + jenkins/harbor/teardown.sh | 10 + jenkins/harbor/terraform/main.tf | 37 + .../harbor/terraform/terraform.templatevars | 4 + jenkins/harbor/terraform/variables.tf | 16 + jenkins/jenkins/casc/jenkins.yaml | 9 + jenkins/setup.sh | 231 ++++-- .../cg-images/vars/cgImage.groovy | 5 +- .../cg-images/vars/cgLogin.groovy | 70 +- 22 files changed, 1267 insertions(+), 139 deletions(-) create mode 100644 jenkins/harbor/.gitignore create mode 100644 jenkins/harbor/README.md create mode 100644 jenkins/harbor/cg/helm/values.template create mode 100644 jenkins/harbor/cg/manifests/deploy-ingress-nginx.template create mode 100755 jenkins/harbor/deploy.sh create mode 100644 jenkins/harbor/kind/config.yaml create mode 100755 jenkins/harbor/teardown.sh create mode 100644 jenkins/harbor/terraform/main.tf create mode 100644 jenkins/harbor/terraform/terraform.templatevars create mode 100644 jenkins/harbor/terraform/variables.tf diff --git a/jenkins/.env.example b/jenkins/.env.example index 89b516dd..3758d5aa 100644 --- a/jenkins/.env.example +++ b/jenkins/.env.example @@ -9,9 +9,24 @@ # CHAINGUARD_ORG=your-org # any org you have chainctl access to CHAINGUARD_ORG=smalls.xyz -# Note: The Chainguard assumed identity UIDP is NOT stored here. setup.sh -# writes it to shared-libraries/cg-images/IDENTITY (filesystem-SCM live- -# loaded; cgLogin reads it at build time). Keeping it out of compose env -# avoids needing a Jenkins restart to propagate it, which would also -# regenerate the oidc-provider signing key and invalidate the uploaded -# Chainguard JWKS. +# The remaining variables in this file are written by setup.sh based on +# the answers you give to its three prompts. You normally don't edit them +# directly — re-run setup.sh to switch modes. + +# Where pipelines pull Chainguard images from. Either: +# cgr.dev/ (Mode A — direct cgr.dev) +# localhost/cgr-proxy/ (Modes B/C — Harbor proxy cache) +PULL_REGISTRY=cgr.dev/smalls.xyz + +# Where the Python/Node OCI-image pipelines push their built images. Either: +# ttl.sh/ (Modes A/B default — anonymous, 24h TTL) +# localhost/library (Mode C — Harbor) +PUSH_REGISTRY=ttl.sh/smalls + +# Whether Harbor is the active pull-through cache. cgLogin reads this and +# skips the OIDC chainctl exchange when true (Harbor pulls are anonymous). +HARBOR_ENABLED=false + +# Note: the Chainguard assumed identity UIDP (Mode A) is NOT stored here. +# setup.sh writes it to shared-libraries/cg-images/IDENTITY (filesystem-SCM +# live-loaded; cgLogin reads it at build time). diff --git a/jenkins/PLAN.md b/jenkins/PLAN.md index 48aadd1b..f5cb6d49 100644 --- a/jenkins/PLAN.md +++ b/jenkins/PLAN.md @@ -55,22 +55,8 @@ Create sub-directories for the various sample applications with Jenkins pipeline - test with the node:25-slim image - Artifact to archive is a new image based on node:25-slim image and pushed to ttl.sh/smalls-nodetest:25 -## Future enhancements -- Decouple the demo from the smalls.xyz hard-coded org, make that configurable -- Add a harbor image registry option as a pull-through-mirror -- Use that harbor server as a destination for image pushes instead of ttl.sh - -### Note: relationship between Harbor work and the OIDC assumed-identity implementation - -When Harbor lands in front of cgr.dev, the per-build `cgLogin → cgr.dev` flow becomes mostly redundant for *runtime pulls* — Jenkins talks to Harbor, Harbor talks to cgr.dev. The pieces shift like this: - -- **Jenkins → Harbor (pulls)**: anonymous if Harbor's proxy-cache project is public; otherwise reuse the OIDC plumbing with audience pointed at Harbor instead of `https://issuer.enforce.dev`. -- **Harbor → cgr.dev**: a long-lived secret (pull token or Chainguard assumed identity) sits in Harbor's proxy config — the long-lived-secret problem moves from Jenkins to Harbor rather than disappearing. -- **Jenkins → Harbor (pushes, this enhancement #3)**: needs auth; strongest case for keeping the OIDC infra, retargeted at Harbor. -- **Bootstrap (controller-image build)**: still needs *some* path to cgr.dev (Harbor isn't running yet at compose-build time). Either keep a one-shot bootstrap pull token or rely on host-side `chainctl auth configure-docker`. - -Net effect on the OIDC work: -- `oidc-provider` plugin + JCasC credential + `cgLogin`-style helper: **reused**, just with a different audience / target. -- `chainguard_identity` (`static` block) Terraform resource and the `chainctl auth login → cgr.dev` exchange: **gone** for runtime; `iac/` gets repurposed for Harbor-side IAM. -- `chainctl` baked into the controller: **gone** for runtime use, possibly still useful for one-off ops. +## Future enhancements (all done) +- ~~Decouple the demo from the smalls.xyz hard-coded org, make that configurable~~ — done via `CHAINGUARD_ORG` in `.env`. +- ~~Add a harbor image registry option as a pull-through-mirror~~ — done in [harbor/](harbor/), opt-in via setup.sh. +- ~~Use that harbor server as a destination for image pushes instead of ttl.sh~~ — done; setup.sh's third question selects Mode B (pull from Harbor, push to ttl.sh) or Mode C (pull and push both via Harbor). diff --git a/jenkins/README.md b/jenkins/README.md index 9971fad7..a8228f3b 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -77,40 +77,32 @@ docker compose up -d --build ## Quick start -The demo uses **OIDC assumed identity** auth — Jenkins itself signs short-lived JWTs per build, and pipelines exchange them for ~30-min Chainguard sessions via `chainctl auth login`. No long-lived pull tokens on disk. +[setup.sh](setup.sh) is interactive and supports **three modes** for image flow. Pick one when prompted: -**Step 1 — Start Jenkins.** +| | Pull source | Push target | Auth path | +|-|-------------|-------------|-----------| +| **Mode A** | `cgr.dev/` directly | `ttl.sh/` (default) | OIDC assumed identity (per-build, short-lived) | +| **Mode B** | `localhost/cgr-proxy/` (Harbor proxy cache) | `ttl.sh/` (default) | None — anonymous pulls + ttl.sh anonymous pushes | +| **Mode C** | `localhost/cgr-proxy/` (Harbor proxy cache) | `localhost/library` (Harbor) | Anonymous pulls + Harbor admin creds for push | -```sh -cd jenkins -docker compose up -d --build -``` - -The controller image bakes in `chainctl` (multi-stage-copied from `cgr.dev/$CHAINGUARD_ORG/chainctl`) so pipelines can run it without an extra agent. - -**Step 2 — Bootstrap the Chainguard assumed identity.** [setup.sh](setup.sh) waits for Jenkins to come up, fetches its OIDC JWKS, then runs Terraform under [iac/](iac/) to create a `chainguard_identity` (`static` block, JWKS uploaded directly) and a `registry.pull` rolebinding. The identity's UIDP is written to `shared-libraries/cg-images/IDENTITY` (gitignored), which the `cgLogin` shared-library var reads at build time. +Modes B and C stand up Harbor in a local `kind` cluster. They require `kind`, `kubectl`, `helm`, `terraform`, and `envsubst` on the host. See [harbor/README.md](harbor/README.md) for the architecture details. ```sh +cd jenkins ./setup.sh ``` -> **Important:** Re-run `setup.sh` after **any** of these: -> - First-time bootstrap. -> - Changing `CHAINGUARD_ORG` in `.env`. -> - **Any restart of the Jenkins controller** (`docker compose restart jenkins`, `docker compose up -d --force-recreate`, or `down`/`up`). The `oidc-provider` plugin regenerates the credential's RSA signing key on JCasC re-apply, which immediately invalidates the JWKS we previously uploaded to Chainguard. `setup.sh` re-fetches the new JWKS and re-applies Terraform; the identity object is updated in place (its UIDP changes, hence the per-restart re-write of the `IDENTITY` file). - -Wait ~30s for Jenkins to finish initial startup (watch with `docker compose logs -f jenkins`), then open and log in: +The script asks three questions, lays out the chosen mode in `.env`, builds & starts (or recreates) Jenkins, and either bootstraps the OIDC assumed identity (Mode A) or deploys Harbor (Modes B/C). Re-run any time to switch modes. -- Username: `admin` -- Password: `admin` (override via `JENKINS_ADMIN_PASSWORD` env in [docker-compose.yml](docker-compose.yml)) +> **Mode A caveat:** the `oidc-provider` plugin regenerates its RSA signing key on JCasC re-apply, which invalidates the JWKS uploaded to Chainguard. **Re-run setup.sh after any restart of the Jenkins controller** (`docker compose restart`, `down`/`up`, or `--force-recreate`). Modes B/C aren't affected by this since they don't use OIDC. -You should see all seven jobs in the dashboard. Click any of them → **Build Now**. Each pipeline runs roughly the same shape: +Open (`admin` / `admin`; override the password via `JENKINS_ADMIN_PASSWORD` in [docker-compose.yml](docker-compose.yml)). You should see all seven sample jobs plus the `refresh-cgimages-digests` ops job. Click any sample → **Build Now**. Each pipeline runs roughly the same shape: -1. **Auth** — `cgLogin()` (a shared-library var) exchanges a fresh per-build Jenkins OIDC token for a short-lived Chainguard session, then writes a docker config that the rest of the build reuses. +1. **Auth** — `cgLogin()` (a shared-library var) handles auth for whichever mode is active: Mode A runs the OIDC chainctl exchange; Mode B is a no-op (anonymous pulls); Mode C writes Harbor admin creds for push. 2. **Checkout** — `cp -R /sources/apps//. .` from the bind-mounted source dir into the build workspace. 3. **Build (deps)** — install/compile in a Chainguard `*-dev` agent container. 4. **Test** — smoke-test in either the runtime image or its `-dev` variant (see [Common gotchas](#common-gotchas) below). -5. **Archive / Push** — for the Java pipelines, archive the JAR/WAR to Jenkins. For Python/Node, build a runtime OCI image and push to `ttl.sh`. +5. **Archive / Push** — Java pipelines archive their JAR/WAR to Jenkins. Python/Node pipelines build a runtime OCI image and push it to `$PUSH_REGISTRY` (ttl.sh in Modes A/B, the local Harbor `library` project in Mode C). A clean build takes 10s–40s once images are cached locally; the Gradle pipeline is slower on first run (~1m45s) because the Gradle wrapper has to download the Gradle distribution. @@ -139,7 +131,7 @@ These bit me while building out the seven samples — useful to know up front wh - **Gradle**: `environment { GRADLE_USER_HOME = "${WORKSPACE}/.gradle" }` - **npm / pnpm**: `environment { HOME = "${WORKSPACE}" }` - **Chainguard's `python:3.x-dev` runs as uid 65532**, which can't write to the system site-packages. For pipelines that want to `pip install --system` or `uv pip install --system`, pass `args '--user 0 --entrypoint='` in the Jenkinsfile **and** add `USER 0` to the corresponding stage in the Dockerfile. -- **OCI-image pipelines push to ttl.sh** (anonymous, public, 24h TTL) so no registry auth is needed for pushes. `cgr.dev/$CHAINGUARD_ORG` pulls are authorized by the per-build chainctl session that the `Auth` stage establishes; the resulting docker config lives at `$DOCKER_CONFIG` (in `/tmp/cgjenkins-home/.docker`) and is overwritten by each build. +- **OCI-image pipelines push to whatever `$PUSH_REGISTRY` resolves to**: `ttl.sh/` in Modes A/B (anonymous, 24h TTL) or `localhost/library` in Mode C (Harbor admin/Harbor12345 baked in by `cgLogin`). Per-app `IMAGE` envs use `${env.PUSH_REGISTRY}/:` — works for both ttl.sh and Harbor without per-mode pipeline edits. - **The Auth stage must precede any `agent { docker { image '...' } }` stage**, because the docker-workflow plugin pulls the agent's image using whatever creds are in `$DOCKER_CONFIG` *at the start of that stage*. The current pipeline shape (`Auth` → `Checkout` → docker-agent stages) gets the ordering right; preserve it when adding new pipelines. ## Teardown @@ -147,10 +139,11 @@ These bit me while building out the seven samples — useful to know up front wh ```sh docker compose down sudo rm -rf /tmp/cgjenkins-home # remove the persisted Jenkins home -rm -rf .secrets # remove the pull-token Docker config +rm -rf .secrets # remove any stale pull-token Docker config +harbor/teardown.sh # delete the Harbor kind cluster (no-op if not deployed) ``` ## Notes - The DooD pattern means Jenkins effectively has root-equivalent access to the host machine via the Docker socket. This is acceptable for a local demo but not for production. -- See [PLAN.md](PLAN.md) for the original sample-app spec and a list of future enhancements (configurable org, Harbor pull-through, push to Harbor instead of ttl.sh). +- See [PLAN.md](PLAN.md) for the original sample-app spec. diff --git a/jenkins/apps/node22-npm-express/Jenkinsfile b/jenkins/apps/node22-npm-express/Jenkinsfile index 3a231f30..a6f33ca4 100644 --- a/jenkins/apps/node22-npm-express/Jenkinsfile +++ b/jenkins/apps/node22-npm-express/Jenkinsfile @@ -5,7 +5,7 @@ pipeline { options { timestamps() } environment { - IMAGE = 'ttl.sh/smalls-nodetest:22' + IMAGE = "${env.PUSH_REGISTRY}/nodetest:22" } stages { diff --git a/jenkins/apps/node25-pnpm-express/Jenkinsfile b/jenkins/apps/node25-pnpm-express/Jenkinsfile index 84365228..6e90d8e1 100644 --- a/jenkins/apps/node25-pnpm-express/Jenkinsfile +++ b/jenkins/apps/node25-pnpm-express/Jenkinsfile @@ -5,7 +5,7 @@ pipeline { options { timestamps() } environment { - IMAGE = 'ttl.sh/smalls-nodetest:25' + IMAGE = "${env.PUSH_REGISTRY}/nodetest:25" } stages { diff --git a/jenkins/apps/python312-pip-django/Jenkinsfile b/jenkins/apps/python312-pip-django/Jenkinsfile index 2df5de67..bac67c62 100644 --- a/jenkins/apps/python312-pip-django/Jenkinsfile +++ b/jenkins/apps/python312-pip-django/Jenkinsfile @@ -5,7 +5,7 @@ pipeline { options { timestamps() } environment { - IMAGE = 'ttl.sh/smalls-pytest:3-12' + IMAGE = "${env.PUSH_REGISTRY}/pytest:3-12" } stages { diff --git a/jenkins/apps/python314-uv-flask/Jenkinsfile b/jenkins/apps/python314-uv-flask/Jenkinsfile index 35038c25..e7f9c130 100644 --- a/jenkins/apps/python314-uv-flask/Jenkinsfile +++ b/jenkins/apps/python314-uv-flask/Jenkinsfile @@ -5,7 +5,10 @@ pipeline { options { timestamps() } environment { - IMAGE = 'ttl.sh/smalls-pytest:3-14' + // env.PUSH_REGISTRY is set by JCasC globalNodeProperties (driven by setup.sh): + // Modes A/B → ttl.sh/, Mode C → localhost/library. Slash-separated + // works in both — ttl.sh accepts arbitrary paths, Harbor needs project/repo. + IMAGE = "${env.PUSH_REGISTRY}/pytest:3-14" } stages { diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml index 4ecaa1b5..810062c6 100644 --- a/jenkins/docker-compose.yml +++ b/jenkins/docker-compose.yml @@ -21,6 +21,13 @@ services: # Surface the configured org to pipelines (Jenkinsfiles read env.CHAINGUARD_ORG) # and to JCasC (jenkins.yaml interpolates ${CHAINGUARD_ORG}). CHAINGUARD_ORG: ${CHAINGUARD_ORG:-smalls.xyz} + # Registry routing — set by setup.sh based on the user's mode choice: + # PULL_REGISTRY cgr.dev/ (Mode A) or localhost/cgr-proxy/ (Modes B/C) + # PUSH_REGISTRY ttl.sh/ (Modes A/B) or localhost/library (Mode C) + # HARBOR_ENABLED true|false (drives whether the cgLogin OIDC dance runs) + PULL_REGISTRY: ${PULL_REGISTRY:-cgr.dev/${CHAINGUARD_ORG:-smalls.xyz}} + PUSH_REGISTRY: ${PUSH_REGISTRY:-ttl.sh/smalls} + HARBOR_ENABLED: ${HARBOR_ENABLED:-false} JENKINS_HOME: /tmp/cgjenkins-home CASC_JENKINS_CONFIG: /tmp/cgjenkins-home/casc JENKINS_ADMIN_PASSWORD: admin diff --git a/jenkins/harbor/.gitignore b/jenkins/harbor/.gitignore new file mode 100644 index 00000000..1ca4b0a6 --- /dev/null +++ b/jenkins/harbor/.gitignore @@ -0,0 +1,14 @@ +# Terraform local state and provider plugin cache. +terraform/.terraform/ +terraform/.terraform.lock.hcl +terraform/*.tfstate +terraform/*.tfstate.* +terraform/*.tfplan +terraform/terraform.tfvars + +# Rendered manifests / values (regenerated by deploy.sh from .template files). +cg/manifests/deploy-ingress-nginx.yaml +cg/helm/values.yaml + +# Pull-token cache (mirrors the upstream tutorial's convention). +.pull-token diff --git a/jenkins/harbor/README.md b/jenkins/harbor/README.md new file mode 100644 index 00000000..e3d07aa1 --- /dev/null +++ b/jenkins/harbor/README.md @@ -0,0 +1,58 @@ +# jenkins/harbor — Optional Harbor pull-through cache + +Stands up a [Harbor](https://goharbor.io/) registry in a local `kind` cluster, configured as a **pull-through cache** for `cgr.dev/${CHAINGUARD_ORG}/*`. Optionally also serves as a **push target** for the demo's OCI-image pipelines (Python and Node samples), replacing `ttl.sh`. + +This directory is a lightly-adapted copy of [chainguard-demo/cs-workshop/.../harbor](https://github.com/chainguard-demo/cs-workshop/tree/main/trainer-development/operations-track/harbor) — the `deploy.sh` script is rewired to be env-var-driven (callable from `setup.sh`) and the Terraform module drops the replication-mirror project we don't need. + +## When you'd want this + +The default demo pulls Chainguard images directly from `cgr.dev/$CHAINGUARD_ORG` (using either a long-lived pull token or, on this branch, the OIDC assumed-identity flow). With Harbor in front: + +- **Faster repeated pulls** — Harbor caches manifests and layers locally, so the second pull of `maven:3-jdk17-dev` is bytes-from-localhost. +- **Anonymous pulls from Jenkins** — the `cgr-proxy` project is public, so the controller doesn't need any cgr.dev credentials at runtime. Harbor itself still holds a pull token to authenticate to cgr.dev (the long-lived-secret problem moves from Jenkins to Harbor rather than disappearing). +- **A demo-able push target** — instead of pushing built images to `ttl.sh` (24h TTL, world-readable), pipelines can push to Harbor's `library` project where they persist. + +## Architecture + +``` + ┌─ host ──────────────────────────────┐ + │ │ + │ kind cluster (jenkins-harbor) │ + docker pull │ ┌─────────────────────────────┐ │ + localhost/cgr-proxy/.. │ │ ingress-nginx :80 │ │ + ─────────────────────▶ │ │ ↓ │ │ + │ │ harbor-portal / -core │ │ + │ │ ↓ │ │ cgr.dev + │ │ harbor-registry │ ──┼──▶ (proxy-cache pull + │ │ ↑ │ │ uses Harbor's + │ │ postgres / trivy / etc. │ │ stored token) + │ └─────────────────────────────┘ │ + │ │ + └─────────────────────────────────────┘ +``` + +Five Harbor microservices run in the `harbor` namespace plus ingress-nginx in `ingress-nginx`. Both pull from `cgr.dev/$CHAINGUARD_ORG` using a `regcred` docker-registry secret in each namespace. Harbor itself uses the same pull token to fetch from the upstream `cgr.dev` registry it's caching. + +## Files + +| Path | Purpose | +|------|---------| +| [`deploy.sh`](deploy.sh) | One-shot: brings up the kind cluster, installs ingress-nginx + Harbor Helm chart, runs Terraform. Idempotent. Driven by env vars. | +| [`teardown.sh`](teardown.sh) | `kind delete cluster --name jenkins-harbor`. | +| [`kind/config.yaml`](kind/config.yaml) | kind cluster definition with host port 80/443 mappings for ingress. | +| [`cg/helm/values.template`](cg/helm/values.template) | Harbor Helm values, parameterized on `${REGISTRY_URL}` so all images come from the configured Chainguard org. | +| [`cg/manifests/deploy-ingress-nginx.template`](cg/manifests/deploy-ingress-nginx.template) | ingress-nginx static manifest, parameterized on `${REGISTRY_URL}`. | +| [`terraform/main.tf`](terraform/main.tf) | After Harbor is up, this declares the cgr.dev upstream registry + the `cgr-proxy` proxy-cache project. | + +## Direct invocation (for debugging) + +The demo's top-level `setup.sh` calls `deploy.sh` automatically when you opt into Harbor mode. To run it by hand: + +```sh +export CHAINGUARD_ORG=smalls.xyz +export PULL_USER=... # chainctl auth pull-token create --parent=$CHAINGUARD_ORG +export PULL_PASS=... +./deploy.sh +``` + +The Harbor admin UI is at (`admin` / `Harbor12345`). Tear down with `./teardown.sh`. diff --git a/jenkins/harbor/cg/helm/values.template b/jenkins/harbor/cg/helm/values.template new file mode 100644 index 00000000..718a9d93 --- /dev/null +++ b/jenkins/harbor/cg/helm/values.template @@ -0,0 +1,51 @@ +externalURL: http://localhost +expose: + type: ingress + tls: + enabled: false + ingress: + hosts: + core: localhost + +imagePullSecrets: + - name: regcred + +portal: + image: + repository: $REGISTRY_URL/harbor-portal + tag: latest + +core: + image: + repository: $REGISTRY_URL/harbor-core + tag: latest + + jobservice: + image: + repository: $REGISTRY_URL/harbor-jobservice + tag: latest + +registry: + registry: + image: + repository: $REGISTRY_URL/harbor-registry + tag: latest + +trivy: + # enabled the flag to enable Trivy scanner + enabled: true + image: + repository: $REGISTRY_URL/harbor-trivy-adapter + tag: latest + imagePullSecrets: + - name: regcred + + +database: + type: internal + internal: + image: + repository: $REGISTRY_URL/harbor-db + tag: latest + + diff --git a/jenkins/harbor/cg/manifests/deploy-ingress-nginx.template b/jenkins/harbor/cg/manifests/deploy-ingress-nginx.template new file mode 100644 index 00000000..ded47571 --- /dev/null +++ b/jenkins/harbor/cg/manifests/deploy-ingress-nginx.template @@ -0,0 +1,685 @@ +# Copied from: +# https://kind.sigs.k8s.io/examples/ingress/deploy-ingress-nginx.yaml +apiVersion: v1 +kind: Namespace +metadata: + labels: + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + name: ingress-nginx +--- +apiVersion: v1 +automountServiceAccountToken: true +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx + namespace: ingress-nginx +--- +apiVersion: v1 +automountServiceAccountToken: true +kind: ServiceAccount +metadata: + labels: + app.kubernetes.io/component: admission-webhook + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-admission + namespace: ingress-nginx +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx + namespace: ingress-nginx +rules: +- apiGroups: + - "" + resources: + - namespaces + verbs: + - get +- apiGroups: + - "" + resources: + - configmaps + - pods + - secrets + - endpoints + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - services + verbs: + - get + - list + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - get + - list + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses/status + verbs: + - update +- apiGroups: + - networking.k8s.io + resources: + - ingressclasses + verbs: + - get + - list + - watch +- apiGroups: + - coordination.k8s.io + resourceNames: + - ingress-nginx-leader + resources: + - leases + verbs: + - get + - update +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - create +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - discovery.k8s.io + resources: + - endpointslices + verbs: + - list + - watch + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + labels: + app.kubernetes.io/component: admission-webhook + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-admission + namespace: ingress-nginx +rules: +- apiGroups: + - "" + resources: + - secrets + verbs: + - get + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx +rules: +- apiGroups: + - "" + resources: + - configmaps + - endpoints + - nodes + - pods + - secrets + - namespaces + verbs: + - list + - watch +- apiGroups: + - coordination.k8s.io + resources: + - leases + verbs: + - list + - watch +- apiGroups: + - "" + resources: + - nodes + verbs: + - get +- apiGroups: + - "" + resources: + - services + verbs: + - get + - list + - watch +- apiGroups: + - networking.k8s.io + resources: + - ingresses + verbs: + - get + - list + - watch +- apiGroups: + - "" + resources: + - events + verbs: + - create + - patch +- apiGroups: + - networking.k8s.io + resources: + - ingresses/status + verbs: + - update +- apiGroups: + - networking.k8s.io + resources: + - ingressclasses + verbs: + - get + - list + - watch +- apiGroups: + - discovery.k8s.io + resources: + - endpointslices + verbs: + - list + - watch + - get +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + labels: + app.kubernetes.io/component: admission-webhook + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-admission +rules: +- apiGroups: + - admissionregistration.k8s.io + resources: + - validatingwebhookconfigurations + verbs: + - get + - update +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx + namespace: ingress-nginx +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ingress-nginx +subjects: +- kind: ServiceAccount + name: ingress-nginx + namespace: ingress-nginx +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + labels: + app.kubernetes.io/component: admission-webhook + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-admission + namespace: ingress-nginx +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: ingress-nginx-admission +subjects: +- kind: ServiceAccount + name: ingress-nginx-admission + namespace: ingress-nginx +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ingress-nginx +subjects: +- kind: ServiceAccount + name: ingress-nginx + namespace: ingress-nginx +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + labels: + app.kubernetes.io/component: admission-webhook + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-admission +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: ingress-nginx-admission +subjects: +- kind: ServiceAccount + name: ingress-nginx-admission + namespace: ingress-nginx +--- +apiVersion: v1 +data: null +kind: ConfigMap +metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-controller + namespace: ingress-nginx +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-controller + namespace: ingress-nginx +spec: + ipFamilies: + - IPv4 + ipFamilyPolicy: SingleStack + ports: + - appProtocol: http + name: http + port: 80 + protocol: TCP + targetPort: http + - appProtocol: https + name: https + port: 443 + protocol: TCP + targetPort: https + selector: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + type: LoadBalancer +--- +apiVersion: v1 +kind: Service +metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-controller-admission + namespace: ingress-nginx +spec: + ports: + - appProtocol: https + name: https-webhook + port: 443 + targetPort: webhook + selector: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + type: ClusterIP +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-controller + namespace: ingress-nginx +spec: + minReadySeconds: 0 + revisionHistoryLimit: 10 + selector: + matchLabels: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + strategy: + rollingUpdate: + maxUnavailable: 1 + type: RollingUpdate + template: + metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + spec: + containers: + - args: + - /nginx-ingress-controller + - --election-id=ingress-nginx-leader + - --controller-class=k8s.io/ingress-nginx + - --ingress-class=nginx + - --configmap=$(POD_NAMESPACE)/ingress-nginx-controller + - --validating-webhook=:8443 + - --validating-webhook-certificate=/usr/local/certificates/cert + - --validating-webhook-key=/usr/local/certificates/key + - --watch-ingress-without-class=true + - --publish-status-address=localhost + env: + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace + - name: LD_PRELOAD + value: /usr/local/lib/libmimalloc.so +# image: registry.k8s.io/ingress-nginx/controller:v1.12.0-beta.0@sha256:9724476b928967173d501040631b23ba07f47073999e80e34b120e8db5f234d5 + image: $REGISTRY_URL/ingress-nginx-controller:latest + imagePullPolicy: IfNotPresent + lifecycle: + preStop: + exec: + command: + - /wait-shutdown + livenessProbe: + failureThreshold: 5 + httpGet: + path: /healthz + port: 10254 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + name: controller + ports: + - containerPort: 80 + hostPort: 80 + name: http + protocol: TCP + - containerPort: 443 + hostPort: 443 + name: https + protocol: TCP + - containerPort: 8443 + name: webhook + protocol: TCP + readinessProbe: + failureThreshold: 3 + httpGet: + path: /healthz + port: 10254 + scheme: HTTP + initialDelaySeconds: 10 + periodSeconds: 10 + successThreshold: 1 + timeoutSeconds: 1 + resources: + requests: + cpu: 100m + memory: 90Mi + securityContext: + allowPrivilegeEscalation: false + capabilities: + add: + - NET_BIND_SERVICE + drop: + - ALL + readOnlyRootFilesystem: false + runAsGroup: 82 + runAsNonRoot: true + runAsUser: 101 + seccompProfile: + type: RuntimeDefault + volumeMounts: + - mountPath: /usr/local/certificates/ + name: webhook-cert + readOnly: true + imagePullSecrets: + - name: regcred + dnsPolicy: ClusterFirst + nodeSelector: + kubernetes.io/os: linux + serviceAccountName: ingress-nginx + terminationGracePeriodSeconds: 0 + tolerations: + - effect: NoSchedule + key: node-role.kubernetes.io/master + operator: Equal + - effect: NoSchedule + key: node-role.kubernetes.io/control-plane + operator: Equal + volumes: + - name: webhook-cert + secret: + secretName: ingress-nginx-admission +--- +apiVersion: batch/v1 +kind: Job +metadata: + labels: + app.kubernetes.io/component: admission-webhook + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-admission-create + namespace: ingress-nginx +spec: + template: + metadata: + labels: + app.kubernetes.io/component: admission-webhook + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-admission-create + spec: + containers: + - args: + - create + - --host=ingress-nginx-controller-admission,ingress-nginx-controller-admission.$(POD_NAMESPACE).svc + - --namespace=$(POD_NAMESPACE) + - --secret-name=ingress-nginx-admission + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace +# image: registry.k8s.io/ingress-nginx/kube-webhook-certgen:v1.4.4@sha256:a9f03b34a3cbfbb26d103a14046ab2c5130a80c3d69d526ff8063d2b37b9fd3f + image: $REGISTRY_URL/kube-webhook-certgen:latest + imagePullPolicy: IfNotPresent + name: create + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsGroup: 65532 + runAsNonRoot: true + runAsUser: 65532 + seccompProfile: + type: RuntimeDefault + imagePullSecrets: + - name: regcred + nodeSelector: + kubernetes.io/os: linux + restartPolicy: OnFailure + serviceAccountName: ingress-nginx-admission +--- +apiVersion: batch/v1 +kind: Job +metadata: + labels: + app.kubernetes.io/component: admission-webhook + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-admission-patch + namespace: ingress-nginx +spec: + template: + metadata: + labels: + app.kubernetes.io/component: admission-webhook + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-admission-patch + spec: + containers: + - args: + - patch + - --webhook-name=ingress-nginx-admission + - --namespace=$(POD_NAMESPACE) + - --patch-mutating=false + - --secret-name=ingress-nginx-admission + - --patch-failure-policy=Fail + env: + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace +# image: registry.k8s.io/ingress-nginx/kube-webhook-certgen:v1.4.4@sha256:a9f03b34a3cbfbb26d103a14046ab2c5130a80c3d69d526ff8063d2b37b9fd3f + image: $REGISTRY_URL/kube-webhook-certgen:latest + imagePullPolicy: IfNotPresent + name: patch + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: + - ALL + readOnlyRootFilesystem: true + runAsGroup: 65532 + runAsNonRoot: true + runAsUser: 65532 + seccompProfile: + type: RuntimeDefault + imagePullSecrets: + - name: regcred + nodeSelector: + kubernetes.io/os: linux + restartPolicy: OnFailure + serviceAccountName: ingress-nginx-admission +--- +apiVersion: networking.k8s.io/v1 +kind: IngressClass +metadata: + labels: + app.kubernetes.io/component: controller + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: nginx +spec: + controller: k8s.io/ingress-nginx +--- +apiVersion: admissionregistration.k8s.io/v1 +kind: ValidatingWebhookConfiguration +metadata: + labels: + app.kubernetes.io/component: admission-webhook + app.kubernetes.io/instance: ingress-nginx + app.kubernetes.io/name: ingress-nginx + app.kubernetes.io/part-of: ingress-nginx + app.kubernetes.io/version: 1.12.0-beta.0 + name: ingress-nginx-admission +webhooks: +- admissionReviewVersions: + - v1 + clientConfig: + service: + name: ingress-nginx-controller-admission + namespace: ingress-nginx + path: /networking/v1/ingresses + port: 443 + failurePolicy: Fail + matchPolicy: Equivalent + name: validate.nginx.ingress.kubernetes.io + rules: + - apiGroups: + - networking.k8s.io + apiVersions: + - v1 + operations: + - CREATE + - UPDATE + resources: + - ingresses + sideEffects: None diff --git a/jenkins/harbor/deploy.sh b/jenkins/harbor/deploy.sh new file mode 100755 index 00000000..3d291858 --- /dev/null +++ b/jenkins/harbor/deploy.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# Deploy a Harbor instance into a local kind cluster, configured to proxy +# cgr.dev/${CHAINGUARD_ORG}/* via a public Harbor project named "cgr-proxy". +# Adapted from chainguard-demo/cs-workshop/.../harbor/deploy-harbor.sh — same +# Helm + ingress-nginx + Terraform machinery, but driven by env vars instead +# of interactive prompts so setup.sh can call it non-interactively. +# +# Required env vars: +# CHAINGUARD_ORG Chainguard org to proxy (e.g. smalls.xyz) +# PULL_USER Pull-token username (Harbor uses this to talk to cgr.dev) +# PULL_PASS Pull-token password +# +# Optional: +# KIND_CLUSTER_NAME default: jenkins-harbor +# +# Idempotent: re-running re-applies the manifests + Helm values + Terraform. +set -euo pipefail + +cd "$(dirname "$0")" + +: "${CHAINGUARD_ORG:?CHAINGUARD_ORG must be set}" +: "${PULL_USER:?PULL_USER must be set (Chainguard pull-token username)}" +: "${PULL_PASS:?PULL_PASS must be set (Chainguard pull-token password)}" +KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-jenkins-harbor}" + +export ORG_NAME="${CHAINGUARD_ORG}" +export REGISTRY_URL="cgr.dev/${CHAINGUARD_ORG}" + +for tool in kind kubectl helm terraform envsubst; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "ERROR: $tool not found in PATH" >&2 + exit 1 + fi +done + +if ! kind get clusters | grep -qx "$KIND_CLUSTER_NAME"; then + echo "==> Creating kind cluster '$KIND_CLUSTER_NAME'..." + kind create cluster --name "$KIND_CLUSTER_NAME" --config kind/config.yaml + kubectl wait --for=condition=Ready "node/${KIND_CLUSTER_NAME}-control-plane" --timeout=2m +else + echo "==> kind cluster '$KIND_CLUSTER_NAME' already exists, reusing it." + kubectl config use-context "kind-${KIND_CLUSTER_NAME}" +fi + +echo "==> Rendering manifests with REGISTRY_URL=${REGISTRY_URL}..." +envsubst < cg/manifests/deploy-ingress-nginx.template > cg/manifests/deploy-ingress-nginx.yaml +envsubst < cg/helm/values.template > cg/helm/values.yaml + +# Namespaces (idempotent). +kubectl get ns ingress-nginx >/dev/null 2>&1 || kubectl create ns ingress-nginx +kubectl get ns harbor >/dev/null 2>&1 || kubectl create ns harbor + +echo "==> Creating regcred docker-registry secrets so the cluster can pull from cgr.dev..." +for ns in ingress-nginx harbor; do + kubectl -n "$ns" create secret docker-registry regcred \ + --docker-server="cgr.dev" \ + --docker-username="$PULL_USER" \ + --docker-password="$PULL_PASS" \ + --dry-run=client -o yaml | kubectl apply -f - +done + +echo "==> Deploying ingress-nginx..." +kubectl apply -f cg/manifests/deploy-ingress-nginx.yaml +kubectl wait --for=condition=Ready -n ingress-nginx pod \ + --selector=app.kubernetes.io/name=ingress-nginx \ + --selector=app.kubernetes.io/component=controller \ + --timeout=3m + +echo "==> Installing/upgrading Harbor via Helm..." +helm repo add harbor https://helm.goharbor.io >/dev/null 2>&1 || true +helm repo update harbor >/dev/null +helm upgrade --install harbor harbor/harbor -n harbor -f cg/helm/values.yaml --wait --timeout=10m + +echo "==> Waiting for Harbor's web ingress to respond..." +for i in $(seq 1 60); do + if curl -fsS -o /dev/null http://localhost/api/v2.0/health 2>/dev/null; then + break + fi + if (( i == 60 )); then + echo "ERROR: Harbor /api/v2.0/health did not respond at http://localhost/ within 2 minutes." >&2 + exit 1 + fi + sleep 2 +done + +echo "==> Configuring Harbor with Terraform (cgr.dev proxy registry + cgr-proxy project)..." +export PULL_USER PULL_PASS +envsubst < terraform/terraform.templatevars > terraform/terraform.tfvars +( cd terraform + terraform init -input=false -upgrade + terraform apply -input=false -auto-approve +) + +echo "==> Done." +echo " Harbor UI: http://localhost/harbor (admin / Harbor12345)" +echo " Proxy cache URL: localhost/cgr-proxy/${CHAINGUARD_ORG}/:" +echo " Push project: localhost/library/:" diff --git a/jenkins/harbor/kind/config.yaml b/jenkins/harbor/kind/config.yaml new file mode 100644 index 00000000..fd7063b0 --- /dev/null +++ b/jenkins/harbor/kind/config.yaml @@ -0,0 +1,11 @@ +kind: Cluster +apiVersion: kind.x-k8s.io/v1alpha4 +nodes: +- role: control-plane + extraPortMappings: + - containerPort: 80 + hostPort: 80 + protocol: TCP + - containerPort: 443 + hostPort: 443 + protocol: TCP diff --git a/jenkins/harbor/teardown.sh b/jenkins/harbor/teardown.sh new file mode 100755 index 00000000..12c22c23 --- /dev/null +++ b/jenkins/harbor/teardown.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +# Tear down the Harbor kind cluster created by deploy.sh. +set -euo pipefail +KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-jenkins-harbor}" +if kind get clusters | grep -qx "$KIND_CLUSTER_NAME"; then + echo "==> Deleting kind cluster '$KIND_CLUSTER_NAME'..." + kind delete cluster --name "$KIND_CLUSTER_NAME" +else + echo "kind cluster '$KIND_CLUSTER_NAME' not present — nothing to do." +fi diff --git a/jenkins/harbor/terraform/main.tf b/jenkins/harbor/terraform/main.tf new file mode 100644 index 00000000..d62b5afd --- /dev/null +++ b/jenkins/harbor/terraform/main.tf @@ -0,0 +1,37 @@ +terraform { + required_providers { + harbor = { + source = "goharbor/harbor" + version = "~> 3.10" + } + } +} + +# The Harbor admin password matches the Helm chart default. If you override +# the chart value `harborAdminPassword`, change this too. +provider "harbor" { + url = "http://localhost" + username = "admin" + password = "Harbor12345" +} + +# Register cgr.dev as an upstream registry so we can use it as a proxy cache +# source. Harbor authenticates to cgr.dev with the supplied pull token. +resource "harbor_registry" "cgr_dev" { + provider_name = "docker-registry" + name = "cgr.dev" + endpoint_url = "https://cgr.dev" + access_id = var.chainguard_username + access_secret = var.chainguard_pull_token +} + +# Public proxy-cache project. Anonymous pulls of +# `localhost/cgr-proxy//:` lazily fetch from cgr.dev on +# first hit and cache locally; subsequent pulls hit Harbor's local copy. +# (Harbor's default `library` project is reused for pushes — no extra +# resource needed.) +resource "harbor_project" "cgr_proxy" { + name = "cgr-proxy" + public = true + registry_id = harbor_registry.cgr_dev.registry_id +} diff --git a/jenkins/harbor/terraform/terraform.templatevars b/jenkins/harbor/terraform/terraform.templatevars new file mode 100644 index 00000000..c0a27456 --- /dev/null +++ b/jenkins/harbor/terraform/terraform.templatevars @@ -0,0 +1,4 @@ +# terraform.tfvars +chainguard_organization_name = "$ORG_NAME" +chainguard_username = "$PULL_USER" +chainguard_pull_token = "$PULL_PASS" \ No newline at end of file diff --git a/jenkins/harbor/terraform/variables.tf b/jenkins/harbor/terraform/variables.tf new file mode 100644 index 00000000..680b2f9c --- /dev/null +++ b/jenkins/harbor/terraform/variables.tf @@ -0,0 +1,16 @@ +variable "chainguard_username" { + type = string + description = "Username portion of the Chainguard pull token Harbor uses to fetch from cgr.dev." + sensitive = true +} + +variable "chainguard_pull_token" { + type = string + description = "Password (JWT) portion of the Chainguard pull token Harbor uses to fetch from cgr.dev." + sensitive = true +} + +variable "chainguard_organization_name" { + type = string + description = "Chainguard org being proxied (matches CHAINGUARD_ORG used elsewhere in the demo)." +} diff --git a/jenkins/jenkins/casc/jenkins.yaml b/jenkins/jenkins/casc/jenkins.yaml index 1eda492e..b59cacaa 100644 --- a/jenkins/jenkins/casc/jenkins.yaml +++ b/jenkins/jenkins/casc/jenkins.yaml @@ -10,6 +10,15 @@ jenkins: env: - key: CHAINGUARD_ORG value: ${CHAINGUARD_ORG} + # Registry routing — set by setup.sh: + # cgImage.groovy uses PULL_REGISTRY; OCI-image apps' IMAGE uses PUSH_REGISTRY; + # cgLogin.groovy short-circuits when HARBOR_ENABLED is true. + - key: PULL_REGISTRY + value: ${PULL_REGISTRY:-cgr.dev/${CHAINGUARD_ORG}} + - key: PUSH_REGISTRY + value: ${PUSH_REGISTRY:-ttl.sh/smalls} + - key: HARBOR_ENABLED + value: ${HARBOR_ENABLED:-false} securityRealm: local: allowsSignup: false diff --git a/jenkins/setup.sh b/jenkins/setup.sh index d052754e..7c1f3619 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -1,24 +1,27 @@ #!/usr/bin/env bash -# One-time setup for the Chainguard assumed-identity flow. +# Interactive bootstrap. Asks the user three questions about how Jenkins +# should pull and push images, then sets up: # -# Replaces the old long-lived pull-token approach. Now does: -# 1. Polls Jenkins until it's healthy and serving its OIDC discovery doc. -# 2. Fetches Jenkins' JWKS into iac/jenkins-jwks.json. -# 3. Runs `terraform apply` to create the Chainguard assumed identity -# (with the JWKS uploaded statically) and a registry.pull rolebinding. -# 4. Reads the identity UIDP from terraform output and writes it into .env -# as CHAINGUARD_IDENTITY=... -# 5. Restarts Jenkins so the new env var is visible to pipelines. +# Mode A — direct cgr.dev (no Harbor) +# PULL: cgr.dev/$CHAINGUARD_ORG (per-build OIDC chainctl session) +# PUSH: ttl.sh/ (anonymous, default; user can override) # -# Re-run this script if you change the Chainguard org, recreate the -# controller (which rotates Jenkins' OIDC signing key), or deliberately -# rotate the identity. +# Mode B — Harbor for pulls, push elsewhere +# PULL: localhost/cgr-proxy/$CHAINGUARD_ORG (anonymous, Harbor pull-through) +# PUSH: ttl.sh/ (default; user can override) +# +# Mode C — Harbor for both pulls and pushes +# PULL: localhost/cgr-proxy/$CHAINGUARD_ORG (anonymous, Harbor pull-through) +# PUSH: localhost/library (Harbor admin creds embedded) +# +# Persists the choice in .env (PULL_REGISTRY, PUSH_REGISTRY, HARBOR_ENABLED) +# and either bootstraps the OIDC assumed identity (Mode A) or stands up the +# Harbor kind cluster (Modes B/C). Re-run any time to switch modes. set -euo pipefail cd "$(dirname "$0")" -# Pick up CHAINGUARD_ORG from .env if present so the script and docker-compose -# stay in sync without the user having to export the variable twice. +# Pick up CHAINGUARD_ORG (and any prior choices) from .env if present. if [[ -f .env ]]; then set -a # shellcheck disable=SC1091 @@ -28,61 +31,165 @@ fi ORG="${CHAINGUARD_ORG:-smalls.xyz}" JENKINS_URL="${JENKINS_URL:-http://localhost:8080}" -# Literal `iss` claim string Jenkins puts in OIDC tokens. Must match the -# scheme/host/port of jenkins.location.url in jenkins.yaml — currently -# https://localhost:8080/ (HTTPS to satisfy the chainguard_identity static -# block validator; static-mode is offline-verification only, the URL never -# has to resolve). JENKINS_OIDC_ISSUER="${JENKINS_OIDC_ISSUER:-https://localhost:8080/oidc}" -JWKS_FILE="iac/jenkins-jwks.json" -echo "==> Verifying Jenkins is up at ${JENKINS_URL}..." -for i in $(seq 1 60); do - if curl -fsS -o /dev/null "${JENKINS_URL}/login"; then - break +prompt_yn() { + # $1=question, $2=default ("y" or "n") + local q="$1" def="$2" hint ans + if [[ "$def" == "y" ]]; then hint="(Y/n)"; else hint="(y/N)"; fi + read -rp "$q $hint: " ans + ans="${ans:-$def}" + [[ "$ans" =~ ^[Yy] ]] +} + +echo "==> Chainguard org: ${ORG}" +echo + +if prompt_yn "Install Harbor as a pull-through cache for cgr.dev/${ORG}/*?" n; then + HARBOR_ENABLED=true + if prompt_yn "Push pipeline-built images to Harbor (rather than ttl.sh)?" n; then + PUSH_TO_HARBOR=true + else + PUSH_TO_HARBOR=false + fi +else + HARBOR_ENABLED=false + PUSH_TO_HARBOR=false +fi + +# Where to push if not Harbor. +if [[ "$PUSH_TO_HARBOR" == "true" ]]; then + PUSH_REGISTRY="localhost/library" +else + read -rp "Where should pipelines push their built images? [ttl.sh/smalls]: " PUSH_REG_INPUT + PUSH_REGISTRY="${PUSH_REG_INPUT:-ttl.sh/smalls}" +fi + +# Where to pull cgr.dev images from. +if [[ "$HARBOR_ENABLED" == "true" ]]; then + PULL_REGISTRY="localhost/cgr-proxy/${ORG}" +else + PULL_REGISTRY="cgr.dev/${ORG}" +fi + +echo +echo "==> Mode summary:" +echo " Harbor enabled: ${HARBOR_ENABLED}" +echo " Pulls from: ${PULL_REGISTRY}" +echo " Pushes to: ${PUSH_REGISTRY}" +echo + +# ---- Phase 1: write .env first so docker compose picks up the new mode ---- + +echo "==> Writing mode flags to .env..." +[[ -f .env ]] || cp .env.example .env +update_env() { + local key="$1" value="$2" + if grep -q "^${key}=" .env; then + sed -i.bak "s|^${key}=.*|${key}=${value}|" .env && rm -f .env.bak + else + printf '%s=%s\n' "$key" "$value" >> .env fi +} +update_env CHAINGUARD_ORG "$ORG" +update_env HARBOR_ENABLED "$HARBOR_ENABLED" +update_env PULL_REGISTRY "$PULL_REGISTRY" +update_env PUSH_REGISTRY "$PUSH_REGISTRY" + +# ---- Phase 2: (re)create Jenkins with the new env so its OIDC signing key +# is the one we'll upload to Chainguard in Phase 3. ---- + +echo "==> Bringing up Jenkins (force-recreate to pick up new env)..." +docker compose up -d --build --force-recreate jenkins +for i in $(seq 1 60); do + if curl -fsS -o /dev/null "$JENKINS_URL/login" 2>/dev/null; then break; fi if (( i == 60 )); then - echo "ERROR: Jenkins did not respond at ${JENKINS_URL}/login within 2 minutes." >&2 - echo "Did you run 'docker compose up -d --build' first?" >&2 + echo "ERROR: Jenkins did not respond at $JENKINS_URL/login within 2 minutes." >&2 exit 1 fi sleep 2 done +echo "==> Jenkins is up at $JENKINS_URL" -echo "==> Fetching Jenkins JWKS to ${JWKS_FILE}..." -mkdir -p iac -curl -fsS "${JENKINS_URL}/oidc/jwks" > "${JWKS_FILE}" -if ! python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "${JWKS_FILE}" >/dev/null 2>&1; then - echo "ERROR: ${JWKS_FILE} is not valid JSON. Got:" >&2 - cat "${JWKS_FILE}" >&2 - exit 1 -fi -echo " Fetched $(python3 -c 'import json,sys; d=json.load(open(sys.argv[1])); print(len(d.get("keys",[])))' "${JWKS_FILE}") signing key(s)." - -echo "==> Running terraform apply (chainguard_group=${ORG})..." -( cd iac - terraform init -input=false -upgrade - terraform apply -input=false -auto-approve \ - -var="chainguard_group_name=${ORG}" \ - -var="jenkins_issuer_url=${JENKINS_OIDC_ISSUER}" -) - -UIDP=$( cd iac && terraform output -raw identity_uidp ) -if [[ -z "$UIDP" ]]; then - echo "ERROR: terraform output identity_uidp was empty." >&2 - exit 1 +# ---- Phase 3: mode-specific bootstrap ---- + +if [[ "$HARBOR_ENABLED" == "true" ]]; then + echo "==> Harbor mode — generating a long-lived pull token for Harbor..." + # Harbor needs durable creds against cgr.dev (it can't use Jenkins' per- + # build OIDC tokens). Reuse a cached one if present, else generate. + PULL_FILE=harbor/.pull-token + if [[ -f "$PULL_FILE" ]]; then + # shellcheck disable=SC1090 + source "$PULL_FILE" + fi + if [[ -z "${PULL_USER:-}" || -z "${PULL_PASS:-}" ]]; then + TOKEN_OUTPUT=$(chainctl auth pull-token create --parent="$ORG" --name="harbor-cgr-proxy" --ttl=720h) + PULL_USER=$(echo "$TOKEN_OUTPUT" | grep -oE -- '--username "[^"]+"' | head -1 | sed -E 's/--username "([^"]+)"/\1/') + PULL_PASS=$(echo "$TOKEN_OUTPUT" | grep -oE -- '--password "[^"]+"' | head -1 | sed -E 's/--password "([^"]+)"/\1/') + if [[ -z "$PULL_USER" || -z "$PULL_PASS" ]]; then + echo "ERROR: failed to parse pull token from chainctl output." >&2 + exit 1 + fi + mkdir -p harbor + cat > "$PULL_FILE" < Deploying Harbor (kind + Helm + Terraform)..." + CHAINGUARD_ORG="$ORG" PULL_USER="$PULL_USER" PULL_PASS="$PULL_PASS" \ + harbor/deploy.sh + + # In Harbor mode the Jenkins OIDC assumed identity isn't used at runtime, + # but we leave it in .env from any prior bootstrap (cleared below). + IDENTITY_FILE=shared-libraries/cg-images/IDENTITY + : > "$IDENTITY_FILE" # truncate; cgLogin will be skipped via env flag +else + echo "==> Direct-cgr.dev mode — bootstrapping the OIDC assumed identity..." + JWKS_FILE="iac/jenkins-jwks.json" + mkdir -p iac + curl -fsS "$JENKINS_URL/oidc/jwks" > "$JWKS_FILE" + if ! python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$JWKS_FILE" >/dev/null 2>&1; then + echo "ERROR: $JWKS_FILE is not valid JSON. Got:" >&2 + cat "$JWKS_FILE" >&2 + exit 1 + fi + echo " Fetched $(python3 -c 'import json,sys; print(len(json.load(open(sys.argv[1])).get("keys",[])))' "$JWKS_FILE") signing key(s)." + ( cd iac + terraform init -input=false -upgrade + terraform apply -input=false -auto-approve \ + -var="chainguard_group_name=${ORG}" \ + -var="jenkins_issuer_url=${JENKINS_OIDC_ISSUER}" + ) + UIDP=$(cd iac && terraform output -raw identity_uidp) + if [[ -z "$UIDP" ]]; then + echo "ERROR: terraform output identity_uidp was empty." >&2 + exit 1 + fi + echo " Created identity: ${UIDP}" + printf '%s\n' "$UIDP" > shared-libraries/cg-images/IDENTITY fi -echo " Created identity: ${UIDP}" - -echo "==> Writing identity UIDP to shared-libraries/cg-images/IDENTITY..." -# Pipelines read this file via cgLogin (filesystem-SCM live-loaded shared -# library) — no Jenkins restart required. We deliberately AVOID a restart -# here because restarting Jenkins regenerates the oidc-provider plugin's -# signing key, which would immediately invalidate the JWKS we just uploaded -# to Chainguard. -printf '%s\n' "${UIDP}" > shared-libraries/cg-images/IDENTITY - -echo "==> Done. Bootstrap complete." -echo " Open ${JENKINS_URL} (admin/admin) and trigger any pipeline — its" -echo " Auth stage will exchange a fresh per-build OIDC token for a" -echo " short-lived chainctl session and continue from there." + +echo +echo "==> Done." +echo " Open $JENKINS_URL (admin/admin) and trigger any pipeline." +echo +case "$HARBOR_ENABLED-$PUSH_TO_HARBOR" in + false-false) + echo " Mode: direct cgr.dev with OIDC assumed identity for pulls." + echo " Pushes go to $PUSH_REGISTRY." + ;; + true-false) + echo " Mode: Harbor proxy cache for pulls (anonymous)." + echo " Pushes go to $PUSH_REGISTRY." + echo " Harbor UI: http://localhost/harbor (admin / Harbor12345)" + ;; + true-true) + echo " Mode: Harbor for both pulls and pushes." + echo " Pushes land in Harbor's library project." + echo " Harbor UI: http://localhost/harbor (admin / Harbor12345)" + ;; +esac diff --git a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy index 68f08915..8c957d30 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy @@ -37,7 +37,10 @@ // newer image versions. def call(String token) { - def reg = "cgr.dev/${env.CHAINGUARD_ORG}" + // PULL_REGISTRY is set by JCasC globalNodeProperties (driven by setup.sh). + // Defaults to cgr.dev/ for the no-Harbor case; switches to + // localhost/cgr-proxy/ when Harbor is the active pull-through cache. + def reg = env.PULL_REGISTRY ?: "cgr.dev/${env.CHAINGUARD_ORG}" def catalog = [ 'corretto-java17': [ build: 'maven:3-jdk17-dev@sha256:d95ab64eb7ce9b3016cc781c65025727b7287f048655ce2a33ec8b9500b4ba39', diff --git a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy index 58bbecd6..26147759 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy @@ -1,9 +1,22 @@ // vars/cgLogin.groovy // -// Per-build chainctl login. Exchanges a Jenkins-issued OIDC token for a -// short-lived (~30 min) Chainguard session, then writes a docker config -// that the rest of the pipeline (and any spawned `agent docker` agents) -// can use to pull from cgr.dev. +// Sets up the controller's docker config so the rest of the pipeline can +// pull (and optionally push) Chainguard images. Behavior depends on the +// mode chosen at setup.sh time: +// +// Mode A (HARBOR_ENABLED=false, PUSH_REGISTRY=ttl.sh/...): +// Exchanges a per-build Jenkins-issued OIDC token for a short-lived +// Chainguard session via `chainctl auth login` + `chainctl auth +// configure-docker`. ttl.sh pushes don't need creds. +// +// Mode B (HARBOR_ENABLED=true, PUSH_REGISTRY=ttl.sh/...): +// Pulls go through Harbor's anonymous proxy cache project, so no +// cgr.dev creds needed. ttl.sh pushes don't need creds either. The +// Auth stage is effectively a no-op — we just print a status line. +// +// Mode C (HARBOR_ENABLED=true, PUSH_REGISTRY=localhost/...): +// Same as Mode B for pulls. For pushes, write Harbor admin/Harbor12345 +// into DOCKER_CONFIG/config.json so docker push to localhost/... works. // // Usage — call from a stage on `agent any` (the controller) BEFORE any // stage that uses `agent { docker { image cgImage(...).build } }`: @@ -12,29 +25,38 @@ // agent any // steps { cgLogin() } // } -// -// Auto-loaded for every pipeline via the same JCasC globalLibraries -// config that loads cgImage. No `@Library` annotation needed. -// -// Required environment / config (set up by setup.sh + jenkins.yaml): -// - env.CHAINGUARD_IDENTITY UIDP of the assumed identity -// (set via JCasC globalNodeProperties) -// - credential 'jenkins-cgr-oidc' an oidcCredential issued by the -// oidc-provider plugin, audience cgr.dev -// - DOCKER_CONFIG path the controller's docker CLI reads -// (set in docker-compose.yml) def call() { - // Read the identity UIDP from a file rather than env.CHAINGUARD_IDENTITY. - // The file is rewritten by setup.sh after each `terraform apply`, and the - // shared-libraries dir is bind-mounted live into the controller, so a - // Jenkins restart is NOT required to pick up a new identity. (Restarting - // Jenkins would also regenerate its OIDC signing key and invalidate the - // JWKS we just uploaded — avoiding that is the whole reason this is a - // file lookup.) + def harborEnabled = (env.HARBOR_ENABLED ?: 'false') == 'true' + def pushRegistry = env.PUSH_REGISTRY ?: '' + + if (harborEnabled) { + if (pushRegistry.startsWith('localhost/')) { + // Mode C: write Harbor admin creds for push. + sh ''' + set -eu + mkdir -p "$DOCKER_CONFIG" + AUTH=$(printf 'admin:Harbor12345' | base64) + cat > "$DOCKER_CONFIG/config.json" < Date: Thu, 7 May 2026 10:47:33 -0500 Subject: [PATCH 16/47] jenkins: add top-level teardown.sh that reverses everything setup.sh did MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One script to clean up the whole demo: 1. harbor/teardown.sh — delete the kind cluster (no-op if not present) 2. iac/ terraform destroy — release the Chainguard assumed identity 3. docker compose down --rmi local --remove-orphans — Jenkins out 4. rm -rf /tmp/cgjenkins-home — JENKINS_HOME bind mount 5. Remove .secrets/, harbor/.pull-token, IDENTITY, terraform state, rendered manifests/values Prompts before doing anything destructive. Idempotent — re-running on a clean slate reports each step as a no-op. Pass --wipe-env to also delete .env (otherwise it's kept so re-running setup.sh remembers the user's CHAINGUARD_ORG choice). Step 4 tries plain rm first and only falls back to sudo when needed — on macOS + OrbStack the bind-mount is owned by the host user (no sudo); on Linux it may be owned by uid 1000 mapped from inside the container. README's Teardown section now points to the script and keeps the manual steps as a fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/README.md | 18 +++++++--- jenkins/teardown.sh | 82 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 4 deletions(-) create mode 100755 jenkins/teardown.sh diff --git a/jenkins/README.md b/jenkins/README.md index a8228f3b..b71e36d0 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -136,11 +136,21 @@ These bit me while building out the seven samples — useful to know up front wh ## Teardown +[`teardown.sh`](teardown.sh) reverses everything `setup.sh` (and Harbor's `deploy.sh`) put in place — Harbor kind cluster, Chainguard assumed identity (`terraform destroy`), Jenkins controller container + image, the persisted `/tmp/cgjenkins-home`, and all generated state/secret files. It prompts before doing anything destructive. + +```sh +./teardown.sh # keeps .env so re-running setup.sh remembers CHAINGUARD_ORG +./teardown.sh --wipe-env # full reset, including .env +``` + +If you'd rather do it by hand (or skip a step): + ```sh -docker compose down -sudo rm -rf /tmp/cgjenkins-home # remove the persisted Jenkins home -rm -rf .secrets # remove any stale pull-token Docker config -harbor/teardown.sh # delete the Harbor kind cluster (no-op if not deployed) +harbor/teardown.sh # delete the Harbor kind cluster +( cd iac && terraform destroy -auto-approve ... ) # release the Chainguard identity +docker compose down --rmi local --remove-orphans # stop Jenkins +sudo rm -rf /tmp/cgjenkins-home # persisted Jenkins home +rm -rf .secrets harbor/.pull-token shared-libraries/cg-images/IDENTITY ``` ## Notes diff --git a/jenkins/teardown.sh b/jenkins/teardown.sh new file mode 100755 index 00000000..bccbdeea --- /dev/null +++ b/jenkins/teardown.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash +# Tear down everything setup.sh + the demo created: +# - Harbor kind cluster (if present) +# - Chainguard assumed identity (terraform destroy on iac/, if state present) +# - Jenkins controller container + image +# - JENKINS_HOME bind-mount at /tmp/cgjenkins-home (needs sudo) +# - .secrets/, harbor/.pull-token, IDENTITY file, terraform state files +# +# Leaves .env in place (so re-running setup.sh remembers your CHAINGUARD_ORG +# choice). Pass --wipe-env to remove that too. +set -euo pipefail + +cd "$(dirname "$0")" + +WIPE_ENV=false +for arg in "$@"; do + case "$arg" in + --wipe-env) WIPE_ENV=true ;; + -h|--help) + sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' + exit 0 + ;; + *) echo "unknown flag: $arg" >&2; exit 2 ;; + esac +done + +cat < 1/5 Tearing down Harbor (kind cluster, if any)..." +if [[ -x harbor/teardown.sh ]]; then + harbor/teardown.sh +fi + +echo "==> 2/5 Releasing Chainguard assumed identity (if Terraform state present)..." +if [[ -f iac/terraform.tfstate ]]; then + ( cd iac && terraform destroy -auto-approve \ + -var="chainguard_group_name=${CHAINGUARD_ORG:-smalls.xyz}" \ + -var="jenkins_issuer_url=${JENKINS_OIDC_ISSUER:-https://localhost:8080/oidc}" || true ) +fi + +echo "==> 3/5 Stopping Jenkins (docker compose down)..." +docker compose down --rmi local --remove-orphans 2>&1 | tail -5 + +echo "==> 4/5 Removing /tmp/cgjenkins-home..." +# On macOS + OrbStack the bind-mount is owned by the host user (no sudo). +# On Linux it may be owned by uid 1000 from inside the container, which maps +# to a different host user — fall back to sudo only when plain rm fails. +if ! rm -rf /tmp/cgjenkins-home 2>/dev/null; then + echo " Plain rm failed, retrying with sudo..." + sudo rm -rf /tmp/cgjenkins-home +fi + +echo "==> 5/5 Cleaning generated files..." +rm -rf .secrets +rm -f harbor/.pull-token +rm -f shared-libraries/cg-images/IDENTITY +rm -rf iac/.terraform iac/terraform.tfstate iac/terraform.tfstate.backup iac/jenkins-jwks.json +rm -rf harbor/terraform/.terraform harbor/terraform/terraform.tfstate harbor/terraform/terraform.tfstate.backup harbor/terraform/terraform.tfvars +rm -f harbor/cg/helm/values.yaml harbor/cg/manifests/deploy-ingress-nginx.yaml + +if [[ "$WIPE_ENV" == "true" ]]; then + rm -f .env + echo " Removed .env." +fi + +echo +echo "==> Done. Re-run ./setup.sh to bootstrap from scratch." From fe72a1f6a7d90abd8fb70643fd93e442f2b7ca84 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 11:39:54 -0500 Subject: [PATCH 17/47] jenkins: serve Harbor UI over HTTPS to dodge gorilla/csrf v1.7.3 origin check Harbor 2.12.3+ pulled in gorilla/csrf v1.7.3, which hardcodes the request scheme to "https" inside its origin check. Harbor's middleware doesn't wrap the request as plaintext, so a browser POST /c/login over HTTP is rejected with 403 "origin invalid" before the password is checked. Upstream tracking issue: goharbor/harbor#22010. Fix: enable TLS on the chart with an auto-generated self-signed cert, keep externalURL as http://localhost, and disable ssl-redirect. Browser users hit https://localhost/harbor (one-time cert warning), the registry path stays HTTP so `docker push localhost/library/...` keeps working (Docker daemon refuses HTTPS to 127.0.0.0/8 by default). Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/README.md | 2 +- jenkins/harbor/README.md | 4 +++- jenkins/harbor/cg/helm/values.template | 22 +++++++++++++++++++++- jenkins/harbor/deploy.sh | 9 ++++++--- jenkins/harbor/terraform/main.tf | 5 ++++- jenkins/setup.sh | 4 ++-- 6 files changed, 37 insertions(+), 9 deletions(-) diff --git a/jenkins/README.md b/jenkins/README.md index b71e36d0..515deb86 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -85,7 +85,7 @@ docker compose up -d --build | **Mode B** | `localhost/cgr-proxy/` (Harbor proxy cache) | `ttl.sh/` (default) | None — anonymous pulls + ttl.sh anonymous pushes | | **Mode C** | `localhost/cgr-proxy/` (Harbor proxy cache) | `localhost/library` (Harbor) | Anonymous pulls + Harbor admin creds for push | -Modes B and C stand up Harbor in a local `kind` cluster. They require `kind`, `kubectl`, `helm`, `terraform`, and `envsubst` on the host. See [harbor/README.md](harbor/README.md) for the architecture details. +Modes B and C stand up Harbor in a local `kind` cluster. They require `kind`, `kubectl`, `helm`, `terraform`, and `envsubst` on the host. The Harbor admin UI lives at (self-signed cert — click through the browser warning once). The registry path stays on `http://localhost` so `docker push` works without daemon-trust gymnastics. See [harbor/README.md](harbor/README.md) for the architecture details and a note on why the UI must be HTTPS. ```sh cd jenkins diff --git a/jenkins/harbor/README.md b/jenkins/harbor/README.md index e3d07aa1..9ae6640e 100644 --- a/jenkins/harbor/README.md +++ b/jenkins/harbor/README.md @@ -55,4 +55,6 @@ export PULL_PASS=... ./deploy.sh ``` -The Harbor admin UI is at (`admin` / `Harbor12345`). Tear down with `./teardown.sh`. +The Harbor admin UI is at (`admin` / `Harbor12345`). The chart issues a self-signed cert, so browsers will show a one-time warning — click through. Tear down with `./teardown.sh`. + +> **Why HTTPS for the UI but HTTP for the registry?** Harbor 2.12.3+ ships with `gorilla/csrf` v1.7.3, which hardcodes the request scheme to `https` inside its origin check. Harbor's middleware doesn't compensate, so a plain-HTTP `POST /c/login` is rejected with `403 origin invalid` before the password is even checked (upstream bug: [goharbor/harbor#22010](https://github.com/goharbor/harbor/issues/22010)). The Docker daemon, in turn, refuses HTTPS connections to `127.0.0.0/8` by default, so the registry path (`/v2/`, `/service/token`, etc.) must remain reachable over HTTP. We split the two: TLS is enabled on the ingress for the UI, but `ssl-redirect` is off so HTTP isn't forced — the `externalURL` stays `http://localhost` so Harbor advertises HTTP to docker clients. Browser users type `https://`, pipelines push over `http://`, both work. diff --git a/jenkins/harbor/cg/helm/values.template b/jenkins/harbor/cg/helm/values.template index 718a9d93..41726bfa 100644 --- a/jenkins/harbor/cg/helm/values.template +++ b/jenkins/harbor/cg/helm/values.template @@ -1,11 +1,31 @@ +# HTTPS is required for the UI: Harbor 2.12.3+ pulled in gorilla/csrf v1.7.3, +# which hardcodes the request scheme to "https" inside its origin check, and +# Harbor's middleware never wraps the request as plaintext. So a browser +# POST /c/login over HTTP fails with 403 "origin invalid" before the password +# is ever checked. Upstream bug: goharbor/harbor#22010. Until that ships a +# fix, we serve the UI over HTTPS with an auto-generated self-signed cert. +# Browsers show a one-time cert warning; click through to log in. +# +# externalURL stays http://localhost so Harbor's registry path (used by +# `docker push localhost/library/...`) advertises HTTP back to clients. The +# Docker daemon refuses HTTPS to 127.0.0.0/8 by default, so HTTPS on the +# registry path would break pipeline pushes. We also disable ssl-redirect +# so HTTP→HTTPS isn't forced — that lets `docker push` over HTTP coexist +# with the HTTPS UI on the same ingress. externalURL: http://localhost expose: type: ingress tls: - enabled: false + enabled: true + certSource: auto + auto: + commonName: "localhost" ingress: hosts: core: localhost + annotations: + ingress.kubernetes.io/ssl-redirect: "false" + nginx.ingress.kubernetes.io/ssl-redirect: "false" imagePullSecrets: - name: regcred diff --git a/jenkins/harbor/deploy.sh b/jenkins/harbor/deploy.sh index 3d291858..54014738 100755 --- a/jenkins/harbor/deploy.sh +++ b/jenkins/harbor/deploy.sh @@ -72,12 +72,15 @@ helm repo update harbor >/dev/null helm upgrade --install harbor harbor/harbor -n harbor -f cg/helm/values.yaml --wait --timeout=10m echo "==> Waiting for Harbor's web ingress to respond..." +# -k: the chart-issued cert is self-signed; we just need to know the +# endpoint is alive, not validate trust. See values.template for why we +# can't run plain HTTP (Harbor #22010). for i in $(seq 1 60); do - if curl -fsS -o /dev/null http://localhost/api/v2.0/health 2>/dev/null; then + if curl -fsSk -o /dev/null https://localhost/api/v2.0/health 2>/dev/null; then break fi if (( i == 60 )); then - echo "ERROR: Harbor /api/v2.0/health did not respond at http://localhost/ within 2 minutes." >&2 + echo "ERROR: Harbor /api/v2.0/health did not respond at https://localhost/ within 2 minutes." >&2 exit 1 fi sleep 2 @@ -92,6 +95,6 @@ envsubst < terraform/terraform.templatevars > terraform/terraform.tfvars ) echo "==> Done." -echo " Harbor UI: http://localhost/harbor (admin / Harbor12345)" +echo " Harbor UI: https://localhost/harbor (admin / Harbor12345; click through cert warning)" echo " Proxy cache URL: localhost/cgr-proxy/${CHAINGUARD_ORG}/:" echo " Push project: localhost/library/:" diff --git a/jenkins/harbor/terraform/main.tf b/jenkins/harbor/terraform/main.tf index d62b5afd..11026dc5 100644 --- a/jenkins/harbor/terraform/main.tf +++ b/jenkins/harbor/terraform/main.tf @@ -9,10 +9,13 @@ terraform { # The Harbor admin password matches the Helm chart default. If you override # the chart value `harborAdminPassword`, change this too. +# `insecure = true` skips TLS verification — the chart issues a self-signed +# cert (see harbor/cg/helm/values.template for why we can't run HTTP). provider "harbor" { - url = "http://localhost" + url = "https://localhost" username = "admin" password = "Harbor12345" + insecure = true } # Register cgr.dev as an upstream registry so we can use it as a proxy cache diff --git a/jenkins/setup.sh b/jenkins/setup.sh index 7c1f3619..d6eb9838 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -185,11 +185,11 @@ case "$HARBOR_ENABLED-$PUSH_TO_HARBOR" in true-false) echo " Mode: Harbor proxy cache for pulls (anonymous)." echo " Pushes go to $PUSH_REGISTRY." - echo " Harbor UI: http://localhost/harbor (admin / Harbor12345)" + echo " Harbor UI: https://localhost/harbor (admin / Harbor12345; click through cert warning)" ;; true-true) echo " Mode: Harbor for both pulls and pushes." echo " Pushes land in Harbor's library project." - echo " Harbor UI: http://localhost/harbor (admin / Harbor12345)" + echo " Harbor UI: https://localhost/harbor (admin / Harbor12345; click through cert warning)" ;; esac From 95e9b02c8c53813bb80c100160fe777c6d3676e1 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 12:41:37 -0500 Subject: [PATCH 18/47] jenkins: cosign-sign and verify OCI images via the credentials store MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The four OCI-image pipelines (python314, python312, node22, node25) now run Sign and Verify stages after Push, so every demo build proves the sign-then-verify loop end-to-end. Setup.sh generates a static cosign keypair on first run (cached at /tmp/cgjenkins-home/.secrets/) and exports the passphrase for docker compose. JCasC wires three credentials into Jenkins's encrypted store at boot — cosign-private-key (Secret file), cosign-public-key (Secret file), cosign-password (Secret text) — using the new plain-credentials plugin. The cgSign and cgVerify shared-library vars pull credentials through withCredentials so the password is masked in build logs and the key material never lives in plaintext outside the Jenkins-managed store. Cosign itself runs as a one-shot --network host sibling container so Mode C's `localhost` registry references resolve to the host ingress (the controller container's loopback can't reach it). cgLogin's Mode C DOCKER_CONFIG now writes auth for both `localhost` and `localhost:80` — cosign's reference parser rejects bare `localhost` and we rewrite digest refs to the explicit-port form before invoking it. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/README.md | 19 +++++ jenkins/apps/node22-npm-express/Jenkinsfile | 10 +++ jenkins/apps/node25-pnpm-express/Jenkinsfile | 10 +++ jenkins/apps/python312-pip-django/Jenkinsfile | 10 +++ jenkins/apps/python314-uv-flask/Jenkinsfile | 10 +++ jenkins/docker-compose.yml | 15 ++++ jenkins/jenkins/casc/jenkins.yaml | 24 ++++++ jenkins/jenkins/plugins.txt | 5 ++ jenkins/setup.sh | 39 ++++++++++ .../cg-images/vars/cgLogin.groovy | 10 ++- .../cg-images/vars/cgSign.groovy | 74 +++++++++++++++++++ .../cg-images/vars/cgVerify.groovy | 53 +++++++++++++ jenkins/teardown.sh | 1 + 13 files changed, 278 insertions(+), 2 deletions(-) create mode 100644 jenkins/shared-libraries/cg-images/vars/cgSign.groovy create mode 100644 jenkins/shared-libraries/cg-images/vars/cgVerify.groovy diff --git a/jenkins/README.md b/jenkins/README.md index 515deb86..3cbc1e07 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -103,9 +103,28 @@ Open (`admin` / `admin`; override the password via `JENK 3. **Build (deps)** — install/compile in a Chainguard `*-dev` agent container. 4. **Test** — smoke-test in either the runtime image or its `-dev` variant (see [Common gotchas](#common-gotchas) below). 5. **Archive / Push** — Java pipelines archive their JAR/WAR to Jenkins. Python/Node pipelines build a runtime OCI image and push it to `$PUSH_REGISTRY` (ttl.sh in Modes A/B, the local Harbor `library` project in Mode C). +6. **Sign / Verify** *(OCI-image pipelines only)* — `cgSign()` signs the pushed image with cosign using a keypair generated once by `setup.sh` (cached on disk at `/tmp/cgjenkins-home/.secrets/`). At Jenkins boot, JCasC reads those files and registers them as three encrypted credentials in the Jenkins store: `cosign-private-key` (Secret file), `cosign-public-key` (Secret file), and `cosign-password` (Secret text). Pipelines pull them via `withCredentials()` — Jenkins materializes the key as a workspace temp file and masks the password in build logs. `cgVerify()` then re-pulls the signature and validates it against the public-key credential, failing the build if it doesn't match. The signature lives as a sibling OCI artifact at `:sha256-.sig` in whatever registry was the push target. Cosign runs as a one-shot container with `--network host` (so `localhost` resolves to the same ingress the docker daemon uses) and inherits the controller's `$DOCKER_CONFIG` so it pushes the signature with the same credentials as `docker push`. A clean build takes 10s–40s once images are cached locally; the Gradle pipeline is slower on first run (~1m45s) because the Gradle wrapper has to download the Gradle distribution. +### Verifying a signed image after the build + +The public key sits at `/tmp/cgjenkins-home/.secrets/cosign.pub`. To check a pushed image from outside Jenkins, run cosign in a host-network container so `localhost` resolves to your ingress: + +```sh +DIGEST=$(docker image inspect --format '{{index .RepoDigests 0}}' localhost/library/pytest:3-14) +# cosign's reference parser rejects bare 'localhost' (it tries Docker Hub), +# so rewrite to 'localhost:80' before passing to cosign: +DIGEST="${DIGEST/#localhost\//localhost:80/}" +docker run --rm --network host \ + -v /tmp/cgjenkins-home/.secrets:/secrets:ro \ + --entrypoint=/usr/bin/cosign \ + cgr.dev/${CHAINGUARD_ORG}/cosign:latest-dev \ + verify --allow-http-registry --key /secrets/cosign.pub "$DIGEST" +``` + +Use the **digest** form, not a tag — cosign signatures are bound to image digests. The keypair persists at `/tmp/cgjenkins-home/.secrets/` until `./teardown.sh` wipes it, so signatures from prior builds stay verifiable across `setup.sh` re-runs as long as that directory is intact. + ## Adding another sample app 1. Create a directory under [apps/](apps/), e.g. `apps/my-new-app/`. diff --git a/jenkins/apps/node22-npm-express/Jenkinsfile b/jenkins/apps/node22-npm-express/Jenkinsfile index a6f33ca4..4183c99e 100644 --- a/jenkins/apps/node22-npm-express/Jenkinsfile +++ b/jenkins/apps/node22-npm-express/Jenkinsfile @@ -92,5 +92,15 @@ const server = app.listen(0, '127.0.0.1', () => { sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' } } + + stage('Sign') { + agent any + steps { cgSign(env.IMAGE) } + } + + stage('Verify') { + agent any + steps { cgVerify(env.IMAGE) } + } } } diff --git a/jenkins/apps/node25-pnpm-express/Jenkinsfile b/jenkins/apps/node25-pnpm-express/Jenkinsfile index 6e90d8e1..77a24668 100644 --- a/jenkins/apps/node25-pnpm-express/Jenkinsfile +++ b/jenkins/apps/node25-pnpm-express/Jenkinsfile @@ -87,5 +87,15 @@ const server = app.listen(0, '127.0.0.1', () => { sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' } } + + stage('Sign') { + agent any + steps { cgSign(env.IMAGE) } + } + + stage('Verify') { + agent any + steps { cgVerify(env.IMAGE) } + } } } diff --git a/jenkins/apps/python312-pip-django/Jenkinsfile b/jenkins/apps/python312-pip-django/Jenkinsfile index bac67c62..44f6e1d1 100644 --- a/jenkins/apps/python312-pip-django/Jenkinsfile +++ b/jenkins/apps/python312-pip-django/Jenkinsfile @@ -75,5 +75,15 @@ print('Smoke test passed') sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' } } + + stage('Sign') { + agent any + steps { cgSign(env.IMAGE) } + } + + stage('Verify') { + agent any + steps { cgVerify(env.IMAGE) } + } } } diff --git a/jenkins/apps/python314-uv-flask/Jenkinsfile b/jenkins/apps/python314-uv-flask/Jenkinsfile index e7f9c130..8d830c44 100644 --- a/jenkins/apps/python314-uv-flask/Jenkinsfile +++ b/jenkins/apps/python314-uv-flask/Jenkinsfile @@ -80,5 +80,15 @@ print('Smoke test passed') sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' } } + + stage('Sign') { + agent any + steps { cgSign(env.IMAGE) } + } + + stage('Verify') { + agent any + steps { cgVerify(env.IMAGE) } + } } } diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml index 810062c6..4a0285b6 100644 --- a/jenkins/docker-compose.yml +++ b/jenkins/docker-compose.yml @@ -28,6 +28,13 @@ services: PULL_REGISTRY: ${PULL_REGISTRY:-cgr.dev/${CHAINGUARD_ORG:-smalls.xyz}} PUSH_REGISTRY: ${PUSH_REGISTRY:-ttl.sh/smalls} HARBOR_ENABLED: ${HARBOR_ENABLED:-false} + # Cosign signing-key passphrase. setup.sh exports this from + # /tmp/cgjenkins-home/.secrets/cosign.password before bringing Jenkins + # up; JCasC interpolates it into the `cosign-password` credential at + # boot. Empty if setup.sh hasn't been run yet — Jenkins still boots, + # but pipelines that call cgSign() will fail with a clear "credential + # not found" error. + COSIGN_PASSWORD: ${COSIGN_PASSWORD:-} JENKINS_HOME: /tmp/cgjenkins-home CASC_JENKINS_CONFIG: /tmp/cgjenkins-home/casc JENKINS_ADMIN_PASSWORD: admin @@ -51,6 +58,14 @@ services: # shared-libraries is read-write so the refresh-cgimages-digests job # can rewrite vars/cgImage.groovy in place when image tags repoint. - ./shared-libraries:/tmp/cgjenkins-home/shared-libraries:rw + # NOTE: cosign keys (generated by setup.sh) live at + # /tmp/cgjenkins-home/.secrets/ on the HOST and ride in via the broad + # /tmp/cgjenkins-home bind-mount above. Doing it that way (instead of + # a separate ./.secrets:/tmp/cgjenkins-home/.secrets bind) means the + # path resolves identically on the host and in the container — so + # when cgSign() spawns a sibling cosign container with --network host, + # the docker daemon can mount the same path back into the new + # container without a host-path translation step. # NOTE: previous setups bind-mounted ./.secrets/docker-config.json here # to seed a long-lived pull-token. With the OIDC assumed-identity flow # each pipeline's Auth stage writes a fresh chainctl-issued config to diff --git a/jenkins/jenkins/casc/jenkins.yaml b/jenkins/jenkins/casc/jenkins.yaml index b59cacaa..64e1b0d0 100644 --- a/jenkins/jenkins/casc/jenkins.yaml +++ b/jenkins/jenkins/casc/jenkins.yaml @@ -87,6 +87,30 @@ credentials: # AND serves JWKS at http://localhost:8080/oidc/jwks. With a # custom issuer set per-credential, the plugin would refuse # to serve JWKS at all (see plugin source: Keys.java). + # Cosign keypair used by cgSign / cgVerify to sign and verify the + # OCI images pushed by the Python/Node pipelines. setup.sh generates + # the keypair to /tmp/cgjenkins-home/.secrets/ once and reuses it on + # every re-run; the JCasC `${readFileBase64:…}` resolver loads the + # key + pub bytes into Jenkins's encrypted credentials store at boot. + # After that, /tmp/cgjenkins-home/.secrets/ is only used as the seed + # source — pipelines fetch creds by ID via withCredentials. + - file: + scope: GLOBAL + id: cosign-private-key + fileName: cosign.key + description: "cosign private key (encrypted with cosign-password)" + secretBytes: "${readFileBase64:/tmp/cgjenkins-home/.secrets/cosign.key}" + - file: + scope: GLOBAL + id: cosign-public-key + fileName: cosign.pub + description: "cosign public key — used by cgVerify" + secretBytes: "${readFileBase64:/tmp/cgjenkins-home/.secrets/cosign.pub}" + - string: + scope: GLOBAL + id: cosign-password + description: "passphrase that decrypts cosign-private-key" + secret: "${COSIGN_PASSWORD}" jobs: - file: /tmp/cgjenkins-home/casc/jobs.groovy diff --git a/jenkins/jenkins/plugins.txt b/jenkins/jenkins/plugins.txt index abf8c19b..82525125 100644 --- a/jenkins/jenkins/plugins.txt +++ b/jenkins/jenkins/plugins.txt @@ -15,3 +15,8 @@ filesystem_scm # token as a string credential, which cgLogin then exchanges for a Chainguard # chainctl session. oidc-provider +# plain-credentials — provides the "Secret file" and "Secret text" credential +# types JCasC uses to register the cosign signing key (file), public key +# (file), and password (string). Pipelines pull all three through +# `withCredentials` in cgSign.groovy / cgVerify.groovy. +plain-credentials diff --git a/jenkins/setup.sh b/jenkins/setup.sh index d6eb9838..7f24d5d1 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -79,6 +79,45 @@ echo " Pulls from: ${PULL_REGISTRY}" echo " Pushes to: ${PUSH_REGISTRY}" echo +# ---- Phase 1a: ensure cosign keypair exists ----------------------------- +# Pipelines that build OCI images sign their pushed images with cosign and +# then verify the signature in the same build. The keypair lives at +# /tmp/cgjenkins-home/.secrets/ — same absolute path on host and in the +# Jenkins container — so cgSign() can spawn a sibling cosign container +# (`docker run --network host …`) that bind-mounts the same path back in. +# The keypair is generated once (cached) and reused on every re-run. + +COSIGN_DIR=/tmp/cgjenkins-home/.secrets +echo "==> Ensuring cosign keypair is present in ${COSIGN_DIR}/..." +mkdir -p "$COSIGN_DIR" +if [[ ! -f "$COSIGN_DIR/cosign.key" ]]; then + echo " Generating new cosign keypair..." + GEN_COSIGN_PASSWORD="$(openssl rand -base64 24)" + printf '%s' "$GEN_COSIGN_PASSWORD" > "$COSIGN_DIR/cosign.password" + docker run --rm \ + --user "$(id -u):$(id -g)" \ + -e "COSIGN_PASSWORD=$GEN_COSIGN_PASSWORD" \ + -v "$COSIGN_DIR:/work" \ + -w /work \ + --entrypoint=/usr/bin/cosign \ + "cgr.dev/${ORG}/cosign:latest-dev" \ + generate-key-pair + # 644 so the Jenkins container (uid 1000) and the spawned cosign container + # can both read these regardless of the host's uid. The private key stays + # encrypted with COSIGN_PASSWORD, so world-readable is fine for a local demo. + chmod 644 "$COSIGN_DIR"/cosign.key "$COSIGN_DIR"/cosign.pub "$COSIGN_DIR"/cosign.password + unset GEN_COSIGN_PASSWORD + echo " Keypair written to ${COSIGN_DIR}/cosign.{key,pub,password}" +else + echo " Reusing existing keypair in ${COSIGN_DIR}/cosign.{key,pub,password}" +fi + +# Export COSIGN_PASSWORD so docker compose forwards it into the Jenkins +# container, where JCasC interpolates it into the `cosign-password` Secret +# Text credential at boot. The key + pub files are loaded directly by JCasC +# via `${readFileBase64:…}` so they don't need to ride in env vars. +export COSIGN_PASSWORD="$(cat "$COSIGN_DIR/cosign.password")" + # ---- Phase 1: write .env first so docker compose picks up the new mode ---- echo "==> Writing mode flags to .env..." diff --git a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy index 26147759..6438fe90 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy @@ -33,6 +33,11 @@ def call() { if (harborEnabled) { if (pushRegistry.startsWith('localhost/')) { // Mode C: write Harbor admin creds for push. + // Two entries with the same auth: cosign's reference parser rejects + // bare 'localhost' (treats it as a Docker Hub path), so cgSign rewrites + // refs to 'localhost:80/...' before invoking cosign — which then looks + // up auth keyed by 'localhost:80'. Docker push uses the bare 'localhost' + // key. Keep both so both code paths find creds. sh ''' set -eu mkdir -p "$DOCKER_CONFIG" @@ -40,11 +45,12 @@ def call() { cat > "$DOCKER_CONFIG/config.json" <:sha256-.sig — no Sigstore Fulcio/Rekor calls. +// +// Cosign runs as a one-shot container with --network host so that the +// `localhost` references used in Mode C (Harbor) resolve to the host's +// ingress instead of the Jenkins controller container's own loopback. We +// cannot run cosign in-process on the controller for the same reason. For +// Modes A/B the registry is ttl.sh (public DNS), so --network host is a +// no-op there but still consistent. +// +// Auth to the destination registry: cosign reads $DOCKER_CONFIG just like +// docker push does. We mount the controller's DOCKER_CONFIG dir read-only +// into the cosign container so it inherits whatever credentials cgLogin() +// wrote earlier in the pipeline (Harbor admin creds in Mode C; nothing +// needed in A/B since signatures push anonymously to ttl.sh). +// +// Usage from a stage on `agent any`: +// +// stage('Sign') { +// agent any +// steps { cgSign(env.IMAGE) } +// } +// +// Cosign needs an immutable digest reference, not a mutable tag, so we +// resolve $IMAGE → $IMAGE@sha256: via `docker image inspect` +// first. (Pipelines push the tag-only ref; the digest is what cosign +// signs.) `--allow-http-registry` is set because Mode C's localhost +// registry is HTTP-only — see harbor/cg/helm/values.template for why. + +def call(String image) { + if (!image?.trim()) { + error('cgSign: image argument is required') + } + def org = env.CHAINGUARD_ORG ?: 'smalls.xyz' + withCredentials([ + file(credentialsId: 'cosign-private-key', variable: 'COSIGN_KEY_FILE'), + string(credentialsId: 'cosign-password', variable: 'COSIGN_PASSWORD'), + ]) { + sh """ + set -eu + DIGEST=\$(docker image inspect --format '{{index .RepoDigests 0}}' '${image}') + if [ -z "\$DIGEST" ]; then + echo "cgSign: could not resolve digest for ${image} (was it pushed?)." >&2 + exit 1 + fi + # cosign's reference parser (via go-containerregistry) doesn't accept + # bare 'localhost' as a registry hostname — it falls back to treating + # the whole ref as a Docker Hub path (index.docker.io/localhost/...). + # Adding ':80' forces the host:port split, so the parser recognizes + # localhost:80 as the registry. Other hostnames (ttl.sh, harbor.foo.bar) + # contain a '.' and parse correctly without modification. + case "\$DIGEST" in + localhost/*) DIGEST="localhost:80/\${DIGEST#localhost/}" ;; + esac + docker run --rm --network host \\ + -v "\$COSIGN_KEY_FILE:/cosign.key:ro" \\ + -v "\$DOCKER_CONFIG:/jenkins-docker:ro" \\ + -e "COSIGN_PASSWORD=\$COSIGN_PASSWORD" \\ + -e DOCKER_CONFIG=/jenkins-docker \\ + --entrypoint=/usr/bin/cosign \\ + cgr.dev/${org}/cosign:latest-dev \\ + sign --yes --allow-http-registry --key /cosign.key "\$DIGEST" + echo "cgSign: signed \$DIGEST" + """ + } +} diff --git a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy new file mode 100644 index 00000000..41bd4152 --- /dev/null +++ b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy @@ -0,0 +1,53 @@ +// vars/cgVerify.groovy +// +// Verifies an OCI image signature using the demo's public key. Pulls the +// public key from Jenkins's credentials store (registered by JCasC at boot +// from /tmp/cgjenkins-home/.secrets/cosign.pub), validates the registry +// signature against it, and fails the build if the signature is missing +// or invalid. Pair with cgSign() in the preceding stage to demonstrate +// the full sign-then-verify loop. +// +// Cosign runs in a one-shot container with --network host for the same +// reason cgSign does — see that file for the full rationale. +// +// Usage from a stage on `agent any`: +// +// stage('Verify') { +// agent any +// steps { cgVerify(env.IMAGE) } +// } +// +// Resolves the same image-by-digest as cgSign so verification targets +// the exact bytes that were signed (not whatever a mutable tag may now +// point at). + +def call(String image) { + if (!image?.trim()) { + error('cgVerify: image argument is required') + } + def org = env.CHAINGUARD_ORG ?: 'smalls.xyz' + withCredentials([ + file(credentialsId: 'cosign-public-key', variable: 'COSIGN_PUB_FILE'), + ]) { + sh """ + set -eu + DIGEST=\$(docker image inspect --format '{{index .RepoDigests 0}}' '${image}') + if [ -z "\$DIGEST" ]; then + echo "cgVerify: could not resolve digest for ${image} (was it pushed?)." >&2 + exit 1 + fi + # See cgSign.groovy for why we rewrite bare 'localhost' to 'localhost:80'. + case "\$DIGEST" in + localhost/*) DIGEST="localhost:80/\${DIGEST#localhost/}" ;; + esac + docker run --rm --network host \\ + -v "\$COSIGN_PUB_FILE:/cosign.pub:ro" \\ + -v "\$DOCKER_CONFIG:/jenkins-docker:ro" \\ + -e DOCKER_CONFIG=/jenkins-docker \\ + --entrypoint=/usr/bin/cosign \\ + cgr.dev/${org}/cosign:latest-dev \\ + verify --allow-http-registry --key /cosign.pub "\$DIGEST" >/dev/null + echo "cgVerify: signature OK for \$DIGEST" + """ + } +} diff --git a/jenkins/teardown.sh b/jenkins/teardown.sh index bccbdeea..f265eb78 100755 --- a/jenkins/teardown.sh +++ b/jenkins/teardown.sh @@ -4,6 +4,7 @@ # - Chainguard assumed identity (terraform destroy on iac/, if state present) # - Jenkins controller container + image # - JENKINS_HOME bind-mount at /tmp/cgjenkins-home (needs sudo) +# - cosign keys (under /tmp/cgjenkins-home/.secrets/, wiped with cgjenkins-home) # - .secrets/, harbor/.pull-token, IDENTITY file, terraform state files # # Leaves .env in place (so re-running setup.sh remembers your CHAINGUARD_ORG From f09292703a84b3483caf703d0bc1fe428eb14df7 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 13:04:26 -0500 Subject: [PATCH 19/47] jenkins: remove hardcoded smalls.xyz default, prompt for org + preflight image probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup.sh now prompts for CHAINGUARD_ORG on first run if .env doesn't supply one and persists the answer for re-runs. All ${CHAINGUARD_ORG:-smalls.xyz} fallbacks throughout the codebase are gone — Dockerfiles, docker-compose.yml, shared-library Groovy, refresh-digests.sh, terraform variables, and docs all require the org to be set explicitly. docker-compose now uses the ${VAR:?msg} form so unset CHAINGUARD_ORG produces a clear "run ./setup.sh to be prompted" error rather than silently building against the wrong catalog. setup.sh also adds a preflight probe before bringing anything up: parallel `docker manifest inspect` calls against every image the demo will pull (controller deps + cgImage catalog + Harbor microservices in Modes B/C). Missing images are listed with auth/ org-typo hints, and setup aborts before docker compose / terraform / helm waste time. Bypass with SKIP_PREFLIGHT=1 ./setup.sh when you know what you're doing. App READMEs that previously wrote `cgr.dev/smalls.xyz/...` now show `cgr.dev/$CHAINGUARD_ORG/...` to make the parameterization obvious. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/.env.example | 25 +++-- jenkins/Dockerfile.jenkins | 6 +- jenkins/PLAN.md | 2 +- jenkins/README.md | 20 ++-- jenkins/apps/adoptium-java8-jetty/README.md | 8 +- jenkins/apps/corretto-java17-maven/README.md | 12 +-- jenkins/apps/node22-npm-express/Dockerfile | 2 +- jenkins/apps/node22-npm-express/README.md | 4 +- jenkins/apps/node25-pnpm-express/Dockerfile | 2 +- jenkins/apps/node25-pnpm-express/README.md | 4 +- jenkins/apps/openjdk21-gradle/README.md | 8 +- jenkins/apps/python312-pip-django/Dockerfile | 2 +- jenkins/apps/python312-pip-django/README.md | 4 +- jenkins/apps/python314-uv-flask/Dockerfile | 2 +- jenkins/apps/python314-uv-flask/README.md | 6 +- jenkins/docker-compose.yml | 8 +- jenkins/harbor/README.md | 2 +- jenkins/harbor/deploy.sh | 2 +- jenkins/iac/README.md | 2 +- jenkins/iac/main.tf | 4 +- jenkins/iac/variables.tf | 3 +- jenkins/setup.sh | 99 ++++++++++++++++++- jenkins/shared-libraries/cg-images/README.md | 2 +- .../cg-images/refresh-digests.sh | 6 +- .../cg-images/vars/cgSign.groovy | 3 +- .../cg-images/vars/cgVerify.groovy | 3 +- jenkins/teardown.sh | 11 ++- 27 files changed, 185 insertions(+), 67 deletions(-) diff --git a/jenkins/.env.example b/jenkins/.env.example index 3758d5aa..06c944d5 100644 --- a/jenkins/.env.example +++ b/jenkins/.env.example @@ -1,13 +1,18 @@ # Chainguard org under cgr.dev/ to pull images from. docker-compose passes -# it to the controller's image build and runtime env; pipelines reference it -# via env.CHAINGUARD_ORG; per-app Dockerfiles use it as a build ARG; setup.sh -# uses it when creating the Chainguard assumed identity via Terraform. +# it to the controller's image build and runtime env; pipelines reference +# it via env.CHAINGUARD_ORG; per-app Dockerfiles use it as a build ARG; +# setup.sh uses it when creating the Chainguard assumed identity via +# Terraform. +# +# Leave commented out (or set to empty) and setup.sh will prompt for it +# on first run, then persist your answer here. Re-runs reuse the persisted +# value without prompting. # # Examples: -# CHAINGUARD_ORG=chainguard # the public Chainguard catalog -# CHAINGUARD_ORG=smalls.xyz # the demo's original org -# CHAINGUARD_ORG=your-org # any org you have chainctl access to -CHAINGUARD_ORG=smalls.xyz +# CHAINGUARD_ORG=chainguard # the public Chainguard catalog +# CHAINGUARD_ORG=your-org.example.com +# +# CHAINGUARD_ORG= # The remaining variables in this file are written by setup.sh based on # the answers you give to its three prompts. You normally don't edit them @@ -16,16 +21,16 @@ CHAINGUARD_ORG=smalls.xyz # Where pipelines pull Chainguard images from. Either: # cgr.dev/ (Mode A — direct cgr.dev) # localhost/cgr-proxy/ (Modes B/C — Harbor proxy cache) -PULL_REGISTRY=cgr.dev/smalls.xyz +# PULL_REGISTRY= # Where the Python/Node OCI-image pipelines push their built images. Either: # ttl.sh/ (Modes A/B default — anonymous, 24h TTL) # localhost/library (Mode C — Harbor) -PUSH_REGISTRY=ttl.sh/smalls +# PUSH_REGISTRY= # Whether Harbor is the active pull-through cache. cgLogin reads this and # skips the OIDC chainctl exchange when true (Harbor pulls are anonymous). -HARBOR_ENABLED=false +# HARBOR_ENABLED=false # Note: the Chainguard assumed identity UIDP (Mode A) is NOT stored here. # setup.sh writes it to shared-libraries/cg-images/IDENTITY (filesystem-SCM diff --git a/jenkins/Dockerfile.jenkins b/jenkins/Dockerfile.jenkins index a5018a7e..bc4eb580 100644 --- a/jenkins/Dockerfile.jenkins +++ b/jenkins/Dockerfile.jenkins @@ -3,8 +3,10 @@ # 2. plugins stage: use the -dev jenkins variant (has shell) to run jenkins-plugin-cli # 3. final stage: ship on the standard jenkins image with plugins baked in -# Configurable via build-arg from docker-compose; see jenkins/.env.example. -ARG CHAINGUARD_ORG=smalls.xyz +# Required build-arg, supplied by docker-compose from .env. No default — +# CHAINGUARD_ORG must be set explicitly so that we don't accidentally build +# against the wrong catalog. setup.sh prompts for this on first run. +ARG CHAINGUARD_ORG ARG CHAINGUARD_ORG FROM cgr.dev/${CHAINGUARD_ORG}/docker-cli:29 AS docker diff --git a/jenkins/PLAN.md b/jenkins/PLAN.md index f5cb6d49..19b6fd33 100644 --- a/jenkins/PLAN.md +++ b/jenkins/PLAN.md @@ -1,6 +1,6 @@ In this directory we will implement a simple Jenkins server demo with pipeline jobs to build applications on various languages (and various versions of them) and build tools. -All images should be Chainguard sourced and pulled from the cgr.dev/smalls.xyz repository. +All images should be Chainguard sourced and pulled from cgr.dev/$CHAINGUARD_ORG (configurable per-deployment; setup.sh prompts and persists to .env). Setup scripts should be provided to quickly stand up the Jenkins environment for future demonstrations. diff --git a/jenkins/README.md b/jenkins/README.md index 3cbc1e07..939b9082 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -2,7 +2,7 @@ A self-contained Jenkins server running on Chainguard images, with seven pipeline jobs that build and test sample applications across Java, Python, and Node — using Chainguard `*-dev` build images and Chainguard runtime images throughout. -All Jenkins infrastructure and all build/test/runtime images come from `cgr.dev/` (default `smalls.xyz` — see [Configuration](#configuration)). +All Jenkins infrastructure and all build/test/runtime images come from `cgr.dev/`. Pick your org once via [`setup.sh`](setup.sh) (it prompts and persists to `.env`) — see [Configuration](#configuration). ## Sample applications @@ -60,20 +60,20 @@ chainctl auth status ## Configuration -The demo defaults to pulling Chainguard images from `cgr.dev/smalls.xyz/`. To use a different org (e.g. the public `chainguard` catalog or your own org): +The demo pulls Chainguard images from whatever Chainguard org you configure. There is **no built-in default** — `setup.sh` prompts on first run and persists your answer in `.env` for re-runs: ```sh -cp .env.example .env -# edit CHAINGUARD_ORG in .env +./setup.sh +# ==> No Chainguard org configured. +# Examples: 'chainguard' (public catalog) or 'your-org.example.com'. +# Enter your Chainguard org: chainguard ``` -`.env` is loaded automatically by `docker-compose`, propagated to the controller container as an env var, and consumed by Jenkinsfiles (via `env.CHAINGUARD_ORG`), the controller Dockerfile, and per-app Dockerfiles (via build ARG). `setup.sh` also sources `.env`, so the pull token is generated against the configured org. +`.env` is loaded automatically by `docker-compose`, propagated to the controller container as an env var, and consumed by Jenkinsfiles (via `env.CHAINGUARD_ORG`), the controller Dockerfile, and per-app Dockerfiles (via build ARG). -After changing the org, re-run the bootstrap and rebuild — the Chainguard assumed identity is org-scoped, so the existing one becomes invalid for the new org: -```sh -docker compose up -d --build -./setup.sh -``` +To switch orgs, edit `CHAINGUARD_ORG` in `.env` (or unset it to be re-prompted) and re-run `./setup.sh`. The Chainguard assumed identity is org-scoped, so it'll be torn down and recreated against the new org. + +Before bringing the stack up, `setup.sh` runs a **preflight image-accessibility probe** against `cgr.dev//` for every image the demo will pull (controller, build/test/runtime, Harbor microservices in Modes B/C, plus cosign/crane/chainctl). If any image isn't accessible, setup aborts with the offending list and a hint about the likely cause (auth, missing access grant, wrong org). Bypass with `SKIP_PREFLIGHT=1 ./setup.sh` if you need to. ## Quick start diff --git a/jenkins/apps/adoptium-java8-jetty/README.md b/jenkins/apps/adoptium-java8-jetty/README.md index 1af56ae4..fc4ed2ee 100644 --- a/jenkins/apps/adoptium-java8-jetty/README.md +++ b/jenkins/apps/adoptium-java8-jetty/README.md @@ -2,16 +2,16 @@ Hello-world Jetty/JSP web app, built with Maven on Adoptium JDK 8 and packaged as a self-executing WAR. -> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. +> `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. ## Pipeline images | Stage | Image | |-------|-------| -| Build | `cgr.dev/smalls.xyz/maven:3-jdk8-dev` | -| Test | `cgr.dev/smalls.xyz/adoptium-jre:adoptium-openjdk-8-dev` | +| Build | `cgr.dev/$CHAINGUARD_ORG/maven:3-jdk8-dev` | +| Test | `cgr.dev/$CHAINGUARD_ORG/adoptium-jre:adoptium-openjdk-8-dev` | -The intended runtime / deploy target is `cgr.dev/smalls.xyz/adoptium-jre:adoptium-openjdk-8` (shell-less). The `-dev` variant is used in the Test stage only because Jenkins' `docker { image ... }` agent invokes `sh` steps, which require a shell. +The intended runtime / deploy target is `cgr.dev/$CHAINGUARD_ORG/adoptium-jre:adoptium-openjdk-8` (shell-less). The `-dev` variant is used in the Test stage only because Jenkins' `docker { image ... }` agent invokes `sh` steps, which require a shell. ## Artifact diff --git a/jenkins/apps/corretto-java17-maven/README.md b/jenkins/apps/corretto-java17-maven/README.md index 9387baef..9b6c71eb 100644 --- a/jenkins/apps/corretto-java17-maven/README.md +++ b/jenkins/apps/corretto-java17-maven/README.md @@ -2,16 +2,16 @@ Hello-world Spring Boot console app, built with Maven on Amazon Corretto JDK 17. -> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. +> `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. ## Pipeline images | Stage | Image | |-------|-------| -| Build | `cgr.dev/smalls.xyz/maven:3-jdk17-dev` | -| Test | `cgr.dev/smalls.xyz/amazon-corretto-jre:17-dev` | +| Build | `cgr.dev/$CHAINGUARD_ORG/maven:3-jdk17-dev` | +| Test | `cgr.dev/$CHAINGUARD_ORG/amazon-corretto-jre:17-dev` | -The intended runtime / deploy target is `cgr.dev/smalls.xyz/amazon-corretto-jre:17` (shell-less). The `-dev` variant is used in the Test stage only because Jenkins' `docker { image ... }` agent runs `sh` steps that require a shell. +The intended runtime / deploy target is `cgr.dev/$CHAINGUARD_ORG/amazon-corretto-jre:17` (shell-less). The `-dev` variant is used in the Test stage only because Jenkins' `docker { image ... }` agent runs `sh` steps that require a shell. ## Artifact @@ -22,9 +22,9 @@ The Spring Boot fat JAR (`target/app.jar`) is archived to Jenkins. Download it f To produce and run the JAR outside Jenkins: ```sh -docker run --rm -v "$PWD":/work -w /work cgr.dev/smalls.xyz/maven:3-jdk17-dev \ +docker run --rm -v "$PWD":/work -w /work cgr.dev/$CHAINGUARD_ORG/maven:3-jdk17-dev \ mvn -B -ntp clean package -DskipTests -docker run --rm -v "$PWD":/work -w /work cgr.dev/smalls.xyz/amazon-corretto-jre:17-dev \ +docker run --rm -v "$PWD":/work -w /work cgr.dev/$CHAINGUARD_ORG/amazon-corretto-jre:17-dev \ java -jar target/app.jar ``` diff --git a/jenkins/apps/node22-npm-express/Dockerfile b/jenkins/apps/node22-npm-express/Dockerfile index 42b29057..08538f2f 100644 --- a/jenkins/apps/node22-npm-express/Dockerfile +++ b/jenkins/apps/node22-npm-express/Dockerfile @@ -2,7 +2,7 @@ # Stage 1: install npm dependencies on the dev image, which ships npm. # Stage 2 then copies the populated node_modules + app source into the # shell-less runtime image (node:22), which has only the node binary. -ARG CHAINGUARD_ORG=smalls.xyz +ARG CHAINGUARD_ORG FROM cgr.dev/${CHAINGUARD_ORG}/node:22-dev AS build WORKDIR /app diff --git a/jenkins/apps/node22-npm-express/README.md b/jenkins/apps/node22-npm-express/README.md index 13d820cd..25f01aca 100644 --- a/jenkins/apps/node22-npm-express/README.md +++ b/jenkins/apps/node22-npm-express/README.md @@ -2,7 +2,7 @@ Hello-world Express web app on Chainguard's Node 22, with `npm` as the package manager. Pipeline artifact is an OCI image pushed to `ttl.sh/smalls-nodetest:22`. -> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. +> `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. > Note: PLAN.md originally called for Node 21, but that line is EOL and not in the catalog. We use Node 22 LTS (the natural successor) instead. @@ -10,7 +10,7 @@ Hello-world Express web app on Chainguard's Node 22, with `npm` as the package m | Stage | Image | |-------------|-------| -| Build deps | `cgr.dev/smalls.xyz/node:22-dev` (ships `npm`) | +| Build deps | `cgr.dev/$CHAINGUARD_ORG/node:22-dev` (ships `npm`) | | Image build | host docker daemon (multi-stage build) | | Test | runs the just-built image | | Push | `ttl.sh/smalls-nodetest:22` | diff --git a/jenkins/apps/node25-pnpm-express/Dockerfile b/jenkins/apps/node25-pnpm-express/Dockerfile index e7f6d1b6..0eb04925 100644 --- a/jenkins/apps/node25-pnpm-express/Dockerfile +++ b/jenkins/apps/node25-pnpm-express/Dockerfile @@ -2,7 +2,7 @@ # Stage 1: install production deps with pnpm on the dev image. Stage 2 copies # the populated node_modules into the slim runtime variant — node:25-slim has # only the node binary (no shell, no npm/pnpm). -ARG CHAINGUARD_ORG=smalls.xyz +ARG CHAINGUARD_ORG FROM cgr.dev/${CHAINGUARD_ORG}/node:25-dev AS build WORKDIR /app diff --git a/jenkins/apps/node25-pnpm-express/README.md b/jenkins/apps/node25-pnpm-express/README.md index de98a322..34860270 100644 --- a/jenkins/apps/node25-pnpm-express/README.md +++ b/jenkins/apps/node25-pnpm-express/README.md @@ -2,13 +2,13 @@ Hello-world Express web app on Chainguard's Node 25, with `pnpm` as the package manager and `node:25-slim` as the runtime variant. Pipeline artifact is an OCI image pushed to `ttl.sh/smalls-nodetest:25`. -> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. +> `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. ## Pipeline images | Stage | Image | |-------------|-------| -| Build deps | `cgr.dev/smalls.xyz/node:25-dev` (ships pnpm 10.33) | +| Build deps | `cgr.dev/$CHAINGUARD_ORG/node:25-dev` (ships pnpm 10.33) | | Image build | host docker daemon (multi-stage build) | | Test | runs the just-built image | | Push | `ttl.sh/smalls-nodetest:25` | diff --git a/jenkins/apps/openjdk21-gradle/README.md b/jenkins/apps/openjdk21-gradle/README.md index ee08c83c..8932db5c 100644 --- a/jenkins/apps/openjdk21-gradle/README.md +++ b/jenkins/apps/openjdk21-gradle/README.md @@ -2,16 +2,16 @@ Hello-world standalone runnable JAR built with Gradle on Chainguard's OpenJDK 21. -> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. +> `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. ## Pipeline images | Stage | Image | |-------|-------| -| Build | `cgr.dev/smalls.xyz/jdk:openjdk-21-dev` | -| Test | `cgr.dev/smalls.xyz/jre:openjdk-21-dev` | +| Build | `cgr.dev/$CHAINGUARD_ORG/jdk:openjdk-21-dev` | +| Test | `cgr.dev/$CHAINGUARD_ORG/jre:openjdk-21-dev` | -The intended runtime / deploy target is `cgr.dev/smalls.xyz/jre:openjdk-21` (shell-less). The `-dev` variant is used in the Test stage only because Jenkins' `docker { image ... }` agent invokes `sh` steps, which require a shell. +The intended runtime / deploy target is `cgr.dev/$CHAINGUARD_ORG/jre:openjdk-21` (shell-less). The `-dev` variant is used in the Test stage only because Jenkins' `docker { image ... }` agent invokes `sh` steps, which require a shell. ## Gradle setup diff --git a/jenkins/apps/python312-pip-django/Dockerfile b/jenkins/apps/python312-pip-django/Dockerfile index 7a51c2b4..1cc863d0 100644 --- a/jenkins/apps/python312-pip-django/Dockerfile +++ b/jenkins/apps/python312-pip-django/Dockerfile @@ -4,7 +4,7 @@ # install. Stage 2 then copies the populated site-packages into the shell-less # python:3.12 runtime image — same Python minor version on both sides keeps # the packages compatible. -ARG CHAINGUARD_ORG=smalls.xyz +ARG CHAINGUARD_ORG FROM cgr.dev/${CHAINGUARD_ORG}/python:3.12-dev AS build USER 0 diff --git a/jenkins/apps/python312-pip-django/README.md b/jenkins/apps/python312-pip-django/README.md index f4258e35..0b32ae38 100644 --- a/jenkins/apps/python312-pip-django/README.md +++ b/jenkins/apps/python312-pip-django/README.md @@ -2,13 +2,13 @@ Hello-world Django site on Chainguard's Python 3.12, with `pip` as the package manager. The pipeline's archived artifact is an OCI image pushed to `ttl.sh/smalls-pytest:3-12`. -> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. +> `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. ## Pipeline images | Stage | Image | |-------------|-------| -| Build deps | `cgr.dev/smalls.xyz/python:3.12-dev` | +| Build deps | `cgr.dev/$CHAINGUARD_ORG/python:3.12-dev` | | Image build | host docker daemon (multi-stage build) | | Test | runs the just-built image | | Push | `ttl.sh/smalls-pytest:3-12` | diff --git a/jenkins/apps/python314-uv-flask/Dockerfile b/jenkins/apps/python314-uv-flask/Dockerfile index fa40e4df..ad794b89 100644 --- a/jenkins/apps/python314-uv-flask/Dockerfile +++ b/jenkins/apps/python314-uv-flask/Dockerfile @@ -3,7 +3,7 @@ # ships uv pre-installed). Stage 2 then copies the populated site-packages # directory into the shell-less runtime python image — same Python minor # version on both sides keeps the packages compatible. -ARG CHAINGUARD_ORG=smalls.xyz +ARG CHAINGUARD_ORG FROM cgr.dev/${CHAINGUARD_ORG}/python:3.14-dev AS build USER 0 diff --git a/jenkins/apps/python314-uv-flask/README.md b/jenkins/apps/python314-uv-flask/README.md index 329ab346..e09f54ca 100644 --- a/jenkins/apps/python314-uv-flask/README.md +++ b/jenkins/apps/python314-uv-flask/README.md @@ -2,18 +2,18 @@ Hello-world Flask web app on Chainguard's Python 3.14, with `uv` as the package manager. The pipeline's archived artifact is an OCI image pushed to `ttl.sh/smalls-pytest:3-14` — not a file checked into Jenkins' archive store. -> Image references below show the default org (`smalls.xyz`); see the demo's top-level [README](../../README.md#configuration) for how to switch. +> `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. ## Pipeline images | Stage | Image | |-------------|-------| -| Build deps | `cgr.dev/smalls.xyz/python:3.14-dev` (ships `uv` pre-installed) | +| Build deps | `cgr.dev/$CHAINGUARD_ORG/python:3.14-dev` (ships `uv` pre-installed) | | Image build | host docker daemon (multi-stage build) | | Test | runs the just-built image | | Push | `ttl.sh/smalls-pytest:3-14` | -The runtime image (final stage of the Dockerfile) is the shell-less `cgr.dev/smalls.xyz/python:3.14`. Site-packages from the build stage are copied across — same Python minor version on both sides keeps the packages compatible. +The runtime image (final stage of the Dockerfile) is the shell-less `cgr.dev/$CHAINGUARD_ORG/python:3.14`. Site-packages from the build stage are copied across — same Python minor version on both sides keeps the packages compatible. ## Smoke test diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml index 4a0285b6..df81d9b6 100644 --- a/jenkins/docker-compose.yml +++ b/jenkins/docker-compose.yml @@ -16,16 +16,18 @@ services: context: . dockerfile: Dockerfile.jenkins args: - CHAINGUARD_ORG: ${CHAINGUARD_ORG:-smalls.xyz} + # `?` makes compose fail with a clear error if CHAINGUARD_ORG isn't set + # in .env. Run setup.sh once to be prompted for it and have it persisted. + CHAINGUARD_ORG: ${CHAINGUARD_ORG:?CHAINGUARD_ORG must be set in .env (run ./setup.sh to be prompted)} environment: # Surface the configured org to pipelines (Jenkinsfiles read env.CHAINGUARD_ORG) # and to JCasC (jenkins.yaml interpolates ${CHAINGUARD_ORG}). - CHAINGUARD_ORG: ${CHAINGUARD_ORG:-smalls.xyz} + CHAINGUARD_ORG: ${CHAINGUARD_ORG:?CHAINGUARD_ORG must be set in .env (run ./setup.sh to be prompted)} # Registry routing — set by setup.sh based on the user's mode choice: # PULL_REGISTRY cgr.dev/ (Mode A) or localhost/cgr-proxy/ (Modes B/C) # PUSH_REGISTRY ttl.sh/ (Modes A/B) or localhost/library (Mode C) # HARBOR_ENABLED true|false (drives whether the cgLogin OIDC dance runs) - PULL_REGISTRY: ${PULL_REGISTRY:-cgr.dev/${CHAINGUARD_ORG:-smalls.xyz}} + PULL_REGISTRY: ${PULL_REGISTRY:-cgr.dev/${CHAINGUARD_ORG}} PUSH_REGISTRY: ${PUSH_REGISTRY:-ttl.sh/smalls} HARBOR_ENABLED: ${HARBOR_ENABLED:-false} # Cosign signing-key passphrase. setup.sh exports this from diff --git a/jenkins/harbor/README.md b/jenkins/harbor/README.md index 9ae6640e..8571b01e 100644 --- a/jenkins/harbor/README.md +++ b/jenkins/harbor/README.md @@ -49,7 +49,7 @@ Five Harbor microservices run in the `harbor` namespace plus ingress-nginx in `i The demo's top-level `setup.sh` calls `deploy.sh` automatically when you opt into Harbor mode. To run it by hand: ```sh -export CHAINGUARD_ORG=smalls.xyz +export CHAINGUARD_ORG=your-org-here # whichever org owns the catalog export PULL_USER=... # chainctl auth pull-token create --parent=$CHAINGUARD_ORG export PULL_PASS=... ./deploy.sh diff --git a/jenkins/harbor/deploy.sh b/jenkins/harbor/deploy.sh index 54014738..92c63b8c 100755 --- a/jenkins/harbor/deploy.sh +++ b/jenkins/harbor/deploy.sh @@ -6,7 +6,7 @@ # of interactive prompts so setup.sh can call it non-interactively. # # Required env vars: -# CHAINGUARD_ORG Chainguard org to proxy (e.g. smalls.xyz) +# CHAINGUARD_ORG Chainguard org to proxy (e.g. 'chainguard' or 'your-org.example.com') # PULL_USER Pull-token username (Harbor uses this to talk to cgr.dev) # PULL_PASS Pull-token password # diff --git a/jenkins/iac/README.md b/jenkins/iac/README.md index c1b9d45b..78d12fcb 100644 --- a/jenkins/iac/README.md +++ b/jenkins/iac/README.md @@ -23,7 +23,7 @@ Manual `terraform apply` works too (useful for debugging or org changes), but yo | Name | Default | What it controls | |------|---------|------------------| -| `chainguard_group_name` | `smalls.xyz` | The parent group the identity lives under and the rolebinding's scope. | +| `chainguard_group_name` | *(required, no default)* | The parent group the identity lives under and the rolebinding's scope. setup.sh passes this via `-var` from `.env`'s `CHAINGUARD_ORG`. | | `jenkins_issuer_url` | `http://localhost:8080/oidc` | Must exactly match the `iss` claim Jenkins puts on its tokens. The `oidc-provider` plugin uses `/oidc` by default. | | `jenkins_subject` | `jenkins-cgimages-puller` | Must exactly match the `sub` claim. Configured on the JCasC OIDC credential. | diff --git a/jenkins/iac/main.tf b/jenkins/iac/main.tf index f09333d9..dccc057b 100644 --- a/jenkins/iac/main.tf +++ b/jenkins/iac/main.tf @@ -9,7 +9,9 @@ terraform { provider "chainguard" {} -# Resolve the parent group (the same group that owns the smalls.xyz catalog). +# Resolve the parent group — the Chainguard org whose catalog this demo +# pulls from. The name comes in via -var from setup.sh (driven by .env's +# CHAINGUARD_ORG). data "chainguard_group" "parent" { name = var.chainguard_group_name } diff --git a/jenkins/iac/variables.tf b/jenkins/iac/variables.tf index 62cae880..11a48bb6 100644 --- a/jenkins/iac/variables.tf +++ b/jenkins/iac/variables.tf @@ -1,7 +1,6 @@ variable "chainguard_group_name" { - description = "Chainguard parent group name (e.g. smalls.xyz). Must match CHAINGUARD_ORG used by the rest of the demo." + description = "Chainguard parent group name (e.g. 'chainguard' or 'your-org.example.com'). Must match CHAINGUARD_ORG used by the rest of the demo. setup.sh always passes this via -var on the command line." type = string - default = "smalls.xyz" } variable "jenkins_issuer_url" { diff --git a/jenkins/setup.sh b/jenkins/setup.sh index 7f24d5d1..e741f596 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -29,7 +29,6 @@ if [[ -f .env ]]; then set +a fi -ORG="${CHAINGUARD_ORG:-smalls.xyz}" JENKINS_URL="${JENKINS_URL:-http://localhost:8080}" JENKINS_OIDC_ISSUER="${JENKINS_OIDC_ISSUER:-https://localhost:8080/oidc}" @@ -42,6 +41,18 @@ prompt_yn() { [[ "$ans" =~ ^[Yy] ]] } +# Prompt for the Chainguard org if .env didn't supply one. The answer gets +# persisted to .env in Phase 1 below, so subsequent re-runs go straight +# through without prompting. +if [[ -z "${CHAINGUARD_ORG:-}" ]]; then + echo "==> No Chainguard org configured." + echo " Examples: 'chainguard' (public catalog) or 'your-org.example.com'." + while [[ -z "${CHAINGUARD_ORG:-}" ]]; do + read -rp " Enter your Chainguard org: " CHAINGUARD_ORG + done + echo +fi +ORG="$CHAINGUARD_ORG" echo "==> Chainguard org: ${ORG}" echo @@ -79,6 +90,92 @@ echo " Pulls from: ${PULL_REGISTRY}" echo " Pushes to: ${PUSH_REGISTRY}" echo +# ---- Phase 0: preflight image accessibility check ------------------------ +# Probe every image the demo will pull from cgr.dev/$ORG/ before spinning +# anything up. Catches misconfigured org names, missing access grants, and +# stale chainctl sessions early — with a clear list of what's missing — +# instead of letting docker build / kubectl apply fail several minutes in +# with cryptic auth errors. Set SKIP_PREFLIGHT=1 to bypass. + +CORE_IMAGES=( + # Controller build dependencies (Dockerfile.jenkins multi-stage sources). + jenkins:2-lts-jdk21-dev + docker-cli:29 + chainctl:latest-dev + # One-shot tools spawned by pipelines or shared-library helpers. + cosign:latest-dev + crane:latest-dev + # cgImage catalog tags. Tags only — manifest probes resolve the + # current-tip digest, which is what cgImage's pinned digests track too. + maven:3-jdk17-dev + amazon-corretto-jre:17-dev + maven:3-jdk8-dev + adoptium-jre:adoptium-openjdk-8-dev + jdk:openjdk-21-dev + jre:openjdk-21-dev + python:3.14-dev + python:3.14 + python:3.12-dev + python:3.12 + node:22-dev + node:22 + node:25-dev + node:25-slim +) + +# Harbor microservice + ingress images, only needed in Modes B/C. +HARBOR_IMAGES=( + harbor-portal:latest + harbor-core:latest + harbor-jobservice:latest + harbor-registry:latest + harbor-trivy-adapter:latest + harbor-db:latest + ingress-nginx-controller:latest + kube-webhook-certgen:latest +) + +if [[ "${SKIP_PREFLIGHT:-0}" != "1" ]]; then + IMAGES_TO_CHECK=("${CORE_IMAGES[@]}") + if [[ "$HARBOR_ENABLED" == "true" ]]; then + IMAGES_TO_CHECK+=("${HARBOR_IMAGES[@]}") + fi + + echo "==> Preflight: probing ${#IMAGES_TO_CHECK[@]} images at cgr.dev/${ORG}/..." + # Parallel HEAD-style probes via `docker manifest inspect`. Each subshell + # echoes only the tag of any image that fails — line-atomic on Linux for + # short writes, so we can collect results just by reading stdout. + MISSING=$(printf '%s\n' "${IMAGES_TO_CHECK[@]}" | xargs -P 8 -I {} sh -c ' + docker manifest inspect "cgr.dev/'"$ORG"'/{}" >/dev/null 2>&1 || echo "{}" + ' || true) + + if [[ -n "$MISSING" ]]; then + echo + echo "ERROR: The following images are not accessible at cgr.dev/${ORG}/:" >&2 + while IFS= read -r tag; do + printf ' cgr.dev/%s/%s\n' "$ORG" "$tag" >&2 + done <<< "$MISSING" + echo >&2 + echo "Possible causes:" >&2 + echo " - You're not authenticated to cgr.dev. Try:" >&2 + echo " chainctl auth login" >&2 + echo " chainctl auth configure-docker" >&2 + echo " - Your org doesn't have access to these images yet — request" >&2 + echo " them at https://console.chainguard.dev/ or via Chainguard support." >&2 + echo " - CHAINGUARD_ORG is wrong (currently '${ORG}'). Edit .env or unset" >&2 + echo " the variable to be re-prompted." >&2 + echo >&2 + echo "Bypass with SKIP_PREFLIGHT=1 ./setup.sh if you know what you're doing." >&2 + echo "Aborting setup." >&2 + exit 1 + fi + echo " All ${#IMAGES_TO_CHECK[@]} images accessible." + echo +else + echo "==> Preflight: SKIP_PREFLIGHT=1 set, skipping image accessibility check." + echo +fi + # ---- Phase 1a: ensure cosign keypair exists ----------------------------- # Pipelines that build OCI images sign their pushed images with cosign and # then verify the signature in the same build. The keypair lives at diff --git a/jenkins/shared-libraries/cg-images/README.md b/jenkins/shared-libraries/cg-images/README.md index a4aaabf7..2acc35d1 100644 --- a/jenkins/shared-libraries/cg-images/README.md +++ b/jenkins/shared-libraries/cg-images/README.md @@ -63,5 +63,5 @@ The `vars/` subdirectory is the standard Jenkins shared-library convention for " - **Edits to `cgImage.groovy` (or `cgLogin.groovy`) apply on the next pipeline run, no Jenkins restart needed.** This is because the retriever is `legacySCM` → `FSSCM` (filesystem_scm plugin) pointed at the bind-mounted source dir, **with `clone: true`** (forces a fresh checkout per build). Without `clone: true` filesystem_scm caches the workspace and edits don't propagate. The shared-library version label (`master` in our config) is a placeholder here; for filesystem sources it does not gate caching the way Git refspecs do. If we ever switched the retriever to a real Git source, the cache key becomes the commit hash and `clone: true` would be costly — drop it then. - **A Groovy syntax error in `cgImage.groovy` fails every pipeline that uses it, immediately, with no graceful fallback.** The library is `implicit: true`, so all pipelines load it whether they call `cgImage(...)` or not. Worth a quick `groovyc` lint or trial run after substantive edits. - **All pipelines see the same version of the library at any moment.** To test a change against a single pipeline without affecting the others, flip `implicit` to `false` in JCasC, restart Jenkins, then add an explicit `@Library('cgImages@') _` annotation to the pipeline you want to opt into a different version. (For the filesystem-SCM case, "branch" is just any value — there's no real version control. The annotation is mostly meaningful when the source is Git.) -- **Digest pins are org-specific.** `cgr.dev/smalls.xyz/maven:3-jdk17-dev` and `cgr.dev/chainguard/maven:3-jdk17-dev` typically resolve to the same content (Chainguard mirrors are byte-identical for shared images), but that's not guaranteed. If you switch `CHAINGUARD_ORG`, re-run `refresh-digests.sh` to verify and re-pin against the new org. A wrong digest fails the pull at build time with a clear error message. +- **Digest pins are org-specific.** `cgr.dev//maven:3-jdk17-dev` and `cgr.dev//maven:3-jdk17-dev` typically resolve to the same content (Chainguard mirrors are byte-identical for shared images), but that's not guaranteed. If you switch `CHAINGUARD_ORG`, re-run `refresh-digests.sh` to verify and re-pin against the new org. A wrong digest fails the pull at build time with a clear error message. - **Digest pins are also frozen in time.** Chainguard rebuilds these images regularly with the latest CVE fixes. Pinning gives you reproducibility but it does not auto-update — for the demo this is fine, but for a production setup you'd want a higher-cadence refresh (e.g. via [`digestabotctl`](../../../digestabotctl/) elsewhere in this repo, Renovate, or a scheduled CI job that re-runs `refresh-digests.sh`). diff --git a/jenkins/shared-libraries/cg-images/refresh-digests.sh b/jenkins/shared-libraries/cg-images/refresh-digests.sh index e687f05d..49a1636c 100755 --- a/jenkins/shared-libraries/cg-images/refresh-digests.sh +++ b/jenkins/shared-libraries/cg-images/refresh-digests.sh @@ -17,7 +17,11 @@ if [[ -z "${CHAINGUARD_ORG:-}" && -f ../../.env ]]; then source ../../.env set +a fi -ORG="${CHAINGUARD_ORG:-smalls.xyz}" +if [[ -z "${CHAINGUARD_ORG:-}" ]]; then + echo "ERROR: CHAINGUARD_ORG must be set (in env or in ../../.env)." >&2 + exit 1 +fi +ORG="$CHAINGUARD_ORG" CATALOG=vars/cgImage.groovy echo "Refreshing digests in ${CATALOG} against cgr.dev/${ORG}/..." diff --git a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy index 42816711..617b93bb 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy @@ -39,7 +39,8 @@ def call(String image) { if (!image?.trim()) { error('cgSign: image argument is required') } - def org = env.CHAINGUARD_ORG ?: 'smalls.xyz' + def org = env.CHAINGUARD_ORG + if (!org) error('cgSign: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (jenkins/casc/jenkins.yaml). Re-run setup.sh.') withCredentials([ file(credentialsId: 'cosign-private-key', variable: 'COSIGN_KEY_FILE'), string(credentialsId: 'cosign-password', variable: 'COSIGN_PASSWORD'), diff --git a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy index 41bd4152..da983eef 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy @@ -25,7 +25,8 @@ def call(String image) { if (!image?.trim()) { error('cgVerify: image argument is required') } - def org = env.CHAINGUARD_ORG ?: 'smalls.xyz' + def org = env.CHAINGUARD_ORG + if (!org) error('cgVerify: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (jenkins/casc/jenkins.yaml). Re-run setup.sh.') withCredentials([ file(credentialsId: 'cosign-public-key', variable: 'COSIGN_PUB_FILE'), ]) { diff --git a/jenkins/teardown.sh b/jenkins/teardown.sh index f265eb78..200ea0e6 100755 --- a/jenkins/teardown.sh +++ b/jenkins/teardown.sh @@ -49,9 +49,14 @@ fi echo "==> 2/5 Releasing Chainguard assumed identity (if Terraform state present)..." if [[ -f iac/terraform.tfstate ]]; then - ( cd iac && terraform destroy -auto-approve \ - -var="chainguard_group_name=${CHAINGUARD_ORG:-smalls.xyz}" \ - -var="jenkins_issuer_url=${JENKINS_OIDC_ISSUER:-https://localhost:8080/oidc}" || true ) + if [[ -z "${CHAINGUARD_ORG:-}" ]]; then + echo " SKIPPING: CHAINGUARD_ORG not set in .env, can't run terraform destroy." + echo " The identity will linger; clean it up manually with chainctl iam identities delete." + else + ( cd iac && terraform destroy -auto-approve \ + -var="chainguard_group_name=${CHAINGUARD_ORG}" \ + -var="jenkins_issuer_url=${JENKINS_OIDC_ISSUER:-https://localhost:8080/oidc}" || true ) + fi fi echo "==> 3/5 Stopping Jenkins (docker compose down)..." From b25190e633bc0132e578f941400bce95f5dd2956 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 13:24:42 -0500 Subject: [PATCH 20/47] =?UTF-8?q?jenkins:=20preflight=20prints=20every=20i?= =?UTF-8?q?mage=20with=20green=20=E2=9C=93=20/=20red=20=E2=9C=97=20markers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch the preflight from "log only the missing ones" to a per-image status list. Parallel probes still run via xargs -P 8, but results land in a tempfile and are read back in input-array order so the printed list matches the curated CORE_IMAGES + HARBOR_IMAGES sequence rather than whatever order the parallel probes happened to finish. Each line gets a colored marker — green ✓ for accessible, red ✗ for missing — and the abort path stays the same: any failure prints the auth/org-typo hint and exits non-zero. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/setup.sh | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/jenkins/setup.sh b/jenkins/setup.sh index e741f596..3305036a 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -143,19 +143,38 @@ if [[ "${SKIP_PREFLIGHT:-0}" != "1" ]]; then echo "==> Preflight: probing ${#IMAGES_TO_CHECK[@]} images at cgr.dev/${ORG}/..." # Parallel HEAD-style probes via `docker manifest inspect`. Each subshell - # echoes only the tag of any image that fails — line-atomic on Linux for - # short writes, so we can collect results just by reading stdout. - MISSING=$(printf '%s\n' "${IMAGES_TO_CHECK[@]}" | xargs -P 8 -I {} sh -c ' - docker manifest inspect "cgr.dev/'"$ORG"'/{}" >/dev/null 2>&1 || echo "{}" - ' || true) + # writes one line per image (`OK ` or `FAIL `) — line-atomic on + # Linux for short writes — to a tempfile that we then iterate in INPUT + # order so the printed list matches the array above. + PREFLIGHT_RESULTS=$(mktemp) + trap 'rm -f "$PREFLIGHT_RESULTS"' EXIT + printf '%s\n' "${IMAGES_TO_CHECK[@]}" | xargs -P 8 -I {} sh -c ' + if docker manifest inspect "cgr.dev/'"$ORG"'/{}" >/dev/null 2>&1; then + echo "OK {}" + else + echo "FAIL {}" + fi + ' >> "$PREFLIGHT_RESULTS" || true + + # ANSI colors. Always emit — setup.sh is interactive. + PF_GREEN=$'\033[32m' + PF_RED=$'\033[31m' + PF_RESET=$'\033[0m' + PF_MISSING=0 + for img in "${IMAGES_TO_CHECK[@]}"; do + if grep -qx "OK $img" "$PREFLIGHT_RESULTS"; then + printf ' %s✓%s cgr.dev/%s/%s\n' "$PF_GREEN" "$PF_RESET" "$ORG" "$img" + else + printf ' %s✗%s cgr.dev/%s/%s\n' "$PF_RED" "$PF_RESET" "$ORG" "$img" + PF_MISSING=$((PF_MISSING + 1)) + fi + done + rm -f "$PREFLIGHT_RESULTS" + trap - EXIT - if [[ -n "$MISSING" ]]; then - echo - echo "ERROR: The following images are not accessible at cgr.dev/${ORG}/:" >&2 - while IFS= read -r tag; do - printf ' cgr.dev/%s/%s\n' "$ORG" "$tag" >&2 - done <<< "$MISSING" + if (( PF_MISSING > 0 )); then echo >&2 + echo "ERROR: ${PF_MISSING} image(s) not accessible at cgr.dev/${ORG}/." >&2 echo "Possible causes:" >&2 echo " - You're not authenticated to cgr.dev. Try:" >&2 echo " chainctl auth login" >&2 From b98b6956559e672c0d19a093208406e939f19c2e Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 13:27:57 -0500 Subject: [PATCH 21/47] jenkins: drop ttl.sh/smalls default from the push-target prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setup.sh used to fall back to ttl.sh/smalls when the user hit enter without typing a push target — bakes one user's prefix into the demo. Replace with: - if PUSH_REGISTRY was persisted from a prior setup.sh run, offer that value as the suggested default (empty input keeps it) - otherwise loop until the user enters something explicit, with a generic ttl.sh/your-prefix hint Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/setup.sh | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/jenkins/setup.sh b/jenkins/setup.sh index 3305036a..e0055d8f 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -68,12 +68,22 @@ else PUSH_TO_HARBOR=false fi -# Where to push if not Harbor. +# Where to push if not Harbor. No baked-in default — if a previous setup.sh +# run persisted a value in .env, offer that as the suggested default; +# otherwise loop until the user enters something explicit. if [[ "$PUSH_TO_HARBOR" == "true" ]]; then PUSH_REGISTRY="localhost/library" else - read -rp "Where should pipelines push their built images? [ttl.sh/smalls]: " PUSH_REG_INPUT - PUSH_REGISTRY="${PUSH_REG_INPUT:-ttl.sh/smalls}" + PUSH_REGISTRY_DEFAULT="${PUSH_REGISTRY:-}" + PUSH_REGISTRY="" + while [[ -z "$PUSH_REGISTRY" ]]; do + if [[ -n "$PUSH_REGISTRY_DEFAULT" ]]; then + read -rp "Where should pipelines push their built images? [last used: ${PUSH_REGISTRY_DEFAULT}]: " PUSH_REG_INPUT + PUSH_REGISTRY="${PUSH_REG_INPUT:-$PUSH_REGISTRY_DEFAULT}" + else + read -rp "Where should pipelines push their built images? (e.g. ttl.sh/your-prefix): " PUSH_REGISTRY + fi + done fi # Where to pull cgr.dev images from. From 7a19af1ffb12baf32598b251156dabafdc397b63 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 13:34:01 -0500 Subject: [PATCH 22/47] jenkins: cgSign/cgVerify pick the RepoDigest matching the pushed image MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous {{index .RepoDigests 0}} grabbed whichever digest was first in the local image cache — fine on a fresh build but wrong when the same image content had been pushed to multiple registries across mode switches. Reproed on a Mode-A re-deploy where python312-pip-django's image already had a stale localhost/library RepoDigest from a prior Mode-C session, which then got rewritten to localhost:80 and tried to dial a registry that wasn't running. Filter RepoDigests to the entry whose repo matches the IMAGE we just pushed and use that. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/shared-libraries/cg-images/vars/cgSign.groovy | 11 +++++++++-- .../shared-libraries/cg-images/vars/cgVerify.groovy | 9 +++++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy index 617b93bb..8f118f1d 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy @@ -47,9 +47,16 @@ def call(String image) { ]) { sh """ set -eu - DIGEST=\$(docker image inspect --format '{{index .RepoDigests 0}}' '${image}') + # Pick the RepoDigest whose repo matches the image we just pushed. + # The local image cache may have stale RepoDigests from prior runs + # under different registries (e.g. localhost/library from a Mode C + # session, ttl.sh from a Mode A session) — `{{index .RepoDigests 0}}` + # returned whichever happened to be first and tripped cosign over. + IMAGE='${image}' + REPO=\${IMAGE%:*} + DIGEST=\$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "\$IMAGE" | grep -F "\${REPO}@" | head -1) if [ -z "\$DIGEST" ]; then - echo "cgSign: could not resolve digest for ${image} (was it pushed?)." >&2 + echo "cgSign: could not resolve digest for \$IMAGE under repo \$REPO (was it pushed?)." >&2 exit 1 fi # cosign's reference parser (via go-containerregistry) doesn't accept diff --git a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy index da983eef..d42d78dd 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy @@ -32,9 +32,14 @@ def call(String image) { ]) { sh """ set -eu - DIGEST=\$(docker image inspect --format '{{index .RepoDigests 0}}' '${image}') + # Pick the RepoDigest whose repo matches the image we want to verify. + # See cgSign.groovy for why .RepoDigests can have stale entries from + # prior runs. + IMAGE='${image}' + REPO=\${IMAGE%:*} + DIGEST=\$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "\$IMAGE" | grep -F "\${REPO}@" | head -1) if [ -z "\$DIGEST" ]; then - echo "cgVerify: could not resolve digest for ${image} (was it pushed?)." >&2 + echo "cgVerify: could not resolve digest for \$IMAGE under repo \$REPO (was it pushed?)." >&2 exit 1 fi # See cgSign.groovy for why we rewrite bare 'localhost' to 'localhost:80'. From 17b67145e99ab3b5c2871630163671c6429dcb7a Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 14:33:39 -0500 Subject: [PATCH 23/47] jenkins: add pipeline-graph-view plugin for cleaner stage display MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stock Stage View is fine for short linear pipelines but starts wrapping once a build has 7+ stages, which all the OCI-image samples do. pipeline-graph-view renders a tighter left-to-right graph in the build's "Pipeline Overview" tab. No JCasC config needed — the plugin auto-mounts its UI on existing pipeline jobs. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/jenkins/plugins.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/jenkins/jenkins/plugins.txt b/jenkins/jenkins/plugins.txt index 82525125..fd9503a0 100644 --- a/jenkins/jenkins/plugins.txt +++ b/jenkins/jenkins/plugins.txt @@ -20,3 +20,7 @@ oidc-provider # (file), and password (string). Pipelines pull all three through # `withCredentials` in cgSign.groovy / cgVerify.groovy. plain-credentials +# pipeline-graph-view — modern stage-graph UI that renders the build's +# Pipeline Overview tab as a left-to-right graph. Easier to scan than the +# classic Stage View, especially for pipelines with many sequential stages. +pipeline-graph-view From 4d028ccccc754c5084679e87dbd862f0bab724a2 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Thu, 7 May 2026 14:50:09 -0500 Subject: [PATCH 24/47] jenkins: fix refresh-cgimages-digests under Mode B/C MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three problems compounded after the recent refactors: 1. The crane container in the agent didn't see CHAINGUARD_ORG (the docker-workflow plugin only forwards env vars listed in `args`), so refresh-digests.sh's required-var check tripped immediately. Forward CHAINGUARD_ORG and PULL_REGISTRY explicitly. 2. The script hardcoded `crane digest cgr.dev/$ORG/...` for lookups, which needs cgr.dev creds in the agent's DOCKER_CONFIG. In Modes B/C cgLogin only writes localhost auth, so crane fell back to anonymous and 401'd. Route lookups through PULL_REGISTRY instead — Harbor proxy serves the upstream manifest verbatim, anonymously, so the digests come back identical to what cgr.dev would return. 3. crane in the agent container couldn't reach the host's localhost:80 (different network namespace) — same `--network host` workaround we already use for cosign. Also rewrite `localhost/...` to `localhost:80/...` so go-containerregistry's parser stops sending the request to Docker Hub instead of the local registry. The refreshed cgImage.groovy reflects today's tag-tip digests (python:3.x-dev and a couple others have rolled forward since the previous pin). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ops/refresh-cgimages-digests/Jenkinsfile | 13 +++++++++++- .../cg-images/refresh-digests.sh | 20 +++++++++++++++++-- .../cg-images/vars/cgImage.groovy | 8 ++++---- 3 files changed, 34 insertions(+), 7 deletions(-) diff --git a/jenkins/ops/refresh-cgimages-digests/Jenkinsfile b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile index 6ce09312..a8617caa 100644 --- a/jenkins/ops/refresh-cgimages-digests/Jenkinsfile +++ b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile @@ -33,8 +33,19 @@ pipeline { // tree (rw) and the pull-token docker config are already visible at // /tmp/cgjenkins-home/... — no extra `-v` flags needed. // -- entrypoint: clear so Jenkins' cat-keepalive works + // -- network host: crane resolves localhost:80 (Harbor proxy in + // Modes B/C) the same way the host docker daemon does. Without + // this, the agent container's own loopback intercepts the + // lookup and produces "connection refused" on Mode B/C runs. + // Harmless in Mode A — there's nothing on host port 80 then, + // and crane talks to cgr.dev over public DNS regardless. // -- DOCKER_CONFIG: point crane at the inherited pull-token config - args '--entrypoint= -e DOCKER_CONFIG=/tmp/cgjenkins-home/.docker' + // -- CHAINGUARD_ORG / PULL_REGISTRY: forward from controller env + // so refresh-digests.sh queries through the right registry — + // cgr.dev directly in Mode A, anonymous Harbor proxy in B/C. + // The docker-workflow plugin only carries env vars listed + // here into the agent container. + args "--entrypoint= --network host -e DOCKER_CONFIG=/tmp/cgjenkins-home/.docker -e CHAINGUARD_ORG=${env.CHAINGUARD_ORG} -e PULL_REGISTRY=${env.PULL_REGISTRY}" reuseNode true } } diff --git a/jenkins/shared-libraries/cg-images/refresh-digests.sh b/jenkins/shared-libraries/cg-images/refresh-digests.sh index 49a1636c..256a405e 100755 --- a/jenkins/shared-libraries/cg-images/refresh-digests.sh +++ b/jenkins/shared-libraries/cg-images/refresh-digests.sh @@ -23,8 +23,24 @@ if [[ -z "${CHAINGUARD_ORG:-}" ]]; then fi ORG="$CHAINGUARD_ORG" +# Route digest lookups through PULL_REGISTRY rather than cgr.dev directly: +# in Mode A that's still cgr.dev/ (using whatever docker auth is in +# DOCKER_CONFIG), but in Modes B/C it's the anonymous Harbor proxy at +# localhost/cgr-proxy/, which sidesteps the need for cgr.dev creds in +# the crane agent. Either way the manifest digest crane returns is the +# same — Harbor proxy serves the upstream manifest verbatim. +REGISTRY="${PULL_REGISTRY:-cgr.dev/${ORG}}" +# crane uses go-containerregistry, whose reference parser rejects bare +# 'localhost' as a registry hostname (treats it as a Docker Hub path +# component) and falls back to index.docker.io. The fix is the same one +# we apply in cgSign / cgVerify: rewrite 'localhost/...' to 'localhost:80/...' +# so the parser sees a host:port and recognises localhost as the registry. +case "$REGISTRY" in + localhost/*) REGISTRY="localhost:80/${REGISTRY#localhost/}" ;; +esac + CATALOG=vars/cgImage.groovy -echo "Refreshing digests in ${CATALOG} against cgr.dev/${ORG}/..." +echo "Refreshing digests in ${CATALOG} against ${REGISTRY}/..." # Extract every single-quoted ":" or ":@sha256:..." entry # from the catalog. We deduplicate by the repo:tag portion (everything before @@ -53,7 +69,7 @@ cp "$CATALOG" "$tmp" for reftag in "${!reftags[@]}"; do printf ' %-50s ' "$reftag" - digest=$(crane digest "cgr.dev/${ORG}/${reftag}") + digest=$(crane digest "${REGISTRY}/${reftag}") echo "$digest" pinned="${reftag}@${digest}" diff --git a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy index 8c957d30..313641ee 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy @@ -55,12 +55,12 @@ def call(String token) { test: 'jre:openjdk-21-dev@sha256:5c7d9d0287cf9bd4c4d3df39f532a028a0f019d96b60a12862c2dc29d9c740fb', ], 'python-3.14': [ - build: 'python:3.14-dev@sha256:3e01b7cc5c6ec2615546784c78e85ac6d1fefa5e07666515d8d32463952adc96', - runtime: 'python:3.14@sha256:7d66fd00301532cfffae8baf4a00f4e590d8bb0a6a1efe5a468a38aacaf970f1', + build: 'python:3.14-dev@sha256:9eba3fde174d8eab51be61c3440f06c529449e1bf2e05edf17cab02feb03a0fb', + runtime: 'python:3.14@sha256:ecb71c9df61b0cf7b94133e41e2c152a8a08fdc1e200891f52c1916642e93e49', ], 'python-3.12': [ - build: 'python:3.12-dev@sha256:c4205ab4b2e326c214f4d438c8cb7fffc093ffc95a4a1b67c40c8657b1851f47', - runtime: 'python:3.12@sha256:0f0c12676d9e4cb87d20c7d88716003c914529f4793d1968c4fbc707ed504198', + build: 'python:3.12-dev@sha256:a17a4c58449b7cf93325ab18d75c9cb180bd8a82cfb6adb24d93c64a5c645ed7', + runtime: 'python:3.12@sha256:010f22ee9a4eeb2d1be561f235659c84544f4f04fb39234bf408d389f4d3d212', ], 'node-22': [ build: 'node:22-dev@sha256:4bc74862aec7fcfcf518e8606dbc0d7cdc294a1a062c6323a42bda40dd443969', From 3c3902a8f7b0009e519891223229c1c94ce165db Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Fri, 8 May 2026 08:14:43 -0500 Subject: [PATCH 25/47] Digest bumps to cgr images Signed-off-by: Eric Smalling --- .../cg-images/vars/cgImage.groovy | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy index 313641ee..aa45aac8 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy @@ -43,32 +43,32 @@ def call(String token) { def reg = env.PULL_REGISTRY ?: "cgr.dev/${env.CHAINGUARD_ORG}" def catalog = [ 'corretto-java17': [ - build: 'maven:3-jdk17-dev@sha256:d95ab64eb7ce9b3016cc781c65025727b7287f048655ce2a33ec8b9500b4ba39', - test: 'amazon-corretto-jre:17-dev@sha256:d00bd8b357f0481d9c9c2778d69b74312f01d582b04df1e296a76ee74714e8be', + build: 'maven:3-jdk17-dev@sha256:90e0bf8239086e814fc92090749f4f3b7b49a88107c655b0661509a2a4f2ee58', + test: 'amazon-corretto-jre:17-dev@sha256:46883ffbb2b5e99cf52cb124d9fced7b4e3740cacd46c1913bc2e970b7613353', ], 'adoptium-java8': [ - build: 'maven:3-jdk8-dev@sha256:cad0490a5f41c922e884d3cc11a810234b8661fe8b2becbcdc6a2d92e2708a9e', - test: 'adoptium-jre:adoptium-openjdk-8-dev@sha256:f745cc893a988fd4065b21b318473315c6fab61607bc89d8fd656633d75fcd6d', + build: 'maven:3-jdk8-dev@sha256:9df83d553cef9c7bc3daabd51fcf993478516339c525eca0307f780f2ed3743a', + test: 'adoptium-jre:adoptium-openjdk-8-dev@sha256:b06752d6781be20ef0ca4bc50459ef1c7518acec79122e4583ee9a24f6ed396a', ], 'openjdk21': [ - build: 'jdk:openjdk-21-dev@sha256:8fccfbd701fe79dc6c53bba92b7d4e3387bd6b140cd69fccbc74109da59a470c', - test: 'jre:openjdk-21-dev@sha256:5c7d9d0287cf9bd4c4d3df39f532a028a0f019d96b60a12862c2dc29d9c740fb', + build: 'jdk:openjdk-21-dev@sha256:22a0cda00ee2980c4e1c7c35f7ddfa4391e06a2a8806887bffb5e5283f149ee1', + test: 'jre:openjdk-21-dev@sha256:c5bc29d25e88d244e7caaa344285007da619cd0bf83c350989398e1553775ecf', ], 'python-3.14': [ - build: 'python:3.14-dev@sha256:9eba3fde174d8eab51be61c3440f06c529449e1bf2e05edf17cab02feb03a0fb', - runtime: 'python:3.14@sha256:ecb71c9df61b0cf7b94133e41e2c152a8a08fdc1e200891f52c1916642e93e49', + build: 'python:3.14-dev@sha256:aa69dcd8a8689f7584fd4e077a52c0f13812ec7f54ace6590ab31f4297b3129d', + runtime: 'python:3.14@sha256:0d0af6d76f7caf6b0d21015b66089bef7f017d062652a3b297f211d07e319ec4', ], 'python-3.12': [ - build: 'python:3.12-dev@sha256:a17a4c58449b7cf93325ab18d75c9cb180bd8a82cfb6adb24d93c64a5c645ed7', - runtime: 'python:3.12@sha256:010f22ee9a4eeb2d1be561f235659c84544f4f04fb39234bf408d389f4d3d212', + build: 'python:3.12-dev@sha256:6f0af8cc50dd3853ce3fb145874e2408c868ba50e7104691a43794efda509e57', + runtime: 'python:3.12@sha256:c989e9a79d89581d777a9249444ebf7a9a64835188fe958d18ee3f19a98d25e1', ], 'node-22': [ - build: 'node:22-dev@sha256:4bc74862aec7fcfcf518e8606dbc0d7cdc294a1a062c6323a42bda40dd443969', - runtime: 'node:22@sha256:2ac85f61d02a044683bf5904183d2215acf167773c4fdf091ff657f4acfc84be', + build: 'node:22-dev@sha256:dd916e3ed5be3b662cb598ad02a9e8b31a5ab23964ac92db4f79378ffa9fccca', + runtime: 'node:22@sha256:63323818cad51c97855be7c45a5ff8933983658b0b88051f7800368bcf5b938c', ], 'node-25': [ - build: 'node:25-dev@sha256:42011912b30c4ed6320a7c48cbec1571886ab57915f54cd6ed616958ae5d9857', - runtime: 'node:25-slim@sha256:affd11bfb77c0d4cd5e87d0ab7b922f2833293d5c8e24e687323d14eb19136c6', + build: 'node:25-dev@sha256:cea252f9844fcaf7a224f009201e202e956d316976fe95df3c3c51d78ba10187', + runtime: 'node:25-slim@sha256:c1c2a3206d54ce0d1e04f108de77429325fd50b3ad266f7fbf827536ffed0292', ], ] if (!catalog.containsKey(token)) { From f8ea5fb3a6485d1e26aac91767189abfd4973c70 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Fri, 8 May 2026 10:23:01 -0500 Subject: [PATCH 26/47] jenkins: fix node25-pnpm-express stash failure on stale workspace Jenkins's anti-symlink-traversal check rejects untar of any tar that writes a regular file inside a directory that's a symlink. pnpm's node_modules/.pnpm/ tree is built almost entirely out of those, so any stash that captured node_modules trips the check on the next unstash. The node25 Checkout stage was doing `cp -R /sources/... .` followed by `stash includes: '**'` against a workspace Jenkins reuses across builds. Once a build had run pnpm install, the next build's Checkout overlaid fresh source on top of the leftover node_modules and stashed all 3016 files together. Subsequent unstash failed. Two fixes: - cleanWs() before the cp so the workspace starts empty regardless of prior-build leftovers, - excludes: 'node_modules/**' on the stash as a safety net for any future stage ordering that ends up with node_modules in the workspace before stash runs. Apply the same to node22 defensively. npm's flat node_modules layout doesn't trigger the symlink check today, but cleaning the workspace and trimming the stash is good hygiene either way. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/apps/node22-npm-express/Jenkinsfile | 8 +++++++- jenkins/apps/node25-pnpm-express/Jenkinsfile | 10 +++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/jenkins/apps/node22-npm-express/Jenkinsfile b/jenkins/apps/node22-npm-express/Jenkinsfile index 4183c99e..cc8adb50 100644 --- a/jenkins/apps/node22-npm-express/Jenkinsfile +++ b/jenkins/apps/node22-npm-express/Jenkinsfile @@ -17,8 +17,14 @@ pipeline { stage('Checkout') { agent any steps { + // See node25-pnpm-express/Jenkinsfile for why we clean + exclude. + // npm's node_modules layout doesn't use symlinks the way pnpm does, + // so this is mostly defensive — keeps the stash small and avoids + // the same class of failure if a future dependency switch reaches + // for symlinks. + cleanWs() sh 'cp -R /sources/apps/node22-npm-express/. .' - stash name: 'src', includes: '**' + stash name: 'src', includes: '**', excludes: 'node_modules/**' } } diff --git a/jenkins/apps/node25-pnpm-express/Jenkinsfile b/jenkins/apps/node25-pnpm-express/Jenkinsfile index 77a24668..fe34c45f 100644 --- a/jenkins/apps/node25-pnpm-express/Jenkinsfile +++ b/jenkins/apps/node25-pnpm-express/Jenkinsfile @@ -17,8 +17,16 @@ pipeline { stage('Checkout') { agent any steps { + // Wipe the workspace before copying sources. Jenkins reuses the + // workspace across builds, and pnpm's node_modules/.pnpm/ tree is + // built out of symlinks the next `unstash` step refuses to extract + // (Jenkins anti-symlink-traversal check). Clearing first means + // stash always sees just the source files. + cleanWs() sh 'cp -R /sources/apps/node25-pnpm-express/. .' - stash name: 'src', includes: '**' + // excludes: belt-and-braces against the same symlink trap if Build + // deps somehow runs before another stash/unstash cycle. + stash name: 'src', includes: '**', excludes: 'node_modules/**' } } From a521df08d3ac0dbdab45a5143c8daa782b92a56d Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 15:43:17 -0500 Subject: [PATCH 27/47] jenkins/iac: fix doc inaccuracies about bootstrap flow + jenkins_issuer_url Three doc/comment fixes all rooted in describing setup.sh's UIDP path incorrectly: - README "Usage" said setup.sh writes the identity UIDP into ../.env as CHAINGUARD_IDENTITY and that a docker compose restart jenkins is required afterward. Neither is true. setup.sh writes the UIDP to ../shared-libraries/cg-images/IDENTITY (a one-line file); cgLogin reads it on each pipeline build from the bind-mounted shared-libraries path, so no controller restart is needed. - outputs.tf identity_uidp description had the same misclaim about ../.env; fixed to reference the IDENTITY file path. - README jenkins_issuer_url default disagreed with variables.tf. TF default is "https://localhost:8080/oidc" (the Chainguard provider validator requires HTTPS); README said "http://". Aligned to https and added a note that JCasC sets JenkinsLocationConfiguration.url to https://localhost:8080/ so the emitted `iss` matches, even though the local Jenkins listens on plain HTTP. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/iac/README.md | 7 +++---- jenkins/iac/outputs.tf | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/jenkins/iac/README.md b/jenkins/iac/README.md index 78d12fcb..2547f3b0 100644 --- a/jenkins/iac/README.md +++ b/jenkins/iac/README.md @@ -13,9 +13,8 @@ The two are mutually exclusive. `claim_match` supports subject patterns but requ This module is driven by [../setup.sh](../setup.sh), not run by hand. The bootstrap flow is: -1. `docker compose up -d --build` — Jenkins starts and exposes its JWKS at `http://localhost:8080/oidc/jwks`. -2. `./setup.sh` — fetches that JWKS into `iac/jenkins-jwks.json`, runs `terraform apply`, captures the output `identity_uidp`, and writes it into `../.env` as `CHAINGUARD_IDENTITY=`. -3. `docker compose restart jenkins` — picks up the new env var so pipelines have access to it. +1. `./setup.sh` — brings Jenkins up via `docker compose`, waits for `http://localhost:8080/oidc/jwks` to respond, fetches that JWKS into `iac/jenkins-jwks.json`, runs `terraform apply`, captures the output `identity_uidp`, and writes it to `../shared-libraries/cg-images/IDENTITY` (a single line, just the UIDP). +2. Jenkins picks up the identity at pipeline-build time. The shared library `cgLogin()` reads the IDENTITY file directly from the bind-mounted filesystem path on every build — no controller restart is needed. Manual `terraform apply` works too (useful for debugging or org changes), but you must populate `jenkins-jwks.json` first. @@ -24,7 +23,7 @@ Manual `terraform apply` works too (useful for debugging or org changes), but yo | Name | Default | What it controls | |------|---------|------------------| | `chainguard_group_name` | *(required, no default)* | The parent group the identity lives under and the rolebinding's scope. setup.sh passes this via `-var` from `.env`'s `CHAINGUARD_ORG`. | -| `jenkins_issuer_url` | `http://localhost:8080/oidc` | Must exactly match the `iss` claim Jenkins puts on its tokens. The `oidc-provider` plugin uses `/oidc` by default. | +| `jenkins_issuer_url` | `https://localhost:8080/oidc` | Must exactly match the `iss` claim Jenkins puts on its tokens. The `oidc-provider` plugin uses `/oidc`. HTTPS is required by the Chainguard Terraform provider's validator even though the local Jenkins listens on plain HTTP — the URL is never fetched (the `static` block does offline JWKS verification), so the scheme is purely a claim-match string. The JCasC config (`jenkins/casc/jenkins.yaml`) sets `unsafe.jenkins.JenkinsLocationConfiguration.url` to `https://localhost:8080/` so Jenkins emits `iss=https://localhost:8080/oidc` to match. | | `jenkins_subject` | `jenkins-cgimages-puller` | Must exactly match the `sub` claim. Configured on the JCasC OIDC credential. | ## Reused conventions diff --git a/jenkins/iac/outputs.tf b/jenkins/iac/outputs.tf index d05adbbb..2e2c7b26 100644 --- a/jenkins/iac/outputs.tf +++ b/jenkins/iac/outputs.tf @@ -1,4 +1,4 @@ output "identity_uidp" { - description = "UIDP of the Chainguard assumed identity. setup.sh writes this into ../.env as CHAINGUARD_IDENTITY." + description = "UIDP of the Chainguard assumed identity. setup.sh writes this single-line value to ../shared-libraries/cg-images/IDENTITY, which cgLogin reads from the bind-mounted shared-libraries path on each pipeline build." value = chainguard_identity.jenkins_puller.id } From 10b6ebcbe2055270e6983df85dbabda3f453a61c Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 16:00:51 -0500 Subject: [PATCH 28/47] Fix typos Signed-off-by: Eric Smalling --- jenkins/Dockerfile.jenkins | 2 -- jenkins/PLAN.md | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/jenkins/Dockerfile.jenkins b/jenkins/Dockerfile.jenkins index bc4eb580..00c9e1dd 100644 --- a/jenkins/Dockerfile.jenkins +++ b/jenkins/Dockerfile.jenkins @@ -6,8 +6,6 @@ # Required build-arg, supplied by docker-compose from .env. No default — # CHAINGUARD_ORG must be set explicitly so that we don't accidentally build # against the wrong catalog. setup.sh prompts for this on first run. -ARG CHAINGUARD_ORG - ARG CHAINGUARD_ORG FROM cgr.dev/${CHAINGUARD_ORG}/docker-cli:29 AS docker diff --git a/jenkins/PLAN.md b/jenkins/PLAN.md index 19b6fd33..7d76d248 100644 --- a/jenkins/PLAN.md +++ b/jenkins/PLAN.md @@ -4,7 +4,7 @@ All images should be Chainguard sourced and pulled from cgr.dev/$CHAINGUARD_ORG Setup scripts should be provided to quickly stand up the Jenkins environment for future demonstrations. -Create sub-directories for the various sample applications with Jenkins pipeline files in them as if they were seperate repos in a real-world scenario. +Create sub-directories for the various sample applications with Jenkins pipeline files in them as if they were separate repos in a real-world scenario. ## Sample applications to build (take these one at a time) - For each, make a simple "hello world" type application for each that also outputs info about the runtime environment (i.e. language version, env vars, etc) From cfb31c83df5c9b88f02d443d1d0d5ed51dba8ad8 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 16:01:41 -0500 Subject: [PATCH 29/47] jenkins: fix shell-injection risk in cgSign/cgVerify image arg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both helpers built their sh script with a Groovy GString and inlined the caller-supplied `image` value inside a single-quoted shell string: IMAGE='${image}' If `image` ever contained a single quote (or other shell metacharacter) it would break out of the quoting and be parsed as shell. Switch the sh body to a single-quoted Groovy string (no Groovy interpolation) and pass `image` through the sh step's environment via withEnv. The shell then references "$IMAGE" with normal double-quoting, which is safe regardless of contents. While here, drop the local `def org = env.CHAINGUARD_ORG` indirection in favor of referencing $CHAINGUARD_ORG directly from the shell — Jenkins already exposes env entries to the sh step, so there's no need to Groovy-interpolate that one either. Same class of issue, though that value is controller-set rather than caller-supplied. Behavior for well-formed image refs is unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../cg-images/vars/cgSign.groovy | 82 ++++++++++--------- .../cg-images/vars/cgVerify.groovy | 62 +++++++------- 2 files changed, 75 insertions(+), 69 deletions(-) diff --git a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy index 8f118f1d..fafb88b7 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy @@ -39,44 +39,48 @@ def call(String image) { if (!image?.trim()) { error('cgSign: image argument is required') } - def org = env.CHAINGUARD_ORG - if (!org) error('cgSign: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (jenkins/casc/jenkins.yaml). Re-run setup.sh.') - withCredentials([ - file(credentialsId: 'cosign-private-key', variable: 'COSIGN_KEY_FILE'), - string(credentialsId: 'cosign-password', variable: 'COSIGN_PASSWORD'), - ]) { - sh """ - set -eu - # Pick the RepoDigest whose repo matches the image we just pushed. - # The local image cache may have stale RepoDigests from prior runs - # under different registries (e.g. localhost/library from a Mode C - # session, ttl.sh from a Mode A session) — `{{index .RepoDigests 0}}` - # returned whichever happened to be first and tripped cosign over. - IMAGE='${image}' - REPO=\${IMAGE%:*} - DIGEST=\$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "\$IMAGE" | grep -F "\${REPO}@" | head -1) - if [ -z "\$DIGEST" ]; then - echo "cgSign: could not resolve digest for \$IMAGE under repo \$REPO (was it pushed?)." >&2 - exit 1 - fi - # cosign's reference parser (via go-containerregistry) doesn't accept - # bare 'localhost' as a registry hostname — it falls back to treating - # the whole ref as a Docker Hub path (index.docker.io/localhost/...). - # Adding ':80' forces the host:port split, so the parser recognizes - # localhost:80 as the registry. Other hostnames (ttl.sh, harbor.foo.bar) - # contain a '.' and parse correctly without modification. - case "\$DIGEST" in - localhost/*) DIGEST="localhost:80/\${DIGEST#localhost/}" ;; - esac - docker run --rm --network host \\ - -v "\$COSIGN_KEY_FILE:/cosign.key:ro" \\ - -v "\$DOCKER_CONFIG:/jenkins-docker:ro" \\ - -e "COSIGN_PASSWORD=\$COSIGN_PASSWORD" \\ - -e DOCKER_CONFIG=/jenkins-docker \\ - --entrypoint=/usr/bin/cosign \\ - cgr.dev/${org}/cosign:latest-dev \\ - sign --yes --allow-http-registry --key /cosign.key "\$DIGEST" - echo "cgSign: signed \$DIGEST" - """ + if (!env.CHAINGUARD_ORG) error('cgSign: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (jenkins/casc/jenkins.yaml). Re-run setup.sh.') + // Pass `image` through the sh step's environment rather than interpolating + // it into the script body — otherwise an image ref containing a single + // quote (or other shell metacharacter) could break out of the surrounding + // quoting. CHAINGUARD_ORG is already exposed to the shell by Jenkins. + withEnv(["IMAGE=${image}"]) { + withCredentials([ + file(credentialsId: 'cosign-private-key', variable: 'COSIGN_KEY_FILE'), + string(credentialsId: 'cosign-password', variable: 'COSIGN_PASSWORD'), + ]) { + sh ''' + set -eu + # Pick the RepoDigest whose repo matches the image we just pushed. + # The local image cache may have stale RepoDigests from prior runs + # under different registries (e.g. localhost/library from a Mode C + # session, ttl.sh from a Mode A session) — `{{index .RepoDigests 0}}` + # returned whichever happened to be first and tripped cosign over. + REPO="${IMAGE%:*}" + DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1) + if [ -z "$DIGEST" ]; then + echo "cgSign: could not resolve digest for $IMAGE under repo $REPO (was it pushed?)." >&2 + exit 1 + fi + # cosign's reference parser (via go-containerregistry) doesn't accept + # bare 'localhost' as a registry hostname — it falls back to treating + # the whole ref as a Docker Hub path (index.docker.io/localhost/...). + # Adding ':80' forces the host:port split, so the parser recognizes + # localhost:80 as the registry. Other hostnames (ttl.sh, harbor.foo.bar) + # contain a '.' and parse correctly without modification. + case "$DIGEST" in + localhost/*) DIGEST="localhost:80/${DIGEST#localhost/}" ;; + esac + docker run --rm --network host \ + -v "$COSIGN_KEY_FILE:/cosign.key:ro" \ + -v "$DOCKER_CONFIG:/jenkins-docker:ro" \ + -e "COSIGN_PASSWORD=$COSIGN_PASSWORD" \ + -e DOCKER_CONFIG=/jenkins-docker \ + --entrypoint=/usr/bin/cosign \ + "cgr.dev/${CHAINGUARD_ORG}/cosign:latest-dev" \ + sign --yes --allow-http-registry --key /cosign.key "$DIGEST" + echo "cgSign: signed $DIGEST" + ''' + } } } diff --git a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy index d42d78dd..fe51d391 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy @@ -25,35 +25,37 @@ def call(String image) { if (!image?.trim()) { error('cgVerify: image argument is required') } - def org = env.CHAINGUARD_ORG - if (!org) error('cgVerify: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (jenkins/casc/jenkins.yaml). Re-run setup.sh.') - withCredentials([ - file(credentialsId: 'cosign-public-key', variable: 'COSIGN_PUB_FILE'), - ]) { - sh """ - set -eu - # Pick the RepoDigest whose repo matches the image we want to verify. - # See cgSign.groovy for why .RepoDigests can have stale entries from - # prior runs. - IMAGE='${image}' - REPO=\${IMAGE%:*} - DIGEST=\$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "\$IMAGE" | grep -F "\${REPO}@" | head -1) - if [ -z "\$DIGEST" ]; then - echo "cgVerify: could not resolve digest for \$IMAGE under repo \$REPO (was it pushed?)." >&2 - exit 1 - fi - # See cgSign.groovy for why we rewrite bare 'localhost' to 'localhost:80'. - case "\$DIGEST" in - localhost/*) DIGEST="localhost:80/\${DIGEST#localhost/}" ;; - esac - docker run --rm --network host \\ - -v "\$COSIGN_PUB_FILE:/cosign.pub:ro" \\ - -v "\$DOCKER_CONFIG:/jenkins-docker:ro" \\ - -e DOCKER_CONFIG=/jenkins-docker \\ - --entrypoint=/usr/bin/cosign \\ - cgr.dev/${org}/cosign:latest-dev \\ - verify --allow-http-registry --key /cosign.pub "\$DIGEST" >/dev/null - echo "cgVerify: signature OK for \$DIGEST" - """ + if (!env.CHAINGUARD_ORG) error('cgVerify: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (jenkins/casc/jenkins.yaml). Re-run setup.sh.') + // Pass `image` through the sh step's environment rather than interpolating + // it into the script body — see cgSign.groovy for the same reasoning. + withEnv(["IMAGE=${image}"]) { + withCredentials([ + file(credentialsId: 'cosign-public-key', variable: 'COSIGN_PUB_FILE'), + ]) { + sh ''' + set -eu + # Pick the RepoDigest whose repo matches the image we want to verify. + # See cgSign.groovy for why .RepoDigests can have stale entries from + # prior runs. + REPO="${IMAGE%:*}" + DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1) + if [ -z "$DIGEST" ]; then + echo "cgVerify: could not resolve digest for $IMAGE under repo $REPO (was it pushed?)." >&2 + exit 1 + fi + # See cgSign.groovy for why we rewrite bare 'localhost' to 'localhost:80'. + case "$DIGEST" in + localhost/*) DIGEST="localhost:80/${DIGEST#localhost/}" ;; + esac + docker run --rm --network host \ + -v "$COSIGN_PUB_FILE:/cosign.pub:ro" \ + -v "$DOCKER_CONFIG:/jenkins-docker:ro" \ + -e DOCKER_CONFIG=/jenkins-docker \ + --entrypoint=/usr/bin/cosign \ + "cgr.dev/${CHAINGUARD_ORG}/cosign:latest-dev" \ + verify --allow-http-registry --key /cosign.pub "$DIGEST" >/dev/null + echo "cgVerify: signature OK for $DIGEST" + ''' + } } } From 04af03a46a755b9b67ab3ed43c6572ec149a2734 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 16:02:17 -0500 Subject: [PATCH 30/47] jenkins: let JENKINS_ADMIN_PASSWORD actually be overridden docker-compose.yml hardcoded `JENKINS_ADMIN_PASSWORD: admin`, which silently defeated overrides even though jenkins/casc/jenkins.yaml is already wired up to read ${JENKINS_ADMIN_PASSWORD:-admin} and the top-level README documents that users can override the admin password via this var. Switch the compose value to `${JENKINS_ADMIN_PASSWORD:-admin}` so it interpolates from the host shell at compose-time. Default stays `admin`, matching the README and JCasC behavior; setting the var in the host env now actually takes effect. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml index df81d9b6..08c4e2c4 100644 --- a/jenkins/docker-compose.yml +++ b/jenkins/docker-compose.yml @@ -39,7 +39,7 @@ services: COSIGN_PASSWORD: ${COSIGN_PASSWORD:-} JENKINS_HOME: /tmp/cgjenkins-home CASC_JENKINS_CONFIG: /tmp/cgjenkins-home/casc - JENKINS_ADMIN_PASSWORD: admin + JENKINS_ADMIN_PASSWORD: ${JENKINS_ADMIN_PASSWORD:-admin} # The docker CLI's config dir. Each pipeline's Auth stage runs # `chainctl auth configure-docker` which writes a fresh, short-lived # token here; subsequent agent docker steps reuse it. From c98dfeb6ec8e849839b6b28567ff8a7b902c11be Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 18:07:45 -0500 Subject: [PATCH 31/47] jenkins: route cosign/crane helper images via PULL_REGISTRY MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In Harbor modes (HARBOR_ENABLED=true) the controller has no cgr.dev credentials, so pulling the cosign helper (cgSign/cgVerify) and the crane agent (refresh-cgimages-digests) directly from cgr.dev failed. Source them via $PULL_REGISTRY the same way cgImage() routes the application build/test agents — cgr.dev/ in Mode A, anonymous localhost/cgr-proxy/ in Modes B/C. Also align the surrounding docs (cgImages README, refresh-cgimages- digests README) with the real cgLogin/PULL_REGISTRY flow, and drop the obsolete .secrets/ entry from .gitignore (the OIDC flow writes to /tmp/cgjenkins-home/.docker, no in-repo secrets dir is created). Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/.gitignore | 3 --- jenkins/ops/refresh-cgimages-digests/Jenkinsfile | 6 +++++- jenkins/ops/refresh-cgimages-digests/README.md | 11 +++++++---- jenkins/shared-libraries/cg-images/README.md | 2 +- .../shared-libraries/cg-images/vars/cgSign.groovy | 12 ++++++++++-- .../shared-libraries/cg-images/vars/cgVerify.groovy | 8 ++++++-- 6 files changed, 29 insertions(+), 13 deletions(-) diff --git a/jenkins/.gitignore b/jenkins/.gitignore index 87d220b1..d9b92314 100644 --- a/jenkins/.gitignore +++ b/jenkins/.gitignore @@ -1,9 +1,6 @@ # Local environment overrides (e.g. CHAINGUARD_ORG). Bootstrapped from .env.example. .env -# Pull-token-derived Docker config — generated by setup.sh, must not be committed. -.secrets/ - # Identity UIDP file — generated by setup.sh, ephemeral, must not be committed. shared-libraries/cg-images/IDENTITY diff --git a/jenkins/ops/refresh-cgimages-digests/Jenkinsfile b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile index a8617caa..e1231e07 100644 --- a/jenkins/ops/refresh-cgimages-digests/Jenkinsfile +++ b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile @@ -27,7 +27,11 @@ pipeline { stage('Refresh digests') { agent { docker { - image "cgr.dev/${env.CHAINGUARD_ORG}/crane:latest-dev" + // Pull crane via PULL_REGISTRY (cgr.dev/ in Mode A, + // localhost/cgr-proxy/ in Modes B/C where the controller + // has no cgr.dev creds — same routing as cgImage() applies to + // the application build/test agents). + image "${env.PULL_REGISTRY ?: "cgr.dev/${env.CHAINGUARD_ORG}"}/crane:latest-dev" // The agent inherits the controller's mounts via `--volumes-from` // (Jenkins docker-workflow plugin default), so the shared-libraries // tree (rw) and the pull-token docker config are already visible at diff --git a/jenkins/ops/refresh-cgimages-digests/README.md b/jenkins/ops/refresh-cgimages-digests/README.md index dba55f7e..07ced462 100644 --- a/jenkins/ops/refresh-cgimages-digests/README.md +++ b/jenkins/ops/refresh-cgimages-digests/README.md @@ -6,11 +6,14 @@ Scheduled Jenkins job that re-resolves every digest in the [cgImages shared-libr | Stage | Image | Purpose | |-------|-------|---------| -| Refresh digests | `cgr.dev/${CHAINGUARD_ORG}/crane:latest-dev` | Runs [refresh-digests.sh](../../shared-libraries/cg-images/refresh-digests.sh) which calls `crane digest` for each entry. | +| Auth | (controller) | Runs `cgLogin()` — in Mode A this exchanges a Jenkins OIDC token for a chainctl session and writes a fresh docker config to `$DOCKER_CONFIG`; in Modes B/C (Harbor) it's a no-op because pulls go anonymously through the Harbor proxy. | +| Refresh digests | `${PULL_REGISTRY}/crane:latest-dev` | Runs [refresh-digests.sh](../../shared-libraries/cg-images/refresh-digests.sh) which calls `crane digest` for each entry. | -The agent container gets: -- `DOCKER_CONFIG=/dockerconfig` pointed at the bind-mounted pull-token config (so `crane` can authenticate to `cgr.dev`) -- `/tmp/cgjenkins-home/shared-libraries` mounted **read-write** at `/sources` so the script can rewrite `vars/cgImage.groovy` +The agent container inherits the controller's mounts (Jenkins docker-workflow `--volumes-from`), so: +- `DOCKER_CONFIG=/tmp/cgjenkins-home/.docker` points at the controller's docker config — populated by `cgLogin()` in Mode A so `crane` can authenticate to `cgr.dev`; unused in Modes B/C where the Harbor proxy serves manifests anonymously. +- `/tmp/cgjenkins-home/shared-libraries` is bind-mounted **read-write** so the script can rewrite `vars/cgImage.groovy` in place. + +The crane image itself is pulled via `PULL_REGISTRY` (not `cgr.dev` directly), matching how `cgImage()` routes application build/test agents — so this job works in Harbor modes even though the controller has no cgr.dev creds. When digests change, the next sample-app pipeline picks them up automatically — the cgImages library is live-loaded from the same bind-mounted dir on every build. No Jenkins restart needed. diff --git a/jenkins/shared-libraries/cg-images/README.md b/jenkins/shared-libraries/cg-images/README.md index 2acc35d1..3114e271 100644 --- a/jenkins/shared-libraries/cg-images/README.md +++ b/jenkins/shared-libraries/cg-images/README.md @@ -48,7 +48,7 @@ To refresh the digests (e.g. to pick up a security patch in the underlying image ./refresh-digests.sh ``` -The script reads the current `repo:tag` portion of each pinned reference, calls `crane digest cgr.dev/$CHAINGUARD_ORG/:` for each, and rewrites the digest in place. Requires `crane`. It picks up `CHAINGUARD_ORG` from `../../.env` so refreshing matches the org the demo is configured for. +The script reads the current `repo:tag` portion of each pinned reference, calls `crane digest $PULL_REGISTRY/:` for each, and rewrites the digest in place. Requires `crane`. It picks up `CHAINGUARD_ORG` (and, optionally, `PULL_REGISTRY`) from `../../.env` — so refreshing matches the org and pull routing the demo is configured for: `cgr.dev/` directly in Mode A, or the anonymous `localhost/cgr-proxy/` Harbor cache in Modes B/C. ## Adding a new token diff --git a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy index fafb88b7..b9c5d0e1 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy @@ -22,6 +22,12 @@ // wrote earlier in the pipeline (Harbor admin creds in Mode C; nothing // needed in A/B since signatures push anonymously to ttl.sh). // +// The cosign helper image itself is pulled from $PULL_REGISTRY (not +// cgr.dev directly) — that's cgr.dev/ in Mode A but the anonymous +// Harbor proxy at localhost/cgr-proxy/ in Modes B/C, where the +// controller has no cgr.dev creds. Same routing as cgImage() uses for +// the application build/test agents. +// // Usage from a stage on `agent any`: // // stage('Sign') { @@ -43,7 +49,8 @@ def call(String image) { // Pass `image` through the sh step's environment rather than interpolating // it into the script body — otherwise an image ref containing a single // quote (or other shell metacharacter) could break out of the surrounding - // quoting. CHAINGUARD_ORG is already exposed to the shell by Jenkins. + // quoting. CHAINGUARD_ORG and PULL_REGISTRY are already exposed to the + // shell by Jenkins. withEnv(["IMAGE=${image}"]) { withCredentials([ file(credentialsId: 'cosign-private-key', variable: 'COSIGN_KEY_FILE'), @@ -71,13 +78,14 @@ def call(String image) { case "$DIGEST" in localhost/*) DIGEST="localhost:80/${DIGEST#localhost/}" ;; esac + COSIGN_IMAGE="${PULL_REGISTRY:-cgr.dev/${CHAINGUARD_ORG}}/cosign:latest-dev" docker run --rm --network host \ -v "$COSIGN_KEY_FILE:/cosign.key:ro" \ -v "$DOCKER_CONFIG:/jenkins-docker:ro" \ -e "COSIGN_PASSWORD=$COSIGN_PASSWORD" \ -e DOCKER_CONFIG=/jenkins-docker \ --entrypoint=/usr/bin/cosign \ - "cgr.dev/${CHAINGUARD_ORG}/cosign:latest-dev" \ + "$COSIGN_IMAGE" \ sign --yes --allow-http-registry --key /cosign.key "$DIGEST" echo "cgSign: signed $DIGEST" ''' diff --git a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy index fe51d391..836db456 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy @@ -8,7 +8,10 @@ // the full sign-then-verify loop. // // Cosign runs in a one-shot container with --network host for the same -// reason cgSign does — see that file for the full rationale. +// reason cgSign does — see that file for the full rationale. The cosign +// helper image itself is pulled from $PULL_REGISTRY so it works whether +// the controller has cgr.dev creds (Mode A) or only the anonymous Harbor +// proxy (Modes B/C). // // Usage from a stage on `agent any`: // @@ -47,12 +50,13 @@ def call(String image) { case "$DIGEST" in localhost/*) DIGEST="localhost:80/${DIGEST#localhost/}" ;; esac + COSIGN_IMAGE="${PULL_REGISTRY:-cgr.dev/${CHAINGUARD_ORG}}/cosign:latest-dev" docker run --rm --network host \ -v "$COSIGN_PUB_FILE:/cosign.pub:ro" \ -v "$DOCKER_CONFIG:/jenkins-docker:ro" \ -e DOCKER_CONFIG=/jenkins-docker \ --entrypoint=/usr/bin/cosign \ - "cgr.dev/${CHAINGUARD_ORG}/cosign:latest-dev" \ + "$COSIGN_IMAGE" \ verify --allow-http-registry --key /cosign.pub "$DIGEST" >/dev/null echo "cgVerify: signature OK for $DIGEST" ''' From 36bb12c9637e236cacacbcd12c1790a7ab89deff Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 18:24:49 -0500 Subject: [PATCH 32/47] jenkins: harden cgLogin base64, setup.sh quoting/perms, error paths Five Copilot findings from the second pass on PR #330: - cgLogin (Mode C): pipe `base64` through `tr -d '\n'` so an internal wrap or trailing newline can't leak into the docker config.json's auth string and break docker push / cosign auth. - setup.sh preflight: export ORG and reference "${ORG}" inside the single-quoted `sh -c` body rather than splicing it via shell-quote juggling, so a CHAINGUARD_ORG containing quotes or metacharacters can't corrupt the probe command. - setup.sh cosign perms: drop cosign.password to 600 (only the host user reads it back to export $COSIGN_PASSWORD); keep cosign.key / cosign.pub at 644 because JCasC reads them as uid 1000 across the bind mount. Pairing world-readable encrypted key + world-readable passphrase defeated the encryption-at-rest for other local users. Applied unconditionally so re-runs upgrade older 644 password files. - cgSign / cgVerify: disambiguate the "see jenkins/casc/jenkins.yaml" hint by labelling the path as relative to the jenkins/ subdir. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/setup.sh | 26 +++++++++++++++---- .../cg-images/vars/cgLogin.groovy | 7 ++++- .../cg-images/vars/cgSign.groovy | 2 +- .../cg-images/vars/cgVerify.groovy | 2 +- 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/jenkins/setup.sh b/jenkins/setup.sh index e0055d8f..42efcc7d 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -158,8 +158,13 @@ if [[ "${SKIP_PREFLIGHT:-0}" != "1" ]]; then # order so the printed list matches the array above. PREFLIGHT_RESULTS=$(mktemp) trap 'rm -f "$PREFLIGHT_RESULTS"' EXIT + # Export ORG so the `sh -c` invocations spawned by xargs see it via the + # environment rather than via shell-quoted splicing of "$ORG" into the + # script body — a CHAINGUARD_ORG value containing a quote or shell + # metacharacter would otherwise corrupt the probe command. + export ORG printf '%s\n' "${IMAGES_TO_CHECK[@]}" | xargs -P 8 -I {} sh -c ' - if docker manifest inspect "cgr.dev/'"$ORG"'/{}" >/dev/null 2>&1; then + if docker manifest inspect "cgr.dev/${ORG}/{}" >/dev/null 2>&1; then echo "OK {}" else echo "FAIL {}" @@ -228,15 +233,26 @@ if [[ ! -f "$COSIGN_DIR/cosign.key" ]]; then --entrypoint=/usr/bin/cosign \ "cgr.dev/${ORG}/cosign:latest-dev" \ generate-key-pair - # 644 so the Jenkins container (uid 1000) and the spawned cosign container - # can both read these regardless of the host's uid. The private key stays - # encrypted with COSIGN_PASSWORD, so world-readable is fine for a local demo. - chmod 644 "$COSIGN_DIR"/cosign.key "$COSIGN_DIR"/cosign.pub "$COSIGN_DIR"/cosign.password unset GEN_COSIGN_PASSWORD echo " Keypair written to ${COSIGN_DIR}/cosign.{key,pub,password}" else echo " Reusing existing keypair in ${COSIGN_DIR}/cosign.{key,pub,password}" fi +# Apply perms unconditionally so re-runs upgrade older 644 cosign.password +# files generated before this hardening: +# cosign.key/cosign.pub at 644 — JCasC (uid 1000 in the container) reads +# cosign.key at boot via `readFileBase64:`, and cgVerify mounts +# cosign.pub into a sibling cosign container. Both happen across the +# bind mount, so host perms must let uid 1000 read regardless of the +# host user's uid. +# cosign.password at 600 — nothing inside the container reads it off +# the bind mount; setup.sh below `cat`s it (as the host user) to +# export $COSIGN_PASSWORD, which docker-compose forwards into the +# controller env, and JCasC reads it from there. Pairing world- +# readable encrypted key with world-readable passphrase would defeat +# the encryption for any other local user. +chmod 644 "$COSIGN_DIR"/cosign.key "$COSIGN_DIR"/cosign.pub +chmod 600 "$COSIGN_DIR"/cosign.password # Export COSIGN_PASSWORD so docker compose forwards it into the Jenkins # container, where JCasC interpolates it into the `cosign-password` Secret diff --git a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy index 6438fe90..86f5e5b9 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy @@ -41,7 +41,12 @@ def call() { sh ''' set -eu mkdir -p "$DOCKER_CONFIG" - AUTH=$(printf 'admin:Harbor12345' | base64) + # Pipe through `tr -d '\n'` so the base64 output is a single line + # even when GNU coreutils wraps at 76 chars (or appends a trailing + # newline that survives an internal wrap). $(...) already trims the + # final trailing newline but not internal ones, so this is a + # defensive guard for any future longer auth string. + AUTH=$(printf 'admin:Harbor12345' | base64 | tr -d '\n') cat > "$DOCKER_CONFIG/config.json" < Date: Mon, 11 May 2026 18:44:40 -0500 Subject: [PATCH 33/47] jenkins: fix kubectl --selector, bash-3.2 portability, expiration default Four Copilot findings from the third pass on PR #330: - harbor/deploy.sh: kubectl wait was passing --selector twice. The second silently wins, so the wait was matching only on app.kubernetes.io/component=controller and not on app.kubernetes.io/name=ingress-nginx. Combine into a single comma-separated selector. - shared-libraries/cg-images/refresh-digests.sh: drop the bash-4 constructs (mapfile, declare -A) so the script runs on macOS's default /bin/bash 3.2. Reftag extraction now goes through a tempfile + sort -u + while-read loop. - iac/variables.tf: bump identity_expiration default from 2027-05-07 to 2030-01-01 so demo-runners aren't forced into a yearly bump, and document why we keep a fixed date rather than computing one from timestamp() (which would churn every plan). - jenkins/plugins.txt: add a header comment making the deliberate no-pin policy explicit (this is a demo image, latest each rebuild). Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/harbor/deploy.sh | 3 +- jenkins/iac/variables.tf | 4 +-- jenkins/jenkins/plugins.txt | 6 ++++ .../cg-images/refresh-digests.sh | 36 +++++++++---------- 4 files changed, 27 insertions(+), 22 deletions(-) diff --git a/jenkins/harbor/deploy.sh b/jenkins/harbor/deploy.sh index 92c63b8c..003b86c6 100755 --- a/jenkins/harbor/deploy.sh +++ b/jenkins/harbor/deploy.sh @@ -62,8 +62,7 @@ done echo "==> Deploying ingress-nginx..." kubectl apply -f cg/manifests/deploy-ingress-nginx.yaml kubectl wait --for=condition=Ready -n ingress-nginx pod \ - --selector=app.kubernetes.io/name=ingress-nginx \ - --selector=app.kubernetes.io/component=controller \ + --selector=app.kubernetes.io/name=ingress-nginx,app.kubernetes.io/component=controller \ --timeout=3m echo "==> Installing/upgrading Harbor via Helm..." diff --git a/jenkins/iac/variables.tf b/jenkins/iac/variables.tf index 11a48bb6..4eb16959 100644 --- a/jenkins/iac/variables.tf +++ b/jenkins/iac/variables.tf @@ -10,9 +10,9 @@ variable "jenkins_issuer_url" { } variable "identity_expiration" { - description = "RFC3339 timestamp at which the assumed identity stops accepting tokens. Must be in the future. Defaults to one year from a fixed-but-recent baseline; bump or rotate by running setup.sh again." + description = "RFC3339 timestamp at which the assumed identity stops accepting tokens. Must be in the future (Chainguard provider validates this at plan time). Default is set well into the future so demo-runners don't have to bump it; rotate sooner by overriding this variable. We deliberately keep a fixed default rather than computing `timeadd(timestamp(), ...)` because `timestamp()` is recomputed on every plan and would force a churned apply every time." type = string - default = "2027-05-07T00:00:00Z" + default = "2030-01-01T00:00:00Z" } variable "jenkins_subject" { diff --git a/jenkins/jenkins/plugins.txt b/jenkins/jenkins/plugins.txt index fd9503a0..f368ea4a 100644 --- a/jenkins/jenkins/plugins.txt +++ b/jenkins/jenkins/plugins.txt @@ -1,3 +1,9 @@ +# Plugins are deliberately unpinned: this is a demo image rebuilt on demand +# (docker compose build), and we want each rebuild to pick up the current +# Jenkins-recommended plugin versions. For a production controller you'd +# pin each line as `plugin:version` (or generate a lockfile via +# `jenkins-plugin-cli --list-plugins --output txt --available-updates=false`) +# and bump them through a controlled review process. configuration-as-code job-dsl workflow-aggregator diff --git a/jenkins/shared-libraries/cg-images/refresh-digests.sh b/jenkins/shared-libraries/cg-images/refresh-digests.sh index 256a405e..0b04bfe9 100755 --- a/jenkins/shared-libraries/cg-images/refresh-digests.sh +++ b/jenkins/shared-libraries/cg-images/refresh-digests.sh @@ -43,31 +43,29 @@ CATALOG=vars/cgImage.groovy echo "Refreshing digests in ${CATALOG} against ${REGISTRY}/..." # Extract every single-quoted ":" or ":@sha256:..." entry -# from the catalog. We deduplicate by the repo:tag portion (everything before -# the optional @ digest) so multiple stale-digest entries collapse to one -# crane call. -mapfile -t pairs < <( - grep -oE "'[a-z][a-z0-9_.-]*:[a-zA-Z0-9._-]+(@sha256:[0-9a-f]{64})?'" "$CATALOG" \ - | tr -d "'" \ - | sort -u -) +# from the catalog, strip the digest suffix so multiple stale-digest entries +# collapse to one crane call, and dedupe with `sort -u`. We avoid bash-4 +# constructs (`mapfile`, `declare -A`) so this script also works on macOS's +# default /bin/bash 3.2. +reftags_file=$(mktemp) +trap 'rm -f "$reftags_file"' EXIT -if (( ${#pairs[@]} == 0 )); then +grep -oE "'[a-z][a-z0-9_.-]*:[a-zA-Z0-9._-]+(@sha256:[0-9a-f]{64})?'" "$CATALOG" \ + | tr -d "'" \ + | sed -E 's/@sha256:[0-9a-f]{64}$//' \ + | sort -u \ + > "$reftags_file" + +if [[ ! -s "$reftags_file" ]]; then echo "No image references found in ${CATALOG}." >&2 exit 1 fi -# Build the unique set of repo:tag values to query. -declare -A reftags -for entry in "${pairs[@]}"; do - reftag="${entry%@*}" - reftags["$reftag"]=1 -done - tmp=$(mktemp) +trap 'rm -f "$reftags_file" "$tmp"' EXIT cp "$CATALOG" "$tmp" -for reftag in "${!reftags[@]}"; do +while IFS= read -r reftag; do printf ' %-50s ' "$reftag" digest=$(crane digest "${REGISTRY}/${reftag}") echo "$digest" @@ -79,9 +77,11 @@ for reftag in "${!reftags[@]}"; do # replacement, eating the @ sign. sed -i.bak -E "s|'${reftag}(@sha256:[0-9a-f]{64})?'|'${pinned}'|g" "$tmp" rm -f "${tmp}.bak" -done +done < "$reftags_file" mv "$tmp" "$CATALOG" +rm -f "$reftags_file" +trap - EXIT echo # Show a diff if git is available and we're inside a checkout (e.g. running # from a developer's laptop); silently skip otherwise (e.g. inside the crane From f6e7483c2dd9ec025bfe91cbb05136c6172e2093 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 18:54:04 -0500 Subject: [PATCH 34/47] =?UTF-8?q?jenkins:=20log=20actual=20PUSH=5FREGISTRY?= =?UTF-8?q?=20in=20Mode=20B,=20fix=20DinD=E2=86=92DooD=20comment?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot findings from the fourth pass on PR #330: - cgLogin Mode B: the log line hardcoded "ttl.sh pushes" but setup.sh accepts any non-localhost PUSH_REGISTRY. Print the actual push target so the message reflects reality. - jobs.groovy: comment referred to "the DinD daemon" but the demo uses DooD (host Docker socket bind-mounted into the controller). Updated to match the architecture documented in docker-compose.yml. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/jenkins/casc/jobs.groovy | 3 ++- jenkins/shared-libraries/cg-images/vars/cgLogin.groovy | 7 +++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/jenkins/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy index 3e66740d..047b3f42 100644 --- a/jenkins/jenkins/casc/jobs.groovy +++ b/jenkins/jenkins/casc/jobs.groovy @@ -4,7 +4,8 @@ // The Jenkinsfile body is inlined into the job at seed time so we don't need an SCM. // Each pipeline's first stage copies the app sources from /sources into the workspace // (these come from the bind-mounted ./apps directory, available on both the controller -// and the DinD daemon at the same path). +// and the host Docker daemon at the same path — this demo uses DooD, not DinD; see +// docker-compose.yml for the architecture rationale). def apps = [ [ diff --git a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy index 86f5e5b9..78b86a20 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy @@ -58,8 +58,11 @@ EOF echo "cgLogin: configured Harbor admin auth for localhost / localhost:80 (Mode C)." ''' } else { - // Mode B: anonymous everywhere, nothing to write. - sh 'echo "cgLogin: Harbor mode, anonymous pulls + ttl.sh pushes (Mode B)."' + // Mode B: anonymous everywhere, nothing to write. PUSH_REGISTRY is + // typically ttl.sh/ but setup.sh accepts any non-localhost + // value, so log the actual target rather than hardcoding ttl.sh. + def pushDisplay = pushRegistry ?: '(unset)' + sh "echo 'cgLogin: Harbor mode, anonymous pulls + pushes to ${pushDisplay} (Mode B).'" } return } From f08d5dc13ef1457f447c324d3608065dc857a282 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 19:08:54 -0500 Subject: [PATCH 35/47] jenkins: drop stale PLAN.md, fix push-target docs, surface teardown failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled cleanup from the fifth Copilot pass on PR #330: - PLAN.md: delete the original spec doc. Everything it scopes is built; it had drifted from reality (NodeJs 21 → 22 LTS, image-tag typos, old hardcoded ttl.sh prefixes) and README + per-app READMEs cover the current-state view. Rewrite the four stale references to PLAN.md in node22/node25 READMEs so the rationale stands on its own. - Push-target docs: per-app READMEs and the top-level artifact table documented refs like `ttl.sh/smalls-pytest:3-14`, but the Jenkinsfiles build `${PUSH_REGISTRY}/pytest:3-14` (slash, not dash) and setup.sh prompts for an arbitrary prefix. Switch all refs to the `$PUSH_REGISTRY/:` placeholder form — matches the existing `$CHAINGUARD_ORG` placeholder convention. - teardown.sh: `terraform destroy ... || true` silently swallowed IAM cleanup failures. Capture into TF_DESTROY_FAILED, print a clear warning that names the chainctl follow-up commands, and exit non-zero at the end so scripted callers notice. Steps 3–5 still run regardless — they're independent and worth doing. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/PLAN.md | 62 --------------------- jenkins/README.md | 11 ++-- jenkins/apps/node22-npm-express/README.md | 12 ++-- jenkins/apps/node25-pnpm-express/README.md | 12 ++-- jenkins/apps/python312-pip-django/README.md | 8 +-- jenkins/apps/python314-uv-flask/README.md | 8 +-- jenkins/teardown.sh | 21 ++++++- 7 files changed, 45 insertions(+), 89 deletions(-) delete mode 100644 jenkins/PLAN.md diff --git a/jenkins/PLAN.md b/jenkins/PLAN.md deleted file mode 100644 index 7d76d248..00000000 --- a/jenkins/PLAN.md +++ /dev/null @@ -1,62 +0,0 @@ -In this directory we will implement a simple Jenkins server demo with pipeline jobs to build applications on various languages (and various versions of them) and build tools. - -All images should be Chainguard sourced and pulled from cgr.dev/$CHAINGUARD_ORG (configurable per-deployment; setup.sh prompts and persists to .env). - -Setup scripts should be provided to quickly stand up the Jenkins environment for future demonstrations. - -Create sub-directories for the various sample applications with Jenkins pipeline files in them as if they were separate repos in a real-world scenario. - -## Sample applications to build (take these one at a time) -- For each, make a simple "hello world" type application for each that also outputs info about the runtime environment (i.e. language version, env vars, etc) -- Jenkins test stage for each should be simple smoke test, nothing fancy. -- Some pipelines will archive language artifacts, others OCI images, use jenkins archival plus any other remote archival destination mentioned per app. - -### Corretto Java 17 + Maven - - Use a Springboot app - - Build stage: maven:3-jdk17-dev image and - - Test stage: amazon-corretto-jre:17 image - - Artifact to archive is the springboot runnable jar - -### Adoptium Java 8 + Maven Web app - - Simple Jetty jsp page app - - build with the maven:3-jdk-8-dev image - - test on the adoptium-jre:adoptium-openjdk-8 - - Artifact to archive is the jetty runnable war - -### OpenJDK 21 + Gradle standalone jar app - - Executable jar that just prints out to stdout - - build with the jdk:openjdk-21-dev image - - test with the jre:openjre-21 image - - Artifact to archive is the runnable jar - -### Python 3.14 + uv app - - Simple flask web site - - Install flask with uv - - build with the python:3.14-dev image - - test with the python:3.14 image - - Artifact to archive is a new image based on python:3.14 image and pushed to ttl.sh/smalls-pytest:3-14 - -### Python 3.12+ pip app - - Simple django web site - - Install django with pip - - build with the python:3.12-dev image - - test with the python:3.12 image - - Artifact to archive is a new image based on python:3.12 image and pushed to ttl.sh/smalls-pytest:3-12 - -### NodeJs 21 + npm app - - Simple npm built application (include some kind of npm library for the example) - - build with the node:21-dev image - - test with the node:21 image - - Artifact to archive is a new image based on node:21 image and pushed to ttl.sh/smalls-nodetest:21 - -### NodeJs 25 + pnpm app (using slim variant) - - Simple npm built application (include some kind of npm library for the example) - - build with the node:25-dev image - - test with the node:25-slim image - - Artifact to archive is a new image based on node:25-slim image and pushed to ttl.sh/smalls-nodetest:25 - -## Future enhancements (all done) - -- ~~Decouple the demo from the smalls.xyz hard-coded org, make that configurable~~ — done via `CHAINGUARD_ORG` in `.env`. -- ~~Add a harbor image registry option as a pull-through-mirror~~ — done in [harbor/](harbor/), opt-in via setup.sh. -- ~~Use that harbor server as a destination for image pushes instead of ttl.sh~~ — done; setup.sh's third question selects Mode B (pull from Harbor, push to ttl.sh) or Mode C (pull and push both via Harbor). diff --git a/jenkins/README.md b/jenkins/README.md index 939b9082..0bc99cbe 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -11,10 +11,12 @@ All Jenkins infrastructure and all build/test/runtime images come from `cgr.dev/ | [`corretto-java17-maven`](apps/corretto-java17-maven/) | Spring Boot console app, Maven | `maven:3-jdk17-dev` | `amazon-corretto-jre:17` | runnable JAR (Jenkins archive) | | [`adoptium-java8-jetty`](apps/adoptium-java8-jetty/) | JSP web app, Maven, Jetty 9.4 | `maven:3-jdk8-dev` | `adoptium-jre:adoptium-openjdk-8` | runnable WAR (Jenkins archive) | | [`openjdk21-gradle`](apps/openjdk21-gradle/) | CLI app, Gradle 8.14 | `jdk:openjdk-21-dev` | `jre:openjdk-21` | runnable JAR (Jenkins archive) | -| [`python314-uv-flask`](apps/python314-uv-flask/) | Flask web app, `uv` | `python:3.14-dev` | `python:3.14` | OCI image → `ttl.sh/smalls-pytest:3-14` | -| [`python312-pip-django`](apps/python312-pip-django/) | Django site, `pip` | `python:3.12-dev` | `python:3.12` | OCI image → `ttl.sh/smalls-pytest:3-12` | -| [`node22-npm-express`](apps/node22-npm-express/) | Express web app, `npm` | `node:22-dev` | `node:22` | OCI image → `ttl.sh/smalls-nodetest:22` | -| [`node25-pnpm-express`](apps/node25-pnpm-express/) | Express web app, `pnpm` | `node:25-dev` | `node:25-slim` | OCI image → `ttl.sh/smalls-nodetest:25` | +| [`python314-uv-flask`](apps/python314-uv-flask/) | Flask web app, `uv` | `python:3.14-dev` | `python:3.14` | OCI image → `$PUSH_REGISTRY/pytest:3-14` | +| [`python312-pip-django`](apps/python312-pip-django/) | Django site, `pip` | `python:3.12-dev` | `python:3.12` | OCI image → `$PUSH_REGISTRY/pytest:3-12` | +| [`node22-npm-express`](apps/node22-npm-express/) | Express web app, `npm` | `node:22-dev` | `node:22` | OCI image → `$PUSH_REGISTRY/nodetest:22` | +| [`node25-pnpm-express`](apps/node25-pnpm-express/) | Express web app, `pnpm` | `node:25-dev` | `node:25-slim` | OCI image → `$PUSH_REGISTRY/nodetest:25` | + +> `$PUSH_REGISTRY` is set by `setup.sh` (and persisted in `.env`) — typically a `ttl.sh/` for the public ttl.sh mode, or `localhost/library` when pushing to the optional Harbor mirror in Mode C. (Java pipelines archive their build artifact directly into Jenkins. Python/Node pipelines build an OCI image and push it to [ttl.sh](https://ttl.sh/) — an anonymous-push registry where tags expire after 24h. Re-run a pipeline to refresh.) @@ -175,4 +177,3 @@ rm -rf .secrets harbor/.pull-token shared-libraries/cg-images/IDENTITY ## Notes - The DooD pattern means Jenkins effectively has root-equivalent access to the host machine via the Docker socket. This is acceptable for a local demo but not for production. -- See [PLAN.md](PLAN.md) for the original sample-app spec. diff --git a/jenkins/apps/node22-npm-express/README.md b/jenkins/apps/node22-npm-express/README.md index 25f01aca..d26bece7 100644 --- a/jenkins/apps/node22-npm-express/README.md +++ b/jenkins/apps/node22-npm-express/README.md @@ -1,10 +1,10 @@ # node22-npm-express -Hello-world Express web app on Chainguard's Node 22, with `npm` as the package manager. Pipeline artifact is an OCI image pushed to `ttl.sh/smalls-nodetest:22`. +Hello-world Express web app on Chainguard's Node 22, with `npm` as the package manager. Pipeline artifact is an OCI image pushed to `$PUSH_REGISTRY/nodetest:22`. > `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. -> Note: PLAN.md originally called for Node 21, but that line is EOL and not in the catalog. We use Node 22 LTS (the natural successor) instead. +> Why Node 22 and not 21? Node 21 is EOL and not in the Chainguard catalog; 22 is the current LTS and the natural successor. ## Pipeline images @@ -13,12 +13,12 @@ Hello-world Express web app on Chainguard's Node 22, with `npm` as the package m | Build deps | `cgr.dev/$CHAINGUARD_ORG/node:22-dev` (ships `npm`) | | Image build | host docker daemon (multi-stage build) | | Test | runs the just-built image | -| Push | `ttl.sh/smalls-nodetest:22` | +| Push | `$PUSH_REGISTRY/nodetest:22` | ## npm libraries used - `express` (web server) -- `picocolors` (terminal coloring for the startup banner — included to satisfy PLAN.md's "include some kind of npm library" requirement with something visibly small and fun) +- `picocolors` (terminal coloring for the startup banner — small, dependency-free, and visibly demonstrates that npm package install is wired up) ## Smoke test @@ -35,8 +35,8 @@ This avoids opening any host port or routing across docker networks. ## Pull and run ```sh -docker pull ttl.sh/smalls-nodetest:22 -docker run --rm -p 8080:8080 ttl.sh/smalls-nodetest:22 +docker pull $PUSH_REGISTRY/nodetest:22 +docker run --rm -p 8080:8080 $PUSH_REGISTRY/nodetest:22 # Visit http://localhost:8080/ ``` diff --git a/jenkins/apps/node25-pnpm-express/README.md b/jenkins/apps/node25-pnpm-express/README.md index 34860270..3cb32c00 100644 --- a/jenkins/apps/node25-pnpm-express/README.md +++ b/jenkins/apps/node25-pnpm-express/README.md @@ -1,6 +1,6 @@ # node25-pnpm-express -Hello-world Express web app on Chainguard's Node 25, with `pnpm` as the package manager and `node:25-slim` as the runtime variant. Pipeline artifact is an OCI image pushed to `ttl.sh/smalls-nodetest:25`. +Hello-world Express web app on Chainguard's Node 25, with `pnpm` as the package manager and `node:25-slim` as the runtime variant. Pipeline artifact is an OCI image pushed to `$PUSH_REGISTRY/nodetest:25`. > `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. @@ -11,17 +11,17 @@ Hello-world Express web app on Chainguard's Node 25, with `pnpm` as the package | Build deps | `cgr.dev/$CHAINGUARD_ORG/node:25-dev` (ships pnpm 10.33) | | Image build | host docker daemon (multi-stage build) | | Test | runs the just-built image | -| Push | `ttl.sh/smalls-nodetest:25` | +| Push | `$PUSH_REGISTRY/nodetest:25` | ## Why pnpm + slim? - **pnpm**: alternative package manager that uses a content-addressable store and symlinks. `pnpm install --prod --frozen-lockfile` installs only production deps and fails fast if `pnpm-lock.yaml` is out of sync with `package.json` — useful for CI reproducibility. -- **slim**: PLAN.md called for the `node:25-slim` variant for the runtime layer. Same shell-less runtime as the regular tag but on a smaller base image. +- **slim**: the `node:25-slim` runtime variant — same shell-less runtime as the regular tag, on a smaller base image. Pairs well with pnpm's leaner `node_modules` to keep the final OCI image compact. ## npm libraries used - `express` (web server) -- `nanoid` (cryptographically random ID — used to generate a per-instance ID shown in the rendered HTML, satisfying PLAN.md's "include some kind of npm library" requirement) +- `nanoid` (cryptographically random ID — used to generate a per-instance ID shown in the rendered HTML; demonstrates a real npm dep going through pnpm install) ## Smoke test @@ -30,8 +30,8 @@ Same in-process self-request pattern as the Node 22 sibling — runs the runtime ## Pull and run ```sh -docker pull ttl.sh/smalls-nodetest:25 -docker run --rm -p 8080:8080 ttl.sh/smalls-nodetest:25 +docker pull $PUSH_REGISTRY/nodetest:25 +docker run --rm -p 8080:8080 $PUSH_REGISTRY/nodetest:25 # Visit http://localhost:8080/ ``` diff --git a/jenkins/apps/python312-pip-django/README.md b/jenkins/apps/python312-pip-django/README.md index 0b32ae38..3eb37ff2 100644 --- a/jenkins/apps/python312-pip-django/README.md +++ b/jenkins/apps/python312-pip-django/README.md @@ -1,6 +1,6 @@ # python312-pip-django -Hello-world Django site on Chainguard's Python 3.12, with `pip` as the package manager. The pipeline's archived artifact is an OCI image pushed to `ttl.sh/smalls-pytest:3-12`. +Hello-world Django site on Chainguard's Python 3.12, with `pip` as the package manager. The pipeline's archived artifact is an OCI image pushed to `$PUSH_REGISTRY/pytest:3-12`. > `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. @@ -11,7 +11,7 @@ Hello-world Django site on Chainguard's Python 3.12, with `pip` as the package m | Build deps | `cgr.dev/$CHAINGUARD_ORG/python:3.12-dev` | | Image build | host docker daemon (multi-stage build) | | Test | runs the just-built image | -| Push | `ttl.sh/smalls-pytest:3-12` | +| Push | `$PUSH_REGISTRY/pytest:3-12` | ## Single-file Django @@ -24,8 +24,8 @@ Same trick as the Flask sibling: the runtime image is shell-less, so the Test st ## Pull and run ```sh -docker pull ttl.sh/smalls-pytest:3-12 -docker run --rm -p 8080:8080 ttl.sh/smalls-pytest:3-12 +docker pull $PUSH_REGISTRY/pytest:3-12 +docker run --rm -p 8080:8080 $PUSH_REGISTRY/pytest:3-12 # Visit http://localhost:8080/ ``` diff --git a/jenkins/apps/python314-uv-flask/README.md b/jenkins/apps/python314-uv-flask/README.md index e09f54ca..4735e776 100644 --- a/jenkins/apps/python314-uv-flask/README.md +++ b/jenkins/apps/python314-uv-flask/README.md @@ -1,6 +1,6 @@ # python314-uv-flask -Hello-world Flask web app on Chainguard's Python 3.14, with `uv` as the package manager. The pipeline's archived artifact is an OCI image pushed to `ttl.sh/smalls-pytest:3-14` — not a file checked into Jenkins' archive store. +Hello-world Flask web app on Chainguard's Python 3.14, with `uv` as the package manager. The pipeline's archived artifact is an OCI image pushed to `$PUSH_REGISTRY/pytest:3-14` — not a file checked into Jenkins' archive store. > `$CHAINGUARD_ORG` below stands in for your configured Chainguard org — see the top-level [README](../../README.md#configuration) for how that gets set. @@ -11,7 +11,7 @@ Hello-world Flask web app on Chainguard's Python 3.14, with `uv` as the package | Build deps | `cgr.dev/$CHAINGUARD_ORG/python:3.14-dev` (ships `uv` pre-installed) | | Image build | host docker daemon (multi-stage build) | | Test | runs the just-built image | -| Push | `ttl.sh/smalls-pytest:3-14` | +| Push | `$PUSH_REGISTRY/pytest:3-14` | The runtime image (final stage of the Dockerfile) is the shell-less `cgr.dev/$CHAINGUARD_ORG/python:3.14`. Site-packages from the build stage are copied across — same Python minor version on both sides keeps the packages compatible. @@ -22,8 +22,8 @@ Because the runtime image has no shell, the Test stage runs the image with `--en ## Pull and run ```sh -docker pull ttl.sh/smalls-pytest:3-14 -docker run --rm -p 8080:8080 ttl.sh/smalls-pytest:3-14 +docker pull $PUSH_REGISTRY/pytest:3-14 +docker run --rm -p 8080:8080 $PUSH_REGISTRY/pytest:3-14 # Visit http://localhost:8080/ ``` diff --git a/jenkins/teardown.sh b/jenkins/teardown.sh index 200ea0e6..8b3f1423 100755 --- a/jenkins/teardown.sh +++ b/jenkins/teardown.sh @@ -48,14 +48,26 @@ if [[ -x harbor/teardown.sh ]]; then fi echo "==> 2/5 Releasing Chainguard assumed identity (if Terraform state present)..." +TF_DESTROY_FAILED=0 if [[ -f iac/terraform.tfstate ]]; then if [[ -z "${CHAINGUARD_ORG:-}" ]]; then echo " SKIPPING: CHAINGUARD_ORG not set in .env, can't run terraform destroy." echo " The identity will linger; clean it up manually with chainctl iam identities delete." + TF_DESTROY_FAILED=1 else - ( cd iac && terraform destroy -auto-approve \ + # Don't abort the whole teardown if destroy fails — the remaining steps + # (stopping Jenkins, wiping /tmp/cgjenkins-home, removing local state) + # are still worth doing. But we DO surface the failure so the user knows + # to clean up the assumed identity manually, and we exit non-zero at the + # end so CI / scripted callers see it. + if ! ( cd iac && terraform destroy -auto-approve \ -var="chainguard_group_name=${CHAINGUARD_ORG}" \ - -var="jenkins_issuer_url=${JENKINS_OIDC_ISSUER:-https://localhost:8080/oidc}" || true ) + -var="jenkins_issuer_url=${JENKINS_OIDC_ISSUER:-https://localhost:8080/oidc}" ); then + TF_DESTROY_FAILED=1 + echo " WARNING: terraform destroy failed. The Chainguard assumed identity may still exist." >&2 + echo " Inspect with: chainctl iam identities list --parent='${CHAINGUARD_ORG}'" >&2 + echo " Delete manually with: chainctl iam identities delete " >&2 + fi fi fi @@ -85,4 +97,9 @@ if [[ "$WIPE_ENV" == "true" ]]; then fi echo +if (( TF_DESTROY_FAILED == 1 )); then + echo "==> Done — WITH WARNINGS (terraform destroy was skipped or failed; see above)." >&2 + echo " Re-run ./setup.sh to bootstrap from scratch." + exit 1 +fi echo "==> Done. Re-run ./setup.sh to bootstrap from scratch." From 8b352b0bd8ded30a067e44d8f316a4ddb35fed1b Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 19:20:29 -0500 Subject: [PATCH 36/47] jenkins: harden update_env, cgLogin Mode B, teardown output, env defaults MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six Copilot findings from the sixth pass on PR #330: - cgLogin Mode B: pass pushRegistry through withEnv instead of interpolating into a single-quoted shell string. Same defense pattern as cgSign/cgVerify use for their image arg. - setup.sh update_env: replace the sed s||| substitution with a pure-bash line-by-line rewrite. Sed's replacement side treats & as the matched string and \N as a backref, which would silently corrupt .env if a user entered a registry/org containing &, \, or | (sanity-tested all three round-trip verbatim now). - teardown.sh: drop the misleading "N/5" step counters — they didn't match the --wipe-env 6-step path. Also remove `| tail -5` on `docker compose down` so failure output isn't truncated. - harbor/cg/helm/values.template: reword the broken-grammar Trivy comment to "Enable the Trivy vulnerability scanner." - ops/refresh-cgimages-digests/Jenkinsfile: use `${env.X ?: ''}` in the args interpolation so a missing env var collapses to empty rather than the literal "null" (which would otherwise bake PULL_REGISTRY=null into the agent env). Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/harbor/cg/helm/values.template | 2 +- .../ops/refresh-cgimages-digests/Jenkinsfile | 7 +++++- jenkins/setup.sh | 25 ++++++++++++++++--- .../cg-images/vars/cgLogin.groovy | 8 ++++-- jenkins/teardown.sh | 14 ++++++----- 5 files changed, 42 insertions(+), 14 deletions(-) diff --git a/jenkins/harbor/cg/helm/values.template b/jenkins/harbor/cg/helm/values.template index 41726bfa..5ec9a6b6 100644 --- a/jenkins/harbor/cg/helm/values.template +++ b/jenkins/harbor/cg/helm/values.template @@ -52,7 +52,7 @@ registry: tag: latest trivy: - # enabled the flag to enable Trivy scanner + # Enable the Trivy vulnerability scanner. enabled: true image: repository: $REGISTRY_URL/harbor-trivy-adapter diff --git a/jenkins/ops/refresh-cgimages-digests/Jenkinsfile b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile index e1231e07..ec15428d 100644 --- a/jenkins/ops/refresh-cgimages-digests/Jenkinsfile +++ b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile @@ -49,7 +49,12 @@ pipeline { // cgr.dev directly in Mode A, anonymous Harbor proxy in B/C. // The docker-workflow plugin only carries env vars listed // here into the agent container. - args "--entrypoint= --network host -e DOCKER_CONFIG=/tmp/cgjenkins-home/.docker -e CHAINGUARD_ORG=${env.CHAINGUARD_ORG} -e PULL_REGISTRY=${env.PULL_REGISTRY}" + // Use `?: ''` (not direct interpolation) so a missing env var + // collapses to empty rather than the literal "null" — Groovy + // GString stringifies a null reference, which would otherwise + // bake PULL_REGISTRY=null into the agent env and break the + // PULL_REGISTRY:-default fallback in refresh-digests.sh. + args "--entrypoint= --network host -e DOCKER_CONFIG=/tmp/cgjenkins-home/.docker -e CHAINGUARD_ORG=${env.CHAINGUARD_ORG ?: ''} -e PULL_REGISTRY=${env.PULL_REGISTRY ?: ''}" reuseNode true } } diff --git a/jenkins/setup.sh b/jenkins/setup.sh index 42efcc7d..8ea84a6e 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -266,11 +266,28 @@ echo "==> Writing mode flags to .env..." [[ -f .env ]] || cp .env.example .env update_env() { local key="$1" value="$2" - if grep -q "^${key}=" .env; then - sed -i.bak "s|^${key}=.*|${key}=${value}|" .env && rm -f .env.bak - else - printf '%s=%s\n' "$key" "$value" >> .env + # Rewrite .env line-by-line in pure bash rather than passing $value through + # sed. A user-supplied registry/org could contain &, \, |, or other sed + # special chars (the s/// replacement side treats & as the matched string + # and \N as a backref), which would silently corrupt the .env. Building + # the file in a temp and atomically renaming avoids that whole class of bug. + local tmp + tmp=$(mktemp) + local found=0 + if [[ -f .env ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + if [[ "$line" == "${key}="* ]]; then + printf '%s=%s\n' "$key" "$value" >> "$tmp" + found=1 + else + printf '%s\n' "$line" >> "$tmp" + fi + done < .env + fi + if (( found == 0 )); then + printf '%s=%s\n' "$key" "$value" >> "$tmp" fi + mv "$tmp" .env } update_env CHAINGUARD_ORG "$ORG" update_env HARBOR_ENABLED "$HARBOR_ENABLED" diff --git a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy index 78b86a20..6aa5b58e 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy @@ -61,8 +61,12 @@ EOF // Mode B: anonymous everywhere, nothing to write. PUSH_REGISTRY is // typically ttl.sh/ but setup.sh accepts any non-localhost // value, so log the actual target rather than hardcoding ttl.sh. - def pushDisplay = pushRegistry ?: '(unset)' - sh "echo 'cgLogin: Harbor mode, anonymous pulls + pushes to ${pushDisplay} (Mode B).'" + // Pass pushRegistry through the sh-step environment rather than + // interpolating into the script body — defends against shell + // metacharacters in a user-supplied PUSH_REGISTRY value. + withEnv(["PUSH_DISPLAY=${pushRegistry ?: '(unset)'}"]) { + sh 'echo "cgLogin: Harbor mode, anonymous pulls + pushes to $PUSH_DISPLAY (Mode B)."' + } } return } diff --git a/jenkins/teardown.sh b/jenkins/teardown.sh index 8b3f1423..2d5c676a 100755 --- a/jenkins/teardown.sh +++ b/jenkins/teardown.sh @@ -42,12 +42,12 @@ read -rp "Continue? [y/N]: " ans # Source .env if present (for ORG / settings that affect cleanup). [[ -f .env ]] && { set -a; source .env; set +a; } || true -echo "==> 1/5 Tearing down Harbor (kind cluster, if any)..." +echo "==> Tearing down Harbor (kind cluster, if any)..." if [[ -x harbor/teardown.sh ]]; then harbor/teardown.sh fi -echo "==> 2/5 Releasing Chainguard assumed identity (if Terraform state present)..." +echo "==> Releasing Chainguard assumed identity (if Terraform state present)..." TF_DESTROY_FAILED=0 if [[ -f iac/terraform.tfstate ]]; then if [[ -z "${CHAINGUARD_ORG:-}" ]]; then @@ -71,10 +71,12 @@ if [[ -f iac/terraform.tfstate ]]; then fi fi -echo "==> 3/5 Stopping Jenkins (docker compose down)..." -docker compose down --rmi local --remove-orphans 2>&1 | tail -5 +echo "==> Stopping Jenkins (docker compose down)..." +# Print full output; truncating with `tail -5` hides earlier errors that +# would explain why compose-down failed. +docker compose down --rmi local --remove-orphans -echo "==> 4/5 Removing /tmp/cgjenkins-home..." +echo "==> Removing /tmp/cgjenkins-home..." # On macOS + OrbStack the bind-mount is owned by the host user (no sudo). # On Linux it may be owned by uid 1000 from inside the container, which maps # to a different host user — fall back to sudo only when plain rm fails. @@ -83,7 +85,7 @@ if ! rm -rf /tmp/cgjenkins-home 2>/dev/null; then sudo rm -rf /tmp/cgjenkins-home fi -echo "==> 5/5 Cleaning generated files..." +echo "==> Cleaning generated files..." rm -rf .secrets rm -f harbor/.pull-token rm -f shared-libraries/cg-images/IDENTITY From 65969e4d8a0bf13d24425a39537ded792e74c5ca Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 19:29:08 -0500 Subject: [PATCH 37/47] jenkins: mkdir DOCKER_CONFIG in cgLogin Mode B, harden django smoke test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot findings from the seventh pass on PR #330: - cgLogin Mode B: mkdir -p "$DOCKER_CONFIG" eagerly even though we don't write creds. cgSign/cgVerify always bind-mount it into a sibling cosign container with `docker run -v "$DOCKER_CONFIG":...`; if the dir doesn't exist the host docker daemon auto-creates it as root, and a later switch to Mode A would then fail when chainctl (uid 1000 inside the controller) tries to write a fresh config there. Mode A/C already mkdir, only Mode B was missing it. - python312-pip-django/app.py: add an explicit django.setup() and a real (in-memory sqlite) DATABASES default. contrib.auth and contrib.contenttypes declare models and can trigger AppRegistryNot Ready / DB-config errors during app loading. The smoke test happens to pass today because the hello view never touches the registry, but the configuration is invalid per Django's contract — verified the fixed app round-trips the Test-stage Client().get('/') call. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/apps/python312-pip-django/app.py | 18 ++++++++++++++- .../cg-images/vars/cgLogin.groovy | 23 ++++++++++++++----- 2 files changed, 34 insertions(+), 7 deletions(-) diff --git a/jenkins/apps/python312-pip-django/app.py b/jenkins/apps/python312-pip-django/app.py index 49e93e6e..e4a38206 100644 --- a/jenkins/apps/python312-pip-django/app.py +++ b/jenkins/apps/python312-pip-django/app.py @@ -5,6 +5,7 @@ import os import platform import sys +import django from django.conf import settings from django.core.management import execute_from_command_line from django.http import HttpResponse @@ -17,10 +18,25 @@ ALLOWED_HOSTS=["*"], INSTALLED_APPS=["django.contrib.contenttypes", "django.contrib.auth"], MIDDLEWARE=[], - DATABASES={}, + # In-memory SQLite — contrib.contenttypes and contrib.auth declare models + # and need a `default` DB or app loading bombs out. We never touch the DB + # at request time (the hello view returns a constant), so :memory: is fine. + DATABASES={ + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + }, + }, USE_TZ=True, ) +# Initialise the app registry. `execute_from_command_line` (runserver path) +# calls this internally, but the Test stage imports this module directly and +# invokes django.test.Client without going through that codepath — without an +# explicit setup() call the app registry stays unpopulated and the test fails +# with AppRegistryNotReady the moment contrib.auth's signals fire. +django.setup() + def hello(request): import django diff --git a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy index 6aa5b58e..022d2bf7 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy @@ -58,14 +58,25 @@ EOF echo "cgLogin: configured Harbor admin auth for localhost / localhost:80 (Mode C)." ''' } else { - // Mode B: anonymous everywhere, nothing to write. PUSH_REGISTRY is - // typically ttl.sh/ but setup.sh accepts any non-localhost - // value, so log the actual target rather than hardcoding ttl.sh. - // Pass pushRegistry through the sh-step environment rather than - // interpolating into the script body — defends against shell + // Mode B: anonymous everywhere, no creds to write. We still mkdir + // $DOCKER_CONFIG eagerly so it exists with uid-1000 ownership before + // cgSign/cgVerify bind-mount it into a sibling cosign container — if + // the dir doesn't exist when `docker run -v "$DOCKER_CONFIG":...` runs, + // the host docker daemon auto-creates it as root, and a later switch + // to Mode A would then fail when chainctl (uid 1000 inside the + // controller) tries to write a fresh docker config there. + // + // PUSH_REGISTRY is typically ttl.sh/ but setup.sh accepts any + // non-localhost value, so log the actual target rather than hardcoding + // ttl.sh. Pass pushRegistry through the sh-step environment rather + // than interpolating into the script body — defends against shell // metacharacters in a user-supplied PUSH_REGISTRY value. withEnv(["PUSH_DISPLAY=${pushRegistry ?: '(unset)'}"]) { - sh 'echo "cgLogin: Harbor mode, anonymous pulls + pushes to $PUSH_DISPLAY (Mode B)."' + sh ''' + set -eu + mkdir -p "$DOCKER_CONFIG" + echo "cgLogin: Harbor mode, anonymous pulls + pushes to $PUSH_DISPLAY (Mode B)." + ''' } } return From d24f2529ce6463f177ddb9cbf1448334bbe36c49 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 19:39:24 -0500 Subject: [PATCH 38/47] jenkins: read cosign passphrase from file, fix doc digest example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Copilot findings from the seventh pass on PR #330: - COSIGN_PASSWORD env var: drop it from the controller env entirely and have JCasC read the passphrase from the bind-mounted file via `${readFile:/tmp/cgjenkins-home/.secrets/cosign.password}` at boot. The env-var path was visible to `docker inspect` and one stray `echo $COSIGN_PASSWORD` away from showing up in build logs. Tradeoff: cosign.password drops back from 600 to 644 because JCasC (uid 1000 inside the controller) needs to read it across the bind mount, and the host user's uid usually isn't 1000. Documented the resulting threat-model assumption (single-user dev laptop). - README verify-image example: replace `{{index .RepoDigests 0}}` with the repo-filtered loop that cgSign/cgVerify already use, so the doc stops handing readers a digest that may belong to a different push (the local image cache often carries stale RepoDigests from earlier runs against different registries). - python312-pip-django/app.py: drop the redundant inner `import django` in hello() — django is already imported at module scope (added in the previous round to fix the AppRegistryNotReady path). Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/README.md | 10 +++++++- jenkins/apps/python312-pip-django/app.py | 1 - jenkins/docker-compose.yml | 12 ++++----- jenkins/jenkins/casc/jenkins.yaml | 7 +++++- jenkins/setup.sh | 32 ++++++++---------------- 5 files changed, 31 insertions(+), 31 deletions(-) diff --git a/jenkins/README.md b/jenkins/README.md index 0bc99cbe..813663e1 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -114,7 +114,15 @@ A clean build takes 10s–40s once images are cached locally; the Gradle pipelin The public key sits at `/tmp/cgjenkins-home/.secrets/cosign.pub`. To check a pushed image from outside Jenkins, run cosign in a host-network container so `localhost` resolves to your ingress: ```sh -DIGEST=$(docker image inspect --format '{{index .RepoDigests 0}}' localhost/library/pytest:3-14) +# Pick the RepoDigest whose repo matches the image you just pulled. A +# single image can have multiple RepoDigests in the local cache (one per +# registry/org it has been pushed to), and `{{index .RepoDigests 0}}` +# returns whichever happens to be first — which may not match the repo +# you want to verify. Filter by the repo prefix to be safe. cgSign and +# cgVerify use the same pattern internally. +IMAGE=localhost/library/pytest:3-14 +REPO="${IMAGE%:*}" +DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1) # cosign's reference parser rejects bare 'localhost' (it tries Docker Hub), # so rewrite to 'localhost:80' before passing to cosign: DIGEST="${DIGEST/#localhost\//localhost:80/}" diff --git a/jenkins/apps/python312-pip-django/app.py b/jenkins/apps/python312-pip-django/app.py index e4a38206..1293077b 100644 --- a/jenkins/apps/python312-pip-django/app.py +++ b/jenkins/apps/python312-pip-django/app.py @@ -39,7 +39,6 @@ def hello(request): - import django body = ( "

Hello from Django on Chainguard

" "

Runtime info

" diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml index 08c4e2c4..9f3b426a 100644 --- a/jenkins/docker-compose.yml +++ b/jenkins/docker-compose.yml @@ -30,13 +30,11 @@ services: PULL_REGISTRY: ${PULL_REGISTRY:-cgr.dev/${CHAINGUARD_ORG}} PUSH_REGISTRY: ${PUSH_REGISTRY:-ttl.sh/smalls} HARBOR_ENABLED: ${HARBOR_ENABLED:-false} - # Cosign signing-key passphrase. setup.sh exports this from - # /tmp/cgjenkins-home/.secrets/cosign.password before bringing Jenkins - # up; JCasC interpolates it into the `cosign-password` credential at - # boot. Empty if setup.sh hasn't been run yet — Jenkins still boots, - # but pipelines that call cgSign() will fail with a clear "credential - # not found" error. - COSIGN_PASSWORD: ${COSIGN_PASSWORD:-} + # NOTE: the cosign signing-key passphrase used to live here as + # COSIGN_PASSWORD. JCasC now reads it directly off the bind-mounted + # file at boot via `${readFile:/tmp/cgjenkins-home/.secrets/cosign.password}`, + # so it never sits in the long-lived controller env (where it would + # show up in `docker inspect` and could leak into build logs). JENKINS_HOME: /tmp/cgjenkins-home CASC_JENKINS_CONFIG: /tmp/cgjenkins-home/casc JENKINS_ADMIN_PASSWORD: ${JENKINS_ADMIN_PASSWORD:-admin} diff --git a/jenkins/jenkins/casc/jenkins.yaml b/jenkins/jenkins/casc/jenkins.yaml index 64e1b0d0..6bd7c2ee 100644 --- a/jenkins/jenkins/casc/jenkins.yaml +++ b/jenkins/jenkins/casc/jenkins.yaml @@ -110,7 +110,12 @@ credentials: scope: GLOBAL id: cosign-password description: "passphrase that decrypts cosign-private-key" - secret: "${COSIGN_PASSWORD}" + # Read the passphrase directly from the bind-mounted secret file + # rather than via the controller's env. Avoids leaving the + # passphrase visible to `docker inspect` and prevents accidental + # propagation into build logs (the env-var path used to be one + # `echo $COSIGN_PASSWORD` in a debug step away from disclosure). + secret: "${readFile:/tmp/cgjenkins-home/.secrets/cosign.password}" jobs: - file: /tmp/cgjenkins-home/casc/jobs.groovy diff --git a/jenkins/setup.sh b/jenkins/setup.sh index 8ea84a6e..c0ef75ce 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -238,27 +238,17 @@ if [[ ! -f "$COSIGN_DIR/cosign.key" ]]; then else echo " Reusing existing keypair in ${COSIGN_DIR}/cosign.{key,pub,password}" fi -# Apply perms unconditionally so re-runs upgrade older 644 cosign.password -# files generated before this hardening: -# cosign.key/cosign.pub at 644 — JCasC (uid 1000 in the container) reads -# cosign.key at boot via `readFileBase64:`, and cgVerify mounts -# cosign.pub into a sibling cosign container. Both happen across the -# bind mount, so host perms must let uid 1000 read regardless of the -# host user's uid. -# cosign.password at 600 — nothing inside the container reads it off -# the bind mount; setup.sh below `cat`s it (as the host user) to -# export $COSIGN_PASSWORD, which docker-compose forwards into the -# controller env, and JCasC reads it from there. Pairing world- -# readable encrypted key with world-readable passphrase would defeat -# the encryption for any other local user. -chmod 644 "$COSIGN_DIR"/cosign.key "$COSIGN_DIR"/cosign.pub -chmod 600 "$COSIGN_DIR"/cosign.password - -# Export COSIGN_PASSWORD so docker compose forwards it into the Jenkins -# container, where JCasC interpolates it into the `cosign-password` Secret -# Text credential at boot. The key + pub files are loaded directly by JCasC -# via `${readFileBase64:…}` so they don't need to ride in env vars. -export COSIGN_PASSWORD="$(cat "$COSIGN_DIR/cosign.password")" +# All three files at 644 so JCasC (uid 1000 inside the Jenkins controller, +# typically a different uid than the host user) can read them across the +# bind mount via `${readFile:…}` / `${readFileBase64:…}` at boot. We used +# to keep cosign.password at 600 — but that only worked because the +# passphrase rode into the controller via the COSIGN_PASSWORD env var; we +# dropped that path (it was visible to `docker inspect` and could leak +# into build logs) in favour of reading the file directly. The local-demo +# threat model is "single dev laptop, host user only"; for production +# you'd want chown 1000:1000 + chmod 600 and a separate setup.sh path that +# doesn't need to round-trip the password through host-side reads. +chmod 644 "$COSIGN_DIR"/cosign.key "$COSIGN_DIR"/cosign.pub "$COSIGN_DIR"/cosign.password # ---- Phase 1: write .env first so docker compose picks up the new mode ---- From ed4eacd69bfc62b4c5f9c0b83bba88383a133e6b Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 19:51:10 -0500 Subject: [PATCH 39/47] jenkins: HARBOR_ADMIN_PASSWORD env, cosign 600 perms, ttl.sh expiry docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bundled remediations from the ninth Copilot pass on PR #330: - HARBOR_ADMIN_PASSWORD: drop the hardcoded "Harbor12345" literal from cgLogin (Mode C push-auth), the harbor Terraform provider, and the Helm values template. All three now consume a single env var that defaults to "Harbor12345" (the chart's own default) when unset, but can be overridden via .env before running setup.sh. Wired through setup.sh → .env, deploy.sh → Helm + tfvars, JCasC globalNodeProperties → controller env → cgLogin. Updated the user-facing docs (top-level README, harbor/README, setup.sh and deploy.sh status prints) to mention the variable. - cosign perms: re-tighten to chmod 600. Round 7's switch to ${readFile:…} forced regress to 644 because JCasC (uid 1000) couldn't read host-user-owned 600 files across the bind mount. setup.sh now does a one-shot root container that chowns the key, pub, and password files to uid 1000:1000 and chmods 600 — JCasC reads as the same uid, and setup.sh no longer needs host-side read access (the passphrase isn't round-tripped through env any more). Teardown.sh's existing sudo-rm fallback covers cleanup. - ttl.sh expiry notes: the four per-app READMEs (python312, 314, node22, 25) all said "ttl.sh tags expire" unconditionally, but Mode C pushes go to Harbor (persistent). Reword to be conditional on $PUSH_REGISTRY. jobs.groovy descriptions had the same issue; also reworded. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/.env.example | 9 ++++ jenkins/README.md | 2 +- jenkins/apps/node22-npm-express/README.md | 2 +- jenkins/apps/node25-pnpm-express/README.md | 2 +- jenkins/apps/python312-pip-django/README.md | 2 +- jenkins/apps/python314-uv-flask/README.md | 2 +- jenkins/docker-compose.yml | 6 +++ jenkins/harbor/README.md | 2 +- jenkins/harbor/cg/helm/values.template | 5 +++ jenkins/harbor/deploy.sh | 7 ++- jenkins/harbor/terraform/main.tf | 9 ++-- .../harbor/terraform/terraform.templatevars | 3 +- jenkins/harbor/terraform/variables.tf | 6 +++ jenkins/jenkins/casc/jenkins.yaml | 6 +++ jenkins/jenkins/casc/jobs.groovy | 8 ++-- jenkins/setup.sh | 44 +++++++++++++------ .../cg-images/vars/cgLogin.groovy | 20 +++++++-- 17 files changed, 103 insertions(+), 32 deletions(-) diff --git a/jenkins/.env.example b/jenkins/.env.example index 06c944d5..f983ab30 100644 --- a/jenkins/.env.example +++ b/jenkins/.env.example @@ -32,6 +32,15 @@ # skips the OIDC chainctl exchange when true (Harbor pulls are anonymous). # HARBOR_ENABLED=false +# Harbor admin password. Default is the chart's own default ("Harbor12345") +# so the demo works out of the box. setup.sh writes this when Harbor is +# enabled; the value drives: +# - the Helm chart's harborAdminPassword +# - the Terraform harbor provider (used for the cgr-proxy project) +# - cgLogin's Mode C push-auth (cgr-proxy admin docker config) +# Override before running setup.sh to use a different password. +# HARBOR_ADMIN_PASSWORD=Harbor12345 + # Note: the Chainguard assumed identity UIDP (Mode A) is NOT stored here. # setup.sh writes it to shared-libraries/cg-images/IDENTITY (filesystem-SCM # live-loaded; cgLogin reads it at build time). diff --git a/jenkins/README.md b/jenkins/README.md index 813663e1..19967d34 100644 --- a/jenkins/README.md +++ b/jenkins/README.md @@ -160,7 +160,7 @@ These bit me while building out the seven samples — useful to know up front wh - **Gradle**: `environment { GRADLE_USER_HOME = "${WORKSPACE}/.gradle" }` - **npm / pnpm**: `environment { HOME = "${WORKSPACE}" }` - **Chainguard's `python:3.x-dev` runs as uid 65532**, which can't write to the system site-packages. For pipelines that want to `pip install --system` or `uv pip install --system`, pass `args '--user 0 --entrypoint='` in the Jenkinsfile **and** add `USER 0` to the corresponding stage in the Dockerfile. -- **OCI-image pipelines push to whatever `$PUSH_REGISTRY` resolves to**: `ttl.sh/` in Modes A/B (anonymous, 24h TTL) or `localhost/library` in Mode C (Harbor admin/Harbor12345 baked in by `cgLogin`). Per-app `IMAGE` envs use `${env.PUSH_REGISTRY}/:` — works for both ttl.sh and Harbor without per-mode pipeline edits. +- **OCI-image pipelines push to whatever `$PUSH_REGISTRY` resolves to**: `ttl.sh/` in Modes A/B (anonymous, 24h TTL) or `localhost/library` in Mode C (Harbor admin auth supplied by `cgLogin`, sourced from `$HARBOR_ADMIN_PASSWORD` — defaults to the chart's `Harbor12345`, overridable in `.env`). Per-app `IMAGE` envs use `${env.PUSH_REGISTRY}/:` — works for both ttl.sh and Harbor without per-mode pipeline edits. - **The Auth stage must precede any `agent { docker { image '...' } }` stage**, because the docker-workflow plugin pulls the agent's image using whatever creds are in `$DOCKER_CONFIG` *at the start of that stage*. The current pipeline shape (`Auth` → `Checkout` → docker-agent stages) gets the ordering right; preserve it when adding new pipelines. ## Teardown diff --git a/jenkins/apps/node22-npm-express/README.md b/jenkins/apps/node22-npm-express/README.md index d26bece7..876bf783 100644 --- a/jenkins/apps/node22-npm-express/README.md +++ b/jenkins/apps/node22-npm-express/README.md @@ -40,4 +40,4 @@ docker run --rm -p 8080:8080 $PUSH_REGISTRY/nodetest:22 # Visit http://localhost:8080/ ``` -ttl.sh tags expire (default 24 hours). Re-run the pipeline to refresh. +If `$PUSH_REGISTRY` points at ttl.sh (Modes A/B), tags expire after the default 24 hours — re-run the pipeline to refresh. In Mode C the push goes to the local Harbor (`localhost/library/...`) and persists until teardown. diff --git a/jenkins/apps/node25-pnpm-express/README.md b/jenkins/apps/node25-pnpm-express/README.md index 3cb32c00..3b255465 100644 --- a/jenkins/apps/node25-pnpm-express/README.md +++ b/jenkins/apps/node25-pnpm-express/README.md @@ -35,4 +35,4 @@ docker run --rm -p 8080:8080 $PUSH_REGISTRY/nodetest:25 # Visit http://localhost:8080/ ``` -ttl.sh tags expire (default 24 hours). Re-run the pipeline to refresh. +If `$PUSH_REGISTRY` points at ttl.sh (Modes A/B), tags expire after the default 24 hours — re-run the pipeline to refresh. In Mode C the push goes to the local Harbor (`localhost/library/...`) and persists until teardown. diff --git a/jenkins/apps/python312-pip-django/README.md b/jenkins/apps/python312-pip-django/README.md index 3eb37ff2..bfac0827 100644 --- a/jenkins/apps/python312-pip-django/README.md +++ b/jenkins/apps/python312-pip-django/README.md @@ -29,4 +29,4 @@ docker run --rm -p 8080:8080 $PUSH_REGISTRY/pytest:3-12 # Visit http://localhost:8080/ ``` -ttl.sh tags expire (default 24 hours). Re-run the pipeline to refresh. +If `$PUSH_REGISTRY` points at ttl.sh (Modes A/B), tags expire after the default 24 hours — re-run the pipeline to refresh. In Mode C the push goes to the local Harbor (`localhost/library/...`) and persists until teardown. diff --git a/jenkins/apps/python314-uv-flask/README.md b/jenkins/apps/python314-uv-flask/README.md index 4735e776..ec058659 100644 --- a/jenkins/apps/python314-uv-flask/README.md +++ b/jenkins/apps/python314-uv-flask/README.md @@ -27,4 +27,4 @@ docker run --rm -p 8080:8080 $PUSH_REGISTRY/pytest:3-14 # Visit http://localhost:8080/ ``` -Note: ttl.sh tags expire (default 24 hours). Re-run the pipeline to refresh. +Note: if `$PUSH_REGISTRY` points at ttl.sh (Modes A/B), tags expire after the default 24 hours — re-run the pipeline to refresh. In Mode C the push goes to the local Harbor (`localhost/library/...`) and persists until teardown. diff --git a/jenkins/docker-compose.yml b/jenkins/docker-compose.yml index 9f3b426a..540cb238 100644 --- a/jenkins/docker-compose.yml +++ b/jenkins/docker-compose.yml @@ -30,6 +30,12 @@ services: PULL_REGISTRY: ${PULL_REGISTRY:-cgr.dev/${CHAINGUARD_ORG}} PUSH_REGISTRY: ${PUSH_REGISTRY:-ttl.sh/smalls} HARBOR_ENABLED: ${HARBOR_ENABLED:-false} + # Harbor admin password — surfaced to pipelines via JCasC so cgLogin's + # Mode C path doesn't have to hardcode the chart default in source. + # setup.sh persists this in .env when Harbor is enabled; deploy.sh and + # the harbor Terraform provider read the same value from there. Empty + # when Harbor isn't in use — cgLogin only reads it on the Mode C path. + HARBOR_ADMIN_PASSWORD: ${HARBOR_ADMIN_PASSWORD:-} # NOTE: the cosign signing-key passphrase used to live here as # COSIGN_PASSWORD. JCasC now reads it directly off the bind-mounted # file at boot via `${readFile:/tmp/cgjenkins-home/.secrets/cosign.password}`, diff --git a/jenkins/harbor/README.md b/jenkins/harbor/README.md index 8571b01e..a509a381 100644 --- a/jenkins/harbor/README.md +++ b/jenkins/harbor/README.md @@ -55,6 +55,6 @@ export PULL_PASS=... ./deploy.sh ``` -The Harbor admin UI is at (`admin` / `Harbor12345`). The chart issues a self-signed cert, so browsers will show a one-time warning — click through. Tear down with `./teardown.sh`. +The Harbor admin UI is at . The username is `admin`; the password is whatever `$HARBOR_ADMIN_PASSWORD` resolves to (default: the chart's `Harbor12345`; override by setting `HARBOR_ADMIN_PASSWORD` in `../.env` before running `../setup.sh`). The chart issues a self-signed cert, so browsers will show a one-time warning — click through. Tear down with `./teardown.sh`. > **Why HTTPS for the UI but HTTP for the registry?** Harbor 2.12.3+ ships with `gorilla/csrf` v1.7.3, which hardcodes the request scheme to `https` inside its origin check. Harbor's middleware doesn't compensate, so a plain-HTTP `POST /c/login` is rejected with `403 origin invalid` before the password is even checked (upstream bug: [goharbor/harbor#22010](https://github.com/goharbor/harbor/issues/22010)). The Docker daemon, in turn, refuses HTTPS connections to `127.0.0.0/8` by default, so the registry path (`/v2/`, `/service/token`, etc.) must remain reachable over HTTP. We split the two: TLS is enabled on the ingress for the UI, but `ssl-redirect` is off so HTTP isn't forced — the `externalURL` stays `http://localhost` so Harbor advertises HTTP to docker clients. Browser users type `https://`, pipelines push over `http://`, both work. diff --git a/jenkins/harbor/cg/helm/values.template b/jenkins/harbor/cg/helm/values.template index 5ec9a6b6..ce991e3e 100644 --- a/jenkins/harbor/cg/helm/values.template +++ b/jenkins/harbor/cg/helm/values.template @@ -13,6 +13,11 @@ # so HTTP→HTTPS isn't forced — that lets `docker push` over HTTP coexist # with the HTTPS UI on the same ingress. externalURL: http://localhost +# Admin password injected at deploy-template-render time from the host env. +# deploy.sh defaults this to "Harbor12345" (the chart's own default) when +# unset, so out-of-the-box demos still work. To override, set +# HARBOR_ADMIN_PASSWORD in .env before running ./setup.sh. +harborAdminPassword: "${HARBOR_ADMIN_PASSWORD}" expose: type: ingress tls: diff --git a/jenkins/harbor/deploy.sh b/jenkins/harbor/deploy.sh index 003b86c6..97f701a8 100755 --- a/jenkins/harbor/deploy.sh +++ b/jenkins/harbor/deploy.sh @@ -22,9 +22,14 @@ cd "$(dirname "$0")" : "${PULL_USER:?PULL_USER must be set (Chainguard pull-token username)}" : "${PULL_PASS:?PULL_PASS must be set (Chainguard pull-token password)}" KIND_CLUSTER_NAME="${KIND_CLUSTER_NAME:-jenkins-harbor}" +# Harbor admin password — defaults to the chart's own default so out-of-the- +# box demos work, but can be overridden via setup.sh / .env. The same value +# is consumed by the Helm values template and by Terraform's harbor provider. +HARBOR_ADMIN_PASSWORD="${HARBOR_ADMIN_PASSWORD:-Harbor12345}" export ORG_NAME="${CHAINGUARD_ORG}" export REGISTRY_URL="cgr.dev/${CHAINGUARD_ORG}" +export HARBOR_ADMIN_PASSWORD for tool in kind kubectl helm terraform envsubst; do if ! command -v "$tool" >/dev/null 2>&1; then @@ -94,6 +99,6 @@ envsubst < terraform/terraform.templatevars > terraform/terraform.tfvars ) echo "==> Done." -echo " Harbor UI: https://localhost/harbor (admin / Harbor12345; click through cert warning)" +echo " Harbor UI: https://localhost/harbor (admin / ${HARBOR_ADMIN_PASSWORD}; click through cert warning)" echo " Proxy cache URL: localhost/cgr-proxy/${CHAINGUARD_ORG}/:" echo " Push project: localhost/library/:" diff --git a/jenkins/harbor/terraform/main.tf b/jenkins/harbor/terraform/main.tf index 11026dc5..411ee9e3 100644 --- a/jenkins/harbor/terraform/main.tf +++ b/jenkins/harbor/terraform/main.tf @@ -7,14 +7,17 @@ terraform { } } -# The Harbor admin password matches the Helm chart default. If you override -# the chart value `harborAdminPassword`, change this too. +# The Harbor admin password comes from a tfvar wired up by deploy.sh +# (which reads HARBOR_ADMIN_PASSWORD from the env, defaulting to the chart's +# own "Harbor12345" when unset). The same value is also fed into the Helm +# chart's harborAdminPassword via values.template — both must agree or this +# provider can't authenticate. # `insecure = true` skips TLS verification — the chart issues a self-signed # cert (see harbor/cg/helm/values.template for why we can't run HTTP). provider "harbor" { url = "https://localhost" username = "admin" - password = "Harbor12345" + password = var.harbor_admin_password insecure = true } diff --git a/jenkins/harbor/terraform/terraform.templatevars b/jenkins/harbor/terraform/terraform.templatevars index c0a27456..6d60f3f6 100644 --- a/jenkins/harbor/terraform/terraform.templatevars +++ b/jenkins/harbor/terraform/terraform.templatevars @@ -1,4 +1,5 @@ # terraform.tfvars chainguard_organization_name = "$ORG_NAME" chainguard_username = "$PULL_USER" -chainguard_pull_token = "$PULL_PASS" \ No newline at end of file +chainguard_pull_token = "$PULL_PASS" +harbor_admin_password = "$HARBOR_ADMIN_PASSWORD" \ No newline at end of file diff --git a/jenkins/harbor/terraform/variables.tf b/jenkins/harbor/terraform/variables.tf index 680b2f9c..7815fc58 100644 --- a/jenkins/harbor/terraform/variables.tf +++ b/jenkins/harbor/terraform/variables.tf @@ -14,3 +14,9 @@ variable "chainguard_organization_name" { type = string description = "Chainguard org being proxied (matches CHAINGUARD_ORG used elsewhere in the demo)." } + +variable "harbor_admin_password" { + type = string + description = "Harbor admin password. Must match the Helm chart's harborAdminPassword value (deploy.sh keeps these in sync via HARBOR_ADMIN_PASSWORD)." + sensitive = true +} diff --git a/jenkins/jenkins/casc/jenkins.yaml b/jenkins/jenkins/casc/jenkins.yaml index 6bd7c2ee..a6bc735f 100644 --- a/jenkins/jenkins/casc/jenkins.yaml +++ b/jenkins/jenkins/casc/jenkins.yaml @@ -19,6 +19,12 @@ jenkins: value: ${PUSH_REGISTRY:-ttl.sh/smalls} - key: HARBOR_ENABLED value: ${HARBOR_ENABLED:-false} + # Surface the Harbor admin password to pipelines so cgLogin's + # Mode C path can read it from env.HARBOR_ADMIN_PASSWORD instead + # of hardcoding the chart default. Empty in Modes A/B; cgLogin + # only reads it on the Mode C push-auth path. + - key: HARBOR_ADMIN_PASSWORD + value: ${HARBOR_ADMIN_PASSWORD:-} securityRealm: local: allowsSignup: false diff --git a/jenkins/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy index 047b3f42..aae02c39 100644 --- a/jenkins/jenkins/casc/jobs.groovy +++ b/jenkins/jenkins/casc/jobs.groovy @@ -22,19 +22,19 @@ def apps = [ ], [ name: 'python314-uv-flask', - description: 'Flask app on Chainguard Python 3.14 with uv; archived as OCI image to ttl.sh', + description: 'Flask app on Chainguard Python 3.14 with uv; OCI image pushed to $PUSH_REGISTRY (ttl.sh in Modes A/B, Harbor in Mode C)', ], [ name: 'python312-pip-django', - description: 'Django site on Chainguard Python 3.12 with pip; archived as OCI image to ttl.sh', + description: 'Django site on Chainguard Python 3.12 with pip; OCI image pushed to $PUSH_REGISTRY (ttl.sh in Modes A/B, Harbor in Mode C)', ], [ name: 'node22-npm-express', - description: 'Express app on Chainguard Node 22 with npm; archived as OCI image to ttl.sh', + description: 'Express app on Chainguard Node 22 with npm; OCI image pushed to $PUSH_REGISTRY (ttl.sh in Modes A/B, Harbor in Mode C)', ], [ name: 'node25-pnpm-express', - description: 'Express app on Chainguard Node 25 (slim runtime) with pnpm; archived as OCI image to ttl.sh', + description: 'Express app on Chainguard Node 25 (slim runtime) with pnpm; OCI image pushed to $PUSH_REGISTRY (ttl.sh in Modes A/B, Harbor in Mode C)', ], ] diff --git a/jenkins/setup.sh b/jenkins/setup.sh index c0ef75ce..79e0d3ae 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -238,17 +238,24 @@ if [[ ! -f "$COSIGN_DIR/cosign.key" ]]; then else echo " Reusing existing keypair in ${COSIGN_DIR}/cosign.{key,pub,password}" fi -# All three files at 644 so JCasC (uid 1000 inside the Jenkins controller, -# typically a different uid than the host user) can read them across the -# bind mount via `${readFile:…}` / `${readFileBase64:…}` at boot. We used -# to keep cosign.password at 600 — but that only worked because the -# passphrase rode into the controller via the COSIGN_PASSWORD env var; we -# dropped that path (it was visible to `docker inspect` and could leak -# into build logs) in favour of reading the file directly. The local-demo -# threat model is "single dev laptop, host user only"; for production -# you'd want chown 1000:1000 + chmod 600 and a separate setup.sh path that -# doesn't need to round-trip the password through host-side reads. -chmod 644 "$COSIGN_DIR"/cosign.key "$COSIGN_DIR"/cosign.pub "$COSIGN_DIR"/cosign.password +# Normalize ownership + perms via a one-shot root container so all three +# files end up owned by uid 1000 (the Jenkins controller's uid) with mode +# 600. JCasC reads them at boot via `${readFile:…}` / `${readFileBase64:…}` +# across the bind mount as uid 1000 — file is owner-readable so that path +# works. Setup.sh itself no longer needs to read these files (the cosign +# passphrase is read directly by JCasC, not round-tripped through the host +# env), so the host user losing read access is fine. +# +# Doing this in a container lets us chown to a uid we don't own without +# requiring sudo on the host. Teardown.sh already falls back to sudo when +# `rm -rf /tmp/cgjenkins-home` hits files owned by uid 1000 on Linux — +# macOS bind-mounts squash ownership for the host user so plain rm works. +echo " Normalizing cosign secret ownership to uid 1000 + chmod 600..." +docker run --rm --user 0:0 \ + -v "$COSIGN_DIR:/work" \ + --entrypoint=sh \ + "cgr.dev/${ORG}/cosign:latest-dev" \ + -c 'chown 1000:1000 /work/cosign.key /work/cosign.pub /work/cosign.password && chmod 600 /work/cosign.key /work/cosign.pub /work/cosign.password' # ---- Phase 1: write .env first so docker compose picks up the new mode ---- @@ -283,6 +290,16 @@ update_env CHAINGUARD_ORG "$ORG" update_env HARBOR_ENABLED "$HARBOR_ENABLED" update_env PULL_REGISTRY "$PULL_REGISTRY" update_env PUSH_REGISTRY "$PUSH_REGISTRY" +# Persist the Harbor admin password if Harbor is enabled. Default to the +# chart's own default ("Harbor12345") so an unmodified first-run works, but +# allow user override by pre-setting HARBOR_ADMIN_PASSWORD in .env. Same +# value drives the Helm chart, the Terraform harbor provider, and cgLogin's +# Mode C push-auth — keeping them all wired off one env var means there's +# only one knob to rotate. +if [[ "$HARBOR_ENABLED" == "true" ]]; then + HARBOR_ADMIN_PASSWORD="${HARBOR_ADMIN_PASSWORD:-Harbor12345}" + update_env HARBOR_ADMIN_PASSWORD "$HARBOR_ADMIN_PASSWORD" +fi # ---- Phase 2: (re)create Jenkins with the new env so its OIDC signing key # is the one we'll upload to Chainguard in Phase 3. ---- @@ -329,6 +346,7 @@ EOF echo "==> Deploying Harbor (kind + Helm + Terraform)..." CHAINGUARD_ORG="$ORG" PULL_USER="$PULL_USER" PULL_PASS="$PULL_PASS" \ + HARBOR_ADMIN_PASSWORD="$HARBOR_ADMIN_PASSWORD" \ harbor/deploy.sh # In Harbor mode the Jenkins OIDC assumed identity isn't used at runtime, @@ -373,11 +391,11 @@ case "$HARBOR_ENABLED-$PUSH_TO_HARBOR" in true-false) echo " Mode: Harbor proxy cache for pulls (anonymous)." echo " Pushes go to $PUSH_REGISTRY." - echo " Harbor UI: https://localhost/harbor (admin / Harbor12345; click through cert warning)" + echo " Harbor UI: https://localhost/harbor (admin / ${HARBOR_ADMIN_PASSWORD}; click through cert warning)" ;; true-true) echo " Mode: Harbor for both pulls and pushes." echo " Pushes land in Harbor's library project." - echo " Harbor UI: https://localhost/harbor (admin / Harbor12345; click through cert warning)" + echo " Harbor UI: https://localhost/harbor (admin / ${HARBOR_ADMIN_PASSWORD}; click through cert warning)" ;; esac diff --git a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy index 022d2bf7..54376181 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy @@ -15,8 +15,10 @@ // Auth stage is effectively a no-op — we just print a status line. // // Mode C (HARBOR_ENABLED=true, PUSH_REGISTRY=localhost/...): -// Same as Mode B for pulls. For pushes, write Harbor admin/Harbor12345 -// into DOCKER_CONFIG/config.json so docker push to localhost/... works. +// Same as Mode B for pulls. For pushes, write Harbor admin creds +// (password from $HARBOR_ADMIN_PASSWORD — defaults to the chart's +// "Harbor12345") into DOCKER_CONFIG/config.json so docker push to +// localhost/... works. // // Usage — call from a stage on `agent any` (the controller) BEFORE any // stage that uses `agent { docker { image cgImage(...).build } }`: @@ -38,6 +40,14 @@ def call() { // refs to 'localhost:80/...' before invoking cosign — which then looks // up auth keyed by 'localhost:80'. Docker push uses the bare 'localhost' // key. Keep both so both code paths find creds. + // + // The Harbor admin password comes from the HARBOR_ADMIN_PASSWORD env + // var (set by JCasC globalNodeProperties → docker-compose → .env). It + // defaults to "Harbor12345" — the chart's own default — when setup.sh + // hasn't seen a user override. Reading from env (rather than hardcoding + // the literal) keeps cgLogin in sync with the Helm chart and the + // Terraform provider, both of which read the same value. + if (!env.HARBOR_ADMIN_PASSWORD) error('cgLogin: env.HARBOR_ADMIN_PASSWORD is empty — JCasC should set it from the controller env in Mode C (see docker-compose.yml + .env). Re-run setup.sh.') sh ''' set -eu mkdir -p "$DOCKER_CONFIG" @@ -45,8 +55,10 @@ def call() { # even when GNU coreutils wraps at 76 chars (or appends a trailing # newline that survives an internal wrap). $(...) already trims the # final trailing newline but not internal ones, so this is a - # defensive guard for any future longer auth string. - AUTH=$(printf 'admin:Harbor12345' | base64 | tr -d '\n') + # defensive guard for any future longer auth string. Pass the + # password via env (not interpolated into the script body) so a + # passphrase containing shell metacharacters round-trips cleanly. + AUTH=$(printf '%s' "admin:$HARBOR_ADMIN_PASSWORD" | base64 | tr -d '\n') cat > "$DOCKER_CONFIG/config.json" < Date: Mon, 11 May 2026 20:00:48 -0500 Subject: [PATCH 40/47] jenkins: filter RepoDigests in Push-stage log lines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Push stages in the four OCI-image pipelines (python314, python312, node22, node25) printed the pushed digest via `{{index .RepoDigests 0}}`, which returns whichever digest happens to be first in the local image cache — possibly from a previous run against a different registry/org. The Sign/Verify stages were already filtering by repo to pick the right digest; do the same in Push so the logged digest matches what was actually pushed. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/apps/node22-npm-express/Jenkinsfile | 10 +++++++++- jenkins/apps/node25-pnpm-express/Jenkinsfile | 10 +++++++++- jenkins/apps/python312-pip-django/Jenkinsfile | 10 +++++++++- jenkins/apps/python314-uv-flask/Jenkinsfile | 10 +++++++++- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/jenkins/apps/node22-npm-express/Jenkinsfile b/jenkins/apps/node22-npm-express/Jenkinsfile index cc8adb50..70d0c4d6 100644 --- a/jenkins/apps/node22-npm-express/Jenkinsfile +++ b/jenkins/apps/node22-npm-express/Jenkinsfile @@ -95,7 +95,15 @@ const server = app.listen(0, '127.0.0.1', () => { agent any steps { sh 'docker push "$IMAGE"' - sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' + // Pick the RepoDigest matching the repo we just pushed. `{{index + // .RepoDigests 0}}` returns whichever digest happens to be first + // in the local cache, which may belong to an unrelated registry + // from a previous run. Same pattern as cgSign/cgVerify. + sh ''' + REPO="${IMAGE%:*}" + DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1) + echo "Pushed $IMAGE digest=$DIGEST" + ''' } } diff --git a/jenkins/apps/node25-pnpm-express/Jenkinsfile b/jenkins/apps/node25-pnpm-express/Jenkinsfile index fe34c45f..64f88ff5 100644 --- a/jenkins/apps/node25-pnpm-express/Jenkinsfile +++ b/jenkins/apps/node25-pnpm-express/Jenkinsfile @@ -92,7 +92,15 @@ const server = app.listen(0, '127.0.0.1', () => { agent any steps { sh 'docker push "$IMAGE"' - sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' + // Pick the RepoDigest matching the repo we just pushed. `{{index + // .RepoDigests 0}}` returns whichever digest happens to be first + // in the local cache, which may belong to an unrelated registry + // from a previous run. Same pattern as cgSign/cgVerify. + sh ''' + REPO="${IMAGE%:*}" + DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1) + echo "Pushed $IMAGE digest=$DIGEST" + ''' } } diff --git a/jenkins/apps/python312-pip-django/Jenkinsfile b/jenkins/apps/python312-pip-django/Jenkinsfile index 44f6e1d1..2c4ce209 100644 --- a/jenkins/apps/python312-pip-django/Jenkinsfile +++ b/jenkins/apps/python312-pip-django/Jenkinsfile @@ -72,7 +72,15 @@ print('Smoke test passed') agent any steps { sh 'docker push "$IMAGE"' - sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' + // Pick the RepoDigest matching the repo we just pushed. `{{index + // .RepoDigests 0}}` returns whichever digest happens to be first + // in the local cache, which may belong to an unrelated registry + // from a previous run. Same pattern as cgSign/cgVerify. + sh ''' + REPO="${IMAGE%:*}" + DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1) + echo "Pushed $IMAGE digest=$DIGEST" + ''' } } diff --git a/jenkins/apps/python314-uv-flask/Jenkinsfile b/jenkins/apps/python314-uv-flask/Jenkinsfile index 8d830c44..8a7fb830 100644 --- a/jenkins/apps/python314-uv-flask/Jenkinsfile +++ b/jenkins/apps/python314-uv-flask/Jenkinsfile @@ -77,7 +77,15 @@ print('Smoke test passed') agent any steps { sh 'docker push "$IMAGE"' - sh 'docker image inspect --format "Pushed {{.RepoTags}} digest={{index .RepoDigests 0}}" "$IMAGE"' + // Pick the RepoDigest matching the repo we just pushed. `{{index + // .RepoDigests 0}}` returns whichever digest happens to be first + // in the local cache, which may belong to an unrelated registry + // from a previous run. Same pattern as cgSign/cgVerify. + sh ''' + REPO="${IMAGE%:*}" + DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1) + echo "Pushed $IMAGE digest=$DIGEST" + ''' } } From bdba2e94479fab1c2356d5f2bca6fc270fcb4069 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 20:10:55 -0500 Subject: [PATCH 41/47] jenkins: fileExists check, preserve tfstate on failure, JSON pull-token Four Copilot findings from the tenth pass on PR #330: - cgLogin: wrap the IDENTITY readFile in a fileExists guard so a missing file produces the friendly "run setup.sh first" error instead of a low-level Groovy exception. - teardown.sh: stop wiping iac/terraform.tfstate when TF_DESTROY_FAILED. Without state there's no handle on the remote assumed identity, so the previous behaviour orphaned IAM resources on failed/skipped destroy. Preserve the iac state for retry; harbor's terraform state is still wiped unconditionally (the kind cluster gets blown away wholesale by harbor/teardown.sh, so the state is moot). - setup.sh update_env: reject values containing a newline. A multi- line value would silently break .env (the value terminates at the embedded newline and the remainder is parsed as a separate key). - setup.sh pull-token parsing: ask chainctl for -o json and parse with python3 (already used for the JWKS validator, no new dep) rather than scraping the human-readable `--username "X"` flag-hint text with grep+sed. Accept a couple of alternative JSON key names for forward-compat; raw response is dumped on failure. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/setup.sh | 26 ++++++++++++++++--- .../cg-images/vars/cgLogin.groovy | 12 +++++++-- jenkins/teardown.sh | 14 +++++++++- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/jenkins/setup.sh b/jenkins/setup.sh index 79e0d3ae..a33d41a2 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -263,6 +263,16 @@ echo "==> Writing mode flags to .env..." [[ -f .env ]] || cp .env.example .env update_env() { local key="$1" value="$2" + # Reject values containing a newline — they'd produce a .env line that + # docker-compose / JCasC env-var parsers would silently misinterpret + # (the value would terminate at the embedded newline and the remainder + # would be parsed as a separate key). docker-compose's dotenv format + # supports escaped/quoted multi-line values but JCasC's `${VAR}` resolver + # doesn't, so the safest rule is no newlines at all. + if [[ "$value" == *$'\n'* ]]; then + echo "ERROR: update_env value for ${key} contains a newline — refusing to corrupt .env." >&2 + exit 1 + fi # Rewrite .env line-by-line in pure bash rather than passing $value through # sed. A user-supplied registry/org could contain &, \, |, or other sed # special chars (the s/// replacement side treats & as the matched string @@ -328,11 +338,19 @@ if [[ "$HARBOR_ENABLED" == "true" ]]; then source "$PULL_FILE" fi if [[ -z "${PULL_USER:-}" || -z "${PULL_PASS:-}" ]]; then - TOKEN_OUTPUT=$(chainctl auth pull-token create --parent="$ORG" --name="harbor-cgr-proxy" --ttl=720h) - PULL_USER=$(echo "$TOKEN_OUTPUT" | grep -oE -- '--username "[^"]+"' | head -1 | sed -E 's/--username "([^"]+)"/\1/') - PULL_PASS=$(echo "$TOKEN_OUTPUT" | grep -oE -- '--password "[^"]+"' | head -1 | sed -E 's/--password "([^"]+)"/\1/') + # Ask chainctl for machine-readable JSON rather than scraping the + # human-readable suggestion text (`--username "X" --password "Y"`) with + # grep+sed — that text is presentation, not contract, and would break + # silently if chainctl ever reformatted it. Today the JSON shape is + # { "id": "", "password": "" }; accept a couple of likely + # alternative key names for forward-compat. python3 parses without + # adding a jq dependency (we already use it for the JWKS validator). + TOKEN_JSON=$(chainctl auth pull-token create --parent="$ORG" --name="harbor-cgr-proxy" --ttl=720h -o json) + PULL_USER=$(printf '%s' "$TOKEN_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("id") or d.get("username") or "")') + PULL_PASS=$(printf '%s' "$TOKEN_JSON" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("password") or d.get("secret") or "")') if [[ -z "$PULL_USER" || -z "$PULL_PASS" ]]; then - echo "ERROR: failed to parse pull token from chainctl output." >&2 + echo "ERROR: failed to extract id/password from chainctl pull-token JSON. Raw response:" >&2 + echo "$TOKEN_JSON" >&2 exit 1 fi mkdir -p harbor diff --git a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy index 54376181..77b2f60d 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy @@ -95,9 +95,17 @@ EOF } // Mode A: OIDC chainctl flow. - def identity = readFile('/tmp/cgjenkins-home/shared-libraries/cg-images/IDENTITY').trim() + // Guard the readFile with fileExists so a missing IDENTITY file (e.g. + // pipeline run before setup.sh, or the shared-libraries bind mount not + // present) surfaces a clear, actionable error instead of a low-level + // Groovy exception from the readFile step. + def identityFile = '/tmp/cgjenkins-home/shared-libraries/cg-images/IDENTITY' + if (!fileExists(identityFile)) { + error('cgLogin: ' + identityFile + ' is missing — run setup.sh first (or set HARBOR_ENABLED=true to use the Harbor proxy cache).') + } + def identity = readFile(identityFile).trim() if (!identity) { - error('cgLogin: shared-libraries/cg-images/IDENTITY is empty — run setup.sh first (or set HARBOR_ENABLED=true to use the Harbor proxy cache).') + error('cgLogin: ' + identityFile + ' is empty — run setup.sh first (or set HARBOR_ENABLED=true to use the Harbor proxy cache).') } withCredentials([string(credentialsId: 'jenkins-cgr-oidc', variable: 'OIDC_TOKEN')]) { sh """ diff --git a/jenkins/teardown.sh b/jenkins/teardown.sh index 2d5c676a..ebe064de 100755 --- a/jenkins/teardown.sh +++ b/jenkins/teardown.sh @@ -89,7 +89,19 @@ echo "==> Cleaning generated files..." rm -rf .secrets rm -f harbor/.pull-token rm -f shared-libraries/cg-images/IDENTITY -rm -rf iac/.terraform iac/terraform.tfstate iac/terraform.tfstate.backup iac/jenkins-jwks.json +# Only wipe the iac/ Terraform state when destroy actually succeeded. +# Preserving it on failure (or when destroy was skipped because +# CHAINGUARD_ORG wasn't set) lets the user re-run ./teardown.sh after +# fixing the cause and still have Terraform clean up the assumed identity +# — without the state file there's no handle on the remote resource and +# the identity gets orphaned. Harbor terraform state is independent (the +# kind cluster gets blown away wholesale by harbor/teardown.sh), so we +# clean it unconditionally. +if (( TF_DESTROY_FAILED == 0 )); then + rm -rf iac/.terraform iac/terraform.tfstate iac/terraform.tfstate.backup iac/jenkins-jwks.json +else + echo " Preserving iac/terraform.tfstate so a future ./teardown.sh can retry the destroy." +fi rm -rf harbor/terraform/.terraform harbor/terraform/terraform.tfstate harbor/terraform/terraform.tfstate.backup harbor/terraform/terraform.tfvars rm -f harbor/cg/helm/values.yaml harbor/cg/manifests/deploy-ingress-nginx.yaml From e16c497ef0c03f83f23f32ffeb27dad66d698946 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 20:17:44 -0500 Subject: [PATCH 42/47] jenkins: validate setup.sh inputs, pipefail in cgSign/Verify, lockfile cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five Copilot findings from the eleventh pass on PR #330: - setup.sh: trim + validate user-supplied CHAINGUARD_ORG and PUSH_REGISTRY (sanitize_env_value + validate_env_value helpers) before persisting. Rejects empty, internal whitespace, and quotes — all of which would silently break .env parsing by docker compose / JCasC / bash `source`. Also re-validates values inherited from .env so a hand-edited corruption surfaces at setup.sh entry rather than mid-`terraform apply`. - cgSign + cgVerify: switch the digest-resolution sh blocks from `set -eu` to `set -eu -o pipefail` so a failing `docker image inspect` mid-pipeline surfaces as the script's exit status rather than being masked by the trailing `head -1` returning 0. - teardown.sh: also remove iac/.terraform.lock.hcl and harbor/terraform/.terraform.lock.hcl on cleanup. They're generated by terraform init and gitignored, so a "full teardown" leaving them behind contradicts the script's stated goal. (Preserved alongside iac/terraform.tfstate when TF_DESTROY_FAILED so the user can retry.) Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/setup.sh | 43 ++++++++++++++++++- .../cg-images/vars/cgSign.groovy | 5 ++- .../cg-images/vars/cgVerify.groovy | 5 ++- jenkins/teardown.sh | 6 +-- 4 files changed, 52 insertions(+), 7 deletions(-) diff --git a/jenkins/setup.sh b/jenkins/setup.sh index a33d41a2..c8a5da47 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -41,16 +41,53 @@ prompt_yn() { [[ "$ans" =~ ^[Yy] ]] } +# Strip leading/trailing whitespace from $1 and echo the result. +sanitize_env_value() { + local v="$1" + v="${v#"${v%%[![:space:]]*}"}" + v="${v%"${v##*[![:space:]]}"}" + printf '%s' "$v" +} + +# Validate a value about to be written to .env. Rejects empty, internal +# whitespace, and quotes — all of which would either silently corrupt the +# dotenv line (parsed by docker compose / JCasC / bash `source`) or be +# easy footguns from a stray paste. +validate_env_value() { + local name="$1" v="$2" + if [[ -z "$v" ]]; then + echo " ERROR: $name must not be empty." >&2 + return 1 + fi + if [[ "$v" =~ [[:space:]] ]]; then + echo " ERROR: $name must not contain whitespace (got: '$v')." >&2 + return 1 + fi + if [[ "$v" == *\"* || "$v" == *\'* ]]; then + echo " ERROR: $name must not contain quotes (got: '$v')." >&2 + return 1 + fi + return 0 +} + # Prompt for the Chainguard org if .env didn't supply one. The answer gets # persisted to .env in Phase 1 below, so subsequent re-runs go straight # through without prompting. if [[ -z "${CHAINGUARD_ORG:-}" ]]; then echo "==> No Chainguard org configured." echo " Examples: 'chainguard' (public catalog) or 'your-org.example.com'." - while [[ -z "${CHAINGUARD_ORG:-}" ]]; do + while :; do read -rp " Enter your Chainguard org: " CHAINGUARD_ORG + CHAINGUARD_ORG=$(sanitize_env_value "$CHAINGUARD_ORG") + if validate_env_value CHAINGUARD_ORG "$CHAINGUARD_ORG"; then break; fi done echo +else + # Even values supplied via env / .env can have stray whitespace from a + # hand-edited dotenv — re-validate so the corruption surfaces here rather + # than mid-`terraform apply`. + CHAINGUARD_ORG=$(sanitize_env_value "$CHAINGUARD_ORG") + validate_env_value CHAINGUARD_ORG "$CHAINGUARD_ORG" || exit 1 fi ORG="$CHAINGUARD_ORG" echo "==> Chainguard org: ${ORG}" @@ -76,13 +113,15 @@ if [[ "$PUSH_TO_HARBOR" == "true" ]]; then else PUSH_REGISTRY_DEFAULT="${PUSH_REGISTRY:-}" PUSH_REGISTRY="" - while [[ -z "$PUSH_REGISTRY" ]]; do + while :; do if [[ -n "$PUSH_REGISTRY_DEFAULT" ]]; then read -rp "Where should pipelines push their built images? [last used: ${PUSH_REGISTRY_DEFAULT}]: " PUSH_REG_INPUT PUSH_REGISTRY="${PUSH_REG_INPUT:-$PUSH_REGISTRY_DEFAULT}" else read -rp "Where should pipelines push their built images? (e.g. ttl.sh/your-prefix): " PUSH_REGISTRY fi + PUSH_REGISTRY=$(sanitize_env_value "$PUSH_REGISTRY") + if validate_env_value PUSH_REGISTRY "$PUSH_REGISTRY"; then break; fi done fi diff --git a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy index f06650ee..8ce59a59 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy @@ -57,7 +57,10 @@ def call(String image) { string(credentialsId: 'cosign-password', variable: 'COSIGN_PASSWORD'), ]) { sh ''' - set -eu + set -eu -o pipefail + # pipefail so a failing `docker image inspect` (e.g. image not in + # the local cache yet) is surfaced as the pipeline's exit status + # rather than masked by the trailing `head -1` returning 0. # Pick the RepoDigest whose repo matches the image we just pushed. # The local image cache may have stale RepoDigests from prior runs # under different registries (e.g. localhost/library from a Mode C diff --git a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy index 58d18948..c58bea8e 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy @@ -36,7 +36,10 @@ def call(String image) { file(credentialsId: 'cosign-public-key', variable: 'COSIGN_PUB_FILE'), ]) { sh ''' - set -eu + set -eu -o pipefail + # pipefail so a failing `docker image inspect` (e.g. image not in + # the local cache yet) is surfaced as the pipeline's exit status + # rather than masked by the trailing `head -1` returning 0. # Pick the RepoDigest whose repo matches the image we want to verify. # See cgSign.groovy for why .RepoDigests can have stale entries from # prior runs. diff --git a/jenkins/teardown.sh b/jenkins/teardown.sh index ebe064de..7dad226c 100755 --- a/jenkins/teardown.sh +++ b/jenkins/teardown.sh @@ -98,11 +98,11 @@ rm -f shared-libraries/cg-images/IDENTITY # kind cluster gets blown away wholesale by harbor/teardown.sh), so we # clean it unconditionally. if (( TF_DESTROY_FAILED == 0 )); then - rm -rf iac/.terraform iac/terraform.tfstate iac/terraform.tfstate.backup iac/jenkins-jwks.json + rm -rf iac/.terraform iac/.terraform.lock.hcl iac/terraform.tfstate iac/terraform.tfstate.backup iac/jenkins-jwks.json else - echo " Preserving iac/terraform.tfstate so a future ./teardown.sh can retry the destroy." + echo " Preserving iac/terraform.tfstate (and .terraform.lock.hcl) so a future ./teardown.sh can retry the destroy." fi -rm -rf harbor/terraform/.terraform harbor/terraform/terraform.tfstate harbor/terraform/terraform.tfstate.backup harbor/terraform/terraform.tfvars +rm -rf harbor/terraform/.terraform harbor/terraform/.terraform.lock.hcl harbor/terraform/terraform.tfstate harbor/terraform/terraform.tfstate.backup harbor/terraform/terraform.tfvars rm -f harbor/cg/helm/values.yaml harbor/cg/manifests/deploy-ingress-nginx.yaml if [[ "$WIPE_ENV" == "true" ]]; then From 8977a1be260cefd0bdffa213b97dd8fca74f768c Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 20:26:31 -0500 Subject: [PATCH 43/47] jenkins: validate HARBOR_ADMIN_PASSWORD, guard cgImage against null org MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Copilot findings from the twelfth pass on PR #330: - setup.sh: also apply sanitize_env_value + validate_env_value to HARBOR_ADMIN_PASSWORD before persisting. Same risks as the other prompted values — whitespace or quotes would break .env sourcing, envsubst into the Helm values template, the harbor Terraform tfvar, and the base64 auth cgLogin builds for Mode C. - cgImage: error when both PULL_REGISTRY and CHAINGUARD_ORG are empty. Otherwise the fallback evaluates to the literal "cgr.dev/null" (Groovy stringifies a null reference inside the GString) and pipelines fail with confusing manifest-not-found errors instead of a clear "re-run setup.sh" message. Same guard pattern cgLogin / cgSign / cgVerify already use. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/setup.sh | 9 +++++++++ jenkins/shared-libraries/cg-images/vars/cgImage.groovy | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/jenkins/setup.sh b/jenkins/setup.sh index c8a5da47..ca1d76cb 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -347,6 +347,15 @@ update_env PUSH_REGISTRY "$PUSH_REGISTRY" # only one knob to rotate. if [[ "$HARBOR_ENABLED" == "true" ]]; then HARBOR_ADMIN_PASSWORD="${HARBOR_ADMIN_PASSWORD:-Harbor12345}" + # Same sanitize/validate rules as the other prompted values — whitespace + # or quotes here would break .env sourcing, envsubst into the Helm + # values template, the harbor Terraform provider tfvar, and the base64 + # auth string cgLogin builds for Mode C. The demo's threat model + # doesn't need passphrase-strength characters here (chart's default + # is `Harbor12345` and the cluster is localhost-only), so the + # restricted character set is acceptable. + HARBOR_ADMIN_PASSWORD=$(sanitize_env_value "$HARBOR_ADMIN_PASSWORD") + validate_env_value HARBOR_ADMIN_PASSWORD "$HARBOR_ADMIN_PASSWORD" || exit 1 update_env HARBOR_ADMIN_PASSWORD "$HARBOR_ADMIN_PASSWORD" fi diff --git a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy index aa45aac8..8cc8c8db 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgImage.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy @@ -40,6 +40,13 @@ def call(String token) { // PULL_REGISTRY is set by JCasC globalNodeProperties (driven by setup.sh). // Defaults to cgr.dev/ for the no-Harbor case; switches to // localhost/cgr-proxy/ when Harbor is the active pull-through cache. + // Guard against both being empty — otherwise the fallback evaluates to + // the literal "cgr.dev/null" (Groovy stringifies a null reference inside + // a GString) and pipelines fail with confusing "manifest not found" + // errors instead of a clear "re-run setup.sh" message. + if (!env.PULL_REGISTRY && !env.CHAINGUARD_ORG) { + error('cgImage: both env.PULL_REGISTRY and env.CHAINGUARD_ORG are empty — JCasC globalNodeProperties should set at least one of these from the controller env (jenkins/jenkins/casc/jenkins.yaml). Re-run setup.sh.') + } def reg = env.PULL_REGISTRY ?: "cgr.dev/${env.CHAINGUARD_ORG}" def catalog = [ 'corretto-java17': [ From 397a51bd189d0bf064474daacbfe7b534fe322c1 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 20:33:32 -0500 Subject: [PATCH 44/47] jenkins: envsubst allowlists, chmod 600 .env, tighten Django defaults Three Copilot findings from the thirteenth pass on PR #330: - harbor/deploy.sh: pass an explicit allowlist to every `envsubst` call. Without it, envsubst expands every `$VAR` sequence in the template, so any future secret containing a `$`-prefixed substring (HARBOR_ADMIN_PASSWORD, PULL_PASS) could be silently mangled. Now each call lists just the variables it actually templates. - setup.sh: `chmod 600 .env` after persisting config. Once HARBOR_ADMIN_PASSWORD lands here it's secret material; a default umask of 022 would leave it group/world-readable. - python312-pip-django/app.py: read SECRET_KEY from DJANGO_SECRET_KEY with a demo-only fallback, and narrow ALLOWED_HOSTS from `["*"]` to `["localhost", "127.0.0.1", "testserver"]`. The Test stage's Django Client uses Host: testserver, so the smoke test still passes (verified locally against an in-memory venv). Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/apps/python312-pip-django/app.py | 13 +++++++++++-- jenkins/harbor/deploy.sh | 17 ++++++++++++++--- jenkins/setup.sh | 5 +++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/jenkins/apps/python312-pip-django/app.py b/jenkins/apps/python312-pip-django/app.py index 1293077b..e5f2d792 100644 --- a/jenkins/apps/python312-pip-django/app.py +++ b/jenkins/apps/python312-pip-django/app.py @@ -13,9 +13,18 @@ settings.configure( DEBUG=False, - SECRET_KEY="demo-not-secret", + # Demo-only fallback. In any non-demo deployment, set DJANGO_SECRET_KEY + # in the environment — Django's signed cookies, password reset tokens, + # and CSRF tokens all derive from this key, so a static value defeats + # those protections. + SECRET_KEY=os.environ.get("DJANGO_SECRET_KEY", "demo-not-secret"), ROOT_URLCONF=__name__, - ALLOWED_HOSTS=["*"], + # Localhost + the test client's default host ("testserver") only. + # ALLOWED_HOSTS=["*"] would let the container respond to any Host + # header — a Host-header injection risk if the image ever escaped its + # localhost demo context. The pipeline's smoke test uses Client().get(), + # which sends Host: testserver, so we include that explicitly. + ALLOWED_HOSTS=["localhost", "127.0.0.1", "testserver"], INSTALLED_APPS=["django.contrib.contenttypes", "django.contrib.auth"], MIDDLEWARE=[], # In-memory SQLite — contrib.contenttypes and contrib.auth declare models diff --git a/jenkins/harbor/deploy.sh b/jenkins/harbor/deploy.sh index 97f701a8..94ba046d 100755 --- a/jenkins/harbor/deploy.sh +++ b/jenkins/harbor/deploy.sh @@ -48,8 +48,15 @@ else fi echo "==> Rendering manifests with REGISTRY_URL=${REGISTRY_URL}..." -envsubst < cg/manifests/deploy-ingress-nginx.template > cg/manifests/deploy-ingress-nginx.yaml -envsubst < cg/helm/values.template > cg/helm/values.yaml +# Pass an explicit allowlist to envsubst so it only substitutes the variables +# we intend to template. Without one it expands every `$VAR` in the file, +# including anything `$FOO`-shaped that happens to appear inside a secret +# (e.g. a future HARBOR_ADMIN_PASSWORD containing `$`) — corrupting the +# rendered output. The allowlist syntax is `'$NAME1 $NAME2 ...'`. +envsubst '$REGISTRY_URL' \ + < cg/manifests/deploy-ingress-nginx.template > cg/manifests/deploy-ingress-nginx.yaml +envsubst '$REGISTRY_URL $HARBOR_ADMIN_PASSWORD' \ + < cg/helm/values.template > cg/helm/values.yaml # Namespaces (idempotent). kubectl get ns ingress-nginx >/dev/null 2>&1 || kubectl create ns ingress-nginx @@ -92,7 +99,11 @@ done echo "==> Configuring Harbor with Terraform (cgr.dev proxy registry + cgr-proxy project)..." export PULL_USER PULL_PASS -envsubst < terraform/terraform.templatevars > terraform/terraform.tfvars +# Same allowlist treatment as the manifests above — keeps `$`-containing +# secrets (e.g. HARBOR_ADMIN_PASSWORD, PULL_PASS) from being interpreted as +# `$VAR` references and silently corrupting the tfvars output. +envsubst '$ORG_NAME $PULL_USER $PULL_PASS $HARBOR_ADMIN_PASSWORD' \ + < terraform/terraform.templatevars > terraform/terraform.tfvars ( cd terraform terraform init -input=false -upgrade terraform apply -input=false -auto-approve diff --git a/jenkins/setup.sh b/jenkins/setup.sh index ca1d76cb..aa807ac3 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -358,6 +358,11 @@ if [[ "$HARBOR_ENABLED" == "true" ]]; then validate_env_value HARBOR_ADMIN_PASSWORD "$HARBOR_ADMIN_PASSWORD" || exit 1 update_env HARBOR_ADMIN_PASSWORD "$HARBOR_ADMIN_PASSWORD" fi +# Tighten .env perms unconditionally — once HARBOR_ADMIN_PASSWORD lands +# here it's a secret, and any future variables we persist could be too. +# 600 = host-user-only; both `bash source` and `docker compose --env-file` +# only need the invoking user to read it. +chmod 600 .env # ---- Phase 2: (re)create Jenkins with the new env so its OIDC signing key # is the one we'll upload to Chainguard in Phase 3. ---- From 14fdb1f93d8ef2be243bdaa95a299b146860831b Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 20:39:36 -0500 Subject: [PATCH 45/47] jenkins: use repo-relative JCasC path in cgSign/cgVerify error messages Copilot re-flagged the JCasC config path in the cgSign/cgVerify error messages: the round-6 "./jenkins/casc/jenkins.yaml under the jenkins/ subdir" wording was ambiguous about which root the path is relative to. Use the unambiguous repo-relative path "jenkins/jenkins/casc/jenkins.yaml" to match cgImage.groovy's matching error message from round 13. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/shared-libraries/cg-images/vars/cgSign.groovy | 2 +- jenkins/shared-libraries/cg-images/vars/cgVerify.groovy | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy index 8ce59a59..9019197a 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy @@ -45,7 +45,7 @@ def call(String image) { if (!image?.trim()) { error('cgSign: image argument is required') } - if (!env.CHAINGUARD_ORG) error('cgSign: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (see ./jenkins/casc/jenkins.yaml under the jenkins/ subdir). Re-run setup.sh.') + if (!env.CHAINGUARD_ORG) error('cgSign: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (see jenkins/jenkins/casc/jenkins.yaml in the repo). Re-run setup.sh.') // Pass `image` through the sh step's environment rather than interpolating // it into the script body — otherwise an image ref containing a single // quote (or other shell metacharacter) could break out of the surrounding diff --git a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy index c58bea8e..8fc3d2c2 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy @@ -28,7 +28,7 @@ def call(String image) { if (!image?.trim()) { error('cgVerify: image argument is required') } - if (!env.CHAINGUARD_ORG) error('cgVerify: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (see ./jenkins/casc/jenkins.yaml under the jenkins/ subdir). Re-run setup.sh.') + if (!env.CHAINGUARD_ORG) error('cgVerify: env.CHAINGUARD_ORG is empty — JCasC globalNodeProperties should set it from the controller env (see jenkins/jenkins/casc/jenkins.yaml in the repo). Re-run setup.sh.') // Pass `image` through the sh step's environment rather than interpolating // it into the script body — see cgSign.groovy for the same reasoning. withEnv(["IMAGE=${image}"]) { From 518f6730be0301ee719d6dc9a9c5e53594870ae0 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 20:45:46 -0500 Subject: [PATCH 46/47] jenkins: COSIGN_PASSWORD via inherited env, compose-down failure handling, pub 644 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Copilot findings from the fifteenth pass on PR #330: - cgSign: pass COSIGN_PASSWORD by name (-e COSIGN_PASSWORD) so docker inherits it from this shell's env rather than embedding the value on the docker-run command line (where `ps` and the docker event log would briefly see it). withCredentials already exported the variable into the shell env. - teardown.sh: capture `docker compose down` failure instead of letting `set -e` abort the rest of teardown. Otherwise a transient daemon issue would leave /tmp/cgjenkins-home and generated state files behind. Continue cleanup, surface the failure at the end via exit code, same pattern as TF_DESTROY_FAILED. - setup.sh: make cosign.pub chmod 644 (key + password stay 600). The public key is used outside Jenkins for manual verify (per the README example), so the host user needs to read it without sudo or a container helper — and it's not secret material. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/setup.sh | 10 ++++++++-- .../cg-images/vars/cgSign.groovy | 7 ++++++- jenkins/teardown.sh | 16 ++++++++++++---- 3 files changed, 26 insertions(+), 7 deletions(-) diff --git a/jenkins/setup.sh b/jenkins/setup.sh index aa807ac3..a0093ed3 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -289,12 +289,18 @@ fi # requiring sudo on the host. Teardown.sh already falls back to sudo when # `rm -rf /tmp/cgjenkins-home` hits files owned by uid 1000 on Linux — # macOS bind-mounts squash ownership for the host user so plain rm works. -echo " Normalizing cosign secret ownership to uid 1000 + chmod 600..." +echo " Normalizing cosign secret ownership to uid 1000..." +# cosign.key (signing key) and cosign.password (the key's passphrase) are +# secrets — owner-only. +# cosign.pub is the public key, used outside Jenkins for manual verify +# (per the example in jenkins/README.md). Keep it world-readable so the +# host user can read it through the bind mount without needing to run +# `sudo cat` or a container helper. docker run --rm --user 0:0 \ -v "$COSIGN_DIR:/work" \ --entrypoint=sh \ "cgr.dev/${ORG}/cosign:latest-dev" \ - -c 'chown 1000:1000 /work/cosign.key /work/cosign.pub /work/cosign.password && chmod 600 /work/cosign.key /work/cosign.pub /work/cosign.password' + -c 'chown 1000:1000 /work/cosign.key /work/cosign.pub /work/cosign.password && chmod 600 /work/cosign.key /work/cosign.password && chmod 644 /work/cosign.pub' # ---- Phase 1: write .env first so docker compose picks up the new mode ---- diff --git a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy index 9019197a..dcb97907 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy @@ -82,10 +82,15 @@ def call(String image) { localhost/*) DIGEST="localhost:80/${DIGEST#localhost/}" ;; esac COSIGN_IMAGE="${PULL_REGISTRY:-cgr.dev/${CHAINGUARD_ORG}}/cosign:latest-dev" + # Pass COSIGN_PASSWORD by NAME (no =value) so docker inherits it from + # this shell's env instead of placing it on the docker-run command + # line — where it would briefly be visible via `ps` / docker event + # logs. withCredentials already exported COSIGN_PASSWORD into the + # shell env for us. docker run --rm --network host \ -v "$COSIGN_KEY_FILE:/cosign.key:ro" \ -v "$DOCKER_CONFIG:/jenkins-docker:ro" \ - -e "COSIGN_PASSWORD=$COSIGN_PASSWORD" \ + -e COSIGN_PASSWORD \ -e DOCKER_CONFIG=/jenkins-docker \ --entrypoint=/usr/bin/cosign \ "$COSIGN_IMAGE" \ diff --git a/jenkins/teardown.sh b/jenkins/teardown.sh index 7dad226c..2dd751c5 100755 --- a/jenkins/teardown.sh +++ b/jenkins/teardown.sh @@ -73,8 +73,16 @@ fi echo "==> Stopping Jenkins (docker compose down)..." # Print full output; truncating with `tail -5` hides earlier errors that -# would explain why compose-down failed. -docker compose down --rmi local --remove-orphans +# would explain why compose-down failed. Capture failure so we can still +# clean up local state (the bind-mount and generated files), and surface +# it at the end via the exit code — set -e on its own would abort here +# and leave /tmp/cgjenkins-home + .secrets behind. +COMPOSE_DOWN_FAILED=0 +docker compose down --rmi local --remove-orphans || COMPOSE_DOWN_FAILED=1 +if (( COMPOSE_DOWN_FAILED == 1 )); then + echo " WARNING: docker compose down failed. Continuing with local-state cleanup;" >&2 + echo " re-run \`docker compose down --rmi local --remove-orphans\` after fixing the daemon." >&2 +fi echo "==> Removing /tmp/cgjenkins-home..." # On macOS + OrbStack the bind-mount is owned by the host user (no sudo). @@ -111,8 +119,8 @@ if [[ "$WIPE_ENV" == "true" ]]; then fi echo -if (( TF_DESTROY_FAILED == 1 )); then - echo "==> Done — WITH WARNINGS (terraform destroy was skipped or failed; see above)." >&2 +if (( TF_DESTROY_FAILED == 1 || COMPOSE_DOWN_FAILED == 1 )); then + echo "==> Done — WITH WARNINGS (terraform destroy and/or docker compose down failed/skipped; see above)." >&2 echo " Re-run ./setup.sh to bootstrap from scratch." exit 1 fi From 1c8fa68c757e8a1bd6880d390322fc0a37baf1f2 Mon Sep 17 00:00:00 2001 From: Eric Smalling Date: Mon, 11 May 2026 20:54:14 -0500 Subject: [PATCH 47/47] jenkins: fix pipefail+grep-no-match abort, add setup.sh dep check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Copilot findings from the sixteenth pass on PR #330: - cgSign + cgVerify: round 12 added `set -eu -o pipefail` but kept the `docker inspect | grep | head -1` pipeline intact — if grep finds no matching RepoDigest, the pipeline exits non-zero and set -e abandons the script before the friendly "could not resolve digest" message runs. Split the inspect from the filter so a grep-no-match is tolerated (`|| true`), then validate $DIGEST and emit the actionable error. Real `docker inspect` failures still abort (the inspect call is its own statement now, no pipe). - setup.sh: add an up-front dependency check for docker, curl, openssl, python3, terraform, chainctl. Otherwise a missing tool produces a confusing `command not found` partway through bootstrap. Harbor-only tools (kind, kubectl, helm, envsubst) stay in harbor/deploy.sh — no point demanding them up front if the user picks the non-Harbor path. Co-Authored-By: Claude Opus 4.7 (1M context) --- jenkins/setup.sh | 17 +++++++++++++++++ .../cg-images/vars/cgSign.groovy | 12 ++++++++---- .../cg-images/vars/cgVerify.groovy | 11 +++++++---- 3 files changed, 32 insertions(+), 8 deletions(-) diff --git a/jenkins/setup.sh b/jenkins/setup.sh index a0093ed3..c70adeca 100755 --- a/jenkins/setup.sh +++ b/jenkins/setup.sh @@ -32,6 +32,23 @@ fi JENKINS_URL="${JENKINS_URL:-http://localhost:8080}" JENKINS_OIDC_ISSUER="${JENKINS_OIDC_ISSUER:-https://localhost:8080/oidc}" +# Fail fast with a clear list of what's missing, instead of hitting a +# `command not found` mid-bootstrap. These are the tools both the direct- +# cgr.dev path and the Harbor path need. Harbor-only tools (kind, kubectl, +# helm, envsubst) are still checked separately by harbor/deploy.sh — no +# point demanding them up front if the user picks the non-Harbor path. +MISSING_TOOLS=() +for tool in docker curl openssl python3 terraform chainctl; do + if ! command -v "$tool" >/dev/null 2>&1; then + MISSING_TOOLS+=("$tool") + fi +done +if (( ${#MISSING_TOOLS[@]} > 0 )); then + echo "ERROR: required tools not found in PATH: ${MISSING_TOOLS[*]}" >&2 + echo "Install them and re-run ./setup.sh." >&2 + exit 1 +fi + prompt_yn() { # $1=question, $2=default ("y" or "n") local q="$1" def="$2" hint ans diff --git a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy index dcb97907..4f531b53 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy @@ -58,16 +58,20 @@ def call(String image) { ]) { sh ''' set -eu -o pipefail - # pipefail so a failing `docker image inspect` (e.g. image not in - # the local cache yet) is surfaced as the pipeline's exit status - # rather than masked by the trailing `head -1` returning 0. + # pipefail so a failing `docker image inspect` is surfaced as the + # pipeline's exit status rather than masked by the trailing + # `head -1` returning 0. Split the inspect from the grep/head + # filter so a grep-no-match (legitimate "no RepoDigest matches + # this repo yet" case) doesn't abort under set -e/pipefail + # before we can emit the friendly error below. # Pick the RepoDigest whose repo matches the image we just pushed. # The local image cache may have stale RepoDigests from prior runs # under different registries (e.g. localhost/library from a Mode C # session, ttl.sh from a Mode A session) — `{{index .RepoDigests 0}}` # returned whichever happened to be first and tripped cosign over. REPO="${IMAGE%:*}" - DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1) + ALL_DIGESTS=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE") + DIGEST=$(printf '%s' "$ALL_DIGESTS" | grep -F "${REPO}@" | head -1 || true) if [ -z "$DIGEST" ]; then echo "cgSign: could not resolve digest for $IMAGE under repo $REPO (was it pushed?)." >&2 exit 1 diff --git a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy index 8fc3d2c2..3675e100 100644 --- a/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy +++ b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy @@ -37,14 +37,17 @@ def call(String image) { ]) { sh ''' set -eu -o pipefail - # pipefail so a failing `docker image inspect` (e.g. image not in - # the local cache yet) is surfaced as the pipeline's exit status - # rather than masked by the trailing `head -1` returning 0. + # pipefail so a failing `docker image inspect` is surfaced as the + # pipeline's exit status rather than masked by the trailing + # `head -1` returning 0. Split the inspect from the grep/head + # filter so a grep-no-match doesn't abort under set -e/pipefail + # before we can emit the friendly error below. # Pick the RepoDigest whose repo matches the image we want to verify. # See cgSign.groovy for why .RepoDigests can have stale entries from # prior runs. REPO="${IMAGE%:*}" - DIGEST=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE" | grep -F "${REPO}@" | head -1) + ALL_DIGESTS=$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$IMAGE") + DIGEST=$(printf '%s' "$ALL_DIGESTS" | grep -F "${REPO}@" | head -1 || true) if [ -z "$DIGEST" ]; then echo "cgVerify: could not resolve digest for $IMAGE under repo $REPO (was it pushed?)." >&2 exit 1