Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions cmd/agent/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ func run(ctx context.Context, logger *slog.Logger, clientset kubernetes.Interfac
"pod", p.Name,
"node", p.Node,
"phase", p.Phase,
"qos", p.QOSClass,
"workload_kind", p.Workload.Kind,
"workload_name", p.Workload.Name,
"containers", p.Containers,
Expand Down
51 changes: 44 additions & 7 deletions internal/collector/pods.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
appslisters "k8s.io/client-go/listers/apps/v1"
batchlisters "k8s.io/client-go/listers/batch/v1"
"k8s.io/client-go/tools/cache"
"k8s.io/utils/ptr"
)

// WorkloadRef identifies the controller that ultimately manages a pod:
Expand All @@ -24,12 +25,25 @@ type WorkloadRef struct {
Name string `json:"name"`
}

// Container is the collected view of a container: name and image only.
// Env, args, and command are deliberately never read (filter early).
// Resources is the declared resource envelope of one container, normalized
// to resource units: CPU in millicores, memory in bytes. A nil field means
// the corresponding request or limit is not set — a meaningful fact in
// itself, distinct from zero.
type Resources struct {
CPURequestMilli *int64 `json:"cpu_request_milli,omitempty"`
CPULimitMilli *int64 `json:"cpu_limit_milli,omitempty"`
MemoryRequestBytes *int64 `json:"memory_request_bytes,omitempty"`
MemoryLimitBytes *int64 `json:"memory_limit_bytes,omitempty"`
}

// Container is the collected view of a container: name, image, and declared
// resources only. Env, args, and command are deliberately never read
// (filter early).
type Container struct {
Name string `json:"name"`
Image string `json:"image"`
Init bool `json:"init,omitempty"`
Name string `json:"name"`
Image string `json:"image"`
Init bool `json:"init,omitempty"`
Resources Resources `json:"resources"`
}

// PodInfo is the collected view of one pod.
Expand All @@ -38,6 +52,7 @@ type PodInfo struct {
Name string
Node string
Phase string
QOSClass string
Workload WorkloadRef
Containers []Container
}
Expand Down Expand Up @@ -111,17 +126,39 @@ func (w *PodWatcher) describe(pod *corev1.Pod) PodInfo {
Name: pod.Name,
Node: pod.Spec.NodeName,
Phase: string(pod.Status.Phase),
QOSClass: string(pod.Status.QOSClass),
Workload: w.resolveWorkload(pod),
}
for _, c := range pod.Spec.InitContainers {
info.Containers = append(info.Containers, Container{Name: c.Name, Image: c.Image, Init: true})
info.Containers = append(info.Containers,
Container{Name: c.Name, Image: c.Image, Init: true, Resources: resourcesOf(&c)})
}
for _, c := range pod.Spec.Containers {
info.Containers = append(info.Containers, Container{Name: c.Name, Image: c.Image})
info.Containers = append(info.Containers,
Container{Name: c.Name, Image: c.Image, Resources: resourcesOf(&c)})
}
return info
}

// resourcesOf normalizes a container's declared requests and limits:
// CPU quantities to millicores, memory to bytes. Absent entries stay nil.
func resourcesOf(c *corev1.Container) Resources {
var r Resources
if q, ok := c.Resources.Requests[corev1.ResourceCPU]; ok {
r.CPURequestMilli = ptr.To(q.MilliValue())
}
if q, ok := c.Resources.Limits[corev1.ResourceCPU]; ok {
r.CPULimitMilli = ptr.To(q.MilliValue())
}
if q, ok := c.Resources.Requests[corev1.ResourceMemory]; ok {
r.MemoryRequestBytes = ptr.To(q.Value())
}
if q, ok := c.Resources.Limits[corev1.ResourceMemory]; ok {
r.MemoryLimitBytes = ptr.To(q.Value())
}
return r
}

// resolveWorkload walks the owner chain one controller hop at a time:
// Pod → ReplicaSet → Deployment (or Argo Rollout), Pod → Job → CronJob.
// Direct controllers (StatefulSet, DaemonSet, custom CRDs) are reported
Expand Down
64 changes: 57 additions & 7 deletions internal/collector/pods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,15 @@ package collector

import (
"context"
"fmt"
"sync"
"testing"
"time"

appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/types"
Expand All @@ -29,14 +31,25 @@ func pod(name string, owner *metav1.OwnerReference) *corev1.Pod {
Spec: corev1.PodSpec{
NodeName: "node-1",
InitContainers: []corev1.Container{
{Name: "init-db", Image: "example.com/migrate:v3"},
{Name: "init-db", Image: "example.com/migrate:v3", Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{corev1.ResourceCPU: resource.MustParse("100m")},
}},
},
Containers: []corev1.Container{
{Name: "app", Image: "example.com/app:1.2.3"},
{Name: "app", Image: "example.com/app:1.2.3", Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("500m"),
corev1.ResourceMemory: resource.MustParse("256Mi"),
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("2"),
corev1.ResourceMemory: resource.MustParse("1Gi"),
},
}},
{Name: "sidecar", Image: "example.com/proxy:latest"},
},
},
Status: corev1.PodStatus{Phase: corev1.PodRunning},
Status: corev1.PodStatus{Phase: corev1.PodRunning, QOSClass: corev1.PodQOSBurstable},
}
if owner != nil {
p.OwnerReferences = []metav1.OwnerReference{*owner}
Expand Down Expand Up @@ -137,21 +150,58 @@ func TestCollectsContainersAndImages(t *testing.T) {

info := seen["shop/checkout-1"]
want := []Container{
{Name: "init-db", Image: "example.com/migrate:v3", Init: true},
{Name: "app", Image: "example.com/app:1.2.3"},
{Name: "init-db", Image: "example.com/migrate:v3", Init: true,
Resources: Resources{CPURequestMilli: ptr.To(int64(100))}},
{Name: "app", Image: "example.com/app:1.2.3",
Resources: Resources{
CPURequestMilli: ptr.To(int64(500)),
CPULimitMilli: ptr.To(int64(2000)),
MemoryRequestBytes: ptr.To(int64(256 << 20)),
MemoryLimitBytes: ptr.To(int64(1 << 30)),
}},
{Name: "sidecar", Image: "example.com/proxy:latest"},
}
if len(info.Containers) != len(want) {
t.Fatalf("containers = %+v, want %+v", info.Containers, want)
}
for i := range want {
if info.Containers[i] != want[i] {
t.Errorf("container %d = %+v, want %+v", i, info.Containers[i], want[i])
got := info.Containers[i]
if got.Name != want[i].Name || got.Image != want[i].Image || got.Init != want[i].Init {
t.Errorf("container %d = %+v, want %+v", i, got, want[i])
}
assertResources(t, got.Name, got.Resources, want[i].Resources)
}
if info.Node != "node-1" || info.Phase != string(corev1.PodRunning) {
t.Errorf("node/phase = %q/%q, want node-1/Running", info.Node, info.Phase)
}
if info.QOSClass != string(corev1.PodQOSBurstable) {
t.Errorf("qos = %q, want Burstable", info.QOSClass)
}
}

// assertResources compares two Resources field by field; a nil on either
// side must be nil on the other (absent means "not set", not zero).
func assertResources(t *testing.T, container string, got, want Resources) {
t.Helper()
check := func(field string, g, w *int64) {
switch {
case (g == nil) != (w == nil):
t.Errorf("%s: %s = %v, want %v", container, field, format(g), format(w))
case g != nil && *g != *w:
t.Errorf("%s: %s = %d, want %d", container, field, *g, *w)
}
}
check("cpu_request_milli", got.CPURequestMilli, want.CPURequestMilli)
check("cpu_limit_milli", got.CPULimitMilli, want.CPULimitMilli)
check("memory_request_bytes", got.MemoryRequestBytes, want.MemoryRequestBytes)
check("memory_limit_bytes", got.MemoryLimitBytes, want.MemoryLimitBytes)
}

func format(v *int64) string {
if v == nil {
return "unset"
}
return fmt.Sprintf("%d", *v)
}

func TestReportsPodsCreatedAfterStart(t *testing.T) {
Expand Down
33 changes: 32 additions & 1 deletion test/e2e/pods_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
appsv1 "k8s.io/api/apps/v1"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
"k8s.io/apimachinery/pkg/api/resource"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/client-go/kubernetes"
"k8s.io/client-go/tools/clientcmd"
Expand Down Expand Up @@ -77,7 +78,19 @@ func TestPodWatcherAgainstRealCluster(t *testing.T) {
Template: corev1.PodTemplateSpec{
ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "web"}},
Spec: corev1.PodSpec{
Containers: []corev1.Container{{Name: "app", Image: pauseImage}},
Containers: []corev1.Container{{
Name: "app", Image: pauseImage,
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("50m"),
corev1.ResourceMemory: resource.MustParse("32Mi"),
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("100m"),
corev1.ResourceMemory: resource.MustParse("64Mi"),
},
},
}},
},
},
},
Expand Down Expand Up @@ -154,6 +167,24 @@ func TestPodWatcherAgainstRealCluster(t *testing.T) {
if len(webPod.Containers) != 1 || webPod.Containers[0].Image != pauseImage {
t.Errorf("deployment pod containers = %+v, want one %s", webPod.Containers, pauseImage)
}
res := webPod.Containers[0].Resources
wantRes := map[string]struct {
got *int64
want int64
}{
"cpu_request_milli": {res.CPURequestMilli, 50},
"cpu_limit_milli": {res.CPULimitMilli, 100},
"memory_request_bytes": {res.MemoryRequestBytes, 32 << 20},
"memory_limit_bytes": {res.MemoryLimitBytes, 64 << 20},
}
for field, v := range wantRes {
if v.got == nil || *v.got != v.want {
t.Errorf("deployment pod %s = %v, want %d", field, v.got, v.want)
}
}
if webPod.QOSClass != string(corev1.PodQOSBurstable) {
t.Errorf("deployment pod qos = %q, want Burstable", webPod.QOSClass)
}

waitForWorkload("CronJob/tick")

Expand Down