// Copyright 2026 certctl LLC. All rights reserved. // SPDX-License-Identifier: BUSL-1.1 // Package k8ssecret implements a target.Connector for deploying certificates to Kubernetes Secrets. // This enables the "proxy agent" pattern — a certctl agent running in a Kubernetes cluster // (or outside with kubeconfig access) can deploy certificates as kubernetes.io/tls Secrets. // The connector is generic and doesn't depend on k8s.io packages — the K8sClient interface // abstracts all Kubernetes operations for maximum testability. package k8ssecret import ( "context" "encoding/json" "fmt" "log/slog" "os" "regexp" "time" "github.com/certctl-io/certctl/internal/connector/target" "github.com/certctl-io/certctl/internal/connector/target/certutil" ) // Config represents the Kubernetes Secrets deployment target configuration. // Supports in-cluster auth by default (ServiceAccount token auto-mounted) or // out-of-cluster auth via kubeconfig file. type Config struct { Namespace string `json:"namespace"` // Required. Kubernetes namespace. SecretName string `json:"secret_name"` // Required. Name of the kubernetes.io/tls Secret. Labels map[string]string `json:"labels,omitempty"` // Optional. Additional labels to add to the Secret. KubeconfigPath string `json:"kubeconfig_path,omitempty"` // Optional. Path to kubeconfig for out-of-cluster auth. } // SecretData represents the structure of a Kubernetes Secret. type SecretData struct { Name string Namespace string Type string // Always "kubernetes.io/tls" Data map[string][]byte // "tls.crt" and "tls.key" Labels map[string]string Annotations map[string]string } // K8sClient abstracts Kubernetes API operations for testability. // The real implementation will use k8s.io/client-go; tests inject a mock. type K8sClient interface { // GetSecret retrieves a Secret from the given namespace. // Returns an error if the Secret doesn't exist. GetSecret(ctx context.Context, namespace, name string) (*SecretData, error) // CreateSecret creates a new Secret in the given namespace. CreateSecret(ctx context.Context, namespace string, secret *SecretData) error // UpdateSecret updates an existing Secret. UpdateSecret(ctx context.Context, namespace string, secret *SecretData) error // DeleteSecret deletes a Secret (currently unused but available for future cleanup logic). DeleteSecret(ctx context.Context, namespace, name string) error } // Connector implements the target.Connector interface for Kubernetes Secrets. // This connector runs on the AGENT side and handles Secret deployment via the Kubernetes API. type Connector struct { config *Config client K8sClient logger *slog.Logger } // Validation regex patterns var ( // namespaceRegex validates Kubernetes namespace names per DNS-1123 (RFC 1123). // Namespace must start and end with alphanumeric, contain only lowercase alphanumeric and hyphens, max 63 chars. namespaceRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-]*[a-z0-9])?$`) // secretNameRegex validates Kubernetes Secret names per DNS-1123 subdomain. // Name must start and end with alphanumeric, contain only lowercase alphanumeric, hyphens, and dots, max 253 chars. secretNameRegex = regexp.MustCompile(`^[a-z0-9]([a-z0-9\-\.]*[a-z0-9])?$`) // labelKeyRegex validates Kubernetes label key format. // Optional prefix (domain), required name (alphanumeric, hyphens, underscores, dots). labelKeyRegex = regexp.MustCompile(`^([a-zA-Z0-9\-_\.]+/)?[a-zA-Z0-9\-_\.]+$`) ) // New creates a new Kubernetes Secrets target connector. // // SEC-003-K8S closure (Sprint 4, 2026-05-16). The production // k8s.io/client-go integration is not yet wired — realK8sClient's // CRUD methods at the bottom of this file are stubs that return // "real Kubernetes client not implemented." Pre-fix, New() would // happily return a working-looking Connector wrapping the stub // client; the operator would only see the failure when an actual // deploy fired against a registered target. Now New() refuses to // construct the connector unless CERTCTL_K8SSECRET_PREVIEW_ACK=true // is set, mirroring the SEC-H3 demo-mode ACK pattern. Tests that // need a working connector (with the in-memory mock client) call // NewWithClient — that path is unchanged. // // README qualifies the connector as preview at line 67; the // runtime guard here closes the gap where an operator could // register a k8ssecret target through the GUI / API and silently // land a non-functional deployment path in their fleet. func New(cfg *Config, logger *slog.Logger) (*Connector, error) { if cfg == nil { return nil, fmt.Errorf("Kubernetes config is required") } if os.Getenv("CERTCTL_K8SSECRET_PREVIEW_ACK") != "true" { return nil, fmt.Errorf( "k8ssecret connector is preview-only — the production client-go integration ships in a future bundle. " + "To register a k8ssecret target on this build, set CERTCTL_K8SSECRET_PREVIEW_ACK=true on the server " + "AND understand that the connector's CRUD calls will return \"real Kubernetes client not implemented\" " + "until the integration lands. See README.md `Deploy automatically` line and " + "docs/reference/deployment-model.md for the per-target guarantee matrix") } // Stub real K8s client — the actual implementation will use k8s.io/client-go // For now, return error to guide users to use the agent with proper kubeconfig client := &realK8sClient{ config: cfg, logger: logger, } return &Connector{ config: cfg, client: client, logger: logger, }, nil } // NewWithClient creates a new Kubernetes Secrets target connector with an injectable K8s client. // Used in tests to mock Kubernetes API operations. func NewWithClient(cfg *Config, client K8sClient, logger *slog.Logger) *Connector { return &Connector{ config: cfg, client: client, logger: logger, } } // ValidateConfig validates the Kubernetes Secrets deployment target configuration. func (c *Connector) ValidateConfig(ctx context.Context, rawConfig json.RawMessage) error { var cfg Config if err := json.Unmarshal(rawConfig, &cfg); err != nil { return fmt.Errorf("invalid Kubernetes config: %w", err) } // Required fields if cfg.Namespace == "" { return fmt.Errorf("Kubernetes namespace is required") } if cfg.SecretName == "" { return fmt.Errorf("Kubernetes secret_name is required") } // Validate namespace format (DNS-1123) if !namespaceRegex.MatchString(cfg.Namespace) || len(cfg.Namespace) > 63 { return fmt.Errorf("Kubernetes namespace must match DNS-1123 pattern and be max 63 characters, got %q", cfg.Namespace) } // Validate secret name format (DNS-1123 subdomain) if !secretNameRegex.MatchString(cfg.SecretName) || len(cfg.SecretName) > 253 { return fmt.Errorf("Kubernetes secret name must match DNS-1123 subdomain pattern and be max 253 characters, got %q", cfg.SecretName) } // Validate labels if present for key := range cfg.Labels { if !labelKeyRegex.MatchString(key) { return fmt.Errorf("Kubernetes label key contains invalid characters: %q", key) } } c.config = &cfg c.logger.Info("Kubernetes Secrets configuration validated", "namespace", cfg.Namespace, "secret_name", cfg.SecretName) return nil } // DeployCertificate deploys a certificate to a Kubernetes Secret. // // Steps: // 1. Build tls.crt (cert PEM + chain PEM) // 2. Require KeyPEM (private key) // 3. Try to get existing Secret — if found, update it; if not found, create it // 4. Set Secret type to kubernetes.io/tls with standard and custom labels // 5. Add deployment metadata annotations func (c *Connector) DeployCertificate(ctx context.Context, request target.DeploymentRequest) (*target.DeploymentResult, error) { if request.CertPEM == "" { return &target.DeploymentResult{ Success: false, Message: "certificate PEM is required", DeployedAt: time.Now(), }, fmt.Errorf("certificate PEM is required") } if request.KeyPEM == "" { return &target.DeploymentResult{ Success: false, Message: "private key PEM is required", DeployedAt: time.Now(), }, fmt.Errorf("private key PEM is required") } c.logger.Info("deploying certificate to Kubernetes Secret", "namespace", c.config.Namespace, "secret_name", c.config.SecretName) startTime := time.Now() // Build tls.crt = cert + chain (standard kubernetes.io/tls format) tlsCrt := request.CertPEM if request.ChainPEM != "" { tlsCrt += "\n" + request.ChainPEM } // Build Secret data secretData := &SecretData{ Name: c.config.SecretName, Namespace: c.config.Namespace, Type: "kubernetes.io/tls", Data: map[string][]byte{ "tls.crt": []byte(tlsCrt), "tls.key": []byte(request.KeyPEM), }, Labels: map[string]string{ "app.kubernetes.io/managed-by": "certctl", }, Annotations: map[string]string{ "certctl.io/deployed-at": startTime.Format(time.RFC3339), }, } // Add custom labels if c.config.Labels != nil { for k, v := range c.config.Labels { secretData.Labels[k] = v } } // Add certificate ID to annotations if available if certID, ok := request.Metadata["certificate_id"]; ok { secretData.Annotations["certctl.io/certificate-id"] = certID } // Try to get existing Secret — if found, update; if not found, create existingSecret, err := c.client.GetSecret(ctx, c.config.Namespace, c.config.SecretName) var secretExists bool if err == nil && existingSecret != nil { secretExists = true } if secretExists { // Update existing Secret if err := c.client.UpdateSecret(ctx, c.config.Namespace, secretData); err != nil { errMsg := fmt.Sprintf("failed to update Kubernetes Secret: %v", err) c.logger.Error("Secret update failed", "error", err) return &target.DeploymentResult{ Success: false, TargetAddress: fmt.Sprintf("%s/%s", c.config.Namespace, c.config.SecretName), Message: errMsg, DeployedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } c.logger.Info("Kubernetes Secret updated", "namespace", c.config.Namespace, "secret_name", c.config.SecretName) } else { // Create new Secret if err := c.client.CreateSecret(ctx, c.config.Namespace, secretData); err != nil { errMsg := fmt.Sprintf("failed to create Kubernetes Secret: %v", err) c.logger.Error("Secret creation failed", "error", err) return &target.DeploymentResult{ Success: false, TargetAddress: fmt.Sprintf("%s/%s", c.config.Namespace, c.config.SecretName), Message: errMsg, DeployedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } c.logger.Info("Kubernetes Secret created", "namespace", c.config.Namespace, "secret_name", c.config.SecretName) } deploymentDuration := time.Since(startTime) return &target.DeploymentResult{ Success: true, TargetAddress: fmt.Sprintf("%s/%s", c.config.Namespace, c.config.SecretName), DeploymentID: fmt.Sprintf("k8s-secret-%d", time.Now().Unix()), Message: fmt.Sprintf("Certificate deployed to Kubernetes Secret %s/%s", c.config.Namespace, c.config.SecretName), DeployedAt: time.Now(), Metadata: map[string]string{ "namespace": c.config.Namespace, "secret_name": c.config.SecretName, "duration_ms": fmt.Sprintf("%d", deploymentDuration.Milliseconds()), }, }, nil } // ValidateDeployment verifies that the deployed certificate Secret is valid and accessible. // // Steps: // 1. Get the Secret from the cluster // 2. Verify tls.crt is present and non-empty // 3. Verify tls.key is present and non-empty // 4. Parse the certificate and extract serial number // 5. Compare with request serial number func (c *Connector) ValidateDeployment(ctx context.Context, request target.ValidationRequest) (*target.ValidationResult, error) { c.logger.Info("validating Kubernetes Secret deployment", "certificate_id", request.CertificateID, "serial", request.Serial, "namespace", c.config.Namespace, "secret_name", c.config.SecretName) startTime := time.Now() targetAddr := fmt.Sprintf("%s/%s", c.config.Namespace, c.config.SecretName) // Get the Secret from the cluster secretData, err := c.client.GetSecret(ctx, c.config.Namespace, c.config.SecretName) if err != nil { errMsg := fmt.Sprintf("failed to get Kubernetes Secret: %v", err) c.logger.Error("validation failed", "error", err) return &target.ValidationResult{ Valid: false, Serial: request.Serial, TargetAddress: targetAddr, Message: errMsg, ValidatedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } if secretData == nil { errMsg := "Kubernetes Secret not found" c.logger.Error("validation failed", "error", errMsg) return &target.ValidationResult{ Valid: false, Serial: request.Serial, TargetAddress: targetAddr, Message: errMsg, ValidatedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } // Verify tls.crt exists and is non-empty tlsCrt, ok := secretData.Data["tls.crt"] if !ok || len(tlsCrt) == 0 { errMsg := "Secret tls.crt not found or empty" c.logger.Error("validation failed", "error", errMsg) return &target.ValidationResult{ Valid: false, Serial: request.Serial, TargetAddress: targetAddr, Message: errMsg, ValidatedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } // Verify tls.key exists and is non-empty tlsKey, ok := secretData.Data["tls.key"] if !ok || len(tlsKey) == 0 { errMsg := "Secret tls.key not found or empty" c.logger.Error("validation failed", "error", errMsg) return &target.ValidationResult{ Valid: false, Serial: request.Serial, TargetAddress: targetAddr, Message: errMsg, ValidatedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } // Parse the certificate and extract serial cert, err := certutil.ParseCertificatePEM(string(tlsCrt)) if err != nil { errMsg := fmt.Sprintf("failed to parse certificate in Secret: %v", err) c.logger.Error("validation failed", "error", err) return &target.ValidationResult{ Valid: false, Serial: request.Serial, TargetAddress: targetAddr, Message: errMsg, ValidatedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } // Get certificate serial number as hex string deployedSerial := cert.SerialNumber.Text(16) // Compare serials if deployedSerial != request.Serial { errMsg := fmt.Sprintf("serial mismatch: expected %s, got %s", request.Serial, deployedSerial) c.logger.Error("validation failed", "error", errMsg) return &target.ValidationResult{ Valid: false, Serial: request.Serial, TargetAddress: targetAddr, Message: errMsg, ValidatedAt: time.Now(), }, fmt.Errorf("%s", errMsg) } validationDuration := time.Since(startTime) c.logger.Info("Kubernetes Secret deployment validated successfully", "duration", validationDuration.String(), "namespace", c.config.Namespace, "secret_name", c.config.SecretName) return &target.ValidationResult{ Valid: true, Serial: deployedSerial, TargetAddress: targetAddr, Message: fmt.Sprintf("Certificate valid in Kubernetes Secret %s/%s", c.config.Namespace, c.config.SecretName), ValidatedAt: time.Now(), Metadata: map[string]string{ "namespace": c.config.Namespace, "secret_name": c.config.SecretName, "duration_ms": fmt.Sprintf("%d", validationDuration.Milliseconds()), }, }, nil } // realK8sClient is a stub placeholder for the real k8s.io/client-go implementation. // The actual implementation will be added when the k8s.io dependencies are wired in. type realK8sClient struct { config *Config logger *slog.Logger } // GetSecret stub implementation. func (r *realK8sClient) GetSecret(ctx context.Context, namespace, name string) (*SecretData, error) { return nil, fmt.Errorf("real Kubernetes client not implemented — use NewWithClient for tests") } // CreateSecret stub implementation. func (r *realK8sClient) CreateSecret(ctx context.Context, namespace string, secret *SecretData) error { return fmt.Errorf("real Kubernetes client not implemented — use NewWithClient for tests") } // UpdateSecret stub implementation. func (r *realK8sClient) UpdateSecret(ctx context.Context, namespace string, secret *SecretData) error { return fmt.Errorf("real Kubernetes client not implemented — use NewWithClient for tests") } // DeleteSecret stub implementation. func (r *realK8sClient) DeleteSecret(ctx context.Context, namespace, name string) error { return fmt.Errorf("real Kubernetes client not implemented — use NewWithClient for tests") }