Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# A DaemonSet with hostPID=true that serves HTTP traffic.
# Used to test that spans from this DaemonSet are correctly attributed to it
# (and not to other pods sharing the host PID namespace).
apiVersion: v1
kind: Service
metadata:
name: hostpid-httpserver
spec:
selector:
app: hostpid-httpserver
ports:
- port: 8082
name: http
targetPort: http
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: hostpid-httpserver
labels:
app: hostpid-httpserver
spec:
selector:
matchLabels:
app: hostpid-httpserver
template:
metadata:
name: hostpid-httpserver
labels:
app: hostpid-httpserver
spec:
hostPID: true
containers:
- name: hostpid-httpserver
image: testserver:dev
imagePullPolicy: Never # loaded into Kind from localhost
ports:
- containerPort: 8082
hostPort: 8082
name: http
env:
- name: LOG_LEVEL
value: "DEBUG"
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: obi-config
data:
obi-config.yml: |
attributes:
kubernetes:
enable: true
log_level: debug
discovery:
instrument:
- k8s_deployment_name: testserver
- k8s_daemonset_name: hostpid-httpserver
routes:
patterns:
- /pingpong
unmatched: path
otel_traces_export:
endpoint: http://jaeger:4318
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: obi
spec:
selector:
matchLabels:
instrumentation: obi
template:
metadata:
labels:
instrumentation: obi
teardown: delete
spec:
hostPID: true
serviceAccountName: obi
volumes:
- name: obi-config
configMap:
name: obi-config
- name: testoutput
persistentVolumeClaim:
claimName: testoutput
containers:
- name: obi
image: obi:dev
imagePullPolicy: Never
args: ["--config=/config/obi-config.yml"]
securityContext:
privileged: true
runAsUser: 0
volumeMounts:
- mountPath: /config
name: obi-config
- mountPath: /testoutput
name: testoutput
env:
- name: GOCOVERDIR
value: "/testoutput"
- name: OTEL_EBPF_DISCOVERY_POLL_INTERVAL
value: "500ms"
- name: OTEL_EBPF_METRICS_INTERVAL
value: "10ms"
- name: OTEL_EBPF_BPF_BATCH_TIMEOUT
value: "10ms"
- name: OTEL_EBPF_OTLP_TRACES_BATCH_TIMEOUT
value: "0ms"
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

// Package sharedpidns tests that spans from pods sharing the host PID namespace
// (hostPID=true) are correctly attributed to their respective pods, rather than
// being misattributed to an arbitrary pod that happens to share the same
// PID namespace inode.
package sharedpidns

import (
"flag"
"fmt"
"log/slog"
"os"
"testing"
"time"

"go.opentelemetry.io/obi/internal/test/integration/components/docker"
"go.opentelemetry.io/obi/internal/test/integration/components/kube"
k8s "go.opentelemetry.io/obi/internal/test/integration/k8s/common"
"go.opentelemetry.io/obi/internal/test/integration/k8s/common/testpath"
"go.opentelemetry.io/obi/internal/test/tools"
)

const (
testTimeout = 3 * time.Minute
jaegerQueryURL = "http://localhost:36686/api/traces"
)

var cluster *kube.Kind

func TestMain(m *testing.M) {
flag.Parse()
if testing.Short() {
fmt.Println("skipping integration tests in short mode")
return
}

if err := docker.Build(os.Stdout, tools.ProjectDir(),
docker.ImageBuild{Tag: "testserver:dev", Dockerfile: k8s.DockerfileTestServer},
docker.ImageBuild{Tag: "obi:dev", Dockerfile: k8s.DockerfileOBI},
); err != nil {
slog.Error("can't build docker images", "error", err)
os.Exit(-1)
}

cluster = kube.NewKind("test-kind-cluster-sharedpidns",
kube.KindConfig(testpath.Manifests+"/00-kind.yml"),
kube.LocalImage("testserver:dev"),
kube.LocalImage("obi:dev"),
kube.Deploy(testpath.Manifests+"/01-volumes.yml"),
kube.Deploy(testpath.Manifests+"/01-serviceaccount.yml"),
kube.Deploy(testpath.Manifests+"/03-otelcol.yml"),
kube.Deploy(testpath.Manifests+"/04-jaeger.yml"),
// Deploy a normal Deployment (no hostPID)
kube.Deploy(testpath.Manifests+"/05-uninstrumented-service.yml"),
// Deploy a DaemonSet with hostPID=true serving HTTP on port 8082
kube.Deploy(testpath.Manifests+"/05-hostpid-daemonset.yml"),
// Deploy OBI configured to instrument both
kube.Deploy(testpath.Manifests+"/06-obi-daemonset-sharedpidns.yml"),
)

cluster.Run(m)
}
150 changes: 150 additions & 0 deletions internal/test/integration/k8s/sharedpidns/k8s_sharedpidns_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0

package sharedpidns

import (
"context"
"encoding/json"
"net/http"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sigs.k8s.io/e2e-framework/pkg/envconf"
"sigs.k8s.io/e2e-framework/pkg/features"

"go.opentelemetry.io/obi/internal/test/integration/components/jaeger"
k8s "go.opentelemetry.io/obi/internal/test/integration/k8s/common"
)

// TestSharedPIDNamespaceAttribution verifies that when a DaemonSet with
// hostPID=true runs alongside a normal Deployment, OBI correctly attributes
// spans to their respective pods. Before the fix in PodContainerByPIDNs,
// multiple containers sharing the host PID namespace (init_pid_ns inode
// 4026531836) would cause spans to be arbitrarily attributed to whichever
// pod happened to be iterated first in a Go map — leading to cross-pod
// misattribution of service.name, k8s.namespace.name, k8s.pod.name, etc.
func TestSharedPIDNamespaceAttribution(t *testing.T) {
feat := features.New("Spans from hostPID pods are not misattributed to other pods").
Assess("spans from the Deployment get the Deployment's k8s metadata",
func(ctx context.Context, t *testing.T, _ *envconf.Config) context.Context {
require.EventuallyWithT(t, func(ct *assert.CollectT) {
// Generate traffic to the Deployment's testserver (port 8080)
resp, err := http.Get("http://localhost:38080/pingpong")
require.NoError(ct, err)
if resp == nil {
return
}

func() {
if resp.Body != nil {
defer func() {
require.NoError(ct, resp.Body.Close())
}()
}

require.Equal(ct, http.StatusOK, resp.StatusCode)
}()

resp, err = http.Get(jaegerQueryURL + "?service=testserver")
require.NoError(ct, err)
if resp == nil {
return
}

var tq jaeger.TracesQuery
func() {
if resp.Body != nil {
defer func() {
require.NoError(ct, resp.Body.Close())
}()
}

require.Equal(ct, http.StatusOK, resp.StatusCode)
require.NoError(ct, json.NewDecoder(resp.Body).Decode(&tq))
}()
traces := tq.FindBySpan(jaeger.Tag{Key: "url.path", Type: "string", Value: "/pingpong"})
require.NotEmpty(ct, traces)
trace := traces[0]
require.NotEmpty(ct, trace.Spans)

res := trace.FindByOperationName("GET /pingpong", "server")
require.Len(ct, res, 1)
parent := res[0]

// The Deployment's spans must carry the Deployment's k8s metadata,
// NOT the hostPID DaemonSet's metadata
sd := jaeger.DiffAsRegexp([]jaeger.Tag{
{Key: "k8s.pod.name", Type: "string", Value: "^testserver-.*"},
{Key: "k8s.container.name", Type: "string", Value: "testserver"},
{Key: "k8s.deployment.name", Type: "string", Value: "^testserver$"},
{Key: "k8s.namespace.name", Type: "string", Value: "^default$"},
{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: "service.instance.id", Type: "string", Value: "^default\\.testserver-.+\\.testserver"},
}, trace.Processes[parent.ProcessID].Tags)
require.Empty(ct, sd, sd.String())
}, testTimeout, 100*time.Millisecond)
return ctx
},
).
Assess("spans from the hostPID DaemonSet get the DaemonSet's k8s metadata",
func(ctx context.Context, t *testing.T, _ *envconf.Config) context.Context {
require.EventuallyWithT(t, func(ct *assert.CollectT) {
// Generate traffic to the hostPID DaemonSet's testserver (port 8082)
trafficResp, err := http.Get("http://localhost:38082/pingpong")
require.NoError(ct, err)
if trafficResp == nil {
return
}
defer trafficResp.Body.Close()
require.Equal(ct, http.StatusOK, trafficResp.StatusCode)

jaegerResp, err := http.Get(jaegerQueryURL + "?service=hostpid-httpserver")
require.NoError(ct, err)
if jaegerResp == nil {
return
}
defer jaegerResp.Body.Close()
require.Equal(ct, http.StatusOK, jaegerResp.StatusCode)
var tq jaeger.TracesQuery
require.NoError(ct, json.NewDecoder(jaegerResp.Body).Decode(&tq))
traces := tq.FindBySpan(jaeger.Tag{Key: "url.path", Type: "string", Value: "/pingpong"})
require.NotEmpty(ct, traces)
trace := traces[0]
require.NotEmpty(ct, trace.Spans)

res := trace.FindByOperationName("GET /pingpong", "server")
require.Len(ct, res, 1)
parent := res[0]

// The DaemonSet's spans must carry the DaemonSet's k8s metadata,
// NOT the Deployment's metadata
sd := jaeger.DiffAsRegexp([]jaeger.Tag{
{Key: "k8s.pod.name", Type: "string", Value: "^hostpid-httpserver-.*"},
{Key: "k8s.container.name", Type: "string", Value: "hostpid-httpserver"},
{Key: "k8s.daemonset.name", Type: "string", Value: "^hostpid-httpserver$"},
{Key: "k8s.namespace.name", Type: "string", Value: "^default$"},
{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: "service.instance.id", Type: "string", Value: "^default\\.hostpid-httpserver-.+\\.hostpid-httpserver"},
}, trace.Processes[parent.ProcessID].Tags)
require.Empty(ct, sd, sd.String())

// Verify no deployment metadata leaks onto DaemonSet spans
sd = jaeger.DiffAsRegexp([]jaeger.Tag{
{Key: "k8s.deployment.name", Type: "string"},
}, trace.Processes[parent.ProcessID].Tags)
require.Equal(ct, jaeger.DiffResult{
{ErrType: jaeger.ErrTypeMissing, Expected: jaeger.Tag{Key: "k8s.deployment.name", Type: "string"}},
}, sd)
}, testTimeout, 100*time.Millisecond)
return ctx
},
).Feature()
cluster.TestEnv().Test(t, feat)
}
Loading