Files
certmanager/AGENTS.md
T
unkin-agent 0860da03c4
ci/woodpecker/pr/build Pipeline was canceled
ci/woodpecker/pr/integration Pipeline was canceled
ci/woodpecker/pr/pre-commit Pipeline was canceled
ci/woodpecker/pr/test Pipeline was canceled
Document the untagged hermetic-env helper
2026-09-22 23:49:06 +10:00

6.8 KiB

AGENTS.md

Project Overview

certmanager issues Vault PKI certificates for Puppet-managed nodes. It is a drop-in replacement for the Python certmanager script that profiles::helpers::certmanager used to render into a venv.

The contract

profiles::pki::vault (puppet-prod) calls the binary during catalog compilation:

/usr/local/bin/certmanager <fqdn> --alt-names "host,fqdn" --ip-sans "127.0.0.1,<ip>" --expiry-days 90 --json

generate() runs with failonfail: true, combine: true. combine: true dups stderr onto stdout at spawn, so anything on stderr is spliced into the JSON stream and parsejson() fails — breaking the compile with a parse error rather than a clean failure. Two rules follow:

  • the success path writes the JSON document to stdout and nothing to stderr (TestRunSuccessWritesNothingToStderr guards this);
  • every failure exits non-zero, with the message on stderr and stdout empty.

Structure

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 + ca_chain requests and the output shapes
internal/vault/               # HTTP client: approle, kubernetes, unauthenticated
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

  • run(args, stdout, stderr) int is the testable entry point; main() only wires it to the real streams and os.Exit. Cobra runs with SilenceUsage and SilenceErrors so nothing reaches stderr unless the run failed. run derives interactivity from isTerminal(stdout) and hands it to runWith, which tests call directly to pin either side. isTerminal is a terminal ioctl (x/term.IsTerminal), not a character-device stat: /dev/null and the /dev/null the runtime reopens onto a closed fd 1 are character devices, and a stat would hand both of them the help-and-exit-zero path.
  • Flat CLI: the root command takes common_name positionally (cobra.MaximumNArgs(1)); no args prints help only when stdout is a terminal and otherwise fails, args-less flags fail. ca, completion and help are the only subcommands; --config is persistent so ca shares it, and an explicitly empty --config is rejected.
  • ca is unauthenticated: GET {addr}/v1/{mount}/ca_chain sends no X-Vault-Token and loads the config through config.LoadCA, which requires only vault.addr. The body is parsed as PEM, never sniffed — Vault labels it application/pkix-cert. It prints raw PEM: Puppet uses it as file content.
  • parseList splits comma lists, trims whitespace, drops empty tokens.
  • config.Load applies defaults (auth_method: approle, jwt_path) then Validate(), which names the offending key and rejects an unknown auth_method before any network call.
  • vault.New authenticates once and never falls back between auth methods. Every request is bounded by vault.timeout (default 30s) — the Python set no timeout, so a black-holed Vault hung the whole catalog compile.
  • config.Resolve picks the config: --config, then $XDG_CONFIG_HOME/certmanager/config.yaml, then $HOME/.config/certmanager/config.yaml (both only if the file exists), then config.FallbackPath() — baked in at build time via -X ...internal/config.defaultPath= (make build CONFIG_PATH=...). Under generate() the puppet user has no usable home, so the baked path is what resolves.

Deliberate deviations from the Python script

  • The CA is served by certmanager ca from the mount's /ca_chain, not welded to issuance. issuing_ca is the intermediate only, and deriving the CA from an issue response would pin CA rotation to each node's 90 day renewal.
  • full_chain is leaf first; the Python put the issuer first.
  • output_path is read from under vault: where it is actually written. The Python declared it at the top level but read it from the unwrapped vault dict, so it never resolved.
  • HTTP timeouts on every request.
  • tls_skip_verify is a config key defaulting to true, preserving the Python's verify=False while letting it be flipped without a code change.
  • Non-zero exit on every failure path.
  • ldap and token auth methods are gone; only approle and kubernetes exist.

Testing

make test              # go test -v -race ./...
make integration-test  # -tags=integration, against a real Vault dev server

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.

hermeticEnv, the subprocess environment those tests share, sits outside the tag in hermetic_env_test.go so a plain go test ./... guards it. It replaces the ambient environment rather than extending it; its test asserts exactly that, because a helper that leaked HOME would point the binary at a real Vault and only the tagged tests would notice.

TestBareInvocationHelpsOnlyATerminal drives the built binary once per kind of fd 1 — pipe, regular file, unix socket, /dev/null, a closed fd 1 and a pty allocated from /dev/ptmx — and only the pty may exit zero with help on stdout. The pty is what pins the true branch: CI containers have no controlling terminal, so a test that reached for /dev/tty would skip.

Gotchas

  • Changing the JSON key set or key order breaks parsejson() in profiles::pki::vault; TestRunJSONGolden pins the exact bytes.
  • alt_names and ip_sans are always sent, as empty strings when unset, to match what the Python posted.
  • The RPM owns /usr/local/bin/certmanager as a symlink; the Puppet class that manages that path must be retired in the same change that ships the RPM.