feat(f5,iis): explicit ValidateOnly + leverage existing transactional rollback

Phase 8 of the deploy-hardening I master bundle. F5 + IIS already
have transactional / explicit-backup-restore rollback semantics
in their DeployCertificate paths. Phase 8 adds the explicit
ValidateOnly dry-run probe that operators use to preview a deploy
without touching the live cert.

F5 (validate_only.go):
- ValidateOnly probes the iControl REST API via Authenticate.
  Cheap (no F5 transaction created) + cached after first success.
  Failure surfaces as a wrapped error so operators see the actual
  cause (auth provider down, invalid creds, BIG-IP unreachable,
  etc.). nil client returns ErrValidateOnlyNotSupported.
- A true cert-bind dry-run requires F5's no-commit transaction
  mode (v17.5+); V3-Pro can add per-version dispatch. V2 ships
  the reachability probe as the load-bearing safety check.
- 5 new tests in validate_only_test.go covering: auth-success,
  auth-fail wrapped, nil-client sentinel, error-message contains
  BIG-IP context, recoverable auth-fail surfaces provider info.

IIS (validate_only.go):
- ValidateOnly runs `Get-WebSite -Name <SiteName>` via the
  injected PowerShellExecutor. Confirms the IIS PS module is
  loaded AND the site exists AND the agent has admin privileges.
  Failure here surfaces the actual PowerShell stderr (site not
  found / module missing / access denied).
- A true cert-bind dry-run would need IIS to expose a no-commit
  New-WebBinding (it doesn't); V3-Pro can extend with a
  temp-install + immediate-remove. V2 ships the permission +
  module probe as the load-bearing check.
- 5 new tests in validate_only_test.go covering: get-website
  succeeds, get-website fails, nil-executor sentinel, site-name
  quoting (handles spaces in 'Default Web Site'), output-context
  in error.

Smoke test connectorsAtPhase3 list shrunk from 10 to 7 entries
(f5 + iis + postfix removed). Caddy stays in (file-mode returns
sentinel; api-mode is real-impl). Envoy + Traefik stay in (no
validate-with-target command exists for either). javakeystore +
k8ssecret + ssh + wincertstore stay in pending Phase 9.

Coverage: F5 holds at ≥85%; IIS holds at ≥85%. Race detector
clean. golangci-lint v2.11.4 clean.

Phase 9 next: SSH + WinCertStore + JavaKeystore + K8s — the
non-file-server connectors.
This commit is contained in:
claude
2026-04-30 15:16:11 +00:00
parent 758dbb283f
commit 8e83f02401
5 changed files with 284 additions and 28 deletions
+30 -8
View File
@@ -2,17 +2,39 @@ package f5
import (
"context"
"fmt"
"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 f5 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.
// ValidateOnly — Phase 8 of the deploy-hardening I master bundle.
// F5 already has full transactional rollback semantics in
// DeployCertificate (the iControl REST API is transactional —
// `mgmt/tm/transaction` wraps the install + bind together; on
// failure the whole transaction aborts atomically with no live
// VS impact). Phase 8 makes the dry-run explicit by probing the
// BIG-IP control plane health: if the API is reachable and
// authenticated, ValidateOnly returns nil; otherwise it returns
// the wrapped client error so operators can preview a deploy
// without touching the live SSL profile.
//
// Note: a full dry-run that simulates the cert install + bind
// without commit would require F5 to expose a no-commit transaction
// mode (it does not in v15.x; it does in v17.5+ — V3-Pro will add
// per-version dispatch). For V2 the reachability probe is the
// load-bearing safety check.
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
return target.ErrValidateOnlyNotSupported
if c.client == nil {
return target.ErrValidateOnlyNotSupported
}
// Probe by attempting authentication. The F5 client caches
// the token after first success, so subsequent ValidateOnly
// calls are cheap. Failure here means the BIG-IP is
// unreachable, the operator credentials are wrong, or the
// auth provider (TACACS+, RADIUS) is down — all reasons to
// abort a deploy preview.
if err := c.client.Authenticate(ctx); err != nil {
return fmt.Errorf("F5 ValidateOnly: BIG-IP control plane probe failed: %w", err)
}
return nil
}
@@ -0,0 +1,115 @@
package f5
import (
"context"
"errors"
"log/slog"
"os"
"testing"
"github.com/shankar0123/certctl/internal/connector/target"
)
// Phase 8 of the deploy-hardening I master bundle: F5 ValidateOnly
// real implementation tests. F5 already has full transactional
// rollback via the iControl REST `mgmt/tm/transaction` endpoint;
// the new bit is the explicit dry-run probe via Authenticate.
type stubF5Authenticator struct {
authErr error
}
func (s *stubF5Authenticator) Authenticate(_ context.Context) error {
return s.authErr
}
// implement the rest of the F5Client interface as no-ops so the
// stub satisfies the interface.
func (s *stubF5Authenticator) UploadFile(context.Context, string, []byte) error {
return nil
}
func (s *stubF5Authenticator) InstallCert(context.Context, string, string) error { return nil }
func (s *stubF5Authenticator) InstallKey(context.Context, string, string) error { return nil }
func (s *stubF5Authenticator) CreateTransaction(context.Context) (string, error) {
return "", nil
}
func (s *stubF5Authenticator) CommitTransaction(context.Context, string) error {
return nil
}
func (s *stubF5Authenticator) UpdateSSLProfile(context.Context, string, string, string, string, string, string) error {
return nil
}
func (s *stubF5Authenticator) GetSSLProfile(context.Context, string, string) (*SSLProfileInfo, error) {
return nil, nil
}
func (s *stubF5Authenticator) DeleteCert(context.Context, string, string) error { return nil }
func (s *stubF5Authenticator) DeleteKey(context.Context, string, string) error { return nil }
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(os.NewFile(0, os.DevNull), &slog.HandlerOptions{Level: slog.LevelError}))
}
func TestF5_ValidateOnly_Auth_Succeeds_ReturnsNil(t *testing.T) {
c := NewWithClient(&Config{Host: "f5.example", Username: "admin"}, quietLogger(), &stubF5Authenticator{authErr: nil})
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil {
t.Errorf("got %v, want nil", err)
}
}
func TestF5_ValidateOnly_AuthFails_ReturnsWrappedError(t *testing.T) {
c := NewWithClient(&Config{Host: "f5.example", Username: "admin"}, quietLogger(), &stubF5Authenticator{authErr: errors.New("invalid credentials")})
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
if err == nil {
t.Fatal("expected error")
}
if errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("got sentinel, want wrapped auth error: %v", err)
}
}
func TestF5_ValidateOnly_NilClient_ReturnsSentinel(t *testing.T) {
c := &Connector{config: &Config{Host: "f5.example"}, logger: quietLogger()}
// Don't inject a client.
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("got %v, want sentinel", err)
}
}
func TestF5_ValidateOnly_AuthFailureMessageMentionsBIGIP(t *testing.T) {
c := NewWithClient(&Config{Host: "f5.example"}, quietLogger(), &stubF5Authenticator{authErr: errors.New("conn refused")})
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
if err == nil {
t.Fatal("expected error")
}
if !contains(err.Error(), "BIG-IP") {
t.Errorf("error missing BIG-IP context: %v", err)
}
}
func TestF5_ValidateOnly_RecoverableAuthErrIsActionable(t *testing.T) {
// Auth-fail variant that simulates a one-time TACACS+ outage —
// the operator is meant to see this as actionable.
c := NewWithClient(&Config{Host: "f5.example"}, quietLogger(), &stubF5Authenticator{authErr: errors.New("TACACS+ auth provider unreachable")})
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
if err == nil {
t.Fatal("expected error")
}
if !contains(err.Error(), "TACACS+") {
t.Errorf("error doesn't surface auth provider info: %v", err)
}
}
func contains(haystack, needle string) bool {
return len(haystack) > 0 && len(needle) > 0 &&
(len(haystack) >= len(needle)) &&
(indexOf(haystack, needle) >= 0)
}
func indexOf(s, substr string) int {
for i := 0; i+len(substr) <= len(s); i++ {
if s[i:i+len(substr)] == substr {
return i
}
}
return -1
}