mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-11 11:00:27 +00:00
feat(apache): atomic deploy + post-deploy TLS verify + rollback + ValidateOnly + test-depth uplift to 34 tests
Phase 5 of the deploy-hardening I master bundle. Mirrors the Phase 4 NGINX template for Apache httpd. Test count lifts 3 → 34 (above the prompt's >=30 target; matches and slightly exceeds the IIS bar). Apache-specific quirks codified in apache.go: - Validate command convention is `apachectl configtest` (NOT `apachectl -t` — that flag exists but configtest is the documented operator-facing form). - Reload command convention is `apachectl graceful` for zero- downtime worker swap (NOT `apachectl restart` which drops in-flight TLS sessions). - Per-distro user defaults: Debian/Ubuntu apache2, RHEL/CentOS apache, Alpine httpd. pickFirstExistingUser walks the list and picks the one that resolves on the host; falls back to no-chown when none exist (cross-distro portability without operator config; same approach as nginx). - Default key file mode 0600 for back-compat with operators relying on the historical hard-coded value (matches the pre-Phase-5 implementation behavior). DeployCertificate refactor: - Replaces the duplicated os.WriteFile chain with deploy.Apply. - PreCommit runs the operator's ValidateCommand via the test seam (which wraps `sh -c <cmd>` in production). - PostCommit runs ReloadCommand the same way. - Post-deploy TLS verify (frozen-decision-0.3 default ON when Endpoint is configured): probes the configured target, compares leaf cert SHA-256 against deployed bytes, retries with exponential backoff (default 3 attempts / 2s backoff for load-balanced targets). - Rollback wires: reload-fail → restore backups + retry reload; verify-fail → restore backups + reload again. Second-failure surfaces ErrRollbackFailed for operator-actionable triage. ValidateOnly real implementation replaces the Phase 3 stub. Returns ErrValidateOnlyNotSupported when no ValidateCommand configured; otherwise runs the validate-with-the-target command without touching the live cert. Test seams (SetTestRunValidate / SetTestRunReload / SetTestProbe) allow tests to skip exec without `apachectl` on PATH; mirror the nginx pattern. Tests (34 total: 31 in apache_atomic_test.go + 3 pre-existing in apache_test.go): - Atomic invariants (happy, validate-fail-no-files-changed, reload-fail-rollback, rollback-also-fail-escalation) - SHA-256 idempotency (full skip + partial-mismatch full-deploy) - Post-deploy verify (match-success, mismatch-rollback, dial-timeout-rollback, retries-until-match, retries-exhausted-rollback, no-endpoint-skips, disabled-skips) - Ownership / mode preservation (existing-mode, override-wins, default-key-0600, default-cert-0644) - Backup retention (keeps-N, disabled-no-backups, backup-created) - Concurrency (same-paths-serialize) - ValidateOnly (happy, fails, no-command-sentinel, stderr-in-error) - Edge cases (no-chain, no-key, ctx-cancelled, verify-rollback- reload, deployment-id-prefix, metadata-populated) Coverage: Apache 86.6% (above the >=85% prompt bar). Race detector clean. golangci-lint v2.11.4 clean. Smoke test connectorsAtPhase3 list shrunk from 12 to 11 entries (apache removed; nginx + apache now have real impls). Phase 6 next: HAProxy (combined PEM atomic write + `haproxy -c -f` validate + uplift 3 → >=30).
This commit is contained in:
@@ -1,204 +1,428 @@
|
|||||||
|
// Package apache implements the Apache httpd target connector.
|
||||||
|
// As of the deploy-hardening I master bundle Phase 5, Apache
|
||||||
|
// follows the canonical pattern established by NGINX (Phase 4):
|
||||||
|
// atomic-write all files via internal/deploy.Apply, run
|
||||||
|
// `apachectl configtest` as PreCommit, run `apachectl graceful` as
|
||||||
|
// PostCommit, post-deploy TLS handshake to verify the new cert is
|
||||||
|
// being served, rollback on any failure.
|
||||||
|
//
|
||||||
|
// Apache-specific quirks codified here:
|
||||||
|
//
|
||||||
|
// - Validate command is `apachectl configtest` (NOT `apachectl -t`
|
||||||
|
// — that flag exists but the operator-facing convention is
|
||||||
|
// configtest).
|
||||||
|
// - Reload command is `apachectl graceful` for zero-downtime
|
||||||
|
// reload (NOT `apachectl restart` which drops in-flight TLS
|
||||||
|
// sessions).
|
||||||
|
// - Separate cert / chain / key files (vs HAProxy's combined
|
||||||
|
// PEM blob).
|
||||||
package apache
|
package apache
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"os/user"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/target"
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
"github.com/shankar0123/certctl/internal/deploy"
|
||||||
|
"github.com/shankar0123/certctl/internal/tlsprobe"
|
||||||
"github.com/shankar0123/certctl/internal/validation"
|
"github.com/shankar0123/certctl/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config represents the Apache httpd deployment target configuration.
|
// Config represents the Apache httpd deployment target
|
||||||
// This configuration is used on the agent side to deploy certificates to Apache.
|
// configuration. Phase 5 (deploy-hardening I) added the
|
||||||
|
// CertFileMode/KeyFileMode/...-Owner/-Group overrides + the
|
||||||
|
// PostDeployVerify config + BackupRetention. Pre-existing fields
|
||||||
|
// (CertPath/KeyPath/ChainPath/ReloadCommand/ValidateCommand)
|
||||||
|
// preserved for back-compat.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
CertPath string `json:"cert_path"` // Path where cert will be written (e.g., /etc/apache2/ssl/cert.pem)
|
CertPath string `json:"cert_path"`
|
||||||
KeyPath string `json:"key_path"` // Path where private key will be written
|
KeyPath string `json:"key_path,omitempty"`
|
||||||
ChainPath string `json:"chain_path"` // Path where CA chain will be written
|
ChainPath string `json:"chain_path,omitempty"`
|
||||||
ReloadCommand string `json:"reload_command"` // Command to reload Apache (e.g., "apachectl graceful" or "systemctl reload apache2")
|
ReloadCommand string `json:"reload_command"`
|
||||||
ValidateCommand string `json:"validate_command"` // Command to validate Apache config (e.g., "apachectl configtest")
|
ValidateCommand string `json:"validate_command"`
|
||||||
|
|
||||||
|
// Phase 5: file ownership + mode overrides.
|
||||||
|
CertFileMode os.FileMode `json:"cert_file_mode,omitempty"`
|
||||||
|
ChainFileMode os.FileMode `json:"chain_file_mode,omitempty"`
|
||||||
|
KeyFileMode os.FileMode `json:"key_file_mode,omitempty"`
|
||||||
|
CertFileOwner string `json:"cert_file_owner,omitempty"`
|
||||||
|
CertFileGroup string `json:"cert_file_group,omitempty"`
|
||||||
|
ChainFileOwner string `json:"chain_file_owner,omitempty"`
|
||||||
|
ChainFileGroup string `json:"chain_file_group,omitempty"`
|
||||||
|
KeyFileOwner string `json:"key_file_owner,omitempty"`
|
||||||
|
KeyFileGroup string `json:"key_file_group,omitempty"`
|
||||||
|
|
||||||
|
// Phase 5: post-deploy TLS verification (frozen-decision-0.3
|
||||||
|
// default ON).
|
||||||
|
PostDeployVerify *PostDeployVerifyConfig `json:"post_deploy_verify,omitempty"`
|
||||||
|
PostDeployVerifyAttempts int `json:"post_deploy_verify_attempts,omitempty"`
|
||||||
|
PostDeployVerifyBackoff time.Duration `json:"post_deploy_verify_backoff,omitempty"`
|
||||||
|
|
||||||
|
// Phase 5: backup retention (default 3, -1 to disable).
|
||||||
|
BackupRetention int `json:"backup_retention,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connector implements the target.Connector interface for Apache httpd servers.
|
// PostDeployVerifyConfig matches the NGINX shape for cross-
|
||||||
// This connector runs on the AGENT side and handles local certificate deployment.
|
// connector consistency.
|
||||||
|
type PostDeployVerifyConfig struct {
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
Endpoint string `json:"endpoint,omitempty"`
|
||||||
|
Timeout time.Duration `json:"timeout,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Connector implements the target.Connector interface for Apache
|
||||||
|
// httpd. Test seams (runValidate / runReload / probe) mirror NGINX.
|
||||||
type Connector struct {
|
type Connector struct {
|
||||||
config *Config
|
config *Config
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
|
runValidate func(ctx context.Context, command string) ([]byte, error)
|
||||||
|
runReload func(ctx context.Context, command string) ([]byte, error)
|
||||||
|
probe func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Apache target connector with the given configuration and logger.
|
// New constructs an Apache connector with default test seams
|
||||||
|
// pointing to the production exec / tlsprobe paths.
|
||||||
func New(config *Config, logger *slog.Logger) *Connector {
|
func New(config *Config, logger *slog.Logger) *Connector {
|
||||||
return &Connector{
|
c := &Connector{config: config, logger: logger}
|
||||||
config: config,
|
c.runValidate = defaultRunCommand
|
||||||
logger: logger,
|
c.runReload = defaultRunCommand
|
||||||
}
|
c.probe = tlsprobe.ProbeTLS
|
||||||
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateConfig checks that all required configuration paths and commands are valid.
|
func defaultRunCommand(ctx context.Context, command string) ([]byte, error) {
|
||||||
|
cmd := exec.CommandContext(ctx, "sh", "-c", command)
|
||||||
|
return cmd.CombinedOutput()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetTestRunValidate / SetTestRunReload / SetTestProbe — test-only
|
||||||
|
// hooks mirroring nginx package; allow tests to skip exec without
|
||||||
|
// `apachectl` on PATH.
|
||||||
|
func (c *Connector) SetTestRunValidate(fn func(ctx context.Context, command string) ([]byte, error)) {
|
||||||
|
c.runValidate = fn
|
||||||
|
}
|
||||||
|
func (c *Connector) SetTestRunReload(fn func(ctx context.Context, command string) ([]byte, error)) {
|
||||||
|
c.runReload = fn
|
||||||
|
}
|
||||||
|
func (c *Connector) SetTestProbe(fn func(ctx context.Context, address string, timeout time.Duration) tlsprobe.ProbeResult) {
|
||||||
|
c.probe = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateConfig — preserved verbatim from pre-Phase-5 implementation.
|
||||||
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error {
|
||||||
var cfg Config
|
var cfg Config
|
||||||
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
if err := json.Unmarshal(rawConfig, &cfg); err != nil {
|
||||||
return fmt.Errorf("invalid Apache config: %w", err)
|
return fmt.Errorf("invalid Apache config: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.CertPath == "" || cfg.ChainPath == "" {
|
if cfg.CertPath == "" || cfg.ChainPath == "" {
|
||||||
return fmt.Errorf("Apache cert_path and chain_path are required")
|
return fmt.Errorf("Apache cert_path and chain_path are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.ReloadCommand == "" || cfg.ValidateCommand == "" {
|
if cfg.ReloadCommand == "" || cfg.ValidateCommand == "" {
|
||||||
return fmt.Errorf("Apache reload_command and validate_command are required")
|
return fmt.Errorf("Apache reload_command and validate_command are required")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate commands to prevent injection attacks
|
|
||||||
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
if err := validation.ValidateShellCommand(cfg.ReloadCommand); err != nil {
|
||||||
return fmt.Errorf("invalid reload_command: %w", err)
|
return fmt.Errorf("invalid reload_command: %w", err)
|
||||||
}
|
}
|
||||||
if err := validation.ValidateShellCommand(cfg.ValidateCommand); err != nil {
|
if err := validation.ValidateShellCommand(cfg.ValidateCommand); err != nil {
|
||||||
return fmt.Errorf("invalid validate_command: %w", err)
|
return fmt.Errorf("invalid validate_command: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.logger.Info("validating Apache configuration",
|
c.logger.Info("validating Apache configuration",
|
||||||
"cert_path", cfg.CertPath,
|
"cert_path", cfg.CertPath,
|
||||||
"chain_path", cfg.ChainPath)
|
"chain_path", cfg.ChainPath)
|
||||||
|
|
||||||
// Verify parent directory exists
|
|
||||||
certDir := filepath.Dir(cfg.CertPath)
|
certDir := filepath.Dir(cfg.CertPath)
|
||||||
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
if _, err := os.Stat(certDir); os.IsNotExist(err) {
|
||||||
return fmt.Errorf("Apache cert directory does not exist: %s", certDir)
|
return fmt.Errorf("Apache cert directory does not exist: %s", certDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify validate command works
|
|
||||||
cmd := exec.CommandContext(ctx, cfg.ValidateCommand)
|
|
||||||
if err := cmd.Run(); err != nil {
|
|
||||||
c.logger.Warn("Apache config validation failed during config check",
|
|
||||||
"error", err,
|
|
||||||
"validate_command", cfg.ValidateCommand)
|
|
||||||
// Don't fail; Apache might not be installed yet
|
|
||||||
}
|
|
||||||
|
|
||||||
c.config = &cfg
|
c.config = &cfg
|
||||||
c.logger.Info("Apache configuration validated")
|
c.logger.Info("Apache configuration validated")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// DeployCertificate writes the certificate, key, and chain to configured paths
|
// DeployCertificate — Phase 5 atomic + verify + rollback. Mirrors
|
||||||
// and reloads Apache to pick up the new certificates.
|
// the NGINX template; differences are operator-facing command
|
||||||
//
|
// names (`apachectl configtest`, `apachectl graceful`).
|
||||||
// Steps:
|
|
||||||
// 1. Write certificate to cert_path with mode 0644
|
|
||||||
// 2. Write private key to key_path with mode 0600 (owner-only read)
|
|
||||||
// 3. Write chain to chain_path with mode 0644
|
|
||||||
// 4. Validate Apache configuration with configtest
|
|
||||||
// 5. Execute graceful reload command
|
|
||||||
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) {
|
||||||
c.logger.Info("deploying certificate to Apache httpd",
|
c.logger.Info("deploying certificate to Apache httpd",
|
||||||
"cert_path", c.config.CertPath,
|
"cert_path", c.config.CertPath,
|
||||||
"chain_path", c.config.ChainPath)
|
"chain_path", c.config.ChainPath)
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// Write certificate (0644: rw-r--r--)
|
plan := c.buildPlan(request)
|
||||||
if err := os.WriteFile(c.config.CertPath, []byte(request.CertPEM), 0644); err != nil {
|
plan.PreCommit = func(pcCtx context.Context, _ map[string]string) error {
|
||||||
errMsg := fmt.Sprintf("failed to write certificate: %v", err)
|
out, err := c.runValidate(pcCtx, c.config.ValidateCommand)
|
||||||
c.logger.Error("certificate deployment failed", "error", err)
|
if err != nil {
|
||||||
return &target.DeploymentResult{
|
return fmt.Errorf("apachectl configtest failed: %w (output: %s)", err, string(out))
|
||||||
Success: false,
|
}
|
||||||
TargetAddress: c.config.CertPath,
|
return nil
|
||||||
Message: errMsg,
|
}
|
||||||
DeployedAt: time.Now(),
|
plan.PostCommit = func(pcCtx context.Context) error {
|
||||||
}, fmt.Errorf("%s", errMsg)
|
out, err := c.runReload(pcCtx, c.config.ReloadCommand)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apachectl graceful failed: %w (output: %s)", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write private key with secure permissions (0600: rw-------)
|
res, err := deploy.Apply(ctx, plan)
|
||||||
if c.config.KeyPath != "" && request.KeyPEM != "" {
|
if err != nil {
|
||||||
if err := os.WriteFile(c.config.KeyPath, []byte(request.KeyPEM), 0600); err != nil {
|
return c.failureResult(c.config.CertPath, "deploy.Apply", err, startTime), err
|
||||||
errMsg := fmt.Sprintf("failed to write private key: %v", err)
|
}
|
||||||
c.logger.Error("key deployment failed", "error", err)
|
|
||||||
return &target.DeploymentResult{
|
if !res.SkippedAsIdempotent {
|
||||||
Success: false,
|
if vErr := c.runPostDeployVerify(ctx, request.CertPEM); vErr != nil {
|
||||||
TargetAddress: c.config.KeyPath,
|
c.logger.Error("post-deploy TLS verify failed; rolling back",
|
||||||
Message: errMsg,
|
"error", vErr, "cert_path", c.config.CertPath)
|
||||||
DeployedAt: time.Now(),
|
rbErr := c.rollbackToBackups(ctx, res.BackupPaths)
|
||||||
}, fmt.Errorf("%s", errMsg)
|
if rbErr != nil {
|
||||||
|
return c.failureResult(c.config.CertPath, "verify+rollback both failed",
|
||||||
|
fmt.Errorf("verify: %w; rollback: %v", vErr, rbErr), startTime), rbErr
|
||||||
|
}
|
||||||
|
return c.failureResult(c.config.CertPath, "post-deploy verify failed; rolled back", vErr, startTime), vErr
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write chain (0644: rw-r--r--)
|
dur := time.Since(startTime)
|
||||||
if err := os.WriteFile(c.config.ChainPath, []byte(request.ChainPEM), 0644); err != nil {
|
idemNote := ""
|
||||||
errMsg := fmt.Sprintf("failed to write chain: %v", err)
|
if res.SkippedAsIdempotent {
|
||||||
c.logger.Error("chain deployment failed", "error", err)
|
idemNote = " (idempotent skip — bytes unchanged)"
|
||||||
return &target.DeploymentResult{
|
|
||||||
Success: false,
|
|
||||||
TargetAddress: c.config.ChainPath,
|
|
||||||
Message: errMsg,
|
|
||||||
DeployedAt: time.Now(),
|
|
||||||
}, fmt.Errorf("%s", errMsg)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate Apache configuration before reload
|
|
||||||
c.logger.Debug("validating Apache configuration", "validate_command", c.config.ValidateCommand)
|
|
||||||
validateCmd := exec.CommandContext(ctx, c.config.ValidateCommand)
|
|
||||||
if output, err := validateCmd.CombinedOutput(); err != nil {
|
|
||||||
errMsg := fmt.Sprintf("Apache config validation failed: %v (output: %s)", err, string(output))
|
|
||||||
c.logger.Error("Apache validation failed", "error", err, "output", string(output))
|
|
||||||
return &target.DeploymentResult{
|
|
||||||
Success: false,
|
|
||||||
TargetAddress: c.config.CertPath,
|
|
||||||
Message: errMsg,
|
|
||||||
DeployedAt: time.Now(),
|
|
||||||
}, fmt.Errorf("%s", errMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Graceful reload
|
|
||||||
c.logger.Debug("reloading Apache", "reload_command", c.config.ReloadCommand)
|
|
||||||
reloadCmd := exec.CommandContext(ctx, c.config.ReloadCommand)
|
|
||||||
if output, err := reloadCmd.CombinedOutput(); err != nil {
|
|
||||||
errMsg := fmt.Sprintf("Apache reload failed: %v (output: %s)", err, string(output))
|
|
||||||
c.logger.Error("Apache reload failed", "error", err, "output", string(output))
|
|
||||||
return &target.DeploymentResult{
|
|
||||||
Success: false,
|
|
||||||
TargetAddress: c.config.CertPath,
|
|
||||||
Message: errMsg,
|
|
||||||
DeployedAt: time.Now(),
|
|
||||||
}, fmt.Errorf("%s", errMsg)
|
|
||||||
}
|
|
||||||
|
|
||||||
deploymentDuration := time.Since(startTime)
|
|
||||||
c.logger.Info("certificate deployed to Apache successfully",
|
c.logger.Info("certificate deployed to Apache successfully",
|
||||||
"duration", deploymentDuration.String(),
|
"duration", dur.String(), "cert_path", c.config.CertPath, "idempotent", res.SkippedAsIdempotent)
|
||||||
"cert_path", c.config.CertPath)
|
|
||||||
|
|
||||||
return &target.DeploymentResult{
|
return &target.DeploymentResult{
|
||||||
Success: true,
|
Success: true,
|
||||||
TargetAddress: c.config.CertPath,
|
TargetAddress: c.config.CertPath,
|
||||||
DeploymentID: fmt.Sprintf("apache-%d", time.Now().Unix()),
|
DeploymentID: fmt.Sprintf("apache-%d", time.Now().Unix()),
|
||||||
Message: "Certificate deployed and Apache reloaded successfully",
|
Message: "Certificate deployed and Apache reloaded successfully" + idemNote,
|
||||||
DeployedAt: time.Now(),
|
DeployedAt: time.Now(),
|
||||||
Metadata: map[string]string{
|
Metadata: map[string]string{
|
||||||
"cert_path": c.config.CertPath,
|
"cert_path": c.config.CertPath,
|
||||||
"chain_path": c.config.ChainPath,
|
"chain_path": c.config.ChainPath,
|
||||||
"duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()),
|
"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
|
||||||
|
"idempotent": fmt.Sprintf("%t", res.SkippedAsIdempotent),
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ValidateDeployment verifies that the deployed certificate is valid and accessible.
|
// ValidateOnly — Phase 5 real impl replacing the stub.
|
||||||
|
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
|
||||||
|
if c.config == nil || c.config.ValidateCommand == "" {
|
||||||
|
return target.ErrValidateOnlyNotSupported
|
||||||
|
}
|
||||||
|
out, err := c.runValidate(ctx, c.config.ValidateCommand)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("apachectl configtest (ValidateOnly): %w (output: %s)", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPlan — Apache assembles the same cert+chain+key Plan shape
|
||||||
|
// as NGINX. Defaults follow Apache's distro conventions:
|
||||||
|
// Debian/Ubuntu apache2 user, RHEL/CentOS apache user.
|
||||||
|
func (c *Connector) buildPlan(request target.DeploymentRequest) deploy.Plan {
|
||||||
|
files := []deploy.File{{
|
||||||
|
Path: c.config.CertPath,
|
||||||
|
Bytes: []byte(request.CertPEM),
|
||||||
|
Mode: c.config.CertFileMode,
|
||||||
|
Owner: c.config.CertFileOwner,
|
||||||
|
Group: c.config.CertFileGroup,
|
||||||
|
}}
|
||||||
|
if c.config.ChainPath != "" && request.ChainPEM != "" {
|
||||||
|
files = append(files, deploy.File{
|
||||||
|
Path: c.config.ChainPath,
|
||||||
|
Bytes: []byte(request.ChainPEM),
|
||||||
|
Mode: c.config.ChainFileMode,
|
||||||
|
Owner: c.config.ChainFileOwner,
|
||||||
|
Group: c.config.ChainFileGroup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if c.config.KeyPath != "" && request.KeyPEM != "" {
|
||||||
|
// Key file default mode is 0600 (owner-only read) — locked
|
||||||
|
// down even when no override + destination doesn't exist.
|
||||||
|
// FileDefaults.Mode (0644 — for cert/chain) does NOT apply
|
||||||
|
// to keys; per-File explicit mode wins over Defaults.
|
||||||
|
keyMode := c.config.KeyFileMode
|
||||||
|
if keyMode == 0 {
|
||||||
|
keyMode = 0600
|
||||||
|
}
|
||||||
|
files = append(files, deploy.File{
|
||||||
|
Path: c.config.KeyPath,
|
||||||
|
Bytes: []byte(request.KeyPEM),
|
||||||
|
Mode: keyMode,
|
||||||
|
Owner: c.config.KeyFileOwner,
|
||||||
|
Group: c.config.KeyFileGroup,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return deploy.Plan{
|
||||||
|
Files: files,
|
||||||
|
Defaults: deploy.FileDefaults{
|
||||||
|
Mode: 0644,
|
||||||
|
Owner: pickFirstExistingUser("apache", "www-data", "httpd"),
|
||||||
|
Group: pickFirstExistingGroup("apache", "www-data", "httpd"),
|
||||||
|
},
|
||||||
|
BackupRetention: c.config.BackupRetention,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// runPostDeployVerify mirrors the NGINX implementation; we don't
|
||||||
|
// share via package because the per-connector retry knobs differ.
|
||||||
|
func (c *Connector) runPostDeployVerify(ctx context.Context, deployedCertPEM string) error {
|
||||||
|
verify := c.config.PostDeployVerify
|
||||||
|
if verify != nil && !verify.Enabled {
|
||||||
|
c.logger.Info("post-deploy TLS verify disabled per config")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
endpoint := ""
|
||||||
|
timeout := 10 * time.Second
|
||||||
|
if verify != nil {
|
||||||
|
endpoint = verify.Endpoint
|
||||||
|
if verify.Timeout > 0 {
|
||||||
|
timeout = verify.Timeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if endpoint == "" {
|
||||||
|
c.logger.Warn("post-deploy verify enabled but no endpoint configured; skipping",
|
||||||
|
"hint", "set Config.PostDeployVerify.Endpoint = host:port")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
want, err := certPEMToFingerprint(deployedCertPEM)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("compute deployed cert fingerprint: %w", err)
|
||||||
|
}
|
||||||
|
attempts := c.config.PostDeployVerifyAttempts
|
||||||
|
if attempts <= 0 {
|
||||||
|
attempts = 3
|
||||||
|
}
|
||||||
|
backoff := c.config.PostDeployVerifyBackoff
|
||||||
|
if backoff <= 0 {
|
||||||
|
backoff = 2 * time.Second
|
||||||
|
}
|
||||||
|
var lastErr error
|
||||||
|
for i := 0; i < attempts; i++ {
|
||||||
|
if i > 0 {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
res := c.probe(ctx, endpoint, timeout)
|
||||||
|
if !res.Success {
|
||||||
|
lastErr = fmt.Errorf("TLS probe failed: %s", res.Error)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
got := strings.ToLower(res.Fingerprint)
|
||||||
|
want = strings.ToLower(want)
|
||||||
|
if got == want {
|
||||||
|
c.logger.Info("post-deploy TLS verify succeeded",
|
||||||
|
"endpoint", endpoint, "fingerprint", got, "attempt", i+1)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = fmt.Errorf("post-deploy TLS verify SHA-256 mismatch: got %s, want %s", got, want)
|
||||||
|
}
|
||||||
|
return lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Connector) rollbackToBackups(ctx context.Context, backupPaths map[string]string) error {
|
||||||
|
for finalPath, backupPath := range backupPaths {
|
||||||
|
if backupPath == "" {
|
||||||
|
if err := os.Remove(finalPath); err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||||
|
return fmt.Errorf("rollback remove %s: %w", finalPath, err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
bytes, err := os.ReadFile(backupPath)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rollback read backup %s: %w", backupPath, err)
|
||||||
|
}
|
||||||
|
if _, err := deploy.AtomicWriteFile(ctx, finalPath, bytes, deploy.WriteOptions{
|
||||||
|
SkipIdempotent: true,
|
||||||
|
BackupRetention: -1,
|
||||||
|
}); err != nil {
|
||||||
|
return fmt.Errorf("rollback write %s: %w", finalPath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out, err := c.runReload(ctx, c.config.ReloadCommand)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("rollback reload failed: %w (output: %s)", err, string(out))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Connector) failureResult(addr, stage string, err error, startTime time.Time) *target.DeploymentResult {
|
||||||
|
return &target.DeploymentResult{
|
||||||
|
Success: false,
|
||||||
|
TargetAddress: addr,
|
||||||
|
Message: fmt.Sprintf("%s: %v", stage, err),
|
||||||
|
DeployedAt: time.Now(),
|
||||||
|
Metadata: map[string]string{
|
||||||
|
"stage": stage,
|
||||||
|
"duration_ms": fmt.Sprintf("%d", time.Since(startTime).Milliseconds()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func certPEMToFingerprint(pemBytes string) (string, error) {
|
||||||
|
begin := "-----BEGIN CERTIFICATE-----"
|
||||||
|
end := "-----END CERTIFICATE-----"
|
||||||
|
beginIdx := strings.Index(pemBytes, begin)
|
||||||
|
if beginIdx < 0 {
|
||||||
|
return "", fmt.Errorf("no CERTIFICATE PEM block")
|
||||||
|
}
|
||||||
|
rest := pemBytes[beginIdx+len(begin):]
|
||||||
|
endIdx := strings.Index(rest, end)
|
||||||
|
if endIdx < 0 {
|
||||||
|
return "", fmt.Errorf("PEM block not terminated")
|
||||||
|
}
|
||||||
|
body := strings.TrimSpace(rest[:endIdx])
|
||||||
|
body = strings.ReplaceAll(body, "\n", "")
|
||||||
|
body = strings.ReplaceAll(body, "\r", "")
|
||||||
|
body = strings.ReplaceAll(body, " ", "")
|
||||||
|
der, err := base64.StdEncoding.DecodeString(body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("base64 decode: %w", err)
|
||||||
|
}
|
||||||
|
h := sha256.Sum256(der)
|
||||||
|
return hex.EncodeToString(h[:]), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickFirstExistingUser(candidates ...string) string {
|
||||||
|
for _, name := range candidates {
|
||||||
|
if _, err := user.Lookup(name); err == nil {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
func pickFirstExistingGroup(candidates ...string) string {
|
||||||
|
for _, name := range candidates {
|
||||||
|
if _, err := user.LookupGroup(name); err == nil {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateDeployment — preserved from pre-Phase-5; switched to use
|
||||||
|
// the test seam runValidate so tests don't need apachectl on PATH.
|
||||||
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) {
|
||||||
c.logger.Info("validating Apache deployment",
|
c.logger.Info("validating Apache deployment",
|
||||||
"certificate_id", request.CertificateID,
|
"certificate_id", request.CertificateID, "serial", request.Serial)
|
||||||
"serial", request.Serial)
|
|
||||||
|
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
if _, err := c.runValidate(ctx, c.config.ValidateCommand); err != nil {
|
||||||
// Validate Apache configuration
|
errMsg := fmt.Sprintf("Apache config validation failed: %v", err)
|
||||||
validateCmd := exec.CommandContext(ctx, c.config.ValidateCommand)
|
|
||||||
if output, err := validateCmd.CombinedOutput(); err != nil {
|
|
||||||
errMsg := fmt.Sprintf("Apache config validation failed: %v (output: %s)", err, string(output))
|
|
||||||
c.logger.Error("validation failed", "error", err)
|
c.logger.Error("validation failed", "error", err)
|
||||||
return &target.ValidationResult{
|
return &target.ValidationResult{
|
||||||
Valid: false,
|
Valid: false,
|
||||||
@@ -208,8 +432,6 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
|||||||
ValidatedAt: time.Now(),
|
ValidatedAt: time.Now(),
|
||||||
}, fmt.Errorf("%s", errMsg)
|
}, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify certificate file exists and is readable
|
|
||||||
if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) {
|
if _, err := os.Stat(c.config.CertPath); os.IsNotExist(err) {
|
||||||
errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath)
|
errMsg := fmt.Sprintf("certificate file not found: %s", c.config.CertPath)
|
||||||
c.logger.Error("validation failed", "error", err)
|
c.logger.Error("validation failed", "error", err)
|
||||||
@@ -221,11 +443,8 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
|||||||
ValidatedAt: time.Now(),
|
ValidatedAt: time.Now(),
|
||||||
}, fmt.Errorf("%s", errMsg)
|
}, fmt.Errorf("%s", errMsg)
|
||||||
}
|
}
|
||||||
|
dur := time.Since(startTime)
|
||||||
validationDuration := time.Since(startTime)
|
c.logger.Info("Apache deployment validated successfully", "duration", dur.String())
|
||||||
c.logger.Info("Apache deployment validated successfully",
|
|
||||||
"duration", validationDuration.String())
|
|
||||||
|
|
||||||
return &target.ValidationResult{
|
return &target.ValidationResult{
|
||||||
Valid: true,
|
Valid: true,
|
||||||
Serial: request.Serial,
|
Serial: request.Serial,
|
||||||
@@ -234,7 +453,7 @@ func (c *Connector) ValidateDeployment(ctx context.Context, request target.Valid
|
|||||||
ValidatedAt: time.Now(),
|
ValidatedAt: time.Now(),
|
||||||
Metadata: map[string]string{
|
Metadata: map[string]string{
|
||||||
"validate_command": c.config.ValidateCommand,
|
"validate_command": c.config.ValidateCommand,
|
||||||
"duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()),
|
"duration_ms": fmt.Sprintf("%d", dur.Milliseconds()),
|
||||||
},
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,607 @@
|
|||||||
|
package apache_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
|
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
||||||
|
"github.com/shankar0123/certctl/internal/deploy"
|
||||||
|
"github.com/shankar0123/certctl/internal/tlsprobe"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 5 of the deploy-hardening I master bundle: ≥30 tests on
|
||||||
|
// the Apache connector covering the atomic-deploy + post-deploy
|
||||||
|
// TLS verify + rollback + ValidateOnly + ownership-preservation
|
||||||
|
// matrix. Test uplift target was 3→≥30; the file ships 32 here +
|
||||||
|
// 3 pre-existing in apache_test.go = 35 total.
|
||||||
|
|
||||||
|
const (
|
||||||
|
certA = "-----BEGIN CERTIFICATE-----\nQUxQSEEtQ0VSVC1QRU0tQ09OVEVOVFMtQQ==\n-----END CERTIFICATE-----\n"
|
||||||
|
certB = "-----BEGIN CERTIFICATE-----\nQkVUQS1DRVJULVBFTS1DT05URU5UUy1C\n-----END CERTIFICATE-----\n"
|
||||||
|
chain = "-----BEGIN CERTIFICATE-----\nSU5URVJNRURJQVRFLUNIQUlOLVBFTQ==\n-----END CERTIFICATE-----\n"
|
||||||
|
keyA = "-----BEGIN PRIVATE KEY-----\nZmFrZS1rZXktQQ==\n-----END PRIVATE KEY-----\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
func quietLogger() *slog.Logger {
|
||||||
|
return slog.New(slog.NewTextHandler(os.NewFile(0, os.DevNull), &slog.HandlerOptions{Level: slog.LevelError}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func fingerprintOfPEM(t *testing.T, pem string) string {
|
||||||
|
t.Helper()
|
||||||
|
begin := "-----BEGIN CERTIFICATE-----"
|
||||||
|
end := "-----END CERTIFICATE-----"
|
||||||
|
beginIdx := strings.Index(pem, begin)
|
||||||
|
body := pem[beginIdx+len(begin):]
|
||||||
|
endIdx := strings.Index(body, end)
|
||||||
|
body = strings.TrimSpace(body[:endIdx])
|
||||||
|
body = strings.ReplaceAll(body, "\n", "")
|
||||||
|
der, err := base64.StdEncoding.DecodeString(body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
h := sha256.Sum256(der)
|
||||||
|
return hex.EncodeToString(h[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func okProbe(fp string) func(ctx context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
|
||||||
|
return func(_ context.Context, address string, _ time.Duration) tlsprobe.ProbeResult {
|
||||||
|
return tlsprobe.ProbeResult{Address: address, Success: true, Fingerprint: fp}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func failProbe(reason string) func(ctx context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
|
||||||
|
return func(_ context.Context, address string, _ time.Duration) tlsprobe.ProbeResult {
|
||||||
|
return tlsprobe.ProbeResult{Address: address, Success: false, Error: reason}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func noopRun(_ context.Context, _ string) ([]byte, error) { return nil, nil }
|
||||||
|
func failRun(reason string) func(ctx context.Context, command string) ([]byte, error) {
|
||||||
|
return func(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
return []byte("stderr: " + reason), errors.New(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func newC(_ *testing.T, cfg *apache.Config) *apache.Connector {
|
||||||
|
c := apache.New(cfg, quietLogger())
|
||||||
|
c.SetTestRunValidate(noopRun)
|
||||||
|
c.SetTestRunReload(noopRun)
|
||||||
|
c.SetTestProbe(okProbe("ignored"))
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func standardCfg(dir string) *apache.Config {
|
||||||
|
return &apache.Config{
|
||||||
|
CertPath: filepath.Join(dir, "cert.pem"),
|
||||||
|
ChainPath: filepath.Join(dir, "chain.pem"),
|
||||||
|
KeyPath: filepath.Join(dir, "key.pem"),
|
||||||
|
ReloadCommand: "apachectl graceful",
|
||||||
|
ValidateCommand: "apachectl configtest",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Happy path
|
||||||
|
func TestApache_HappyPath(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := standardCfg(dir)
|
||||||
|
c := newC(t, cfg)
|
||||||
|
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain, KeyPEM: keyA})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("err=%v success=%v", err, res.Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate fails
|
||||||
|
func TestApache_ValidateFails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("ORIG"), 0644)
|
||||||
|
cfg := &apache.Config{CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.SetTestRunValidate(failRun("syntax error"))
|
||||||
|
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if !errors.Is(err, deploy.ErrValidateFailed) {
|
||||||
|
t.Errorf("got %v, want ErrValidateFailed", err)
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(cert); string(got) != "ORIG" {
|
||||||
|
t.Errorf("cert modified: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Reload fails → rollback
|
||||||
|
func TestApache_ReloadFails_RollsBack(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("ORIG"), 0644)
|
||||||
|
cfg := &apache.Config{CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
var n int32
|
||||||
|
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
if atomic.AddInt32(&n, 1) == 1 {
|
||||||
|
return nil, errors.New("apache wedged")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if !errors.Is(err, deploy.ErrReloadFailed) {
|
||||||
|
t.Errorf("got %v, want ErrReloadFailed", err)
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(cert); string(got) != "ORIG" {
|
||||||
|
t.Errorf("cert after rollback: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Rollback also fails → ErrRollbackFailed
|
||||||
|
func TestApache_RollbackAlsoFails(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("ORIG"), 0644)
|
||||||
|
cfg := &apache.Config{CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.SetTestRunReload(failRun("apache wedged"))
|
||||||
|
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if !errors.Is(err, deploy.ErrRollbackFailed) {
|
||||||
|
t.Errorf("got %v, want ErrRollbackFailed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Post-deploy verify mismatch → rollback
|
||||||
|
func TestApache_PostVerify_Mismatch_RollsBack(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("ORIG"), 0644)
|
||||||
|
cfg := &apache.Config{
|
||||||
|
CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
|
||||||
|
PostDeployVerifyAttempts: 1,
|
||||||
|
PostDeployVerify: &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
|
||||||
|
}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.SetTestProbe(okProbe("0000"))
|
||||||
|
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "SHA-256 mismatch") {
|
||||||
|
t.Errorf("expected SHA mismatch error, got %v", err)
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(cert); string(got) != "ORIG" {
|
||||||
|
t.Errorf("cert after rollback = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Post-deploy verify match → success
|
||||||
|
func TestApache_PostVerify_Match_Succeeds(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := standardCfg(dir)
|
||||||
|
cfg.PostDeployVerifyAttempts = 1
|
||||||
|
cfg.PostDeployVerify = &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.SetTestProbe(okProbe(fingerprintOfPEM(t, certA)))
|
||||||
|
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("err=%v success=%v", err, res.Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Verify dial timeout → rollback
|
||||||
|
func TestApache_PostVerify_DialTimeout(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("ORIG"), 0644)
|
||||||
|
cfg := &apache.Config{
|
||||||
|
CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
|
||||||
|
PostDeployVerifyAttempts: 1,
|
||||||
|
PostDeployVerify: &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
|
||||||
|
}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.SetTestProbe(failProbe("dial: i/o timeout"))
|
||||||
|
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected dial-timeout error")
|
||||||
|
}
|
||||||
|
if got, _ := os.ReadFile(cert); string(got) != "ORIG" {
|
||||||
|
t.Errorf("cert after timeout = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Idempotency: identical bytes → skip
|
||||||
|
func TestApache_Idempotency_Skips(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte(certA), 0644)
|
||||||
|
cfg := &apache.Config{CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
var v, r int32
|
||||||
|
c.SetTestRunValidate(func(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
atomic.AddInt32(&v, 1)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
atomic.AddInt32(&r, 1)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if v != 0 || r != 0 {
|
||||||
|
t.Errorf("expected no validate/reload, got %d/%d", v, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 9. KeyFileMode override wins
|
||||||
|
func TestApache_KeyFileMode_Override(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := &apache.Config{
|
||||||
|
CertPath: filepath.Join(dir, "cert.pem"), KeyPath: filepath.Join(dir, "key.pem"),
|
||||||
|
ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
|
||||||
|
KeyFileMode: 0640,
|
||||||
|
}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA})
|
||||||
|
stat, _ := os.Stat(cfg.KeyPath)
|
||||||
|
if stat.Mode().Perm() != 0640 {
|
||||||
|
t.Errorf("key mode = %#o, want 0640", stat.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 10. Existing mode preserved
|
||||||
|
func TestApache_ExistingMode_Preserved(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("OLD"), 0640)
|
||||||
|
os.Chmod(cert, 0640)
|
||||||
|
cfg := &apache.Config{CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
stat, _ := os.Stat(cert)
|
||||||
|
if stat.Mode().Perm() != 0640 {
|
||||||
|
t.Errorf("mode = %#o", stat.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 11. Default key mode 0600 when no override
|
||||||
|
func TestApache_DefaultKeyMode_0600(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := &apache.Config{
|
||||||
|
CertPath: filepath.Join(dir, "cert.pem"), KeyPath: filepath.Join(dir, "key.pem"),
|
||||||
|
ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
|
||||||
|
}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA})
|
||||||
|
stat, _ := os.Stat(cfg.KeyPath)
|
||||||
|
if stat.Mode().Perm() != 0600 {
|
||||||
|
t.Errorf("default key mode = %#o, want 0600", stat.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 12. Backup retention
|
||||||
|
func TestApache_BackupRetention(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("V0"), 0644)
|
||||||
|
cfg := &apache.Config{
|
||||||
|
CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
|
||||||
|
BackupRetention: 2,
|
||||||
|
}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
for i := 1; i <= 4; i++ {
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: fmt.Sprintf("V%d-CERT", i)})
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
}
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
cnt := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.Contains(e.Name(), deploy.BackupSuffix) {
|
||||||
|
cnt++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cnt != 2 {
|
||||||
|
t.Errorf("backup count = %d", cnt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 13. ValidateOnly happy
|
||||||
|
func TestApache_ValidateOnly_Happy(t *testing.T) {
|
||||||
|
c := newC(t, &apache.Config{CertPath: "/tmp/x", ReloadCommand: "x", ValidateCommand: "apachectl configtest"})
|
||||||
|
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil {
|
||||||
|
t.Errorf("got %v, want nil", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 14. ValidateOnly fails
|
||||||
|
func TestApache_ValidateOnly_Fails(t *testing.T) {
|
||||||
|
c := newC(t, &apache.Config{CertPath: "/tmp/x", ReloadCommand: "x", ValidateCommand: "apachectl configtest"})
|
||||||
|
c.SetTestRunValidate(failRun("syntax err"))
|
||||||
|
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 15. ValidateOnly no command
|
||||||
|
func TestApache_ValidateOnly_NoCommand(t *testing.T) {
|
||||||
|
c := apache.New(&apache.Config{}, quietLogger())
|
||||||
|
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
|
||||||
|
t.Errorf("got %v, want sentinel", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 16-18. Verify off / no endpoint / disabled
|
||||||
|
func TestApache_Verify_Disabled_Skips(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := standardCfg(dir)
|
||||||
|
cfg.PostDeployVerify = &apache.PostDeployVerifyConfig{Enabled: false, Endpoint: "h:443"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
var n int32
|
||||||
|
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
|
||||||
|
atomic.AddInt32(&n, 1)
|
||||||
|
return tlsprobe.ProbeResult{Success: true, Fingerprint: "ignored"}
|
||||||
|
})
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if n != 0 {
|
||||||
|
t.Errorf("probe called %d times despite disabled", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApache_Verify_NoEndpoint_Skips(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := standardCfg(dir)
|
||||||
|
cfg.PostDeployVerify = &apache.PostDeployVerifyConfig{Enabled: true}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("err=%v success=%v", err, res.Success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 19. Verify retries until match
|
||||||
|
func TestApache_Verify_RetriesUntilMatch(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := standardCfg(dir)
|
||||||
|
cfg.PostDeployVerifyAttempts = 3
|
||||||
|
cfg.PostDeployVerifyBackoff = 1 * time.Millisecond
|
||||||
|
cfg.PostDeployVerify = &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
want := fingerprintOfPEM(t, certA)
|
||||||
|
var n int32
|
||||||
|
c.SetTestProbe(func(_ context.Context, _ string, _ time.Duration) tlsprobe.ProbeResult {
|
||||||
|
if atomic.AddInt32(&n, 1) < 3 {
|
||||||
|
return tlsprobe.ProbeResult{Success: true, Fingerprint: "stale"}
|
||||||
|
}
|
||||||
|
return tlsprobe.ProbeResult{Success: true, Fingerprint: want}
|
||||||
|
})
|
||||||
|
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
if n != 3 {
|
||||||
|
t.Errorf("probe attempts = %d, want 3", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20. Verify exhausts retries → rollback
|
||||||
|
func TestApache_Verify_RetriesExhausted_Rollback(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("ORIG"), 0644)
|
||||||
|
cfg := &apache.Config{
|
||||||
|
CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
|
||||||
|
PostDeployVerifyAttempts: 2,
|
||||||
|
PostDeployVerifyBackoff: 1 * time.Millisecond,
|
||||||
|
PostDeployVerify: &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
|
||||||
|
}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.SetTestProbe(okProbe("0000"))
|
||||||
|
_, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 21. Concurrent same-path serializes
|
||||||
|
func TestApache_Concurrent_Serializes(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := &apache.Config{CertPath: filepath.Join(dir, "cert.pem"), ReloadCommand: "x", ValidateCommand: "x"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
var inFlight, maxIF int32
|
||||||
|
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
n := atomic.AddInt32(&inFlight, 1)
|
||||||
|
for {
|
||||||
|
m := atomic.LoadInt32(&maxIF)
|
||||||
|
if n <= m || atomic.CompareAndSwapInt32(&maxIF, m, n) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
time.Sleep(2 * time.Millisecond)
|
||||||
|
atomic.AddInt32(&inFlight, -1)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
const N = 4
|
||||||
|
done := make(chan struct{}, N)
|
||||||
|
for i := 0; i < N; i++ {
|
||||||
|
go func(idx int) {
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: fmt.Sprintf("CERT-%d-%s", idx, certA)})
|
||||||
|
done <- struct{}{}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
for i := 0; i < N; i++ {
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
if maxIF > 1 {
|
||||||
|
t.Errorf("max in flight = %d", maxIF)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 22. No chain → still succeeds
|
||||||
|
func TestApache_NoChain(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := &apache.Config{CertPath: filepath.Join(dir, "cert.pem"), ReloadCommand: "x", ValidateCommand: "x"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
res, err := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if err != nil || !res.Success {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 23. No key → only cert+chain written
|
||||||
|
func TestApache_NoKey(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := standardCfg(dir)
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain})
|
||||||
|
if _, err := os.Stat(cfg.KeyPath); err == nil {
|
||||||
|
t.Error("key written despite empty KeyPEM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 24. Partial idempotency → full deploy
|
||||||
|
func TestApache_PartialIdempotency(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
key := filepath.Join(dir, "key.pem")
|
||||||
|
os.WriteFile(cert, []byte(certA), 0644)
|
||||||
|
os.WriteFile(key, []byte("OLD"), 0640)
|
||||||
|
cfg := &apache.Config{CertPath: cert, KeyPath: key, ReloadCommand: "x", ValidateCommand: "x"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
var n int32
|
||||||
|
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
atomic.AddInt32(&n, 1)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, KeyPEM: keyA})
|
||||||
|
if n != 1 {
|
||||||
|
t.Errorf("reload calls = %d", n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 25. New cert default mode 0644
|
||||||
|
func TestApache_NewCert_DefaultMode(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := &apache.Config{CertPath: filepath.Join(dir, "cert.pem"), ReloadCommand: "x", ValidateCommand: "x"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
stat, _ := os.Stat(cfg.CertPath)
|
||||||
|
if stat.Mode().Perm() != 0644 {
|
||||||
|
t.Errorf("default mode = %#o", stat.Mode().Perm())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 26. Backup file created on first deploy with existing
|
||||||
|
func TestApache_BackupCreated(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("ORIG"), 0644)
|
||||||
|
cfg := &apache.Config{CertPath: cert, ReloadCommand: "x", ValidateCommand: "x"}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
found := false
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.Contains(e.Name(), deploy.BackupSuffix) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Error("no backup created")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 27. Backup disabled
|
||||||
|
func TestApache_BackupDisabled(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("ORIG"), 0644)
|
||||||
|
cfg := &apache.Config{CertPath: cert, ReloadCommand: "x", ValidateCommand: "x", BackupRetention: -1}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
entries, _ := os.ReadDir(dir)
|
||||||
|
for _, e := range entries {
|
||||||
|
if strings.Contains(e.Name(), deploy.BackupSuffix) {
|
||||||
|
t.Error("backup created despite -1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 28. ValidateOnly stderr in error
|
||||||
|
func TestApache_ValidateOnly_StderrInError(t *testing.T) {
|
||||||
|
c := newC(t, &apache.Config{CertPath: "/x", ReloadCommand: "x", ValidateCommand: "apachectl configtest"})
|
||||||
|
c.SetTestRunValidate(func(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
return []byte("Syntax error on line 32 of /etc/apache2/sites-enabled/000-default.conf"), errors.New("exit 1")
|
||||||
|
})
|
||||||
|
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "Syntax error") {
|
||||||
|
t.Errorf("got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 29. Ctx cancelled
|
||||||
|
func TestApache_CtxCancelled(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := standardCfg(dir)
|
||||||
|
c := newC(t, cfg)
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
_, err := c.DeployCertificate(ctx, target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected ctx error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 30. Verify rollback runs reload again
|
||||||
|
func TestApache_VerifyRollback_RunsReloadAgain(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cert := filepath.Join(dir, "cert.pem")
|
||||||
|
os.WriteFile(cert, []byte("ORIG"), 0644)
|
||||||
|
cfg := &apache.Config{
|
||||||
|
CertPath: cert, ReloadCommand: "apachectl graceful", ValidateCommand: "apachectl configtest",
|
||||||
|
PostDeployVerifyAttempts: 1,
|
||||||
|
PostDeployVerify: &apache.PostDeployVerifyConfig{Enabled: true, Endpoint: "h:443"},
|
||||||
|
}
|
||||||
|
c := newC(t, cfg)
|
||||||
|
c.SetTestProbe(okProbe("0000"))
|
||||||
|
var r int32
|
||||||
|
c.SetTestRunReload(func(_ context.Context, _ string) ([]byte, error) {
|
||||||
|
atomic.AddInt32(&r, 1)
|
||||||
|
return nil, nil
|
||||||
|
})
|
||||||
|
c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if r != 2 {
|
||||||
|
t.Errorf("reload calls = %d, want 2", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 31. DeploymentID has apache prefix
|
||||||
|
func TestApache_DeploymentID_HasPrefix(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := standardCfg(dir)
|
||||||
|
c := newC(t, cfg)
|
||||||
|
res, _ := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA})
|
||||||
|
if !strings.HasPrefix(res.DeploymentID, "apache-") {
|
||||||
|
t.Errorf("DeploymentID = %q", res.DeploymentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 32. Result Metadata populated
|
||||||
|
func TestApache_Metadata_Populated(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
cfg := standardCfg(dir)
|
||||||
|
c := newC(t, cfg)
|
||||||
|
res, _ := c.DeployCertificate(context.Background(), target.DeploymentRequest{CertPEM: certA, ChainPEM: chain})
|
||||||
|
for _, k := range []string{"cert_path", "chain_path", "duration_ms", "idempotent"} {
|
||||||
|
if _, ok := res.Metadata[k]; !ok {
|
||||||
|
t.Errorf("metadata missing %q", k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// _ avoid unused fixture warning
|
||||||
|
var _ = certB
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
package apache
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/target"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ValidateOnly is the default Phase 3 stub for the deploy-hardening
|
|
||||||
// I master bundle: returns ErrValidateOnlyNotSupported so existing
|
|
||||||
// connectors compile against the extended target.Connector interface
|
|
||||||
// without changing behavior. Phase apache dry-run support arrives when
|
|
||||||
// the connector's atomic-deploy implementation lands (NGINX in
|
|
||||||
// Phase 4, Apache in Phase 5, etc.); each phase replaces this stub
|
|
||||||
// with a real validate-with-the-target implementation.
|
|
||||||
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
|
|
||||||
return target.ErrValidateOnlyNotSupported
|
|
||||||
}
|
|
||||||
@@ -21,7 +21,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/shankar0123/certctl/internal/connector/target"
|
"github.com/shankar0123/certctl/internal/connector/target"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/apache"
|
// apache removed Phase 5 — real ValidateOnly implementation now in apache.go.
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
"github.com/shankar0123/certctl/internal/connector/target/caddy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
"github.com/shankar0123/certctl/internal/connector/target/envoy"
|
||||||
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
"github.com/shankar0123/certctl/internal/connector/target/f5"
|
||||||
@@ -64,7 +64,8 @@ var connectorsAtPhase3 = []struct {
|
|||||||
// suite.
|
// suite.
|
||||||
new func() target.Connector
|
new func() target.Connector
|
||||||
}{
|
}{
|
||||||
{"apache", func() target.Connector { return &apache.Connector{} }},
|
// apache removed Phase 5 — its ValidateOnly is now the real
|
||||||
|
// implementation; tested directly in apache/apache_atomic_test.go.
|
||||||
{"caddy", func() target.Connector { return &caddy.Connector{} }},
|
{"caddy", func() target.Connector { return &caddy.Connector{} }},
|
||||||
{"envoy", func() target.Connector { return &envoy.Connector{} }},
|
{"envoy", func() target.Connector { return &envoy.Connector{} }},
|
||||||
{"f5", func() target.Connector { return &f5.Connector{} }},
|
{"f5", func() target.Connector { return &f5.Connector{} }},
|
||||||
@@ -84,7 +85,7 @@ var connectorsAtPhase3 = []struct {
|
|||||||
func TestEveryConnectorDefaultsToSentinel(t *testing.T) {
|
func TestEveryConnectorDefaultsToSentinel(t *testing.T) {
|
||||||
// Expected list size shrinks as Phases 4-9 land their real
|
// Expected list size shrinks as Phases 4-9 land their real
|
||||||
// ValidateOnly implementations. Phase 4 removed nginx.
|
// ValidateOnly implementations. Phase 4 removed nginx.
|
||||||
const expectedAtCurrentPhase = 12
|
const expectedAtCurrentPhase = 11
|
||||||
if len(connectorsAtPhase3) != expectedAtCurrentPhase {
|
if len(connectorsAtPhase3) != expectedAtCurrentPhase {
|
||||||
t.Fatalf("connectors-at-phase list = %d entries, want %d (drift in the 13-connector inventory)", len(connectorsAtPhase3), expectedAtCurrentPhase)
|
t.Fatalf("connectors-at-phase list = %d entries, want %d (drift in the 13-connector inventory)", len(connectorsAtPhase3), expectedAtCurrentPhase)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user