diff --git a/discovery/discovery.go b/discovery/discovery.go index 991bd2d..c6c5177 100644 --- a/discovery/discovery.go +++ b/discovery/discovery.go @@ -5,13 +5,42 @@ type Manifest struct { DiscoveredImages []DiscoveredImage } -// DiscoveredImage is a container image that' been discovered based on the -// workload watchers. +// DiscoveredImage is a container image which was discovered in one or more workloads. type DiscoveredImage struct { - // PodName is the pod.metadata.name value where the image was discovered. - PodName string - // ContainerName represents the container in the pod that had the image. - ContainerName string // Image is a fully qualified container image name and tag or digest. Image string + + // Containers is a list of DiscoveredContainer objects which are using + // the discovered image. + Containers []DiscoveredContainer +} + +// DiscoveredContainer is a container which was observed during the discovery process. +type DiscoveredContainer struct { + // Name is the name of a container in a pod. + Name string + + // Type is the ContainerType of the container in its pod. + Type ContainerType + + // Pod is the DiscoveredPod which this container is a part of. + Pod DiscoveredPod +} + +// ContainerType is the type of a container in a pod. +type ContainerType = string + +const ( + ContainerTypeStandard ContainerType = "Container" + ContainerTypeInit ContainerType = "InitContainer" + ContainerTypeEphemeral ContainerType = "EphemeralContainer" +) + +// DiscoveredPod is a pod that contains a discovered image. +type DiscoveredPod struct { + // Name is the pod.metadata.name value where the image was discovered. + Name string + + // Namespace is the pod.metadata.namespace value of the pod. + Namespace string } diff --git a/internal/discover/processor.go b/internal/discover/processor.go index ececcd3..7dbcc42 100644 --- a/internal/discover/processor.go +++ b/internal/discover/processor.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "log/slog" + "slices" corev1 "k8s.io/api/core/v1" @@ -28,7 +29,7 @@ func NewManifestJSONProcessorFn(out io.Writer) ProcessingFunction { continueRunning = false break } - m.DiscoveredImages = append(m.DiscoveredImages, processContainers(p, logger)...) + m = appendToManifest(m, processContainers(p, logger)...) case <-ctx.Done(): logger.Debug("processorFn completing because the context completed") continueRunning = false @@ -64,9 +65,17 @@ func processContainers( found = append( found, discovery.DiscoveredImage{ - PodName: p.Name, - ContainerName: c.Name, - Image: c.Image, + Image: c.Image, + Containers: []discovery.DiscoveredContainer{ + { + Name: c.Name, + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: p.Name, + Namespace: p.Namespace, + }, + }, + }, }, ) } @@ -76,9 +85,17 @@ func processContainers( found = append( found, discovery.DiscoveredImage{ - PodName: p.Name, - ContainerName: c.Name, - Image: c.Image, + Image: c.Image, + Containers: []discovery.DiscoveredContainer{ + { + Name: c.Name, + Type: discovery.ContainerTypeInit, + Pod: discovery.DiscoveredPod{ + Name: p.Name, + Namespace: p.Namespace, + }, + }, + }, }, ) } @@ -88,12 +105,45 @@ func processContainers( found = append( found, discovery.DiscoveredImage{ - PodName: p.Name, - ContainerName: c.Name, - Image: c.Image, + Image: c.Image, + Containers: []discovery.DiscoveredContainer{ + { + Name: c.Name, + Type: discovery.ContainerTypeEphemeral, + Pod: discovery.DiscoveredPod{ + Name: p.Name, + Namespace: p.Namespace, + }, + }, + }, }, ) } return found } + +func appendToManifest(m discovery.Manifest, images ...discovery.DiscoveredImage) discovery.Manifest { + for _, image := range images { + idx := slices.IndexFunc(m.DiscoveredImages, func(i discovery.DiscoveredImage) bool { + return i.Image == image.Image + }) + + if idx == -1 { + m.DiscoveredImages = append(m.DiscoveredImages, image) + continue + } + + for _, container := range image.Containers { + if !slices.Contains(m.DiscoveredImages[idx].Containers, container) { + m.DiscoveredImages[idx].Containers = append(m.DiscoveredImages[idx].Containers, container) + } + } + } + + return m +} + +func imagesEqual(i1, i2 discovery.DiscoveredImage) bool { + return i1.Image == i2.Image && slices.Equal(i1.Containers, i2.Containers) +} diff --git a/internal/discover/processor_test.go b/internal/discover/processor_test.go index 3411e6c..3389fcb 100644 --- a/internal/discover/processor_test.go +++ b/internal/discover/processor_test.go @@ -4,7 +4,6 @@ import ( "bytes" "context" "io" - "slices" "sync" "testing" @@ -14,6 +13,379 @@ import ( "github.com/opdev/discover-workload/discovery" ) +func TestManifestInsert(t *testing.T) { + t.Parallel() + testcases := map[string]struct { + ctx context.Context + input []corev1.Pod + expected discovery.Manifest + }{ + "unique containers": { + ctx: context.TODO(), + input: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pod-1"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container-1-1", + Image: "example.com/namespace/image:0.1.1", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "container-1-2", + Image: "example.com/namespace/image:0.1.2", + }, + }, + EphemeralContainers: []corev1.EphemeralContainer{ + { + EphemeralContainerCommon: corev1.EphemeralContainerCommon{ + Name: "container-1-3", + Image: "example.com/namespace/image:0.1.3", + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pod-2"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container-2-1", + Image: "example.com/namespace/image:0.2.1", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "container-2-2", + Image: "example.com/namespace/image:0.2.2", + }, + }, + EphemeralContainers: []corev1.EphemeralContainer{ + { + EphemeralContainerCommon: corev1.EphemeralContainerCommon{ + Name: "container-2-3", + Image: "example.com/namespace/image:0.2.3", + }, + }, + }, + }, + }, + }, + expected: discovery.Manifest{ + DiscoveredImages: []discovery.DiscoveredImage{ + { + Image: "example.com/namespace/image:0.1.1", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-1-1", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-1", + }, + }, + }, + }, + { + Image: "example.com/namespace/image:0.1.2", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-1-2", + Type: discovery.ContainerTypeInit, + Pod: discovery.DiscoveredPod{ + Name: "pod-1", + }, + }, + }, + }, + { + Image: "example.com/namespace/image:0.1.3", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-1-3", + Type: discovery.ContainerTypeEphemeral, + Pod: discovery.DiscoveredPod{ + Name: "pod-1", + }, + }, + }, + }, + { + Image: "example.com/namespace/image:0.2.1", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-2-1", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-2", + }, + }, + }, + }, + { + Image: "example.com/namespace/image:0.2.2", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-2-2", + Type: discovery.ContainerTypeInit, + Pod: discovery.DiscoveredPod{ + Name: "pod-2", + }, + }, + }, + }, + { + Image: "example.com/namespace/image:0.2.3", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-2-3", + Type: discovery.ContainerTypeEphemeral, + Pod: discovery.DiscoveredPod{ + Name: "pod-2", + }, + }, + }, + }, + }, + }, + }, + "multiple pods with same image": { + ctx: context.TODO(), + input: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pod-1"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container-1-1", + Image: "example.com/namespace/image:0.1.0", + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pod-2"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container-2-1", + Image: "example.com/namespace/image:0.1.0", + }, + }, + }, + }, + }, + expected: discovery.Manifest{ + DiscoveredImages: []discovery.DiscoveredImage{ + { + Image: "example.com/namespace/image:0.1.0", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-1-1", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-1", + }, + }, + { + Name: "container-2-1", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-2", + }, + }, + }, + }, + }, + }, + }, + "multiple containers with same image": { + ctx: context.TODO(), + input: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pod-1"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container-1-1", + Image: "example.com/namespace/image:0.1.0", + }, + { + Name: "container-1-2", + Image: "example.com/namespace/image:0.1.0", + }, + }, + }, + }, + }, + expected: discovery.Manifest{ + DiscoveredImages: []discovery.DiscoveredImage{ + { + Image: "example.com/namespace/image:0.1.0", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-1-1", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-1", + }, + }, + { + Name: "container-1-2", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-1", + }, + }, + }, + }, + }, + }, + }, + "multiple pods and containers with same image": { + ctx: context.TODO(), + input: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{Name: "pod-1"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container-1-1", + Image: "example.com/namespace/image:0.1.1", + }, + { + Name: "container-1-2", + Image: "example.com/namespace/image:0.1.2", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "container-1-3", + Image: "example.com/namespace/image:0.1.1", + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{Name: "pod-2"}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{ + { + Name: "container-2-1", + Image: "example.com/namespace/image:0.1.2", + }, + { + Name: "container-2-2", + Image: "example.com/namespace/image:0.1.3", + }, + { + Name: "container-2-3", + Image: "example.com/namespace/image:0.1.3", + }, + }, + InitContainers: []corev1.Container{ + { + Name: "container-2-4", + Image: "example.com/namespace/image:0.1.2", + }, + }, + }, + }, + }, + expected: discovery.Manifest{ + DiscoveredImages: []discovery.DiscoveredImage{ + { + Image: "example.com/namespace/image:0.1.1", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-1-1", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-1", + }, + }, + { + Name: "container-1-3", + Type: discovery.ContainerTypeInit, + Pod: discovery.DiscoveredPod{ + Name: "pod-1", + }, + }, + }, + }, + { + Image: "example.com/namespace/image:0.1.2", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-1-2", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-1", + }, + }, + { + Name: "container-2-1", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-2", + }, + }, + { + Name: "container-2-4", + Type: discovery.ContainerTypeInit, + Pod: discovery.DiscoveredPod{ + Name: "pod-2", + }, + }, + }, + }, + { + Image: "example.com/namespace/image:0.1.3", + Containers: []discovery.DiscoveredContainer{ + { + Name: "container-2-2", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-2", + }, + }, + { + Name: "container-2-3", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "pod-2", + }, + }, + }, + }, + }, + }, + }, + } + + for description, tc := range testcases { + testLogger := NewSlogDiscardLogger() + t.Run(description, func(t *testing.T) { + t.Parallel() + actual := discovery.Manifest{} + for _, pod := range tc.input { + actual = appendToManifest(actual, processContainers(&pod, testLogger)...) + } + + if len(actual.DiscoveredImages) != len(tc.expected.DiscoveredImages) { + t.Fatalf("Processing returned %v; expected %v", actual, tc.expected) + } + for idx := range actual.DiscoveredImages { + if !imagesEqual(actual.DiscoveredImages[idx], tc.expected.DiscoveredImages[idx]) { + t.Fatalf("Processing returned %v; expected %v", actual, tc.expected) + } + } + }) + } +} + func TestManifestJSONProcessor(t *testing.T) { t.Parallel() testcases := map[string]struct { @@ -36,7 +408,7 @@ func TestManifestJSONProcessor(t *testing.T) { }, }, }, - expected: []byte("{\"DiscoveredImages\":[{\"PodName\":\"init-podname\",\"ContainerName\":\"init-cname\",\"Image\":\"example.com/namespace/image:0.0.1\"}]}\n"), + expected: []byte("{\"DiscoveredImages\":[{\"Image\":\"example.com/namespace/image:0.0.1\",\"Containers\":[{\"Name\":\"init-cname\",\"Type\":\"InitContainer\",\"Pod\":{\"Name\":\"init-podname\",\"Namespace\":\"\"}}]}]}\n"), }, } @@ -100,9 +472,16 @@ func TestContainerProcessing(t *testing.T) { }, expected: []discovery.DiscoveredImage{ { - PodName: "init-podname", - ContainerName: "init-cname", - Image: "example.com/namespace/image:0.0.1", + Image: "example.com/namespace/image:0.0.1", + Containers: []discovery.DiscoveredContainer{ + { + Name: "init-cname", + Type: discovery.ContainerTypeInit, + Pod: discovery.DiscoveredPod{ + Name: "init-podname", + }, + }, + }, }, }, }, @@ -120,9 +499,16 @@ func TestContainerProcessing(t *testing.T) { }, expected: []discovery.DiscoveredImage{ { - PodName: "c-podname", - ContainerName: "c-cname", - Image: "example.com/namespace/image:0.0.1", + Image: "example.com/namespace/image:0.0.1", + Containers: []discovery.DiscoveredContainer{ + { + Name: "c-cname", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "c-podname", + }, + }, + }, }, }, }, @@ -142,9 +528,16 @@ func TestContainerProcessing(t *testing.T) { }, expected: []discovery.DiscoveredImage{ { - PodName: "eph-podname", - ContainerName: "eph-cname", - Image: "example.com/namespace/image:0.0.1", + Image: "example.com/namespace/image:0.0.1", + Containers: []discovery.DiscoveredContainer{ + { + Name: "eph-cname", + Type: discovery.ContainerTypeEphemeral, + Pod: discovery.DiscoveredPod{ + Name: "eph-podname", + }, + }, + }, }, }, }, @@ -176,19 +569,38 @@ func TestContainerProcessing(t *testing.T) { }, expected: []discovery.DiscoveredImage{ { - PodName: "all-podname", - ContainerName: "c-cname", - Image: "example.com/namespace/image:0.0.1", - }, - { - PodName: "all-podname", - ContainerName: "init-cname", - Image: "example.com/namespace/image:0.0.1", - }, - { - PodName: "all-podname", - ContainerName: "eph-cname", - Image: "example.com/namespace/image:0.0.1", + Image: "example.com/namespace/image:0.0.1", + Containers: []discovery.DiscoveredContainer{ + { + Name: "c-cname", + Type: discovery.ContainerTypeStandard, + Pod: discovery.DiscoveredPod{ + Name: "all-podname", + }, + }, + }, + }, { + Image: "example.com/namespace/image:0.0.1", + Containers: []discovery.DiscoveredContainer{ + { + Name: "init-cname", + Type: discovery.ContainerTypeInit, + Pod: discovery.DiscoveredPod{ + Name: "all-podname", + }, + }, + }, + }, { + Image: "example.com/namespace/image:0.0.1", + Containers: []discovery.DiscoveredContainer{ + { + Name: "eph-cname", + Type: discovery.ContainerTypeEphemeral, + Pod: discovery.DiscoveredPod{ + Name: "all-podname", + }, + }, + }, }, }, }, @@ -203,8 +615,14 @@ func TestContainerProcessing(t *testing.T) { // purposes, make sure the actual and expected values are sorted. If // not possible in the definition of the table, then we'll need to // add a sort function here. - if !slices.Equal(actual, tc.expected) { - t.Fatalf("Processing returned %q; expected %q", actual, tc.expected) + if len(actual) != len(tc.expected) { + t.Fatalf("Processing returned %v; expected %v", actual, tc.expected) + } + + for idx := range actual { + if !imagesEqual(actual[idx], tc.expected[idx]) { + t.Fatalf("Processing returned %v; expected %v", actual, tc.expected) + } } }) }