mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 23:28:58 +00:00
0792271dc6
Closes Top-10 fix #5 of the 2026-05-03 issuer-coverage audit (see cowork/issuer-coverage-audit-2026-05-03/RESULTS.md). Pre-fix, the VaultPKI adapter authenticated with a static token and never called renew-self. Long-lived deploys hit token expiry; the first operator-visible signal was failed cert renewals on production targets. This commit: 1. Connector.Start(ctx) spawns a goroutine that calls POST /v1/auth/token/renew-self at TTL/2 cadence (computed from a one-shot lookup-self at startup). Honours ctx.Done() for graceful shutdown via a per-loop done channel + Stop(). 2. On `renewable: false` response (initial lookup OR any subsequent renewal), the loop emits a WARN, increments the not_renewable counter, and exits. The operator must rotate the token before Vault's Max TTL elapses. 3. New Prometheus counter certctl_vault_token_renewals_total with labels result={success,failure,not_renewable}. Registered alongside existing certctl_issuance_* counters in internal/api/handler/metrics.go. 4. ERROR-level logging on renewal failure with operator-actionable substring ("vault token renewal failed; rotate the token before TTL expires") so journalctl + grep find it. Loop keeps ticking after a failure — transient blips don't kill it. New optional issuer.Lifecycle interface: type Lifecycle interface { Start(ctx context.Context) error Stop() } Connectors that hold no background goroutines (almost all of them) do not implement this — IssuerRegistry.StartLifecycles / StopLifecycles feature-detect via type assertion. New lifecycle-bearing connectors plug in by implementing the interface; no further registry plumbing required. Wiring (cmd/server/main.go): - service.NewVaultRenewalMetrics() instance is shared between issuerRegistry.SetVaultRenewalMetrics (so Vault connectors built by Rebuild get a recorder) and metricsHandler.SetVaultRenewals (so the Prometheus exposer emits the new series). - issuerRegistry.StartLifecycles(ctx) is called after issuerService.BuildRegistry; defer issuerRegistry.StopLifecycles is paired so goroutines exit cleanly on signal. - IssuerConnectorAdapter.Underlying() exposes the wrapped issuer.Connector so registry-level machinery can reach the concrete connector behind the adapter without duplicating the wiring at every call site. Tests (internal/connector/issuer/vault/vault_renew_test.go): - TestVault_RenewLoop_TickAtHalfTTL — three ticks → three renewals, all "success". - TestVault_RenewLoop_StopsOnNotRenewable — second renewal returns renewable=false, loop exits, third tick fires no HTTP call. - TestVault_RenewLoop_FailureSurfacesViaMetric — first renewal 403 bumps "failure", second renewal succeeds → loop kept ticking. - TestVault_RenewLoop_CtxCancellation_StopsCleanly — Stop returns within 200ms after ctx cancel. - TestVault_RenewLoop_StartsNothingWhenNotRenewable — token already non-renewable at boot ⇒ no goroutine, "not_renewable" metric increments at startup so operators see it in Grafana. - TestVault_ComputeInterval — 4 cases pinning TTL/2 + minRenewInterval floor. - TestVault_RenewSelf_ParseFailure_NamesActionableInError — surfaced error contains "vault token renewal failed" + "rotate the token". Cadence is dynamic — every successful renewal re-derives TTL/2 from the renewed lease's lease_duration, so a short bootstrap token that gets renewed up to a longer Max TTL shifts to the longer cadence automatically (defends against degenerate fast ticking on a token whose Max TTL is far longer than its initial TTL). Documentation: - docs/connectors.md Vault PKI section gains "Token TTL + automatic renewal" subsection (operator-facing: cadence, metric, renewable=false rotation playbook). Out of scope (intentional, flagged in the audit follow-up): - AppRole / Kubernetes / AWS IAM auth methods (different renewal semantics). - Hot-reload of rotated token from disk (operator restarts today; future: GUI/MCP issuer-update path triggers Rebuild which Stops the old connector and Starts the new one). - Auto-re-auth after token death (operator playbook owns it). CHANGELOG.md is intentionally not hand-edited (per CHANGELOG.md itself: "no longer maintains a hand-edited per-version changelog; per-release notes are auto-generated from commit messages between consecutive tags"). Verified locally: - gofmt clean. - go vet ./internal/service/... ./internal/api/handler/... ./internal/connector/issuer/vault/... ./cmd/server/... clean. - go test -short -count=1 ./internal/connector/issuer/vault/... ./internal/service/... ./internal/api/handler/... green. - go test -race -count=10 -run 'TestVault_RenewLoop|TestVault_ComputeInterval' ./internal/connector/issuer/vault/... green. Audit reference: cowork/issuer-coverage-audit-2026-05-03/RESULTS.md Top-10 fix #5.
41 lines
1.9 KiB
Go
41 lines
1.9 KiB
Go
package issuer
|
|
|
|
import "context"
|
|
|
|
// Lifecycle is an OPTIONAL extension interface for issuer connectors that
|
|
// need to run long-running background work bound to a context. Connectors
|
|
// that hold no background goroutines (almost all of them) do not implement
|
|
// this interface and the registry feature-detects via type assertion.
|
|
//
|
|
// Concrete users today (2026-05-03):
|
|
// - VaultPKI: periodic POST /v1/auth/token/renew-self at TTL/2 cadence
|
|
// so long-lived deploys don't hit token expiry.
|
|
//
|
|
// The lifecycle contract is deliberately small. Connectors that need
|
|
// per-tick state, retries, or cross-tick cancellation handle all of that
|
|
// internally; the registry's job is just "kick off background work
|
|
// once" and "block until it cleanly exits". Keeping the interface this
|
|
// small means new lifecycle-bearing connectors don't have to touch the
|
|
// registry plumbing — they implement Start/Stop and the existing
|
|
// IssuerRegistry.StartLifecycles / StopLifecycles wiring picks them up
|
|
// automatically.
|
|
//
|
|
// Start MUST be non-blocking — spawn a goroutine and return immediately.
|
|
// Returning an error means startup failed; the registry logs the error
|
|
// and continues. Stop MUST block until the goroutine has fully exited;
|
|
// callers rely on this for graceful shutdown ordering.
|
|
type Lifecycle interface {
|
|
// Start kicks off any long-running background work bound to ctx.
|
|
// Returns nil on successful startup; the goroutine continues until
|
|
// ctx is cancelled or Stop is called. Returns a non-nil error if
|
|
// startup itself failed (e.g. precondition not met) — the goroutine
|
|
// did NOT start and Stop need not be called.
|
|
Start(ctx context.Context) error
|
|
|
|
// Stop blocks until the background work has fully exited. Safe to
|
|
// call after Start returned an error or wasn't called at all.
|
|
// Idempotent — multiple Stop calls return immediately after the
|
|
// first.
|
|
Stop()
|
|
}
|