diff --git a/cmd/apply.go b/cmd/apply.go index 06cacf5..cef68b8 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -2,15 +2,19 @@ package cmd import ( "context" + "encoding/base64" "fmt" "os" "os/exec" "path/filepath" "strings" + "k8s.io/client-go/kubernetes" + "github.com/confidential-devhub/cococtl/pkg/cluster" "github.com/confidential-devhub/cococtl/pkg/config" "github.com/confidential-devhub/cococtl/pkg/initdata" + "github.com/confidential-devhub/cococtl/pkg/k8s" "github.com/confidential-devhub/cococtl/pkg/manifest" "github.com/confidential-devhub/cococtl/pkg/secrets" "github.com/confidential-devhub/cococtl/pkg/sidecar" @@ -62,6 +66,7 @@ var ( sidecarSANDNS string sidecarSkipAutoSANs bool sidecarPortForward int + namespaceFlag string ) func init() { @@ -81,9 +86,27 @@ func init() { applyCmd.Flags().StringVar(&sidecarSANDNS, "sidecar-san-dns", "", "Comma-separated list of DNS names for sidecar server certificate SANs") applyCmd.Flags().BoolVar(&sidecarSkipAutoSANs, "sidecar-skip-auto-sans", false, "Skip auto-detection of SANs (node IPs and service DNS)") applyCmd.Flags().IntVar(&sidecarPortForward, "sidecar-port-forward", 0, "Port to forward from primary container (requires --sidecar)") + applyCmd.Flags().StringVarP(&namespaceFlag, "namespace", "n", "", "Namespace for operations (overrides manifest and kubeconfig)") } -func runApply(_ *cobra.Command, _ []string) error { +func runApply(cmd *cobra.Command, _ []string) error { + var ctx context.Context + + if skipApply { + // Skip-apply mode: kubectl is optional, only needed for informational message + _, err := exec.LookPath("kubectl") + if err != nil { + fmt.Println(" \u2139 kubectl not found (--skip-apply mode: cluster write operations unavailable)") + } + ctx = cmd.Context() + } else { + // Normal mode: kubectl required, fail fast + ctx = detectKubectl(cmd.Context()) + if err := requireKubectl(ctx, "apply"); err != nil { + return err + } + } + // Validate required flags (manual validation to keep all flags visible in shell completion) if manifestFile == "" { return fmt.Errorf("required flag(s) \"filename\" not set") @@ -179,7 +202,13 @@ func runApply(_ *cobra.Command, _ []string) error { } // Transform manifest - if err := transformManifest(m, cfg, rc, skipApply); err != nil { + // Resolve namespace before transformation + resolvedNamespace, err := resolveNamespace(namespaceFlag, m.GetNamespace()) + if err != nil { + return err + } + + if err := transformManifest(ctx, m, cfg, rc, skipApply, resolvedNamespace); err != nil { return fmt.Errorf("failed to transform manifest: %w", err) } @@ -194,17 +223,9 @@ func runApply(_ *cobra.Command, _ []string) error { var servicePath string if enableSidecar || cfg.Sidecar.Enabled { appName := m.GetName() - namespace := m.GetNamespace() - if namespace == "" { - var err error - namespace, err = getCurrentNamespace() - if err != nil { - return fmt.Errorf("failed to get current namespace: %w", err) - } - } fmt.Println("Generating Service manifest for sidecar...") - serviceManifest, err := sidecar.GenerateService(m, cfg, appName, namespace) + serviceManifest, err := sidecar.GenerateService(m, cfg, appName, resolvedNamespace) if err != nil { return fmt.Errorf("failed to generate sidecar Service: %w", err) } @@ -229,14 +250,14 @@ func runApply(_ *cobra.Command, _ []string) error { // Apply manifests if not skipped if !skipApply { fmt.Println("Applying manifest with kubectl...") - if err := applyWithKubectl(backupPath); err != nil { + if err := applyWithKubectl(ctx, backupPath); err != nil { return fmt.Errorf("failed to apply manifest: %w", err) } // Apply Service manifest if generated if servicePath != "" { fmt.Println("Applying sidecar Service manifest with kubectl...") - if err := applyWithKubectl(servicePath); err != nil { + if err := applyWithKubectl(ctx, servicePath); err != nil { return fmt.Errorf("failed to apply sidecar Service: %w", err) } } @@ -249,7 +270,15 @@ func runApply(_ *cobra.Command, _ []string) error { return nil } -func transformManifest(m *manifest.Manifest, cfg *config.CocoConfig, rc string, skipApply bool) error { +func transformManifest(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, rc string, skipApply bool, resolvedNamespace string) error { + // Create Kubernetes client once for all operations that need cluster access. + // Client creation is deferred-error: handlers that need it check clientErr. + client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + var clientset kubernetes.Interface + if clientErr == nil { + clientset = client.Clientset + } + // 1. Set RuntimeClass fmt.Printf(" - Setting runtimeClassName: %s\n", rc) if err := m.SetRuntimeClass(rc); err != nil { @@ -258,7 +287,7 @@ func transformManifest(m *manifest.Manifest, cfg *config.CocoConfig, rc string, // 2. Convert secrets if enabled if convertSecrets { - if err := handleSecrets(m, cfg, skipApply); err != nil { + if err := handleSecrets(ctx, m, cfg, skipApply, clientset, clientErr); err != nil { return fmt.Errorf("failed to convert secrets: %w", err) } } else { @@ -275,7 +304,7 @@ func transformManifest(m *manifest.Manifest, cfg *config.CocoConfig, rc string, var imagePullSecretsInfo []initdata.ImagePullSecretInfo if convertSecrets { var err error - imagePullSecretsInfo, err = handleImagePullSecrets(m, cfg, skipApply) + imagePullSecretsInfo, err = handleImagePullSecrets(ctx, m, cfg, skipApply, clientset, clientErr) if err != nil { return fmt.Errorf("failed to handle imagePullSecrets: %w", err) } @@ -310,22 +339,14 @@ func transformManifest(m *manifest.Manifest, cfg *config.CocoConfig, rc string, if appName == "" { return fmt.Errorf("manifest must have metadata.name for sidecar injection") } - namespace := m.GetNamespace() - if namespace == "" { - // Use current kubectl namespace instead of hardcoding "default" - var err error - namespace, err = getCurrentNamespace() - if err != nil { - return fmt.Errorf("failed to get current namespace: %w", err) - } - } + namespace := resolvedNamespace // Get Trustee namespace from config (where KBS is deployed) trusteeNamespace := cfg.GetTrusteeNamespace() - // Generate and upload server certificate + // Generate and upload server certificate (or save to file in skip-apply mode) fmt.Println(" - Setting up sidecar server certificate") - if err := handleSidecarServerCert(appName, namespace, trusteeNamespace); err != nil { + if err := handleSidecarServerCert(ctx, appName, namespace, trusteeNamespace, skipApply, manifestFile, clientset, clientErr); err != nil { return fmt.Errorf("failed to setup sidecar server certificate: %w", err) } @@ -400,7 +421,7 @@ func handleInitContainer(m *manifest.Manifest, cfg *config.CocoConfig) error { return nil } -func handleSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) error { +func handleSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool, clientset kubernetes.Interface, clientErr error) error { // 1. Detect all secret references allSecretRefs, err := secrets.DetectSecrets(m.GetData()) if err != nil { @@ -429,27 +450,60 @@ func handleSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) fmt.Printf(" - Found %d K8s secret(s) to convert\n", len(secretRefs)) - // 2. Inspect K8s secrets - inspectedKeys, err := secrets.InspectSecrets(secretRefs) - if err != nil { - return fmt.Errorf("failed to inspect secrets via kubectl: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the secrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) + // 2. Split refs by lookup requirement + var offlineRefs, clusterRefs []secrets.SecretReference + for _, ref := range secretRefs { + if ref.NeedsLookup { + clusterRefs = append(clusterRefs, ref) + } else { + offlineRefs = append(offlineRefs, ref) + } } - // 3. Convert to sealed secrets - sealedSecrets, err := secrets.ConvertSecrets(secretRefs, inspectedKeys) - if err != nil { - return err + // 3. Process offline refs (always works - uses only manifest metadata, no cluster needed) + var allSealedSecrets []*secrets.SealedSecretData + if len(offlineRefs) > 0 { + fmt.Printf(" - Resolving %d secret(s) offline (explicit keys in manifest)\n", len(offlineRefs)) + offlineSecrets, err := secrets.InspectSecrets(ctx, nil, offlineRefs) + if err != nil { + return fmt.Errorf("failed to resolve offline secrets: %w", err) + } + offlineKeys := secrets.ToSecretKeys(offlineSecrets) + offlineSealed, err := secrets.ConvertSecrets(offlineRefs, offlineKeys) + if err != nil { + return err + } + allSealedSecrets = append(allSealedSecrets, offlineSealed...) + } + + // 4. Process cluster refs (needs cluster connection for key enumeration) + if len(clusterRefs) > 0 { + fmt.Printf(" - %d secret(s) require cluster access for key enumeration\n", len(clusterRefs)) + if clientErr != nil { + return secretsClusterUnreachableError(clusterRefs, clientErr) + } + + clusterSecrets, err := secrets.InspectSecrets(ctx, clientset, clusterRefs) + if err != nil { + return secretsClusterQueryError(clusterRefs, err) + } + clusterKeys := secrets.ToSecretKeys(clusterSecrets) + clusterSealed, err := secrets.ConvertSecrets(clusterRefs, clusterKeys) + if err != nil { + return err + } + allSealedSecrets = append(allSealedSecrets, clusterSealed...) } - fmt.Printf(" - Generated %d sealed secret(s)\n", len(sealedSecrets)) + fmt.Printf(" - Generated %d sealed secret(s)\n", len(allSealedSecrets)) - // 4. Create or save sealed secrets based on skipApply flag + // 5. Create or save sealed secrets based on skipApply flag var sealedSecretNames map[string]string if skipApply { // Generate YAML and save to file instead of creating in cluster fmt.Println(" - Generating sealed secret manifests") var yamlContent string - sealedSecretNames, yamlContent, err = secrets.GenerateSealedSecretsYAML(sealedSecrets) + sealedSecretNames, yamlContent, err = secrets.GenerateSealedSecretsYAML(allSealedSecrets) if err != nil { return fmt.Errorf("failed to generate sealed secret YAML: %w", err) } @@ -470,7 +524,7 @@ func handleSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) } else { // Create sealed secrets in cluster fmt.Println(" - Creating K8s sealed secrets in cluster") - sealedSecretNames, err = secrets.CreateSealedSecrets(sealedSecrets) + sealedSecretNames, err = secrets.CreateSealedSecrets(allSealedSecrets) if err != nil { return fmt.Errorf("failed to create sealed secrets: %w", err) } @@ -512,12 +566,12 @@ func handleSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) baseName := strings.TrimSuffix(manifestFile, ext) trusteeConfigPath := baseName + "-trustee-secrets.yaml" - if err := secrets.GenerateTrusteeConfig(sealedSecrets, trusteeConfigPath); err != nil { + if err := secrets.GenerateTrusteeConfig(allSealedSecrets, trusteeConfigPath); err != nil { return fmt.Errorf("failed to generate Trustee config: %w", err) } // 8. Print instructions - secrets.PrintTrusteeInstructions(sealedSecrets, trusteeConfigPath, autoUploadSuccess) + secrets.PrintTrusteeInstructions(allSealedSecrets, trusteeConfigPath, autoUploadSuccess) return nil } @@ -535,8 +589,7 @@ func updateManifestSecretNames(m *manifest.Manifest, sealedSecretNames map[strin return nil } -func applyWithKubectl(manifestPath string) error { - ctx := context.Background() +func applyWithKubectl(ctx context.Context, manifestPath string) error { cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", manifestPath) cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr @@ -592,7 +645,7 @@ func addSecretsToTrustee(secretRefs []secrets.SecretReference, trusteeNamespace secretNamespace := ref.Namespace if secretNamespace == "" { var err error - secretNamespace, err = getCurrentNamespace() + secretNamespace, err = k8s.GetCurrentNamespace() if err != nil { return fmt.Errorf("failed to get current namespace for secret %s: %w", ref.Name, err) } @@ -616,9 +669,10 @@ func addK8sSecretToTrustee(trusteeNamespace, secretName, secretNamespace string) // handleImagePullSecrets processes imagePullSecrets from the manifest // It detects, uploads to KBS, and prepares them for initdata // Falls back to default service account if no imagePullSecrets in manifest -func handleImagePullSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool) ([]initdata.ImagePullSecretInfo, error) { +func handleImagePullSecrets(ctx context.Context, m *manifest.Manifest, cfg *config.CocoConfig, skipApply bool, clientset kubernetes.Interface, clientErr error) ([]initdata.ImagePullSecretInfo, error) { // Detect imagePullSecrets in manifest, with fallback to default service account - imagePullSecretRefs, err := secrets.DetectImagePullSecretsWithServiceAccount(m.GetData()) + // Pass clientset for SA fallback (nil if client creation failed — fallback is skipped) + imagePullSecretRefs, err := secrets.DetectImagePullSecretsWithServiceAccount(ctx, clientset, m.GetData()) if err != nil { return nil, err } @@ -637,12 +691,30 @@ func handleImagePullSecrets(m *manifest.Manifest, cfg *config.CocoConfig, skipAp imagePullSecretRefs = imagePullSecretRefs[:1] } + // Ensure client is available for secret inspection + if clientErr != nil { + if skipApply { + // In skip-apply mode, imagePullSecrets are optional since we're not applying + fmt.Printf(" - Skipping imagePullSecret inspection (cluster not reachable in offline mode)\n") + fmt.Printf(" To include imagePullSecrets, ensure cluster is reachable or specify them manually\n") + return nil, nil + } + return nil, fmt.Errorf("failed to create Kubernetes client: %w\n\nTo fix:\n 1. Ensure kubeconfig is properly configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", clientErr) + } + // Inspect K8s secrets to get keys - inspectedKeys, err := secrets.InspectSecrets(imagePullSecretRefs) + inspectedSecrets, err := secrets.InspectSecrets(ctx, clientset, imagePullSecretRefs) if err != nil { - return nil, fmt.Errorf("failed to inspect imagePullSecrets via kubectl: %w\n\nTo fix:\n 1. Ensure kubectl is configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) + if skipApply { + fmt.Printf(" - Skipping imagePullSecret inspection (cluster query failed in offline mode)\n") + return nil, nil + } + return nil, fmt.Errorf("failed to inspect imagePullSecrets: %w\n\nTo fix:\n 1. Ensure kubeconfig is properly configured and can access the cluster\n 2. Create the imagePullSecrets in the cluster first, then run this command\n 3. Or disable secret conversion with --convert-secrets=false", err) } + // Convert to SecretKeys format + inspectedKeys := secrets.ToSecretKeys(inspectedSecrets) + // Build ImagePullSecretInfo for initdata var imagePullSecretsInfo []initdata.ImagePullSecretInfo for _, ref := range imagePullSecretRefs { @@ -697,7 +769,7 @@ func addImagePullSecretsToTrustee(secretRefs []secrets.SecretReference, trusteeN secretNamespace := ref.Namespace if secretNamespace == "" { var err error - secretNamespace, err = getCurrentNamespace() + secretNamespace, err = k8s.GetCurrentNamespace() if err != nil { return fmt.Errorf("failed to get current namespace for imagePullSecret %s: %w", ref.Name, err) } @@ -720,12 +792,15 @@ func addImagePullSecretToTrustee(trusteeNamespace, secretName, secretNamespace s // handleSidecarServerCert generates and uploads a server certificate for the sidecar. // It loads the Client CA, auto-detects or uses provided SANs, generates the server cert, -// and uploads it to Trustee KBS at per-app paths. +// and either uploads it to Trustee KBS or saves it to a file (when skipApply is true). // Parameters: +// - ctx: context for Kubernetes API calls (for proper signal handling) // - appName: name of the application (from manifest metadata.name) // - namespace: namespace for certificate KBS path (from manifest metadata.namespace) // - trusteeNamespace: namespace where Trustee KBS is deployed -func handleSidecarServerCert(appName, namespace, trusteeNamespace string) error { +// - skipApply: when true, save certs to file instead of uploading to Trustee +// - manifestPath: path to the original manifest file (for cert file naming) +func handleSidecarServerCert(ctx context.Context, appName, namespace, trusteeNamespace string, skipApply bool, manifestPath string, clientset kubernetes.Interface, clientErr error) error { // Load Client CA from ~/.kube/coco-sidecar/ homeDir, err := os.UserHomeDir() if err != nil { @@ -769,11 +844,15 @@ func handleSidecarServerCert(appName, namespace, trusteeNamespace string) error // Auto-detect SANs unless skipped if !sidecarSkipAutoSANs { // Auto-detect node IPs - nodeIPs, err := cluster.GetNodeIPs() - if err != nil { - fmt.Printf("Warning: failed to auto-detect node IPs: %v\n", err) + if clientErr != nil { + fmt.Printf("Warning: failed to create Kubernetes client for node IP detection: %v\n", clientErr) } else { - sans.IPAddresses = append(sans.IPAddresses, nodeIPs...) + nodeIPs, err := cluster.GetNodeIPs(ctx, clientset) + if err != nil { + fmt.Printf("Warning: failed to auto-detect node IPs: %v\n", err) + } else { + sans.IPAddresses = append(sans.IPAddresses, nodeIPs...) + } } // Add service DNS names (format: ..svc.cluster.local) @@ -799,20 +878,153 @@ func handleSidecarServerCert(appName, namespace, trusteeNamespace string) error return fmt.Errorf("failed to generate server certificate: %w", err) } - // Upload to Trustee KBS (in the namespace where Trustee is deployed) - fmt.Printf(" - Uploading server certificate to Trustee KBS (namespace: %s)...\n", trusteeNamespace) + // Build KBS resource paths for certificate storage serverCertPath := namespace + "/sidecar-tls-" + appName + "/server-cert" serverKeyPath := namespace + "/sidecar-tls-" + appName + "/server-key" - resources := map[string][]byte{ - serverCertPath: serverCert.CertPEM, - serverKeyPath: serverCert.KeyPEM, + if !skipApply { + // Normal mode: upload to Trustee KBS + if clientErr != nil { + return fmt.Errorf("failed to create Kubernetes client for certificate upload: %w", clientErr) + } + fmt.Printf(" - Uploading server certificate to Trustee KBS (namespace: %s)...\n", trusteeNamespace) + resources := map[string][]byte{ + serverCertPath: serverCert.CertPEM, + serverKeyPath: serverCert.KeyPEM, + } + if err := trustee.UploadResources(ctx, clientset, trusteeNamespace, resources); err != nil { + return fmt.Errorf("failed to upload server certificate to KBS: %w", err) + } + fmt.Printf(" - Server certificate uploaded to kbs:///%s and kbs:///%s\n", serverCertPath, serverKeyPath) + } else { + // Skip-apply mode: save certs to file instead of uploading + certFilePath, err := saveSidecarCertsToYAML(manifestPath, serverCert, appName, namespace) + if err != nil { + return err + } + fmt.Printf(" - Sidecar certificate saved to: %s (Trustee upload skipped)\n", certFilePath) + fmt.Printf(" - KBS resource paths: kbs:///%s and kbs:///%s\n", serverCertPath, serverKeyPath) } - if err := trustee.UploadResources(trusteeNamespace, resources); err != nil { - return fmt.Errorf("failed to upload server certificate to KBS: %w", err) + return nil +} + +// saveSidecarCertsToYAML saves sidecar server certificate and key to a YAML file +// as a Kubernetes TLS Secret. The file is saved alongside the manifest with the +// naming convention {basename}-sidecar-certs.yaml, matching the existing pattern +// used by other generated files (e.g., {basename}-sealed-secrets.yaml). +func saveSidecarCertsToYAML(manifestPath string, serverCert *certs.CertificateSet, appName, namespace string) (string, error) { + // Build output path following existing naming convention + ext := filepath.Ext(manifestPath) + if ext == "" { + ext = ".yaml" + } + baseName := strings.TrimSuffix(manifestPath, ext) + certFilePath := baseName + "-sidecar-certs.yaml" + + // Build Kubernetes Secret structure (kubernetes.io/tls) + secretData := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": "sidecar-tls-" + appName, + "namespace": namespace, + }, + "type": "kubernetes.io/tls", + "data": map[string]string{ + "tls.crt": base64.StdEncoding.EncodeToString(serverCert.CertPEM), + "tls.key": base64.StdEncoding.EncodeToString(serverCert.KeyPEM), + }, + } + + yamlData, err := yaml.Marshal(secretData) + if err != nil { + return "", fmt.Errorf("failed to marshal sidecar certificate Secret: %w", err) } - fmt.Printf(" - Server certificate uploaded to kbs:///%s and kbs:///%s\n", serverCertPath, serverKeyPath) - return nil + if err := os.WriteFile(certFilePath, yamlData, 0600); err != nil { + return "", fmt.Errorf("failed to write sidecar certificate file: %w", err) + } + + return certFilePath, nil +} + +// resolveNamespace determines the namespace using kubectl precedence order: +// 1. --namespace flag (highest priority) +// 2. manifest metadata.namespace field +// 3. kubeconfig context namespace (offline via k8s.GetCurrentNamespace) +// 4. "default" (final fallback) +// +// Returns an error if --namespace flag and manifest namespace both exist but differ, +// matching kubectl behavior. +func resolveNamespace(flagNamespace, manifestNamespace string) (string, error) { + // Validation: flag and manifest namespace must not conflict (kubectl behavior) + if flagNamespace != "" && manifestNamespace != "" && flagNamespace != manifestNamespace { + return "", fmt.Errorf("the namespace from the provided object %q does not match the namespace %q. You must pass '--namespace=%s' to perform this operation", + manifestNamespace, flagNamespace, manifestNamespace) + } + + // 1. --namespace flag (highest priority) + if flagNamespace != "" { + return flagNamespace, nil + } + + // 2. manifest metadata.namespace + if manifestNamespace != "" { + return manifestNamespace, nil + } + + // 3. kubeconfig context namespace (offline read) + kubeconfigNs, err := k8s.GetCurrentNamespace() + if err == nil && kubeconfigNs != "" { + return kubeconfigNs, nil + } + + // 4. "default" (final fallback) + return "default", nil +} + +// secretsClusterUnreachableError creates an actionable error when the cluster +// can't be reached and some secrets require key enumeration. +func secretsClusterUnreachableError(clusterRefs []secrets.SecretReference, clientErr error) error { + names := make([]string, len(clusterRefs)) + for i, ref := range clusterRefs { + usageTypes := make([]string, 0) + seen := make(map[string]bool) + for _, u := range ref.Usages { + if !seen[u.Type] { + usageTypes = append(usageTypes, u.Type) + seen[u.Type] = true + } + } + names[i] = fmt.Sprintf(" - %s (used in %s)", ref.Name, strings.Join(usageTypes, ", ")) + } + + return fmt.Errorf("cannot determine keys for %d secret(s) in offline mode:\n%s\n\n"+ + "These secrets use envFrom, volume mounts without explicit items, or other patterns\n"+ + "that require querying the cluster to enumerate their keys.\n\n"+ + "To fix:\n"+ + " 1. Use explicit key references in your manifest instead of envFrom or whole-secret mounts\n"+ + " Example: env.valueFrom.secretKeyRef with explicit 'key' field\n"+ + " 2. Ensure cluster is reachable (check kubeconfig and network connectivity)\n"+ + " 3. Or disable secret conversion with --convert-secrets=false\n\n"+ + "Underlying error: %v", + len(clusterRefs), strings.Join(names, "\n"), clientErr) +} + +// secretsClusterQueryError creates an actionable error when the cluster is reachable +// but the secret query fails (e.g., secret doesn't exist, permission denied). +func secretsClusterQueryError(clusterRefs []secrets.SecretReference, queryErr error) error { + names := make([]string, len(clusterRefs)) + for i, ref := range clusterRefs { + names[i] = ref.Name + } + + return fmt.Errorf("failed to inspect secrets requiring cluster lookup (%s): %w\n\n"+ + "To fix:\n"+ + " 1. Ensure the secrets exist in the cluster\n"+ + " 2. Verify your kubeconfig has read access to secrets\n"+ + " 3. Or use explicit key references to avoid cluster lookups\n"+ + " 4. Or disable secret conversion with --convert-secrets=false", + strings.Join(names, ", "), queryErr) } diff --git a/cmd/apply_skip_test.go b/cmd/apply_skip_test.go new file mode 100644 index 0000000..40d2d4a --- /dev/null +++ b/cmd/apply_skip_test.go @@ -0,0 +1,366 @@ +package cmd + +import ( + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/confidential-devhub/cococtl/pkg/k8s" + "github.com/confidential-devhub/cococtl/pkg/secrets" + "github.com/confidential-devhub/cococtl/pkg/sidecar/certs" + "gopkg.in/yaml.v3" +) + +// TestSkipApply_NamespaceResolution_Flag tests that the --namespace flag takes +// highest priority in resolveNamespace() and validates conflict detection. +func TestSkipApply_NamespaceResolution_Flag(t *testing.T) { + t.Run("flag value returned when only flag is set", func(t *testing.T) { + ns, err := resolveNamespace("my-namespace", "") + if err != nil { + t.Fatalf("resolveNamespace returned unexpected error: %v", err) + } + if ns != "my-namespace" { + t.Errorf("resolveNamespace() = %q, want %q", ns, "my-namespace") + } + }) + + t.Run("flag value returned when flag matches manifest", func(t *testing.T) { + ns, err := resolveNamespace("production", "production") + if err != nil { + t.Fatalf("resolveNamespace returned unexpected error: %v", err) + } + if ns != "production" { + t.Errorf("resolveNamespace() = %q, want %q", ns, "production") + } + }) + + t.Run("error when flag and manifest namespace conflict", func(t *testing.T) { + _, err := resolveNamespace("flag-ns", "manifest-ns") + if err == nil { + t.Fatal("resolveNamespace should return error when flag and manifest namespace conflict") + } + if !strings.Contains(err.Error(), "does not match") { + t.Errorf("error message should contain 'does not match', got: %v", err) + } + }) + + t.Run("error message includes both namespaces", func(t *testing.T) { + _, err := resolveNamespace("alpha", "beta") + if err == nil { + t.Fatal("resolveNamespace should return error for conflicting namespaces") + } + errMsg := err.Error() + if !strings.Contains(errMsg, "beta") || !strings.Contains(errMsg, "alpha") { + t.Errorf("error message should contain both namespace values, got: %v", err) + } + }) +} + +// TestSkipApply_NamespaceResolution_ManifestOnly tests that the manifest +// metadata.namespace is used when no flag is provided. +func TestSkipApply_NamespaceResolution_ManifestOnly(t *testing.T) { + ns, err := resolveNamespace("", "manifest-namespace") + if err != nil { + t.Fatalf("resolveNamespace returned unexpected error: %v", err) + } + if ns != "manifest-namespace" { + t.Errorf("resolveNamespace() = %q, want %q", ns, "manifest-namespace") + } +} + +// TestSkipApply_NamespaceResolution_KubeconfigFallback tests that the namespace +// from kubeconfig context is used when no flag and no manifest namespace exist. +func TestSkipApply_NamespaceResolution_KubeconfigFallback(t *testing.T) { + tmpDir := t.TempDir() + + // Create a minimal valid kubeconfig with a namespace set in the context + kubeconfigContent := `apiVersion: v1 +kind: Config +clusters: +- cluster: + server: https://localhost:6443 + name: test-cluster +contexts: +- context: + cluster: test-cluster + namespace: test-from-kubeconfig + name: test-context +current-context: test-context +users: +- name: test-user +` + kubeconfigPath := filepath.Join(tmpDir, "kubeconfig") + if err := os.WriteFile(kubeconfigPath, []byte(kubeconfigContent), 0600); err != nil { + t.Fatalf("Failed to write test kubeconfig: %v", err) + } + + // Set KUBECONFIG env (t.Setenv restores original value on cleanup) + t.Setenv("KUBECONFIG", kubeconfigPath) + + // Verify k8s.GetCurrentNamespace() returns the kubeconfig namespace + kubeconfigNs, err := k8s.GetCurrentNamespace() + if err != nil { + t.Fatalf("k8s.GetCurrentNamespace() returned error: %v", err) + } + if kubeconfigNs != "test-from-kubeconfig" { + t.Errorf("k8s.GetCurrentNamespace() = %q, want %q", kubeconfigNs, "test-from-kubeconfig") + } + + // Verify resolveNamespace falls back to kubeconfig when flag and manifest are empty + ns, err := resolveNamespace("", "") + if err != nil { + t.Fatalf("resolveNamespace returned unexpected error: %v", err) + } + if ns != "test-from-kubeconfig" { + t.Errorf("resolveNamespace() = %q, want %q (expected kubeconfig fallback)", ns, "test-from-kubeconfig") + } +} + +// TestSkipApply_NamespaceResolution_DefaultFallback tests that "default" is +// returned when no flag, no manifest namespace, and no kubeconfig namespace exist. +func TestSkipApply_NamespaceResolution_DefaultFallback(t *testing.T) { + // Point KUBECONFIG to a non-existent path so kubeconfig reading fails + // (t.Setenv restores original value on cleanup) + t.Setenv("KUBECONFIG", filepath.Join(t.TempDir(), "nonexistent-kubeconfig")) + + ns, err := resolveNamespace("", "") + if err != nil { + t.Fatalf("resolveNamespace returned unexpected error: %v", err) + } + if ns != "default" { + t.Errorf("resolveNamespace() = %q, want %q (expected default fallback)", ns, "default") + } +} + +// TestSkipApply_SidecarCertFileSaving tests that saveSidecarCertsToYAML creates +// a properly formatted Kubernetes TLS Secret YAML file with correct permissions. +func TestSkipApply_SidecarCertFileSaving(t *testing.T) { + // Generate a CA for signing the server cert + ca, err := certs.GenerateCA("test-ca") + if err != nil { + t.Fatalf("Failed to generate CA: %v", err) + } + + // Generate a server certificate + sans := certs.SANs{ + DNSNames: []string{"test-app.test-ns.svc.cluster.local"}, + IPAddresses: []string{"10.0.0.1"}, + } + serverCert, err := certs.GenerateServerCert(ca.CertPEM, ca.KeyPEM, "test-app", sans) + if err != nil { + t.Fatalf("Failed to generate server cert: %v", err) + } + + // Create a temp manifest path + tmpDir := t.TempDir() + manifestPath := filepath.Join(tmpDir, "app.yaml") + // Create a placeholder manifest file (saveSidecarCertsToYAML uses the path for naming) + if err := os.WriteFile(manifestPath, []byte("placeholder"), 0600); err != nil { + t.Fatalf("Failed to create placeholder manifest: %v", err) + } + + // Call saveSidecarCertsToYAML + certFilePath, err := saveSidecarCertsToYAML(manifestPath, serverCert, "test-app", "test-ns") + if err != nil { + t.Fatalf("saveSidecarCertsToYAML returned error: %v", err) + } + + // Verify the cert file path follows naming convention + expectedPath := filepath.Join(tmpDir, "app-sidecar-certs.yaml") + if certFilePath != expectedPath { + t.Errorf("cert file path = %q, want %q", certFilePath, expectedPath) + } + + // Verify file exists + fileInfo, err := os.Stat(certFilePath) + if err != nil { + t.Fatalf("cert file does not exist: %v", err) + } + + // Verify file permissions are 0600 + perm := fileInfo.Mode().Perm() + if perm != 0600 { + t.Errorf("cert file permissions = %o, want %o", perm, 0600) + } + + // Read and parse the YAML file + data, err := os.ReadFile(certFilePath) + if err != nil { + t.Fatalf("Failed to read cert file: %v", err) + } + + var secret map[string]interface{} + if err := yaml.Unmarshal(data, &secret); err != nil { + t.Fatalf("Failed to parse cert file YAML: %v", err) + } + + // Verify apiVersion + if apiVersion, ok := secret["apiVersion"].(string); !ok || apiVersion != "v1" { + t.Errorf("apiVersion = %v, want %q", secret["apiVersion"], "v1") + } + + // Verify kind + if kind, ok := secret["kind"].(string); !ok || kind != "Secret" { + t.Errorf("kind = %v, want %q", secret["kind"], "Secret") + } + + // Verify type + if secretType, ok := secret["type"].(string); !ok || secretType != "kubernetes.io/tls" { + t.Errorf("type = %v, want %q", secret["type"], "kubernetes.io/tls") + } + + // Verify metadata + metadata, ok := secret["metadata"].(map[string]interface{}) + if !ok { + t.Fatalf("metadata is not a map: %T", secret["metadata"]) + } + if name, ok := metadata["name"].(string); !ok || name != "sidecar-tls-test-app" { + t.Errorf("metadata.name = %v, want %q", metadata["name"], "sidecar-tls-test-app") + } + if namespace, ok := metadata["namespace"].(string); !ok || namespace != "test-ns" { + t.Errorf("metadata.namespace = %v, want %q", metadata["namespace"], "test-ns") + } + + // Verify data fields contain base64-encoded content + secretData, ok := secret["data"].(map[string]interface{}) + if !ok { + t.Fatalf("data is not a map: %T", secret["data"]) + } + + tlsCrt, ok := secretData["tls.crt"].(string) + if !ok || tlsCrt == "" { + t.Error("data[tls.crt] is missing or empty") + } else { + // Verify tls.crt is valid base64 + decoded, err := base64.StdEncoding.DecodeString(tlsCrt) + if err != nil { + t.Errorf("data[tls.crt] is not valid base64: %v", err) + } + // Verify decoded content matches the original cert PEM + if string(decoded) != string(serverCert.CertPEM) { + t.Error("data[tls.crt] decoded content does not match original cert PEM") + } + } + + tlsKey, ok := secretData["tls.key"].(string) + if !ok || tlsKey == "" { + t.Error("data[tls.key] is missing or empty") + } else { + // Verify tls.key is valid base64 + decoded, err := base64.StdEncoding.DecodeString(tlsKey) + if err != nil { + t.Errorf("data[tls.key] is not valid base64: %v", err) + } + // Verify decoded content matches the original key PEM + if string(decoded) != string(serverCert.KeyPEM) { + t.Error("data[tls.key] decoded content does not match original key PEM") + } + } +} + +func TestSkipApply_SecretsClusterUnreachableError_Format(t *testing.T) { + refs := []secrets.SecretReference{ + { + Name: "app-config", + Usages: []secrets.SecretUsage{ + {Type: "envFrom", ContainerName: "app"}, + }, + }, + { + Name: "volume-data", + Usages: []secrets.SecretUsage{ + {Type: "volume", VolumeName: "data-vol"}, + }, + }, + } + + err := secretsClusterUnreachableError(refs, fmt.Errorf("connection refused")) + + errMsg := err.Error() + + // Verify error mentions secret names + if !strings.Contains(errMsg, "app-config") { + t.Errorf("Error should mention 'app-config', got: %s", errMsg) + } + if !strings.Contains(errMsg, "volume-data") { + t.Errorf("Error should mention 'volume-data', got: %s", errMsg) + } + + // Verify error mentions usage types + if !strings.Contains(errMsg, "envFrom") { + t.Errorf("Error should mention 'envFrom' usage type, got: %s", errMsg) + } + if !strings.Contains(errMsg, "volume") { + t.Errorf("Error should mention 'volume' usage type, got: %s", errMsg) + } + + // Verify actionable guidance + if !strings.Contains(errMsg, "explicit key references") { + t.Errorf("Error should suggest explicit key references, got: %s", errMsg) + } + if !strings.Contains(errMsg, "convert-secrets=false") { + t.Errorf("Error should suggest --convert-secrets=false, got: %s", errMsg) + } + + // Verify underlying error included + if !strings.Contains(errMsg, "connection refused") { + t.Errorf("Error should include underlying error, got: %s", errMsg) + } +} + +func TestSkipApply_SecretsClusterQueryError_Format(t *testing.T) { + refs := []secrets.SecretReference{ + {Name: "missing-secret"}, + } + + err := secretsClusterQueryError(refs, fmt.Errorf("secret not found")) + + errMsg := err.Error() + + if !strings.Contains(errMsg, "missing-secret") { + t.Errorf("Error should mention secret name, got: %s", errMsg) + } + if !strings.Contains(errMsg, "secret not found") { + t.Errorf("Error should include underlying error, got: %s", errMsg) + } + if !strings.Contains(errMsg, "explicit key references") { + t.Errorf("Error should suggest explicit key references, got: %s", errMsg) + } +} + +func TestSkipApply_SecretRefSplitting(t *testing.T) { + // Simulate mixed refs + allRefs := []secrets.SecretReference{ + {Name: "explicit-secret", NeedsLookup: false, Keys: []string{"key1"}}, + {Name: "envfrom-secret", NeedsLookup: true}, + {Name: "volume-explicit", NeedsLookup: false, Keys: []string{"cert", "key"}}, + {Name: "volume-all", NeedsLookup: true}, + } + + var offlineRefs, clusterRefs []secrets.SecretReference + for _, ref := range allRefs { + if ref.NeedsLookup { + clusterRefs = append(clusterRefs, ref) + } else { + offlineRefs = append(offlineRefs, ref) + } + } + + if len(offlineRefs) != 2 { + t.Errorf("Expected 2 offline refs, got %d", len(offlineRefs)) + } + if len(clusterRefs) != 2 { + t.Errorf("Expected 2 cluster refs, got %d", len(clusterRefs)) + } + + // Verify correct assignment + if offlineRefs[0].Name != "explicit-secret" { + t.Errorf("First offline ref should be 'explicit-secret', got %q", offlineRefs[0].Name) + } + if clusterRefs[0].Name != "envfrom-secret" { + t.Errorf("First cluster ref should be 'envfrom-secret', got %q", clusterRefs[0].Name) + } +} diff --git a/cmd/init.go b/cmd/init.go index 9f1ccc8..23769d5 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -2,13 +2,17 @@ package cmd import ( "bufio" + "context" "fmt" "os" "path/filepath" "strings" + "k8s.io/client-go/kubernetes" + "github.com/confidential-devhub/cococtl/pkg/cluster" "github.com/confidential-devhub/cococtl/pkg/config" + "github.com/confidential-devhub/cococtl/pkg/k8s" "github.com/confidential-devhub/cococtl/pkg/sidecar/certs" "github.com/confidential-devhub/cococtl/pkg/trustee" "github.com/spf13/cobra" @@ -88,7 +92,7 @@ func runInit(cmd *cobra.Command, _ []string) error { cfg := config.DefaultConfig() // Handle Trustee setup - trusteeDeployed, actualNamespace, err := handleTrusteeSetup(cfg, interactive, skipTrusteeDeploy, trusteeNamespace, trusteeURL) + trusteeDeployed, actualNamespace, err := handleTrusteeSetup(cmd, cfg, interactive, skipTrusteeDeploy, trusteeNamespace, trusteeURL) if err != nil { return err } @@ -106,7 +110,11 @@ func runInit(cmd *cobra.Command, _ []string) error { // Handle sidecar certificate setup if enabled if enableSidecar { - if err := handleSidecarCertSetup(sidecarNamespace); err != nil { + sidecarClient, err := k8s.NewClient(k8s.ClientOptions{}) + if err != nil { + return fmt.Errorf("failed to create Kubernetes client for sidecar cert setup: %w", err) + } + if err := handleSidecarCertSetup(cmd.Context(), sidecarClient.Clientset, sidecarNamespace); err != nil { return err } } @@ -116,7 +124,16 @@ func runInit(cmd *cobra.Command, _ []string) error { cfg.RuntimeClass = runtimeClass } else { // Auto-detect RuntimeClass with SNP or TDX support - cfg.RuntimeClass = cluster.DetectRuntimeClass(config.DefaultRuntimeClass) + // Create Kubernetes client for runtime class detection + client, err := k8s.NewClient(k8s.ClientOptions{}) + if err != nil { + // Log warning but don't fail - use default runtime class + fmt.Printf("Warning: unable to create Kubernetes client: %v\n", err) + cfg.RuntimeClass = config.DefaultRuntimeClass + } else { + ctx := cmd.Context() + cfg.RuntimeClass = cluster.DetectRuntimeClass(ctx, client.Clientset, config.DefaultRuntimeClass) + } } // In non-interactive mode, show the RuntimeClass being used @@ -190,7 +207,7 @@ func promptString(prompt, defaultValue string, required bool) string { return input } -func handleTrusteeSetup(cfg *config.CocoConfig, interactive, skipDeploy bool, namespace, url string) (bool, string, error) { +func handleTrusteeSetup(cmd *cobra.Command, cfg *config.CocoConfig, interactive, skipDeploy bool, namespace, url string) (bool, string, error) { // If URL provided via flag, use it and skip deployment if url != "" { cfg.TrusteeServer = url @@ -231,14 +248,23 @@ func handleTrusteeSetup(cfg *config.CocoConfig, interactive, skipDeploy bool, na // Get current namespace if not specified if namespace == "" { var err error - namespace, err = getCurrentNamespace() + namespace, err = k8s.GetCurrentNamespace() if err != nil { return false, "", err } } + // Create Kubernetes client for trustee operations + client, clientErr := k8s.NewClient(k8s.ClientOptions{}) + if clientErr != nil { + return false, "", fmt.Errorf("failed to create Kubernetes client: %w", clientErr) + } + + // Detect kubectl availability and enhance context + ctx := detectKubectl(cmd.Context()) + // Check if Trustee is already deployed - deployed, err := trustee.IsDeployed(namespace) + deployed, err := trustee.IsDeployed(ctx, client.Clientset, namespace) if err != nil { return false, "", fmt.Errorf("failed to check Trustee deployment: %w", err) } @@ -252,6 +278,11 @@ func handleTrusteeSetup(cfg *config.CocoConfig, interactive, skipDeploy bool, na // Deploy Trustee fmt.Printf("Deploying Trustee to namespace '%s'...\n", namespace) + // Check kubectl availability before deployment (kubectl is required for trustee.Deploy) + if err := requireKubectl(ctx, "init"); err != nil { + return false, "", err + } + kbsImage := cfg.KBSImage if kbsImage == "" { kbsImage = config.DefaultKBSImage @@ -264,7 +295,7 @@ func handleTrusteeSetup(cfg *config.CocoConfig, interactive, skipDeploy bool, na PCCSURL: cfg.PCCSURL, } - if err := trustee.Deploy(trusteeCfg); err != nil { + if err := trustee.Deploy(ctx, client.Clientset, trusteeCfg); err != nil { return false, "", fmt.Errorf("failed to deploy Trustee: %w", err) } @@ -280,7 +311,7 @@ func handleTrusteeSetup(cfg *config.CocoConfig, interactive, skipDeploy bool, na // uploads the Client CA to Trustee KBS, and saves both the CA and client certificate locally. // The CA is needed during 'apply' to sign per-app server certificates. // The trusteeNamespace parameter specifies where the Trustee KBS pod is deployed. -func handleSidecarCertSetup(trusteeNamespace string) error { +func handleSidecarCertSetup(ctx context.Context, clientset kubernetes.Interface, trusteeNamespace string) error { fmt.Println("\nSetting up sidecar certificates...") // Generate Client CA @@ -304,7 +335,7 @@ func handleSidecarCertSetup(trusteeNamespace string) error { const kbsResourceNamespace = "default" fmt.Printf(" - Uploading Client CA to Trustee KBS (Trustee namespace: %s, resource path: default)...\n", trusteeNamespace) clientCAPath := kbsResourceNamespace + "/sidecar-tls/client-ca" - if err := trustee.UploadResource(trusteeNamespace, clientCAPath, clientCA.CertPEM); err != nil { + if err := trustee.UploadResource(ctx, clientset, trusteeNamespace, clientCAPath, clientCA.CertPEM); err != nil { return fmt.Errorf("failed to upload client CA to KBS: %w", err) } diff --git a/cmd/init_test.go b/cmd/init_test.go index c4d41aa..74a66f4 100644 --- a/cmd/init_test.go +++ b/cmd/init_test.go @@ -1,6 +1,7 @@ package cmd import ( + "context" "os" "path/filepath" "testing" @@ -66,6 +67,9 @@ func TestInitCommand_WithoutRuntimeClassFlag(t *testing.T) { t.Fatalf("Failed to set runtime-class flag: %v", err) } + // Set context for Kubernetes client operations (required when auto-detecting RuntimeClass) + cmd.SetContext(context.Background()) + err := runInit(cmd, []string{}) if err != nil { t.Fatalf("runInit failed: %v", err) diff --git a/cmd/root.go b/cmd/root.go index 65aed62..1ec1da2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,7 +5,6 @@ import ( "context" "fmt" "os/exec" - "strings" "github.com/spf13/cobra" ) @@ -35,19 +34,33 @@ func init() { cobra.OnInitialize() } -// getCurrentNamespace gets the current namespace from kubectl config -func getCurrentNamespace() (string, error) { - ctx := context.Background() - cmd := exec.CommandContext(ctx, "kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}") - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to get current namespace: %w", err) - } +// contextKey is the type for context keys used in cococtl +type contextKey int + +const kubectlAvailableKey contextKey = iota + +// detectKubectl checks if kubectl is available in PATH and caches the result in context +func detectKubectl(ctx context.Context) context.Context { + _, err := exec.LookPath("kubectl") + return context.WithValue(ctx, kubectlAvailableKey, err == nil) +} - namespace := strings.TrimSpace(string(output)) - if namespace == "" { - namespace = "default" +// isKubectlAvailable retrieves the cached kubectl availability from context +func isKubectlAvailable(ctx context.Context) bool { + if v := ctx.Value(kubectlAvailableKey); v != nil { + return v.(bool) } + return false +} - return namespace, nil +// requireKubectl returns an error if kubectl is not available, providing installation guidance +func requireKubectl(ctx context.Context, operation string) error { + if !isKubectlAvailable(ctx) { + return fmt.Errorf("kubectl is required for %s operations\n\n"+ + "To fix:\n"+ + " 1. Install kubectl: https://kubernetes.io/docs/tasks/tools/\n"+ + " 2. Ensure kubectl is in your PATH\n"+ + " 3. Verify with: kubectl version --client", operation) + } + return nil } diff --git a/go.mod b/go.mod index 1446ad5..c124806 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,51 @@ module github.com/confidential-devhub/cococtl -go 1.24.4 +go 1.25.0 require ( github.com/pelletier/go-toml/v2 v2.2.4 github.com/spf13/cobra v1.10.1 gopkg.in/yaml.v3 v3.0.1 + k8s.io/apimachinery v0.35.0 + k8s.io/client-go v0.35.0 ) require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/jsonreference v0.20.2 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/google/gnostic-models v0.7.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/spf13/pflag v1.0.9 // indirect + github.com/x448/float16 v0.8.4 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect + golang.org/x/text v0.31.0 // indirect + golang.org/x/time v0.9.0 // indirect + google.golang.org/protobuf v1.36.8 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + k8s.io/api v0.35.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 // indirect + k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.0 // indirect + sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 2a5a356..bbdb618 100644 --- a/go.sum +++ b/go.sum @@ -1,14 +1,137 @@ +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emicklei/go-restful/v3 v3.12.2 h1:DhwDP0vY3k8ZzE0RunuJy8GhNpPL6zqLkDf9B/a0/xU= +github.com/emicklei/go-restful/v3 v3.12.2/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= +github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= +github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/google/gnostic-models v0.7.0 h1:qwTtogB15McXDaNqTZdzPJRHvaVJlAl+HVQnLmJEJxo= +github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I= +golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY= +golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/api v0.35.0 h1:iBAU5LTyBI9vw3L5glmat1njFK34srdLmktWwLTprlY= +k8s.io/api v0.35.0/go.mod h1:AQ0SNTzm4ZAczM03QH42c7l3bih1TbAXYo0DkF8ktnA= +k8s.io/apimachinery v0.35.0 h1:Z2L3IHvPVv/MJ7xRxHEtk6GoJElaAqDCCU0S6ncYok8= +k8s.io/apimachinery v0.35.0/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/client-go v0.35.0 h1:IAW0ifFbfQQwQmga0UdoH0yvdqrbwMdq9vIFEhRpxBE= +k8s.io/client-go v0.35.0/go.mod h1:q2E5AAyqcbeLGPdoRB+Nxe3KYTfPce1Dnu1myQdqz9o= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZcmKS3g6CthxToOb37KgwE= +k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4 h1:SjGebBtkBqHFOli+05xYbK8YF1Dzkbzn+gDM4X9T4Ck= +k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0 h1:jTijUJbW353oVOd9oTlifJqOGEkUw2jB/fXCbTiQEco= +sigs.k8s.io/structured-merge-diff/v6 v6.3.0/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= diff --git a/integration_test/workflow_test.go b/integration_test/workflow_test.go index f117695..7bb2c05 100644 --- a/integration_test/workflow_test.go +++ b/integration_test/workflow_test.go @@ -3,6 +3,7 @@ package integration_test import ( "bytes" "compress/gzip" + "context" "encoding/base64" "io" "os" @@ -10,11 +11,20 @@ import ( "strings" "testing" + "github.com/confidential-devhub/cococtl/pkg/cluster" "github.com/confidential-devhub/cococtl/pkg/config" "github.com/confidential-devhub/cococtl/pkg/initdata" "github.com/confidential-devhub/cococtl/pkg/manifest" "github.com/confidential-devhub/cococtl/pkg/sealed" + "github.com/confidential-devhub/cococtl/pkg/secrets" + "github.com/confidential-devhub/cococtl/pkg/trustee" "github.com/pelletier/go-toml/v2" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + nodev1 "k8s.io/api/node/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/fake" ) // TestWorkflow_BasicTransformation tests the basic transformation workflow: @@ -526,3 +536,377 @@ func TestWorkflow_PreserveExisting(t *testing.T) { t.Errorf("First initContainer = %v, want new-init", firstInit["name"]) } } + +// TestWorkflow_InitDetection tests that init detection queries work without kubectl +// using fake clientset. Validates RuntimeClass detection, node IP extraction, and +// Trustee deployment checks work via client-go when kubectl is not available. +func TestWorkflow_InitDetection(t *testing.T) { + tests := []struct { + name string + setupFunc func(*testing.T) kubernetes.Interface + testFunc func(*testing.T, kubernetes.Interface) + }{ + { + name: "detect RuntimeClass without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + // Create RuntimeClass with kata-qemu handler + rc := &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kata-qemu", + }, + Handler: "kata-qemu", + } + return fake.NewSimpleClientset(rc) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + runtimeClass := cluster.DetectRuntimeClass(ctx, client, "kata-remote") + if runtimeClass == "" { + t.Error("DetectRuntimeClass returned empty string") + } + }, + }, + { + name: "extract node IPs without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + // Create nodes with external and internal IPs + node1 := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker-1", + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "203.0.113.10"}, + {Type: corev1.NodeInternalIP, Address: "10.0.1.10"}, + }, + }, + } + node2 := &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{ + Name: "worker-2", + }, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "203.0.113.20"}, + {Type: corev1.NodeInternalIP, Address: "10.0.1.20"}, + }, + }, + } + return fake.NewSimpleClientset(node1, node2) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + ips, err := cluster.GetNodeIPs(ctx, client) + if err != nil { + t.Fatalf("GetNodeIPs failed: %v", err) + } + if len(ips) == 0 { + t.Error("GetNodeIPs returned empty list") + } + // Verify we got external IPs + expectedIPs := map[string]bool{ + "203.0.113.10": true, + "203.0.113.20": true, + } + for _, ip := range ips { + if !expectedIPs[ip] { + t.Errorf("Unexpected IP: %s", ip) + } + } + }, + }, + { + name: "check Trustee deployment without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + // Create Trustee deployment with correct label (app=kbs) + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "kbs", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "kbs", + }, + }, + }, + } + return fake.NewSimpleClientset(deployment) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + deployed, err := trustee.IsDeployed(ctx, client, "coco-tenant") + if err != nil { + t.Fatalf("IsDeployed failed: %v", err) + } + if !deployed { + t.Error("IsDeployed returned false, expected true") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := tt.setupFunc(t) + tt.testFunc(t, client) + }) + } +} + +// TestWorkflow_SecretInspection tests that secret queries work without kubectl +// using fake clientset. Validates InspectSecret and InspectSecrets work via +// client-go when kubectl is not available. +func TestWorkflow_SecretInspection(t *testing.T) { + tests := []struct { + name string + setupFunc func(*testing.T) kubernetes.Interface + testFunc func(*testing.T, kubernetes.Interface) + }{ + { + name: "inspect single secret without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + secret := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "db-credentials", + Namespace: "default", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "password": []byte("secret123"), + }, + Type: corev1.SecretTypeOpaque, + } + return fake.NewSimpleClientset(secret) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + secret, err := secrets.InspectSecret(ctx, client, "db-credentials", "default") + if err != nil { + t.Fatalf("InspectSecret failed: %v", err) + } + if secret == nil { + t.Fatal("InspectSecret returned nil") + } + if secret.Name != "db-credentials" { + t.Errorf("Secret name = %q, want %q", secret.Name, "db-credentials") + } + if len(secret.Data) != 2 { + t.Errorf("Secret data length = %d, want 2", len(secret.Data)) + } + }, + }, + { + name: "inspect multiple secrets without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + secret1 := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "api-key", + Namespace: "default", + }, + Data: map[string][]byte{ + "key": []byte("abc123"), + }, + Type: corev1.SecretTypeOpaque, + } + secret2 := &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "tls-cert", + Namespace: "default", + }, + Data: map[string][]byte{ + "tls.crt": []byte("cert-data"), + "tls.key": []byte("key-data"), + }, + Type: corev1.SecretTypeTLS, + } + return fake.NewSimpleClientset(secret1, secret2) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + refs := []secrets.SecretReference{ + {Name: "api-key", Namespace: "default", NeedsLookup: true}, + {Name: "tls-cert", Namespace: "default", NeedsLookup: true}, + } + secretMap, err := secrets.InspectSecrets(ctx, client, refs) + if err != nil { + t.Fatalf("InspectSecrets failed: %v", err) + } + if len(secretMap) != 2 { + t.Errorf("InspectSecrets returned %d secrets, want 2", len(secretMap)) + } + if secretMap["api-key"] == nil { + t.Error("api-key secret not found in result") + } + if secretMap["tls-cert"] == nil { + t.Error("tls-cert secret not found in result") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := tt.setupFunc(t) + tt.testFunc(t, client) + }) + } +} + +// TestWorkflow_TrusteeQueries tests that Trustee pod and deployment queries work +// without kubectl using fake clientset. Validates IsDeployed and GetKBSPodName +// work via client-go when kubectl is not available. +func TestWorkflow_TrusteeQueries(t *testing.T) { + tests := []struct { + name string + setupFunc func(*testing.T) kubernetes.Interface + testFunc func(*testing.T, kubernetes.Interface) + }{ + { + name: "check Trustee not deployed", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + // Empty clientset - no Trustee deployed + return fake.NewSimpleClientset() + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + deployed, err := trustee.IsDeployed(ctx, client, "coco-tenant") + if err != nil { + t.Fatalf("IsDeployed failed: %v", err) + } + if deployed { + t.Error("IsDeployed returned true, expected false for empty cluster") + } + }, + }, + { + name: "get KBS pod name without kubectl", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs-6f8d9c7b5-xk9wz", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "kbs", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + return fake.NewSimpleClientset(pod) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + podName, err := trustee.GetKBSPodName(ctx, client, "coco-tenant") + if err != nil { + t.Fatalf("GetKBSPodName failed: %v", err) + } + if podName != "kbs-6f8d9c7b5-xk9wz" { + t.Errorf("GetKBSPodName = %q, want %q", podName, "kbs-6f8d9c7b5-xk9wz") + } + }, + }, + { + name: "Trustee deployed with multiple components", + setupFunc: func(t *testing.T) kubernetes.Interface { + t.Helper() + // Create KBS deployment + kbsDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "kbs", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "kbs", + }, + }, + }, + } + // Create Trustee operator deployment + operatorDeployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "trustee-operator", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "trustee-operator", + }, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{ + MatchLabels: map[string]string{ + "app": "trustee-operator", + }, + }, + }, + } + // Create KBS pod + kbsPod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs-7d9f8c6b4-mnp2q", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "kbs", + }, + }, + Status: corev1.PodStatus{ + Phase: corev1.PodRunning, + }, + } + return fake.NewSimpleClientset(kbsDeployment, operatorDeployment, kbsPod) + }, + testFunc: func(t *testing.T, client kubernetes.Interface) { + t.Helper() + ctx := context.Background() + + // Verify Trustee is deployed + deployed, err := trustee.IsDeployed(ctx, client, "coco-tenant") + if err != nil { + t.Fatalf("IsDeployed failed: %v", err) + } + if !deployed { + t.Error("IsDeployed returned false, expected true") + } + + // Verify we can get KBS pod name + podName, err := trustee.GetKBSPodName(ctx, client, "coco-tenant") + if err != nil { + t.Fatalf("GetKBSPodName failed: %v", err) + } + if podName != "kbs-7d9f8c6b4-mnp2q" { + t.Errorf("GetKBSPodName = %q, want %q", podName, "kbs-7d9f8c6b4-mnp2q") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + client := tt.setupFunc(t) + tt.testFunc(t, client) + }) + } +} diff --git a/pkg/cluster/nodes.go b/pkg/cluster/nodes.go index bfa4bf8..488f576 100644 --- a/pkg/cluster/nodes.go +++ b/pkg/cluster/nodes.go @@ -2,59 +2,49 @@ package cluster import ( - "bytes" + "context" "fmt" - "os/exec" - "strings" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" ) // GetNodeIPs retrieves IP addresses of all nodes in the cluster. // It attempts to get ExternalIP first, falling back to InternalIP if unavailable. // Returns a deduplicated list of node IP addresses. -func GetNodeIPs() ([]string, error) { - // Try external IPs first - externalIPs, err := getNodeIPsByType("ExternalIP") - if err == nil && len(externalIPs) > 0 { - return externalIPs, nil +func GetNodeIPs(ctx context.Context, clientset kubernetes.Interface) ([]string, error) { + nodes, err := clientset.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to list nodes: %w", err) } - // Fall back to internal IPs - internalIPs, err := getNodeIPsByType("InternalIP") - if err != nil { - return nil, fmt.Errorf("failed to get node IPs: %w", err) + // Try external IPs first + externalIPs := extractAddresses(nodes.Items, corev1.NodeExternalIP) + if len(externalIPs) > 0 { + return deduplicateStrings(externalIPs), nil } + // Fall back to internal IPs + internalIPs := extractAddresses(nodes.Items, corev1.NodeInternalIP) if len(internalIPs) == 0 { return nil, fmt.Errorf("no node IPs found in cluster") } - return internalIPs, nil + return deduplicateStrings(internalIPs), nil } -// getNodeIPsByType retrieves node IPs of a specific address type. -func getNodeIPsByType(addressType string) ([]string, error) { - jsonPath := fmt.Sprintf("{.items[*].status.addresses[?(@.type==\"%s\")].address}", addressType) - - // #nosec G204 -- addressType is controlled, only called with "ExternalIP" or "InternalIP" - cmd := exec.Command("kubectl", "get", "nodes", - "-o", fmt.Sprintf("jsonpath=%s", jsonPath)) - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - return nil, fmt.Errorf("kubectl get nodes failed: %w: %s", err, stderr.String()) - } - - output := strings.TrimSpace(stdout.String()) - if output == "" { - return nil, nil +// extractAddresses extracts addresses of a specific type from all nodes. +func extractAddresses(nodes []corev1.Node, addrType corev1.NodeAddressType) []string { + var addresses []string + for _, node := range nodes { + for _, addr := range node.Status.Addresses { + if addr.Type == addrType { + addresses = append(addresses, addr.Address) + } + } } - - // Split by spaces and deduplicate - ips := strings.Fields(output) - return deduplicateStrings(ips), nil + return addresses } // deduplicateStrings removes duplicate entries from a string slice. diff --git a/pkg/cluster/nodes_test.go b/pkg/cluster/nodes_test.go new file mode 100644 index 0000000..a5335e4 --- /dev/null +++ b/pkg/cluster/nodes_test.go @@ -0,0 +1,263 @@ +package cluster + +import ( + "context" + "sort" + "strings" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestGetNodeIPs_ExternalIP(t *testing.T) { + // Setup fake clientset with 2 nodes, each having both ExternalIP and InternalIP + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "5.6.7.8"}, + {Type: corev1.NodeInternalIP, Address: "10.0.0.2"}, + }, + }, + }, + ) + + ctx := context.Background() + ips, err := GetNodeIPs(ctx, fakeClient) + if err != nil { + t.Fatalf("GetNodeIPs() error = %v", err) + } + + // Should prefer external IPs + if len(ips) != 2 { + t.Errorf("GetNodeIPs() returned %d IPs, want 2", len(ips)) + } + + // Check external IPs are returned (order may vary) + sort.Strings(ips) + expected := []string{"1.2.3.4", "5.6.7.8"} + sort.Strings(expected) + + if len(ips) != len(expected) { + t.Errorf("GetNodeIPs() = %v, want %v", ips, expected) + return + } + for i := range ips { + if ips[i] != expected[i] { + t.Errorf("GetNodeIPs() = %v, want %v", ips, expected) + return + } + } +} + +func TestGetNodeIPs_FallbackToInternal(t *testing.T) { + // Setup fake clientset with 2 nodes, only InternalIP (no ExternalIP) + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeInternalIP, Address: "10.0.0.2"}, + }, + }, + }, + ) + + ctx := context.Background() + ips, err := GetNodeIPs(ctx, fakeClient) + if err != nil { + t.Fatalf("GetNodeIPs() error = %v", err) + } + + // Should fall back to internal IPs + if len(ips) != 2 { + t.Errorf("GetNodeIPs() returned %d IPs, want 2", len(ips)) + } + + sort.Strings(ips) + expected := []string{"10.0.0.1", "10.0.0.2"} + sort.Strings(expected) + + for i := range ips { + if ips[i] != expected[i] { + t.Errorf("GetNodeIPs() = %v, want %v", ips, expected) + return + } + } +} + +func TestGetNodeIPs_NoNodes(t *testing.T) { + // Setup empty fake clientset (no Nodes) + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + _, err := GetNodeIPs(ctx, fakeClient) + if err == nil { + t.Fatal("GetNodeIPs() expected error for empty cluster, got nil") + } + + if !strings.Contains(err.Error(), "no node IPs found") { + t.Errorf("GetNodeIPs() error = %q, want error containing 'no node IPs found'", err.Error()) + } +} + +func TestGetNodeIPs_NoAddresses(t *testing.T) { + // Setup fake clientset with node that has no addresses + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{}, + }, + }, + ) + + ctx := context.Background() + _, err := GetNodeIPs(ctx, fakeClient) + if err == nil { + t.Fatal("GetNodeIPs() expected error for node with no addresses, got nil") + } + + if !strings.Contains(err.Error(), "no node IPs found") { + t.Errorf("GetNodeIPs() error = %q, want error containing 'no node IPs found'", err.Error()) + } +} + +func TestGetNodeIPs_Deduplication(t *testing.T) { + // Setup fake clientset with 2 nodes having same ExternalIP (rare but possible) + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + }, + }, + }, + ) + + ctx := context.Background() + ips, err := GetNodeIPs(ctx, fakeClient) + if err != nil { + t.Fatalf("GetNodeIPs() error = %v", err) + } + + // Should return deduplicated list (single IP) + if len(ips) != 1 { + t.Errorf("GetNodeIPs() returned %d IPs, want 1 (deduplicated)", len(ips)) + } + + if ips[0] != "1.2.3.4" { + t.Errorf("GetNodeIPs() = %v, want [1.2.3.4]", ips) + } +} + +func TestGetNodeIPs_MixedAddressTypes(t *testing.T) { + // Setup fake clientset with nodes having various address types (Hostname, InternalDNS, etc.) + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeHostName, Address: "node-1.example.com"}, + {Type: corev1.NodeInternalDNS, Address: "node-1.cluster.local"}, + {Type: corev1.NodeExternalIP, Address: "1.2.3.4"}, + {Type: corev1.NodeInternalIP, Address: "10.0.0.1"}, + }, + }, + }, + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-2"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeHostName, Address: "node-2.example.com"}, + {Type: corev1.NodeExternalDNS, Address: "node-2.public.example.com"}, + {Type: corev1.NodeExternalIP, Address: "5.6.7.8"}, + {Type: corev1.NodeInternalIP, Address: "10.0.0.2"}, + }, + }, + }, + ) + + ctx := context.Background() + ips, err := GetNodeIPs(ctx, fakeClient) + if err != nil { + t.Fatalf("GetNodeIPs() error = %v", err) + } + + // Should only return ExternalIP, not Hostname or DNS + if len(ips) != 2 { + t.Errorf("GetNodeIPs() returned %d IPs, want 2 (ExternalIPs only)", len(ips)) + } + + // Ensure only ExternalIPs are returned + sort.Strings(ips) + expected := []string{"1.2.3.4", "5.6.7.8"} + sort.Strings(expected) + + for i := range ips { + if ips[i] != expected[i] { + t.Errorf("GetNodeIPs() = %v, want %v (ExternalIPs only)", ips, expected) + return + } + } + + // Verify hostnames and DNS names are NOT included + for _, ip := range ips { + if strings.Contains(ip, "example.com") || strings.Contains(ip, "cluster.local") { + t.Errorf("GetNodeIPs() returned hostname/DNS %q, should only return IPs", ip) + } + } +} + +func TestGetNodeIPs_OnlyHostname(t *testing.T) { + // Edge case: node only has Hostname, no ExternalIP or InternalIP + fakeClient := fake.NewSimpleClientset( + &corev1.Node{ + ObjectMeta: metav1.ObjectMeta{Name: "node-1"}, + Status: corev1.NodeStatus{ + Addresses: []corev1.NodeAddress{ + {Type: corev1.NodeHostName, Address: "node-1.example.com"}, + }, + }, + }, + ) + + ctx := context.Background() + _, err := GetNodeIPs(ctx, fakeClient) + if err == nil { + t.Fatal("GetNodeIPs() expected error when node only has Hostname, got nil") + } + + if !strings.Contains(err.Error(), "no node IPs found") { + t.Errorf("GetNodeIPs() error = %q, want error containing 'no node IPs found'", err.Error()) + } +} diff --git a/pkg/cluster/runtimeclass.go b/pkg/cluster/runtimeclass.go index ad8156e..e72307a 100644 --- a/pkg/cluster/runtimeclass.go +++ b/pkg/cluster/runtimeclass.go @@ -2,57 +2,33 @@ package cluster import ( - "bytes" - "encoding/json" + "context" "fmt" - "os/exec" "strings" -) -// runtimeClassList represents the JSON response from kubectl get runtimeclasses -type runtimeClassList struct { - Items []struct { - Metadata struct { - Name string `json:"name"` - } `json:"metadata"` - Handler string `json:"handler"` - } `json:"items"` -} + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) // DetectRuntimeClass attempts to auto-detect a RuntimeClass with SNP or TDX support. // It retrieves all RuntimeClasses from the cluster and selects the first one whose // handler contains "snp" or "tdx" (case-insensitive). // Returns the default RuntimeClass if: -// - There's an error retrieving RuntimeClasses (permissions, kubectl not available, etc.) +// - There's an error retrieving RuntimeClasses (permissions, cluster unreachable, etc.) // - No RuntimeClasses have handlers containing "snp" or "tdx" -func DetectRuntimeClass(defaultRuntimeClass string) string { - // #nosec G204 -- static command with no user-controlled input - cmd := exec.Command("kubectl", "get", "runtimeclasses", "-o", "json") - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - if err := cmd.Run(); err != nil { - // Error retrieving RuntimeClasses (permissions, kubectl not available, etc.) - // Return default - fmt.Printf("Unable to detect RuntimeClasses: %v (stderr: %s) (using default: %s)\n", err, strings.TrimSpace(stderr.String()), defaultRuntimeClass) - return defaultRuntimeClass - } - - var rcList runtimeClassList - if err := json.Unmarshal(stdout.Bytes(), &rcList); err != nil { - // Error parsing JSON, return default - fmt.Printf("Unable to parse RuntimeClasses: %v (using default: %s)\n", err, defaultRuntimeClass) +func DetectRuntimeClass(ctx context.Context, clientset kubernetes.Interface, defaultRuntimeClass string) string { + rcs, err := clientset.NodeV1().RuntimeClasses().List(ctx, metav1.ListOptions{}) + if err != nil { + fmt.Printf("Unable to detect RuntimeClasses: %v (using default: %s)\n", err, defaultRuntimeClass) return defaultRuntimeClass } // Look for RuntimeClasses with handlers containing "snp" or "tdx" - for _, rc := range rcList.Items { + for _, rc := range rcs.Items { handler := strings.ToLower(rc.Handler) if strings.Contains(handler, "snp") || strings.Contains(handler, "tdx") { - fmt.Printf("Detected RuntimeClass: %s\n", rc.Metadata.Name) - return rc.Metadata.Name + fmt.Printf("Detected RuntimeClass: %s\n", rc.Name) + return rc.Name } } diff --git a/pkg/cluster/runtimeclass_test.go b/pkg/cluster/runtimeclass_test.go new file mode 100644 index 0000000..0325672 --- /dev/null +++ b/pkg/cluster/runtimeclass_test.go @@ -0,0 +1,167 @@ +package cluster + +import ( + "context" + "testing" + + nodev1 "k8s.io/api/node/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestDetectRuntimeClass_SNPHandler(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "kata-cc-snp"}, + Handler: "kata-snp", + }, + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "kata-cc-tdx"}, + Handler: "kata-tdx", + }, + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "runc"}, + Handler: "runc", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "kata-cc") + + // Should return first SNP/TDX match (SNP preferred if both present) + // Note: fake clientset may return items in any order, so accept either SNP or TDX + if result != "kata-cc-snp" && result != "kata-cc-tdx" { + t.Errorf("DetectRuntimeClass() = %q, want %q or %q", result, "kata-cc-snp", "kata-cc-tdx") + } +} + +func TestDetectRuntimeClass_TDXHandler(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "kata-cc-tdx"}, + Handler: "kata-tdx", + }, + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "runc"}, + Handler: "runc", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "kata-cc") + + // Should return TDX match when no SNP present + if result != "kata-cc-tdx" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "kata-cc-tdx") + } +} + +func TestDetectRuntimeClass_NoMatch(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "runc"}, + Handler: "runc", + }, + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "gvisor"}, + Handler: "gvisor", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "kata-cc") + + // Should return default when no SNP/TDX match + if result != "kata-cc" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "kata-cc") + } +} + +func TestDetectRuntimeClass_EmptyCluster(t *testing.T) { + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "kata-cc") + + // Should return default when no RuntimeClasses exist + if result != "kata-cc" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "kata-cc") + } +} + +func TestDetectRuntimeClass_CaseInsensitive(t *testing.T) { + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "kata-uppercase"}, + Handler: "KATA-SNP", // uppercase handler + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "default-rc") + + // Should return the matching RuntimeClass name (case-insensitive handler matching) + if result != "kata-uppercase" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "kata-uppercase") + } +} + +func TestDetectRuntimeClass_PrefersSNPOverTDX(t *testing.T) { + // When both SNP and TDX are available, the function should return + // whichever comes first in the list. This test ensures the function + // properly handles both handler types. + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "runc"}, + Handler: "runc", + }, + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "only-snp"}, + Handler: "kata-snp", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "default-rc") + + // Should return the SNP runtime class + if result != "only-snp" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "only-snp") + } +} + +func TestDetectRuntimeClass_HandlerContainsSNP(t *testing.T) { + // Test that handler just needs to CONTAIN "snp", not equal it exactly + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "my-custom-rc"}, + Handler: "my-kata-snp-handler", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "default-rc") + + // Should match because handler contains "snp" + if result != "my-custom-rc" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "my-custom-rc") + } +} + +func TestDetectRuntimeClass_HandlerContainsTDX(t *testing.T) { + // Test that handler just needs to CONTAIN "tdx", not equal it exactly + fakeClient := fake.NewSimpleClientset( + &nodev1.RuntimeClass{ + ObjectMeta: metav1.ObjectMeta{Name: "my-tdx-rc"}, + Handler: "secure-tdx-runtime", + }, + ) + + ctx := context.Background() + result := DetectRuntimeClass(ctx, fakeClient, "default-rc") + + // Should match because handler contains "tdx" + if result != "my-tdx-rc" { + t.Errorf("DetectRuntimeClass() = %q, want %q", result, "my-tdx-rc") + } +} diff --git a/pkg/k8s/client.go b/pkg/k8s/client.go new file mode 100644 index 0000000..14a0c9a --- /dev/null +++ b/pkg/k8s/client.go @@ -0,0 +1,215 @@ +// Package k8s provides a shared Kubernetes client factory with kubectl-compatible +// kubeconfig discovery and namespace resolution. +package k8s + +import ( + "fmt" + "os" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" +) + +const ( + // inClusterNamespacePath is the path to the namespace file in a Kubernetes pod. + inClusterNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace" +) + +// Client wraps a Kubernetes clientset with resolved configuration. +type Client struct { + // Clientset is the Kubernetes client interface. Using Interface (not *Clientset) + // allows for easy testing with fake.NewSimpleClientset(). + Clientset kubernetes.Interface + + // Namespace is the resolved default namespace for operations. + Namespace string + + // Config is the underlying REST config for advanced use cases. + Config *rest.Config +} + +// ClientOptions configures client creation. +type ClientOptions struct { + // Kubeconfig is an explicit kubeconfig path. If empty, standard discovery is used: + // KUBECONFIG env -> ~/.kube/config -> in-cluster config. + Kubeconfig string + + // Context is an explicit context name. If empty, current-context is used. + Context string + + // Namespace is an explicit namespace. If empty, namespace is resolved from: + // kubeconfig context -> in-cluster namespace file -> "default". + Namespace string + + // Timeout is the default timeout for API operations. If zero, no timeout is set. + Timeout time.Duration +} + +// NewClient creates a Kubernetes client with kubectl-compatible kubeconfig discovery. +// +// Kubeconfig discovery order (same as kubectl): +// 1. opts.Kubeconfig (if provided) +// 2. KUBECONFIG environment variable +// 3. ~/.kube/config +// 4. In-cluster config (when running inside a pod) +// +// Namespace resolution order: +// 1. opts.Namespace (if provided) +// 2. Namespace from kubeconfig current context +// 3. In-cluster namespace file (/var/run/secrets/kubernetes.io/serviceaccount/namespace) +// 4. "default" +func NewClient(opts ClientOptions) (*Client, error) { + config, namespace, err := loadConfig(opts) + if err != nil { + return nil, fmt.Errorf("failed to load kubernetes config: %w", err) + } + + // Apply timeout if specified + if opts.Timeout > 0 { + config.Timeout = opts.Timeout + } + + // Create clientset + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes client: %w", err) + } + + // Override namespace if explicitly provided + if opts.Namespace != "" { + namespace = opts.Namespace + } + + // Ensure we always have a namespace + if namespace == "" { + namespace = getInClusterNamespace("") + } + + return &Client{ + Clientset: clientset, + Namespace: namespace, + Config: config, + }, nil +} + +// GetCurrentNamespace returns the namespace from the current kubeconfig context. +// This is a standalone function for cases where you only need the namespace +// without creating a full client. +// +// Resolution order: +// 1. Namespace from kubeconfig current context +// 2. In-cluster namespace file +// 3. "default" +func GetCurrentNamespace() (string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + configOverrides := &clientcmd.ConfigOverrides{} + + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + configOverrides, + ) + + // Namespace() may fail if kubeconfig is missing or invalid; + // treat that the same as an empty namespace and fall through + // to in-cluster / "default" resolution below. + namespace, _, _ := kubeConfig.Namespace() + + if namespace == "" { + namespace = getInClusterNamespace("") + } + + return namespace, nil +} + +// WrapError wraps a Kubernetes API error with operation context. +// It provides user-friendly messages for common error types. +func WrapError(err error, operation, resource, namespace string) error { + if err == nil { + return nil + } + + if apierrors.IsNotFound(err) { + if namespace != "" { + return fmt.Errorf("%s not found in namespace %s", resource, namespace) + } + return fmt.Errorf("%s not found", resource) + } + + if apierrors.IsForbidden(err) { + if namespace != "" { + return fmt.Errorf("permission denied: cannot %s %s in namespace %s", operation, resource, namespace) + } + return fmt.Errorf("permission denied: cannot %s %s", operation, resource) + } + + if namespace != "" { + return fmt.Errorf("failed to %s %s in namespace %s: %w", operation, resource, namespace, err) + } + return fmt.Errorf("failed to %s %s: %w", operation, resource, err) +} + +// loadConfig loads the Kubernetes config using kubectl-compatible discovery. +func loadConfig(opts ClientOptions) (*rest.Config, string, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + + // Use explicit kubeconfig path if provided + if opts.Kubeconfig != "" { + loadingRules.ExplicitPath = opts.Kubeconfig + } + + configOverrides := &clientcmd.ConfigOverrides{} + + // Use explicit context if provided + if opts.Context != "" { + configOverrides.CurrentContext = opts.Context + } + + kubeConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + loadingRules, + configOverrides, + ) + + // Get namespace from context + namespace, _, err := kubeConfig.Namespace() + if err != nil { + // Namespace resolution failed, but we might still get a valid config + namespace = "" + } + + // Try to get client config from kubeconfig + config, err := kubeConfig.ClientConfig() + if err != nil { + // Kubeconfig failed, try in-cluster config + config, err = rest.InClusterConfig() + if err != nil { + return nil, "", fmt.Errorf("unable to load kubeconfig (tried KUBECONFIG, ~/.kube/config, in-cluster): %w", err) + } + // For in-cluster, namespace comes from the namespace file + namespace = getInClusterNamespace(namespace) + } + + return config, namespace, nil +} + +// getInClusterNamespace returns the namespace from the in-cluster namespace file, +// or the override if provided, or "default" as a fallback. +func getInClusterNamespace(override string) string { + if override != "" { + return override + } + + data, err := os.ReadFile(inClusterNamespacePath) + if err != nil { + return "default" + } + + ns := string(data) + if ns == "" { + return "default" + } + + return ns +} diff --git a/pkg/k8s/client_test.go b/pkg/k8s/client_test.go new file mode 100644 index 0000000..cfb21e1 --- /dev/null +++ b/pkg/k8s/client_test.go @@ -0,0 +1,360 @@ +package k8s + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/kubernetes/fake" +) + +// createTestKubeconfig creates a temporary kubeconfig file for testing. +// Returns the path to the created file. +func createTestKubeconfig(t *testing.T, namespace string) string { + t.Helper() + + template := `apiVersion: v1 +kind: Config +current-context: test-context +contexts: +- name: test-context + context: + cluster: test-cluster +` + + // Add namespace if provided + if namespace != "" { + template += " namespace: " + namespace + "\n" + } + + template += `clusters: +- name: test-cluster + cluster: + server: https://localhost:6443 +users: +- name: test-user + user: + token: fake-token +` + + dir := t.TempDir() + path := filepath.Join(dir, "config") + + if err := os.WriteFile(path, []byte(template), 0600); err != nil { + t.Fatalf("failed to write test kubeconfig: %v", err) + } + + return path +} + +func TestNewClient_WithMockKubeconfig(t *testing.T) { + // Create temporary kubeconfig with namespace "test-namespace" + kubeconfigPath := createTestKubeconfig(t, "test-namespace") + + client, err := NewClient(ClientOptions{ + Kubeconfig: kubeconfigPath, + }) + + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + + if client == nil { + t.Fatal("NewClient returned nil client") + } + + if client.Namespace != "test-namespace" { + t.Errorf("expected namespace 'test-namespace', got '%s'", client.Namespace) + } + + if client.Clientset == nil { + t.Error("Clientset is nil") + } + + if client.Config == nil { + t.Error("Config is nil") + } +} + +func TestNewClient_WithExplicitNamespace(t *testing.T) { + // Create kubeconfig with context namespace "from-context" + kubeconfigPath := createTestKubeconfig(t, "from-context") + + client, err := NewClient(ClientOptions{ + Kubeconfig: kubeconfigPath, + Namespace: "explicit-ns", + }) + + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + + // Explicit namespace should override context namespace + if client.Namespace != "explicit-ns" { + t.Errorf("expected namespace 'explicit-ns', got '%s'", client.Namespace) + } +} + +func TestNewClient_DefaultNamespace(t *testing.T) { + // Create kubeconfig with no namespace in context + kubeconfigPath := createTestKubeconfig(t, "") + + client, err := NewClient(ClientOptions{ + Kubeconfig: kubeconfigPath, + }) + + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + + // Should fall back to "default" + if client.Namespace != "default" { + t.Errorf("expected namespace 'default', got '%s'", client.Namespace) + } +} + +func TestNewClient_WithTimeout(t *testing.T) { + kubeconfigPath := createTestKubeconfig(t, "test-ns") + + client, err := NewClient(ClientOptions{ + Kubeconfig: kubeconfigPath, + Timeout: 30 * time.Second, + }) + + if err != nil { + t.Fatalf("NewClient failed: %v", err) + } + + if client.Config.Timeout != 30*time.Second { + t.Errorf("expected timeout 30s, got %v", client.Config.Timeout) + } +} + +func TestGetCurrentNamespace_FromContext(t *testing.T) { + // Create kubeconfig with namespace + kubeconfigPath := createTestKubeconfig(t, "test-ns") + + // Set KUBECONFIG env (t.Setenv restores original value on cleanup) + t.Setenv("KUBECONFIG", kubeconfigPath) + + namespace, err := GetCurrentNamespace() + if err != nil { + t.Fatalf("GetCurrentNamespace failed: %v", err) + } + + if namespace != "test-ns" { + t.Errorf("expected namespace 'test-ns', got '%s'", namespace) + } +} + +func TestGetCurrentNamespace_Default(t *testing.T) { + // Create kubeconfig without namespace + kubeconfigPath := createTestKubeconfig(t, "") + + // Set KUBECONFIG env (t.Setenv restores original value on cleanup) + t.Setenv("KUBECONFIG", kubeconfigPath) + + namespace, err := GetCurrentNamespace() + if err != nil { + t.Fatalf("GetCurrentNamespace failed: %v", err) + } + + if namespace != "default" { + t.Errorf("expected namespace 'default', got '%s'", namespace) + } +} + +func TestWrapError_NotFound(t *testing.T) { + // Create a real NotFound error + notFoundErr := apierrors.NewNotFound( + schema.GroupResource{Group: "", Resource: "pods"}, + "test-pod", + ) + + wrapped := WrapError(notFoundErr, "get", "pod/test-pod", "test-ns") + + if wrapped == nil { + t.Fatal("WrapError returned nil for NotFound error") + } + + expectedMsg := "pod/test-pod not found in namespace test-ns" + if wrapped.Error() != expectedMsg { + t.Errorf("expected '%s', got '%s'", expectedMsg, wrapped.Error()) + } +} + +func TestWrapError_NotFoundNoNamespace(t *testing.T) { + notFoundErr := apierrors.NewNotFound( + schema.GroupResource{Group: "", Resource: "pods"}, + "test-pod", + ) + + wrapped := WrapError(notFoundErr, "get", "pod/test-pod", "") + + expectedMsg := "pod/test-pod not found" + if wrapped.Error() != expectedMsg { + t.Errorf("expected '%s', got '%s'", expectedMsg, wrapped.Error()) + } +} + +func TestWrapError_Forbidden(t *testing.T) { + forbiddenErr := apierrors.NewForbidden( + schema.GroupResource{Group: "", Resource: "secrets"}, + "my-secret", + nil, + ) + + wrapped := WrapError(forbiddenErr, "get", "secret/my-secret", "test-ns") + + if wrapped == nil { + t.Fatal("WrapError returned nil for Forbidden error") + } + + expectedMsg := "permission denied: cannot get secret/my-secret in namespace test-ns" + if wrapped.Error() != expectedMsg { + t.Errorf("expected '%s', got '%s'", expectedMsg, wrapped.Error()) + } +} + +func TestWrapError_GenericError(t *testing.T) { + // Create a generic timeout error + genericErr := apierrors.NewTimeoutError("request timeout", 30) + + wrapped := WrapError(genericErr, "list", "pods", "default") + + if wrapped == nil { + t.Fatal("WrapError returned nil for generic error") + } + + // Should contain the operation context + msg := wrapped.Error() + if len(msg) == 0 { + t.Error("wrapped error message is empty") + } + + // Generic errors should be wrapped with context + expected := "failed to list pods in namespace default:" + if len(msg) < len(expected) { + t.Errorf("expected message to start with '%s', got '%s'", expected, msg) + } +} + +func TestWrapError_Nil(t *testing.T) { + wrapped := WrapError(nil, "get", "pod", "default") + + if wrapped != nil { + t.Error("WrapError should return nil for nil error") + } +} + +func TestClient_WithFakeClientset(t *testing.T) { + // Create fake clientset with pre-populated objects + fakeClientset := fake.NewSimpleClientset( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-pod", + Namespace: "test-ns", + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-secret", + Namespace: "test-ns", + }, + }, + ) + + // Create a Client struct with the fake clientset + // This proves that kubernetes.Interface typing is correct + client := &Client{ + Clientset: fakeClientset, + Namespace: "test-ns", + } + + if client.Clientset == nil { + t.Fatal("Clientset is nil") + } + + // Verify we can use the fake clientset + pods, err := client.Clientset.CoreV1().Pods("test-ns").List( + context.Background(), + metav1.ListOptions{}, + ) + + if err != nil { + t.Fatalf("failed to list pods: %v", err) + } + + if len(pods.Items) != 1 { + t.Errorf("expected 1 pod, got %d", len(pods.Items)) + } + + if pods.Items[0].Name != "test-pod" { + t.Errorf("expected pod name 'test-pod', got '%s'", pods.Items[0].Name) + } +} + +func TestClient_FakeClientset_NotFound(t *testing.T) { + fakeClientset := fake.NewSimpleClientset() + + client := &Client{ + Clientset: fakeClientset, + Namespace: "test-ns", + } + + // Try to get a non-existent pod + _, err := client.Clientset.CoreV1().Pods("test-ns").Get( + context.Background(), + "nonexistent", + metav1.GetOptions{}, + ) + + if err == nil { + t.Fatal("expected error for non-existent pod") + } + + // Verify it's a NotFound error + if !apierrors.IsNotFound(err) { + t.Errorf("expected NotFound error, got %T: %v", err, err) + } + + // Test WrapError with the real NotFound error + wrapped := WrapError(err, "get", "pod/nonexistent", "test-ns") + expectedMsg := "pod/nonexistent not found in namespace test-ns" + if wrapped.Error() != expectedMsg { + t.Errorf("expected '%s', got '%s'", expectedMsg, wrapped.Error()) + } +} + +func TestNewClient_InvalidKubeconfig(t *testing.T) { + // Create an invalid kubeconfig + dir := t.TempDir() + invalidPath := filepath.Join(dir, "invalid-config") + if err := os.WriteFile(invalidPath, []byte("not valid yaml: ["), 0600); err != nil { + t.Fatalf("failed to write invalid kubeconfig: %v", err) + } + + _, err := NewClient(ClientOptions{ + Kubeconfig: invalidPath, + }) + + if err == nil { + t.Error("expected error for invalid kubeconfig, got nil") + } +} + +func TestNewClient_NonExistentKubeconfig(t *testing.T) { + _, err := NewClient(ClientOptions{ + Kubeconfig: "/nonexistent/path/to/kubeconfig", + }) + + if err == nil { + t.Error("expected error for non-existent kubeconfig, got nil") + } +} diff --git a/pkg/secrets/kubernetes.go b/pkg/secrets/kubernetes.go index 358bdff..15c3968 100644 --- a/pkg/secrets/kubernetes.go +++ b/pkg/secrets/kubernetes.go @@ -2,129 +2,117 @@ package secrets import ( "context" - "encoding/json" - "errors" "fmt" "os/exec" "strings" + + "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "github.com/confidential-devhub/cococtl/pkg/k8s" ) // SecretKeys holds the keys found in a K8s secret +// Kept for backward compatibility with converter and cmd layer type SecretKeys struct { Name string Namespace string Keys []string } -// K8sSecret represents the structure of a K8s secret from kubectl output -type K8sSecret struct { - Metadata struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - } `json:"metadata"` - Data map[string]string `json:"data"` // Keys are base64 encoded values -} - -// GetCurrentNamespace returns the current namespace from kubectl config. -// If no namespace is set in the current context or if there is no current context, -// returns "default". -func GetCurrentNamespace() (string, error) { - ctx := context.Background() - - // First check if a current context exists - checkCmd := exec.CommandContext(ctx, "kubectl", "config", "current-context") - if err := checkCmd.Run(); err != nil { - // No current context is not an error; we intentionally return "default" - return "default", nil //nolint:nilerr - } - - // Get namespace from the current context - cmd := exec.CommandContext(ctx, "kubectl", "config", "view", "--minify", "-o", "jsonpath={..namespace}") - output, err := cmd.Output() - if err != nil { - return "", fmt.Errorf("failed to get current namespace: %w", err) +// SecretToSecretKeys converts a corev1.Secret to SecretKeys format +func SecretToSecretKeys(secret *corev1.Secret) *SecretKeys { + keys := make([]string, 0, len(secret.Data)) + for key := range secret.Data { + keys = append(keys, key) } - namespace := strings.TrimSpace(string(output)) - if namespace == "" { - namespace = "default" + return &SecretKeys{ + Name: secret.Name, + Namespace: secret.Namespace, + Keys: keys, } - - return namespace, nil } -// InspectSecret queries K8s to get all keys in a secret -// If namespace is empty, uses current context namespace (no -n flag) -// Returns error if kubectl fails or secret doesn't exist -func InspectSecret(secretName, namespace string) (*SecretKeys, error) { - ctx := context.Background() - // Build kubectl command - var cmd *exec.Cmd - if namespace != "" { - // Explicit namespace specified - cmd = exec.CommandContext(ctx, "kubectl", "get", "secret", secretName, "-n", namespace, "-o", "json") - } else { - // No namespace specified - use current context namespace - cmd = exec.CommandContext(ctx, "kubectl", "get", "secret", secretName, "-o", "json") +// ToSecretKeys converts a map of corev1.Secret to SecretKeys format +func ToSecretKeys(secrets map[string]*corev1.Secret) map[string]*SecretKeys { + result := make(map[string]*SecretKeys, len(secrets)) + for name, secret := range secrets { + result[name] = SecretToSecretKeys(secret) } + return result +} - // Execute command - output, err := cmd.Output() - if err != nil { - // Check if it's an exit error with stderr - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return nil, fmt.Errorf("kubectl get secret failed: %s", string(exitErr.Stderr)) +// InspectSecret queries K8s to get a secret using client-go +// If namespace is empty, uses current context namespace +// Returns typed *corev1.Secret with decoded Data field +func InspectSecret(ctx context.Context, clientset kubernetes.Interface, secretName, namespace string) (*corev1.Secret, error) { + // Resolve empty namespace to current context namespace + ns := namespace + if ns == "" { + var err error + ns, err = k8s.GetCurrentNamespace() + if err != nil { + return nil, fmt.Errorf("failed to resolve namespace: %w", err) } - return nil, fmt.Errorf("kubectl get secret failed: %w", err) } - // Parse JSON output - var k8sSecret K8sSecret - if err := json.Unmarshal(output, &k8sSecret); err != nil { - return nil, fmt.Errorf("failed to parse kubectl output: %w", err) - } - - // Extract keys - keys := make([]string, 0, len(k8sSecret.Data)) - for key := range k8sSecret.Data { - keys = append(keys, key) + // Get secret using client-go + secret, err := clientset.CoreV1().Secrets(ns).Get(ctx, secretName, metav1.GetOptions{}) + if err != nil { + return nil, k8s.WrapError(err, "get", fmt.Sprintf("secret/%s", secretName), ns) } - // Use the actual namespace from kubectl response (not the input parameter) - actualNamespace := k8sSecret.Metadata.Namespace - - return &SecretKeys{ - Name: secretName, - Namespace: actualNamespace, - Keys: keys, - }, nil + return secret, nil } -// InspectSecrets queries multiple secrets in batch -// Returns a map of secretName -> SecretKeys (includes namespace and keys) +// InspectSecrets queries multiple secrets in batch using client-go +// Returns a map of secretName -> *corev1.Secret // Fails immediately on first error -func InspectSecrets(refs []SecretReference) (map[string]*SecretKeys, error) { - result := make(map[string]*SecretKeys) +func InspectSecrets(ctx context.Context, clientset kubernetes.Interface, refs []SecretReference) (map[string]*corev1.Secret, error) { + result := make(map[string]*corev1.Secret) for _, ref := range refs { // Skip if lookup not needed (all keys already known) if !ref.NeedsLookup { if len(ref.Keys) > 0 { - // For secrets that don't need lookup, we still need namespace - // Use the namespace from ref (could be empty or explicit) - // If empty, it will be resolved during conversion - result[ref.Name] = &SecretKeys{ - Name: ref.Name, - Namespace: ref.Namespace, - Keys: ref.Keys, + // For secrets that don't need lookup, create minimal corev1.Secret + // with known keys populated + data := make(map[string][]byte) + for _, key := range ref.Keys { + data[key] = []byte{} // Empty data - actual values unknown + } + + // Resolve namespace if empty + ns := ref.Namespace + if ns == "" { + var err error + ns, err = k8s.GetCurrentNamespace() + if err != nil { + return nil, fmt.Errorf("failed to resolve namespace for secret %s: %w", ref.Name, err) + } + } + + result[ref.Name] = &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: ref.Name, + Namespace: ns, + }, + Data: data, } } continue } - // Inspect the secret - secretKeys, err := InspectSecret(ref.Name, ref.Namespace) + // clientset required for cluster lookup + if clientset == nil { + return nil, fmt.Errorf("cluster connection required to inspect secret %q (needs key enumeration for %s usage)", ref.Name, describeUsageTypes(ref.Usages)) + } + + // Inspect the secret using client-go + secret, err := InspectSecret(ctx, clientset, ref.Name, ref.Namespace) if err != nil { // Fail immediately nsInfo := "current context namespace" @@ -134,27 +122,7 @@ func InspectSecrets(refs []SecretReference) (map[string]*SecretKeys, error) { return nil, fmt.Errorf("failed to inspect secret %s in %s: %w", ref.Name, nsInfo, err) } - // Merge with known keys - allKeys := make(map[string]bool) - for _, key := range ref.Keys { - allKeys[key] = true - } - for _, key := range secretKeys.Keys { - allKeys[key] = true - } - - // Convert to slice - keys := make([]string, 0, len(allKeys)) - for key := range allKeys { - keys = append(keys, key) - } - - // Store with actual namespace from kubectl - result[ref.Name] = &SecretKeys{ - Name: ref.Name, - Namespace: secretKeys.Namespace, - Keys: keys, - } + result[ref.Name] = secret } return result, nil @@ -166,36 +134,31 @@ func InspectSecrets(refs []SecretReference) (map[string]*SecretKeys, error) { func GenerateSealedSecretYAML(secretName, namespace string, sealedData map[string]string) (string, string, error) { sealedSecretName := secretName + "-sealed" - // Build kubectl command to create secret - args := []string{"create", "secret", "generic", sealedSecretName} - - // Add namespace if specified - if namespace != "" { - args = append(args, "-n", namespace) + // Build Kubernetes Secret structure using stringData for readability + // stringData is functionally equivalent to data (Kubernetes auto-encodes on apply) + secret := map[string]interface{}{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]interface{}{ + "name": sealedSecretName, + "namespace": namespace, + }, + "type": "Opaque", + "stringData": sealedData, } - // Add each sealed secret as a literal - for key, sealedValue := range sealedData { - args = append(args, fmt.Sprintf("--from-literal=%s=%s", key, sealedValue)) + // Omit namespace from metadata if empty (matches kubectl behavior) + if namespace == "" { + metadata := secret["metadata"].(map[string]interface{}) + delete(metadata, "namespace") } - // Add --dry-run=client and -o yaml to generate YAML without applying - args = append(args, "--dry-run=client", "-o", "yaml") - - // Execute command to generate YAML - // #nosec G204 - args are constructed from application-controlled inputs (secret name, namespace, sealed values) - // No arbitrary user input is passed to kubectl - cmd := exec.Command("kubectl", args...) - output, err := cmd.Output() + yamlData, err := yaml.Marshal(secret) if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", "", fmt.Errorf("kubectl create secret failed: %s", string(exitErr.Stderr)) - } - return "", "", fmt.Errorf("kubectl create secret failed: %w", err) + return "", "", fmt.Errorf("failed to marshal sealed secret YAML: %w", err) } - return sealedSecretName, string(output), nil + return sealedSecretName, string(yamlData), nil } // CreateSealedSecret creates a K8s secret with sealed secret values @@ -300,47 +263,44 @@ func CreateSealedSecrets(sealedSecrets []*SealedSecretData) (map[string]string, return result, nil } -// ServiceAccount represents the structure of a K8s service account from kubectl output -type ServiceAccount struct { - Metadata struct { - Name string `json:"name"` - Namespace string `json:"namespace"` - } `json:"metadata"` - ImagePullSecrets []struct { - Name string `json:"name"` - } `json:"imagePullSecrets"` -} - // GetServiceAccountImagePullSecrets queries a service account for imagePullSecrets // If namespace is empty, uses current context namespace // Returns the first imagePullSecret name or empty string if none found -func GetServiceAccountImagePullSecrets(serviceAccountName, namespace string) (string, error) { - ctx := context.Background() - - var cmd *exec.Cmd - if namespace != "" { - cmd = exec.CommandContext(ctx, "kubectl", "get", "sa", serviceAccountName, "-n", namespace, "-o", "json") - } else { - cmd = exec.CommandContext(ctx, "kubectl", "get", "sa", serviceAccountName, "-o", "json") - } - - output, err := cmd.Output() - if err != nil { - var exitErr *exec.ExitError - if errors.As(err, &exitErr) { - return "", fmt.Errorf("kubectl get sa failed: %s", string(exitErr.Stderr)) +func GetServiceAccountImagePullSecrets(ctx context.Context, clientset kubernetes.Interface, serviceAccountName, namespace string) (string, error) { + // Resolve empty namespace to current context namespace + ns := namespace + if ns == "" { + var err error + ns, err = k8s.GetCurrentNamespace() + if err != nil { + return "", fmt.Errorf("failed to resolve namespace: %w", err) } - return "", fmt.Errorf("kubectl get sa failed: %w", err) } - var sa ServiceAccount - if err := json.Unmarshal(output, &sa); err != nil { - return "", fmt.Errorf("failed to parse kubectl output: %w", err) + // Get ServiceAccount using client-go + sa, err := clientset.CoreV1().ServiceAccounts(ns).Get(ctx, serviceAccountName, metav1.GetOptions{}) + if err != nil { + return "", k8s.WrapError(err, "get", fmt.Sprintf("serviceaccount/%s", serviceAccountName), ns) } + // Typed field access - ImagePullSecrets is []corev1.LocalObjectReference if len(sa.ImagePullSecrets) == 0 { return "", nil } + // Return first imagePullSecret name return sa.ImagePullSecrets[0].Name, nil } + +// describeUsageTypes returns a comma-separated list of usage types for error messages +func describeUsageTypes(usages []SecretUsage) string { + types := make([]string, 0, len(usages)) + seen := make(map[string]bool) + for _, u := range usages { + if !seen[u.Type] { + types = append(types, u.Type) + seen[u.Type] = true + } + } + return strings.Join(types, ", ") +} diff --git a/pkg/secrets/kubernetes_test.go b/pkg/secrets/kubernetes_test.go new file mode 100644 index 0000000..6e168f5 --- /dev/null +++ b/pkg/secrets/kubernetes_test.go @@ -0,0 +1,765 @@ +package secrets + +import ( + "context" + "strings" + "testing" + + "gopkg.in/yaml.v3" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestInspectSecret_Found(t *testing.T) { + // Setup fake clientset with a secret + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "username": []byte("admin"), + "password": []byte("secret123"), + }, + Type: corev1.SecretTypeOpaque, + }, + ) + + ctx := context.Background() + secret, err := InspectSecret(ctx, fakeClient, "my-secret", "default") + if err != nil { + t.Fatalf("InspectSecret() error = %v", err) + } + + // Verify secret metadata + if secret.Name != "my-secret" { + t.Errorf("InspectSecret() Name = %q, want %q", secret.Name, "my-secret") + } + if secret.Namespace != "default" { + t.Errorf("InspectSecret() Namespace = %q, want %q", secret.Namespace, "default") + } + + // Verify data keys exist + if len(secret.Data) != 2 { + t.Errorf("InspectSecret() Data has %d keys, want 2", len(secret.Data)) + } + if _, ok := secret.Data["username"]; !ok { + t.Error("InspectSecret() Data missing 'username' key") + } + if _, ok := secret.Data["password"]; !ok { + t.Error("InspectSecret() Data missing 'password' key") + } + + // Verify data values are decoded (not base64) + if string(secret.Data["username"]) != "admin" { + t.Errorf("InspectSecret() Data['username'] = %q, want %q", string(secret.Data["username"]), "admin") + } +} + +func TestInspectSecret_NotFound(t *testing.T) { + // Setup empty fake clientset + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + _, err := InspectSecret(ctx, fakeClient, "missing-secret", "default") + if err == nil { + t.Fatal("InspectSecret() expected error for missing secret, got nil") + } + + // Error should mention the secret name + if !strings.Contains(err.Error(), "missing-secret") { + t.Errorf("InspectSecret() error = %q, want error mentioning 'missing-secret'", err.Error()) + } +} + +func TestInspectSecret_WithExplicitNamespace(t *testing.T) { + // Setup fake clientset with secret in custom namespace + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "custom-ns", + }, + Data: map[string][]byte{ + "key1": []byte("value1"), + }, + }, + ) + + ctx := context.Background() + secret, err := InspectSecret(ctx, fakeClient, "my-secret", "custom-ns") + if err != nil { + t.Fatalf("InspectSecret() error = %v", err) + } + + if secret.Namespace != "custom-ns" { + t.Errorf("InspectSecret() Namespace = %q, want %q", secret.Namespace, "custom-ns") + } +} + +func TestInspectSecret_EmptyNamespaceResolution(t *testing.T) { + // Setup fake clientset with secret in default namespace + // Note: fake clientset doesn't validate empty namespace (known limitation) + // Real client would error: "an empty namespace may not be set when a resource name is provided" + // This test verifies we resolve namespace before calling API + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "key1": []byte("value1"), + }, + }, + ) + + ctx := context.Background() + // Empty namespace should resolve to current context namespace (likely "default") + secret, err := InspectSecret(ctx, fakeClient, "my-secret", "") + if err != nil { + t.Fatalf("InspectSecret() error = %v", err) + } + + // Verify namespace was resolved (not empty) + if secret.Namespace == "" { + t.Error("InspectSecret() returned secret with empty namespace, expected resolved namespace") + } +} + +func TestInspectSecret_DataFieldDecoding(t *testing.T) { + // Setup fake clientset with secret containing various data types + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "data-secret", + Namespace: "default", + }, + Data: map[string][]byte{ + "text": []byte("plain text"), + "number": []byte("12345"), + "json": []byte(`{"key":"value"}`), + }, + Type: corev1.SecretTypeOpaque, + }, + ) + + ctx := context.Background() + secret, err := InspectSecret(ctx, fakeClient, "data-secret", "default") + if err != nil { + t.Fatalf("InspectSecret() error = %v", err) + } + + // Verify all data fields are accessible as []byte (auto-decoded from base64 in etcd) + if string(secret.Data["text"]) != "plain text" { + t.Errorf("InspectSecret() Data['text'] = %q, want %q", string(secret.Data["text"]), "plain text") + } + if string(secret.Data["number"]) != "12345" { + t.Errorf("InspectSecret() Data['number'] = %q, want %q", string(secret.Data["number"]), "12345") + } + if string(secret.Data["json"]) != `{"key":"value"}` { + t.Errorf("InspectSecret() Data['json'] = %q, want %q", string(secret.Data["json"]), `{"key":"value"}`) + } + + // Verify Data is map[string][]byte (not base64 strings) + for key, val := range secret.Data { + if val == nil { + t.Errorf("InspectSecret() Data[%q] is nil, expected []byte", key) + } + } +} + +func TestInspectSecrets_MultipleSecrets(t *testing.T) { + // Setup fake clientset with multiple secrets + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret1", + Namespace: "default", + }, + Data: map[string][]byte{ + "key1": []byte("value1"), + }, + }, + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret2", + Namespace: "default", + }, + Data: map[string][]byte{ + "key2": []byte("value2"), + }, + }, + ) + + ctx := context.Background() + refs := []SecretReference{ + {Name: "secret1", Namespace: "default", NeedsLookup: true}, + {Name: "secret2", Namespace: "default", NeedsLookup: true}, + } + + secrets, err := InspectSecrets(ctx, fakeClient, refs) + if err != nil { + t.Fatalf("InspectSecrets() error = %v", err) + } + + // Verify both secrets returned + if len(secrets) != 2 { + t.Errorf("InspectSecrets() returned %d secrets, want 2", len(secrets)) + } + + // Verify secret1 + if secret1, ok := secrets["secret1"]; !ok { + t.Error("InspectSecrets() missing 'secret1' in results") + } else { + if secret1.Name != "secret1" { + t.Errorf("InspectSecrets() secret1.Name = %q, want %q", secret1.Name, "secret1") + } + if _, ok := secret1.Data["key1"]; !ok { + t.Error("InspectSecrets() secret1 missing 'key1' in Data") + } + } + + // Verify secret2 + if secret2, ok := secrets["secret2"]; !ok { + t.Error("InspectSecrets() missing 'secret2' in results") + } else { + if secret2.Name != "secret2" { + t.Errorf("InspectSecrets() secret2.Name = %q, want %q", secret2.Name, "secret2") + } + if _, ok := secret2.Data["key2"]; !ok { + t.Error("InspectSecrets() secret2 missing 'key2' in Data") + } + } +} + +func TestInspectSecrets_NoLookupNeeded(t *testing.T) { + // Setup empty fake clientset (secret doesn't need to exist) + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + refs := []SecretReference{ + { + Name: "secret1", + Namespace: "default", + Keys: []string{"key1", "key2"}, + NeedsLookup: false, // Keys already known, no lookup needed + }, + } + + secrets, err := InspectSecrets(ctx, fakeClient, refs) + if err != nil { + t.Fatalf("InspectSecrets() error = %v", err) + } + + // Verify secret returned with known keys + if len(secrets) != 1 { + t.Errorf("InspectSecrets() returned %d secrets, want 1", len(secrets)) + } + + secret, ok := secrets["secret1"] + if !ok { + t.Fatal("InspectSecrets() missing 'secret1' in results") + } + + // Verify metadata populated + if secret.Name != "secret1" { + t.Errorf("InspectSecrets() secret.Name = %q, want %q", secret.Name, "secret1") + } + if secret.Namespace != "default" { + t.Errorf("InspectSecrets() secret.Namespace = %q, want %q", secret.Namespace, "default") + } + + // Verify keys are present (values will be empty) + if len(secret.Data) != 2 { + t.Errorf("InspectSecrets() secret.Data has %d keys, want 2", len(secret.Data)) + } + if _, ok := secret.Data["key1"]; !ok { + t.Error("InspectSecrets() secret missing 'key1' in Data") + } + if _, ok := secret.Data["key2"]; !ok { + t.Error("InspectSecrets() secret missing 'key2' in Data") + } +} + +func TestInspectSecrets_FailFast(t *testing.T) { + // Setup fake clientset with only one secret + fakeClient := fake.NewSimpleClientset( + &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: "secret1", + Namespace: "default", + }, + Data: map[string][]byte{ + "key1": []byte("value1"), + }, + }, + ) + + ctx := context.Background() + refs := []SecretReference{ + {Name: "secret1", Namespace: "default", NeedsLookup: true}, + {Name: "missing-secret", Namespace: "default", NeedsLookup: true}, + {Name: "secret3", Namespace: "default", NeedsLookup: true}, + } + + _, err := InspectSecrets(ctx, fakeClient, refs) + if err == nil { + t.Fatal("InspectSecrets() expected error for missing secret, got nil") + } + + // Error should mention the missing secret (fail-fast on first error) + if !strings.Contains(err.Error(), "missing-secret") { + t.Errorf("InspectSecrets() error = %q, want error mentioning 'missing-secret'", err.Error()) + } +} + +func TestGetServiceAccountImagePullSecrets_Found(t *testing.T) { + // Setup fake clientset with serviceaccount that has imagePullSecrets + fakeClient := fake.NewSimpleClientset( + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: "default", + }, + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: "regcred"}, + {Name: "regcred2"}, + }, + }, + ) + + ctx := context.Background() + secretName, err := GetServiceAccountImagePullSecrets(ctx, fakeClient, "test-sa", "default") + if err != nil { + t.Fatalf("GetServiceAccountImagePullSecrets() error = %v, want nil", err) + } + + // Should return first imagePullSecret name + expectedName := "regcred" + if secretName != expectedName { + t.Errorf("GetServiceAccountImagePullSecrets() = %q, want %q", secretName, expectedName) + } +} + +func TestGetServiceAccountImagePullSecrets_NotFound(t *testing.T) { + // Empty fake clientset - serviceaccount doesn't exist + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + _, err := GetServiceAccountImagePullSecrets(ctx, fakeClient, "missing-sa", "default") + if err == nil { + t.Fatal("GetServiceAccountImagePullSecrets() expected error for missing serviceaccount, got nil") + } + + // Error should mention the serviceaccount name + if !strings.Contains(err.Error(), "missing-sa") && !strings.Contains(err.Error(), "not found") { + t.Errorf("GetServiceAccountImagePullSecrets() error = %q, want error mentioning 'missing-sa' or 'not found'", err.Error()) + } +} + +func TestGetServiceAccountImagePullSecrets_NoSecrets(t *testing.T) { + // Setup fake clientset with serviceaccount but no imagePullSecrets + fakeClient := fake.NewSimpleClientset( + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: "default", + }, + ImagePullSecrets: []corev1.LocalObjectReference{}, + }, + ) + + ctx := context.Background() + secretName, err := GetServiceAccountImagePullSecrets(ctx, fakeClient, "test-sa", "default") + if err != nil { + t.Fatalf("GetServiceAccountImagePullSecrets() error = %v, want nil", err) + } + + // Should return empty string when no imagePullSecrets configured + if secretName != "" { + t.Errorf("GetServiceAccountImagePullSecrets() = %q, want empty string", secretName) + } +} + +func TestGetServiceAccountImagePullSecrets_EmptyNamespace(t *testing.T) { + // Setup fake clientset with serviceaccount in default namespace + // Note: fake clientset doesn't validate empty namespace (known limitation) + // Real client would error: "an empty namespace may not be set when a resource name is provided" + // This test verifies we resolve namespace before calling API + fakeClient := fake.NewSimpleClientset( + &corev1.ServiceAccount{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-sa", + Namespace: "default", + }, + ImagePullSecrets: []corev1.LocalObjectReference{ + {Name: "regcred"}, + }, + }, + ) + + ctx := context.Background() + // Empty namespace should resolve to current context namespace + secretName, err := GetServiceAccountImagePullSecrets(ctx, fakeClient, "test-sa", "") + if err != nil { + t.Fatalf("GetServiceAccountImagePullSecrets() error = %v, want nil", err) + } + + // Should still return the secret name + expectedName := "regcred" + if secretName != expectedName { + t.Errorf("GetServiceAccountImagePullSecrets() = %q, want %q", secretName, expectedName) + } +} + +func TestGenerateSealedSecretYAML_NativeYAML(t *testing.T) { + // Test native YAML generation produces correct Kubernetes Secret structure + secretName := "db-creds" + namespace := "production" + sealedData := map[string]string{ + "password": "sealed-value", + "username": "sealed-user", + } + + sealedName, yamlContent, err := GenerateSealedSecretYAML(secretName, namespace, sealedData) + if err != nil { + t.Fatalf("GenerateSealedSecretYAML() error = %v", err) + } + + // Verify returned name + expectedName := "db-creds-sealed" + if sealedName != expectedName { + t.Errorf("GenerateSealedSecretYAML() name = %q, want %q", sealedName, expectedName) + } + + // Parse the YAML + var secret map[string]interface{} + if err := yaml.Unmarshal([]byte(yamlContent), &secret); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) + } + + // Verify apiVersion + if secret["apiVersion"] != "v1" { + t.Errorf("apiVersion = %q, want %q", secret["apiVersion"], "v1") + } + + // Verify kind + if secret["kind"] != "Secret" { + t.Errorf("kind = %q, want %q", secret["kind"], "Secret") + } + + // Verify type + if secret["type"] != "Opaque" { + t.Errorf("type = %q, want %q", secret["type"], "Opaque") + } + + // Verify metadata + metadata, ok := secret["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("metadata is not a map") + } + if metadata["name"] != expectedName { + t.Errorf("metadata.name = %q, want %q", metadata["name"], expectedName) + } + if metadata["namespace"] != namespace { + t.Errorf("metadata.namespace = %q, want %q", metadata["namespace"], namespace) + } + + // Verify stringData contains correct keys and values + stringData, ok := secret["stringData"].(map[string]interface{}) + if !ok { + t.Fatal("stringData is not a map") + } + if stringData["password"] != "sealed-value" { + t.Errorf("stringData.password = %q, want %q", stringData["password"], "sealed-value") + } + if stringData["username"] != "sealed-user" { + t.Errorf("stringData.username = %q, want %q", stringData["username"], "sealed-user") + } +} + +func TestGenerateSealedSecretYAML_EmptyNamespace(t *testing.T) { + // Test that empty namespace is omitted from metadata + secretName := "test-secret" + namespace := "" + sealedData := map[string]string{ + "key": "value", + } + + _, yamlContent, err := GenerateSealedSecretYAML(secretName, namespace, sealedData) + if err != nil { + t.Fatalf("GenerateSealedSecretYAML() error = %v", err) + } + + // Parse the YAML + var secret map[string]interface{} + if err := yaml.Unmarshal([]byte(yamlContent), &secret); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) + } + + // Verify metadata does NOT contain namespace key + metadata, ok := secret["metadata"].(map[string]interface{}) + if !ok { + t.Fatal("metadata is not a map") + } + if _, hasNamespace := metadata["namespace"]; hasNamespace { + t.Error("metadata contains namespace key when it should be omitted for empty namespace") + } +} + +func TestGenerateSealedSecretYAML_SpecialCharacters(t *testing.T) { + // Test that JWS-format values are correctly included + secretName := "jws-secret" + namespace := "default" + sealedData := map[string]string{ + "key": "sealed.fakejwsheader.eyJ0ZXN0IjoiZGF0YSJ9.fakesignature", + } + + _, yamlContent, err := GenerateSealedSecretYAML(secretName, namespace, sealedData) + if err != nil { + t.Fatalf("GenerateSealedSecretYAML() error = %v", err) + } + + // Verify the YAML contains the value (no escaping issues) + if !strings.Contains(yamlContent, "sealed.fakejwsheader.eyJ0ZXN0IjoiZGF0YSJ9.fakesignature") { + t.Errorf("YAML does not contain expected JWS value, got:\n%s", yamlContent) + } + + // Parse to verify it's valid YAML + var secret map[string]interface{} + if err := yaml.Unmarshal([]byte(yamlContent), &secret); err != nil { + t.Fatalf("Failed to parse YAML with special characters: %v", err) + } + + // Verify stringData preserves the value + stringData, ok := secret["stringData"].(map[string]interface{}) + if !ok { + t.Fatal("stringData is not a map") + } + if stringData["key"] != "sealed.fakejwsheader.eyJ0ZXN0IjoiZGF0YSJ9.fakesignature" { + t.Errorf("stringData.key = %q, want JWS value", stringData["key"]) + } +} + +func TestInspectSecrets_NilClientset_OfflineRefsSucceed(t *testing.T) { + // Test that offline refs (NeedsLookup=false) succeed with nil clientset + ctx := context.Background() + refs := []SecretReference{ + { + Name: "offline-secret", + Namespace: "default", + Keys: []string{"key1", "key2"}, + NeedsLookup: false, + }, + } + + secrets, err := InspectSecrets(ctx, nil, refs) + if err != nil { + t.Fatalf("InspectSecrets() with nil clientset and offline refs error = %v, want nil", err) + } + + // Verify secret returned + if len(secrets) != 1 { + t.Errorf("InspectSecrets() returned %d secrets, want 1", len(secrets)) + } + + secret, ok := secrets["offline-secret"] + if !ok { + t.Fatal("InspectSecrets() missing 'offline-secret' in results") + } + + // Verify metadata + if secret.Name != "offline-secret" { + t.Errorf("secret.Name = %q, want %q", secret.Name, "offline-secret") + } + if secret.Namespace != "default" { + t.Errorf("secret.Namespace = %q, want %q", secret.Namespace, "default") + } + + // Verify keys are present + if len(secret.Data) != 2 { + t.Errorf("secret.Data has %d keys, want 2", len(secret.Data)) + } + if _, ok := secret.Data["key1"]; !ok { + t.Error("secret missing 'key1' in Data") + } + if _, ok := secret.Data["key2"]; !ok { + t.Error("secret missing 'key2' in Data") + } +} + +func TestInspectSecrets_NilClientset_ClusterRefsError(t *testing.T) { + // Test that cluster refs (NeedsLookup=true) fail with nil clientset + ctx := context.Background() + refs := []SecretReference{ + { + Name: "cluster-secret", + Namespace: "default", + NeedsLookup: true, + Usages: []SecretUsage{ + {Type: "volume"}, + }, + }, + } + + _, err := InspectSecrets(ctx, nil, refs) + if err == nil { + t.Fatal("InspectSecrets() with nil clientset and cluster refs expected error, got nil") + } + + // Verify error mentions cluster connection required + if !strings.Contains(err.Error(), "cluster connection required") { + t.Errorf("error = %q, want error mentioning 'cluster connection required'", err.Error()) + } + + // Verify error mentions the secret name + if !strings.Contains(err.Error(), "cluster-secret") { + t.Errorf("error = %q, want error mentioning 'cluster-secret'", err.Error()) + } +} + +func TestInspectSecrets_NilClientset_MixedRefs(t *testing.T) { + // Test that mixed refs (offline + cluster) fail on the cluster ref + ctx := context.Background() + refs := []SecretReference{ + { + Name: "offline-secret", + Namespace: "default", + Keys: []string{"key1"}, + NeedsLookup: false, + }, + { + Name: "cluster-secret", + Namespace: "default", + NeedsLookup: true, + Usages: []SecretUsage{ + {Type: "env"}, + }, + }, + } + + _, err := InspectSecrets(ctx, nil, refs) + if err == nil { + t.Fatal("InspectSecrets() with nil clientset and mixed refs expected error, got nil") + } + + // Verify error mentions cluster connection (fails on cluster ref) + if !strings.Contains(err.Error(), "cluster connection required") { + t.Errorf("error = %q, want error mentioning 'cluster connection required'", err.Error()) + } + + // Verify error mentions the cluster secret name + if !strings.Contains(err.Error(), "cluster-secret") { + t.Errorf("error = %q, want error mentioning 'cluster-secret'", err.Error()) + } +} + +func TestOfflineSecretResolution_EndToEnd(t *testing.T) { + ctx := context.Background() + + // Simulate secrets with explicit keys from manifest + refs := []SecretReference{ + { + Name: "db-creds", + Namespace: "production", + Keys: []string{"password", "username"}, + NeedsLookup: false, + Usages: []SecretUsage{ + {Type: "env", Key: "password"}, + {Type: "env", Key: "username"}, + }, + }, + { + Name: "api-config", + Namespace: "production", + Keys: []string{"api-key"}, + NeedsLookup: false, + Usages: []SecretUsage{ + {Type: "volume", VolumeName: "config-vol"}, + }, + }, + } + + // InspectSecrets with nil clientset (offline mode) + inspected, err := InspectSecrets(ctx, nil, refs) + if err != nil { + t.Fatalf("InspectSecrets(nil) failed for offline refs: %v", err) + } + + // Verify synthetic secrets created + if len(inspected) != 2 { + t.Fatalf("Expected 2 inspected secrets, got %d", len(inspected)) + } + + // Convert to SecretKeys and then to sealed secrets + keys := ToSecretKeys(inspected) + sealed, err := ConvertSecrets(refs, keys) + if err != nil { + t.Fatalf("ConvertSecrets failed: %v", err) + } + + // Verify correct number of sealed secrets (3 total: 2 from db-creds + 1 from api-config) + if len(sealed) != 3 { + t.Fatalf("Expected 3 sealed secrets, got %d", len(sealed)) + } + + // Verify URIs follow consistent format + for _, s := range sealed { + expectedPrefix := "kbs:///production/" + if !strings.HasPrefix(s.ResourceURI, expectedPrefix) { + t.Errorf("ResourceURI %q doesn't start with %q", s.ResourceURI, expectedPrefix) + } + + // Verify sealed secret format + if !strings.HasPrefix(s.SealedSecret, "sealed.fakejwsheader.") { + t.Errorf("SealedSecret doesn't have correct format: %s", s.SealedSecret) + } + } +} + +func TestOfflineSecretResolution_ConsistentWithCluster(t *testing.T) { + ctx := context.Background() + + // Same secret resolved offline + offlineRef := SecretReference{ + Name: "test-secret", Namespace: "myns", + Keys: []string{"key1"}, NeedsLookup: false, + } + offlineResult, err := InspectSecrets(ctx, nil, []SecretReference{offlineRef}) + if err != nil { + t.Fatalf("Offline InspectSecrets failed: %v", err) + } + offlineKeys := ToSecretKeys(offlineResult) + offlineSealed, err := ConvertSecrets([]SecretReference{offlineRef}, offlineKeys) + if err != nil { + t.Fatalf("Offline ConvertSecrets failed: %v", err) + } + + // Same secret resolved via cluster (fake clientset) + fakeClient := fake.NewSimpleClientset(&corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "test-secret", Namespace: "myns"}, + Data: map[string][]byte{"key1": []byte("value1")}, + }) + clusterRef := SecretReference{ + Name: "test-secret", Namespace: "myns", + Keys: []string{"key1"}, NeedsLookup: true, + } + clusterResult, err := InspectSecrets(ctx, fakeClient, []SecretReference{clusterRef}) + if err != nil { + t.Fatalf("Cluster InspectSecrets failed: %v", err) + } + clusterKeys := ToSecretKeys(clusterResult) + clusterSealed, err := ConvertSecrets([]SecretReference{clusterRef}, clusterKeys) + if err != nil { + t.Fatalf("Cluster ConvertSecrets failed: %v", err) + } + + // URIs must match regardless of resolution path + if offlineSealed[0].ResourceURI != clusterSealed[0].ResourceURI { + t.Errorf("URI mismatch: offline=%q cluster=%q", + offlineSealed[0].ResourceURI, clusterSealed[0].ResourceURI) + } +} diff --git a/pkg/secrets/output_test.go b/pkg/secrets/output_test.go index fea6fb0..afd500f 100644 --- a/pkg/secrets/output_test.go +++ b/pkg/secrets/output_test.go @@ -1,10 +1,11 @@ package secrets import ( - "encoding/json" "os" "path/filepath" "testing" + + "gopkg.in/yaml.v3" ) func TestGenerateTrusteeConfig(t *testing.T) { @@ -38,8 +39,8 @@ func TestGenerateTrusteeConfig(t *testing.T) { } var config TrusteeConfig - if err := json.Unmarshal(data, &config); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) } // Verify content @@ -107,8 +108,8 @@ func TestGenerateTrusteeConfig_MultipleSecrets(t *testing.T) { } var config TrusteeConfig - if err := json.Unmarshal(data, &config); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) } if len(config.Secrets) != 2 { @@ -134,8 +135,8 @@ func TestGenerateTrusteeConfig_EmptySecrets(t *testing.T) { } var config TrusteeConfig - if err := json.Unmarshal(data, &config); err != nil { - t.Fatalf("Failed to parse JSON: %v", err) + if err := yaml.Unmarshal(data, &config); err != nil { + t.Fatalf("Failed to parse YAML: %v", err) } if len(config.Secrets) != 0 { diff --git a/pkg/secrets/secrets.go b/pkg/secrets/secrets.go index bedc004..5a7e7bf 100644 --- a/pkg/secrets/secrets.go +++ b/pkg/secrets/secrets.go @@ -1,8 +1,12 @@ package secrets import ( + "context" "fmt" + "k8s.io/client-go/kubernetes" + + "github.com/confidential-devhub/cococtl/pkg/k8s" "github.com/confidential-devhub/cococtl/pkg/manifest" ) @@ -37,7 +41,7 @@ func DetectSecrets(manifestData map[string]interface{}) ([]SecretReference, erro // If manifest doesn't specify namespace, get current kubectl context namespace if namespace == "" { var err error - namespace, err = GetCurrentNamespace() + namespace, err = k8s.GetCurrentNamespace() if err != nil { return nil, fmt.Errorf("manifest has no namespace and failed to get current namespace: %w", err) } @@ -296,8 +300,10 @@ func detectImagePullSecrets(spec map[string]interface{}, namespace string, secre } // DetectImagePullSecretsWithServiceAccount detects imagePullSecrets from manifest -// and falls back to default service account if none are found in the spec -func DetectImagePullSecretsWithServiceAccount(manifestData map[string]interface{}) ([]SecretReference, error) { +// and falls back to default service account if none are found in the spec. +// The clientset parameter is used for the service account fallback lookup; +// pass nil to skip the fallback. +func DetectImagePullSecretsWithServiceAccount(ctx context.Context, clientset kubernetes.Interface, manifestData map[string]interface{}) ([]SecretReference, error) { // Create manifest wrapper to reuse existing manifest methods m := manifest.GetFromData(manifestData) @@ -306,7 +312,7 @@ func DetectImagePullSecretsWithServiceAccount(manifestData map[string]interface{ // If manifest doesn't specify namespace, get current kubectl context namespace if namespace == "" { var err error - namespace, err = GetCurrentNamespace() + namespace, err = k8s.GetCurrentNamespace() if err != nil { return nil, fmt.Errorf("manifest has no namespace and failed to get current namespace: %w", err) } @@ -324,8 +330,8 @@ func DetectImagePullSecretsWithServiceAccount(manifestData map[string]interface{ detectImagePullSecrets(podSpec, namespace, secretsMap) // If no imagePullSecrets found in manifest, check default service account - if len(secretsMap) == 0 { - secretName, err := GetServiceAccountImagePullSecrets("default", namespace) + if len(secretsMap) == 0 && clientset != nil { + secretName, err := GetServiceAccountImagePullSecrets(ctx, clientset, "default", namespace) if err == nil && secretName != "" { // Found imagePullSecret in default service account ref := getOrCreateSecretRef(secretsMap, secretName, namespace) diff --git a/pkg/trustee/kbs.go b/pkg/trustee/kbs.go index fcb2f5f..09563a1 100644 --- a/pkg/trustee/kbs.go +++ b/pkg/trustee/kbs.go @@ -1,12 +1,16 @@ package trustee import ( + "context" "crypto/rand" "fmt" "os" "os/exec" "path/filepath" "strings" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" ) const kbsRepositoryPath = "/opt/confidential-containers/kbs/repository" @@ -14,7 +18,7 @@ const kbsRepositoryPath = "/opt/confidential-containers/kbs/repository" // UploadResource uploads a single resource to Trustee KBS. // The resourcePath should be relative (e.g., "default/sidecar-tls/server-cert"). // The data is the raw bytes to upload. -func UploadResource(namespace, resourcePath string, data []byte) error { +func UploadResource(ctx context.Context, clientset kubernetes.Interface, namespace, resourcePath string, data []byte) error { resources := []SecretResource{ { URI: "kbs:///" + resourcePath, @@ -22,13 +26,13 @@ func UploadResource(namespace, resourcePath string, data []byte) error { }, } - return populateSecrets(namespace, resources) + return populateSecrets(ctx, clientset, namespace, resources) } // UploadResources uploads multiple resources to Trustee KBS in a single operation. // Each resource is specified as a map entry where key is the resource path // (e.g., "default/sidecar-tls/server-cert") and value is the data bytes. -func UploadResources(namespace string, resources map[string][]byte) error { +func UploadResources(ctx context.Context, clientset kubernetes.Interface, namespace string, resources map[string][]byte) error { if len(resources) == 0 { return nil } @@ -41,35 +45,34 @@ func UploadResources(namespace string, resources map[string][]byte) error { }) } - return populateSecrets(namespace, secretResources) + return populateSecrets(ctx, clientset, namespace, secretResources) } // GetKBSPodName retrieves the name of the KBS pod in the specified namespace. -func GetKBSPodName(namespace string) (string, error) { - cmd := exec.Command("kubectl", "get", "pod", "-n", namespace, - "-l", "app=kbs", "-o", "jsonpath={.items[0].metadata.name}") - output, err := cmd.CombinedOutput() +func GetKBSPodName(ctx context.Context, clientset kubernetes.Interface, namespace string) (string, error) { + pods, err := clientset.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: "app=kbs", + }) if err != nil { - return "", fmt.Errorf("failed to get KBS pod: %w\n%s", err, output) + return "", fmt.Errorf("failed to list pods: %w", err) } - podName := strings.TrimSpace(string(output)) - if podName == "" { + if len(pods.Items) == 0 { return "", fmt.Errorf("no KBS pod found in namespace %s", namespace) } - return podName, nil + return pods.Items[0].Name, nil } // WaitForKBSReady waits for the KBS pod to be ready. -func WaitForKBSReady(namespace string) error { - podName, err := GetKBSPodName(namespace) +func WaitForKBSReady(ctx context.Context, clientset kubernetes.Interface, namespace string) error { + podName, err := GetKBSPodName(ctx, clientset, namespace) if err != nil { return err } // #nosec G204 - namespace is from function parameter, podName is from kubectl get output - cmd := exec.Command("kubectl", "wait", "--for=condition=ready", "--timeout=120s", + cmd := exec.CommandContext(ctx, "kubectl", "wait", "--for=condition=ready", "--timeout=120s", "-n", namespace, fmt.Sprintf("pod/%s", podName)) if output, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("pod not ready: %w\n%s", err, output) @@ -79,17 +82,17 @@ func WaitForKBSReady(namespace string) error { } // It uploads multiple secrets to KBS via kubectl cp. -func populateSecrets(namespace string, secrets []SecretResource) error { +func populateSecrets(ctx context.Context, clientset kubernetes.Interface, namespace string, secrets []SecretResource) error { if len(secrets) == 0 { return nil } - podName, err := GetKBSPodName(namespace) + podName, err := GetKBSPodName(ctx, clientset, namespace) if err != nil { return err } - if err := WaitForKBSReady(namespace); err != nil { + if err := WaitForKBSReady(ctx, clientset, namespace); err != nil { return err } @@ -123,7 +126,7 @@ func populateSecrets(namespace string, secrets []SecretResource) error { // #nosec G204 - namespace is from function parameter, tmpDir is from os.MkdirTemp, podName is from kubectl get // Use --no-preserve to avoid tar ownership errors when local files have different uid/gid than container - cmd := exec.Command("kubectl", "cp", "--no-preserve=true", "-n", namespace, + cmd := exec.CommandContext(ctx, "kubectl", "cp", "--no-preserve=true", "-n", namespace, tmpDir+"/.", podName+":"+kbsRepositoryPath+"/") output, err := cmd.CombinedOutput() if err != nil { diff --git a/pkg/trustee/kbs_test.go b/pkg/trustee/kbs_test.go new file mode 100644 index 0000000..ab8aa57 --- /dev/null +++ b/pkg/trustee/kbs_test.go @@ -0,0 +1,93 @@ +package trustee + +import ( + "context" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +func TestGetKBSPodName_Found(t *testing.T) { + // Setup fake clientset with KBS pod + fakeClient := fake.NewSimpleClientset( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs-pod-1", + Namespace: "trustee-ns", + Labels: map[string]string{"app": "kbs"}, + }, + }, + ) + + ctx := context.Background() + podName, err := GetKBSPodName(ctx, fakeClient, "trustee-ns") + if err != nil { + t.Fatalf("GetKBSPodName() error = %v, want nil", err) + } + + expectedName := "kbs-pod-1" + if podName != expectedName { + t.Errorf("GetKBSPodName() = %v, want %v", podName, expectedName) + } +} + +func TestGetKBSPodName_NotFound(t *testing.T) { + // Empty fake clientset - no pods + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + podName, err := GetKBSPodName(ctx, fakeClient, "trustee-ns") + if err == nil { + t.Fatalf("GetKBSPodName() error = nil, want error") + } + + if podName != "" { + t.Errorf("GetKBSPodName() = %v, want empty string on error", podName) + } + + expectedErr := "no KBS pod found in namespace trustee-ns" + if err.Error() != expectedErr { + t.Errorf("GetKBSPodName() error = %v, want %v", err.Error(), expectedErr) + } +} + +func TestGetKBSPodName_MultiplePods(t *testing.T) { + // Setup fake clientset with multiple KBS pods + fakeClient := fake.NewSimpleClientset( + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs-pod-1", + Namespace: "trustee-ns", + Labels: map[string]string{"app": "kbs"}, + }, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kbs-pod-2", + Namespace: "trustee-ns", + Labels: map[string]string{"app": "kbs"}, + }, + }, + &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-pod", + Namespace: "trustee-ns", + Labels: map[string]string{"app": "other"}, + }, + }, + ) + + ctx := context.Background() + podName, err := GetKBSPodName(ctx, fakeClient, "trustee-ns") + if err != nil { + t.Fatalf("GetKBSPodName() error = %v, want nil", err) + } + + // Should return first matching pod + expectedName := "kbs-pod-1" + if podName != expectedName { + t.Errorf("GetKBSPodName() = %v, want %v (first pod)", podName, expectedName) + } +} diff --git a/pkg/trustee/trustee.go b/pkg/trustee/trustee.go index 1a82405..2a6c18e 100644 --- a/pkg/trustee/trustee.go +++ b/pkg/trustee/trustee.go @@ -2,6 +2,7 @@ package trustee import ( + "context" "crypto/ed25519" "crypto/x509" "encoding/base64" @@ -12,6 +13,11 @@ import ( "os/exec" "path/filepath" "strings" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" ) const ( @@ -51,51 +57,45 @@ type SecretResource struct { } // IsDeployed checks if Trustee is already running in the namespace -func IsDeployed(namespace string) (bool, error) { - cmd := exec.Command("kubectl", "get", "deployment", "-n", namespace, "-l", trusteeLabel, "-o", "json") - output, err := cmd.CombinedOutput() +func IsDeployed(ctx context.Context, clientset kubernetes.Interface, namespace string) (bool, error) { + // List deployments with the trustee label + deployments, err := clientset.AppsV1().Deployments(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: trusteeLabel, + }) if err != nil { - if strings.Contains(string(output), "NotFound") || strings.Contains(string(output), "No resources found") { + // If namespace doesn't exist or no permission, treat as not deployed + if apierrors.IsNotFound(err) || apierrors.IsForbidden(err) { return false, nil } - return false, fmt.Errorf("failed to check Trustee deployment: %w\n%s", err, output) - } - - var result map[string]interface{} - if err := json.Unmarshal(output, &result); err != nil { - return false, fmt.Errorf("failed to parse kubectl output: %w", err) + return false, fmt.Errorf("failed to check Trustee deployment: %w", err) } - items, ok := result["items"].([]interface{}) - if !ok || len(items) == 0 { - return false, nil - } - - return true, nil + // Check if any deployments were found + return len(deployments.Items) > 0, nil } // Deploy deploys Trustee all-in-one KBS to the specified namespace -func Deploy(cfg *Config) error { - if err := ensureNamespace(cfg.Namespace); err != nil { +func Deploy(ctx context.Context, clientset kubernetes.Interface, cfg *Config) error { + if err := ensureNamespace(ctx, clientset, cfg.Namespace); err != nil { return fmt.Errorf("failed to create namespace: %w", err) } - if err := createAuthSecretFromKeys(cfg.Namespace); err != nil { + if err := createAuthSecretFromKeys(ctx, cfg.Namespace); err != nil { return fmt.Errorf("failed to create auth secret: %w", err) } - if err := deployConfigMaps(cfg.Namespace); err != nil { + if err := deployConfigMaps(ctx, cfg.Namespace); err != nil { return fmt.Errorf("failed to deploy ConfigMaps: %w", err) } // Deploy PCCS ConfigMap if PCCSURL is configured if cfg.PCCSURL != "" { - if err := deployPCCSConfigMap(cfg.Namespace, cfg.PCCSURL); err != nil { + if err := deployPCCSConfigMap(ctx, cfg.Namespace, cfg.PCCSURL); err != nil { return fmt.Errorf("failed to deploy PCCS ConfigMap: %w", err) } } - if err := deployKBS(cfg); err != nil { + if err := deployKBS(ctx, cfg); err != nil { return fmt.Errorf("failed to deploy KBS: %w", err) } @@ -105,7 +105,7 @@ func Deploy(cfg *Config) error { } if len(cfg.Secrets) > 0 { - if err := populateSecrets(cfg.Namespace, cfg.Secrets); err != nil { + if err := populateSecrets(ctx, clientset, cfg.Namespace, cfg.Secrets); err != nil { return fmt.Errorf("failed to populate secrets: %w", err) } } @@ -118,40 +118,28 @@ func GetServiceURL(namespace, serviceName string) string { return fmt.Sprintf("http://%s.%s.svc.cluster.local:8080", serviceName, namespace) } -func ensureNamespace(namespace string) error { - // Check if namespace exists by trying to access it (namespace-level permission) - // This is more reliable than 'kubectl get namespace' which requires cluster-level permissions - cmd := exec.Command("kubectl", "get", "serviceaccounts", "-n", namespace, "--limit=1") - output, err := cmd.CombinedOutput() - if err == nil { - // Successfully accessed resources in namespace, so it exists - return nil - } - - // Check if the error indicates namespace doesn't exist - outputStr := string(output) - namespaceNotFound := strings.Contains(outputStr, "NotFound") || - strings.Contains(outputStr, "not found") || - strings.Contains(outputStr, fmt.Sprintf("namespace \"%s\" not found", namespace)) - - if namespaceNotFound { - // Namespace doesn't exist, try to create it - cmd = exec.Command("kubectl", "create", "namespace", namespace) - output, err = cmd.CombinedOutput() - if err != nil && !strings.Contains(string(output), "AlreadyExists") { - return fmt.Errorf("failed to create namespace: %w\n%s", err, output) +func ensureNamespace(ctx context.Context, clientset kubernetes.Interface, namespace string) error { + // Try to create the namespace directly. This is simpler and more reliable + // than checking existence first: + // - If namespace doesn't exist: creates it + // - If namespace exists: AlreadyExists error is ignored + // - If user lacks create permission but namespace exists: subsequent + // operations will work (or fail with clear permission errors) + _, err := clientset.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: namespace}, + }, metav1.CreateOptions{}) + if err != nil && !apierrors.IsAlreadyExists(err) { + // Ignore Forbidden — namespace likely exists but user lacks create permission. + // Subsequent operations will fail appropriately if it truly doesn't exist. + if !apierrors.IsForbidden(err) { + return fmt.Errorf("failed to create namespace %s: %w", namespace, err) } - return nil } - - // For any other error (e.g., Forbidden when user lacks namespace creation permissions - // but namespace exists), assume namespace exists and proceed. - // Subsequent operations will fail appropriately if the namespace truly doesn't exist. return nil } -func applyManifest(yaml string) error { - cmd := exec.Command("kubectl", "apply", "-f", "-") +func applyManifest(ctx context.Context, yaml string) error { + cmd := exec.CommandContext(ctx, "kubectl", "apply", "-f", "-") cmd.Stdin = strings.NewReader(yaml) output, err := cmd.CombinedOutput() if err != nil { @@ -160,7 +148,7 @@ func applyManifest(yaml string) error { return nil } -func createAuthSecretFromKeys(namespace string) error { +func createAuthSecretFromKeys(ctx context.Context, namespace string) error { // Generate ED25519 key pair locally publicKey, privateKey, err := ed25519.GenerateKey(nil) if err != nil { @@ -223,10 +211,10 @@ func createAuthSecretFromKeys(namespace string) error { } // Apply the secret - return applyManifest(string(secretYAML)) + return applyManifest(ctx, string(secretYAML)) } -func deployConfigMaps(namespace string) error { +func deployConfigMaps(ctx context.Context, namespace string) error { manifest := fmt.Sprintf(` apiVersion: v1 kind: ConfigMap @@ -287,10 +275,10 @@ data: {} `, namespace, namespace, namespace) - return applyManifest(manifest) + return applyManifest(ctx, manifest) } -func deployPCCSConfigMap(namespace, pccsURL string) error { +func deployPCCSConfigMap(ctx context.Context, namespace, pccsURL string) error { qcnlConfig := fmt.Sprintf(`{"collateral_service":"%s"}`, pccsURL) manifest := fmt.Sprintf(` @@ -303,10 +291,10 @@ data: sgx_default_qcnl.conf: '%s' `, namespace, qcnlConfig) - return applyManifest(manifest) + return applyManifest(ctx, manifest) } -func deployKBS(cfg *Config) error { +func deployKBS(ctx context.Context, cfg *Config) error { // Build volumeMounts - base mounts volumeMounts := ` - name: confidential-containers mountPath: /opt/confidential-containers @@ -417,7 +405,7 @@ spec: protocol: TCP `, cfg.Namespace, cfg.KBSImage, volumeMounts, volumes, cfg.ServiceName, cfg.Namespace) - return applyManifest(manifest) + return applyManifest(ctx, manifest) } // ParseSecretSpec parses a secret specification and reads the file diff --git a/pkg/trustee/trustee_test.go b/pkg/trustee/trustee_test.go index 67559d7..7e61a0d 100644 --- a/pkg/trustee/trustee_test.go +++ b/pkg/trustee/trustee_test.go @@ -1,12 +1,130 @@ package trustee import ( + "context" "strings" "testing" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + "gopkg.in/yaml.v3" ) +// TestIsDeployed_Found tests that IsDeployed returns true when a deployment with the trustee label exists +func TestIsDeployed_Found(t *testing.T) { + // Create a fake clientset with a deployment that has the trustee label + fakeClient := fake.NewSimpleClientset( + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "trustee-deployment", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "kbs", + }, + }, + }, + ) + + ctx := context.Background() + deployed, err := IsDeployed(ctx, fakeClient, "coco-tenant") + + if err != nil { + t.Fatalf("IsDeployed() error = %v, want nil", err) + } + + if !deployed { + t.Errorf("IsDeployed() = false, want true") + } +} + +// TestIsDeployed_NotFound tests that IsDeployed returns false when no deployment exists +func TestIsDeployed_NotFound(t *testing.T) { + // Create an empty fake clientset + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + deployed, err := IsDeployed(ctx, fakeClient, "coco-tenant") + + if err != nil { + t.Fatalf("IsDeployed() error = %v, want nil", err) + } + + if deployed { + t.Errorf("IsDeployed() = true, want false") + } +} + +// TestIsDeployed_WrongLabel tests that IsDeployed returns false when deployment exists but has wrong label +func TestIsDeployed_WrongLabel(t *testing.T) { + // Create a fake clientset with a deployment that has a different label + fakeClient := fake.NewSimpleClientset( + &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: "other-deployment", + Namespace: "coco-tenant", + Labels: map[string]string{ + "app": "other-app", + }, + }, + }, + ) + + ctx := context.Background() + deployed, err := IsDeployed(ctx, fakeClient, "coco-tenant") + + if err != nil { + t.Fatalf("IsDeployed() error = %v, want nil", err) + } + + if deployed { + t.Errorf("IsDeployed() = true, want false when deployment has wrong label") + } +} + +// TestEnsureNamespace_Exists tests that ensureNamespace returns nil when namespace already exists +func TestEnsureNamespace_Exists(t *testing.T) { + // Create a fake clientset with the namespace already existing + fakeClient := fake.NewSimpleClientset( + &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "coco-tenant", + }, + }, + ) + + ctx := context.Background() + err := ensureNamespace(ctx, fakeClient, "coco-tenant") + + if err != nil { + t.Errorf("ensureNamespace() error = %v, want nil when namespace exists", err) + } +} + +// TestEnsureNamespace_Creates tests that ensureNamespace creates a new namespace +func TestEnsureNamespace_Creates(t *testing.T) { + // Create an empty fake clientset (namespace doesn't exist yet) + fakeClient := fake.NewSimpleClientset() + + ctx := context.Background() + err := ensureNamespace(ctx, fakeClient, "new-ns") + + if err != nil { + t.Errorf("ensureNamespace() error = %v, want nil when creating namespace", err) + } + + // Verify namespace was actually created + ns, getErr := fakeClient.CoreV1().Namespaces().Get(ctx, "new-ns", metav1.GetOptions{}) + if getErr != nil { + t.Fatalf("expected namespace to be created, got error: %v", getErr) + } + if ns.Name != "new-ns" { + t.Errorf("namespace name = %q, want %q", ns.Name, "new-ns") + } +} + // TestDeployKBS_ResourceLimits tests that the KBS deployment includes CPU resource requests and limits func TestDeployKBS_ResourceLimits(t *testing.T) { cfg := &Config{