Release via goreleaser; test against a real Vault
Replace the nfpm script with .goreleaser.yaml as the single driver for the RPM, the raw release binaries the compiler initContainer fetches, archives and checksums. Add an end-to-end test that issues a certificate from a real Vault dev server over approle.
This commit is contained in:
@@ -3,3 +3,5 @@
|
||||
# cross-compiled release artifacts (e.g. certmanager-linux-amd64)
|
||||
/certmanager-*
|
||||
dist/
|
||||
# goreleaser-generated shell completions
|
||||
/completions/
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
version: 2
|
||||
|
||||
project_name: certmanager
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# goreleaser refuses to release from a dirty tree, so nothing here may
|
||||
# touch tracked files; completions/ is gitignored.
|
||||
- mkdir -p completions
|
||||
- sh -c "go run ./cmd/certmanager completion bash > completions/certmanager.bash"
|
||||
- sh -c "go run ./cmd/certmanager completion zsh > completions/_certmanager"
|
||||
- sh -c "go run ./cmd/certmanager completion fish > completions/certmanager.fish"
|
||||
|
||||
builds:
|
||||
- id: certmanager
|
||||
main: ./cmd/certmanager
|
||||
binary: certmanager
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
goarch:
|
||||
- amd64
|
||||
- arm64
|
||||
flags:
|
||||
- -trimpath
|
||||
ldflags:
|
||||
# Puppet writes the config to /opt/certmanager/config.yaml.
|
||||
- -s -w -X main.version={{ .Version }} -X git.unkin.net/unkin/certmanager/internal/config.defaultPath=/opt/certmanager/config.yaml
|
||||
|
||||
archives:
|
||||
# Raw binaries: the puppetserver-compiler initContainer fetches a single
|
||||
# pinned file by URL and checksums it, so it cannot unpack an archive.
|
||||
- id: binaries
|
||||
ids: [certmanager]
|
||||
formats: [binary]
|
||||
name_template: "{{ .ProjectName }}-{{ .Os }}-{{ .Arch }}"
|
||||
- id: archives
|
||||
ids: [certmanager]
|
||||
formats: [tar.gz]
|
||||
name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
files:
|
||||
- README.md
|
||||
- completions/*
|
||||
|
||||
nfpms:
|
||||
- id: certmanager
|
||||
ids: [certmanager]
|
||||
package_name: certmanager
|
||||
# certmanager-<version>-1.x86_64.rpm, matching the previous nfpm output.
|
||||
file_name_template: "{{ .ConventionalFileName }}"
|
||||
vendor: unkin
|
||||
homepage: https://git.unkin.net/unkin/certmanager
|
||||
maintainer: Ben Vincent <ben@unkin.net>
|
||||
description: Issues Vault PKI certificates for Puppet-managed nodes; drop-in replacement for the Python certmanager script
|
||||
license: MIT
|
||||
formats:
|
||||
- rpm
|
||||
release: "1"
|
||||
bindir: /usr/bin
|
||||
provides:
|
||||
- certmanager
|
||||
replaces:
|
||||
- certmanager
|
||||
contents:
|
||||
# Puppet's profiles::pki::vault calls the binary at this hardcoded path.
|
||||
- src: /usr/bin/certmanager
|
||||
dst: /usr/local/bin/certmanager
|
||||
type: symlink
|
||||
- src: completions/certmanager.bash
|
||||
dst: /usr/share/bash-completion/completions/certmanager
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: completions/_certmanager
|
||||
dst: /usr/share/zsh/site-functions/_certmanager
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: completions/certmanager.fish
|
||||
dst: /usr/share/fish/vendor_completions.d/certmanager.fish
|
||||
file_info:
|
||||
mode: 0644
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
release:
|
||||
gitea:
|
||||
owner: unkin
|
||||
name: certmanager
|
||||
|
||||
gitea_urls:
|
||||
api: https://git.unkin.net/api/v1
|
||||
download: https://git.unkin.net
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^chore:"
|
||||
@@ -0,0 +1,41 @@
|
||||
when:
|
||||
- event: pull_request
|
||||
|
||||
# Vault dev mode serves plain HTTP on 8200 with a known root token; the image's
|
||||
# default command is already `server -dev`.
|
||||
services:
|
||||
- name: vault
|
||||
image: hashicorp/vault:1.21
|
||||
environment:
|
||||
VAULT_DEV_ROOT_TOKEN_ID: root
|
||||
VAULT_DEV_LISTEN_ADDRESS: 0.0.0.0:8200
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
steps:
|
||||
# End-to-end: real PKI mount, real approle login, real issuance, asserted
|
||||
# against the exact argv Puppet's generate() uses.
|
||||
- name: integration
|
||||
image: golang:1.25
|
||||
environment:
|
||||
CERTMANAGER_IT_VAULT_ADDR: http://vault:8200
|
||||
commands:
|
||||
- make integration-test
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
+20
-85
@@ -17,23 +17,20 @@ steps:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
# Build the dist/ binary consumed by the RPM step plus the cross-platform
|
||||
# binaries attached to the Gitea release.
|
||||
- name: build
|
||||
image: git.unkin.net/unkin/almalinux9-gobuilder:20260606
|
||||
# goreleaser is the single driver for the release: it cross-compiles the
|
||||
# binaries, builds the RPM (nfpm is built in) and creates the Gitea release
|
||||
# with the raw binaries, archives, RPM and checksums attached.
|
||||
- name: goreleaser
|
||||
image: golang:1.26
|
||||
environment:
|
||||
GITEA_TOKEN:
|
||||
from_secret: RELEASER_TOKEN
|
||||
commands:
|
||||
- make build VERSION=${CI_COMMIT_TAG}
|
||||
# Shell variables/expansions are escaped as $$ so Woodpecker leaves them
|
||||
# for the shell instead of substituting them at parse time.
|
||||
# ${CI_COMMIT_TAG} is a real Woodpecker var and stays single-$.
|
||||
- |
|
||||
for osarch in linux/amd64 linux/arm64; do
|
||||
os="$${osarch%/*}"; arch="$${osarch#*/}"
|
||||
GOOS="$$os" GOARCH="$$arch" CGO_ENABLED=0 \
|
||||
go build -trimpath \
|
||||
-ldflags="-s -w -X main.version=${CI_COMMIT_TAG} -X git.unkin.net/unkin/certmanager/internal/config.defaultPath=/opt/certmanager/config.yaml" \
|
||||
-o "certmanager-$${os}-$${arch}" ./cmd/certmanager
|
||||
done
|
||||
# goreleaser reads the tag history for the changelog; Woodpecker's clone
|
||||
# can be shallow.
|
||||
- git fetch --tags --force
|
||||
- go install github.com/goreleaser/goreleaser/v2@v2.17.1
|
||||
- goreleaser release --clean
|
||||
depends_on: [test]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
@@ -46,25 +43,9 @@ steps:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
# Package the built binary + generated shell completions into an RPM.
|
||||
- name: package
|
||||
image: git.unkin.net/unkin/almalinux9-rpmbuilder:latest
|
||||
commands:
|
||||
- ./scripts/build-rpm.sh ${CI_COMMIT_TAG}
|
||||
depends_on: [build]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 512Mi
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
# Publish the RPM to the artifactapi local rpm repo (a real yum repo;
|
||||
# repodata regenerates automatically).
|
||||
# goreleaser cannot PUT to artifactapi, so the RPM it built into dist/ is
|
||||
# published to the local rpm repo here (a real yum repo; repodata
|
||||
# regenerates automatically).
|
||||
- name: upload-rpm
|
||||
image: git.unkin.net/unkin/almalinux9-base:20260606
|
||||
commands:
|
||||
@@ -86,60 +67,14 @@ steps:
|
||||
-H "Content-Type: application/x-rpm" \
|
||||
--data-binary @"$$rpm"
|
||||
done
|
||||
depends_on: [package]
|
||||
depends_on: [goreleaser]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 128Mi
|
||||
cpu: 100m
|
||||
limits:
|
||||
memory: 512Mi
|
||||
cpu: 500m
|
||||
|
||||
# Cut a Gitea release with the binaries and the RPM attached.
|
||||
- name: release
|
||||
image: git.unkin.net/unkin/almalinux9-base:20260606
|
||||
environment:
|
||||
RELEASER_TOKEN:
|
||||
from_secret: RELEASER_TOKEN
|
||||
commands:
|
||||
- |
|
||||
curl --output /usr/local/bin/tea https://artifactapi.k8s.syd1.au.unkin.net/api/v1/remote/gitea-dl/tea/0.12.0/tea-0.12.0-linux-amd64 && chmod +x /usr/local/bin/tea
|
||||
tea logins add --name gitea --url https://git.unkin.net --token "$${RELEASER_TOKEN}" --no-version-check
|
||||
# Find the previous release tag for the changelog range. Several tags
|
||||
# can point at the same commit, so skip tags on the current commit and
|
||||
# pick the newest semver tag that is a real ancestor of this one.
|
||||
CUR_SHA=$$(git rev-list -n1 "${CI_COMMIT_TAG}")
|
||||
PREV_TAG=""
|
||||
for t in $$(git tag --sort=-v:refname); do
|
||||
[ "$$t" = "${CI_COMMIT_TAG}" ] && continue
|
||||
[ "$$(git rev-list -n1 "$$t")" = "$$CUR_SHA" ] && continue
|
||||
if git merge-base --is-ancestor "$$t" "${CI_COMMIT_TAG}" 2>/dev/null; then
|
||||
PREV_TAG="$$t"; break
|
||||
fi
|
||||
done
|
||||
if [ -n "$$PREV_TAG" ]; then
|
||||
NOTES=$$(git log "$${PREV_TAG}..${CI_COMMIT_TAG}" --pretty=format:"- %s")
|
||||
else
|
||||
NOTES=$$(git log --pretty=format:"- %s")
|
||||
fi
|
||||
tea releases create --tag "${CI_COMMIT_TAG}" --title "${CI_COMMIT_TAG}" --note "$${NOTES}" --login gitea --repo "${CI_REPO}"
|
||||
RPM=$$(ls dist/*.rpm 2>/dev/null | head -1)
|
||||
ASSETS="certmanager-linux-amd64 certmanager-linux-arm64"
|
||||
[ -n "$$RPM" ] && ASSETS="$$ASSETS $$RPM"
|
||||
sha256sum $$ASSETS > sha256sums.txt
|
||||
tea releases assets create "${CI_COMMIT_TAG}" $$ASSETS sha256sums.txt \
|
||||
--login gitea --repo "${CI_REPO}"
|
||||
depends_on: [upload-rpm]
|
||||
backend_options:
|
||||
kubernetes:
|
||||
serviceAccountName: default
|
||||
resources:
|
||||
requests:
|
||||
memory: 128Mi
|
||||
cpu: 100m
|
||||
cpu: 1
|
||||
limits:
|
||||
memory: 512Mi
|
||||
cpu: 500m
|
||||
memory: 2Gi
|
||||
cpu: 2
|
||||
|
||||
@@ -29,14 +29,15 @@ than a clean failure. Two rules follow:
|
||||
```
|
||||
cmd/certmanager/main.go # CLI (flattened: the positional arg is the command)
|
||||
cmd/certmanager/main_test.go # end-to-end tests against an httptest Vault stub
|
||||
test/integration/ # end-to-end tests against a real Vault dev server
|
||||
internal/config/ # YAML config: parsing, defaults, validation
|
||||
internal/pki/ # issue request/response + the JSON output shape
|
||||
internal/vault/ # authenticated HTTP client (approle, kubernetes)
|
||||
Makefile # build / test / lint / completions / rpm / version bump
|
||||
packaging/nfpm.yaml # nfpm spec (envsubst-templated) for the RPM
|
||||
scripts/build-rpm.sh # generates completions + packages the RPM
|
||||
.woodpecker/ # CI: build, test, pre-commit (PR) + release (tag)
|
||||
dist/ # build output: binary, completions, RPM (not committed)
|
||||
Makefile # build / test / lint / completions / version bump
|
||||
.goreleaser.yaml # single release driver: binaries + RPM + Gitea release
|
||||
scripts/integration-test.sh # runs the integration test against a Vault dev server
|
||||
.woodpecker/ # CI: build, test, integration, pre-commit (PR) + release (tag)
|
||||
dist/ # build output: binaries, RPM, checksums (not committed)
|
||||
```
|
||||
|
||||
## Code Patterns
|
||||
@@ -73,15 +74,23 @@ dist/ # build output: binary, completions, RPM (not comm
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
make test # go test -v -race ./...
|
||||
make test # go test -v -race ./...
|
||||
make integration-test # -tags=integration, against a real Vault dev server
|
||||
```
|
||||
|
||||
Vault is stubbed with `httptest.NewTLSServer` — no live Vault is required.
|
||||
Unit tests stub Vault with `httptest.NewTLSServer` — no live Vault is required.
|
||||
Coverage: the golden `--json` stdout document, stderr emptiness on success,
|
||||
rejected logins for both auth methods, missing/empty JWT, unknown
|
||||
`auth_method`, a non-200 issue response, request timeout, TLS verification when
|
||||
enabled, file and zip output, and the comma-list parsing table.
|
||||
|
||||
`test/integration` (build tag `integration`) provisions a real PKI mount,
|
||||
policy and `bind_secret_id: false` approle role on a Vault dev server, then
|
||||
runs the binary with Puppet's argv and asserts exit code, empty stderr, the
|
||||
four JSON keys, the parsed X.509 CN/SANs/lifetime, and that a refused name
|
||||
exits non-zero with empty stdout. Dev mode is plain HTTP, so it does not
|
||||
exercise the TLS path.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Changing the JSON key set or key order breaks `parsejson()` in
|
||||
|
||||
@@ -10,7 +10,7 @@ GOFLAGS := -trimpath -ldflags="-s -w -X main.version=$(VERSION) -X $(CONFIG_PKG)
|
||||
OS ?= $(shell go env GOOS)
|
||||
ARCH ?= $(shell go env GOARCH)
|
||||
|
||||
.PHONY: all build test lint fmt pre-commit clean install completions rpm rpm-package patch minor major _tag
|
||||
.PHONY: all build test integration-test lint fmt pre-commit clean install completions release-snapshot patch minor major _tag
|
||||
|
||||
all: build
|
||||
|
||||
@@ -20,6 +20,11 @@ build:
|
||||
test:
|
||||
go test -v -race ./...
|
||||
|
||||
# End-to-end test against a real Vault dev server (docker/podman locally, the
|
||||
# Woodpecker `vault` service in CI). Set VAULT_ADDR to use an existing server.
|
||||
integration-test:
|
||||
./scripts/integration-test.sh
|
||||
|
||||
lint:
|
||||
golangci-lint run ./...
|
||||
|
||||
@@ -42,12 +47,10 @@ completions: build
|
||||
$(DIST)/$(BINARY) completion zsh > $(DIST)/completions/_$(BINARY)
|
||||
$(DIST)/$(BINARY) completion fish > $(DIST)/completions/$(BINARY).fish
|
||||
|
||||
# Build the binary then package it (with completions) into an RPM via nfpm.
|
||||
rpm: build rpm-package
|
||||
|
||||
# Package an already-built binary into an RPM (used by CI after the build step).
|
||||
rpm-package:
|
||||
./scripts/build-rpm.sh $(VERSION)
|
||||
# Build every release artifact (binaries, tar.gz, RPM) locally without
|
||||
# publishing. goreleaser is the single driver for the tagged release too.
|
||||
release-snapshot:
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
# Bump helpers — read the latest semver tag and create the next one.
|
||||
_LATEST := $(shell git tag --sort=-v:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$$' | head -1)
|
||||
|
||||
@@ -108,11 +108,28 @@ make build # -> dist/certmanager (CGO disabled, static)
|
||||
make test # go test -v -race ./...
|
||||
make lint # golangci-lint
|
||||
make pre-commit # uvx pre-commit run --all-files
|
||||
make completions # -> dist/completions/
|
||||
make rpm # build + package into dist/*.rpm via nfpm
|
||||
make completions # -> dist/completions/
|
||||
make integration-test # end-to-end against a real Vault dev server
|
||||
make release-snapshot # goreleaser: binaries, tar.gz and RPM into dist/
|
||||
```
|
||||
|
||||
The RPM installs `/usr/bin/certmanager`, symlinks `/usr/local/bin/certmanager`
|
||||
(the path Puppet calls), and ships bash/zsh/fish completions. On a `v*` tag the
|
||||
release pipeline builds the RPM and `PUT`s it to the artifactapi `rpm-internal`
|
||||
repo.
|
||||
`make integration-test` starts a `hashicorp/vault` dev container with
|
||||
docker/podman, provisions a PKI mount and an approle role, then runs the built
|
||||
binary with the exact argv Puppet uses and asserts on the issued certificate.
|
||||
Point it at an existing dev server with `CERTMANAGER_IT_VAULT_ADDR`
|
||||
(`VAULT_ADDR` is deliberately ignored — the test writes to the PKI mount).
|
||||
|
||||
## Releasing
|
||||
|
||||
goreleaser drives the whole release from a `v*` tag:
|
||||
|
||||
- raw binaries `certmanager-<os>-<arch>` (linux/darwin, amd64/arm64) attached to
|
||||
the Gitea release — the puppetserver-compiler initContainer fetches one file
|
||||
by URL and checksums it, so it cannot unpack an archive;
|
||||
- `tar.gz` archives and `checksums.txt`;
|
||||
- `certmanager-<version>-1.<arch>.rpm`, installing `/usr/bin/certmanager`,
|
||||
symlinking `/usr/local/bin/certmanager` (the path Puppet calls) and shipping
|
||||
bash/zsh/fish completions.
|
||||
|
||||
The release pipeline then `PUT`s the RPM to the artifactapi `rpm-internal`
|
||||
repo, which goreleaser cannot do itself.
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
---
|
||||
# nfpm config for building the certmanager RPM.
|
||||
# Rendered through envsubst (see scripts/build-rpm.sh) then fed to `nfpm pkg`.
|
||||
|
||||
name: ${PACKAGE_NAME}
|
||||
version: ${PACKAGE_VERSION}
|
||||
release: ${PACKAGE_RELEASE}
|
||||
arch: ${PACKAGE_ARCH}
|
||||
platform: ${PACKAGE_PLATFORM}
|
||||
section: default
|
||||
priority: extra
|
||||
description: "${PACKAGE_DESCRIPTION}"
|
||||
|
||||
maintainer: ${PACKAGE_MAINTAINER}
|
||||
homepage: ${PACKAGE_HOMEPAGE}
|
||||
license: ${PACKAGE_LICENSE}
|
||||
|
||||
disable_globbing: false
|
||||
|
||||
replaces:
|
||||
- certmanager
|
||||
provides:
|
||||
- certmanager
|
||||
|
||||
contents:
|
||||
- src: dist/certmanager
|
||||
dst: /usr/bin/certmanager
|
||||
file_info:
|
||||
mode: 0755
|
||||
owner: root
|
||||
group: root
|
||||
|
||||
# Puppet's profiles::pki::vault calls the binary at this hardcoded path.
|
||||
- src: /usr/bin/certmanager
|
||||
dst: /usr/local/bin/certmanager
|
||||
type: symlink
|
||||
|
||||
# Shell completions (generated by scripts/build-rpm.sh before packaging).
|
||||
- src: dist/completions/certmanager.bash
|
||||
dst: /usr/share/bash-completion/completions/certmanager
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: dist/completions/_certmanager
|
||||
dst: /usr/share/zsh/site-functions/_certmanager
|
||||
file_info:
|
||||
mode: 0644
|
||||
- src: dist/completions/certmanager.fish
|
||||
dst: /usr/share/fish/vendor_completions.d/certmanager.fish
|
||||
file_info:
|
||||
mode: 0644
|
||||
@@ -1,44 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Package the (already built) certmanager binary into an RPM with nfpm,
|
||||
# bundling generated bash/zsh/fish shell completions.
|
||||
# Usage: scripts/build-rpm.sh [version] (version defaults to $CI_COMMIT_TAG)
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
VERSION="${1:-${CI_COMMIT_TAG:-0.0.0-dev}}"
|
||||
VERSION="${VERSION#v}" # strip a leading v
|
||||
BINARY="certmanager"
|
||||
DIST="dist"
|
||||
|
||||
if [ ! -f "${DIST}/${BINARY}" ]; then
|
||||
echo "ERROR: ${DIST}/${BINARY} not found; run 'make build' first" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Generate shell completions from the freshly built binary so they always match
|
||||
# the shipped flags.
|
||||
COMP_DIR="${DIST}/completions"
|
||||
mkdir -p "${COMP_DIR}"
|
||||
"./${DIST}/${BINARY}" completion bash >"${COMP_DIR}/${BINARY}.bash"
|
||||
"./${DIST}/${BINARY}" completion zsh >"${COMP_DIR}/_${BINARY}"
|
||||
"./${DIST}/${BINARY}" completion fish >"${COMP_DIR}/${BINARY}.fish"
|
||||
|
||||
export PACKAGE_NAME="${BINARY}"
|
||||
export PACKAGE_VERSION="${VERSION}"
|
||||
export PACKAGE_RELEASE="1"
|
||||
export PACKAGE_ARCH="amd64"
|
||||
export PACKAGE_PLATFORM="linux"
|
||||
export PACKAGE_DESCRIPTION="Issues Vault PKI certificates for Puppet-managed nodes; drop-in replacement for the Python certmanager script"
|
||||
export PACKAGE_MAINTAINER="Ben Vincent <ben@unkin.net>"
|
||||
export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/certmanager"
|
||||
export PACKAGE_LICENSE="MIT"
|
||||
|
||||
envsubst <packaging/nfpm.yaml >"${DIST}/nfpm.yaml"
|
||||
nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm
|
||||
|
||||
echo "Built:"
|
||||
ls -1 "${DIST}"/*.rpm
|
||||
Executable
+63
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Run the end-to-end integration test against a real Vault dev server.
|
||||
#
|
||||
# With CERTMANAGER_IT_VAULT_ADDR set (CI, where Woodpecker runs Vault as a
|
||||
# service) the test is run directly. Otherwise a throwaway dev server is
|
||||
# started with docker/podman and torn down afterwards.
|
||||
#
|
||||
# VAULT_ADDR is deliberately ignored: this test mounts PKI and writes policies,
|
||||
# and a developer shell usually has VAULT_ADDR pointing at production.
|
||||
#
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "${ROOT_DIR}"
|
||||
|
||||
VAULT_IMAGE="${VAULT_IMAGE:-hashicorp/vault:1.21}"
|
||||
CONTAINER_NAME="${CONTAINER_NAME:-certmanager-integration-vault}"
|
||||
|
||||
run_tests() {
|
||||
env -u VAULT_ADDR -u VAULT_TOKEN \
|
||||
CERTMANAGER_IT_VAULT_ADDR="${CERTMANAGER_IT_VAULT_ADDR}" \
|
||||
CERTMANAGER_IT_VAULT_TOKEN="${CERTMANAGER_IT_VAULT_TOKEN}" \
|
||||
go test -tags=integration -count=1 -v ./test/integration/...
|
||||
}
|
||||
|
||||
if [ -n "${CERTMANAGER_IT_VAULT_ADDR:-}" ]; then
|
||||
echo "Using the Vault dev server at ${CERTMANAGER_IT_VAULT_ADDR}"
|
||||
CERTMANAGER_IT_VAULT_TOKEN="${CERTMANAGER_IT_VAULT_TOKEN:-root}"
|
||||
run_tests
|
||||
exit 0
|
||||
fi
|
||||
|
||||
RUNTIME=""
|
||||
for candidate in docker podman; do
|
||||
if command -v "${candidate}" >/dev/null 2>&1; then
|
||||
RUNTIME="${candidate}"
|
||||
break
|
||||
fi
|
||||
done
|
||||
if [ -z "${RUNTIME}" ]; then
|
||||
echo "ERROR: need docker or podman to start a Vault dev server, or set VAULT_ADDR" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cleanup() {
|
||||
"${RUNTIME}" rm -f "${CONTAINER_NAME}" >/dev/null 2>&1 || true
|
||||
}
|
||||
trap cleanup EXIT
|
||||
cleanup
|
||||
|
||||
echo "Starting ${VAULT_IMAGE} dev server via ${RUNTIME}"
|
||||
"${RUNTIME}" run -d --name "${CONTAINER_NAME}" \
|
||||
-p 8200:8200 \
|
||||
-e VAULT_DEV_ROOT_TOKEN_ID=root \
|
||||
-e VAULT_DEV_LISTEN_ADDRESS=0.0.0.0:8200 \
|
||||
"${VAULT_IMAGE}" \
|
||||
server -dev >/dev/null
|
||||
|
||||
# The test itself polls sys/health, so no sleep here.
|
||||
CERTMANAGER_IT_VAULT_ADDR="http://127.0.0.1:8200"
|
||||
CERTMANAGER_IT_VAULT_TOKEN="root"
|
||||
run_tests
|
||||
@@ -0,0 +1,355 @@
|
||||
//go:build integration
|
||||
|
||||
// Package integration exercises the certmanager binary end to end against a
|
||||
// real Vault dev server: real PKI mount, real approle login, real issuance.
|
||||
package integration
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
pkiMount = "pki"
|
||||
pkiRole = "puppet"
|
||||
approlePath = "approle"
|
||||
approleRole = "certmanager"
|
||||
policyName = "certmanager-integration"
|
||||
certDomain = "integration.test"
|
||||
shortName = "certmanager-host"
|
||||
commonName = "host." + certDomain
|
||||
altName = "alias." + certDomain
|
||||
ipSAN1 = "127.0.0.1"
|
||||
ipSAN2 = "10.0.0.5"
|
||||
expiryDays = 90
|
||||
)
|
||||
|
||||
// The address and token come from CERTMANAGER_IT_* rather than VAULT_ADDR /
|
||||
// VAULT_TOKEN on purpose: this test mounts and writes to PKI, and a developer
|
||||
// shell nearly always has VAULT_ADDR pointing at production.
|
||||
func vaultAddr() string {
|
||||
if a := os.Getenv("CERTMANAGER_IT_VAULT_ADDR"); a != "" {
|
||||
return strings.TrimRight(a, "/")
|
||||
}
|
||||
return "http://127.0.0.1:8200"
|
||||
}
|
||||
|
||||
func vaultToken() string {
|
||||
if t := os.Getenv("CERTMANAGER_IT_VAULT_TOKEN"); t != "" {
|
||||
return t
|
||||
}
|
||||
return "root"
|
||||
}
|
||||
|
||||
// waitForVault polls sys/health until the server is initialised and unsealed.
|
||||
func waitForVault(t *testing.T) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
url := vaultAddr() + "/v1/sys/health?standbyok=true"
|
||||
var last string
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get(url) //nolint:gosec,noctx // fixed test-local URL
|
||||
if err != nil {
|
||||
last = err.Error()
|
||||
} else {
|
||||
code := resp.StatusCode
|
||||
_ = resp.Body.Close()
|
||||
if code == http.StatusOK {
|
||||
return
|
||||
}
|
||||
last = fmt.Sprintf("HTTP %d", code)
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("vault at %s not ready: %s", vaultAddr(), last)
|
||||
}
|
||||
|
||||
// vaultReq performs an authenticated request against the dev server and
|
||||
// decodes the JSON body. A non-2xx response fails the test unless the error
|
||||
// body matches one of okErrors (used to make mount/auth enabling idempotent).
|
||||
func vaultReq(t *testing.T, method, path string, payload any, okErrors ...string) map[string]any {
|
||||
t.Helper()
|
||||
|
||||
var body io.Reader
|
||||
if payload != nil {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal payload for %s: %v", path, err)
|
||||
}
|
||||
body = bytes.NewReader(raw)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(method, vaultAddr()+"/v1/"+path, body) //nolint:noctx // test helper
|
||||
if err != nil {
|
||||
t.Fatalf("build request %s %s: %v", method, path, err)
|
||||
}
|
||||
req.Header.Set("X-Vault-Token", vaultToken())
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
for _, ok := range okErrors {
|
||||
if strings.Contains(string(raw), ok) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
t.Fatalf("%s %s: vault returned %d: %s", method, path, resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
|
||||
var out map[string]any
|
||||
if len(bytes.TrimSpace(raw)) > 0 {
|
||||
_ = json.Unmarshal(raw, &out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// setupVault provisions the PKI mount, issuing role, policy and approle role,
|
||||
// returning the role_id certmanager will authenticate with.
|
||||
func setupVault(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
vaultReq(t, http.MethodPost, "sys/mounts/"+pkiMount,
|
||||
map[string]any{"type": "pki", "config": map[string]any{"max_lease_ttl": "87600h"}},
|
||||
"path is already in use")
|
||||
|
||||
vaultReq(t, http.MethodPost, pkiMount+"/root/generate/internal", map[string]any{
|
||||
"common_name": certDomain + " integration CA",
|
||||
"ttl": "87600h",
|
||||
})
|
||||
|
||||
vaultReq(t, http.MethodPost, pkiMount+"/roles/"+pkiRole, map[string]any{
|
||||
"allowed_domains": []string{certDomain, shortName},
|
||||
"allow_subdomains": true,
|
||||
"allow_bare_domains": true,
|
||||
"allow_ip_sans": true,
|
||||
"key_type": "rsa",
|
||||
"key_bits": 2048,
|
||||
"max_ttl": "8760h",
|
||||
})
|
||||
|
||||
vaultReq(t, http.MethodPut, "sys/policies/acl/"+policyName, map[string]any{
|
||||
"policy": fmt.Sprintf(`path "%s/issue/%s" { capabilities = ["update"] }`, pkiMount, pkiRole),
|
||||
})
|
||||
|
||||
vaultReq(t, http.MethodPost, "sys/auth/"+approlePath,
|
||||
map[string]any{"type": "approle"}, "path is already in use")
|
||||
|
||||
// Mirrors the production role: bind_secret_id false (role_id alone logs in)
|
||||
// with token_bound_cidrs as the required constraint.
|
||||
vaultReq(t, http.MethodPost, fmt.Sprintf("auth/%s/role/%s", approlePath, approleRole), map[string]any{
|
||||
"bind_secret_id": false,
|
||||
"token_bound_cidrs": []string{"0.0.0.0/0"},
|
||||
"token_policies": []string{policyName},
|
||||
"token_ttl": "10m",
|
||||
})
|
||||
|
||||
out := vaultReq(t, http.MethodGet, fmt.Sprintf("auth/%s/role/%s/role-id", approlePath, approleRole), nil)
|
||||
data, _ := out["data"].(map[string]any)
|
||||
roleID, _ := data["role_id"].(string)
|
||||
if roleID == "" {
|
||||
t.Fatal("vault returned an empty approle role_id")
|
||||
}
|
||||
return roleID
|
||||
}
|
||||
|
||||
// buildBinary compiles certmanager with the config path baked in, so the test
|
||||
// can invoke it with exactly the argv Puppet's generate() uses -- no --config.
|
||||
func buildBinary(t *testing.T, dir, configPath string) string {
|
||||
t.Helper()
|
||||
|
||||
bin := filepath.Join(dir, "certmanager")
|
||||
ldflags := fmt.Sprintf("-s -w -X main.version=integration -X git.unkin.net/unkin/certmanager/internal/config.defaultPath=%s", configPath)
|
||||
cmd := exec.Command("go", "build", "-trimpath", "-ldflags="+ldflags, "-o", bin, "./cmd/certmanager")
|
||||
cmd.Dir = repoRoot(t)
|
||||
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
t.Fatalf("build certmanager: %v\n%s", err, out)
|
||||
}
|
||||
return bin
|
||||
}
|
||||
|
||||
func repoRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatalf("getwd: %v", err)
|
||||
}
|
||||
return filepath.Join(wd, "..", "..")
|
||||
}
|
||||
|
||||
func writeConfig(t *testing.T, dir, roleID string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
body := fmt.Sprintf(`vault:
|
||||
addr: %s
|
||||
auth_method: approle
|
||||
approle_path: %s
|
||||
role_id: %s
|
||||
mount_point: %s
|
||||
role_name: %s
|
||||
output_path: %s
|
||||
`, vaultAddr(), approlePath, roleID, pkiMount, pkiRole, dir)
|
||||
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
||||
t.Fatalf("write config: %v", err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func runCertmanager(t *testing.T, bin string, args ...string) (int, string, string) {
|
||||
t.Helper()
|
||||
cmd := exec.Command(bin, args...)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
code := 0
|
||||
if err := cmd.Run(); err != nil {
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(err, &exitErr) {
|
||||
t.Fatalf("run %s: %v", bin, err)
|
||||
}
|
||||
code = exitErr.ExitCode()
|
||||
}
|
||||
return code, stdout.String(), stderr.String()
|
||||
}
|
||||
|
||||
func TestIssueCertificateAgainstVault(t *testing.T) {
|
||||
waitForVault(t)
|
||||
roleID := setupVault(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
cfg := writeConfig(t, dir, roleID)
|
||||
bin := buildBinary(t, dir, cfg)
|
||||
|
||||
// Exactly the argv profiles::pki::vault passes to generate().
|
||||
code, stdout, stderr := runCertmanager(t, bin,
|
||||
commonName,
|
||||
"--alt-names", shortName+","+altName,
|
||||
"--ip-sans", ipSAN1+","+ipSAN2,
|
||||
"--expiry-days", fmt.Sprint(expiryDays),
|
||||
"--json",
|
||||
)
|
||||
|
||||
if code != 0 {
|
||||
t.Fatalf("exit code %d, stderr: %s", code, stderr)
|
||||
}
|
||||
// generate() runs with combine: true, splicing stderr into the stdout that
|
||||
// parsejson() reads -- a single byte on stderr breaks the catalog compile.
|
||||
if stderr != "" {
|
||||
t.Errorf("stderr must be empty on success, got %q", stderr)
|
||||
}
|
||||
|
||||
var doc map[string]string
|
||||
if err := json.Unmarshal([]byte(stdout), &doc); err != nil {
|
||||
t.Fatalf("stdout is not JSON: %v\n%s", err, stdout)
|
||||
}
|
||||
for _, key := range []string{"certificate", "private_key", "full_chain", "ca_certificate"} {
|
||||
if doc[key] == "" {
|
||||
t.Errorf("JSON key %q is missing or empty", key)
|
||||
}
|
||||
}
|
||||
|
||||
cert := parseCert(t, doc["certificate"])
|
||||
if cert.Subject.CommonName != commonName {
|
||||
t.Errorf("certificate CN = %q, want %q", cert.Subject.CommonName, commonName)
|
||||
}
|
||||
for _, want := range []string{shortName, altName} {
|
||||
if !slices.Contains(cert.DNSNames, want) {
|
||||
t.Errorf("certificate DNS SANs %v missing %q", cert.DNSNames, want)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{ipSAN1, ipSAN2} {
|
||||
if !containsIP(cert.IPAddresses, want) {
|
||||
t.Errorf("certificate IP SANs %v missing %q", cert.IPAddresses, want)
|
||||
}
|
||||
}
|
||||
|
||||
// Proves the "<days>d" TTL string is understood by a real Vault.
|
||||
lifetime := cert.NotAfter.Sub(cert.NotBefore)
|
||||
if lifetime < 89*24*time.Hour || lifetime > 91*24*time.Hour {
|
||||
t.Errorf("certificate lifetime %v, want ~%dd", lifetime, expiryDays)
|
||||
}
|
||||
|
||||
ca := parseCert(t, doc["ca_certificate"])
|
||||
if !ca.IsCA {
|
||||
t.Errorf("ca_certificate is not a CA certificate")
|
||||
}
|
||||
if err := cert.CheckSignatureFrom(ca); err != nil {
|
||||
t.Errorf("certificate is not signed by ca_certificate: %v", err)
|
||||
}
|
||||
if !strings.Contains(doc["full_chain"], strings.TrimSpace(doc["certificate"])) ||
|
||||
!strings.Contains(doc["full_chain"], strings.TrimSpace(doc["ca_certificate"])) {
|
||||
t.Errorf("full_chain does not contain both the leaf and the issuing CA")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefusedNameFailsCleanly(t *testing.T) {
|
||||
waitForVault(t)
|
||||
roleID := setupVault(t)
|
||||
|
||||
dir := t.TempDir()
|
||||
cfg := writeConfig(t, dir, roleID)
|
||||
bin := buildBinary(t, dir, cfg)
|
||||
|
||||
code, stdout, stderr := runCertmanager(t, bin,
|
||||
"nope.example.com",
|
||||
"--alt-names", "",
|
||||
"--ip-sans", "",
|
||||
"--expiry-days", fmt.Sprint(expiryDays),
|
||||
"--json",
|
||||
)
|
||||
|
||||
if code == 0 {
|
||||
t.Fatalf("expected a non-zero exit for a refused name, stdout: %s", stdout)
|
||||
}
|
||||
// A half-written JSON document would break parsejson() in the catalog.
|
||||
if stdout != "" {
|
||||
t.Errorf("stdout must be empty on failure, got %q", stdout)
|
||||
}
|
||||
if !strings.Contains(stderr, "certmanager:") {
|
||||
t.Errorf("stderr should carry the cause, got %q", stderr)
|
||||
}
|
||||
}
|
||||
|
||||
func parseCert(t *testing.T, data string) *x509.Certificate {
|
||||
t.Helper()
|
||||
block, _ := pem.Decode([]byte(data))
|
||||
if block == nil {
|
||||
t.Fatalf("not a PEM document: %s", data)
|
||||
}
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
return cert
|
||||
}
|
||||
|
||||
func containsIP(haystack []net.IP, needle string) bool {
|
||||
want := net.ParseIP(needle)
|
||||
for _, v := range haystack {
|
||||
if v.Equal(want) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user