diff --git a/.gitignore b/.gitignore index 19838c3..50b7ecf 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ # cross-compiled release artifacts (e.g. certmanager-linux-amd64) /certmanager-* dist/ +# goreleaser-generated shell completions +/completions/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..79606e3 --- /dev/null +++ b/.goreleaser.yaml @@ -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--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 + 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:" diff --git a/.woodpecker/integration.yaml b/.woodpecker/integration.yaml new file mode 100644 index 0000000..457298c --- /dev/null +++ b/.woodpecker/integration.yaml @@ -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 diff --git a/.woodpecker/release.yaml b/.woodpecker/release.yaml index 5a42193..8667e0d 100644 --- a/.woodpecker/release.yaml +++ b/.woodpecker/release.yaml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index ba5da95..b2da9d2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/Makefile b/Makefile index 7eba10f..cc787bc 100644 --- a/Makefile +++ b/Makefile @@ -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) diff --git a/README.md b/README.md index bb55870..846267a 100644 --- a/README.md +++ b/README.md @@ -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--` (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--1..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. diff --git a/packaging/nfpm.yaml b/packaging/nfpm.yaml deleted file mode 100644 index 943dd71..0000000 --- a/packaging/nfpm.yaml +++ /dev/null @@ -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 diff --git a/scripts/build-rpm.sh b/scripts/build-rpm.sh deleted file mode 100755 index b0db5cd..0000000 --- a/scripts/build-rpm.sh +++ /dev/null @@ -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 " -export PACKAGE_HOMEPAGE="https://git.unkin.net/unkin/certmanager" -export PACKAGE_LICENSE="MIT" - -envsubst "${DIST}/nfpm.yaml" -nfpm pkg --config "${DIST}/nfpm.yaml" --target "${DIST}" --packager rpm - -echo "Built:" -ls -1 "${DIST}"/*.rpm diff --git a/scripts/integration-test.sh b/scripts/integration-test.sh new file mode 100755 index 0000000..d5684ee --- /dev/null +++ b/scripts/integration-test.sh @@ -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 diff --git a/test/integration/certmanager_test.go b/test/integration/certmanager_test.go new file mode 100644 index 0000000..7a1fdc0 --- /dev/null +++ b/test/integration/certmanager_test.go @@ -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 "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 +}