package libkubectl import ( "os" "strings" "testing" "k8s.io/apimachinery/pkg/api/meta" "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" "k8s.io/apimachinery/pkg/runtime" "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/apimachinery/pkg/types" "k8s.io/client-go/dynamic/fake" k8stesting "k8s.io/client-go/testing" ) // TestApplyDynamic tests require a Kubernetes cluster. // // Running the tests: // - With cluster: go test -v ./pkg/libkubectl -run TestApplyDynamic // - Without cluster: Tests will skip automatically // // Cleanup: Tests automatically clean up resources using client.DeleteDynamic(). // Resources are deleted after each test completes, keeping the cluster clean. // skipIfNoKubeconfig skips the test if no kubeconfig is available func skipIfNoKubeconfig(tb testing.TB) string { tb.Helper() // Check for kubeconfig in environment variable kubeconfig := os.Getenv("KUBECONFIG") if kubeconfig == "" { // Try default location homeDir, err := os.UserHomeDir() if err != nil { tb.Skip("No KUBECONFIG environment variable set and cannot determine home directory") } kubeconfig = homeDir + "/.kube/config" } // Check if kubeconfig file exists if _, err := os.Stat(kubeconfig); os.IsNotExist(err) { tb.Skip("No Kubernetes cluster available (kubeconfig not found). Set KUBECONFIG environment variable to run these tests") } return kubeconfig } func TestApplyDynamic(t *testing.T) { t.Parallel() tests := []struct { name string manifests []string wantErr bool errMsg string desc string }{ { name: "apply simple deployment", desc: "Test basic deployment resource application", manifests: []string{ `apiVersion: apps/v1 kind: Deployment metadata: name: test-deployment namespace: default spec: replicas: 1 selector: matchLabels: app: test template: metadata: labels: app: test spec: containers: - name: test image: nginx:latest`, }, wantErr: false, }, { name: "apply statefulset", desc: "Test StatefulSet resource application", manifests: []string{ `apiVersion: apps/v1 kind: StatefulSet metadata: name: test-statefulset namespace: default spec: serviceName: test-service replicas: 2 selector: matchLabels: app: test-stateful template: metadata: labels: app: test-stateful spec: containers: - name: test image: nginx:latest`, }, wantErr: false, }, { name: "apply daemonset", desc: "Test DaemonSet resource application", manifests: []string{ `apiVersion: apps/v1 kind: DaemonSet metadata: name: test-daemonset namespace: default spec: selector: matchLabels: app: test-daemon template: metadata: labels: app: test-daemon spec: containers: - name: test image: nginx:latest`, }, wantErr: false, }, { name: "apply service", desc: "Test Service resource application", manifests: []string{ `apiVersion: v1 kind: Service metadata: name: test-service namespace: default spec: selector: app: test ports: - protocol: TCP port: 80 targetPort: 8080`, }, wantErr: false, }, { name: "apply ingress", desc: "Test Ingress resource application", manifests: []string{ `apiVersion: networking.k8s.io/v1 kind: Ingress metadata: name: test-ingress namespace: default spec: rules: - host: test.example.com http: paths: - path: / pathType: Prefix backend: service: name: test-service port: number: 80`, }, wantErr: false, }, { name: "apply configmap and secret", desc: "Test ConfigMap and Secret resource application", manifests: []string{ `apiVersion: v1 kind: ConfigMap metadata: name: test-config namespace: default data: key1: value1 key2: value2 --- apiVersion: v1 kind: Secret metadata: name: test-secret namespace: default type: Opaque stringData: username: admin password: secret123`, }, wantErr: false, }, { name: "apply persistent volume claim", desc: "Test PersistentVolumeClaim resource application", manifests: []string{ `apiVersion: v1 kind: PersistentVolumeClaim metadata: name: test-pvc namespace: default spec: accessModes: - ReadWriteOnce resources: requests: storage: 1Gi`, }, wantErr: false, }, { name: "apply serviceaccount with rbac", desc: "Test ServiceAccount, Role, and RoleBinding resources", manifests: []string{ `apiVersion: v1 kind: ServiceAccount metadata: name: test-sa namespace: default --- apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: name: test-role namespace: default rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: test-rolebinding namespace: default subjects: - kind: ServiceAccount name: test-sa namespace: default roleRef: kind: Role name: test-role apiGroup: rbac.authorization.k8s.io`, }, wantErr: false, }, { name: "apply cluster-scoped resources", desc: "Test cluster-scoped resources (ClusterRole, ClusterRoleBinding)", manifests: []string{ `apiVersion: v1 kind: Namespace metadata: name: test-namespace --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: name: test-cluster-role rules: - apiGroups: [""] resources: ["nodes"] verbs: ["get", "list"] --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRoleBinding metadata: name: test-cluster-rolebinding subjects: - kind: ServiceAccount name: default namespace: default roleRef: kind: ClusterRole name: test-cluster-role apiGroup: rbac.authorization.k8s.io`, }, wantErr: false, }, { name: "apply job and cronjob", desc: "Test Job and CronJob resources", manifests: []string{ `apiVersion: batch/v1 kind: Job metadata: name: test-job namespace: default spec: template: spec: containers: - name: test image: busybox command: ["echo", "Hello"] restartPolicy: Never --- apiVersion: batch/v1 kind: CronJob metadata: name: test-cronjob namespace: default spec: schedule: "*/5 * * * *" jobTemplate: spec: template: spec: containers: - name: test image: busybox command: ["echo", "Scheduled"] restartPolicy: Never`, }, wantErr: false, }, { name: "apply network policy", desc: "Test NetworkPolicy resource application", manifests: []string{ `apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: test-network-policy namespace: default spec: podSelector: matchLabels: app: test policyTypes: - Ingress - Egress ingress: - from: - podSelector: matchLabels: app: frontend ports: - protocol: TCP port: 8080`, }, wantErr: false, }, { name: "apply horizontal pod autoscaler", desc: "Test HorizontalPodAutoscaler resource application", manifests: []string{ `apiVersion: autoscaling/v2 kind: HorizontalPodAutoscaler metadata: name: test-hpa namespace: default spec: scaleTargetRef: apiVersion: apps/v1 kind: Deployment name: test-deployment minReplicas: 1 maxReplicas: 10 metrics: - type: Resource resource: name: cpu target: type: Utilization averageUtilization: 80`, }, wantErr: false, }, { name: "apply full application stack", desc: "Test complete application with multiple resources", manifests: []string{ `apiVersion: v1 kind: Namespace metadata: name: test-app --- apiVersion: v1 kind: ConfigMap metadata: name: app-config namespace: test-app data: DATABASE_URL: postgresql://db:5432 --- apiVersion: v1 kind: Secret metadata: name: app-secret namespace: test-app type: Opaque stringData: db-password: supersecret --- apiVersion: apps/v1 kind: Deployment metadata: name: app-deployment namespace: test-app spec: replicas: 3 selector: matchLabels: app: myapp template: metadata: labels: app: myapp spec: containers: - name: app image: nginx:latest envFrom: - configMapRef: name: app-config - secretRef: name: app-secret --- apiVersion: v1 kind: Service metadata: name: app-service namespace: test-app spec: selector: app: myapp ports: - protocol: TCP port: 80 targetPort: 8080`, }, wantErr: false, }, { name: "apply multiple manifests separately", desc: "Test applying multiple manifests in separate strings", manifests: []string{ `apiVersion: v1 kind: Namespace metadata: name: multi-test`, `apiVersion: v1 kind: ConfigMap metadata: name: config1 namespace: multi-test data: key: value1`, `apiVersion: v1 kind: ConfigMap metadata: name: config2 namespace: multi-test data: key: value2`, }, wantErr: false, }, { name: "empty manifest", desc: "Test empty manifest handling", manifests: []string{ "", }, wantErr: false, }, { name: "multiple empty documents", desc: "Test multiple empty documents separated by ---", manifests: []string{ `--- --- ---`, }, wantErr: false, }, { name: "invalid yaml", desc: "Test invalid YAML syntax handling", manifests: []string{ `invalid: yaml: content: [unclosed`, }, wantErr: true, errMsg: "failed to decode YAML", }, { name: "missing required fields", desc: "Test manifest with missing kind field", manifests: []string{ `apiVersion: v1 metadata: name: test`, }, wantErr: true, errMsg: "failed to decode YAML", // Kubernetes returns decode error for missing required fields }, { name: "partial failure with multiple resources", desc: "Test partial application with some invalid resources", manifests: []string{ `apiVersion: v1 kind: ConfigMap metadata: name: valid-config namespace: default data: key: value --- invalid: yaml: [unclosed --- apiVersion: v1 kind: ConfigMap metadata: name: another-valid-config namespace: default data: key: value2`, }, wantErr: true, errMsg: "partially applied resources with errors", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Skip if no Kubernetes cluster is available kubeconfig := skipIfNoKubeconfig(t) // Create client with empty namespace to let manifest namespaces be used // (simulates "use namespace from manifest" toggle being ON) client, err := NewClient(&ClientAccess{}, "", kubeconfig, false) if err != nil { t.Fatalf("Failed to create client: %v", err) } // Register cleanup to delete resources after test completes // This runs even if the test fails, ensuring cluster stays clean if !tt.wantErr && len(tt.manifests) > 0 { t.Cleanup(func() { _, err := client.DeleteDynamic(t.Context(), tt.manifests) if err != nil { t.Logf("Warning: failed to cleanup resources: %v", err) } }) } output, err := client.ApplyDynamic(t.Context(), tt.manifests) if tt.wantErr { if err == nil { t.Errorf("ApplyDynamic() expected error but got none") } else if tt.errMsg != "" && !strings.Contains(err.Error(), tt.errMsg) { t.Errorf("ApplyDynamic() error = %v, want error containing %v", err, tt.errMsg) } } else { if err != nil { t.Errorf("ApplyDynamic() unexpected error = %v", err) } if output == "" && len(tt.manifests) > 0 && tt.manifests[0] != "" { t.Errorf("ApplyDynamic() expected output but got empty string") } } }) } } // newFakeDynamicClient creates a fake dynamic client that handles Server-Side Apply func newFakeDynamicClient() *fake.FakeDynamicClient { client := fake.NewSimpleDynamicClient(runtime.NewScheme()) client.PrependReactor("patch", "*", func(action k8stesting.Action) (bool, runtime.Object, error) { patchAction := action.(k8stesting.PatchAction) if patchAction.GetPatchType() == types.ApplyPatchType { obj := &unstructured.Unstructured{} if err := obj.UnmarshalJSON(patchAction.GetPatch()); err != nil { return true, nil, err } return true, obj, nil } return false, nil, nil }) return client } // newTestMapper creates a simple RESTMapper for common resource types func newTestMapper() meta.RESTMapper { mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{ {Group: "", Version: "v1"}, {Group: "apps", Version: "v1"}, }) mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, meta.RESTScopeNamespace) mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "Secret"}, meta.RESTScopeNamespace) mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "Namespace"}, meta.RESTScopeRoot) mapper.Add(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, meta.RESTScopeNamespace) return mapper } // TestApplyResource unit tests for applyResource using fake client (no cluster needed) func TestApplyResource(t *testing.T) { t.Parallel() client := &Client{} // applyResource doesn't use Client fields directly dynamicClient := newFakeDynamicClient() mapper := newTestMapper() tests := []struct { name string yaml string configuredNS string wantErr bool errContains string }{ { name: "configmap with configured namespace", yaml: `apiVersion: v1 kind: ConfigMap metadata: name: test-cm data: key: value`, configuredNS: "my-ns", }, { name: "configmap with manifest namespace", yaml: `apiVersion: v1 kind: ConfigMap metadata: name: test-cm namespace: manifest-ns data: key: value`, configuredNS: "", }, { name: "namespace conflict", yaml: `apiVersion: v1 kind: ConfigMap metadata: name: test-cm namespace: manifest-ns`, configuredNS: "form-ns", wantErr: true, errContains: "namespace conflict", }, { name: "namespaces match", yaml: `apiVersion: v1 kind: ConfigMap metadata: name: test-cm namespace: same-ns`, configuredNS: "same-ns", }, { name: "defaults to default namespace when neither set", yaml: `apiVersion: v1 kind: ConfigMap metadata: name: test-cm`, configuredNS: "", }, { name: "cluster-scoped resource ignores namespace", yaml: `apiVersion: v1 kind: Namespace metadata: name: new-ns`, configuredNS: "ignored", }, { name: "invalid yaml", yaml: "not: valid: yaml: {{", wantErr: true, errContains: "failed to decode", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result, err := client.applyResource(t.Context(), dynamicClient, mapper, []byte(tt.yaml), tt.configuredNS) if tt.wantErr { if err == nil { t.Errorf("expected error containing %q, got nil", tt.errContains) } else if !strings.Contains(err.Error(), tt.errContains) { t.Errorf("expected error containing %q, got %v", tt.errContains, err) } return } if err != nil { t.Errorf("unexpected error: %v", err) return } if result == "" { t.Error("expected non-empty result") } }) } } func TestIsManifestFile(t *testing.T) { t.Parallel() tests := []struct { name string resource string want bool }{ { name: "yaml file", resource: "deployment.yaml", want: true, }, { name: "yml file", resource: "deployment.yml", want: true, }, { name: "yaml file with path", resource: "/path/to/deployment.yaml", want: true, }, { name: "yaml file with spaces", resource: " deployment.yaml ", want: true, }, { name: "not a file", resource: "apiVersion: v1\nkind: Pod", want: false, }, { name: "resource type", resource: "deployment/my-deployment", want: false, }, { name: "empty string", resource: "", want: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := isManifestFile(tt.resource); got != tt.want { t.Errorf("isManifestFile() = %v, want %v", got, tt.want) } }) } }