Puppet calls /usr/local/bin/certmanager from generate() with combine: true, so the CLI must take the common name positionally and keep stderr clean or the catalog compile dies on a parsejson error. - Flatten the CLI: drop the pki subcommand, add -a/-i/-e/-c/--json - Emit ca_certificate so vault.pp stops writing undef to that file - Read output_path from under vault:, where Puppet writes it - Bound every Vault request with a timeout; exit non-zero on failure - Gate TLS verification on vault.tls_skip_verify (default true) - Restrict auth to approle and kubernetes, no fallback between them - Remove the SSH half; add Woodpecker CI, nfpm RPM and completions
certmanager
Issues Vault PKI certificates for Puppet-managed nodes. Drop-in replacement for
the Python certmanager script deployed by profiles::helpers::certmanager,
compiled to a single static binary with no runtime dependencies.
Puppet calls it server-side during catalog compilation from
profiles::pki::vault:
/usr/local/bin/certmanager <fqdn> --alt-names "host,fqdn" --ip-sans "127.0.0.1,<ip>" --expiry-days 90 --json
generate() runs the binary with combine: true, which splices stderr into the
stdout that parsejson() reads — so on the success path the binary writes
nothing but the JSON document to stdout, and nothing at all to stderr. Every
failure path exits non-zero.
Usage
certmanager <common_name> [flags]
| Flag | Short | Type | Default | Description |
|---|---|---|---|---|
--alt-names |
-a |
comma list | "" |
DNS subject alternative names |
--ip-sans |
-i |
comma list | "" |
IP subject alternative names |
--expiry-days |
-e |
int | 365 |
Certificate validity in days (ttl: <n>d) |
--compress |
-c |
bool | false |
Write a zip instead of a directory |
--json |
bool | false |
Print the certificate data as JSON | |
--config |
path | /opt/certmanager/config.yaml |
Config file location |
Comma lists are split on ,, whitespace-trimmed, and empty tokens dropped.
JSON output
{
"certificate": "<PEM leaf>",
"private_key": "<PEM key>",
"full_chain": "<issuing_ca PEM>\n<certificate PEM>",
"ca_certificate": "<issuing_ca PEM>"
}
ca_certificate is new. profiles::pki::vault writes
$cert_data['ca_certificate'] to /etc/pki/tls/vault/ca_certificate.crt, but
the Python never emitted the key, so Puppet saw undef and the file was never
populated.
File output
Without --json the certificate is written under vault.output_path
(defaulting to the working directory):
| Path | Contents |
|---|---|
<output_path>/<common_name>/certificate.crt |
Signed certificate |
<output_path>/<common_name>/private.key |
Private key (mode 0600) |
<output_path>/<common_name>/full_chain.crt |
Issuing CA + certificate |
<output_path>/<common_name>/ca_certificate.crt |
Issuing CA certificate |
With -c the same four files are written into
<output_path>/<common_name>.zip.
Configuration
YAML, everything nested under vault:. The path is baked in at build time
(make build CONFIG_PATH=...) and overridable per-invocation with --config.
| Key | Required | Default | Notes |
|---|---|---|---|
addr |
yes | e.g. https://vault.service.consul:8200 |
|
auth_method |
no | approle |
approle or kubernetes |
mount_point |
yes | e.g. pki_int |
|
role_name |
yes | e.g. servers_default |
|
role_id |
iff approle |
||
approle_path |
iff approle |
e.g. approle |
|
k8s_mount |
iff kubernetes |
e.g. k8s/au/syd1 |
|
k8s_role |
iff kubernetes |
e.g. puppet_certmanager |
|
jwt_path |
no | /var/run/secrets/kubernetes.io/serviceaccount/token |
|
output_path |
no | . |
Non-JSON output directory |
tls_skip_verify |
no | true |
Set false once every node trusts the CA |
timeout |
no | 30s |
Per-request Vault timeout |
vault:
addr: https://vault.service.consul:8200
auth_method: approle
approle_path: approle
role_id: <role_id>
mount_point: pki_int
role_name: servers_default
output_path: /tmp/certmanager
AppRole logs in with role_id only — the Vault role sets
bind_secret_id: false. Kubernetes reads the JWT from jwt_path and posts it
with k8s_role. A missing, unreadable or empty JWT, a rejected login, or an
unknown auth_method each fail naming the cause; there is no fallback between
methods.
Development
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
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 PUTs it to the artifactapi rpm-internal
repo.