From b9b1bc7f544fa848c896b6abec1f1892da9685f9 Mon Sep 17 00:00:00 2001 From: Alessandro Franceschi Date: Sun, 30 Aug 2026 13:52:36 +0200 Subject: [PATCH] Add Docker image publishing and CI usage documentation Cutting a `v*` tag now also builds and pushes a multi-platform `example42/piace` image to Docker Hub, assembled from the same release binaries the build job already verified rather than from a builder stage, so a `docker pull` runs the exact bytes `SHA256SUMS` certifies. `:latest` moves only for non-prereleases. - Dockerfile: distroless/static:nonroot base, binary copied in via build args, CA bundle kept only for `piace explain` TLS - .dockerignore: allowlist so the gitignored `dist/` still reaches the build context - ci.yml: fourth `image` job gated on both `build` and `release` so the GitHub Release stays primary and a failed push can be re-run alone - docs/ci.md plus examples/ci/: copy-ready GitHub Actions and GitLab pipelines, two jobs so the catalog-reader identity and the inference token are never held together, with a rendered services template - docs/release.md, README, CHANGELOG (0.2.1), examples/README updated --- .dockerignore | 9 ++ .github/workflows/ci.yml | 115 +++++++++++++++- CHANGELOG.md | 21 ++- Dockerfile | 57 ++++++++ README.md | 23 +++- docs/ci.md | 233 +++++++++++++++++++++++++++++++++ docs/release.md | 69 ++++++++++ examples/README.md | 14 ++ examples/ci/github-actions.yml | 189 ++++++++++++++++++++++++++ examples/ci/gitlab-ci.yml | 147 +++++++++++++++++++++ examples/ci/services.yaml.tmpl | 34 +++++ 11 files changed, 906 insertions(+), 5 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 docs/ci.md create mode 100644 examples/ci/github-actions.yml create mode 100644 examples/ci/gitlab-ci.yml create mode 100644 examples/ci/services.yaml.tmpl diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..09045a3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +# An allowlist, not a denylist. The build context is exactly the release +# binaries the Dockerfile copies: no source, no fixtures, no .git. +# +# Written this way round on purpose: `dist/` is gitignored, so a +# .dockerignore modelled on .gitignore would exclude the one directory +# the build actually needs and the failure would read as a missing +# artifact rather than a mistake in this file. +* +!dist/piace-* diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fbb682..f039c3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,10 +2,11 @@ name: CI # Pull requests run the test job. A merge to main runs the same test job # and then, only if it passed, builds the release artifacts and uploads -# them. A `v*` tag runs both and then publishes a GitHub Release from the -# artifacts the build job already verified. +# them. A `v*` tag runs both, publishes a GitHub Release from the +# artifacts the build job already verified, and then pushes the container +# image built from those same artifacts to Docker Hub. # -# All three live in one workflow so `needs:` can gate each stage on the +# All four live in one workflow so `needs:` can gate each stage on the # one before it — a cross-workflow dependency would need `workflow_run`, # which reports its status against the wrong commit and is easy to # misread. That gating is the point on a tag: a release is published only @@ -283,3 +284,111 @@ jobs: --notes-file release-notes.md \ "${flags[@]}" \ dist/piace-* dist/SHA256SUMS + + image: + name: publish container image + # Both: `build` for the verified artifacts the image is assembled + # from, `release` so Docker Hub follows the GitHub Release rather than + # racing it. The GitHub Release stays the primary artifact: if the + # push here fails, a release is already published and re-running this + # job alone finishes the job. + needs: [build, release] + if: github.ref_type == 'tag' + runs-on: ubuntu-latest + steps: + # Only the Dockerfile and .dockerignore are needed here; the + # binaries come from the build job, downloaded next. + - uses: actions/checkout@v4 + + - name: Download the verified artifacts + uses: actions/download-artifact@v4 + with: + name: piace-${{ needs.build.outputs.version }} + path: dist + + - name: Re-verify the checksum manifest + working-directory: dist + run: sha256sum --check SHA256SUMS + + # Nothing runs in the target architecture during the build (every + # image layer is a COPY of an already cross-compiled binary), so + # buildx alone covers linux/arm64 and no QEMU setup is needed. + - uses: docker/setup-buildx-action@v3 + + # DOCKERHUB_TOKEN is a Docker Hub access token scoped to + # read/write, not the account password. See docs/release.md. + # + # Before the first build, not just before the push: the Dockerfile's + # `# syntax=` line makes BuildKit pull its frontend image from + # Docker Hub, and an anonymous pull from a shared GitHub runner IP + # is the kind of thing that hits a rate limit and fails a release + # for reasons that have nothing to do with this repository. + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Built and loaded locally first, so the assertion below runs + # against the image that is about to be pushed rather than after the + # fact. The multi-platform build that follows reuses this build's + # cache, so the amd64 half of it is near-free. + - name: Build the amd64 image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64 + load: true + push: false + build-args: VERSION=${{ needs.build.outputs.version }} + tags: piace:verify + provenance: false + + # The same assertion the build job makes about the bare binary, made + # again about the packaged one: it catches a COPY that picked up the + # wrong artifact, and an image whose binary lost its executable bit + # in transit through actions/upload-artifact. + - name: Confirm the image reports the version it was built from + env: + VERSION: ${{ needs.build.outputs.version }} + run: | + reported="$(docker run --rm piace:verify version)" + echo "$reported" + if [ "$reported" != "piace ${VERSION}" ]; then + echo "::error::image reports '$reported', expected 'piace ${VERSION}'" + exit 1 + fi + + # `latest` moves only for a full release. A prerelease that took it + # would hand every `docker run example42/piace` a version nobody + # asked for. Composed here rather than inline in `tags:` so an empty + # line never reaches the action. + - name: Compose the image tags + id: tags + env: + VERSION: ${{ needs.build.outputs.version }} + PRERELEASE: ${{ needs.build.outputs.prerelease }} + run: | + { + echo 'tags<> "$GITHUB_OUTPUT" + + # provenance: false keeps the pushed manifest list to the two + # platforms it actually carries. The default attaches a provenance + # attestation as a third manifest entry, which Docker Hub renders as + # an `unknown/unknown` architecture beside the real ones. + - name: Build and push the multi-platform image + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + build-args: VERSION=${{ needs.build.outputs.version }} + tags: ${{ steps.tags.outputs.tags }} + labels: org.opencontainers.image.revision=${{ github.sha }} + provenance: false diff --git a/CHANGELOG.md b/CHANGELOG.md index 7782dcf..b6233f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,23 @@ All notable changes to PIACE are recorded here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.2.1] - 2026-08-30 + +### Added + +- **A published container image**: cutting a `v*` tag now also pushes + `example42/piace:` to Docker Hub, as a `linux/amd64` + + `linux/arm64` manifest list; `:latest` moves with every non-prerelease. The + image is the release binary the workflow already verified, copied onto + `distroless/static`, so what a `docker pull` runs is the bytes `SHA256SUMS` + certifies. It runs as a non-root user out of `/work`: see the README's + Install section for the mount and `--user` flags. +- **[docs/ci.md](docs/ci.md) and [examples/ci/](examples/ci/)**: copy-ready + GitHub Actions and GitLab CI pipelines, where each configuration file belongs + in a control repository, and what changes when the runner is one you do not + control. Two jobs by design, so the catalog-reader identity and the inference + token are never held by the same job. + ## [0.2.0] - 2026-08-29 ### Added @@ -156,5 +173,7 @@ than what changed. The last two are recorded as skipped tests carrying their confirmation procedures in `cmd/piace/acceptance_assumptions_test.go`. -[Unreleased]: https://github.com/example42/piace/compare/v0.1.0...HEAD +[Unreleased]: https://github.com/example42/piace/compare/v0.2.1...HEAD +[0.2.1]: https://github.com/example42/piace/releases/tag/v0.2.1 +[0.2.0]: https://github.com/example42/piace/releases/tag/v0.2.0 [0.1.0]: https://github.com/example42/piace/releases/tag/v0.1.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..649542a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,57 @@ +# syntax=docker/dockerfile:1 +# +# The PIACE container image: one statically linked binary on a base that +# carries nothing but a CA bundle, /etc/passwd and /tmp. +# +# This image is assembled from the artifacts `scripts/build-release.sh` +# already produced, not from a `golang` builder stage. The release job in +# .github/workflows/ci.yml publishes the bytes the build job verified +# rather than rebuilding, and the image holds to the same rule: what a +# `docker pull` runs is byte-for-byte what the GitHub Release publishes +# and what SHA256SUMS certifies. A builder stage would produce a second, +# unchecked binary that only looks identical: a different Go patch level +# in the base image is enough to make it differ. +# +# It also means nothing ever executes in the target architecture during +# the build: the arm64 image is a `COPY` of a cross-compiled binary, so +# multi-platform builds need buildx but no QEMU emulation. +# +# Build it by hand with: +# +# scripts/build-release.sh 1.0.0 +# docker build --build-arg VERSION=1.0.0 -t piace:1.0.0 . + +# distroless/static rather than scratch: `piace explain` reaches an +# OpenAI-compatible inference service over ordinary TLS (the compiler and +# PuppetDB transports carry their own CA bundle from the services file, +# but the inference client uses Go's default transport), so the image +# needs system root certificates or every `explain` run fails to verify. +# The `nonroot` variant runs as uid 65532; see docs/release.md for the +# `--user` flag that makes report output land in a bind mount. +FROM gcr.io/distroless/static-debian12:nonroot + +# Both are consumed by the COPY below. TARGETARCH is a predefined build +# argument, but a stage sees it only after declaring it. Undeclared, it +# expands to the empty string and the COPY silently looks for the wrong +# file. +ARG VERSION +ARG TARGETARCH + +# --chmod because the artifacts arrive in CI through actions/upload-artifact, +# which does not preserve the executable bit. Without it the image builds +# clean and fails at `docker run` with "permission denied". +COPY --chmod=0755 dist/piace-${VERSION}-linux-${TARGETARCH} /usr/local/bin/piace + +# PIACE reads its targets, services and snapshot files from the working +# directory and writes its reports back to it, so the whole interface is +# one bind mount here. +WORKDIR /work + +LABEL org.opencontainers.image.title="piace" \ + org.opencontainers.image.description="Puppet Impact Assessment & Change Explorer" \ + org.opencontainers.image.source="https://github.com/example42/piace" \ + org.opencontainers.image.documentation="https://github.com/example42/piace/blob/main/README.md" \ + org.opencontainers.image.vendor="example42" \ + org.opencontainers.image.version="${VERSION}" + +ENTRYPOINT ["/usr/local/bin/piace"] diff --git a/README.md b/README.md index e77ce99..da00735 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,23 @@ go build -o piace ./cmd/piace Go 1.22+, no other dependency. Release artifacts, checksums and signature verification: [docs/release.md](docs/release.md). +Or run the published image, which is the same release binary on a +distroless base. It runs as a non-root user and works out of `/work`, so +mount your workspace there and pass your own uid; without both, writing a +report into the mount fails with a permission error: + +```sh +docker run --rm \ + --user "$(id -u):$(id -g)" \ + --volume "$PWD:/work" \ + example42/piace:latest \ + compare --targets targets.yaml --services services.yaml --html-out report.html +``` + +Every path in `targets.yaml` and `services.yaml` (CA bundle, client +certificate, key, snapshots, outputs) is resolved inside the container, +so keep them under the mount. + ## Quick start 1. **Write `services.yaml`** — where your compiler and PuppetDB are, and the @@ -48,7 +65,9 @@ piace compare --targets targets.yaml --services services.yaml \ ``` The text report goes to stdout; the exit code tells CI what happened. See -[Exit codes](#exit-codes). +[Exit codes](#exit-codes), and [docs/ci.md](docs/ci.md) for the pipeline +around it: file layout, credential handling, and copy-ready GitHub Actions and +GitLab CI jobs. --- @@ -636,4 +655,6 @@ intact, inside the fence. - [docs/development.md](docs/development.md) — building, testing, CI, releases, package layout, project status - [examples/](examples/) — loadable sample configuration for every usage pattern +- [docs/ci.md](docs/ci.md): running PIACE in CI, pipeline shape, where each + file belongs, and credentials on a runner you do not control - [docs/release.md](docs/release.md) — release artifacts and verification diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..50a1a55 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,233 @@ +# Running PIACE in CI + +PIACE is a CI tool: one command, a stable exit code, two files in and three +files out. The work is not in the invocation, it is in deciding what lives in +the repository, what is injected per job, and what must never touch either. +That decision gets sharper the less you control the runner. + +Working pipelines to copy: [`examples/ci/github-actions.yml`](../examples/ci/github-actions.yml) +and [`examples/ci/gitlab-ci.yml`](../examples/ci/gitlab-ci.yml), with the +services template they render, +[`examples/ci/services.yaml.tmpl`](../examples/ci/services.yaml.tmpl). + +## The shape + +Two jobs, not one: + +1. **`compare`** holds the catalog-reader identity, talks to the compiler and + PuppetDB, and writes `report.json` and `report.html`. Its exit code is the + gate. +2. **`explain`** holds an inference token, reads the stored `report.json`, and + writes an advisory assessment. It contacts nothing else, and it cannot + change an outcome or an exit code. + +They are split because they need different credentials and neither needs the +other's. `explain` runs happily on a services file carrying nothing but +`version:` and `inference:` (see +[`examples/services-explain-only.yaml`](../examples/services-explain-only.yaml)), +so the job that talks to a third-party inference service never has the private +key that reads every catalog in your estate. A runner compromise in either job +yields one credential rather than both. + +Run `explain` even when `compare` failed the gate. `compare` writes its result +document before it exits `10`, and the run worth reading is usually the one +that just stopped a merge. + +## Getting the binary onto the runner + +Pin a version and verify it. A job that fetches "the latest binary" on every +run has made your pipeline a client of whatever is published tomorrow. + +```sh +version=0.2.1 +base="https://github.com/example42/piace/releases/download/v${version}" +wget -q -O "piace-${version}-linux-amd64" "$base/piace-${version}-linux-amd64" +wget -q -O SHA256SUMS "$base/SHA256SUMS" +grep " piace-${version}-linux-amd64\$" SHA256SUMS | sha256sum -c - +install -m 0755 "piace-${version}-linux-amd64" /usr/local/bin/piace +``` + +One manifest line rather than the whole file: the other platforms were not +downloaded, and a line that matches nothing on disk has to fail rather than +pass quietly. Once the detached signature is attached to a release, verify +that first and treat the checksum as the second step, not the only one. See +[release.md](release.md#verifying-a-downloaded-release). + +Both `grep` and `sha256sum` here are busybox-compatible, so this runs on a +plain `alpine` image with no package installation, which matters on a runner +with no route to a package mirror. + +**Air-gapped:** mirror the binary and `SHA256SUMS` into your internal artifact +repository, verify the signature once at the boundary, and have jobs fetch +from the mirror. Nothing in PIACE resolves a dependency at run time, so a +mirrored binary is the whole install. + +**The container image is not the CI install path.** `example42/piace` is +distroless: no shell, no `git`, and its entrypoint is the binary. GitLab's +docker executor runs a job script by passing `sh` or `bash` to the image, and +GitHub Actions `container:` jobs likewise expect a shell in the image, so +neither can use it as a job image. It is built for `docker run` on a workstation, a Kubernetes Job, or +a step on a runner where you already have a Docker socket: + +```sh +docker run --rm \ + --user "$(id -u):$(id -g)" \ + --volume "$PWD:/work" \ + --volume "$PIACE_RUN:/run/piace:ro" \ + example42/piace:0.2.1 \ + compare --targets ci/piace/targets.yaml --services /run/piace/services.yaml \ + --json-out report.json --html-out report.html +``` + +Render the services template with `@PIACE_RUN@` set to `/run/piace` in that +case: the paths in it are resolved inside the container. + +## Where each file goes + +Committed to the control repository, under one directory: + +| Path | What it is | Why it is committed | +| --- | --- | --- | +| `ci/piace/targets.yaml` | Which nodes, which environments, what to exclude and redact | It is policy. A change to an exclusion rule belongs in a review diff | +| `ci/piace/services.yaml.tmpl` | Endpoints, and the TLS paths as `@PIACE_RUN@` placeholders | Endpoints are not secrets, and a changed endpoint should be reviewed | +| `ci/piace/services-explain.yaml` | The `inference:` section only, token referenced by `token_env` | No secret in it; `compare` cannot see it | +| `ci/piace/policy-notes.md` | Site policy handed to the model | Reviewable, and capped at 4000 bytes | +| `ci/piace/change-context.sh` | A copy of PIACE's `scripts/change-context.sh` | It runs in your repository, against your history | +| `snapshots/catalogs/{certname}.json` | A frozen baseline, if you use a file baseline | It is a versioned input, refreshed by `piace capture` | + +Written per job, into a private directory outside the checkout, and removed +when the job ends: + +| Path | What it is | +| --- | --- | +| `$PIACE_RUN/ca.pem`, `client.pem`, `client.key` | The catalog-reader identity, `0600` in a `0700` directory | +| `$PIACE_RUN/services.yaml` | The rendered template | + +Produced by the run, in the workspace, uploaded as job artifacts: +`report.json`, `report.html`, `assessment.json`. + +### Why the services file is a template + +PIACE expands nothing: no environment variables, no includes. Its TLS paths +resolve against the **process working directory**, not against the services +file, so the only reliable form is an absolute path, and the absolute path of +a per-job directory is not known until the job starts. One `sed` closes the +gap: + +```sh +install -d -m 0700 "$PIACE_RUN" +sed "s|@PIACE_RUN@|$PIACE_RUN|g" ci/piace/services.yaml.tmpl > "$PIACE_RUN/services.yaml" +``` + +The other two path rules differ, and copying a file to a new directory is +exactly when that bites: `baseline.file` and `facts.file` resolve against the +**target file's** directory, and `policy_notes_file` against the **services +file's** directory. That last one is why `services-explain.yaml` is committed +next to `policy-notes.md` and rendered from nothing: it can then name the +notes file relatively and stay correct. + +### Why credentials never go in the checkout + +Everything in the workspace is one `artifacts:` glob, one `actions/cache` key +or one forgotten `git add` away from being somewhere else. A per-job directory +outside it (`$RUNNER_TEMP` on GitHub, any path you create on GitLab) is not +reachable by any of those. + +## Change context + +`piace explain --change` reads a file the caller produces; PIACE never invokes +git. `scripts/change-context.sh` generates one, and it is meant to be copied +into your control repository: it is 50 lines of dependency-free bash that +runs against your history, not PIACE's. + +Two things it needs from the CI system: + +- **Full history.** It takes a merge base. Set `fetch-depth: 0` on + `actions/checkout`, or `GIT_DEPTH: "0"` on GitLab, or the `git merge-base` + call fails on a shallow clone. +- **`bash` and `git`, not `sh` alone.** It uses process substitution, and it + is the only step in either pipeline that shells out to git. On a bare Alpine + job image that means `apk add --no-cache bash git`, which is also the only + step that needs a package mirror: the comparison job installs nothing. On a + runner with no route to one, give the assessment job an image that already + carries both. + +Read [`examples/change-context.yaml`](../examples/change-context.yaml) before +enabling it. A change context is forwarded to the inference service exactly as +written and is **not** pseudonymized: PIACE cannot tell which words in a merge +request description are node names. + +## On a runner you do not fully control + +The catalog-reader identity is the asset. It is authorized to read the catalog +of every node you compare, which makes it a read-only credential to your +estate's configuration, file content included. Assume that anyone who can run +a job on the runner can read it while it exists on disk, and plan from there. + +**Give it its own identity.** One certificate used only by PIACE in CI, +authorized for catalog retrieval and nothing else. See the README's +[Authorizing the catalog-reader certificate](../README.md#authorizing-the-catalog-reader-certificate). +Rotating or revoking it then costs one `auth.conf` rule and no agent runs. + +**Keep it off unprotected branches.** On GitLab, mark all three PEM variables +**Protected**, so only pipelines on protected branches and tags receive them, +and use the **file** variable type: GitLab writes the value to a temporary +file and hands the job its path. A PEM is multi-line, and a multi-line value +cannot be masked, so a variable-type key is one `echo` away from a job log. On +GitHub, a `pull_request` from a fork gets no secrets at all, which is correct +behavior to keep: skip the job for forks rather than reaching for +`pull_request_target`, which hands the secrets to a workflow the fork's branch +can influence. An Environment with required reviewers adds a human gate in +front of the credential. + +**Prefer an ephemeral executor.** The docker and Kubernetes executors give +each job a fresh container, so a `0700` directory in `/tmp` is private to the +job. A shell or ssh executor does not: every job on that host runs as the same +user and can read the same paths. On a shared shell runner, treat the identity +as disclosed to every project that can schedule work there, and use a +dedicated runner instead. + +**Clean up on the failure paths.** GitLab's `after_script` runs even when the +job fails, times out or is cancelled; GitHub needs `if: always()`. That is +precisely when a key is most likely to be left behind. + +**Do not trace the secret-handling steps.** PIACE never prints credentials, +and `--debug` reports only request metadata and response member names, so it +is safe in a job log. A `set -x` in your own script is not: it would echo the +commands that write the key. + +**Treat the reports as sensitive output.** They carry catalog values, redacted +per your `redact:` selectors but not otherwise sanitized. Keep retention short +(`expire_in`, `retention-days`), restrict who can download them where the +platform allows it (GitLab's `artifacts:access`), and never use +`--debug-dump-dir` in CI: it writes unredacted request and response bodies. + +**Mind the network path, not just the credential.** The runner needs to reach +your compiler on 8140 and PuppetDB on 8081. A hosted runner reaching them +means those ports are reachable from the hosted runner's network. PIACE opens +connections only to the endpoints in its own services file, so nothing else in +the job's egress is PIACE's doing, but the route you opened for it stays open +for the rest of the job. + +## The exit code is the gate + +| Code | Meaning | Usual CI handling | +| --- | --- | --- | +| `0` | No differences, or all allowed by policy | Pass | +| `10` | A `fail_on_diff` target had a non-excluded difference | Block the merge, or warn and let a human read the report | +| `20` | A candidate did not compile, or its identity or environment did not verify | Fail. The change does not build | +| `30` | Config, TLS, retrieval, snapshot or normalization failure | Fail. The run did not complete, so `0` would be a lie | + +To review differences without blocking, set `fail_on_diff: false` in +`targets.yaml` rather than swallowing the exit code in the job script: the +comparison then reports `differences_allowed`, and the report still says what +changed. Where the platform can express it, keeping `fail_on_diff: true` and +softening only `10` is better still, because `20` and `30` stay hard failures. +GitLab spells that `allow_failure: {exit_codes: 10}`. + +`piace explain` exits `0` or `30` only. It returns `30` for its own +operational failures, a services file it cannot load or a result document it +cannot read, and for an assessment the inference service did not produce only +if you pass `--fail-on-inference-error`. By default a failed assessment is a +diagnostic, not a reason to fail a pipeline that already has its +deterministic answer. diff --git a/docs/release.md b/docs/release.md index c98a771..02d152e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -18,6 +18,12 @@ distribution". | `SHA256SUMS` | One SHA-256 line per binary, sorted by filename | | `SHA256SUMS.asc` | Detached OpenPGP signature over `SHA256SUMS` | +Published alongside them, from the same artifacts: + +| Image | Contents | +| --- | --- | +| `example42/piace:` | A `linux/amd64` + `linux/arm64` manifest list; `:latest` moves with every non-prerelease | + The signature covers the **manifest**, not each binary individually. One signature then transitively covers every artifact, and a consumer needs exactly one trusted public key rather than one signature per platform. @@ -68,6 +74,26 @@ artifacts attests to integrity, never to origin. The release notes say so in as many words, so a consumer is not left following a verification step that cannot yet succeed. +### The container image + +Once the release exists, a fourth job packages those same binaries as +`example42/piace:` and pushes it to Docker Hub. It waits on the +release rather than running beside it, so the GitHub Release stays the +primary artifact: if the push fails, the release is already out and +re-running the `publish container image` job on its own finishes the +work. A prerelease publishes its version tag but does not move `latest`. + +The image job needs two repository secrets, and fails visibly on the +first tag pushed without them: + +| Secret | Value | +| --- | --- | +| `DOCKERHUB_USERNAME` | A Docker Hub account with push access to `example42/piace` | +| `DOCKERHUB_TOKEN` | A Docker Hub **access token** for that account with Read & Write scope, not the account password | + +Use an access token: it is scoped, revocable on its own, and does not +carry the account's Hub session. + ## Generating the artifacts by hand CI runs exactly this, and it stays usable directly for an air-gapped or @@ -99,6 +125,49 @@ CGO_ENABLED=0 GOOS= GOARCH= \ - `-X main.toolVersion=` stamps the version the tool reports and records in every result document's invocation metadata. +## Building the image by hand + +The `Dockerfile` copies release artifacts; it does not compile. Build +them first, then hand the same version in as a build argument: + +```sh +scripts/build-release.sh 1.0.0 +docker build --build-arg VERSION=1.0.0 -t piace:1.0.0 . +``` + +The image is `gcr.io/distroless/static-debian12:nonroot` plus the one +binary: no shell, no package manager, and a CA bundle only because +`piace explain` verifies an inference service against the system roots +(the compiler and PuppetDB transports carry their own CA bundle from the +services file, and trust nothing else). Assembling it from the built +artifacts rather than from a `golang` builder stage is what makes the +binary inside the image the same bytes `SHA256SUMS` certifies. + +It runs as uid 65532, which cannot write to a bind mount owned by +someone else, and writing a report into the mounted workspace is the +common case, so pass the invoking user: + +```sh +docker run --rm \ + --user "$(id -u):$(id -g)" \ + --volume "$PWD:/work" \ + example42/piace:1.0.0 \ + compare --targets targets.yaml --services services.yaml --html-out report.html +``` + +The working directory is `/work`. Every path in `targets.yaml` and +`services.yaml` (CA bundle, client certificate, key, snapshots, output +files) is resolved inside the container, so they have to be reachable +under that mount. + +The image deliberately carries the binary and nothing else, which decides +where it fits. `scripts/change-context.sh` is not in it and cannot be: it +needs bash, git, and a checkout with history, none of which belong in an +image whose job is to hold one static binary. It runs on the runner, which +has all three, and PIACE reads the file it produces. For the same reason the +image cannot serve as a GitLab or GitHub CI job image, which must provide a +shell: in CI, install the verified binary instead. See [ci.md](ci.md). + ## Signing the manifest ```sh diff --git a/examples/README.md b/examples/README.md index 7c3c445..79aa37f 100644 --- a/examples/README.md +++ b/examples/README.md @@ -20,6 +20,7 @@ TLS paths, and delete the comments you no longer need. | [`services-explain-only.yaml`](services-explain-only.yaml) | `explain` | Assessment on a runner with no Puppet mTLS material | | [`change-context.yaml`](change-context.yaml) | `explain` | The repository change under test | | [`policy-notes.md`](policy-notes.md) | `explain` | Site policy handed to the model as context | +| [`ci/`](ci/) | `compare`, `explain` | Working GitHub Actions and GitLab CI pipelines, and the services template they render | Two files, deliberately separate: the reviewable selection/policy file (`targets-*.yaml`), and the endpoint/mTLS file that does not belong in a review @@ -68,4 +69,17 @@ TLS paths do not exist. They load, which is the part these files are for. corrupts the baseline it just read. See [`targets-v3-legacy.yaml`](targets-v3-legacy.yaml). +## In a pipeline + +[`ci/`](ci/) holds the same configuration arranged for CI: two jobs so the +identity that reads every catalog in the estate is never in the same job as +the inference token, the identity written to a per-job directory outside the +checkout, and a services file rendered from +[`ci/services.yaml.tmpl`](ci/services.yaml.tmpl) because PIACE expands no +variables and its TLS paths resolve against the process working directory. + +Read [docs/ci.md](../docs/ci.md) alongside them: it covers where each file +belongs, how the exit code becomes a gate, and what changes when the runner is +one you do not control. + Full reference: the [README](../README.md). diff --git a/examples/ci/github-actions.yml b/examples/ci/github-actions.yml new file mode 100644 index 0000000..55fa35b --- /dev/null +++ b/examples/ci/github-actions.yml @@ -0,0 +1,189 @@ +# GitHub Actions: compare on every pull request, then assess the result. +# +# Copy to .github/workflows/piace.yml in your control repository, with the +# layout docs/ci.md describes: +# +# ci/piace/targets.yaml committed, reviewable policy +# ci/piace/services.yaml.tmpl committed, rendered per job +# ci/piace/services-explain.yaml committed, inference only +# ci/piace/policy-notes.md committed, site policy for the model +# ci/piace/change-context.sh committed, copied from PIACE's scripts/ +# +# Two jobs, not one, because they need different credentials: `compare` holds +# the catalog-reader identity and never sees the inference token, `explain` +# holds the inference token and never sees a private key. A runner compromise +# in either job yields one of the two. + +name: piace + +on: + pull_request: + +# The workflow reads the checkout and nothing else. Neither job writes to the +# repository. +permissions: + contents: read + +env: + PIACE_VERSION: '0.2.1' + +jobs: + compare: + runs-on: ubuntu-latest + # A pull request from a fork gets no secrets, so the job would fail at TLS + # load with exit 30 rather than doing anything useful. Skip it instead of + # producing a red run nobody can fix. Never reach for `pull_request_target` + # to work around this: it hands the secrets to code the fork controls. + if: github.event.pull_request.head.repo.full_name == github.repository + steps: + - uses: actions/checkout@v4 + with: + # change-context.sh takes a merge base, which a shallow clone does + # not have. + fetch-depth: 0 + + # Pinned to a version and verified against the manifest published with + # it. A runner that fetches "the latest binary" from the internet on + # every run is a supply chain you do not control. + - name: Install piace + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/piace-bin" + cd "$RUNNER_TEMP/piace-bin" + base="https://github.com/example42/piace/releases/download/v${PIACE_VERSION}" + curl -fsSLO "$base/piace-${PIACE_VERSION}-linux-amd64" + curl -fsSLO "$base/SHA256SUMS" + # Verifying one line of the manifest rather than the whole file: the + # other platforms were not downloaded, and a manifest line that + # matches nothing on disk must fail rather than pass quietly. + grep " piace-${PIACE_VERSION}-linux-amd64\$" SHA256SUMS | sha256sum -c - + install -m 0755 "piace-${PIACE_VERSION}-linux-amd64" piace + echo "$RUNNER_TEMP/piace-bin" >> "$GITHUB_PATH" + # Once the detached signature is attached to the release, verify it + # here first and drop the checksum line above to a second step: + # gpg --verify SHA256SUMS.asc SHA256SUMS + + # $RUNNER_TEMP is per-job and outside the checkout, which is what makes + # it the right home for a private key: nothing here reaches actions/cache, + # an uploaded artifact, or a later `git status`. + # + # No `set -x` in this step, ever. The secrets themselves are never + # echoed; a trace of the commands that write them would be. + - name: Render the services file and write the catalog-reader identity + env: + PIACE_CA_BUNDLE: ${{ secrets.PIACE_CA_BUNDLE }} + PIACE_CLIENT_CERT: ${{ secrets.PIACE_CLIENT_CERT }} + PIACE_PRIVATE_KEY: ${{ secrets.PIACE_PRIVATE_KEY }} + run: | + set -euo pipefail + umask 077 + install -d -m 0700 "$RUNNER_TEMP/piace-run" + printf '%s\n' "$PIACE_CA_BUNDLE" > "$RUNNER_TEMP/piace-run/ca.pem" + printf '%s\n' "$PIACE_CLIENT_CERT" > "$RUNNER_TEMP/piace-run/client.pem" + printf '%s\n' "$PIACE_PRIVATE_KEY" > "$RUNNER_TEMP/piace-run/client.key" + sed "s|@PIACE_RUN@|$RUNNER_TEMP/piace-run|g" ci/piace/services.yaml.tmpl \ + > "$RUNNER_TEMP/piace-run/services.yaml" + + # Exit 10 is a policy difference, which fails the step and so the job: + # that is `fail_on_diff: true` in targets.yaml doing its work. 20 and 30 + # mean the run did not complete. To review differences without blocking + # the pull request, set `fail_on_diff: false` rather than swallowing the + # exit code here, so the report still says what changed. + - name: Compare + run: | + piace compare \ + --targets ci/piace/targets.yaml \ + --services "$RUNNER_TEMP/piace-run/services.yaml" \ + --json-out report.json \ + --html-out report.html + + # Both reports carry catalog values, redacted per targets.yaml but not + # otherwise sanitized. Keep the retention short and remember that anyone + # who can read the repository can download them. + - name: Upload the reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: piace-report + path: | + report.json + report.html + retention-days: 5 + if-no-files-found: warn + + # A hosted runner is destroyed after the job and a self-hosted one is + # not. This step costs nothing on the first and matters on the second. + - name: Remove the identity + if: always() + run: rm -rf "$RUNNER_TEMP/piace-run" + + explain: + needs: compare + runs-on: ubuntu-latest + # `always()` on purpose: the run worth assessing is usually the one that + # just failed the gate. `compare` writes report.json before it exits 10. + if: always() && needs.compare.result != 'skipped' + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install piace + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/piace-bin" + cd "$RUNNER_TEMP/piace-bin" + base="https://github.com/example42/piace/releases/download/v${PIACE_VERSION}" + curl -fsSLO "$base/piace-${PIACE_VERSION}-linux-amd64" + curl -fsSLO "$base/SHA256SUMS" + grep " piace-${PIACE_VERSION}-linux-amd64\$" SHA256SUMS | sha256sum -c - + install -m 0755 "piace-${PIACE_VERSION}-linux-amd64" piace + echo "$RUNNER_TEMP/piace-bin" >> "$GITHUB_PATH" + + # GitHub has no per-exit-code handling, so `compare` fails its job on + # 10, 20 and 30 alike and this job cannot tell them apart from + # needs.compare.result. Whether a result document was produced is the + # honest proxy: exit 10 wrote one, exit 30 usually did not. + - name: Download the reports + id: reports + continue-on-error: true + uses: actions/download-artifact@v4 + with: + name: piace-report + + # PIACE never invokes git. This script does, in the checkout, on the + # runner: commit subjects and changed paths only, never bodies. Read + # examples/change-context.yaml before enabling it. Everything in the + # generated file is forwarded to the inference service as data, and the + # change context is not pseudonymized. + - name: Describe the change + if: steps.reports.outcome == 'success' + run: | + ci/piace/change-context.sh \ + "origin/${{ github.base_ref }}" HEAD > change-context.yaml + + # The token is referenced by name; there is no field that inlines one. + # `explain` cannot change an exit code, so a failure here is advisory + # unless you pass --fail-on-inference-error. + - name: Assess + if: steps.reports.outcome == 'success' + env: + PIACE_INFERENCE_TOKEN: ${{ secrets.PIACE_INFERENCE_TOKEN }} + run: | + piace explain \ + --json-in report.json \ + --services ci/piace/services-explain.yaml \ + --change change-context.yaml \ + --ai-out assessment.json \ + --html-out report.html + + - name: Upload the assessment + if: always() && steps.reports.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: piace-assessment + path: | + assessment.json + report.html + retention-days: 5 + if-no-files-found: warn diff --git a/examples/ci/gitlab-ci.yml b/examples/ci/gitlab-ci.yml new file mode 100644 index 0000000..2dc3a88 --- /dev/null +++ b/examples/ci/gitlab-ci.yml @@ -0,0 +1,147 @@ +# GitLab CI: compare on every merge request, then assess the result. +# +# Copy to .gitlab-ci.yml, or `include:` it, with the layout docs/ci.md +# describes: +# +# ci/piace/targets.yaml committed, reviewable policy +# ci/piace/services.yaml.tmpl committed, rendered per job +# ci/piace/services-explain.yaml committed, inference only +# ci/piace/policy-notes.md committed, site policy for the model +# ci/piace/change-context.sh committed, copied from PIACE's scripts/ +# +# Two jobs, not one, because they need different credentials: `piace-compare` +# holds the catalog-reader identity and never sees the inference token, +# `piace-explain` holds the inference token and never sees a private key. +# +# `image:` is a plain Alpine, not example42/piace. The docker executor runs a +# job script by passing `sh` or `bash` to the image, so a job image has to +# provide a shell (and grep) and an entrypoint that runs them. The published +# PIACE image is distroless: no shell, and its entrypoint is the binary +# itself. Install the verified binary into a shell image instead. The clone is +# not the problem, that happens in the runner's helper container. + +variables: + PIACE_VERSION: "0.2.1" + # change-context.sh takes a merge base. GitLab clones shallow by default, + # and a shallow clone does not have one. + GIT_DEPTH: "0" + # Outside the checkout: nothing here reaches `cache:`, `artifacts:`, or a + # later `git status`. The docker and Kubernetes executors give each job a + # fresh container, so this directory is private to the job. A shell or ssh + # executor does not: there, every job on the host runs as the same user and + # can read this path. + PIACE_RUN: "/tmp/piace-run" + +stages: + - assess + +.piace-install: &piace-install + # Pinned to a version and verified against the manifest published with it. + # A runner that fetches "the latest binary" on every run is a supply chain + # you do not control. Alpine's busybox has wget and sha256sum built in, so + # this needs no apk and works on a runner with no package mirror. + - | + set -eu + base="https://github.com/example42/piace/releases/download/v${PIACE_VERSION}" + wget -q -O "piace-${PIACE_VERSION}-linux-amd64" "$base/piace-${PIACE_VERSION}-linux-amd64" + wget -q -O SHA256SUMS "$base/SHA256SUMS" + # One line of the manifest, not the whole file: the other platforms were + # not downloaded, and a line matching nothing on disk must fail rather + # than pass quietly. + grep " piace-${PIACE_VERSION}-linux-amd64\$" SHA256SUMS | sha256sum -c - + install -m 0755 "piace-${PIACE_VERSION}-linux-amd64" /usr/local/bin/piace + +piace-compare: + stage: assess + image: alpine:3.21 + rules: + # Merge requests from a fork do not get protected variables, so the job + # would fail at TLS load with exit 30 rather than doing anything useful. + - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_PROJECT_PATH == $CI_PROJECT_PATH + before_script: + - *piace-install + script: + # PIACE_CA_BUNDLE, PIACE_CLIENT_CERT and PIACE_PRIVATE_KEY are **file + # type** CI/CD variables: GitLab writes each value to a temporary file and + # puts that file's path in the variable. Use file type rather than + # variable type for all three. A PEM is multi-line, and a multi-line value + # cannot be masked, so a variable-type key is one `echo` away from a job + # log. Mark all three Protected as well, so an unprotected branch never + # sees them. + - install -d -m 0700 "$PIACE_RUN" + - install -m 0600 "$PIACE_CA_BUNDLE" "$PIACE_RUN/ca.pem" + - install -m 0600 "$PIACE_CLIENT_CERT" "$PIACE_RUN/client.pem" + - install -m 0600 "$PIACE_PRIVATE_KEY" "$PIACE_RUN/client.key" + # PIACE expands nothing in a services file, and its TLS paths resolve + # against the process working directory, so the run directory has to be + # substituted in before the run rather than referenced from it. + - sed "s|@PIACE_RUN@|$PIACE_RUN|g" ci/piace/services.yaml.tmpl > "$PIACE_RUN/services.yaml" + - | + piace compare \ + --targets ci/piace/targets.yaml \ + --services "$PIACE_RUN/services.yaml" \ + --json-out report.json \ + --html-out report.html + after_script: + # after_script runs even when the job fails, times out or is cancelled, + # which is exactly when a private key is most likely to be left behind. + - rm -rf "$PIACE_RUN" + allow_failure: + # 10 is a policy difference: a real finding, reported as a warning so the + # assessment job still runs and a reviewer sees the report. 20 (the + # candidate did not compile) and 30 (the run did not complete) stay hard + # failures. Drop this block to block the merge request on 10 as well. + exit_codes: 10 + artifacts: + when: always + expire_in: 1 week + # Both reports carry catalog values, redacted per targets.yaml but not + # otherwise sanitized. `access:` keeps them away from roles that can see + # the pipeline but have no business reading a catalog. + access: 'developer' + paths: + - report.json + - report.html + +piace-explain: + stage: assess + image: alpine:3.21 + needs: + # piace-compare is `allow_failure: exit_codes: 10`, so a policy difference + # counts as success for the DAG and this job still runs: that is the + # pipeline most worth assessing, and `compare` writes report.json before + # it exits 10. A hard failure (20 or 30) skips this job, which is right, + # because there is no complete result document to assess. + - job: piace-compare + artifacts: true + rules: + - if: $CI_PIPELINE_SOURCE == "merge_request_event" && $CI_MERGE_REQUEST_SOURCE_PROJECT_PATH == $CI_PROJECT_PATH + before_script: + - *piace-install + # git is not in the base Alpine image, and this job needs a checkout with + # history to describe the change. + - apk add --no-cache git bash + script: + # PIACE never invokes git. This script does, in the checkout, on the + # runner: commit subjects and changed paths only, never bodies. Read + # examples/change-context.yaml before enabling it. Everything in the + # generated file is forwarded to the inference service as data, and the + # change context is not pseudonymized. + - ci/piace/change-context.sh "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" HEAD > change-context.yaml + # PIACE_INFERENCE_TOKEN is a masked, protected variable of type variable: + # a bearer token is single-line, so unlike a PEM it can be masked. The + # services file references it by name; there is no field that inlines one. + - | + piace explain \ + --json-in report.json \ + --services ci/piace/services-explain.yaml \ + --change change-context.yaml \ + --ai-out assessment.json \ + --html-out report.html + artifacts: + when: always + expire_in: 1 week + access: 'developer' + paths: + - assessment.json + - report.html diff --git a/examples/ci/services.yaml.tmpl b/examples/ci/services.yaml.tmpl new file mode 100644 index 0000000..698f8e5 --- /dev/null +++ b/examples/ci/services.yaml.tmpl @@ -0,0 +1,34 @@ +# services.yaml.tmpl: the services file a CI job renders before it runs. +# +# PIACE expands nothing: no environment variables, no includes. TLS paths in +# a services file resolve against the process working directory, which on a +# runner is wherever the job happens to be standing, so the only safe form is +# an absolute path. The path of a per-job directory is not known until the job +# starts, and that is the whole reason this file is a template: +# +# install -d -m 0700 "$PIACE_RUN" +# sed "s|@PIACE_RUN@|$PIACE_RUN|g" ci/piace/services.yaml.tmpl \ +# > "$PIACE_RUN/services.yaml" +# +# Commit this file. It names endpoints and paths, which are not secrets, and +# it belongs in the diff when either changes. What lands in @PIACE_RUN@ is the +# catalog-reader identity, which is never committed and never written into the +# checkout. See docs/ci.md. +# +# There is no `inference:` section here on purpose. `compare` cannot see one, +# and the job that holds the Puppet mTLS material is not the job that should +# also hold an inference token. + +version: 1 + +compiler: + endpoint: https://puppet.ops.example.com:8140 + ca_bundle: "@PIACE_RUN@/ca.pem" + client_cert: "@PIACE_RUN@/client.pem" + private_key: "@PIACE_RUN@/client.key" + +puppetdb: + endpoint: https://puppetdb.ops.example.com:8081 + ca_bundle: "@PIACE_RUN@/ca.pem" + client_cert: "@PIACE_RUN@/client.pem" + private_key: "@PIACE_RUN@/client.key"