From f4ec95d027ddc97d4808b7e808c4720aec1fb858 Mon Sep 17 00:00:00 2001 From: Sam Xie Date: Fri, 4 Mar 2022 01:13:31 +0800 Subject: [PATCH] Add container id support to Resource (#2418) * Add container id support to Resource * Fix wrong test case name * Add WithContainer option * Update CHANGELOG * Fix comments * Update CHANGELOG * Use regex to find container id * Add tests for reading cgroup file * Update sdk/resource/container.go Co-authored-by: Chester Cheung * Update format Co-authored-by: Chester Cheung Co-authored-by: Anthony Mirabella --- CHANGELOG.md | 1 + sdk/resource/config.go | 13 +++ sdk/resource/container.go | 100 +++++++++++++++++++ sdk/resource/container_test.go | 169 +++++++++++++++++++++++++++++++++ sdk/resource/export_test.go | 2 + sdk/resource/process_test.go | 5 +- sdk/resource/resource_test.go | 71 ++++++++++++++ 7 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 sdk/resource/container.go create mode 100644 sdk/resource/container_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index d7d51e21893..52cc0c413e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ This update is a breaking change of the unstable Metrics API. Code instrumented If the provided environment variables are invalid (negative), the default values would be used. - Rename the `gc` runtime name to `go` (#2560) +- Add container id support to Resource. (#2418) - Add span attribute value length limit. The new `AttributeValueLengthLimit` field is added to the `"go.opentelemetry.io/otel/sdk/trace".SpanLimits` type to configure this limit for a `TracerProvider`. The default limit for this resource is "unlimited". (#2637) diff --git a/sdk/resource/config.go b/sdk/resource/config.go index d80b5ae6214..09f30d57127 100644 --- a/sdk/resource/config.go +++ b/sdk/resource/config.go @@ -171,3 +171,16 @@ func WithProcessRuntimeVersion() Option { func WithProcessRuntimeDescription() Option { return WithDetectors(processRuntimeDescriptionDetector{}) } + +// WithContainer adds all the Container attributes to the configured Resource. +// See individual WithContainer* functions to configure specific attributes. +func WithContainer() Option { + return WithDetectors( + cgroupContainerIDDetector{}, + ) +} + +// WithContainerID adds an attribute with the id of the container to the configured Resource. +func WithContainerID() Option { + return WithDetectors(cgroupContainerIDDetector{}) +} diff --git a/sdk/resource/container.go b/sdk/resource/container.go new file mode 100644 index 00000000000..e56978adad5 --- /dev/null +++ b/sdk/resource/container.go @@ -0,0 +1,100 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resource // import "go.opentelemetry.io/otel/sdk/resource" + +import ( + "bufio" + "context" + "errors" + "io" + "os" + "regexp" + + semconv "go.opentelemetry.io/otel/semconv/v1.7.0" +) + +type containerIDProvider func() (string, error) + +var ( + containerID containerIDProvider = getContainerIDFromCGroup + cgroupContainerIDRe = regexp.MustCompile(`^.*/(?:.*-)?([0-9a-f]+)(?:\.|\s*$)`) +) + +type cgroupContainerIDDetector struct{} + +const cgroupPath = "/proc/self/cgroup" + +// Detect returns a *Resource that describes the id of the container. +// If no container id found, an empty resource will be returned. +func (cgroupContainerIDDetector) Detect(ctx context.Context) (*Resource, error) { + containerID, err := containerID() + if err != nil { + return nil, err + } + + if containerID == "" { + return Empty(), nil + } + return NewWithAttributes(semconv.SchemaURL, semconv.ContainerIDKey.String(containerID)), nil +} + +var ( + defaultOSStat = os.Stat + osStat = defaultOSStat + + defaultOSOpen = func(name string) (io.ReadCloser, error) { + return os.Open(name) + } + osOpen = defaultOSOpen +) + +// getContainerIDFromCGroup returns the id of the container from the cgroup file. +// If no container id found, an empty string will be returned. +func getContainerIDFromCGroup() (string, error) { + if _, err := osStat(cgroupPath); errors.Is(err, os.ErrNotExist) { + // File does not exist, skip + return "", nil + } + + file, err := osOpen(cgroupPath) + if err != nil { + return "", err + } + defer file.Close() + + return getContainerIDFromReader(file), nil +} + +// getContainerIDFromReader returns the id of the container from reader. +func getContainerIDFromReader(reader io.Reader) string { + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + line := scanner.Text() + + if id := getContainerIDFromLine(line); id != "" { + return id + } + } + return "" +} + +// getContainerIDFromLine returns the id of the container from one string line. +func getContainerIDFromLine(line string) string { + matches := cgroupContainerIDRe.FindStringSubmatch(line) + if len(matches) <= 1 { + return "" + } + return matches[1] +} diff --git a/sdk/resource/container_test.go b/sdk/resource/container_test.go new file mode 100644 index 00000000000..b09160da872 --- /dev/null +++ b/sdk/resource/container_test.go @@ -0,0 +1,169 @@ +// Copyright The OpenTelemetry Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package resource + +import ( + "errors" + "io" + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func setDefaultContainerProviders() { + setContainerProviders( + getContainerIDFromCGroup, + ) +} + +func setContainerProviders( + idProvider containerIDProvider, +) { + containerID = idProvider +} + +func TestGetContainerIDFromLine(t *testing.T) { + testCases := []struct { + name string + line string + expectedContainerID string + }{ + { + name: "with suffix", + line: "13:name=systemd:/podruntime/docker/kubepods/ac679f8a8319c8cf7d38e1adf263bc08d23.aaaa", + expectedContainerID: "ac679f8a8319c8cf7d38e1adf263bc08d23", + }, + { + name: "with prefix and suffix", + line: "13:name=systemd:/podruntime/docker/kubepods/crio-dc679f8a8319c8cf7d38e1adf263bc08d23.stuff", + expectedContainerID: "dc679f8a8319c8cf7d38e1adf263bc08d23", + }, + { + name: "no prefix and suffix", + line: "13:name=systemd:/pod/d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356", + expectedContainerID: "d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356", + }, + { + name: "with space", + line: " 13:name=systemd:/pod/d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356 ", + expectedContainerID: "d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356", + }, + { + name: "invalid hex string", + line: "13:name=systemd:/podruntime/docker/kubepods/ac679f8a8319c8cf7d38e1adf263bc08d23zzzz", + }, + { + name: "no container id - 1", + line: "pids: /", + }, + { + name: "no container id - 2", + line: "pids: ", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + containerID := getContainerIDFromLine(tc.line) + assert.Equal(t, tc.expectedContainerID, containerID) + }) + } +} + +func TestGetContainerIDFromReader(t *testing.T) { + testCases := []struct { + name string + reader io.Reader + expectedContainerID string + }{ + { + name: "multiple lines", + reader: strings.NewReader(`// +1:name=systemd:/podruntime/docker/kubepods/docker-dc579f8a8319c8cf7d38e1adf263bc08d23 +1:name=systemd:/podruntime/docker/kubepods/docker-dc579f8a8319c8cf7d38e1adf263bc08d24 +`), + expectedContainerID: "dc579f8a8319c8cf7d38e1adf263bc08d23", + }, + { + name: "no container id", + reader: strings.NewReader(`// +1:name=systemd:/podruntime/docker +`), + expectedContainerID: "", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + containerID := getContainerIDFromReader(tc.reader) + assert.Equal(t, tc.expectedContainerID, containerID) + }) + } +} + +func TestGetContainerIDFromCGroup(t *testing.T) { + t.Cleanup(func() { + osStat = defaultOSStat + osOpen = defaultOSOpen + }) + + testCases := []struct { + name string + cgroupFileNotExist bool + openFileError error + content string + expectedContainerID string + expectedError bool + }{ + { + name: "the cgroup file does not exist", + cgroupFileNotExist: true, + }, + { + name: "error when opening cgroup file", + openFileError: errors.New("test"), + expectedError: true, + }, + { + name: "cgroup file", + content: "1:name=systemd:/podruntime/docker/kubepods/docker-dc579f8a8319c8cf7d38e1adf263bc08d23", + expectedContainerID: "dc579f8a8319c8cf7d38e1adf263bc08d23", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + osStat = func(name string) (os.FileInfo, error) { + if tc.cgroupFileNotExist { + return nil, os.ErrNotExist + } + return nil, nil + } + + osOpen = func(name string) (io.ReadCloser, error) { + if tc.openFileError != nil { + return nil, tc.openFileError + } + return io.NopCloser(strings.NewReader(tc.content)), nil + } + + containerID, err := getContainerIDFromCGroup() + assert.Equal(t, tc.expectedError, err != nil) + assert.Equal(t, tc.expectedContainerID, containerID) + }) + } +} diff --git a/sdk/resource/export_test.go b/sdk/resource/export_test.go index 4cad64f0b65..6c767e595c5 100644 --- a/sdk/resource/export_test.go +++ b/sdk/resource/export_test.go @@ -23,6 +23,8 @@ var ( SetUserProviders = setUserProviders SetDefaultOSDescriptionProvider = setDefaultOSDescriptionProvider SetOSDescriptionProvider = setOSDescriptionProvider + SetDefaultContainerProviders = setDefaultContainerProviders + SetContainerProviders = setContainerProviders ) var ( diff --git a/sdk/resource/process_test.go b/sdk/resource/process_test.go index 0f9c628cc8d..408d0a5a300 100644 --- a/sdk/resource/process_test.go +++ b/sdk/resource/process_test.go @@ -102,13 +102,14 @@ func restoreAttributesProviders() { resource.SetDefaultRuntimeProviders() resource.SetDefaultUserProviders() resource.SetDefaultOSDescriptionProvider() + resource.SetDefaultContainerProviders() } func TestWithProcessFuncsErrors(t *testing.T) { mockProcessAttributesProvidersWithErrors() - t.Run("WithPID", testWithProcessExecutablePathError) - t.Run("WithExecutableName", testWithProcessOwnerError) + t.Run("WithExecutablePath", testWithProcessExecutablePathError) + t.Run("WithOwner", testWithProcessOwnerError) restoreAttributesProviders() } diff --git a/sdk/resource/resource_test.go b/sdk/resource/resource_test.go index 526ad13008f..fa9b9e4ea05 100644 --- a/sdk/resource/resource_test.go +++ b/sdk/resource/resource_test.go @@ -649,3 +649,74 @@ func hostname() string { } return hn } + +func TestWithContainerID(t *testing.T) { + t.Cleanup(restoreAttributesProviders) + + fakeContainerID := "fake-container-id" + + testCases := []struct { + name string + containerIDProvider func() (string, error) + expectedResource map[string]string + expectedErr bool + }{ + { + name: "get container id", + containerIDProvider: func() (string, error) { + return fakeContainerID, nil + }, + expectedResource: map[string]string{ + string(semconv.ContainerIDKey): fakeContainerID, + }, + }, + { + name: "no container id found", + containerIDProvider: func() (string, error) { + return "", nil + }, + expectedResource: map[string]string{}, + }, + { + name: "error", + containerIDProvider: func() (string, error) { + return "", fmt.Errorf("unable to get container id") + }, + expectedResource: map[string]string{}, + expectedErr: true, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + resource.SetContainerProviders(tc.containerIDProvider) + + res, err := resource.New(context.Background(), + resource.WithContainerID(), + ) + + if tc.expectedErr { + assert.Error(t, err) + } + assert.Equal(t, tc.expectedResource, toMap(res)) + }) + } +} + +func TestWithContainer(t *testing.T) { + t.Cleanup(restoreAttributesProviders) + + fakeContainerID := "fake-container-id" + resource.SetContainerProviders(func() (string, error) { + return fakeContainerID, nil + }) + + res, err := resource.New(context.Background(), + resource.WithContainer(), + ) + + assert.NoError(t, err) + assert.Equal(t, map[string]string{ + string(semconv.ContainerIDKey): fakeContainerID, + }, toMap(res)) +}