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
(
TestRunSuccessWritesNothingToStderrguards 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) intis the testable entry point;main()only wires it to the real streams andos.Exit. Cobra runs withSilenceUsageandSilenceErrorsso nothing reaches stderr unless the run failed.runderives interactivity fromisTerminal(stdout)and hands it torunWith, which tests call directly to pin either side.isTerminalis a terminal ioctl (x/term.IsTerminal), not a character-device stat:/dev/nulland the/dev/nullthe 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_namepositionally (cobra.MaximumNArgs(1)); no args prints help only when stdout is a terminal and otherwise fails, args-less flags fail.ca,completionandhelpare the only subcommands;--configis persistent socashares it, and an explicitly empty--configis rejected. cais unauthenticated:GET {addr}/v1/{mount}/ca_chainsends noX-Vault-Tokenand loads the config throughconfig.LoadCA, which requires onlyvault.addr. The body is parsed as PEM, never sniffed — Vault labels itapplication/pkix-cert. It prints raw PEM: Puppet uses it as file content.parseListsplits comma lists, trims whitespace, drops empty tokens.config.Loadapplies defaults (auth_method: approle,jwt_path) thenValidate(), which names the offending key and rejects an unknownauth_methodbefore any network call.vault.Newauthenticates once and never falls back between auth methods. Every request is bounded byvault.timeout(default 30s) — the Python set no timeout, so a black-holed Vault hung the whole catalog compile.config.Resolvepicks the config:--config, then$XDG_CONFIG_HOME/certmanager/config.yaml, then$HOME/.config/certmanager/config.yaml(both only if the file exists), thenconfig.FallbackPath()— baked in at build time via-X ...internal/config.defaultPath=(make build CONFIG_PATH=...). Undergenerate()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 cafrom the mount's/ca_chain, not welded to issuance.issuing_cais the intermediate only, and deriving the CA from an issue response would pin CA rotation to each node's 90 day renewal. full_chainis leaf first; the Python put the issuer first.output_pathis read from undervault:where it is actually written. The Python declared it at the top level but read it from the unwrappedvaultdict, so it never resolved.- HTTP timeouts on every request.
tls_skip_verifyis a config key defaulting totrue, preserving the Python'sverify=Falsewhile letting it be flipped without a code change.- Non-zero exit on every failure path.
ldapandtokenauth methods are gone; onlyapproleandkubernetesexist.
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()inprofiles::pki::vault;TestRunJSONGoldenpins the exact bytes. alt_namesandip_sansare always sent, as empty strings when unset, to match what the Python posted.- The RPM owns
/usr/local/bin/certmanageras a symlink; the Puppet class that manages that path must be retired in the same change that ships the RPM.