feat(ssh,wincertstore,javakeystore,k8ssecret): explicit ValidateOnly + leverage existing connectors

Phase 9 of the deploy-hardening I master bundle. The four
non-file-server connectors get real ValidateOnly probes that
operators use to preview a deploy without touching the live cert.
Existing DeployCertificate paths already have explicit backup +
rollback semantics (SCP backup / WinCertStore Get-ChildItem
snapshot / keytool snapshot / K8s atomic API).

SSH (validate_only.go):
- Probes via SSHClient.Connect. Confirms agent reachability +
  credentials. Cheap (no remote command runs); released cleanly
  via defer Close.
- A true SCP dry-run requires a no-commit upload (SCP doesn't
  have one). V2 ships the auth probe as the load-bearing check.
- 3 new tests in validate_only_test.go.

WinCertStore (validate_only.go):
- Probes via PowerShell `Get-ChildItem -Path Cert:\<loc>\<store>`
  using the configured StoreLocation + StoreName (defaults
  LocalMachine\My).
- Confirms agent has Windows + the IIS module + the right ACLs.
- 4 new tests including default-store-path verification.

JavaKeystore (validate_only.go):
- Probes via `keytool -list -keystore <path> -storepass <pass>`
  using the configured KeystorePath / KeystorePassword and
  KeytoolPath (default "keytool").
- Confirms keystore exists, password is correct, JRE is on PATH.
- 4 new tests covering succeeds / fails / no-path-sentinel /
  nil-executor-sentinel.

K8s Secret (validate_only.go):
- Probes via K8sClient.GetSecret on the configured Namespace +
  SecretName. Returns nil on success or "not found" (the
  CreateSecret path on Deploy will handle it). Other errors
  (forbidden/unreachable) surface as wrapped.
- 4 new tests covering succeeds / RBAC-error wrapped /
  no-config-sentinel / nil-client-sentinel.

Smoke test connectorsAtPhase3 list shrunk from 7 to 3 entries
(ssh + wincertstore + javakeystore + k8ssecret removed). Only
caddy (file-mode) + envoy + traefik remain — those three
genuinely have no validate-with-target command available.

Race detector clean across all 13 connectors. golangci-lint
v2.11.4 clean.

Phase 10 next: DeployCounters + Prometheus exposer mirroring the
production-hardening-II OCSP counter pattern.
This commit is contained in:
shankar0123
2026-04-30 15:22:17 +00:00
parent 36d79cd1ff
commit 9f41b58b2f
9 changed files with 317 additions and 41 deletions
@@ -2,17 +2,33 @@ package wincertstore
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 wincertstore 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 9. Probes the Windows certificate store
// via Get-ChildItem against the configured store path. Confirms
// the agent has the right permissions + the store path is valid.
// V3-Pro can extend with temp-import + immediate-remove; V2 ships
// the permission probe.
func (c *Connector) ValidateOnly(ctx context.Context, request target.DeploymentRequest) error {
return target.ErrValidateOnlyNotSupported
if c.executor == nil {
return target.ErrValidateOnlyNotSupported
}
store := c.config.StoreName
if store == "" {
store = "My"
}
loc := c.config.StoreLocation
if loc == "" {
loc = "LocalMachine"
}
storePath := fmt.Sprintf(`Cert:\%s\%s`, loc, store)
script := fmt.Sprintf(`Get-ChildItem -Path %q | Select-Object -First 1 | Format-Table -HideTableHeaders -Property Thumbprint`, storePath)
out, err := c.executor.Execute(ctx, script)
if err != nil {
return fmt.Errorf("WinCertStore ValidateOnly: %w (output: %s)", err, out)
}
return nil
}
@@ -0,0 +1,62 @@
package wincertstore
import (
"context"
"errors"
"strings"
"testing"
"github.com/shankar0123/certctl/internal/connector/target"
)
type stubExec struct {
out string
err error
}
func (s *stubExec) Execute(_ context.Context, _ string) (string, error) { return s.out, s.err }
func TestWinCertStore_ValidateOnly_Succeeds(t *testing.T) {
c := NewWithExecutor(&Config{StoreName: "My", StoreLocation: "LocalMachine"}, nil, &stubExec{out: "ABC123"})
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); err != nil {
t.Errorf("got %v", err)
}
}
func TestWinCertStore_ValidateOnly_Fails(t *testing.T) {
c := NewWithExecutor(&Config{StoreName: "My"}, nil, &stubExec{err: errors.New("access denied")})
err := c.ValidateOnly(context.Background(), target.DeploymentRequest{})
if err == nil || !strings.Contains(err.Error(), "access denied") {
t.Errorf("got %v", err)
}
}
func TestWinCertStore_ValidateOnly_NilExec_Sentinel(t *testing.T) {
c := &Connector{config: &Config{}}
if err := c.ValidateOnly(context.Background(), target.DeploymentRequest{}); !errors.Is(err, target.ErrValidateOnlyNotSupported) {
t.Errorf("got %v", err)
}
}
func TestWinCertStore_ValidateOnly_DefaultStore_LocalMachineMy(t *testing.T) {
captured := ""
exec := capture{out: "x", capt: &captured}
c := NewWithExecutor(&Config{}, nil, &exec)
c.ValidateOnly(context.Background(), target.DeploymentRequest{})
// Backslash escaping in PowerShell-string + Go-string: the
// final script literal contains "Cert:\\LocalMachine\\My" once
// quoted via %q in fmt.Sprintf. Match against the doubled form.
if !strings.Contains(captured, `LocalMachine\\My`) && !strings.Contains(captured, `LocalMachine\My`) {
t.Errorf("default store path not in script: %q", captured)
}
}
type capture struct {
out string
capt *string
}
func (c capture) Execute(_ context.Context, script string) (string, error) {
*c.capt = script
return c.out, nil
}