133 lines
6.8 KiB
Markdown
133 lines
6.8 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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.
|