diff --git a/pkg/internal/discover/services/criteria.go b/pkg/internal/discover/services/criteria.go index 20f43b505..893e21ca2 100644 --- a/pkg/internal/discover/services/criteria.go +++ b/pkg/internal/discover/services/criteria.go @@ -11,18 +11,26 @@ import ( ) const ( - AttrNamespace = "k8s_namespace" - AttrPodName = "k8s_pod_name" - AttrDeploymentName = "k8s_deployment_name" - AttrReplicaSetName = "k8s_replicaset_name" + AttrNamespace = "k8s_namespace" + AttrPodName = "k8s_pod_name" + AttrDeploymentName = "k8s_deployment_name" + AttrReplicaSetName = "k8s_replicaset_name" + AttrDaemonSetName = "k8s_daemonset_name" + AttrStatefulSetName = "k8s_statefulset_name" + // AttrOwnerName would be a generic search criteria that would + // match against deployment, replicaset, daemonset and statefulset names + AttrOwnerName = "k8s_owner_name" ) // any attribute name not in this set will cause an error during the YAML unmarshalling var allowedAttributeNames = map[string]struct{}{ - AttrNamespace: {}, - AttrPodName: {}, - AttrDeploymentName: {}, - AttrReplicaSetName: {}, + AttrNamespace: {}, + AttrPodName: {}, + AttrDeploymentName: {}, + AttrReplicaSetName: {}, + AttrDaemonSetName: {}, + AttrStatefulSetName: {}, + AttrOwnerName: {}, } // ProcessInfo stores some relevant information about a running process diff --git a/pkg/internal/discover/watcher_kube.go b/pkg/internal/discover/watcher_kube.go index ca5c807ce..746e9dfa6 100644 --- a/pkg/internal/discover/watcher_kube.go +++ b/pkg/internal/discover/watcher_kube.go @@ -232,8 +232,10 @@ func (wk *WatcherKubeEnricher) onNewReplicaSet(rsInfo *kube.ReplicaSetInfo) []Ev for _, pod := range podInfos { for _, containerID := range pod.ContainerIDs { if procInfo, ok := wk.processByContainer[containerID]; ok { - pod.ReplicaSetName = rsInfo.Name - pod.DeploymentName = rsInfo.DeploymentName + pod.Owner = &kube.Owner{Type: kube.OwnerReplicaSet, Name: rsInfo.Name} + if rsInfo.DeploymentName != "" { + pod.Owner.Owner = &kube.Owner{Type: kube.OwnerDeployment, Name: rsInfo.DeploymentName} + } allProcesses = append(allProcesses, Event[processAttrs]{ Type: EventCreated, Obj: withMetadata(procInfo, pod), @@ -280,14 +282,14 @@ func (wk *WatcherKubeEnricher) getReplicaSetPods(namespace, name string) []*kube } func (wk *WatcherKubeEnricher) updateNewPodsByOwnerIndex(pod *kube.PodInfo) { - if pod.ReplicaSetName != "" { - wk.podsByOwner.Put(nsName{namespace: pod.Namespace, name: pod.ReplicaSetName}, pod.Name, pod) + if pod.Owner != nil { + wk.podsByOwner.Put(nsName{namespace: pod.Namespace, name: pod.Owner.Name}, pod.Name, pod) } } func (wk *WatcherKubeEnricher) updateDeletedPodsByOwnerIndex(pod *kube.PodInfo) { - if pod.ReplicaSetName != "" { - wk.podsByOwner.Delete(nsName{namespace: pod.Namespace, name: pod.ReplicaSetName}, pod.Name) + if pod.Owner != nil { + wk.podsByOwner.Delete(nsName{namespace: pod.Namespace, name: pod.Owner.Name}, pod.Name) } } @@ -298,11 +300,20 @@ func withMetadata(pp processAttrs, info *kube.PodInfo) processAttrs { services.AttrNamespace: info.Namespace, services.AttrPodName: info.Name, } - if info.DeploymentName != "" { - ret.metadata[services.AttrDeploymentName] = info.DeploymentName - } - if info.ReplicaSetName != "" { - ret.metadata[services.AttrReplicaSetName] = info.ReplicaSetName + owner := info.Owner + for owner != nil { + ret.metadata[services.AttrOwnerName] = owner.Name + switch owner.Type { + case kube.OwnerDaemonSet: + ret.metadata[services.AttrDaemonSetName] = owner.Name + case kube.OwnerReplicaSet: + ret.metadata[services.AttrReplicaSetName] = owner.Name + case kube.OwnerDeployment: + ret.metadata[services.AttrDeploymentName] = owner.Name + case kube.OwnerStatefulSet: + ret.metadata[services.AttrStatefulSetName] = owner.Name + } + owner = owner.Owner } return ret } diff --git a/pkg/internal/export/prom/prom.go b/pkg/internal/export/prom/prom.go index b3d40126b..1d641cada 100644 --- a/pkg/internal/export/prom/prom.go +++ b/pkg/internal/export/prom/prom.go @@ -9,9 +9,9 @@ import ( "github.com/grafana/beyla/pkg/internal/connector" "github.com/grafana/beyla/pkg/internal/export/otel" + "github.com/grafana/beyla/pkg/internal/kube" "github.com/grafana/beyla/pkg/internal/pipe/global" "github.com/grafana/beyla/pkg/internal/request" - "github.com/grafana/beyla/pkg/internal/transform" ) // using labels and names that are equivalent names to the OTEL attributes @@ -45,12 +45,15 @@ const ( rpcSystemGRPC = "rpc_system" DBOperationKey = "db_operation" - k8sNamespaceName = "k8s_namespace_name" - k8sPodName = "k8s_pod_name" - k8sDeploymentName = "k8s_deployment_name" - k8sNodeName = "k8s_node_name" - k8sPodUID = "k8s_pod_uid" - k8sPodStartTime = "k8s_pod_start_time" + k8sNamespaceName = "k8s_namespace_name" + k8sPodName = "k8s_pod_name" + k8sDeploymentName = "k8s_deployment_name" + k8sStatefulSetName = "k8s_statefulset_name" + k8sReplicaSetName = "k8s_replicaset_name" + k8sDaemonSetName = "k8s_daemonset_name" + k8sNodeName = "k8s_node_name" + k8sPodUID = "k8s_pod_uid" + k8sPodStartTime = "k8s_pod_start_time" ) // TODO: TLS @@ -305,19 +308,23 @@ func (r *metricsReporter) labelValuesHTTP(span *request.Span) []string { } func appendK8sLabelNames(names []string) []string { - names = append(names, k8sNamespaceName, k8sDeploymentName, k8sPodName, k8sNodeName, k8sPodUID, k8sPodStartTime) + names = append(names, k8sNamespaceName, k8sPodName, k8sNodeName, k8sPodUID, k8sPodStartTime, + k8sDeploymentName, k8sReplicaSetName, k8sStatefulSetName, k8sDaemonSetName) return names } func appendK8sLabelValues(values []string, span *request.Span) []string { // must follow the order in appendK8sLabelNames values = append(values, - span.ServiceID.Metadata[transform.NamespaceName], - span.ServiceID.Metadata[transform.DeploymentName], - span.ServiceID.Metadata[transform.PodName], - span.ServiceID.Metadata[transform.NodeName], - span.ServiceID.Metadata[transform.PodUID], - span.ServiceID.Metadata[transform.PodStartTime], + span.ServiceID.Metadata[kube.NamespaceName], + span.ServiceID.Metadata[kube.PodName], + span.ServiceID.Metadata[kube.NodeName], + span.ServiceID.Metadata[kube.PodUID], + span.ServiceID.Metadata[kube.PodStartTime], + span.ServiceID.Metadata[kube.DeploymentName], + span.ServiceID.Metadata[kube.ReplicaSetName], + span.ServiceID.Metadata[kube.StatefulSetName], + span.ServiceID.Metadata[kube.DaemonSetName], ) return values } diff --git a/pkg/internal/kube/informer.go b/pkg/internal/kube/informer.go index e284f85bb..617415fff 100644 --- a/pkg/internal/kube/informer.go +++ b/pkg/internal/kube/informer.go @@ -54,12 +54,10 @@ type Metadata struct { type PodInfo struct { // Informers need that internal object is an ObjectMeta instance metav1.ObjectMeta - NodeName string - ReplicaSetName string - // Pod Info includes the ReplicaSet as owner reference, and ReplicaSet info - // has Deployment as owner reference. We initially do a two-steps lookup to - // get the Pod's Deployment, but then cache the Deployment value here - DeploymentName string + NodeName string + + Owner *Owner + // StartTimeStr caches value of ObjectMeta.StartTimestamp.String() StartTimeStr string ContainerIDs []string @@ -81,6 +79,11 @@ var podIndexer = cache.Indexers{ }, } +// usually all the data required by the discovery and enrichement is inside +// te v1.Pod object. However, when the Pod object has a ReplicaSet as owner, +// if the ReplicaSet is owned by a Deployment, the reported Pod Owner should +// be the Deployment, as the Replicaset is just an intermediate entity +// used by the Deployment that it's actually defined by the user var replicaSetIndexer = cache.Indexers{ IndexReplicaSetNames: func(obj interface{}) ([]string, error) { rs := obj.(*ReplicaSetInfo) @@ -131,18 +134,11 @@ func (k *Metadata) initPodInformer(informerFactory informers.SharedInformerFacto rmContainerIDSchema(pod.Status.EphemeralContainerStatuses[i].ContainerID)) } - var replicaSet string - for i := range pod.OwnerReferences { - or := &pod.OwnerReferences[i] - if or.APIVersion == "apps/v1" && or.Kind == "ReplicaSet" { - replicaSet = or.Name - break - } - } + owner := OwnerFromPodInfo(pod) startTime := pod.GetCreationTimestamp().String() if log.Enabled(context.TODO(), slog.LevelDebug) { log.Debug("inserting pod", "name", pod.Name, "namespace", pod.Namespace, - "uid", pod.UID, "replicaSet", replicaSet, + "uid", pod.UID, "owner", owner, "node", pod.Spec.NodeName, "startTime", startTime, "containerIDs", containerIDs) } @@ -152,10 +148,10 @@ func (k *Metadata) initPodInformer(informerFactory informers.SharedInformerFacto Namespace: pod.Namespace, UID: pod.UID, }, - ReplicaSetName: replicaSet, - NodeName: pod.Spec.NodeName, - StartTimeStr: startTime, - ContainerIDs: containerIDs, + Owner: owner, + NodeName: pod.Spec.NodeName, + StartTimeStr: startTime, + ContainerIDs: containerIDs, }, nil }); err != nil { return fmt.Errorf("can't set pods transform: %w", err) @@ -309,12 +305,14 @@ func (k *Metadata) initInformers(client kubernetes.Interface, timeout time.Durat } } -// FetchPodOwnerInfo updates the passed pod with the owner Desployment info, if required and -// if it exists. +// FetchPodOwnerInfo updates the pod owner with the Deployment information, if it exists. +// Pod Info might include a ReplicaSet as owner, and ReplicaSet info +// usually has a Deployment as owner reference, which is the one that we'd really like +// to report as owner. func (k *Metadata) FetchPodOwnerInfo(pod *PodInfo) { - if pod.DeploymentName == "" && pod.ReplicaSetName != "" { - if rsi, ok := k.GetReplicaSetInfo(pod.Namespace, pod.ReplicaSetName); ok { - pod.DeploymentName = rsi.DeploymentName + if pod.Owner != nil && pod.Owner.Type == OwnerReplicaSet { + if rsi, ok := k.GetReplicaSetInfo(pod.Namespace, pod.Owner.Name); ok { + pod.Owner.Owner = &Owner{Type: OwnerDeployment, Name: rsi.DeploymentName} } } } diff --git a/pkg/internal/kube/owner.go b/pkg/internal/kube/owner.go new file mode 100644 index 000000000..f912ffdca --- /dev/null +++ b/pkg/internal/kube/owner.go @@ -0,0 +1,89 @@ +package kube + +import ( + "strings" + + v1 "k8s.io/api/core/v1" +) + +const ( + NamespaceName = "k8s.namespace.name" + PodName = "k8s.pod.name" + DeploymentName = "k8s.deployment.name" + ReplicaSetName = "k8s.replicaset.name" + DaemonSetName = "k8s.daemonset.name" + StatefulSetName = "k8s.statefulset.name" + NodeName = "k8s.node.name" + PodUID = "k8s.pod.uid" + PodStartTime = "k8s.pod.start_time" +) + +type OwnerType int + +const ( + OwnerUnknown = OwnerType(iota) + OwnerReplicaSet + OwnerDeployment + OwnerStatefulSet + OwnerDaemonSet +) + +func (o OwnerType) LabelName() string { + switch o { + case OwnerReplicaSet: + return ReplicaSetName + case OwnerDeployment: + return DeploymentName + case OwnerStatefulSet: + return StatefulSetName + case OwnerDaemonSet: + return DaemonSetName + default: + return "k8s.unknown.owner" + } +} + +type Owner struct { + Type OwnerType + Name string + // Owner of the owner. For example, a ReplicaSet might be owned by a Deployment + Owner *Owner +} + +// OwnerFromPodInfo returns the pod Owner reference. It might be +// null if the Pod does not have any owner +func OwnerFromPodInfo(pod *v1.Pod) *Owner { + for i := range pod.OwnerReferences { + or := &pod.OwnerReferences[i] + if or.APIVersion != "apps/v1" { + continue + } + switch or.Kind { + case "ReplicaSet": + return &Owner{Type: OwnerReplicaSet, Name: or.Name} + case "Deployment": + return &Owner{Type: OwnerDeployment, Name: or.Name} + case "StatefulSet": + return &Owner{Type: OwnerStatefulSet, Name: or.Name} + case "DaemonSet": + return &Owner{Type: OwnerDaemonSet, Name: or.Name} + } + } + return nil +} + +func (o *Owner) String() string { + sb := strings.Builder{} + o.string(&sb) + return sb.String() +} + +func (o *Owner) string(sb *strings.Builder) { + if o.Owner != nil { + o.Owner.string(sb) + sb.WriteString("->") + } + sb.WriteString(o.Type.LabelName()) + sb.WriteByte(':') + sb.WriteString(o.Name) +} diff --git a/pkg/internal/kube/owner_test.go b/pkg/internal/kube/owner_test.go new file mode 100644 index 000000000..4fe5156f5 --- /dev/null +++ b/pkg/internal/kube/owner_test.go @@ -0,0 +1,14 @@ +package kube + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestOwnerString(t *testing.T) { + owner := Owner{Type: OwnerReplicaSet, Name: "rs"} + assert.Equal(t, "k8s.replicaset.name:rs", owner.String()) + owner.Owner = &Owner{Type: OwnerDeployment, Name: "dep"} + assert.Equal(t, "k8s.deployment.name:dep->k8s.replicaset.name:rs", owner.String()) +} diff --git a/pkg/internal/transform/k8s.go b/pkg/internal/transform/k8s.go index 215be6d48..f657ce855 100644 --- a/pkg/internal/transform/k8s.go +++ b/pkg/internal/transform/k8s.go @@ -23,12 +23,6 @@ const ( EnabledDefault = EnabledFalse // TODO: let the user decide which attributes to add, as in https://opentelemetry.io/docs/kubernetes/collector/components/#kubernetes-attributes-processor - NamespaceName = "k8s.namespace.name" - PodName = "k8s.pod.name" - DeploymentName = "k8s.deployment.name" - NodeName = "k8s.node.name" - PodUID = "k8s.pod.uid" - PodStartTime = "k8s.pod.start_time" ) func klog() *slog.Logger { @@ -107,10 +101,13 @@ func appendMetadata(span *request.Span, info *kube.PodInfo) { // service name and namespace, we will automatically set it from // the kubernetes metadata if span.ServiceID.AutoName { - if info.DeploymentName != "" { - span.ServiceID.Name = info.DeploymentName - } else if info.ReplicaSetName != "" { - span.ServiceID.Name = info.ReplicaSetName + if info.Owner != nil { + // we have two levels of ownership at most + if info.Owner.Owner != nil { + span.ServiceID.Name = info.Owner.Owner.Name + } else { + span.ServiceID.Name = info.Owner.Name + } } else { span.ServiceID.Name = info.Name } @@ -123,13 +120,15 @@ func appendMetadata(span *request.Span, info *kube.PodInfo) { // if, in the future, other pipeline steps modify the service metadata, we should // replace the map literal by individual entry insertions span.ServiceID.Metadata = map[string]string{ - NamespaceName: info.Namespace, - PodName: info.Name, - NodeName: info.NodeName, - PodUID: string(info.UID), - PodStartTime: info.StartTimeStr, + kube.NamespaceName: info.Namespace, + kube.PodName: info.Name, + kube.NodeName: info.NodeName, + kube.PodUID: string(info.UID), + kube.PodStartTime: info.StartTimeStr, } - if info.DeploymentName != "" { - span.ServiceID.Metadata[DeploymentName] = info.DeploymentName + owner := info.Owner + for owner != nil { + span.ServiceID.Metadata[owner.Type.LabelName()] = owner.Name + owner = owner.Owner } } diff --git a/pkg/internal/transform/k8s_test.go b/pkg/internal/transform/k8s_test.go index 713ede0d0..a638dbc06 100644 --- a/pkg/internal/transform/k8s_test.go +++ b/pkg/internal/transform/k8s_test.go @@ -23,18 +23,17 @@ func TestDecoration(t *testing.T) { ObjectMeta: v1.ObjectMeta{ Name: "pod-12", Namespace: "the-ns", UID: "uid-12", }, - NodeName: "the-node", - StartTimeStr: "2020-01-02 12:12:56", - DeploymentName: "deployment-12", - ReplicaSetName: "rs-12", + NodeName: "the-node", + StartTimeStr: "2020-01-02 12:12:56", + Owner: &kube.Owner{Type: kube.OwnerDeployment, Name: "deployment-12"}, }, 34: &kube.PodInfo{ ObjectMeta: v1.ObjectMeta{ Name: "pod-34", Namespace: "the-ns", UID: "uid-34", }, - NodeName: "the-node", - StartTimeStr: "2020-01-02 12:34:56", - ReplicaSetName: "rs-34", + NodeName: "the-node", + StartTimeStr: "2020-01-02 12:34:56", + Owner: &kube.Owner{Type: kube.OwnerReplicaSet, Name: "rs-34"}, }, 56: &kube.PodInfo{ ObjectMeta: v1.ObjectMeta{ @@ -74,11 +73,12 @@ func TestDecoration(t *testing.T) { assert.Equal(t, "the-ns", deco[0].ServiceID.Namespace) assert.Equal(t, "rs-34", deco[0].ServiceID.Name) assert.Equal(t, map[string]string{ - "k8s.node.name": "the-node", - "k8s.namespace.name": "the-ns", - "k8s.pod.name": "pod-34", - "k8s.pod.uid": "uid-34", - "k8s.pod.start_time": "2020-01-02 12:34:56", + "k8s.node.name": "the-node", + "k8s.namespace.name": "the-ns", + "k8s.replicaset.name": "rs-34", + "k8s.pod.name": "pod-34", + "k8s.pod.uid": "uid-34", + "k8s.pod.start_time": "2020-01-02 12:34:56", }, deco[0].ServiceID.Metadata) }) t.Run("pod info with only pod name should set pod name as name", func(t *testing.T) { diff --git a/test/integration/k8s/common/k8s_metrics_testfuncs.go b/test/integration/k8s/common/k8s_metrics_testfuncs.go index 45e7ce4aa..17c5e48a8 100644 --- a/test/integration/k8s/common/k8s_metrics_testfuncs.go +++ b/test/integration/k8s/common/k8s_metrics_testfuncs.go @@ -105,6 +105,7 @@ func FeatureHTTPMetricsDecoration() features.Feature { "k8s_pod_uid": UUIDRegex, "k8s_pod_start_time": TimeRegex, "k8s_deployment_name": "^testserver$", + "k8s_replicaset_name": "^testserver-", }), ).Feature() } @@ -134,6 +135,7 @@ func FeatureGRPCMetricsDecoration() features.Feature { "k8s_pod_uid": UUIDRegex, "k8s_pod_start_time": TimeRegex, "k8s_deployment_name": "^testserver$", + "k8s_replicaset_name": "^testserver-", }), ).Feature() } @@ -156,10 +158,10 @@ func testMetricsDecoration( for _, r := range results { for ek, ev := range expectedLabels { - assert.Regexpf(t, ev, r.Metric[ek], "expected %q:%q entry in map %v", ek, ev, r.Metric) + assert.Regexpf(t, ev, r.Metric[ek], "%s: expected %q:%q entry in map %v", metric, ek, ev, r.Metric) } for _, ek := range expectedMissingLabels { - assert.NotContainsf(t, r.Metric, ek, "not expected %q entry in map %v", ek, r.Metric) + assert.NotContainsf(t, r.Metric, ek, "%s: not expected %q entry in map %v", metric, ek, r.Metric) } } }) diff --git a/test/integration/k8s/manifests/05-uninstrumented-daemonset.yml b/test/integration/k8s/manifests/05-uninstrumented-daemonset.yml new file mode 100644 index 000000000..6bf169d9e --- /dev/null +++ b/test/integration/k8s/manifests/05-uninstrumented-daemonset.yml @@ -0,0 +1,39 @@ +apiVersion: v1 +kind: Service +metadata: + name: dsservice +spec: + selector: + app: dsservice + ports: + - port: 8081 + name: http1 + targetPort: http1 +--- +apiVersion: apps/v1 +kind: DaemonSet +metadata: + name: dsservice + labels: + app: dsservice +spec: + selector: + matchLabels: + app: dsservice + template: + metadata: + name: dsservice + labels: + app: dsservice + spec: + containers: + - name: dsservice + image: testserver:dev + imagePullPolicy: Never # loaded into Kind from localhost + ports: + - containerPort: 8081 + hostPort: 8081 + name: http1 + env: + - name: LOG_LEVEL + value: "DEBUG" diff --git a/test/integration/k8s/manifests/05-uninstrumented-statefulset.yml b/test/integration/k8s/manifests/05-uninstrumented-statefulset.yml new file mode 100644 index 000000000..fc8c06fd9 --- /dev/null +++ b/test/integration/k8s/manifests/05-uninstrumented-statefulset.yml @@ -0,0 +1,41 @@ +apiVersion: v1 +kind: Service +metadata: + name: statefulservice +spec: + selector: + app: statefulservice + ports: + - port: 8080 + name: http + targetPort: http +--- +apiVersion: apps/v1 +kind: StatefulSet +metadata: + name: statefulservice + labels: + app: statefulservice +spec: + replicas: 1 + selector: + matchLabels: + app: statefulservice + template: + metadata: + name: statefulservice + labels: + app: statefulservice + spec: + containers: + - name: statefulservice + image: testserver:dev + imagePullPolicy: Never # loaded into Kind from localhost + ports: + - containerPort: 8080 + hostPort: 8080 + name: http + env: + - name: LOG_LEVEL + value: "DEBUG" + serviceName: statefulservice diff --git a/test/integration/k8s/manifests/06-beyla-daemonset.yml b/test/integration/k8s/manifests/06-beyla-daemonset.yml index adfff1e6a..b9890bd57 100644 --- a/test/integration/k8s/manifests/06-beyla-daemonset.yml +++ b/test/integration/k8s/manifests/06-beyla-daemonset.yml @@ -15,6 +15,9 @@ data: # deployment (ignoring the "testserver" in the other pod) # name and namespace will be automatically set from the K8s metadata - k8s_deployment_name: otherinstance + # used in the k8s_owners_*_test.go + - k8s_statefulset_name: statefulservice + - k8s_daemonset_name: dsservice routes: patterns: - /pingpong diff --git a/test/integration/k8s/owners/k8s_daemonset_metadata_test.go b/test/integration/k8s/owners/k8s_daemonset_metadata_test.go new file mode 100644 index 000000000..8772d0756 --- /dev/null +++ b/test/integration/k8s/owners/k8s_daemonset_metadata_test.go @@ -0,0 +1,87 @@ +//go:build integration + +package owners + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/mariomac/guara/pkg/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" + + "github.com/grafana/beyla/test/integration/components/jaeger" + k8s "github.com/grafana/beyla/test/integration/k8s/common" +) + +// For the DaemonSet scenario, we only check that Beyla is able to instrument any +// process in the system. We just check that traces are properly generated without +// entering in too many details +func TestDaemonSetMetadata(t *testing.T) { + feat := features.New("Beyla is able to decorate the metadata of a daemonset"). + Assess("it sends decorated traces for the daemonset", + func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + test.Eventually(t, testTimeout, func(t require.TestingT) { + // Invoking both service instances, but we will expect that only one + // is instrumented, according to the discovery mechanisms + resp, err := http.Get("http://localhost:38081/pingpong") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + resp, err = http.Get(jaegerQueryURL + "?service=dsservice") + require.NoError(t, err) + if resp == nil { + return + } + require.Equal(t, http.StatusOK, resp.StatusCode) + var tq jaeger.TracesQuery + require.NoError(t, json.NewDecoder(resp.Body).Decode(&tq)) + traces := tq.FindBySpan(jaeger.Tag{Key: "url.path", Type: "string", Value: "/pingpong"}) + require.NotEmpty(t, traces) + trace := traces[0] + require.NotEmpty(t, trace.Spans) + + // Check that the service.namespace is set from the K8s namespace + assert.Len(t, trace.Processes, 1) + for _, proc := range trace.Processes { + sd := jaeger.DiffAsRegexp([]jaeger.Tag{ + {Key: "service.namespace", Type: "string", Value: "^default$"}, + }, proc.Tags) + require.Empty(t, sd) + } + + // Check the information of the parent span + res := trace.FindByOperationName("GET /pingpong") + require.Len(t, res, 1) + parent := res[0] + sd := jaeger.DiffAsRegexp([]jaeger.Tag{ + {Key: "k8s.pod.name", Type: "string", Value: "^dsservice-.*"}, + {Key: "k8s.node.name", Type: "string", Value: ".+-control-plane$"}, + {Key: "k8s.pod.uid", Type: "string", Value: k8s.UUIDRegex}, + {Key: "k8s.pod.start_time", Type: "string", Value: k8s.TimeRegex}, + {Key: "k8s.daemonset.name", Type: "string", Value: "^dsservice$"}, + {Key: "k8s.namespace.name", Type: "string", Value: "^default$"}, + }, trace.Processes[parent.ProcessID].Tags) + require.Empty(t, sd) + + // check that no other labels are added + sd = jaeger.DiffAsRegexp([]jaeger.Tag{ + {Key: "k8s.deployment.name", Type: "string"}, + {Key: "k8s.statefulset.name", Type: "string"}, + }, trace.Processes[parent.ProcessID].Tags) + require.Equal(t, jaeger.DiffResult{ + {ErrType: jaeger.ErrTypeMissing, Expected: jaeger.Tag{Key: "k8s.deployment.name", Type: "string"}}, + {ErrType: jaeger.ErrTypeMissing, Expected: jaeger.Tag{Key: "k8s.statefulset.name", Type: "string"}}, + }, sd) + + }, test.Interval(100*time.Millisecond)) + return ctx + }, + ).Feature() + cluster.TestEnv().Test(t, feat) +} diff --git a/test/integration/k8s/owners/k8s_owners_main_test.go b/test/integration/k8s/owners/k8s_owners_main_test.go new file mode 100644 index 000000000..18cbcd50c --- /dev/null +++ b/test/integration/k8s/owners/k8s_owners_main_test.go @@ -0,0 +1,52 @@ +//go:build integration + +// package owners tests the selection and detection of pod ownership metadata, others than deployment: +// StatefulSet and DaemonSet +package owners + +import ( + "log/slog" + "os" + "testing" + "time" + + "github.com/grafana/beyla/test/integration/components/docker" + "github.com/grafana/beyla/test/integration/components/kube" + k8s "github.com/grafana/beyla/test/integration/k8s/common" + "github.com/grafana/beyla/test/tools" +) + +const ( + testTimeout = 3 * time.Minute + + jaegerQueryURL = "http://localhost:36686/api/traces" +) + +var cluster *kube.Kind + +func TestMain(m *testing.M) { + if err := docker.Build(os.Stdout, tools.ProjectDir(), + docker.ImageBuild{Tag: "testserver:dev", Dockerfile: k8s.DockerfileTestServer}, + docker.ImageBuild{Tag: "beyla:dev", Dockerfile: k8s.DockerfileBeyla}, + ); err != nil { + slog.Error("can't build docker images", err) + os.Exit(-1) + } + + cluster = kube.NewKind("test-kind-cluster-daemonset", + kube.ExportLogs(k8s.PathKindLogs), + kube.KindConfig(k8s.PathManifests+"/00-kind.yml"), + kube.LocalImage("testserver:dev"), + kube.LocalImage("beyla:dev"), + kube.LocalImage("grpcpinger:dev"), + kube.Deploy(k8s.PathManifests+"/01-volumes.yml"), + kube.Deploy(k8s.PathManifests+"/01-serviceaccount.yml"), + kube.Deploy(k8s.PathManifests+"/03-otelcol.yml"), + kube.Deploy(k8s.PathManifests+"/04-jaeger.yml"), + kube.Deploy(k8s.PathManifests+"/05-uninstrumented-statefulset.yml"), + kube.Deploy(k8s.PathManifests+"/05-uninstrumented-daemonset.yml"), + kube.Deploy(k8s.PathManifests+"/06-beyla-daemonset.yml"), + ) + + cluster.Run(m) +} diff --git a/test/integration/k8s/owners/k8s_statefulset_metadata_test.go b/test/integration/k8s/owners/k8s_statefulset_metadata_test.go new file mode 100644 index 000000000..02038f34c --- /dev/null +++ b/test/integration/k8s/owners/k8s_statefulset_metadata_test.go @@ -0,0 +1,86 @@ +//go:build integration + +package owners + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/mariomac/guara/pkg/test" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "sigs.k8s.io/e2e-framework/pkg/envconf" + "sigs.k8s.io/e2e-framework/pkg/features" + + "github.com/grafana/beyla/test/integration/components/jaeger" + k8s "github.com/grafana/beyla/test/integration/k8s/common" +) + +// For the DaemonSet scenario, we only check that Beyla is able to instrument any +// process in the system. We just check that traces are properly generated without +// entering in too many details +func TestStatefulSetMetadata(t *testing.T) { + feat := features.New("Beyla is able to decorate the metadata of a statefulset"). + Assess("it sends decorated traces for the statefulset", + func(ctx context.Context, t *testing.T, config *envconf.Config) context.Context { + test.Eventually(t, testTimeout, func(t require.TestingT) { + // Invoking both service instances, but we will expect that only one + // is instrumented, according to the discovery mechanisms + resp, err := http.Get("http://localhost:38080/pingpong") + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) + + resp, err = http.Get(jaegerQueryURL + "?service=statefulservice") + require.NoError(t, err) + if resp == nil { + return + } + require.Equal(t, http.StatusOK, resp.StatusCode) + var tq jaeger.TracesQuery + require.NoError(t, json.NewDecoder(resp.Body).Decode(&tq)) + traces := tq.FindBySpan(jaeger.Tag{Key: "url.path", Type: "string", Value: "/pingpong"}) + require.NotEmpty(t, traces) + trace := traces[0] + require.NotEmpty(t, trace.Spans) + + // Check that the service.namespace is set from the K8s namespace + assert.Len(t, trace.Processes, 1) + for _, proc := range trace.Processes { + sd := jaeger.DiffAsRegexp([]jaeger.Tag{ + {Key: "service.namespace", Type: "string", Value: "^default$"}, + }, proc.Tags) + require.Empty(t, sd) + } + + // Check the information of the parent span + res := trace.FindByOperationName("GET /pingpong") + require.Len(t, res, 1) + parent := res[0] + sd := jaeger.DiffAsRegexp([]jaeger.Tag{ + {Key: "k8s.pod.name", Type: "string", Value: "^statefulservice-.*"}, + {Key: "k8s.node.name", Type: "string", Value: ".+-control-plane$"}, + {Key: "k8s.pod.uid", Type: "string", Value: k8s.UUIDRegex}, + {Key: "k8s.pod.start_time", Type: "string", Value: k8s.TimeRegex}, + {Key: "k8s.statefulset.name", Type: "string", Value: "^statefulservice$"}, + {Key: "k8s.namespace.name", Type: "string", Value: "^default$"}, + }, trace.Processes[parent.ProcessID].Tags) + require.Empty(t, sd) + + // check that no other labels are added + sd = jaeger.DiffAsRegexp([]jaeger.Tag{ + {Key: "k8s.deployment.name", Type: "string"}, + {Key: "k8s.daemonset.name", Type: "string"}, + }, trace.Processes[parent.ProcessID].Tags) + require.Equal(t, jaeger.DiffResult{ + {ErrType: jaeger.ErrTypeMissing, Expected: jaeger.Tag{Key: "k8s.deployment.name", Type: "string"}}, + {ErrType: jaeger.ErrTypeMissing, Expected: jaeger.Tag{Key: "k8s.daemonset.name", Type: "string"}}, + }, sd) + }, test.Interval(100*time.Millisecond)) + return ctx + }, + ).Feature() + cluster.TestEnv().Test(t, feat) +}