Fall back to $XDG_CONFIG_HOME and $HOME/.config before the baked-in /opt/certmanager/config.yaml, and stop failing when called with no args.
5.6 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.- Flat CLI: the root command takes
common_namepositionally (cobra.MaximumNArgs(1)); no args prints help, args-less flags fail.ca,completionandhelpare the only subcommands;--configis persistent socashares it. 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.
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.