diff --git a/go.mod b/go.mod index bcbdfcd95083..2c36f3b81c59 100644 --- a/go.mod +++ b/go.mod @@ -1272,7 +1272,7 @@ replace github.com/iceber/iouring-go => github.com/lebauce/iouring-go v0.0.0-202 // Fork to remove some text/template usage, https://github.com/DataDog/opa/tree/lightweight-1.7.1 replace github.com/open-policy-agent/opa => github.com/DataDog/opa v0.0.0-20251126100856-d2e1e78e0816 -replace github.com/DataDog/agent-payload/v5 => github.com/DataDog/agent-payload/v5 v5.0.196 +replace github.com/DataDog/agent-payload/v5 => github.com/DataDog/agent-payload/v5 v5.0.198-0.20260521091023-1b5b2888eeb2 // This section was automatically added by 'dda inv modules.add-all-replace' command, do not edit manually diff --git a/go.sum b/go.sum index 4f90cc1af2ce..a54710eec7f2 100644 --- a/go.sum +++ b/go.sum @@ -2540,8 +2540,8 @@ github.com/CycloneDX/cyclonedx-go v0.9.3 h1:Pyk/lwavPz7AaZNvugKFkdWOm93MzaIyWmBw github.com/CycloneDX/cyclonedx-go v0.9.3/go.mod h1:vcK6pKgO1WanCdd61qx4bFnSsDJQ6SbM2ZuMIgq86Jg= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= -github.com/DataDog/agent-payload/v5 v5.0.196 h1:YdxilvpEGdiT5OVz9dkVqxEKoAA1frt0l9GjWJAt9lY= -github.com/DataDog/agent-payload/v5 v5.0.196/go.mod h1:GyA1vuCeRywrNNCbYeR0cOYKE8QkOVNAFDqsH8VNF5M= +github.com/DataDog/agent-payload/v5 v5.0.198-0.20260521091023-1b5b2888eeb2 h1:CsJDcAl9q8XEmPAhgKXFYta1dJgL409yuGraiLeS4VI= +github.com/DataDog/agent-payload/v5 v5.0.198-0.20260521091023-1b5b2888eeb2/go.mod h1:GyA1vuCeRywrNNCbYeR0cOYKE8QkOVNAFDqsH8VNF5M= github.com/DataDog/aptly v1.4.1-0.20260507130549-83af7604fdc9 h1:SqGEmv63IrjqqDmXf49ZjlZnyBiHiBbF/1AVaM3q+wI= github.com/DataDog/aptly v1.4.1-0.20260507130549-83af7604fdc9/go.mod h1:UmLcF4Qcn6yaiqp5xrsVwEfeBSD2YTWkWLv69SIlvtE= github.com/DataDog/cast v1.8.0 h1:uooY8bMzq+cjgiNP1VTquCWve5emgk8fRspZojJwQa8= diff --git a/pkg/collector/corechecks/cluster/orchestrator/processors/k8s/cluster.go b/pkg/collector/corechecks/cluster/orchestrator/processors/k8s/cluster.go index ea77ec00b1fc..1f9256673075 100644 --- a/pkg/collector/corechecks/cluster/orchestrator/processors/k8s/cluster.go +++ b/pkg/collector/corechecks/cluster/orchestrator/processors/k8s/cluster.go @@ -147,6 +147,12 @@ func (p *ClusterProcessor) Process(ctx processors.ProcessorContext, list interfa clusterModel.CreationTimestamp = kubeSystemCreationTimestamp.Unix() } + if info, err := k8sTransformers.FetchClusterInfo(context.TODO(), pctx.APIClient.Cl.CoreV1()); err != nil { + log.Debugc(fmt.Sprintf("failed to fetch dd-cluster-info ConfigMap: %s", err), orchestrator.ExtraLogContext...) + } else { + k8sTransformers.ApplyClusterInfo(clusterModel, nodesInfo, info, pctx.Cfg.KubeClusterName) + } + if err := fillClusterResourceVersion(clusterModel); err != nil { return processResult, 0, fmt.Errorf("failed to compute resource version: %s", err.Error()) } diff --git a/pkg/collector/corechecks/cluster/orchestrator/processors/k8s/cluster_test.go b/pkg/collector/corechecks/cluster/orchestrator/processors/k8s/cluster_test.go index 1f35c7e7a9b8..0e1a7f62b12f 100644 --- a/pkg/collector/corechecks/cluster/orchestrator/processors/k8s/cluster_test.go +++ b/pkg/collector/corechecks/cluster/orchestrator/processors/k8s/cluster_test.go @@ -163,6 +163,147 @@ func TestClusterProcessor_Process_Success(t *testing.T) { assert.Equal(t, *cluster, clusterFromManifest) } +func TestClusterProcessor_Process_NoClusterInfo(t *testing.T) { + node := createTestClusterNode("node-1", "v1.20.0", "1000m", "2Gi", "110") + kubeSystemNS := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-system", + CreationTimestamp: metav1.NewTime(time.Date(2021, time.April, 16, 14, 30, 0, 0, time.UTC)), + }, + } + client := fake.NewClientset(node, kubeSystemNS) + fakeDiscoveryClient := client.Discovery().(*fakediscovery.FakeDiscovery) + fakeDiscoveryClient.FakedServerVersion = &version.Info{Major: "1", Minor: "20", GitVersion: "v1.20.0"} + + cfg := orchestratorconfig.NewDefaultOrchestratorConfig(nil) + cfg.KubeClusterName = "test-cluster" + ctx := &processors.K8sProcessorContext{ + BaseProcessorContext: processors.BaseProcessorContext{ + Cfg: cfg, + Clock: clock.New(), + ClusterID: "test-cluster-id", + MsgGroupID: 1, + ManifestProducer: true, + }, + APIClient: &apiserver.APIClient{Cl: client}, + HostName: "test-host", + } + + processor := NewClusterProcessor() + result, _, err := processor.Process(ctx, []*corev1.Node{node}) + require.NoError(t, err) + + cluster := result.MetadataMessages[0].(*model.CollectorCluster).Cluster + assert.Nil(t, cluster.Autoscaling) + assert.Equal(t, int64(0), cluster.ClusterInfoGeneratedAtUnixNano) + assert.Nil(t, cluster.CloudResourceId) + for _, n := range cluster.NodesInfo { + assert.Empty(t, n.NodeManager, "node %q should have no nodeManager when ConfigMap is absent", n.Name) + } +} + +func TestClusterProcessor_Process_WithClusterInfo(t *testing.T) { + node1 := createTestClusterNode("node-1", "v1.20.0", "1000m", "2Gi", "110") + node2 := createTestClusterNode("node-2", "v1.20.0", "2000m", "4Gi", "220") + kubeSystemNS := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: "kube-system", + CreationTimestamp: metav1.NewTime(time.Date(2021, time.April, 16, 14, 30, 0, 0, time.UTC)), + }, + } + clusterInfoCM := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "dd-cluster-info", + Namespace: "dd-karpenter", + Labels: map[string]string{ + "app.kubernetes.io/managed-by": "kubectl-datadog", + }, + }, + Data: map[string]string{ + "cluster-info": "apiVersion: v1\n" + + "clusterName: test-cluster\n" + + "clusterArn: arn:aws:eks:eu-west-3:000000000000:cluster/test-cluster\n" + + "region: eu-west-3\n" + + "generatedAt: 2026-05-13T22:42:45Z\n" + + "nodeManagement:\n" + + " karpenter:\n" + + " pool-a:\n" + + " nodes: [node-1]\n" + + " managedByDatadog: true\n" + + " asg:\n" + + " group-z:\n" + + " nodes: [node-2]\n" + + "autoscaling:\n" + + " clusterAutoscaler:\n" + + " present: false\n" + + " karpenter:\n" + + " present: true\n" + + " namespace: dd-karpenter\n" + + " name: karpenter\n" + + " version: 1.12.1\n" + + " managedByDatadog: true\n" + + " installerVersion: v0.7.0\n" + + " eksAutoMode:\n" + + " enabled: false\n", + }, + } + + client := fake.NewClientset(node1, node2, kubeSystemNS, clusterInfoCM) + fakeDiscoveryClient := client.Discovery().(*fakediscovery.FakeDiscovery) + fakeDiscoveryClient.FakedServerVersion = &version.Info{Major: "1", Minor: "20", GitVersion: "v1.20.0"} + + cfg := orchestratorconfig.NewDefaultOrchestratorConfig(nil) + cfg.KubeClusterName = "test-cluster" + ctx := &processors.K8sProcessorContext{ + BaseProcessorContext: processors.BaseProcessorContext{ + Cfg: cfg, + Clock: clock.New(), + ClusterID: "test-cluster-id", + MsgGroupID: 1, + ManifestProducer: true, + }, + APIClient: &apiserver.APIClient{Cl: client}, + HostName: "test-host", + } + + processor := NewClusterProcessor() + result, _, err := processor.Process(ctx, []*corev1.Node{node1, node2}) + require.NoError(t, err) + + cluster := result.MetadataMessages[0].(*model.CollectorCluster).Cluster + require.NotNil(t, cluster.Autoscaling) + require.NotNil(t, cluster.Autoscaling.Karpenter) + assert.True(t, cluster.Autoscaling.Karpenter.Present) + assert.Equal(t, "1.12.1", cluster.Autoscaling.Karpenter.Version) + assert.NotZero(t, cluster.ClusterInfoGeneratedAtUnixNano) + + arnWrapper, ok := cluster.CloudResourceId.(*model.Cluster_Arn) + require.True(t, ok) + assert.Equal(t, "arn:aws:eks:eu-west-3:000000000000:cluster/test-cluster", arnWrapper.Arn) + + nodesByName := make(map[string]*model.ClusterNodeInfo) + for _, n := range cluster.NodesInfo { + nodesByName[n.Name] = n + } + require.Contains(t, nodesByName, "node-1") + assert.Equal(t, "karpenter", nodesByName["node-1"].NodeManager) + assert.Equal(t, "pool-a", nodesByName["node-1"].NodeManagerName) + assert.True(t, nodesByName["node-1"].NodeManagerManagedByDatadog) + + require.Contains(t, nodesByName, "node-2") + assert.Equal(t, "asg", nodesByName["node-2"].NodeManager) + assert.Equal(t, "group-z", nodesByName["node-2"].NodeManagerName) + assert.False(t, nodesByName["node-2"].NodeManagerManagedByDatadog) + + assert.NotEmpty(t, cluster.ResourceVersion, "cluster info should be hashed into ResourceVersion") + + manifestContent := string(result.ManifestMessages[0].(*model.CollectorManifest).Manifests[0].Content) + assert.Contains(t, manifestContent, `"autoscaling":`) + assert.Contains(t, manifestContent, `"karpenter":`) + assert.Contains(t, manifestContent, `"nodeManager":"karpenter"`) + assert.Contains(t, manifestContent, `"arn":"arn:aws:eks:eu-west-3:000000000000:cluster/test-cluster"`) +} + func TestClusterProcessor_ExtendedResources(t *testing.T) { // Create node with extended resources node := createTestClusterNode("node-1", "v1.20.0", "1000m", "2Gi", "110") diff --git a/pkg/collector/corechecks/cluster/orchestrator/transformers/k8s/clusterinfo.go b/pkg/collector/corechecks/cluster/orchestrator/transformers/k8s/clusterinfo.go new file mode 100644 index 000000000000..c5dfe60f8464 --- /dev/null +++ b/pkg/collector/corechecks/cluster/orchestrator/transformers/k8s/clusterinfo.go @@ -0,0 +1,211 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +//go:build orchestrator + +package k8s + +import ( + "context" + "fmt" + "time" + + "github.com/samber/lo" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + corev1Client "k8s.io/client-go/kubernetes/typed/core/v1" + "sigs.k8s.io/yaml" + + model "github.com/DataDog/agent-payload/v5/process" + "github.com/DataDog/datadog-agent/pkg/orchestrator" + "github.com/DataDog/datadog-agent/pkg/util/log" +) + +const ( + clusterInfoConfigMapName = "dd-cluster-info" + clusterInfoConfigMapDataKey = "cluster-info" + clusterInfoManagedByLabel = "app.kubernetes.io/managed-by" + clusterInfoManagedByValue = "kubectl-datadog" + clusterInfoAPIVersion = "v1" +) + +// ClusterInfo mirrors the YAML payload of the dd-cluster-info ConfigMap +// produced by `kubectl datadog autoscaling cluster install`. The Go schema +// lives in datadog-operator; we replicate the YAML tags here so the agent +// does not depend on that repository. +type ClusterInfo struct { + APIVersion string `json:"apiVersion"` + ClusterName string `json:"clusterName"` + ClusterARN string `json:"clusterArn,omitempty"` + Region string `json:"region,omitempty"` + GeneratedAt time.Time `json:"generatedAt"` + NodeManagement map[string]map[string]nodeManagerEntry `json:"nodeManagement"` + Autoscaling autoscalingYAML `json:"autoscaling"` +} + +type nodeManagerEntry struct { + Nodes []string `json:"nodes"` + ManagedByDatadog bool `json:"managedByDatadog,omitempty"` +} + +type autoscalingYAML struct { + ClusterAutoscaler clusterAutoscalerYAML `json:"clusterAutoscaler"` + Karpenter karpenterYAML `json:"karpenter"` + EKSAutoMode eksAutoModeYAML `json:"eksAutoMode"` +} + +type clusterAutoscalerYAML struct { + Present bool `json:"present"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` +} + +type karpenterYAML struct { + Present bool `json:"present"` + Namespace string `json:"namespace,omitempty"` + Name string `json:"name,omitempty"` + Version string `json:"version,omitempty"` + ManagedByDatadog bool `json:"managedByDatadog,omitempty"` + InstallerVersion string `json:"installerVersion,omitempty"` +} + +type eksAutoModeYAML struct { + Enabled bool `json:"enabled"` +} + +// FetchClusterInfo locates the dd-cluster-info ConfigMap anywhere in the +// cluster and returns its parsed payload. The ConfigMap is identified by +// its fixed name and the kubectl-datadog managed-by label. Returns +// (nil, nil) when no matching ConfigMap exists, when its schema version +// is not recognised, or when the payload key is missing — the absence of +// cluster-info is not an error, the snapshot is informational. +func FetchClusterInfo(ctx context.Context, coreClient corev1Client.CoreV1Interface) (*ClusterInfo, error) { + cms, err := coreClient.ConfigMaps(metav1.NamespaceAll).List(ctx, metav1.ListOptions{ + LabelSelector: clusterInfoManagedByLabel + "=" + clusterInfoManagedByValue, + FieldSelector: "metadata.name=" + clusterInfoConfigMapName, + }) + if err != nil { + return nil, fmt.Errorf("listing %s ConfigMaps: %w", clusterInfoConfigMapName, err) + } + if len(cms.Items) == 0 { + return nil, nil + } + + if len(cms.Items) > 1 { + namespaces := lo.Map(cms.Items, func(cm corev1.ConfigMap, _ int) string { return cm.Namespace }) + log.Warnc(fmt.Sprintf("found multiple %s ConfigMaps in namespaces %v, using %q", + clusterInfoConfigMapName, namespaces, cms.Items[0].Namespace), orchestrator.ExtraLogContext...) + } + chosen := &cms.Items[0] + + payload, ok := chosen.Data[clusterInfoConfigMapDataKey] + if !ok { + log.Warnc(fmt.Sprintf("%s ConfigMap in namespace %q is missing the %q data key", + clusterInfoConfigMapName, chosen.Namespace, clusterInfoConfigMapDataKey), + orchestrator.ExtraLogContext...) + return nil, nil + } + + var info ClusterInfo + if err := yaml.Unmarshal([]byte(payload), &info); err != nil { + return nil, fmt.Errorf("parsing %s ConfigMap payload: %w", clusterInfoConfigMapName, err) + } + if info.APIVersion != clusterInfoAPIVersion { + log.Warnc(fmt.Sprintf("%s ConfigMap apiVersion %q is not supported (expected %q), ignoring", + clusterInfoConfigMapName, info.APIVersion, clusterInfoAPIVersion), + orchestrator.ExtraLogContext...) + return nil, nil + } + + return &info, nil +} + +// ApplyClusterInfo enriches a cluster model and its node summaries with +// data sourced from the dd-cluster-info ConfigMap. +func ApplyClusterInfo(cluster *model.Cluster, nodesInfo []*model.ClusterNodeInfo, info *ClusterInfo, agentClusterName string) { + if info == nil { + return + } + + if info.ClusterName != "" && agentClusterName != "" && info.ClusterName != agentClusterName { + log.Warnc(fmt.Sprintf("dd-cluster-info clusterName %q does not match the agent cluster name %q", + info.ClusterName, agentClusterName), orchestrator.ExtraLogContext...) + } + if info.Region != "" { + for _, n := range nodesInfo { + if n.Region != "" && n.Region != info.Region { + log.Warnc(fmt.Sprintf("dd-cluster-info region %q does not match node %q region %q", + info.Region, n.Name, n.Region), orchestrator.ExtraLogContext...) + break + } + } + } + + cluster.Autoscaling = toAutoscalingInfo(info.Autoscaling) + if !info.GeneratedAt.IsZero() { + cluster.ClusterInfoGeneratedAtUnixNano = info.GeneratedAt.UnixNano() + } + if info.ClusterARN != "" { + cluster.CloudResourceId = &model.Cluster_Arn{Arn: info.ClusterARN} + } + + nodeIndex := buildNodeManagerIndex(info.NodeManagement) + for _, n := range nodesInfo { + if entry, ok := nodeIndex[n.Name]; ok { + n.NodeManager = entry.manager + n.NodeManagerName = entry.name + n.NodeManagerManagedByDatadog = entry.managedByDatadog + } + } +} + +type nodeManagerAssignment struct { + manager string + name string + managedByDatadog bool +} + +// buildNodeManagerIndex inverts the dd-cluster-info nodeManagement +// structure (manager → entity name → entry) into a lookup by node name. +// A given node belongs to exactly one management entity, so the inner +// writes never collide. +func buildNodeManagerIndex(nodeManagement map[string]map[string]nodeManagerEntry) map[string]nodeManagerAssignment { + out := make(map[string]nodeManagerAssignment) + for manager, entries := range nodeManagement { + for name, entry := range entries { + for _, node := range entry.Nodes { + out[node] = nodeManagerAssignment{ + manager: manager, + name: name, + managedByDatadog: entry.ManagedByDatadog, + } + } + } + } + return out +} + +func toAutoscalingInfo(a autoscalingYAML) *model.AutoscalingInfo { + return &model.AutoscalingInfo{ + ClusterAutoscaler: &model.ClusterAutoscalerInfo{ + Present: a.ClusterAutoscaler.Present, + Namespace: a.ClusterAutoscaler.Namespace, + Name: a.ClusterAutoscaler.Name, + Version: a.ClusterAutoscaler.Version, + }, + Karpenter: &model.KarpenterInfo{ + Present: a.Karpenter.Present, + Namespace: a.Karpenter.Namespace, + Name: a.Karpenter.Name, + Version: a.Karpenter.Version, + ManagedByDatadog: a.Karpenter.ManagedByDatadog, + InstallerVersion: a.Karpenter.InstallerVersion, + }, + EksAutoMode: &model.EKSAutoModeInfo{ + Enabled: a.EKSAutoMode.Enabled, + }, + } +} diff --git a/pkg/collector/corechecks/cluster/orchestrator/transformers/k8s/clusterinfo_test.go b/pkg/collector/corechecks/cluster/orchestrator/transformers/k8s/clusterinfo_test.go new file mode 100644 index 000000000000..f18d83975841 --- /dev/null +++ b/pkg/collector/corechecks/cluster/orchestrator/transformers/k8s/clusterinfo_test.go @@ -0,0 +1,339 @@ +// Unless explicitly stated otherwise all files in this repository are licensed +// under the Apache License Version 2.0. +// This product includes software developed at Datadog (https://www.datadoghq.com/). +// Copyright 2016-present Datadog, Inc. + +//go:build orchestrator + +package k8s + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" + ktesting "k8s.io/client-go/testing" + + model "github.com/DataDog/agent-payload/v5/process" +) + +const fullClusterInfoPayload = `apiVersion: v1 +clusterName: lenaic-karpenter-test +clusterArn: arn:aws:eks:eu-west-3:013364996899:cluster/lenaic-karpenter-test +region: eu-west-3 +generatedAt: 2026-05-13T22:42:45.753133827Z +nodeManagement: + asg: + eksctl-lenaic-karpenter-test-nodegroup-ng-bar-NodeGroup: + nodes: + - ip-10-11-234-221.eu-west-3.compute.internal + eksManagedNodeGroup: + ng: + nodes: + - ip-10-11-232-187.eu-west-3.compute.internal + - ip-10-11-234-216.eu-west-3.compute.internal + ng-foo: + nodes: + - ip-10-11-233-156.eu-west-3.compute.internal + fargate: + dd-karpenter-lenaic-karpenter-test: + nodes: + - fargate-ip-10-11-233-211.eu-west-3.compute.internal + - fargate-ip-10-11-233-55.eu-west-3.compute.internal + managedByDatadog: true + karpenter: + dd-karpenter-pejpk: + nodes: + - ip-10-11-234-171.eu-west-3.compute.internal + - ip-10-11-234-179.eu-west-3.compute.internal + managedByDatadog: true + dd-karpenter-y7msc: + nodes: [] + managedByDatadog: true +autoscaling: + clusterAutoscaler: + present: false + karpenter: + present: true + namespace: dd-karpenter + name: karpenter + version: 1.12.1 + managedByDatadog: true + installerVersion: v0.7.0 + eksAutoMode: + enabled: false +` + +// newFakeClient wraps fake.NewClientset with a reactor that honours +// FieldSelectors on ConfigMap list calls. The default fake client only +// applies the LabelSelector, which would let wrong-named ConfigMaps +// leak through tests that exercise the metadata.name filter. +func newFakeClient(objects ...runtime.Object) *fake.Clientset { + client := fake.NewClientset(objects...) + tracker := client.Tracker() + gvr := corev1.SchemeGroupVersion.WithResource("configmaps") + gvk := corev1.SchemeGroupVersion.WithKind("ConfigMap") + client.PrependReactor("list", "configmaps", func(action ktesting.Action) (bool, runtime.Object, error) { + listAction := action.(ktesting.ListAction) + obj, err := tracker.List(gvr, gvk, listAction.GetNamespace()) + if err != nil { + return true, nil, err + } + fieldSelector := listAction.GetListRestrictions().Fields + if fieldSelector == nil || fieldSelector.Empty() { + return true, obj, nil + } + cmList := obj.(*corev1.ConfigMapList) + filtered := &corev1.ConfigMapList{ListMeta: cmList.ListMeta} + for _, cm := range cmList.Items { + if fieldSelector.Matches(fields.Set{ + "metadata.name": cm.Name, + "metadata.namespace": cm.Namespace, + }) { + filtered.Items = append(filtered.Items, cm) + } + } + return true, filtered, nil + }) + return client +} + +func newClusterInfoConfigMap(namespace, name, payload string, labels map[string]string) *corev1.ConfigMap { + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + Labels: labels, + }, + Data: map[string]string{ + clusterInfoConfigMapDataKey: payload, + }, + } +} + +func TestFetchClusterInfo_Absent(t *testing.T) { + client := newFakeClient() + info, err := FetchClusterInfo(context.Background(), client.CoreV1()) + require.NoError(t, err) + assert.Nil(t, info) +} + +func TestFetchClusterInfo_FullPayload(t *testing.T) { + cm := newClusterInfoConfigMap("dd-karpenter", clusterInfoConfigMapName, fullClusterInfoPayload, map[string]string{ + clusterInfoManagedByLabel: clusterInfoManagedByValue, + }) + client := newFakeClient(cm) + + info, err := FetchClusterInfo(context.Background(), client.CoreV1()) + require.NoError(t, err) + require.NotNil(t, info) + + assert.Equal(t, "v1", info.APIVersion) + assert.Equal(t, "lenaic-karpenter-test", info.ClusterName) + assert.Equal(t, "arn:aws:eks:eu-west-3:013364996899:cluster/lenaic-karpenter-test", info.ClusterARN) + assert.Equal(t, "eu-west-3", info.Region) + assert.False(t, info.GeneratedAt.IsZero()) + assert.True(t, info.Autoscaling.Karpenter.Present) + assert.Equal(t, "1.12.1", info.Autoscaling.Karpenter.Version) + assert.True(t, info.Autoscaling.Karpenter.ManagedByDatadog) + assert.Equal(t, "v0.7.0", info.Autoscaling.Karpenter.InstallerVersion) + assert.False(t, info.Autoscaling.ClusterAutoscaler.Present) + assert.False(t, info.Autoscaling.EKSAutoMode.Enabled) + assert.Len(t, info.NodeManagement, 4) + assert.Len(t, info.NodeManagement["karpenter"], 2) + assert.True(t, info.NodeManagement["fargate"]["dd-karpenter-lenaic-karpenter-test"].ManagedByDatadog) +} + +func TestFetchClusterInfo_WrongLabel(t *testing.T) { + cm := newClusterInfoConfigMap("dd-karpenter", clusterInfoConfigMapName, fullClusterInfoPayload, map[string]string{ + "app.kubernetes.io/managed-by": "someone-else", + }) + client := newFakeClient(cm) + + info, err := FetchClusterInfo(context.Background(), client.CoreV1()) + require.NoError(t, err) + assert.Nil(t, info) +} + +func TestFetchClusterInfo_WrongName(t *testing.T) { + cm := newClusterInfoConfigMap("dd-karpenter", "some-other-name", fullClusterInfoPayload, map[string]string{ + clusterInfoManagedByLabel: clusterInfoManagedByValue, + }) + client := newFakeClient(cm) + + info, err := FetchClusterInfo(context.Background(), client.CoreV1()) + require.NoError(t, err) + assert.Nil(t, info) +} + +func TestFetchClusterInfo_MultipleConfigMaps(t *testing.T) { + labels := map[string]string{clusterInfoManagedByLabel: clusterInfoManagedByValue} + cmA := newClusterInfoConfigMap("a-ns", clusterInfoConfigMapName, fullClusterInfoPayload, labels) + cmB := newClusterInfoConfigMap("b-ns", clusterInfoConfigMapName, fullClusterInfoPayload, labels) + client := newFakeClient(cmB, cmA) + + info, err := FetchClusterInfo(context.Background(), client.CoreV1()) + require.NoError(t, err) + require.NotNil(t, info) + assert.Equal(t, "v1", info.APIVersion) +} + +func TestFetchClusterInfo_UnknownAPIVersion(t *testing.T) { + payload := "apiVersion: v2\nclusterName: foo\n" + cm := newClusterInfoConfigMap("ns", clusterInfoConfigMapName, payload, map[string]string{ + clusterInfoManagedByLabel: clusterInfoManagedByValue, + }) + client := newFakeClient(cm) + + info, err := FetchClusterInfo(context.Background(), client.CoreV1()) + require.NoError(t, err) + assert.Nil(t, info) +} + +func TestFetchClusterInfo_MissingDataKey(t *testing.T) { + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: clusterInfoConfigMapName, + Namespace: "ns", + Labels: map[string]string{clusterInfoManagedByLabel: clusterInfoManagedByValue}, + }, + } + client := newFakeClient(cm) + + info, err := FetchClusterInfo(context.Background(), client.CoreV1()) + require.NoError(t, err) + assert.Nil(t, info) +} + +func TestFetchClusterInfo_MalformedYAML(t *testing.T) { + cm := newClusterInfoConfigMap("ns", clusterInfoConfigMapName, "this: is: not: valid yaml\n", map[string]string{ + clusterInfoManagedByLabel: clusterInfoManagedByValue, + }) + client := newFakeClient(cm) + + info, err := FetchClusterInfo(context.Background(), client.CoreV1()) + require.Error(t, err) + assert.Nil(t, info) +} + +func TestApplyClusterInfo_NilInfoIsNoOp(t *testing.T) { + cluster := &model.Cluster{NodeCount: 3} + nodes := []*model.ClusterNodeInfo{{Name: "n1"}} + ApplyClusterInfo(cluster, nodes, nil, "any-cluster") + assert.Equal(t, int32(3), cluster.NodeCount) + assert.Nil(t, cluster.Autoscaling) + assert.Equal(t, int64(0), cluster.ClusterInfoGeneratedAtUnixNano) + assert.Nil(t, cluster.CloudResourceId) + assert.Empty(t, nodes[0].NodeManager) +} + +func TestApplyClusterInfo_NodeEnrichment(t *testing.T) { + info := mustFetchFromPayload(t, fullClusterInfoPayload) + cluster := &model.Cluster{} + nodes := []*model.ClusterNodeInfo{ + {Name: "ip-10-11-234-171.eu-west-3.compute.internal"}, + {Name: "ip-10-11-232-187.eu-west-3.compute.internal"}, + {Name: "fargate-ip-10-11-233-211.eu-west-3.compute.internal"}, + {Name: "added-after-snapshot"}, + } + + ApplyClusterInfo(cluster, nodes, info, "lenaic-karpenter-test") + + assert.Equal(t, "karpenter", nodes[0].NodeManager) + assert.Equal(t, "dd-karpenter-pejpk", nodes[0].NodeManagerName) + assert.True(t, nodes[0].NodeManagerManagedByDatadog) + + assert.Equal(t, "eksManagedNodeGroup", nodes[1].NodeManager) + assert.Equal(t, "ng", nodes[1].NodeManagerName) + assert.False(t, nodes[1].NodeManagerManagedByDatadog) + + assert.Equal(t, "fargate", nodes[2].NodeManager) + assert.Equal(t, "dd-karpenter-lenaic-karpenter-test", nodes[2].NodeManagerName) + assert.True(t, nodes[2].NodeManagerManagedByDatadog) + + assert.Empty(t, nodes[3].NodeManager) + assert.Empty(t, nodes[3].NodeManagerName) + assert.False(t, nodes[3].NodeManagerManagedByDatadog) +} + +func TestApplyClusterInfo_ClusterFields(t *testing.T) { + info := mustFetchFromPayload(t, fullClusterInfoPayload) + cluster := &model.Cluster{} + + ApplyClusterInfo(cluster, nil, info, "lenaic-karpenter-test") + + require.NotNil(t, cluster.Autoscaling) + require.NotNil(t, cluster.Autoscaling.Karpenter) + assert.True(t, cluster.Autoscaling.Karpenter.Present) + assert.Equal(t, "1.12.1", cluster.Autoscaling.Karpenter.Version) + assert.True(t, cluster.Autoscaling.Karpenter.ManagedByDatadog) + assert.Equal(t, "v0.7.0", cluster.Autoscaling.Karpenter.InstallerVersion) + + require.NotNil(t, cluster.Autoscaling.ClusterAutoscaler) + assert.False(t, cluster.Autoscaling.ClusterAutoscaler.Present) + + require.NotNil(t, cluster.Autoscaling.EksAutoMode) + assert.False(t, cluster.Autoscaling.EksAutoMode.Enabled) + + assert.NotZero(t, cluster.ClusterInfoGeneratedAtUnixNano) + + arnWrapper, ok := cluster.CloudResourceId.(*model.Cluster_Arn) + require.True(t, ok, "CloudResourceId should be a *Cluster_Arn") + assert.Equal(t, "arn:aws:eks:eu-west-3:013364996899:cluster/lenaic-karpenter-test", arnWrapper.Arn) +} + +func TestApplyClusterInfo_NoArnLeavesOneofUnset(t *testing.T) { + info := mustFetchFromPayload(t, "apiVersion: v1\nclusterName: foo\ngeneratedAt: 2026-05-13T22:42:45Z\nnodeManagement: {}\nautoscaling:\n clusterAutoscaler:\n present: false\n karpenter:\n present: false\n eksAutoMode:\n enabled: false\n") + cluster := &model.Cluster{} + ApplyClusterInfo(cluster, nil, info, "foo") + assert.Nil(t, cluster.CloudResourceId) +} + +func TestApplyClusterInfo_Deterministic(t *testing.T) { + info := mustFetchFromPayload(t, fullClusterInfoPayload) + + c1 := &model.Cluster{} + n1 := []*model.ClusterNodeInfo{ + {Name: "ip-10-11-234-171.eu-west-3.compute.internal"}, + {Name: "ip-10-11-232-187.eu-west-3.compute.internal"}, + } + ApplyClusterInfo(c1, n1, info, "lenaic-karpenter-test") + + c2 := &model.Cluster{} + n2 := []*model.ClusterNodeInfo{ + {Name: "ip-10-11-234-171.eu-west-3.compute.internal"}, + {Name: "ip-10-11-232-187.eu-west-3.compute.internal"}, + } + ApplyClusterInfo(c2, n2, info, "lenaic-karpenter-test") + + assert.Equal(t, c1, c2) + assert.Equal(t, n1, n2) +} + +func TestApplyClusterInfo_ClusterNameMismatchStillEnriches(t *testing.T) { + info := mustFetchFromPayload(t, fullClusterInfoPayload) + cluster := &model.Cluster{} + + ApplyClusterInfo(cluster, nil, info, "different-cluster-name") + + assert.NotNil(t, cluster.Autoscaling) + assert.NotZero(t, cluster.ClusterInfoGeneratedAtUnixNano) +} + +func mustFetchFromPayload(t *testing.T, payload string) *ClusterInfo { + t.Helper() + cm := newClusterInfoConfigMap("ns", clusterInfoConfigMapName, payload, map[string]string{ + clusterInfoManagedByLabel: clusterInfoManagedByValue, + }) + client := newFakeClient(cm) + info, err := FetchClusterInfo(context.Background(), client.CoreV1()) + require.NoError(t, err) + require.NotNil(t, info) + return info +} diff --git a/releasenotes-dca/notes/orchestrator-check-cluster-info-0dfc49c966ffb219.yaml b/releasenotes-dca/notes/orchestrator-check-cluster-info-0dfc49c966ffb219.yaml new file mode 100644 index 000000000000..78223666aef3 --- /dev/null +++ b/releasenotes-dca/notes/orchestrator-check-cluster-info-0dfc49c966ffb219.yaml @@ -0,0 +1,13 @@ +--- +enhancements: + - | + The Kubernetes orchestrator check now forwards the contents of the + ``dd-cluster-info`` ConfigMap (when present in the cluster) inside its + synthetic cluster payload. This surfaces the per-node management method + (Fargate, Karpenter, EKS managed node group, ASG, standalone), the + autoscaling solutions detected on the cluster (legacy cluster-autoscaler, + Karpenter, EKS auto-mode), and the EKS cluster ARN to the Datadog + back-end. The ConfigMap is written by + ``kubectl datadog autoscaling cluster install`` from the + `datadog-operator `_ + repository; clusters that have not run that command are unaffected.