diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 62d0462..45bcf12 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -41,6 +41,8 @@ jobs: uses: actions/setup-go@7a3fe6cf4cb3a834922a1244abfce67bcef6a0c5 # v6.2.0 with: go-version-file: go.mod + - name: Install setup-envtest + run: go install sigs.k8s.io/controller-runtime/tools/setup-envtest@4dbfa5c66aa24a35003c41507385c2a91e94d404 # release-0.23 - name: Test run: | make test diff --git a/Makefile b/Makefile index 0a31e34..0bbc5c1 100644 --- a/Makefile +++ b/Makefile @@ -19,4 +19,7 @@ fmt: go fmt ./... test: - go test ./... + KUBEBUILDER_ASSETS=$$(setup-envtest use -p path) go test ./... + +test-short: + go test -short ./... diff --git a/go.mod b/go.mod index 497b131..1513421 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,12 @@ go 1.25.4 require ( github.com/bradleyfalzon/ghinstallation/v2 v2.17.0 github.com/prometheus/client_golang v1.23.2 + github.com/stretchr/testify v1.11.1 golang.org/x/time v0.14.0 k8s.io/api v0.35.1 k8s.io/apimachinery v0.35.1 k8s.io/client-go v0.35.1 + sigs.k8s.io/controller-runtime v0.23.1 ) require ( @@ -16,6 +18,7 @@ require ( github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/emicklei/go-restful/v3 v3.12.2 // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // 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 @@ -50,11 +53,12 @@ require ( gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + k8s.io/apiextensions-apiserver 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/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect sigs.k8s.io/yaml v1.6.0 // indirect ) diff --git a/go.sum b/go.sum index 0e28a11..89a292b 100644 --- a/go.sum +++ b/go.sum @@ -12,10 +12,16 @@ 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/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= 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-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= 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= @@ -100,6 +106,10 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 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= @@ -123,6 +133,8 @@ golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= +gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= 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= @@ -137,6 +149,8 @@ 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.1 h1:0PO/1FhlK/EQNVK5+txc4FuhQibV25VLSdLMmGpDE/Q= k8s.io/api v0.35.1/go.mod h1:28uR9xlXWml9eT0uaGo6y71xK86JBELShLy4wR1XtxM= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= k8s.io/apimachinery v0.35.1 h1:yxO6gV555P1YV0SANtnTjXYfiivaTPvCTKX6w6qdDsU= k8s.io/apimachinery v0.35.1/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= @@ -147,11 +161,13 @@ k8s.io/kube-openapi v0.0.0-20250910181357-589584f1c912 h1:Y3gxNAuB0OBLImH611+UDZ 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/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= 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/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/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/internal/controller/controller.go b/internal/controller/controller.go index ef3e80c..73c4183 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -39,6 +39,10 @@ type ttlCache interface { Delete(k any) } +type deploymentRecordPoster interface { + PostOne(ctx context.Context, record *deploymentrecord.DeploymentRecord) error +} + type podMetadataAggregator interface { BuildAggregatePodMetadata(ctx context.Context, obj *metav1.PartialObjectMetadata) *metadata.AggregatePodMetadata } @@ -56,7 +60,7 @@ type Controller struct { metadataAggregator podMetadataAggregator podInformer cache.SharedIndexInformer workqueue workqueue.TypedRateLimitingInterface[PodEvent] - apiClient *deploymentrecord.Client + apiClient deploymentRecordPoster cfg *Config // best effort cache to avoid redundant posts // post requests are idempotent, so if this cache fails due to diff --git a/internal/controller/controller_integration_test.go b/internal/controller/controller_integration_test.go new file mode 100644 index 0000000..4be8391 --- /dev/null +++ b/internal/controller/controller_integration_test.go @@ -0,0 +1,527 @@ +package controller + +import ( + "context" + "fmt" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/github/deployment-tracker/internal/metadata" + "github.com/github/deployment-tracker/pkg/deploymentrecord" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + 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" + k8smetadata "k8s.io/client-go/metadata" + "k8s.io/client-go/tools/cache" + "sigs.k8s.io/controller-runtime/pkg/envtest" +) + +type mockRecordPoster struct { + mu sync.Mutex + records []*deploymentrecord.DeploymentRecord + err error // to simulate failures +} + +func (m *mockRecordPoster) PostOne(_ context.Context, record *deploymentrecord.DeploymentRecord) error { + m.mu.Lock() + defer m.mu.Unlock() + m.records = append(m.records, record) + return m.err +} + +// Helper that allows tests to read captured records safely. +func (m *mockRecordPoster) getRecords() []*deploymentrecord.DeploymentRecord { + m.mu.Lock() + defer m.mu.Unlock() + return slices.Clone(m.records) +} + +const testControllerNamespace = "test-controller-ns" + +func setup(t *testing.T, onlyNamespace string, excludeNamespaces string) (*kubernetes.Clientset, *mockRecordPoster) { + t.Helper() + testEnv := &envtest.Environment{} + + cfg, err := testEnv.Start() + if err != nil { + t.Fatalf("failed to start test environment: %v", err) + } + + clientset, err := kubernetes.NewForConfig(cfg) + if err != nil { + t.Fatalf("failed to create Kubernetes clientset: %v", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(func() { + cancel() + _ = testEnv.Stop() + }) + + ns := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: testControllerNamespace}} + _, err = clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create namespace: %v", err) + } + + if onlyNamespace != "" { + ns = &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: onlyNamespace}} + _, err = clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create onlyNamespace: %v", err) + } + } + + if excludeNamespaces != "" { + for _, nsName := range strings.Split(excludeNamespaces, ",") { + ns = &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: nsName}} + _, err = clientset.CoreV1().Namespaces().Create(ctx, ns, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create excludeNamespace %s: %v", nsName, err) + } + } + } + + metadataClient, err := k8smetadata.NewForConfig(cfg) + if err != nil { + t.Fatalf("failed to create Kubernetes metadata client: %v", err) + } + + metadataAggregator := metadata.NewAggregator(metadataClient) + + ctrl, err := New( + clientset, + metadataAggregator, + onlyNamespace, + excludeNamespaces, + &Config{ + Template: "{{namespace}}/{{deploymentName}}/{{containerName}}", + LogicalEnvironment: "test-logical-env", + PhysicalEnvironment: "test-physical-env", + Cluster: "test-cluster", + Organization: "test-org", + }, + ) + if err != nil { + t.Fatalf("failed to create controller: %v", err) + } + mockDeploymentRecordPoster := &mockRecordPoster{} + ctrl.apiClient = mockDeploymentRecordPoster + + go func() { + _ = ctrl.Run(ctx, 1) + }() + if !cache.WaitForCacheSync(ctx.Done(), ctrl.podInformer.HasSynced) { + t.Fatal("timed out waiting for informer cache to sync") + } + + return clientset, mockDeploymentRecordPoster +} + +func makeDeployment(t *testing.T, clientset *kubernetes.Clientset, owners []metav1.OwnerReference, namespace, name string) *appsv1.Deployment { + t.Helper() + ctx := context.Background() + labels := map[string]string{"app": name} + deployment := &appsv1.Deployment{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + OwnerReferences: owners, + }, + Spec: appsv1.DeploymentSpec{ + Selector: &metav1.LabelSelector{MatchLabels: labels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "nginx:latest"}}, + }, + }, + }, + } + d, err := clientset.AppsV1().Deployments(namespace).Create(ctx, deployment, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create Deployment: %v", err) + } + return d +} + +func makeReplicaSet(t *testing.T, clientset *kubernetes.Clientset, owners []metav1.OwnerReference, namespace, name string) *appsv1.ReplicaSet { + t.Helper() + ctx := context.Background() + labels := map[string]string{"app": name} + replicaSet := &appsv1.ReplicaSet{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + OwnerReferences: owners, + }, + Spec: appsv1.ReplicaSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: labels}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: labels}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "nginx:latest"}}, + }, + }, + }, + } + rs, err := clientset.AppsV1().ReplicaSets(namespace).Create(ctx, replicaSet, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create ReplicaSet: %v", err) + } + return rs +} + +func makePod(t *testing.T, clientset *kubernetes.Clientset, owners []metav1.OwnerReference, namespace, name string) *corev1.Pod { + t.Helper() + ctx := context.Background() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + OwnerReferences: owners, + }, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "app", Image: "nginx:latest"}}, + }, + } + created, err := clientset.CoreV1().Pods(namespace).Create(ctx, pod, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create Pod: %v", err) + } + + // First set the pod to Pending phase + created.Status.Phase = corev1.PodPending + pending, err := clientset.CoreV1().Pods(namespace).UpdateStatus(ctx, created, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("failed to update Pod status to Pending: %v", err) + } + + // Then transition to Running + pending.Status.Phase = corev1.PodRunning + pending.Status.ContainerStatuses = []corev1.ContainerStatus{{ + Name: "app", + ImageID: "docker-pullable://nginx@sha256:abc123def456", + }} + updated, err := clientset.CoreV1().Pods(namespace).UpdateStatus(ctx, pending, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("failed to update Pod status to Running: %v", err) + } + return updated +} + +func makePodWithInitContainer(t *testing.T, clientset *kubernetes.Clientset, owners []metav1.OwnerReference, namespace, name string) *corev1.Pod { + t.Helper() + ctx := context.Background() + pod := &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + OwnerReferences: owners, + }, + Spec: corev1.PodSpec{ + InitContainers: []corev1.Container{{Name: "init", Image: "busybox:latest"}}, + Containers: []corev1.Container{{Name: "app", Image: "nginx:latest"}}, + }, + } + created, err := clientset.CoreV1().Pods(namespace).Create(ctx, pod, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create Pod: %v", err) + } + + created.Status.Phase = corev1.PodPending + pending, err := clientset.CoreV1().Pods(namespace).UpdateStatus(ctx, created, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("failed to update Pod status to Pending: %v", err) + } + + pending.Status.Phase = corev1.PodRunning + pending.Status.InitContainerStatuses = []corev1.ContainerStatus{{ + Name: "init", + ImageID: "docker-pullable://busybox@sha256:initdigest789", + }} + pending.Status.ContainerStatuses = []corev1.ContainerStatus{{ + Name: "app", + ImageID: "docker-pullable://nginx@sha256:abc123def456", + }} + updated, err := clientset.CoreV1().Pods(namespace).UpdateStatus(ctx, pending, metav1.UpdateOptions{}) + if err != nil { + t.Fatalf("failed to update Pod status to Running: %v", err) + } + return updated +} + +func deleteDeployment(t *testing.T, clientset *kubernetes.Clientset, namespace, name string) { + t.Helper() + ctx := context.Background() + err := clientset.AppsV1().Deployments(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + t.Fatalf("failed to delete Deployment: %v", err) + } +} + +func deleteReplicaSet(t *testing.T, clientset *kubernetes.Clientset, namespace, name string) { + t.Helper() + ctx := context.Background() + err := clientset.AppsV1().ReplicaSets(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + t.Fatalf("failed to delete ReplicaSet: %v", err) + } +} + +func deletePod(t *testing.T, clientset *kubernetes.Clientset, namespace, name string) { + t.Helper() + ctx := context.Background() + err := clientset.CoreV1().Pods(namespace).Delete(ctx, name, metav1.DeleteOptions{}) + if err != nil { + t.Fatalf("failed to delete Pod: %v", err) + } +} + +func TestControllerIntegration_KubernetesDeploymentLifecycle(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + t.Parallel() + namespace := "test-controller-ns" + clientset, mock := setup(t, "", "") + + // Create deployment, replicaset, and pod; expect 1 record + deployment := makeDeployment(t, clientset, []metav1.OwnerReference{}, namespace, "test-deployment") + replicaSet := makeReplicaSet(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deployment.Name, + UID: deployment.UID, + }}, namespace, "test-deployment-123456") + _ = makePod(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: replicaSet.Name, + UID: replicaSet.UID, + }}, namespace, "test-deployment-123456-1") + + require.Eventually(t, func() bool { + return len(mock.getRecords()) >= 1 + }, 3*time.Second, 100*time.Millisecond) + records := mock.getRecords() + require.Len(t, records, 1) + assert.Equal(t, deploymentrecord.StatusDeployed, records[0].Status) + + // Create another pod in replicaset; the dedup cache should prevent a new record as there is only one worker + // and no risk of multiple workers processing before cache is set. + _ = makePod(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: replicaSet.Name, + UID: replicaSet.UID, + }}, namespace, "test-deployment-123456-2") + require.Never(t, func() bool { + return len(mock.getRecords()) != 1 + }, 3*time.Second, 100*time.Millisecond) + + // Delete second pod; still expect 1 record + deletePod(t, clientset, namespace, "test-deployment-123456-2") + require.Never(t, func() bool { + return len(mock.getRecords()) != 1 + }, 3*time.Second, 100*time.Millisecond) + + // Delete deployment, replicaset, and first pod; expect 2 records + deleteDeployment(t, clientset, namespace, "test-deployment") + deleteReplicaSet(t, clientset, namespace, "test-deployment-123456") + deletePod(t, clientset, namespace, "test-deployment-123456-1") + + require.Eventually(t, func() bool { + return len(mock.getRecords()) >= 2 + }, 3*time.Second, 100*time.Millisecond) + records = mock.getRecords() + require.Len(t, records, 2) + assert.Equal(t, deploymentrecord.StatusDecommissioned, records[1].Status) +} + +func TestControllerIntegration_InitContainers(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + t.Parallel() + namespace := "test-controller-ns" + clientset, mock := setup(t, "", "") + + // Create deployment, replicaset, and pod with an init container; expect 2 records (one per container) + deployment := makeDeployment(t, clientset, []metav1.OwnerReference{}, namespace, "init-deployment") + replicaSet := makeReplicaSet(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deployment.Name, + UID: deployment.UID, + }}, namespace, "init-deployment-abc123") + _ = makePodWithInitContainer(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: replicaSet.Name, + UID: replicaSet.UID, + }}, namespace, "init-deployment-abc123-1") + + require.Eventually(t, func() bool { + return len(mock.getRecords()) >= 2 + }, 3*time.Second, 100*time.Millisecond) + records := mock.getRecords() + require.Len(t, records, 2) + + // Both records should be deployed; collect deployment names to verify both containers are recorded + deploymentNames := make([]string, len(records)) + for i, r := range records { + assert.Equal(t, deploymentrecord.StatusDeployed, r.Status) + deploymentNames[i] = r.DeploymentName + } + assert.Contains(t, deploymentNames, fmt.Sprintf("%s/init-deployment/app", namespace)) + assert.Contains(t, deploymentNames, fmt.Sprintf("%s/init-deployment/init", namespace)) + + // Delete deployment, replicaset, and pod; expect 2 more decommissioned records (one per container) + deleteDeployment(t, clientset, namespace, "init-deployment") + deleteReplicaSet(t, clientset, namespace, "init-deployment-abc123") + deletePod(t, clientset, namespace, "init-deployment-abc123-1") + + require.Eventually(t, func() bool { + return len(mock.getRecords()) >= 4 + }, 3*time.Second, 100*time.Millisecond) + records = mock.getRecords() + require.Len(t, records, 4) + + decommissionedNames := make([]string, 0, 2) + for _, r := range records[2:] { + assert.Equal(t, deploymentrecord.StatusDecommissioned, r.Status) + decommissionedNames = append(decommissionedNames, r.DeploymentName) + } + assert.Contains(t, decommissionedNames, fmt.Sprintf("%s/init-deployment/app", namespace)) + assert.Contains(t, decommissionedNames, fmt.Sprintf("%s/init-deployment/init", namespace)) +} + +func TestControllerIntegration_OnlyWatchOneNamespace(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + t.Parallel() + namespace1 := "namespace1" + namespace2 := "namespace2" + clientset, mock := setup(t, namespace1, "") + + // Make invalid namespaces + ns2 := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace2}} + _, err := clientset.CoreV1().Namespaces().Create(context.Background(), ns2, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create namespace: %v", err) + } + + // Make new deployment in namespace1; expect 1 record + deployment1 := makeDeployment(t, clientset, []metav1.OwnerReference{}, namespace1, "init-deployment") + replicaSet1 := makeReplicaSet(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deployment1.Name, + UID: deployment1.UID, + }}, namespace1, "init-deployment-abc123") + _ = makePod(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: replicaSet1.Name, + UID: replicaSet1.UID, + }}, namespace1, "init-deployment-abc123-1") + require.Eventually(t, func() bool { + return len(mock.getRecords()) == 1 + }, 3*time.Second, 100*time.Millisecond) + + // Make new deployment in namespace2; expect no new records + deployment2 := makeDeployment(t, clientset, []metav1.OwnerReference{}, namespace2, "init-deployment") + replicaSet2 := makeReplicaSet(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deployment2.Name, + UID: deployment2.UID, + }}, namespace2, "init-deployment-abc123") + _ = makePod(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: replicaSet2.Name, + UID: replicaSet2.UID, + }}, namespace2, "init-deployment-abc123-1") + require.Never(t, func() bool { + return len(mock.getRecords()) != 1 + }, 3*time.Second, 100*time.Millisecond) +} + +func TestControllerIntegration_ExcludeNamespaces(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + t.Parallel() + namespace1 := "namespace1" + namespace2 := "namespace2" + namespace3 := "namespace3" + clientset, mock := setup(t, "", fmt.Sprintf("%s,%s", namespace2, namespace3)) + + // Make valid namespace + ns1 := &corev1.Namespace{ObjectMeta: metav1.ObjectMeta{Name: namespace1}} + _, err := clientset.CoreV1().Namespaces().Create(context.Background(), ns1, metav1.CreateOptions{}) + if err != nil { + t.Fatalf("failed to create namespace: %v", err) + } + + // Make new deployment in namespace1; expect 1 record + deployment1 := makeDeployment(t, clientset, []metav1.OwnerReference{}, namespace1, "init-deployment") + replicaSet1 := makeReplicaSet(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deployment1.Name, + UID: deployment1.UID, + }}, namespace1, "init-deployment-abc123") + _ = makePod(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: replicaSet1.Name, + UID: replicaSet1.UID, + }}, namespace1, "init-deployment-abc123-1") + require.Eventually(t, func() bool { + return len(mock.getRecords()) == 1 + }, 3*time.Second, 100*time.Millisecond) + + // Make new deployment in namespace2; expect no new records + deployment2 := makeDeployment(t, clientset, []metav1.OwnerReference{}, namespace2, "init-deployment") + replicaSet2 := makeReplicaSet(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deployment2.Name, + UID: deployment2.UID, + }}, namespace2, "init-deployment-abc123") + _ = makePod(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: replicaSet2.Name, + UID: replicaSet2.UID, + }}, namespace2, "init-deployment-abc123-1") + + // Make new deployment in namespace 3; expect no new records + deployment3 := makeDeployment(t, clientset, []metav1.OwnerReference{}, namespace3, "init-deployment") + replicaSet3 := makeReplicaSet(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "Deployment", + Name: deployment3.Name, + UID: deployment3.UID, + }}, namespace3, "init-deployment-abc123") + _ = makePod(t, clientset, []metav1.OwnerReference{{ + APIVersion: "apps/v1", + Kind: "ReplicaSet", + Name: replicaSet3.Name, + UID: replicaSet3.UID, + }}, namespace3, "init-deployment-abc123-1") + + require.Never(t, func() bool { + return len(mock.getRecords()) != 1 + }, 3*time.Second, 100*time.Millisecond) +}