diff --git a/jenkins/.env.example b/jenkins/.env.example new file mode 100644 index 00000000..f983ab30 --- /dev/null +++ b/jenkins/.env.example @@ -0,0 +1,46 @@ +# 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. +# +# 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=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 +# 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= + +# 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= + +# 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 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/.gitignore b/jenkins/.gitignore new file mode 100644 index 00000000..d9b92314 --- /dev/null +++ b/jenkins/.gitignore @@ -0,0 +1,16 @@ +# Local environment overrides (e.g. CHAINGUARD_ORG). Bootstrapped from .env.example. +.env + +# 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/ + +# 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/Dockerfile.jenkins b/jenkins/Dockerfile.jenkins new file mode 100644 index 00000000..00c9e1dd --- /dev/null +++ b/jenkins/Dockerfile.jenkins @@ -0,0 +1,37 @@ +# 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 + +# 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 +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 +COPY jenkins/plugins.txt /tmp/plugins.txt +RUN jenkins-plugin-cli --plugin-file /tmp/plugins.txt --verbose + +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 new file mode 100644 index 00000000..19967d34 --- /dev/null +++ b/jenkins/README.md @@ -0,0 +1,187 @@ +# Chainguard Jenkins Demo + +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/`. Pick your org once via [`setup.sh`](setup.sh) (it prompts and persists to `.env`) — see [Configuration](#configuration). + +## 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 → `$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.) + +## How it works + +```mermaid +flowchart LR + user[User] + host[(Host Docker daemon
OrbStack / Docker Desktop)] + 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] + 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/$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. + +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@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 + +- Docker (Docker Desktop, OrbStack, or Linux Docker engine) +- `chainctl` CLI, authenticated against an org with access to `cgr.dev//*` + +Verify auth: +```sh +chainctl auth status +``` + +## Configuration + +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 +./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). + +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 + +[setup.sh](setup.sh) is interactive and supports **three modes** for image flow. Pick one when prompted: + +| | 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 | + +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 +./setup.sh +``` + +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. + +> **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. + +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) 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** — 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 +# 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/}" +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/`. +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 + 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 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 + +[`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 +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 + +- 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. diff --git a/jenkins/apps/adoptium-java8-jetty/Jenkinsfile b/jenkins/apps/adoptium-java8-jetty/Jenkinsfile new file mode 100644 index 00000000..90d39e05 --- /dev/null +++ b/jenkins/apps/adoptium-java8-jetty/Jenkinsfile @@ -0,0 +1,77 @@ +def img = cgImage('adoptium-java8') + +pipeline { + agent none + options { timestamps() } + + stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/adoptium-java8-jetty/. .' + stash name: 'src', includes: '**', excludes: 'target/**' + } + } + + stage('Build') { + agent { + docker { + image img.build + 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 img.test + 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..fc4ed2ee --- /dev/null +++ b/jenkins/apps/adoptium-java8-jetty/README.md @@ -0,0 +1,29 @@ +# adoptium-java8-jetty + +Hello-world Jetty/JSP web app, built with Maven on Adoptium JDK 8 and packaged as a self-executing WAR. + +> `$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/$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/$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 + +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/apps/corretto-java17-maven/Jenkinsfile b/jenkins/apps/corretto-java17-maven/Jenkinsfile new file mode 100644 index 00000000..02ce6cd9 --- /dev/null +++ b/jenkins/apps/corretto-java17-maven/Jenkinsfile @@ -0,0 +1,61 @@ +// cgImage is auto-loaded from the cgImages shared library (JCasC, implicit). +def img = cgImage('corretto-java17') + +pipeline { + agent none + options { timestamps() } + + stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/corretto-java17-maven/. .' + stash name: 'src', includes: '**', excludes: 'target/**' + } + } + + stage('Build') { + agent { + docker { + 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=' + 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 img.test + 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..9b6c71eb --- /dev/null +++ b/jenkins/apps/corretto-java17-maven/README.md @@ -0,0 +1,30 @@ +# corretto-java17-maven + +Hello-world Spring Boot console app, built with Maven on Amazon Corretto JDK 17. + +> `$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/$CHAINGUARD_ORG/maven:3-jdk17-dev` | +| Test | `cgr.dev/$CHAINGUARD_ORG/amazon-corretto-jre:17-dev` | + +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 + +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/$CHAINGUARD_ORG/maven:3-jdk17-dev \ + mvn -B -ntp clean package -DskipTests + +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/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/apps/node22-npm-express/Dockerfile b/jenkins/apps/node22-npm-express/Dockerfile new file mode 100644 index 00000000..08538f2f --- /dev/null +++ b/jenkins/apps/node22-npm-express/Dockerfile @@ -0,0 +1,18 @@ +# 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. +ARG CHAINGUARD_ORG + +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/${CHAINGUARD_ORG}/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..70d0c4d6 --- /dev/null +++ b/jenkins/apps/node22-npm-express/Jenkinsfile @@ -0,0 +1,120 @@ +def img = cgImage('node-22') + +pipeline { + agent none + options { timestamps() } + + environment { + IMAGE = "${env.PUSH_REGISTRY}/nodetest:22" + } + + stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + + 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: '**', excludes: 'node_modules/**' + } + } + + stage('Build deps') { + agent { + docker { + image img.build + 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 --build-arg CHAINGUARD_ORG="$CHAINGUARD_ORG" -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"' + // 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" + ''' + } + } + + stage('Sign') { + agent any + steps { cgSign(env.IMAGE) } + } + + stage('Verify') { + agent any + steps { cgVerify(env.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..876bf783 --- /dev/null +++ b/jenkins/apps/node22-npm-express/README.md @@ -0,0 +1,43 @@ +# 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 `$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. + +> 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 + +| Stage | Image | +|-------------|-------| +| 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 | `$PUSH_REGISTRY/nodetest:22` | + +## npm libraries used + +- `express` (web server) +- `picocolors` (terminal coloring for the startup banner — small, dependency-free, and visibly demonstrates that npm package install is wired up) + +## 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 $PUSH_REGISTRY/nodetest:22 +docker run --rm -p 8080:8080 $PUSH_REGISTRY/nodetest:22 +# Visit http://localhost:8080/ +``` + +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/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/apps/node25-pnpm-express/Dockerfile b/jenkins/apps/node25-pnpm-express/Dockerfile new file mode 100644 index 00000000..0eb04925 --- /dev/null +++ b/jenkins/apps/node25-pnpm-express/Dockerfile @@ -0,0 +1,18 @@ +# 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). +ARG CHAINGUARD_ORG + +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/${CHAINGUARD_ORG}/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..64f88ff5 --- /dev/null +++ b/jenkins/apps/node25-pnpm-express/Jenkinsfile @@ -0,0 +1,117 @@ +def img = cgImage('node-25') + +pipeline { + agent none + options { timestamps() } + + environment { + IMAGE = "${env.PUSH_REGISTRY}/nodetest:25" + } + + stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + + 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/. .' + // 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/**' + } + } + + stage('Build deps') { + agent { + docker { + image img.build + 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 --build-arg CHAINGUARD_ORG="$CHAINGUARD_ORG" -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"' + // 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" + ''' + } + } + + stage('Sign') { + agent any + steps { cgSign(env.IMAGE) } + } + + stage('Verify') { + agent any + steps { cgVerify(env.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..3b255465 --- /dev/null +++ b/jenkins/apps/node25-pnpm-express/README.md @@ -0,0 +1,38 @@ +# 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 `$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. + +## Pipeline images + +| Stage | Image | +|-------------|-------| +| 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 | `$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**: 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; demonstrates a real npm dep going through pnpm install) + +## 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 $PUSH_REGISTRY/nodetest:25 +docker run --rm -p 8080:8080 $PUSH_REGISTRY/nodetest:25 +# Visit http://localhost:8080/ +``` + +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/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/apps/openjdk21-gradle/Jenkinsfile b/jenkins/apps/openjdk21-gradle/Jenkinsfile new file mode 100644 index 00000000..4c844291 --- /dev/null +++ b/jenkins/apps/openjdk21-gradle/Jenkinsfile @@ -0,0 +1,70 @@ +def img = cgImage('openjdk21') + +pipeline { + agent none + options { timestamps() } + + stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/openjdk21-gradle/. .' + stash name: 'src', includes: '**', excludes: 'build/**, .gradle/**' + } + } + + stage('Build') { + agent { + docker { + image img.build + 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 img.test + 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..8932db5c --- /dev/null +++ b/jenkins/apps/openjdk21-gradle/README.md @@ -0,0 +1,28 @@ +# openjdk21-gradle + +Hello-world standalone runnable JAR built with Gradle on Chainguard's OpenJDK 21. + +> `$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/$CHAINGUARD_ORG/jdk:openjdk-21-dev` | +| Test | `cgr.dev/$CHAINGUARD_ORG/jre:openjdk-21-dev` | + +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 + +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 00000000..1b33c55b Binary files /dev/null and b/jenkins/apps/openjdk21-gradle/gradle/wrapper/gradle-wrapper.jar differ diff --git a/jenkins/apps/openjdk21-gradle/gradle/wrapper/gradle-wrapper.properties b/jenkins/apps/openjdk21-gradle/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..aaaabb3c --- /dev/null +++ b/jenkins/apps/openjdk21-gradle/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/jenkins/apps/openjdk21-gradle/gradlew b/jenkins/apps/openjdk21-gradle/gradlew new file mode 100755 index 00000000..23d15a93 --- /dev/null +++ b/jenkins/apps/openjdk21-gradle/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + 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/apps/python312-pip-django/Dockerfile b/jenkins/apps/python312-pip-django/Dockerfile new file mode 100644 index 00000000..1cc863d0 --- /dev/null +++ b/jenkins/apps/python312-pip-django/Dockerfile @@ -0,0 +1,20 @@ +# 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. +ARG CHAINGUARD_ORG + +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/${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 +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..2c4ce209 --- /dev/null +++ b/jenkins/apps/python312-pip-django/Jenkinsfile @@ -0,0 +1,97 @@ +def img = cgImage('python-3.12') + +pipeline { + agent none + options { timestamps() } + + environment { + IMAGE = "${env.PUSH_REGISTRY}/pytest:3-12" + } + + stages { + stage('Auth') { + agent any + steps { cgLogin() } + } + + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/python312-pip-django/. .' + stash name: 'src', includes: '**' + } + } + + stage('Build deps') { + agent { + docker { + 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=' + 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 --build-arg CHAINGUARD_ORG="$CHAINGUARD_ORG" -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"' + // 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" + ''' + } + } + + stage('Sign') { + agent any + steps { cgSign(env.IMAGE) } + } + + stage('Verify') { + agent any + steps { cgVerify(env.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..bfac0827 --- /dev/null +++ b/jenkins/apps/python312-pip-django/README.md @@ -0,0 +1,32 @@ +# 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 `$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. + +## Pipeline images + +| Stage | Image | +|-------------|-------| +| 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 | `$PUSH_REGISTRY/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 $PUSH_REGISTRY/pytest:3-12 +docker run --rm -p 8080:8080 $PUSH_REGISTRY/pytest:3-12 +# Visit http://localhost:8080/ +``` + +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/app.py b/jenkins/apps/python312-pip-django/app.py new file mode 100644 index 00000000..e5f2d792 --- /dev/null +++ b/jenkins/apps/python312-pip-django/app.py @@ -0,0 +1,73 @@ +""" +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 +import django +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, + # 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__, + # 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 + # 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): + 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/apps/python314-uv-flask/Dockerfile b/jenkins/apps/python314-uv-flask/Dockerfile new file mode 100644 index 00000000..ad794b89 --- /dev/null +++ b/jenkins/apps/python314-uv-flask/Dockerfile @@ -0,0 +1,19 @@ +# 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. +ARG CHAINGUARD_ORG + +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/${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 +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..8a7fb830 --- /dev/null +++ b/jenkins/apps/python314-uv-flask/Jenkinsfile @@ -0,0 +1,102 @@ +def img = cgImage('python-3.14') + +pipeline { + agent none + options { timestamps() } + + environment { + // 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 { + stage('Auth') { + agent any + steps { cgLogin() } + } + + stage('Checkout') { + agent any + steps { + sh 'cp -R /sources/apps/python314-uv-flask/. .' + stash name: 'src', includes: '**' + } + } + + stage('Build deps') { + agent { + docker { + 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=' + 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 --build-arg CHAINGUARD_ORG="$CHAINGUARD_ORG" -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"' + // 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" + ''' + } + } + + stage('Sign') { + agent any + steps { cgSign(env.IMAGE) } + } + + stage('Verify') { + agent any + steps { cgVerify(env.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..ec058659 --- /dev/null +++ b/jenkins/apps/python314-uv-flask/README.md @@ -0,0 +1,30 @@ +# 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 `$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. + +## Pipeline images + +| Stage | Image | +|-------------|-------| +| 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 | `$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. + +## 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 $PUSH_REGISTRY/pytest:3-14 +docker run --rm -p 8080:8080 $PUSH_REGISTRY/pytest:3-14 +# Visit http://localhost:8080/ +``` + +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/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/docker-compose.yml b/jenkins/docker-compose.yml new file mode 100644 index 00000000..540cb238 --- /dev/null +++ b/jenkins/docker-compose.yml @@ -0,0 +1,78 @@ +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 + args: + # `?` 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:?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}} + 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}`, + # 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} + # 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. + 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 + - ./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 + # 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 + # DOCKER_CONFIG at build time, so no static config is needed. 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..a509a381 --- /dev/null +++ b/jenkins/harbor/README.md @@ -0,0 +1,60 @@ +# 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=your-org-here # whichever org owns the catalog +export PULL_USER=... # chainctl auth pull-token create --parent=$CHAINGUARD_ORG +export PULL_PASS=... +./deploy.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 new file mode 100644 index 00000000..ce991e3e --- /dev/null +++ b/jenkins/harbor/cg/helm/values.template @@ -0,0 +1,76 @@ +# 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 +# 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: + 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 + +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: + # Enable the Trivy vulnerability 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..94ba046d --- /dev/null +++ b/jenkins/harbor/deploy.sh @@ -0,0 +1,115 @@ +#!/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. 'chainguard' or 'your-org.example.com') +# 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}" +# 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 + 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}..." +# 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 +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,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..." +# -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 -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 https://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 +# 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 +) + +echo "==> Done." +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/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..411ee9e3 --- /dev/null +++ b/jenkins/harbor/terraform/main.tf @@ -0,0 +1,43 @@ +terraform { + required_providers { + harbor = { + source = "goharbor/harbor" + version = "~> 3.10" + } + } +} + +# 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 = var.harbor_admin_password + insecure = true +} + +# 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..6d60f3f6 --- /dev/null +++ b/jenkins/harbor/terraform/terraform.templatevars @@ -0,0 +1,5 @@ +# terraform.tfvars +chainguard_organization_name = "$ORG_NAME" +chainguard_username = "$PULL_USER" +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 new file mode 100644 index 00000000..7815fc58 --- /dev/null +++ b/jenkins/harbor/terraform/variables.tf @@ -0,0 +1,22 @@ +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)." +} + +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/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..2547f3b0 --- /dev/null +++ b/jenkins/iac/README.md @@ -0,0 +1,31 @@ +# 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. `./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. + +## Variables + +| 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` | `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 + +- 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..dccc057b --- /dev/null +++ b/jenkins/iac/main.tf @@ -0,0 +1,49 @@ +terraform { + required_providers { + chainguard = { + source = "chainguard-dev/chainguard" + version = "~> 0.2" + } + } +} + +provider "chainguard" {} + +# 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 +} + +# 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..2e2c7b26 --- /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 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 +} diff --git a/jenkins/iac/variables.tf b/jenkins/iac/variables.tf new file mode 100644 index 00000000..4eb16959 --- /dev/null +++ b/jenkins/iac/variables.tf @@ -0,0 +1,22 @@ +variable "chainguard_group_name" { + 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 +} + +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 (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 = "2030-01-01T00: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 new file mode 100644 index 00000000..a6bc735f --- /dev/null +++ b/jenkins/jenkins/casc/jenkins.yaml @@ -0,0 +1,127 @@ +jenkins: + 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} + # 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} + # 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 + users: + - id: "admin" + password: "${JENKINS_ADMIN_PASSWORD:-admin}" + authorizationStrategy: + 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: + # 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 + # Auto-loaded for every pipeline (no @Library annotation needed). + implicit: true + 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). + # 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" + # 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/jenkins/casc/jobs.groovy b/jenkins/jenkins/casc/jobs.groovy new file mode 100644 index 00000000..aae02c39 --- /dev/null +++ b/jenkins/jenkins/casc/jobs.groovy @@ -0,0 +1,66 @@ +// 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 host Docker daemon at the same path — this demo uses DooD, not DinD; see +// docker-compose.yml for the architecture rationale). + +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', + ], + [ + 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; 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; 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; 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; OCI image pushed to $PUSH_REGISTRY (ttl.sh in Modes A/B, Harbor in Mode C)', + ], +] + +apps.each { app -> + pipelineJob(app.name) { + description(app.description) + definition { + cps { + script(new File("/sources/apps/${app.name}/Jenkinsfile").text) + sandbox(true) + } + } + } +} + +// 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/jenkins/plugins.txt b/jenkins/jenkins/plugins.txt new file mode 100644 index 00000000..f368ea4a --- /dev/null +++ b/jenkins/jenkins/plugins.txt @@ -0,0 +1,32 @@ +# 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 +docker-workflow +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 +# 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 +# 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 +# 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 diff --git a/jenkins/ops/refresh-cgimages-digests/Jenkinsfile b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile new file mode 100644 index 00000000..ec15428d --- /dev/null +++ b/jenkins/ops/refresh-cgimages-digests/Jenkinsfile @@ -0,0 +1,72 @@ +// 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('Auth') { + agent any + steps { cgLogin() } + } + + stage('Refresh digests') { + agent { + docker { + // 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 + // /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 + // -- 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. + // 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 + } + } + 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..07ced462 --- /dev/null +++ b/jenkins/ops/refresh-cgimages-digests/README.md @@ -0,0 +1,30 @@ +# 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 | +|-------|-------|---------| +| 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 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. + +## 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/setup.sh b/jenkins/setup.sh new file mode 100755 index 00000000..c70adeca --- /dev/null +++ b/jenkins/setup.sh @@ -0,0 +1,495 @@ +#!/usr/bin/env bash +# Interactive bootstrap. Asks the user three questions about how Jenkins +# should pull and push images, then sets up: +# +# 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) +# +# 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 (and any prior choices) from .env if present. +if [[ -f .env ]]; then + set -a + # shellcheck disable=SC1091 + source .env + set +a +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 + if [[ "$def" == "y" ]]; then hint="(Y/n)"; else hint="(y/N)"; fi + read -rp "$q $hint: " ans + ans="${ans:-$def}" + [[ "$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 :; 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}" +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. 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 + PUSH_REGISTRY_DEFAULT="${PUSH_REGISTRY:-}" + PUSH_REGISTRY="" + 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 + +# 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 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 + # 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 + # 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 + 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 (( 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 + 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 +# /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 + 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 +# 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..." +# 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.password && chmod 644 /work/cosign.pub' + +# ---- 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" + # 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 + # 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" +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}" + # 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 +# 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. ---- + +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 + exit 1 + fi + sleep 2 +done +echo "==> Jenkins is up at $JENKINS_URL" + +# ---- 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 + # 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 extract id/password from chainctl pull-token JSON. Raw response:" >&2 + echo "$TOKEN_JSON" >&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_ADMIN_PASSWORD="$HARBOR_ADMIN_PASSWORD" \ + 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 +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: 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 / ${HARBOR_ADMIN_PASSWORD}; click through cert warning)" + ;; +esac diff --git a/jenkins/shared-libraries/cg-images/README.md b/jenkins/shared-libraries/cg-images/README.md new file mode 100644 index 00000000..3114e271 --- /dev/null +++ b/jenkins/shared-libraries/cg-images/README.md @@ -0,0 +1,67 @@ +# 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('Auth') { + agent any + steps { cgLogin() } // see vars/cgLogin.groovy + } + stage('Build') { + agent { docker { image img.build; args '--entrypoint=' } } + ... + } + stage('Test') { + agent { docker { image img.test; args '--entrypoint=' } } + ... + } + } +} +``` + +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 | +|-----------|---------| +| `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). | + +## 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 $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 + +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 + +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` (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//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 new file mode 100755 index 00000000..0b04bfe9 --- /dev/null +++ b/jenkins/shared-libraries/cg-images/refresh-digests.sh @@ -0,0 +1,94 @@ +#!/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 +if [[ -z "${CHAINGUARD_ORG:-}" ]]; then + echo "ERROR: CHAINGUARD_ORG must be set (in env or in ../../.env)." >&2 + exit 1 +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 ${REGISTRY}/..." + +# Extract every single-quoted ":" or ":@sha256:..." entry +# 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 + +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 + +tmp=$(mktemp) +trap 'rm -f "$reftags_file" "$tmp"' EXIT +cp "$CATALOG" "$tmp" + +while IFS= read -r reftag; do + printf ' %-50s ' "$reftag" + digest=$(crane digest "${REGISTRY}/${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 < "$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 +# 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 new file mode 100644 index 00000000..8cc8c8db --- /dev/null +++ b/jenkins/shared-libraries/cg-images/vars/cgImage.groovy @@ -0,0 +1,85 @@ +// 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) +// +// 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) { + // 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': [ + build: 'maven:3-jdk17-dev@sha256:90e0bf8239086e814fc92090749f4f3b7b49a88107c655b0661509a2a4f2ee58', + test: 'amazon-corretto-jre:17-dev@sha256:46883ffbb2b5e99cf52cb124d9fced7b4e3740cacd46c1913bc2e970b7613353', + ], + 'adoptium-java8': [ + build: 'maven:3-jdk8-dev@sha256:9df83d553cef9c7bc3daabd51fcf993478516339c525eca0307f780f2ed3743a', + test: 'adoptium-jre:adoptium-openjdk-8-dev@sha256:b06752d6781be20ef0ca4bc50459ef1c7518acec79122e4583ee9a24f6ed396a', + ], + 'openjdk21': [ + build: 'jdk:openjdk-21-dev@sha256:22a0cda00ee2980c4e1c7c35f7ddfa4391e06a2a8806887bffb5e5283f149ee1', + test: 'jre:openjdk-21-dev@sha256:c5bc29d25e88d244e7caaa344285007da619cd0bf83c350989398e1553775ecf', + ], + 'python-3.14': [ + build: 'python:3.14-dev@sha256:aa69dcd8a8689f7584fd4e077a52c0f13812ec7f54ace6590ab31f4297b3129d', + runtime: 'python:3.14@sha256:0d0af6d76f7caf6b0d21015b66089bef7f017d062652a3b297f211d07e319ec4', + ], + 'python-3.12': [ + build: 'python:3.12-dev@sha256:6f0af8cc50dd3853ce3fb145874e2408c868ba50e7104691a43794efda509e57', + runtime: 'python:3.12@sha256:c989e9a79d89581d777a9249444ebf7a9a64835188fe958d18ee3f19a98d25e1', + ], + 'node-22': [ + build: 'node:22-dev@sha256:dd916e3ed5be3b662cb598ad02a9e8b31a5ab23964ac92db4f79378ffa9fccca', + runtime: 'node:22@sha256:63323818cad51c97855be7c45a5ff8933983658b0b88051f7800368bcf5b938c', + ], + 'node-25': [ + build: 'node:25-dev@sha256:cea252f9844fcaf7a224f009201e202e956d316976fe95df3c3c51d78ba10187', + runtime: 'node:25-slim@sha256:c1c2a3206d54ce0d1e04f108de77429325fd50b3ad266f7fbf827536ffed0292', + ], + ] + if (!catalog.containsKey(token)) { + error("Unknown cgImage token '${token}'. Valid tokens: ${catalog.keySet().sort().join(', ')}") + } + return catalog[token].collectEntries { k, v -> [(k): "${reg}/${v}"] } +} 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..77b2f60d --- /dev/null +++ b/jenkins/shared-libraries/cg-images/vars/cgLogin.groovy @@ -0,0 +1,121 @@ +// vars/cgLogin.groovy +// +// 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 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 } }`: +// +// stage('Auth') { +// agent any +// steps { cgLogin() } +// } + +def call() { + 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. + // 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. + // + // 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" + # 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. 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" < 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 ''' + set -eu + mkdir -p "$DOCKER_CONFIG" + echo "cgLogin: Harbor mode, anonymous pulls + pushes to $PUSH_DISPLAY (Mode B)." + ''' + } + } + return + } + + // Mode A: OIDC chainctl flow. + // 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: ' + 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 """ + 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} (Mode A).' + """ + } +} diff --git a/jenkins/shared-libraries/cg-images/vars/cgSign.groovy b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy new file mode 100644 index 00000000..4f531b53 --- /dev/null +++ b/jenkins/shared-libraries/cg-images/vars/cgSign.groovy @@ -0,0 +1,106 @@ +// vars/cgSign.groovy +// +// Signs a freshly-pushed OCI image with cosign using the demo's static +// keypair. The private key + password live in Jenkins's encrypted +// credentials store (registered by JCasC at boot from +// /tmp/cgjenkins-home/.secrets/) and are pulled in by `withCredentials`, +// which writes the key to a temp file in the build workspace and exposes +// the password as a masked env var. The signature is uploaded to the same +// registry the image lives in, as a sibling OCI artifact at +// :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). +// +// 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') { +// 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') + } + 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 + // 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'), + string(credentialsId: 'cosign-password', variable: 'COSIGN_PASSWORD'), + ]) { + sh ''' + set -eu -o pipefail + # 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%:*}" + 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 + 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 + 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 \ + -e DOCKER_CONFIG=/jenkins-docker \ + --entrypoint=/usr/bin/cosign \ + "$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 new file mode 100644 index 00000000..3675e100 --- /dev/null +++ b/jenkins/shared-libraries/cg-images/vars/cgVerify.groovy @@ -0,0 +1,71 @@ +// 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. 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`: +// +// 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') + } + 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}"]) { + withCredentials([ + file(credentialsId: 'cosign-public-key', variable: 'COSIGN_PUB_FILE'), + ]) { + sh ''' + set -eu -o pipefail + # 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%:*}" + 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 + fi + # See cgSign.groovy for why we rewrite bare 'localhost' to 'localhost:80'. + 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 \ + "$COSIGN_IMAGE" \ + 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 new file mode 100755 index 00000000..2dd751c5 --- /dev/null +++ b/jenkins/teardown.sh @@ -0,0 +1,127 @@ +#!/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) +# - 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 +# 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 < Tearing down Harbor (kind cluster, if any)..." +if [[ -x harbor/teardown.sh ]]; then + harbor/teardown.sh +fi + +echo "==> 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 + # 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}" ); 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 + +echo "==> Stopping Jenkins (docker compose down)..." +# Print full output; truncating with `tail -5` hides earlier errors that +# 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). +# 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 "==> Cleaning generated files..." +rm -rf .secrets +rm -f harbor/.pull-token +rm -f shared-libraries/cg-images/IDENTITY +# 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.lock.hcl iac/terraform.tfstate iac/terraform.tfstate.backup iac/jenkins-jwks.json +else + 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.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 + rm -f .env + echo " Removed .env." +fi + +echo +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 +echo "==> Done. Re-run ./setup.sh to bootstrap from scratch."