diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8d38d6fe9c0..a2068324c27 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -27,6 +27,16 @@ updates: schedule: interval: "weekly" day: "sunday" + - + package-ecosystem: "gomod" + directory: "/detectors/aws/eks" + labels: + - dependencies + - go + - "Skip Changelog" + schedule: + interval: "weekly" + day: "sunday" - package-ecosystem: "gomod" directory: "/detectors/aws" diff --git a/CHANGELOG.md b/CHANGELOG.md index 60ae84a6da0..5d862c1edac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +### Added + +- Amazon EKS resource detector. (#465) + ## [0.14.0] - 2020-11-20 ### Added diff --git a/detectors/aws/eks/detector.go b/detectors/aws/eks/detector.go new file mode 100644 index 00000000000..5ea57cabc60 --- /dev/null +++ b/detectors/aws/eks/detector.go @@ -0,0 +1,237 @@ +// 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 eks + +import ( + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "os" + "regexp" + "strings" + "time" + + "go.opentelemetry.io/otel/label" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/semconv" +) + +const ( + k8sSvcURL = "https://kubernetes.default.svc" + k8sTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + k8sCertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt" + authConfigmapPath = "/api/v1/namespaces/kube-system/configmaps/aws-auth" + cwConfigmapPath = "/api/v1/namespaces/amazon-cloudwatch/configmaps/cluster-info" + defaultCgroupPath = "/proc/self/cgroup" + containerIDLength = 64 + timeoutMillis = 2000 +) + +// detectorUtils is used for testing the ResourceDetector by abstracting functions that rely on external systems. +type detectorUtils interface { + fileExists(filename string) bool + fetchString(httpMethod string, URL string) (string, error) + getContainerID() (string, error) +} + +// This struct will implement the detectorUtils interface +type eksDetectorUtils struct{} + +// ResourceDetector for detecting resources running on Amazon EKS +type ResourceDetector struct { + utils detectorUtils +} + +// This struct will help unmarshal clustername from JSON response +type data struct { + ClusterName string `json:"cluster.name"` +} + +// Compile time assertion that ResourceDetector implements the resource.Detector interface. +var _ resource.Detector = (*ResourceDetector)(nil) + +// Compile time assertion that eksDetectorUtils implements the detectorUtils interface. +var _ detectorUtils = (*eksDetectorUtils)(nil) + +// Detect returns a Resource describing the Amazon EKS environment being run in. +func (detector *ResourceDetector) Detect(ctx context.Context) (*resource.Resource, error) { + + isEks, err := isEKS(detector.utils) + if err != nil { + return nil, err + } + + // Return empty resource object if not running in EKS + if !isEks { + return resource.Empty(), nil + } + + // Create variable to hold resource attributes + labels := []label.KeyValue{} + + // Get clusterName and append to labels + clusterName, err := getClusterName(detector.utils) + if err != nil { + return nil, err + } + if clusterName != "" { + labels = append(labels, semconv.K8SClusterNameKey.String(clusterName)) + } + + // Get containerID and append to labels + containerID, err := detector.utils.getContainerID() + if err != nil { + return nil, err + } + if containerID != "" { + labels = append(labels, semconv.ContainerIDKey.String(containerID)) + } + + // Return new resource object with clusterName and containerID as attributes + return resource.NewWithAttributes(labels...), nil + +} + +// isEKS checks if the current environment is running in EKS. +func isEKS(utils detectorUtils) (bool, error) { + if !isK8s(utils) { + return false, nil + } + + // Make HTTP GET request + awsAuth, err := utils.fetchString(http.MethodGet, k8sSvcURL+authConfigmapPath) + if err != nil { + return false, fmt.Errorf("isEks() error retrieving auth configmap: %w", err) + } + + return awsAuth != "", nil +} + +// isK8s checks if the current environment is running in a Kubernetes environment +func isK8s(utils detectorUtils) bool { + return utils.fileExists(k8sTokenPath) && utils.fileExists(k8sCertPath) +} + +// fileExists checks if a file with a given filename exists. +func (eksUtils eksDetectorUtils) fileExists(filename string) bool { + info, err := os.Stat(filename) + return err == nil && !info.IsDir() +} + +// fetchString executes an HTTP request with a given HTTP Method and URL string. +func (eksUtils eksDetectorUtils) fetchString(httpMethod string, URL string) (string, error) { + request, err := http.NewRequest(httpMethod, URL, nil) + if err != nil { + return "", fmt.Errorf("failed to create new HTTP request with method=%s, URL=%s: %w", httpMethod, URL, err) + } + + // Set HTTP request header with authentication credentials + authHeader, err := getK8sCredHeader() + if err != nil { + return "", err + } + request.Header.Set("Authorization", authHeader) + + // Get certificate + caCert, err := ioutil.ReadFile(k8sCertPath) + if err != nil { + return "", fmt.Errorf("failed to read file with path %s", k8sCertPath) + } + caCertPool := x509.NewCertPool() + caCertPool.AppendCertsFromPEM(caCert) + + // Set HTTP request timeout and add certificate + client := &http.Client{ + Timeout: timeoutMillis * time.Millisecond, + Transport: &http.Transport{ + TLSClientConfig: &tls.Config{ + RootCAs: caCertPool, + }, + }, + } + + response, err := client.Do(request) + if err != nil || response.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to execute HTTP request with method=%s, URL=%s, Status Code=%d: %w", httpMethod, URL, response.StatusCode, err) + } + + // Retrieve response body from HTTP request + body, err := ioutil.ReadAll(response.Body) + if err != nil { + return "", fmt.Errorf("failed to read response from HTTP request with method=%s, URL=%s: %w", httpMethod, URL, err) + } + + return string(body), nil +} + +// getK8sCredHeader retrieves the kubernetes credential information. +func getK8sCredHeader() (string, error) { + content, err := ioutil.ReadFile(k8sTokenPath) + if err != nil { + return "", fmt.Errorf("getK8sCredHeader() error: cannot read file with path %s", k8sTokenPath) + } + + return "Bearer " + string(content), nil +} + +// getClusterName retrieves the clusterName resource attribute +func getClusterName(utils detectorUtils) (string, error) { + resp, err := utils.fetchString("GET", k8sSvcURL+cwConfigmapPath) + if err != nil { + return "", fmt.Errorf("getClusterName() error: %w", err) + } + + // parse JSON object returned from HTTP request + var respmap map[string]json.RawMessage + err = json.Unmarshal([]byte(resp), &respmap) + if err != nil { + return "", fmt.Errorf("getClusterName() error: cannot parse JSON: %w", err) + } + var d data + err = json.Unmarshal(respmap["data"], &d) + if err != nil { + return "", fmt.Errorf("getClusterName() error: cannot parse JSON: %w", err) + } + + clusterName := d.ClusterName + + return clusterName, nil +} + +// getContainerID returns the containerID if currently running within a container. +func (eksUtils eksDetectorUtils) getContainerID() (string, error) { + fileData, err := ioutil.ReadFile(defaultCgroupPath) + if err != nil { + return "", fmt.Errorf("getContainerID() error: cannot read file with path %s: %w", defaultCgroupPath, err) + } + + r, err := regexp.Compile(`^.*/docker/(.+)$`) + if err != nil { + return "", err + } + + // Retrieve containerID from file + splitData := strings.Split(strings.TrimSpace(string(fileData)), "\n") + for _, str := range splitData { + if r.MatchString(str) { + return str[len(str)-containerIDLength:], nil + } + } + return "", fmt.Errorf("getContainerID() error: cannot read containerID from file %s", defaultCgroupPath) +} diff --git a/detectors/aws/eks/detector_test.go b/detectors/aws/eks/detector_test.go new file mode 100644 index 00000000000..94b29d097df --- /dev/null +++ b/detectors/aws/eks/detector_test.go @@ -0,0 +1,95 @@ +// 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 eks + +import ( + "context" + "testing" + + "github.com/bmizerany/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/otel/label" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/semconv" +) + +type MockDetectorUtils struct { + mock.Mock +} + +// Mock function for fileExists() +func (detectorUtils *MockDetectorUtils) fileExists(filename string) bool { + args := detectorUtils.Called(filename) + return args.Bool(0) +} + +// Mock function for fetchString() +func (detectorUtils *MockDetectorUtils) fetchString(httpMethod string, URL string) (string, error) { + args := detectorUtils.Called(httpMethod, URL) + return args.String(0), args.Error(1) +} + +// Mock function for getContainerID() +func (detectorUtils *MockDetectorUtils) getContainerID() (string, error) { + args := detectorUtils.Called() + return args.String(0), args.Error(1) +} + +// Tests EKS resource detector running in EKS environment +func TestEks(t *testing.T) { + + detectorUtils := new(MockDetectorUtils) + + // Mock functions and set expectations + detectorUtils.On("fileExists", k8sTokenPath).Return(true) + detectorUtils.On("fileExists", k8sCertPath).Return(true) + detectorUtils.On("fetchString", "GET", k8sSvcURL+authConfigmapPath).Return("not empty", nil) + detectorUtils.On("fetchString", "GET", k8sSvcURL+cwConfigmapPath).Return(`{"data":{"cluster.name":"my-cluster"}}`, nil) + detectorUtils.On("getContainerID").Return("0123456789A", nil) + + // Expected resource object + eksResourceLabels := []label.KeyValue{ + semconv.K8SClusterNameKey.String("my-cluster"), + semconv.ContainerIDKey.String("0123456789A"), + } + expectedResource := resource.NewWithAttributes(eksResourceLabels...) + + // Call EKS Resource detector to detect resources + eksResourceDetector := ResourceDetector{detectorUtils} + resourceObj, err := eksResourceDetector.Detect(context.Background()) + require.NoError(t, err) + + assert.Equal(t, expectedResource, resourceObj, "Resource object returned is incorrect") + detectorUtils.AssertExpectations(t) +} + +// Tests EKS resource detector not running in EKS environment +func TestNotEKS(t *testing.T) { + + detectorUtils := new(MockDetectorUtils) + + k8sTokenPath := "/var/run/secrets/kubernetes.io/serviceaccount/token" + + // Mock functions and set expectations + detectorUtils.On("fileExists", k8sTokenPath).Return(false) + + detector := ResourceDetector{detectorUtils} + r, err := detector.Detect(context.Background()) + require.NoError(t, err) + assert.Equal(t, resource.Empty(), r, "Resource object should be empty") + detectorUtils.AssertExpectations(t) +} diff --git a/detectors/aws/eks/go.mod b/detectors/aws/eks/go.mod new file mode 100644 index 00000000000..4b94fa6b955 --- /dev/null +++ b/detectors/aws/eks/go.mod @@ -0,0 +1,11 @@ +module go.opentelemetry.io/contrib/detectors/aws/eks + +go 1.15 + +require ( + github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 + github.com/kr/pretty v0.2.1 // indirect + github.com/stretchr/testify v1.6.1 + go.opentelemetry.io/otel v0.14.0 + go.opentelemetry.io/otel/sdk v0.14.0 +) diff --git a/detectors/aws/eks/go.sum b/detectors/aws/eks/go.sum new file mode 100644 index 00000000000..80396ac2251 --- /dev/null +++ b/detectors/aws/eks/go.sum @@ -0,0 +1,30 @@ +github.com/DataDog/sketches-go v0.0.1/go.mod h1:Q5DbzQ+3AkgGwymQO7aZFNP7ns2lZKGtvRBzRXfdi60= +github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869 h1:DDGfHa7BWjL4YnC6+E63dPcxHo2sUxDIu8g3QgEJdRY= +github.com/bmizerany/assert v0.0.0-20160611221934-b7ed37b82869/go.mod h1:Ekp36dRnpXw/yCqJaO+ZrUyxD+3VXMFFr56k5XYrpB4= +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/go-cmp v0.5.3 h1:x95R7cp+rSeeqAMI2knLtQ0DKlaBhv2NrtrOvafPHRo= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/gofuzz v1.1.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +go.opentelemetry.io/otel v0.14.0 h1:YFBEfjCk9MTjaytCNSUkp9Q8lF7QJezA06T71FbQxLQ= +go.opentelemetry.io/otel v0.14.0/go.mod h1:vH5xEuwy7Rts0GNtsCW3HYQoZDY+OmBJ6t1bFGGlxgw= +go.opentelemetry.io/otel/sdk v0.14.0 h1:Pqgd85y5XhyvHQlOxkKW+FD4DAX7AoeaNIDKC2VhfHQ= +go.opentelemetry.io/otel/sdk v0.14.0/go.mod h1:kGO5pEMSNqSJppHAm8b73zztLxB5fgDQnD56/dl5xqE= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543 h1:E7g+9GITq07hpfrRu66IVDexMakfv52eLZ2CXBWiKr4= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=