package handler import ( "context" "encoding/json" "errors" "fmt" "testing" "time" "github.com/goodrain/rainbond/api/model" "github.com/goodrain/rainbond/db" "github.com/goodrain/rainbond/db/dao" dbmodel "github.com/goodrain/rainbond/db/model" apierrors "k8s.io/apimachinery/pkg/api/errors" "k8s.io/apimachinery/pkg/api/meta" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "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" dynamicfake "k8s.io/client-go/dynamic/fake" ktesting "k8s.io/client-go/testing" ) var ( testCRDGVR = schema.GroupVersionResource{Group: "apiextensions.k8s.io", Version: "v1", Resource: "customresourcedefinitions"} testWidgetGVR = schema.GroupVersionResource{Group: "example.com", Version: "v1", Resource: "widgets"} testReportGVR = schema.GroupVersionResource{Group: "aquasecurity.github.io", Version: "v1alpha1", Resource: "clusterconfigauditreports"} testConfigGVR = schema.GroupVersionResource{Version: "v1", Resource: "configmaps"} testDeploymentGVR = schema.GroupVersionResource{Group: "apps", Version: "v1", Resource: "deployments"} ) // capability_id: rainbond.k8s-resource.response-array-contract func TestK8sResourceDeletionResponseArrays(t *testing.T) { for _, test := range []struct { name string kind string instance *unstructured.Unstructured }{ {name: "empty selection"}, {name: "ordinary ConfigMap", kind: "ConfigMap"}, {name: "CRD without instances", kind: "CustomResourceDefinition"}, {name: "CRD with current application instance", kind: "CustomResourceDefinition", instance: newTestWidget("owned", "team-a", "app-a")}, {name: "CRD with another application instance", kind: "CustomResourceDefinition", instance: newTestWidget("shared", "team-b", "app-b")}, } { t.Run(test.name, func(t *testing.T) { var objects []*unstructured.Unstructured if test.instance != nil { objects = append(objects, test.instance) } orchestrator, client := newDeletionTestOrchestrator(t, objects...) req := &model.K8sResourceDeletionRequest{AppID: "app-a"} wantIDs := []string{} wantCRDs := []model.CRDDeletionImpact{} switch test.kind { case "ConfigMap": orchestrator.mapper.(*meta.DefaultRESTMapper).Add(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, meta.RESTScopeNamespace) configMap := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]interface{}{"name": "settings", "namespace": "team-a"}, }} if err := client.Tracker().Add(configMap); err != nil { t.Fatal(err) } req.K8sResources = []model.HandleResource{{ ClientID: "config-row", AppID: "app-a", Namespace: "team-a", Name: "settings", Kind: "ConfigMap", ResourceYaml: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: settings\n namespace: team-a\n", State: model.CreateSuccess, }} wantIDs = []string{"config-row"} case "CustomResourceDefinition": req = newCRDDeletionRequest(true) wantIDs = []string{"crd-row"} wantCRDs = []model.CRDDeletionImpact{{ Name: "widgets.example.com", Group: "example.com", Version: "v1", Kind: "Widget", Plural: "widgets", Scope: "Namespaced", AffectedRegionAppIDs: []string{}, }} if test.instance != nil { if test.instance.GetLabels()[appIDLabel] == req.AppID { wantCRDs[0].CurrentAppCRCount = 1 } else { wantCRDs[0].OtherAppCRCount = 1 wantCRDs[0].AffectedRegionAppIDs = []string{"app-b"} } } } impact, err := orchestrator.Preview(context.Background(), req) if err != nil { t.Fatalf("Preview() error = %v", err) } assertResourceResponseJSONField(t, impact, "crds", wantCRDs) for _, action := range client.Actions() { if action.GetVerb() == "delete" { t.Fatalf("Preview() mutated Kubernetes: %#v", action) } } result, err := orchestrator.Delete(context.Background(), req) if err != nil { t.Fatalf("Delete() error = %v", err) } assertResourceResponseJSONField(t, result, "status", "completed") assertResourceResponseJSONField(t, result, "deleted_client_ids", wantIDs) assertResourceResponseJSONField(t, result, "cascaded_crds", wantCRDs) }) } } func TestK8sResourceReconcileResponseArrays(t *testing.T) { lookupErr := apierrors.NewForbidden(testWidgetGVR.GroupResource(), "unknown", errors.New("denied")) for _, test := range []struct { name string names []string wantMissing []string wantUnknown []model.K8sResourceUnknown }{ {name: "empty selection", wantMissing: []string{}, wantUnknown: []model.K8sResourceUnknown{}}, {name: "all present", names: []string{"present"}, wantMissing: []string{}, wantUnknown: []model.K8sResourceUnknown{}}, {name: "missing", names: []string{"missing"}, wantMissing: []string{"missing"}, wantUnknown: []model.K8sResourceUnknown{}}, {name: "unknown", names: []string{"unknown"}, wantMissing: []string{}, wantUnknown: []model.K8sResourceUnknown{{ClientID: "unknown", Error: lookupErr.Error()}}}, {name: "mixed", names: []string{"present", "missing", "unknown"}, wantMissing: []string{"missing"}, wantUnknown: []model.K8sResourceUnknown{{ClientID: "unknown", Error: lookupErr.Error()}}}, } { t.Run(test.name, func(t *testing.T) { orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("present", "team-a", "app-a")) client.PrependReactor("get", "widgets", func(action ktesting.Action) (bool, runtime.Object, error) { if action.(ktesting.GetAction).GetName() == "unknown" { return true, nil, lookupErr } return false, nil, nil }) req := &model.K8sResourceReconcileRequest{AppID: "app-a"} for _, name := range test.names { req.K8sResources = append(req.K8sResources, model.HandleResource{ ClientID: name, Namespace: "team-a", Name: name, Kind: "Widget", ResourceYaml: testWidgetYAML(name, "team-a"), State: model.CreateSuccess, }) } result := orchestrator.Reconcile(context.Background(), req) assertResourceResponseJSONField(t, result, "missing_client_ids", test.wantMissing) assertResourceResponseJSONField(t, result, "unknown", test.wantUnknown) }) } } func assertResourceResponseJSONField(t *testing.T, response interface{}, field string, want interface{}) { t.Helper() data, err := json.Marshal(response) if err != nil { t.Fatalf("marshal response: %v", err) } var fields map[string]json.RawMessage if err := json.Unmarshal(data, &fields); err != nil { t.Fatalf("decode response: %v", err) } expected, err := json.Marshal(want) if err != nil { t.Fatalf("marshal expected %s: %v", field, err) } if string(fields[field]) != string(expected) { t.Errorf("response %s = %s, want %s", field, fields[field], expected) } } func TestK8sResourceDeletionPreviewRequiresCascadeForOtherApplications(t *testing.T) { orchestrator, _ := newDeletionTestOrchestrator(t, newTestWidget("owned", "team-a", "app-a"), newTestWidget("shared", "team-b", "app-b"), newTestWidget("unowned", "team-c", ""), ) impact, err := orchestrator.Preview(context.Background(), newCRDDeletionRequest(false)) if err != nil { t.Fatalf("Preview() error = %v", err) } if !impact.HasCRD || !impact.RequiresCascade { t.Fatalf("Preview() impact = %#v, want CRD requiring cascade", impact) } if impact.CRDCount != 1 || impact.CRCount != 3 || impact.OtherAppCount != 1 || impact.UnownedCRCount != 1 { t.Fatalf("Preview() counts = %#v", impact) } crd := impact.CRDs[0] if crd.CurrentAppCRCount != 1 || crd.OtherAppCRCount != 1 || crd.UnownedCRCount != 1 { t.Fatalf("Preview() CRD counts = %#v", crd) } if len(crd.AffectedRegionAppIDs) != 1 || crd.AffectedRegionAppIDs[0] != "app-b" { t.Fatalf("Preview() affected apps = %v, want [app-b]", crd.AffectedRegionAppIDs) } } func TestK8sResourceDeletionBlocksCascadeBeforeMutation(t *testing.T) { orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("shared", "team-b", "app-b")) _, err := orchestrator.Delete(context.Background(), newCRDDeletionRequest(false)) if !errors.Is(err, ErrCRDCascadeConfirmationRequired) { t.Fatalf("Delete() error = %v, want ErrCRDCascadeConfirmationRequired", err) } for _, action := range client.Actions() { if action.GetVerb() == "delete" { t.Fatalf("Delete() mutated Kubernetes before confirmation: %#v", action) } } } // capability_id: rainbond.k8s-resource.failed-delete-metadata-only func TestK8sResourceDeletionSkipsFailedResourceKubernetesDeletion(t *testing.T) { orchestrator, client := newDeletionTestOrchestrator(t) req := newCRDDeletionRequest(false) req.K8sResources[0].State = model.CreateError result, err := orchestrator.Delete(context.Background(), req) if err != nil { t.Fatalf("Delete() error = %v", err) } if result.Status != "completed" || len(result.DeletedClientIDs) != 1 || result.DeletedClientIDs[0] != "crd-row" { t.Fatalf("Delete() result = %#v, want metadata-only completion", result) } for _, action := range client.Actions() { if action.GetVerb() == "delete" { t.Fatalf("Delete() mutated Kubernetes for failed resource: %#v", action) } } } // capability_id: rainbond.k8s-resource.crd-cascade-delete func TestK8sResourceDeletionDeletesCustomResourcesBeforeDefinition(t *testing.T) { orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("owned", "team-a", "app-a"), newTestWidget("shared", "team-b", "app-b"), ) result, err := orchestrator.Delete(context.Background(), newCRDDeletionRequest(true)) if err != nil { t.Fatalf("Delete() error = %v", err) } if result.Status != "completed" || len(result.CascadedCRDs) != 1 { t.Fatalf("Delete() result = %#v", result) } var deletedResources []string for _, action := range client.Actions() { if action.GetVerb() == "delete" { deletedResources = append(deletedResources, action.GetResource().Resource) } } if len(deletedResources) != 3 { t.Fatalf("delete actions = %v, want two CRs and one CRD", deletedResources) } if deletedResources[0] != "widgets" || deletedResources[1] != "widgets" || deletedResources[2] != "customresourcedefinitions" { t.Fatalf("delete order = %v, want CRs before CRD", deletedResources) } } func TestK8sResourceDeletionDoesNotDeleteDefinitionWhenCustomResourceDeleteFails(t *testing.T) { orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("blocked", "team-a", "app-a")) client.PrependReactor("delete", "widgets", func(action ktesting.Action) (bool, runtime.Object, error) { return true, nil, fmt.Errorf("webhook unavailable") }) _, err := orchestrator.Delete(context.Background(), newCRDDeletionRequest(false)) if err == nil { t.Fatal("Delete() error = nil, want CR deletion failure") } for _, action := range client.Actions() { if action.GetVerb() == "delete" && action.GetResource() == testCRDGVR { t.Fatalf("Delete() removed CRD after CR deletion failure: %#v", action) } } } // capability_id: rainbond.k8s-resource.deletion-timeout-final-check func TestK8sResourceDeletionFinalCheckAvoidsTimeoutRace(t *testing.T) { orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("owned", "team-a", "app-a")) orchestrator.waitInterval = 100 * time.Millisecond orchestrator.waitTimeout = time.Millisecond listCalls := 0 client.PrependReactor("list", "widgets", func(action ktesting.Action) (bool, runtime.Object, error) { listCalls++ list := &unstructured.UnstructuredList{} list.SetGroupVersionKind(schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "WidgetList"}) if listCalls <= 2 { list.Items = []unstructured.Unstructured{*newTestWidget("owned", "team-a", "app-a")} } return true, list, nil }) result, err := orchestrator.Delete(context.Background(), newCRDDeletionRequest(false)) if err != nil { t.Fatalf("Delete() error = %v, want final live check to confirm deletion", err) } if result.Status != "completed" { t.Fatalf("Delete() result = %#v, want completed", result) } if listCalls != 3 { t.Fatalf("widget list calls = %d, want build, poll, and final check", listCalls) } } func TestK8sResourceDeletionRejectsInvalidResourceBeforeMutation(t *testing.T) { orchestrator, client := newDeletionTestOrchestrator(t) req := &model.K8sResourceDeletionRequest{ AppID: "app-a", K8sResources: []model.HandleResource{{ ClientID: "bad", Name: "bad", Kind: "Widget", ResourceYaml: "metadata:\n name: bad\n", State: model.CreateSuccess, }}, } _, err := orchestrator.Delete(context.Background(), req) if !errors.Is(err, ErrInvalidK8sResourceDeletionRequest) { t.Fatalf("Delete() error = %v, want ErrInvalidK8sResourceDeletionRequest", err) } for _, action := range client.Actions() { if action.GetVerb() == "delete" { t.Fatalf("Delete() mutated Kubernetes for invalid input: %#v", action) } } } func TestK8sResourceDeletionDeletesAndConfirmsOrdinaryResource(t *testing.T) { configMap := &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "v1", "kind": "ConfigMap", "metadata": map[string]interface{}{ "name": "settings", "namespace": "team-a", }, }} client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( runtime.NewScheme(), map[schema.GroupVersionResource]string{testConfigGVR: "ConfigMapList"}, configMap, ) mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{{Version: "v1"}}) mapper.Add(schema.GroupVersionKind{Version: "v1", Kind: "ConfigMap"}, meta.RESTScopeNamespace) orchestrator := &k8sResourceDeletionOrchestrator{ dynamicClient: client, mapper: mapper, refreshMapper: func() (meta.RESTMapper, error) { return mapper, nil }, waitInterval: time.Millisecond, waitTimeout: 50 * time.Millisecond, } result, err := orchestrator.Delete(context.Background(), &model.K8sResourceDeletionRequest{ AppID: "app-a", K8sResources: []model.HandleResource{{ ClientID: "config-row", AppID: "app-a", Namespace: "team-a", Name: "settings", Kind: "ConfigMap", ResourceYaml: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: settings\n namespace: team-a\n", State: model.CreateSuccess, }}, }) if err != nil { t.Fatalf("Delete() error = %v", err) } if len(result.DeletedClientIDs) != 1 || result.DeletedClientIDs[0] != "config-row" { t.Fatalf("Delete() result = %#v", result) } if _, err := client.Resource(testConfigGVR).Namespace("team-a").Get(context.Background(), "settings", metav1.GetOptions{}); !apierrors.IsNotFound(err) { t.Fatalf("ConfigMap still exists, Get() error = %v", err) } } // capability_id: rainbond.k8s-resource.stop-recreating-controller func TestK8sResourceDeletionStopsManagedControllerBeforeGeneratedResources(t *testing.T) { orchestrator, client := newRecreatingControllerTestOrchestrator(t, false) client.PrependReactor("delete", testReportGVR.Resource, func(action ktesting.Action) (bool, runtime.Object, error) { deleteAction := action.(ktesting.DeleteAction) if err := client.Tracker().Delete(testReportGVR, "", deleteAction.GetName()); err != nil { return true, nil, err } if _, err := client.Tracker().Get(testDeploymentGVR, "rbd-plugins", "trivy-operator"); err == nil { recreated := newTestGeneratedReport("recreated-report", false) if err := client.Tracker().Add(recreated); err != nil { return true, nil, err } } return true, nil, nil }) result, err := orchestrator.Delete(context.Background(), newGeneratedReportDeletionRequest()) if err != nil { t.Fatalf("Delete() error = %v", err) } if result.Status != "completed" { t.Fatalf("Delete() result = %#v, want completed", result) } var deletedResources []string for _, action := range client.Actions() { if action.GetVerb() == "delete" { deletedResources = append(deletedResources, action.GetResource().Resource) } } want := []string{"deployments", "clusterconfigauditreports", "customresourcedefinitions"} if fmt.Sprint(deletedResources) != fmt.Sprint(want) { t.Fatalf("delete order = %v, want %v", deletedResources, want) } } func TestK8sResourceDeletionKeepsControllerUntilFinalizedResourcesAreGone(t *testing.T) { orchestrator, client := newRecreatingControllerTestOrchestrator(t, true) result, err := orchestrator.Delete(context.Background(), newGeneratedReportDeletionRequest()) if err != nil { t.Fatalf("Delete() error = %v", err) } if result.Status != "completed" { t.Fatalf("Delete() result = %#v, want completed", result) } var deletedResources []string for _, action := range client.Actions() { if action.GetVerb() == "delete" { deletedResources = append(deletedResources, action.GetResource().Resource) } } want := []string{"clusterconfigauditreports", "deployments", "customresourcedefinitions"} if fmt.Sprint(deletedResources) != fmt.Sprint(want) { t.Fatalf("delete order = %v, want %v", deletedResources, want) } } // capability_id: rainbond.k8s-resource.metadata-reconcile func TestK8sResourceReconcileDistinguishesMissingAndUnknown(t *testing.T) { orchestrator, client := newDeletionTestOrchestrator(t, newTestWidget("present", "team-a", "app-a")) client.PrependReactor("get", "widgets", func(action ktesting.Action) (bool, runtime.Object, error) { getAction := action.(ktesting.GetAction) if getAction.GetName() == "unknown" { return true, nil, apierrors.NewForbidden( schema.GroupResource{Group: testWidgetGVR.Group, Resource: testWidgetGVR.Resource}, getAction.GetName(), fmt.Errorf("denied"), ) } return false, nil, nil }) result := orchestrator.Reconcile(context.Background(), &model.K8sResourceReconcileRequest{ AppID: "app-a", K8sResources: []model.HandleResource{ {ClientID: "1", Namespace: "team-a", Name: "present", Kind: "Widget", ResourceYaml: testWidgetYAML("present", "team-a"), State: model.CreateSuccess}, {ClientID: "2", Namespace: "team-a", Name: "missing", Kind: "Widget", ResourceYaml: testWidgetYAML("missing", "team-a"), State: model.CreateSuccess}, {ClientID: "3", Namespace: "team-a", Name: "unknown", Kind: "Widget", ResourceYaml: testWidgetYAML("unknown", "team-a"), State: model.CreateSuccess}, }, }) if len(result.MissingClientIDs) != 1 || result.MissingClientIDs[0] != "2" { t.Fatalf("Reconcile() missing = %v, want [2]", result.MissingClientIDs) } if len(result.Unknown) != 1 || result.Unknown[0].ClientID != "3" { t.Fatalf("Reconcile() unknown = %#v, want client 3", result.Unknown) } } func TestK8sResourceReconcileTreatsConfirmedNoMatchAsMissing(t *testing.T) { client := newDeletionTestDynamicClient(t) mapper := meta.NewDefaultRESTMapper(nil) orchestrator := &k8sResourceDeletionOrchestrator{ dynamicClient: client, mapper: mapper, refreshMapper: func() (meta.RESTMapper, error) { return meta.NewDefaultRESTMapper(nil), nil }, waitInterval: time.Millisecond, waitTimeout: 50 * time.Millisecond, } result := orchestrator.Reconcile(context.Background(), &model.K8sResourceReconcileRequest{ AppID: "app-a", K8sResources: []model.HandleResource{{ ClientID: "gone-api", Namespace: "team-a", Name: "missing", Kind: "Widget", ResourceYaml: testWidgetYAML("missing", "team-a"), State: model.CreateSuccess, }}, }) if len(result.MissingClientIDs) != 1 || result.MissingClientIDs[0] != "gone-api" { t.Fatalf("Reconcile() missing = %v, want confirmed removed API", result.MissingClientIDs) } } func TestK8sResourceMetadataMatchesCRDByGroupAndKind(t *testing.T) { resources := []dbmodel.K8sResource{ {Model: dbmodel.Model{ID: 1}, Kind: "Widget", Content: testWidgetYAML("one", "team-a")}, {Model: dbmodel.Model{ID: 2}, Kind: "Widget", Content: "apiVersion: other.example.com/v1\nkind: Widget\nmetadata:\n name: two\n"}, {Model: dbmodel.Model{ID: 3}, Kind: "Other", Content: "apiVersion: example.com/v1\nkind: Other\nmetadata:\n name: three\n"}, {Model: dbmodel.Model{ID: 4}, Kind: "Widget", Content: ": invalid"}, } impacts := []model.CRDDeletionImpact{{Group: "example.com", Kind: "Widget"}} ids := matchingCRDMetadataIDs(resources, impacts) if len(ids) != 1 || ids[0] != 1 { t.Fatalf("matchingCRDMetadataIDs() = %v, want [1]", ids) } } func TestCleanupDeletedResourceMetadataIncludesSelectedAndCascadedRows(t *testing.T) { k8sDAO := &deletionTestK8sResourceDAO{ byApp: []dbmodel.K8sResource{ {Model: dbmodel.Model{ID: 10}, AppID: "app-a", Name: "widgets.example.com", Kind: "CustomResourceDefinition", Content: testCRDYAML()}, {Model: dbmodel.Model{ID: 11}, AppID: "app-a", Name: "keep", Kind: "ConfigMap", Content: "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: keep\n"}, }, byKind: []dbmodel.K8sResource{ {Model: dbmodel.Model{ID: 20}, AppID: "app-b", Name: "shared", Kind: "Widget", Content: testWidgetYAML("shared", "team-b")}, {Model: dbmodel.Model{ID: 21}, AppID: "app-c", Name: "other", Kind: "Widget", Content: "apiVersion: other.example.com/v1\nkind: Widget\nmetadata:\n name: other\n"}, }, } db.SetTestManager(deletionTestDBManager{k8sDAO: k8sDAO}) defer db.SetTestManager(nil) err := cleanupDeletedResourceMetadata(newCRDDeletionRequest(true), &model.K8sResourceDeletionResult{ CascadedCRDs: []model.CRDDeletionImpact{{Group: "example.com", Kind: "Widget"}}, }) if err != nil { t.Fatalf("cleanupDeletedResourceMetadata() error = %v", err) } if len(k8sDAO.deletedIDs) != 2 || k8sDAO.deletedIDs[0] != 10 || k8sDAO.deletedIDs[1] != 20 { t.Fatalf("cleanupDeletedResourceMetadata() deleted = %v, want [10 20]", k8sDAO.deletedIDs) } } func TestClusterActionReconcileCleansOnlyConfirmedMissingRegionMetadata(t *testing.T) { orchestrator, _ := newDeletionTestOrchestrator(t) k8sDAO := &deletionTestK8sResourceDAO{} db.SetTestManager(deletionTestDBManager{k8sDAO: k8sDAO}) defer db.SetTestManager(nil) action := &clusterAction{dynamicClient: orchestrator.dynamicClient, mapper: orchestrator.mapper} result, handleErr := action.ReconcileK8SResources(context.Background(), &model.K8sResourceReconcileRequest{ AppID: "app-a", K8sResources: []model.HandleResource{{ ClientID: "missing-row", Name: "missing", Kind: "Widget", Namespace: "team-a", ResourceYaml: testWidgetYAML("missing", "team-a"), State: model.CreateSuccess, }}, }) if handleErr != nil { t.Fatalf("ReconcileK8SResources() error = %v", handleErr) } if len(result.MissingClientIDs) != 1 || len(k8sDAO.deletedResources) != 1 { t.Fatalf("ReconcileK8SResources() result = %#v, deleted = %v", result, k8sDAO.deletedResources) } if k8sDAO.deletedResources[0] != "app-a/missing/Widget" { t.Fatalf("deleted metadata = %v", k8sDAO.deletedResources) } } func newDeletionTestOrchestrator(t *testing.T, widgets ...*unstructured.Unstructured) (*k8sResourceDeletionOrchestrator, *dynamicfake.FakeDynamicClient) { t.Helper() objects := []runtime.Object{newTestCRD()} for _, widget := range widgets { objects = append(objects, widget) } client := newDeletionTestDynamicClient(t, objects...) mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{ {Group: "apiextensions.k8s.io", Version: "v1"}, {Group: "example.com", Version: "v1"}, }) mapper.Add(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}, meta.RESTScopeRoot) mapper.Add(schema.GroupVersionKind{Group: "example.com", Version: "v1", Kind: "Widget"}, meta.RESTScopeNamespace) orchestrator := &k8sResourceDeletionOrchestrator{ dynamicClient: client, mapper: mapper, refreshMapper: func() (meta.RESTMapper, error) { return mapper, nil }, waitInterval: time.Millisecond, waitTimeout: 100 * time.Millisecond, } return orchestrator, client } func newDeletionTestDynamicClient(t *testing.T, objects ...runtime.Object) *dynamicfake.FakeDynamicClient { t.Helper() client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( runtime.NewScheme(), map[schema.GroupVersionResource]string{ testCRDGVR: "CustomResourceDefinitionList", testWidgetGVR: "WidgetList", }, objects..., ) return client } func newRecreatingControllerTestOrchestrator(t *testing.T, finalized bool) (*k8sResourceDeletionOrchestrator, *dynamicfake.FakeDynamicClient) { t.Helper() client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds( runtime.NewScheme(), map[schema.GroupVersionResource]string{ testCRDGVR: "CustomResourceDefinitionList", testReportGVR: "ClusterConfigAuditReportList", testDeploymentGVR: "DeploymentList", }, newTestGeneratedReportCRD(), newTestGeneratedReport("persistentvolume-data", finalized), newTestControllerDeployment(), ) mapper := meta.NewDefaultRESTMapper([]schema.GroupVersion{ {Group: "apiextensions.k8s.io", Version: "v1"}, {Group: "aquasecurity.github.io", Version: "v1alpha1"}, {Group: "apps", Version: "v1"}, }) mapper.Add(schema.GroupVersionKind{Group: "apiextensions.k8s.io", Version: "v1", Kind: "CustomResourceDefinition"}, meta.RESTScopeRoot) mapper.Add(schema.GroupVersionKind{Group: "aquasecurity.github.io", Version: "v1alpha1", Kind: "ClusterConfigAuditReport"}, meta.RESTScopeRoot) mapper.Add(schema.GroupVersionKind{Group: "apps", Version: "v1", Kind: "Deployment"}, meta.RESTScopeNamespace) orchestrator := &k8sResourceDeletionOrchestrator{ dynamicClient: client, mapper: mapper, refreshMapper: func() (meta.RESTMapper, error) { return mapper, nil }, waitInterval: time.Millisecond, waitTimeout: 100 * time.Millisecond, } return orchestrator, client } func newTestGeneratedReportCRD() *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "apiextensions.k8s.io/v1", "kind": "CustomResourceDefinition", "metadata": map[string]interface{}{ "name": "clusterconfigauditreports.aquasecurity.github.io", }, "spec": map[string]interface{}{ "group": "aquasecurity.github.io", "scope": "Cluster", "names": map[string]interface{}{ "kind": "ClusterConfigAuditReport", "plural": "clusterconfigauditreports", }, "versions": []interface{}{ map[string]interface{}{"name": "v1alpha1", "served": true, "storage": true}, }, }, }} } func newTestGeneratedReport(name string, finalized bool) *unstructured.Unstructured { metadata := map[string]interface{}{ "name": name, "labels": map[string]interface{}{ "app.kubernetes.io/managed-by": "trivy-operator", }, } if finalized { metadata["finalizers"] = []interface{}{"aquasecurity.github.io/finalizer"} } return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "aquasecurity.github.io/v1alpha1", "kind": "ClusterConfigAuditReport", "metadata": metadata, }} } func newTestControllerDeployment() *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "apps/v1", "kind": "Deployment", "metadata": map[string]interface{}{ "name": "trivy-operator", "namespace": "rbd-plugins", }, }} } func newGeneratedReportDeletionRequest() *model.K8sResourceDeletionRequest { return &model.K8sResourceDeletionRequest{ AppID: "security-app", CascadeCRD: true, K8sResources: []model.HandleResource{ { ClientID: "report-crd-row", AppID: "security-app", Name: "clusterconfigauditreports.aquasecurity.github.io", Kind: "CustomResourceDefinition", ResourceYaml: generatedReportCRDYAML(), State: model.CreateSuccess, }, { ClientID: "operator-row", AppID: "security-app", Namespace: "rbd-plugins", Name: "trivy-operator", Kind: "Deployment", ResourceYaml: "apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: trivy-operator\n namespace: rbd-plugins\n", State: model.CreateSuccess, }, }, } } func generatedReportCRDYAML() string { return `apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: clusterconfigauditreports.aquasecurity.github.io spec: group: aquasecurity.github.io scope: Cluster names: kind: ClusterConfigAuditReport plural: clusterconfigauditreports versions: - name: v1alpha1 served: true storage: true ` } func newTestCRD() *unstructured.Unstructured { return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "apiextensions.k8s.io/v1", "kind": "CustomResourceDefinition", "metadata": map[string]interface{}{ "name": "widgets.example.com", "uid": string(types.UID("crd-uid")), }, "spec": map[string]interface{}{ "group": "example.com", "scope": "Namespaced", "names": map[string]interface{}{ "kind": "Widget", "plural": "widgets", }, "versions": []interface{}{ map[string]interface{}{"name": "v1", "served": true, "storage": true}, }, }, }} } func newTestWidget(name, namespace, appID string) *unstructured.Unstructured { labels := map[string]interface{}{} if appID != "" { labels["app_id"] = appID } return &unstructured.Unstructured{Object: map[string]interface{}{ "apiVersion": "example.com/v1", "kind": "Widget", "metadata": map[string]interface{}{ "name": name, "namespace": namespace, "labels": labels, }, }} } func newCRDDeletionRequest(cascade bool) *model.K8sResourceDeletionRequest { return &model.K8sResourceDeletionRequest{ AppID: "app-a", CascadeCRD: cascade, K8sResources: []model.HandleResource{{ ClientID: "crd-row", AppID: "app-a", Name: "widgets.example.com", Kind: "CustomResourceDefinition", ResourceYaml: testCRDYAML(), State: model.CreateSuccess, }}, } } func testCRDYAML() string { return `apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: name: widgets.example.com spec: group: example.com scope: Namespaced names: kind: Widget plural: widgets versions: - name: v1 served: true storage: true ` } func testWidgetYAML(name, namespace string) string { return fmt.Sprintf(`apiVersion: example.com/v1 kind: Widget metadata: name: %s namespace: %s `, name, namespace) } // Compile-time checks keep the fake test dependencies honest across client-go upgrades. var _ dynamic.Interface = (*dynamicfake.FakeDynamicClient)(nil) type deletionTestDBManager struct { db.Manager k8sDAO dao.K8sResourceDao } func (m deletionTestDBManager) K8sResourceDao() dao.K8sResourceDao { return m.k8sDAO } type deletionTestK8sResourceDAO struct { dao.K8sResourceDao byApp []dbmodel.K8sResource byKind []dbmodel.K8sResource deletedIDs []uint deletedResources []string } func (d *deletionTestK8sResourceDAO) ListByAppID(string) ([]dbmodel.K8sResource, error) { return d.byApp, nil } func (d *deletionTestK8sResourceDAO) ListByKind(string) ([]dbmodel.K8sResource, error) { return d.byKind, nil } func (d *deletionTestK8sResourceDAO) DeleteK8sResourceByIDs(ids []uint) error { d.deletedIDs = append(d.deletedIDs, ids...) return nil } func (d *deletionTestK8sResourceDAO) DeleteK8sResource(appID, name, kind string) error { d.deletedResources = append(d.deletedResources, appID+"/"+name+"/"+kind) return nil }