Release via goreleaser; test against a real Vault
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline failed
ci/woodpecker/pr/integration Pipeline was canceled
ci/woodpecker/pr/build Pipeline was successful

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:
2026-09-19 12:12:25 +10:00
parent b9633481a5
commit 618e1414e4
11 changed files with 632 additions and 199 deletions
+2
View File
@@ -3,3 +3,5 @@
# cross-compiled release artifacts (e.g. certmanager-linux-amd64)
/certmanager-*
dist/
# goreleaser-generated shell completions
/completions/
+102
View File
@@ -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:"
+41
View File
@@ -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
View File
@@ -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
+16 -7
View File
@@ -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
View File
@@ -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)
+23 -6
View File
@@ -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.
-50
View File
@@ -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
-44
View File
@@ -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
+63
View File
@@ -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
+355
View File
@@ -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
}