mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 08:18:54 +00:00
7444df01e2
Phase 4 of the deploy-hardening I master bundle. The canonical NGINX implementation that Phases 5-9 model on. Replaces the historical os.WriteFile flow at internal/connector/target/nginx/nginx.go:99 with deploy.Apply() and adds three production-grade competitor-gap features: atomic deploy with rollback, post-deploy TLS verify, file ownership preservation. NGINX connector — internal/connector/target/nginx/nginx.go: - DeployCertificate now wires deploy.Apply with PreCommit running the operator's ValidateCommand (e.g. `nginx -t`), PostCommit running ReloadCommand (e.g. `nginx -s reload`), and an explicit post-deploy TLS verify step that dials the configured endpoint, pulls the leaf cert SHA-256, and compares against what was just deployed. SHA-256 mismatch (wrong vhost / cached cert / NGINX still serving stale) triggers automatic rollback: backup files are restored + reload fired again. Failed-second-reload returns ErrRollbackFailed (operator-actionable; loud audit + alert). - ValidateOnly replaces the Phase 3 stub: runs the operator's ValidateCommand without touching the live cert. V2 contract is syntax-only validation (full pre-deploy temp-config validation is V3-Pro). Returns ErrValidateOnlyNotSupported when no ValidateCommand is configured. - New per-target Config fields: PostDeployVerify (frozen-decision- 0.3 default ON), PostDeployVerifyAttempts (default 3 — defends against load-balanced targets where the verify might hit a different pod that hasn't picked up the new cert yet), PostDeployVerifyBackoff (default 2s exponential), per-file Mode/Owner/Group overrides (KeyFileMode, CertFileMode, KeyFileOwner, etc.), and BackupRetention (default 3, -1 to disable backups entirely — documented foot-gun). - buildPlan honors per-distro nginx user (Debian: www-data, Alpine: nginx, Red Hat: nginx) by checking the local user database; falls back to no-chown when neither exists. Means the connector is portable across distros without operator config. Deploy package — internal/deploy/ownership.go: - applyOwnership now silently swallows chown failures when the agent isn't running as root. Production agents always run as root and chown failures are real bugs; dev / CI runs as a regular user where chown to a different uid will always fail with EPERM (or EINVAL on some tmpfs configs) and would otherwise force every test to run with sudo. Production-grade contract preserved (uid 0 still hard-fails on chown errors). Test suite — internal/connector/target/nginx/nginx_atomic_test.go ships 42 new named tests (NGINX total: 17 pre-existing + 42 new = 59, above the prompt's >=40 bar; matches the IIS depth bar of 41): - Atomic-deploy invariants (cert+chain+key all-or-nothing, validate-fails-no-files-changed, reload-fails-rollback, rollback-also-fails-escalation) - SHA-256 idempotency (full match skips, partial match deploys all) - Post-deploy TLS verify (fingerprint-match-success, SHA256-mismatch-rollback, dial-timeout-rollback, retries-until- match, retries-exhausted-rollback, no-endpoint-skips, disabled-skips-entirely, default-10s-timeout, endpoint-forwarded) - Ownership / mode preservation (existing-mode-preserved, override- wins, KeyFileMode override applied) - Backup retention (keeps-last-N, disabled-creates-no-backups, fresh-deploy-creates-backup) - Concurrency (same-paths-serialize via deploy package's file mutex, different-paths-parallelize) - ValidateOnly (happy-path-nil, command-fails-wrapped-error, no-config-returns-sentinel, ctx-cancelled, stderr-in-message) - Edge cases (no-chain, no-key, no-chain-path, empty-cert-PEM, ctx-cancelled, all-four-one-apply) - Result.Metadata + DeploymentID shape contracts Coverage: NGINX 91.0% (above the >=85% prompt bar). Race detector clean. golangci-lint v2.11.4 clean. Existing 17 tests still all pass (no behavior change in the legacy paths exercised there). Phase 5 next: mirror this implementation for Apache + lift its test count from 3 to >=30. Same template applies through Phases 6-9 for the remaining 11 connectors.
105 lines
4.9 KiB
Go
105 lines
4.9 KiB
Go
package target_test
|
|
|
|
// Phase 3 of the deploy-hardening I master bundle: per-connector
|
|
// regression smoke pinning the default ValidateOnly stub returns
|
|
// the sentinel for every one of the 13 connectors. This test lives
|
|
// in target_test (external test package) so it can import each
|
|
// connector concretely + assert the interface contract.
|
|
//
|
|
// As Phases 4-9 replace each connector's stub with a real
|
|
// validate-with-the-target implementation, the corresponding
|
|
// per-connector entry in TestEveryConnectorDefaultsToSentinel
|
|
// MUST be deleted (or the test will fail because the real
|
|
// implementation no longer returns the sentinel). That deletion
|
|
// IS the bookkeeping that the operator-visible bit + behavior
|
|
// change are wired together.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/shankar0123/certctl/internal/connector/target"
|
|
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
|
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
|
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
|
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
|
"github.com/shankar0123/certctl/internal/connector/target/haproxy"
|
|
"github.com/shankar0123/certctl/internal/connector/target/iis"
|
|
"github.com/shankar0123/certctl/internal/connector/target/javakeystore"
|
|
"github.com/shankar0123/certctl/internal/connector/target/k8ssecret"
|
|
// nginx removed Phase 4 — real ValidateOnly implementation now in nginx.go.
|
|
"github.com/shankar0123/certctl/internal/connector/target/postfix"
|
|
"github.com/shankar0123/certctl/internal/connector/target/ssh"
|
|
"github.com/shankar0123/certctl/internal/connector/target/traefik"
|
|
"github.com/shankar0123/certctl/internal/connector/target/wincertstore"
|
|
)
|
|
|
|
// connectorsAtPhase3 is the canonical list of connectors that, as
|
|
// of Phase 3, return ErrValidateOnlyNotSupported from
|
|
// ValidateOnly. Each entry is a (name, factory) tuple; the factory
|
|
// returns a target.Connector via the connector's bare-NewConnector
|
|
// constructor pattern. As Phases 4-9 land, the corresponding
|
|
// connector is REMOVED from this list — its real ValidateOnly
|
|
// implementation is then exercised in the per-connector test
|
|
// suite, NOT here.
|
|
//
|
|
// CI guard rationale: a future PR that adds a 14th connector
|
|
// without wiring ValidateOnly fails this test (the sentinel
|
|
// contract is not satisfied). A future PR that implements a real
|
|
// ValidateOnly for, say, NGINX, but forgets to remove its entry
|
|
// from this list, fails this test (real impl no longer returns
|
|
// the sentinel). Both are the load-bearing bookkeeping protections.
|
|
var connectorsAtPhase3 = []struct {
|
|
name string
|
|
// new returns a fresh Connector instance. The default
|
|
// ValidateOnly stub doesn't dereference any field on the
|
|
// receiver, so a zero-value &pkg.Connector{} is sufficient
|
|
// to satisfy the interface and exercise the sentinel return.
|
|
// Phases 4-9 introduce real validate-with-the-target impls
|
|
// that DO read fields; those connectors will need a populated
|
|
// constructor here OR (more likely) be removed from this list
|
|
// entirely and exercised in their own per-connector test
|
|
// suite.
|
|
new func() target.Connector
|
|
}{
|
|
{"apache", func() target.Connector { return &apache.Connector{} }},
|
|
{"caddy", func() target.Connector { return &caddy.Connector{} }},
|
|
{"envoy", func() target.Connector { return &envoy.Connector{} }},
|
|
{"f5", func() target.Connector { return &f5.Connector{} }},
|
|
{"haproxy", func() target.Connector { return &haproxy.Connector{} }},
|
|
{"iis", func() target.Connector { return &iis.Connector{} }},
|
|
{"javakeystore", func() target.Connector { return &javakeystore.Connector{} }},
|
|
{"k8ssecret", func() target.Connector { return &k8ssecret.Connector{} }},
|
|
// nginx removed Phase 4 — its ValidateOnly is now the real
|
|
// implementation; tested directly in
|
|
// internal/connector/target/nginx/nginx_test.go.
|
|
{"postfix", func() target.Connector { return &postfix.Connector{} }},
|
|
{"ssh", func() target.Connector { return &ssh.Connector{} }},
|
|
{"traefik", func() target.Connector { return &traefik.Connector{} }},
|
|
{"wincertstore", func() target.Connector { return &wincertstore.Connector{} }},
|
|
}
|
|
|
|
func TestEveryConnectorDefaultsToSentinel(t *testing.T) {
|
|
// Expected list size shrinks as Phases 4-9 land their real
|
|
// ValidateOnly implementations. Phase 4 removed nginx.
|
|
const expectedAtCurrentPhase = 12
|
|
if len(connectorsAtPhase3) != expectedAtCurrentPhase {
|
|
t.Fatalf("connectors-at-phase list = %d entries, want %d (drift in the 13-connector inventory)", len(connectorsAtPhase3), expectedAtCurrentPhase)
|
|
}
|
|
for _, c := range connectorsAtPhase3 {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
conn := c.new()
|
|
err := conn.ValidateOnly(context.Background(), target.DeploymentRequest{
|
|
CertPEM: "ignored-by-stub",
|
|
ChainPEM: "ignored",
|
|
TargetConfig: json.RawMessage(`{}`),
|
|
})
|
|
if !errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
|
t.Errorf("got %v, want ErrValidateOnlyNotSupported", err)
|
|
}
|
|
})
|
|
}
|
|
}
|