diff --git a/opentelemetry-dotnet-contrib.sln b/opentelemetry-dotnet-contrib.sln index 936e6672f8..3c91bad51a 100644 --- a/opentelemetry-dotnet-contrib.sln +++ b/opentelemetry-dotnet-contrib.sln @@ -240,6 +240,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{1FCC8E ProjectSection(SolutionItems) = preProject src\Shared\ActivityInstrumentationHelper.cs = src\Shared\ActivityInstrumentationHelper.cs src\Shared\AssemblyVersionExtensions.cs = src\Shared\AssemblyVersionExtensions.cs + src\Shared\AsyncHelper.cs = src\Shared\AsyncHelper.cs src\Shared\DatabaseSemanticConventionHelper.cs = src\Shared\DatabaseSemanticConventionHelper.cs src\Shared\DiagnosticSourceListener.cs = src\Shared\DiagnosticSourceListener.cs src\Shared\DiagnosticSourceSubscriber.cs = src\Shared\DiagnosticSourceSubscriber.cs @@ -255,6 +256,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Shared", "Shared", "{1FCC8E src\Shared\PropertyFetcher.AOT.cs = src\Shared\PropertyFetcher.AOT.cs src\Shared\PropertyFetcher.cs = src\Shared\PropertyFetcher.cs src\Shared\RedactionHelper.cs = src\Shared\RedactionHelper.cs + src\Shared\ResourceDetectorUtils.cs = src\Shared\ResourceDetectorUtils.cs src\Shared\ResourceSemanticConventions.cs = src\Shared\ResourceSemanticConventions.cs src\Shared\SemanticConventions.cs = src\Shared\SemanticConventions.cs src\Shared\ServerCertificateValidationHandler.cs = src\Shared\ServerCertificateValidationHandler.cs diff --git a/src/OpenTelemetry.Resources.AWS/OpenTelemetry.Resources.AWS.csproj b/src/OpenTelemetry.Resources.AWS/OpenTelemetry.Resources.AWS.csproj index 6c9fe6bf60..4fc595e4ac 100644 --- a/src/OpenTelemetry.Resources.AWS/OpenTelemetry.Resources.AWS.csproj +++ b/src/OpenTelemetry.Resources.AWS/OpenTelemetry.Resources.AWS.csproj @@ -24,9 +24,11 @@ + + diff --git a/src/OpenTelemetry.Resources.Container/CHANGELOG.md b/src/OpenTelemetry.Resources.Container/CHANGELOG.md index a8ebb3af83..d6e252c943 100644 --- a/src/OpenTelemetry.Resources.Container/CHANGELOG.md +++ b/src/OpenTelemetry.Resources.Container/CHANGELOG.md @@ -13,6 +13,9 @@ Released 2024-Dec-09 * Updated OpenTelemetry core component version(s) to `1.10.0`. ([#2317](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/2317)) +* Add Kubernetes support. + ([#1699](https://github.com/open-telemetry/opentelemetry-dotnet-contrib/pull/1699)) + ## 1.0.0-beta.9 Released 2024-Jun-18 diff --git a/src/OpenTelemetry.Resources.Container/ContainerDetector.cs b/src/OpenTelemetry.Resources.Container/ContainerDetector.cs index 0f43e1495f..eb9974677a 100644 --- a/src/OpenTelemetry.Resources.Container/ContainerDetector.cs +++ b/src/OpenTelemetry.Resources.Container/ContainerDetector.cs @@ -14,21 +14,25 @@ internal sealed class ContainerDetector : IResourceDetector private const string Filepath = "/proc/self/cgroup"; private const string FilepathV2 = "/proc/self/mountinfo"; private const string Hostname = "hostname"; + private const string K8sCertificatePath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"; + + private readonly IK8sMetadataFetcher k8sMetadataFetcher; + + /// + /// Initializes a new instance of the class. + /// + public ContainerDetector() + : this(new K8sMetadataFetcher()) + { + } /// - /// CGroup Parse Versions. + /// Initializes a new instance of the class for testing. /// - internal enum ParseMode + /// The . + internal ContainerDetector(IK8sMetadataFetcher k8sMetadataFetcher) { - /// - /// Represents CGroupV1. - /// - V1, - - /// - /// Represents CGroupV2. - /// - V2, + this.k8sMetadataFetcher = k8sMetadataFetcher; } /// @@ -37,26 +41,78 @@ internal enum ParseMode /// Resource with key-value pairs of resource attributes. public Resource Detect() { - var cGroupBuild = this.BuildResource(Filepath, ParseMode.V1); - if (cGroupBuild == Resource.Empty) + var containerId = this.ExtractK8sContainerId(); + if (!string.IsNullOrEmpty(containerId)) { - cGroupBuild = this.BuildResource(FilepathV2, ParseMode.V2); + return BuildResource(containerId); } - return cGroupBuild; + containerId = this.ExtractContainerId(Filepath, ParseMode.V1); + if (!string.IsNullOrEmpty(containerId)) + { + return BuildResource(containerId); + } + + containerId = this.ExtractContainerId(FilepathV2, ParseMode.V2); + if (!string.IsNullOrEmpty(containerId)) + { + return BuildResource(containerId); + } + + return Resource.Empty; + + static Resource BuildResource(string containerId) + { + return new Resource(new List>(1) { new(ContainerSemanticConventions.AttributeContainerId, containerId!) }); + } } /// - /// Builds the resource attributes from Container Id in file path. + /// Extracts Container Id from path using the cgroupv1 format. /// - /// File path where container id exists. - /// CGroup Version of file to parse from. - /// Returns Resource with list of key-value pairs of container resource attributes if container id exists else empty resource. - internal Resource BuildResource(string path, ParseMode cgroupVersion) + /// cgroup path. + /// CGroup Version of file to parse from. + /// Container Id, if not found or exception being thrown. + internal string? ExtractContainerId(string path, ParseMode parseMode) { - var containerId = this.ExtractContainerId(path, cgroupVersion); + try + { + if (!File.Exists(path)) + { + return null; + } + + foreach (string line in File.ReadLines(path)) + { + string? containerId = null; + if (!string.IsNullOrEmpty(line)) + { + if (parseMode == ParseMode.V1) + { + containerId = GetIdFromLineV1(line); + } +#if NET + else if (parseMode == ParseMode.V2 && line.Contains(Hostname, StringComparison.Ordinal)) +#else + else if (parseMode == ParseMode.V2 && line.Contains(Hostname)) +#endif + { + containerId = GetIdFromLineV2(line); + } + } - return string.IsNullOrEmpty(containerId) ? Resource.Empty : new Resource([new(ContainerSemanticConventions.AttributeContainerId, containerId!)]); + if (!string.IsNullOrEmpty(containerId)) + { + return containerId; + } + } + } + catch (Exception ex) + { + ContainerResourceEventSource.Log.ExtractResourceAttributesException($"{nameof(ContainerDetector)} : Failed to extract Container id from path", ex); + } + + return null; } /// @@ -102,7 +158,6 @@ internal Resource BuildResource(string path, ParseMode cgroupVersion) private static string RemovePrefixAndSuffixIfNeeded(string input, int startIndex, int endIndex) { startIndex = (startIndex == -1) ? 0 : startIndex + 1; - if (endIndex == -1) { endIndex = input.Length; @@ -111,49 +166,47 @@ private static string RemovePrefixAndSuffixIfNeeded(string input, int startIndex return input.Substring(startIndex, endIndex - startIndex); } - /// - /// Extracts Container Id from path using the cgroupv1 format. - /// - /// cgroup path. - /// CGroup Version of file to parse from. - /// Container Id, Null if not found or exception being thrown. - private string? ExtractContainerId(string path, ParseMode cgroupVersion) + private string? ExtractK8sContainerId() { try { - if (!File.Exists(path)) + var baseUrl = this.k8sMetadataFetcher.GetServiceBaseUrl(); + var containerName = this.k8sMetadataFetcher.GetContainerName(); + if (string.IsNullOrEmpty(baseUrl) || string.IsNullOrEmpty(containerName)) { return null; } - foreach (var line in File.ReadLines(path)) + var @namespace = this.k8sMetadataFetcher.GetNamespace(); + var hostname = this.k8sMetadataFetcher.GetPodName() ?? this.k8sMetadataFetcher.GetHostname(); + var url = $"{baseUrl}/api/v1/namespaces/{@namespace}/pods/{hostname}"; + var credentials = this.k8sMetadataFetcher.GetApiCredential(); + if (string.IsNullOrEmpty(credentials)) { - string? containerId = null; - if (!string.IsNullOrEmpty(line)) - { - if (cgroupVersion == ParseMode.V1) - { - containerId = GetIdFromLineV1(line); - } -#if NET - else if (cgroupVersion == ParseMode.V2 && line.Contains(Hostname, StringComparison.Ordinal)) -#else - else if (cgroupVersion == ParseMode.V2 && line.Contains(Hostname)) -#endif - { - containerId = GetIdFromLineV2(line); - } - } + return null; + } - if (!string.IsNullOrEmpty(containerId)) - { - return containerId; - } + using var httpClientHandler = ServerCertificateValidationHandler.Create(K8sCertificatePath, ContainerResourceEventSource.Log); + var response = AsyncHelper.RunSync(() => ResourceDetectorUtils.SendOutRequestAsync(url, HttpMethod.Get, new KeyValuePair("Authorization", credentials), httpClientHandler)); + var pod = ResourceDetectorUtils.DeserializeFromString(response, SourceGenerationContext.Default.K8sPod); + if (pod?.Status?.ContainerStatuses == null) + { + return null; } + + var container = pod.Status.ContainerStatuses.SingleOrDefault(p => p.Name == containerName); + if (string.IsNullOrEmpty(container?.Id)) + { + return null; + } + + // Container's ID is in :// format. + var index = container.Id.LastIndexOf('/'); + return container.Id.Substring(index + 1); } catch (Exception ex) { - ContainerExtensionsEventSource.Log.ExtractResourceAttributesException($"{nameof(ContainerDetector)} : Failed to extract Container id from path", ex); + ContainerResourceEventSource.Log.ExtractResourceAttributesException($"{nameof(ContainerDetector)}: Failed to extract container id", ex); } return null; diff --git a/src/OpenTelemetry.Resources.Container/ContainerExtensionsEventSource.cs b/src/OpenTelemetry.Resources.Container/ContainerExtensionsEventSource.cs deleted file mode 100644 index 8efa496e6e..0000000000 --- a/src/OpenTelemetry.Resources.Container/ContainerExtensionsEventSource.cs +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright The OpenTelemetry Authors -// SPDX-License-Identifier: Apache-2.0 - -using System.Diagnostics.Tracing; -using OpenTelemetry.Internal; - -namespace OpenTelemetry.Resources.Container; - -[EventSource(Name = "OpenTelemetry-Resources-Container")] -internal class ContainerExtensionsEventSource : EventSource -{ - public static ContainerExtensionsEventSource Log = new(); - - [NonEvent] - public void ExtractResourceAttributesException(string format, Exception ex) - { - if (this.IsEnabled(EventLevel.Error, (EventKeywords)(-1))) - { - this.FailedToExtractResourceAttributes(format, ex.ToInvariantString()); - } - } - - [Event(1, Message = "Failed to extract resource attributes in '{0}'.", Level = EventLevel.Error)] - public void FailedToExtractResourceAttributes(string format, string exception) - { - this.WriteEvent(1, format, exception); - } -} diff --git a/src/OpenTelemetry.Resources.Container/ContainerResourceEventSource.cs b/src/OpenTelemetry.Resources.Container/ContainerResourceEventSource.cs new file mode 100644 index 0000000000..5283956384 --- /dev/null +++ b/src/OpenTelemetry.Resources.Container/ContainerResourceEventSource.cs @@ -0,0 +1,58 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Diagnostics.Tracing; +using OpenTelemetry.Internal; + +namespace OpenTelemetry.Resources.Container; + +[EventSource(Name = "OpenTelemetry-Resources-Container")] +internal sealed class ContainerResourceEventSource : EventSource, IServerCertificateValidationEventSource +{ + public static ContainerResourceEventSource Log = new(); + + private const int EventIdFailedToExtractResourceAttributes = 1; + private const int EventIdFailedToValidateCertificate = 2; + private const int EventIdFailedToCreateHttpHandler = 3; + private const int EventIdFailedCertificateFileNotExists = 4; + private const int EventIdFailedToLoadCertificateInStorage = 5; + + [NonEvent] + public void ExtractResourceAttributesException(string format, Exception ex) + { + if (this.IsEnabled(EventLevel.Error, (EventKeywords)(-1))) + { + this.FailedToExtractResourceAttributes(format, ex.ToInvariantString()); + } + } + + [Event(EventIdFailedToExtractResourceAttributes, Message = "Failed to extract resource attributes in '{0}'.", Level = EventLevel.Error)] + public void FailedToExtractResourceAttributes(string format, string exception) + { + this.WriteEvent(EventIdFailedToExtractResourceAttributes, format, exception); + } + + [Event(EventIdFailedToValidateCertificate, Message = "Failed to validate certificate. Details: '{0}'", Level = EventLevel.Warning)] + public void FailedToValidateCertificate(string error) + { + this.WriteEvent(EventIdFailedToValidateCertificate, error); + } + + [Event(EventIdFailedToCreateHttpHandler, Message = "Failed to create HTTP handler. Exception: '{0}'", Level = EventLevel.Warning)] + public void FailedToCreateHttpHandler(Exception exception) + { + this.WriteEvent(EventIdFailedToCreateHttpHandler, exception.ToInvariantString()); + } + + [Event(EventIdFailedCertificateFileNotExists, Message = "Certificate file does not exist. File: '{0}'", Level = EventLevel.Warning)] + public void CertificateFileDoesNotExist(string filename) + { + this.WriteEvent(EventIdFailedCertificateFileNotExists, filename); + } + + [Event(EventIdFailedToLoadCertificateInStorage, Message = "Failed to load certificate in trusted storage. File: '{0}'", Level = EventLevel.Warning)] + public void FailedToLoadCertificateInTrustedStorage(string filename) + { + this.WriteEvent(EventIdFailedToLoadCertificateInStorage, filename); + } +} diff --git a/src/OpenTelemetry.Resources.Container/IK8sMetadataFetcher.cs b/src/OpenTelemetry.Resources.Container/IK8sMetadataFetcher.cs new file mode 100644 index 0000000000..72992fcc2b --- /dev/null +++ b/src/OpenTelemetry.Resources.Container/IK8sMetadataFetcher.cs @@ -0,0 +1,19 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.Resources.Container; + +internal interface IK8sMetadataFetcher +{ + string? GetApiCredential(); + + string? GetContainerName(); + + string? GetHostname(); + + string? GetPodName(); + + string? GetNamespace(); + + string? GetServiceBaseUrl(); +} diff --git a/src/OpenTelemetry.Resources.Container/K8sMetadataFetcher.cs b/src/OpenTelemetry.Resources.Container/K8sMetadataFetcher.cs new file mode 100644 index 0000000000..2ff6c58d05 --- /dev/null +++ b/src/OpenTelemetry.Resources.Container/K8sMetadataFetcher.cs @@ -0,0 +1,74 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Text; + +namespace OpenTelemetry.Resources.Container; + +internal sealed class K8sMetadataFetcher : IK8sMetadataFetcher +{ + private const string KubernetesServiceHostEnvVar = "KUBERNETES_SERVICE_HOST"; + private const string KubernetesServicePortEnvVar = "KUBERNETES_SERVICE_PORT_HTTPS"; + private const string KubernetesHostnameEnvVar = "HOSTNAME"; + private const string KubernetesPodNameEnvVar = "KUBERNETES_POD_NAME"; + private const string KubernetesContainerNameEnvVar = "KUBERNETES_CONTAINER_NAME"; + private const string KubernetesNamespacePath = "/var/run/secrets/kubernetes.io/serviceaccount/namespace"; + private const string KubernetesCredentialPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"; + + public string? GetApiCredential() + { + try + { + var stringBuilder = new StringBuilder("Bearer "); + + using (var streamReader = ResourceDetectorUtils.GetStreamReader(KubernetesCredentialPath)) + { + while (!streamReader.EndOfStream) + { + _ = stringBuilder.Append(streamReader.ReadLine()?.Trim()); + } + } + + return stringBuilder.ToString(); + } + catch (Exception ex) + { + ContainerResourceEventSource.Log.ExtractResourceAttributesException($"{nameof(ContainerDetector)}: Failed to load client token", ex); + } + + return null; + } + + public string? GetContainerName() + { + return Environment.GetEnvironmentVariable(KubernetesContainerNameEnvVar); + } + + public string? GetHostname() + { + return Environment.GetEnvironmentVariable(KubernetesHostnameEnvVar); + } + + public string? GetPodName() + { + return Environment.GetEnvironmentVariable(KubernetesPodNameEnvVar); + } + + public string? GetNamespace() + { + return File.ReadAllText(KubernetesNamespacePath); + } + + public string? GetServiceBaseUrl() + { + var serviceHost = Environment.GetEnvironmentVariable(KubernetesServiceHostEnvVar); + var servicePort = Environment.GetEnvironmentVariable(KubernetesServicePortEnvVar); + + if (string.IsNullOrWhiteSpace(serviceHost) || string.IsNullOrWhiteSpace(servicePort)) + { + return null; + } + + return $"https://{serviceHost}:{servicePort}"; + } +} diff --git a/src/OpenTelemetry.Resources.Container/Models/K8sContainerStatus.cs b/src/OpenTelemetry.Resources.Container/Models/K8sContainerStatus.cs new file mode 100644 index 0000000000..22fc260562 --- /dev/null +++ b/src/OpenTelemetry.Resources.Container/Models/K8sContainerStatus.cs @@ -0,0 +1,15 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json.Serialization; + +namespace OpenTelemetry.Resources.Container.Models; + +internal sealed class K8sContainerStatus +{ + [JsonPropertyName("name")] + public string Name { get; set; } = default!; + + [JsonPropertyName("containerID")] + public string Id { get; set; } = default!; +} diff --git a/src/OpenTelemetry.Resources.Container/Models/K8sPod.cs b/src/OpenTelemetry.Resources.Container/Models/K8sPod.cs new file mode 100644 index 0000000000..681cbe3409 --- /dev/null +++ b/src/OpenTelemetry.Resources.Container/Models/K8sPod.cs @@ -0,0 +1,12 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json.Serialization; + +namespace OpenTelemetry.Resources.Container.Models; + +internal sealed class K8sPod +{ + [JsonPropertyName("status")] + public K8sPodStatus? Status { get; set; } +} diff --git a/src/OpenTelemetry.Resources.Container/Models/K8sPodStatus.cs b/src/OpenTelemetry.Resources.Container/Models/K8sPodStatus.cs new file mode 100644 index 0000000000..7451635af3 --- /dev/null +++ b/src/OpenTelemetry.Resources.Container/Models/K8sPodStatus.cs @@ -0,0 +1,12 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json.Serialization; + +namespace OpenTelemetry.Resources.Container.Models; + +internal sealed class K8sPodStatus +{ + [JsonPropertyName("containerStatuses")] + public IReadOnlyList ContainerStatuses { get; set; } = new List(); +} diff --git a/src/OpenTelemetry.Resources.Container/OpenTelemetry.Resources.Container.csproj b/src/OpenTelemetry.Resources.Container/OpenTelemetry.Resources.Container.csproj index 6bef037057..a244ed78ca 100644 --- a/src/OpenTelemetry.Resources.Container/OpenTelemetry.Resources.Container.csproj +++ b/src/OpenTelemetry.Resources.Container/OpenTelemetry.Resources.Container.csproj @@ -7,6 +7,11 @@ Resources.Container- + + + $(NoWarn);nullable + + @@ -15,11 +20,17 @@ + + + + + + diff --git a/src/OpenTelemetry.Resources.Container/ParseMode.cs b/src/OpenTelemetry.Resources.Container/ParseMode.cs new file mode 100644 index 0000000000..5a3ce66bf7 --- /dev/null +++ b/src/OpenTelemetry.Resources.Container/ParseMode.cs @@ -0,0 +1,20 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +namespace OpenTelemetry.Resources.Container; + +/// +/// CGroup Parse Versions. +/// +internal enum ParseMode +{ + /// + /// Represents CGroupV1. + /// + V1, + + /// + /// Represents CGroupV2. + /// + V2, +} diff --git a/src/OpenTelemetry.Resources.Container/README.md b/src/OpenTelemetry.Resources.Container/README.md index 9cb0c6ce3c..4a0ab04514 100644 --- a/src/OpenTelemetry.Resources.Container/README.md +++ b/src/OpenTelemetry.Resources.Container/README.md @@ -52,6 +52,75 @@ your application is running: - **ContainerDetector**: container.id. +## Kubernetes + +To make container ID resolution work, container and pod name should be provided +through `KUBERNETES_CONTAINER_NAME` and `KUBERNETES_POD_NAME` environment variable +respectively and pod should have at least +get permission to kubernetes resource pods. +It can be done by utilizing YAML anchoring, downwards API +and RBAC (Role-Based Access Control). + +If `KUBERNETES_POD_NAME` is not provided, detector will use `HOSTNAME` +as a fallback, but it may not work in some environments +or if hostname was overridden in pod spec. + +Below is an example of how to configure sample pod +to make container ID resolution working: + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: pod-reader-account +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + namespace: default + name: pod-reader +rules: +- apiGroups: [""] + resources: ["pods"] + verbs: ["get"] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: read-pods + namespace: default +subjects: +- kind: ServiceAccount + name: pod-reader-account + namespace: default +roleRef: + kind: Role + name: pod-reader + apiGroup: rbac.authorization.k8s.io +--- +apiVersion: v1 +kind: Pod +metadata: + name: container-resolve-demo +spec: + serviceAccountName: pod-reader-account + volumes: + - name: shared-data + emptyDir: {} + containers: + - name: &container_name my_container_name + image: ubuntu:latest + command: [ "/bin/bash", "-c", "--" ] + args: [ "while true; do sleep 30; done;" ] + env: + - name: KUBERNETES_CONTAINER_NAME + value: *container_name + - name: KUBERNETES_POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name +``` + ## References - [OpenTelemetry Project](https://opentelemetry.io/) diff --git a/src/OpenTelemetry.Resources.Container/SourceGenerationContext.cs b/src/OpenTelemetry.Resources.Container/SourceGenerationContext.cs new file mode 100644 index 0000000000..22cb03deb1 --- /dev/null +++ b/src/OpenTelemetry.Resources.Container/SourceGenerationContext.cs @@ -0,0 +1,22 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +using System.Text.Json.Serialization; +using OpenTelemetry.Resources.Container.Models; + +namespace OpenTelemetry.Resources.Container; + +/// +/// "Source Generation" is feature added to System.Text.Json in .NET 6.0. +/// This is a performance optimization that avoids runtime reflection when performing serialization. +/// Serialization metadata will be computed at compile-time and included in the assembly. +/// . +/// . +/// . +/// +[JsonSerializable(typeof(K8sPod))] +[JsonSerializable(typeof(K8sPodStatus))] +[JsonSerializable(typeof(K8sContainerStatus))] +internal sealed partial class SourceGenerationContext : JsonSerializerContext +{ +} diff --git a/src/OpenTelemetry.Resources.AWS/AsyncHelper.cs b/src/Shared/AsyncHelper.cs similarity index 97% rename from src/OpenTelemetry.Resources.AWS/AsyncHelper.cs rename to src/Shared/AsyncHelper.cs index a75d5579c9..9b0782b2f0 100644 --- a/src/OpenTelemetry.Resources.AWS/AsyncHelper.cs +++ b/src/Shared/AsyncHelper.cs @@ -1,7 +1,7 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 -namespace OpenTelemetry.Resources.AWS; +namespace OpenTelemetry.Resources; /// /// A helper class for running asynchronous methods synchronously. diff --git a/src/OpenTelemetry.Resources.AWS/ResourceDetectorUtils.cs b/src/Shared/ResourceDetectorUtils.cs similarity index 98% rename from src/OpenTelemetry.Resources.AWS/ResourceDetectorUtils.cs rename to src/Shared/ResourceDetectorUtils.cs index b870fd499d..5b89c51cfb 100644 --- a/src/OpenTelemetry.Resources.AWS/ResourceDetectorUtils.cs +++ b/src/Shared/ResourceDetectorUtils.cs @@ -10,7 +10,7 @@ using System.Text.Json.Serialization.Metadata; #endif -namespace OpenTelemetry.Resources.AWS; +namespace OpenTelemetry.Resources; /// /// Class for resource detector utils. diff --git a/test/OpenTelemetry.Resources.Container.Tests/ContainerDetectorTests.cs b/test/OpenTelemetry.Resources.Container.Tests/ContainerDetectorTests.cs index 47b43b329b..3928947fa7 100644 --- a/test/OpenTelemetry.Resources.Container.Tests/ContainerDetectorTests.cs +++ b/test/OpenTelemetry.Resources.Container.Tests/ContainerDetectorTests.cs @@ -1,6 +1,11 @@ // Copyright The OpenTelemetry Authors // SPDX-License-Identifier: Apache-2.0 +using System.Text; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Hosting.Server.Features; +using Microsoft.AspNetCore.Http; using Xunit; namespace OpenTelemetry.Resources.Container.Tests; @@ -13,25 +18,25 @@ public class ContainerDetectorTests name: "cgroupv1 with prefix", line: "13:name=systemd:/podruntime/docker/kubepods/crio-e2cc29debdf85dde404998aa128997a819ff", expectedContainerId: "e2cc29debdf85dde404998aa128997a819ff", - cgroupVersion: ContainerDetector.ParseMode.V1), + cgroupVersion: ParseMode.V1), new( name: "cgroupv1 with suffix", line: "13:name=systemd:/podruntime/docker/kubepods/ac679f8a8319c8cf7d38e1adf263bc08d23.aaaa", expectedContainerId: "ac679f8a8319c8cf7d38e1adf263bc08d23", - cgroupVersion: ContainerDetector.ParseMode.V1), + cgroupVersion: ParseMode.V1), new( name: "cgroupv1 with prefix and suffix", line: "13:name=systemd:/podruntime/docker/kubepods/crio-dc679f8a8319c8cf7d38e1adf263bc08d23.stuff", expectedContainerId: "dc679f8a8319c8cf7d38e1adf263bc08d23", - cgroupVersion: ContainerDetector.ParseMode.V1), + cgroupVersion: ParseMode.V1), new( name: "cgroupv1 with container Id", line: "13:name=systemd:/pod/d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356", expectedContainerId: "d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356", - cgroupVersion: ContainerDetector.ParseMode.V1) + cgroupVersion: ParseMode.V1) ]; @@ -41,37 +46,37 @@ public class ContainerDetectorTests name: "cgroupv2 with container Id", line: "13:name=systemd:/pod/d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356/hostname", expectedContainerId: "d86d75589bf6cc254f3e2cc29debdf85dde404998aa128997a819ff991827356", - cgroupVersion: ContainerDetector.ParseMode.V2), + cgroupVersion: ParseMode.V2), new( name: "cgroupv2 with full line", line: "473 456 254:1 /docker/containers/dc64b5743252dbaef6e30521c34d6bbd1620c8ce65bdb7bf9e7143b61bb5b183/hostname /etc/hostname rw,relatime - ext4 /dev/vda1 rw", expectedContainerId: "dc64b5743252dbaef6e30521c34d6bbd1620c8ce65bdb7bf9e7143b61bb5b183", - cgroupVersion: ContainerDetector.ParseMode.V2), + cgroupVersion: ParseMode.V2), new( name: "cgroupv2 with minikube containerd mountinfo", line: "1537 1517 8:1 /var/lib/containerd/io.containerd.grpc.v1.cri/sandboxes/fb5916a02feca96bdeecd8e062df9e5e51d6617c8214b5e1f3ff9320f4402ae6/hostname /etc/hostname rw,relatime - ext4 /dev/sda1 rw", expectedContainerId: "fb5916a02feca96bdeecd8e062df9e5e51d6617c8214b5e1f3ff9320f4402ae6", - cgroupVersion: ContainerDetector.ParseMode.V2), + cgroupVersion: ParseMode.V2), new( name: "cgroupv2 with minikube docker mountinfo", line: "2327 2307 8:1 /var/lib/docker/containers/a1551a1d7e1881d6c18d2c9ec462cab6ad3666825f0adb2098e9d5b198fd7e19/hostname /etc/hostname rw,relatime - ext4 /dev/sda1 rw", expectedContainerId: "a1551a1d7e1881d6c18d2c9ec462cab6ad3666825f0adb2098e9d5b198fd7e19", - cgroupVersion: ContainerDetector.ParseMode.V2), + cgroupVersion: ParseMode.V2), new( name: "cgroupv2 with minikube docker mountinfo2", line: "929 920 254:1 /docker/volumes/minikube/_data/lib/docker/containers/0eaa6718003210b6520f7e82d14b4c8d4743057a958a503626240f8d1900bc33/hostname /etc/hostname rw,relatime - ext4 /dev/vda1 rw", expectedContainerId: "0eaa6718003210b6520f7e82d14b4c8d4743057a958a503626240f8d1900bc33", - cgroupVersion: ContainerDetector.ParseMode.V2), + cgroupVersion: ParseMode.V2), new( name: "cgroupv2 with podman mountinfo", line: "1096 1088 0:104 /containers/overlay-containers/1a2de27e7157106568f7e081e42a8c14858c02bd9df30d6e352b298178b46809/userdata/hostname /etc/hostname rw,nosuid,nodev,relatime - tmpfs tmpfs rw,size=813800k,nr_inodes=203450,mode=700,uid=1000,gid=1000", expectedContainerId: "1a2de27e7157106568f7e081e42a8c14858c02bd9df30d6e352b298178b46809", - cgroupVersion: ContainerDetector.ParseMode.V2) + cgroupVersion: ParseMode.V2) ]; @@ -80,12 +85,12 @@ public class ContainerDetectorTests new( name: "Invalid cgroupv1 line", line: "13:name=systemd:/podruntime/docker/kubepods/ac679f8a8319c8cf7d38e1adf263bc08d23zzzz", - cgroupVersion: ContainerDetector.ParseMode.V1), + cgroupVersion: ParseMode.V1), new( name: "Invalid hex cgroupv2 line (contains a z)", line: "13:name=systemd:/var/lib/containerd/io.containerd.grpc.v1.cri/sandboxes/fb5916a02feca96bdeecd8e062df9e5e51d6617c8214b5e1f3fz9320f4402ae6/hostname", - cgroupVersion: ContainerDetector.ParseMode.V2) + cgroupVersion: ParseMode.V2) ]; @@ -101,7 +106,7 @@ public void TestValidContainer() tempFile.Write(testCase.Line); Assert.Equal( testCase.ExpectedContainerId, - GetContainerId(containerDetector.BuildResource(tempFile.FilePath, testCase.CgroupVersion))); + containerDetector.ExtractContainerId(tempFile.FilePath, testCase.CgroupVersion)); } } @@ -115,9 +120,8 @@ public void TestInvalidContainer() { using var tempFile = new TempFile(); tempFile.Write(testCase.Line); - Assert.Equal( - containerDetector.BuildResource(tempFile.FilePath, ContainerDetector.ParseMode.V2), - Resource.Empty); + Assert.Null( + containerDetector.ExtractContainerId(tempFile.FilePath, ParseMode.V2)); } // Valid in cgroupv1 is not valid in cgroupv1 @@ -125,9 +129,8 @@ public void TestInvalidContainer() { using var tempFile = new TempFile(); tempFile.Write(testCase.Line); - Assert.Equal( - containerDetector.BuildResource(tempFile.FilePath, ContainerDetector.ParseMode.V1), - Resource.Empty); + Assert.Null( + containerDetector.ExtractContainerId(tempFile.FilePath, ParseMode.V1)); } // test invalid cases @@ -135,23 +138,28 @@ public void TestInvalidContainer() { using var tempFile = new TempFile(); tempFile.Write(testCase.Line); - Assert.Equal(containerDetector.BuildResource(tempFile.FilePath, testCase.CgroupVersion), Resource.Empty); + Assert.Null(containerDetector.ExtractContainerId(tempFile.FilePath, testCase.CgroupVersion)); } // test invalid file - Assert.Equal(containerDetector.BuildResource(Path.GetTempPath(), ContainerDetector.ParseMode.V1), Resource.Empty); - Assert.Equal(containerDetector.BuildResource(Path.GetTempPath(), ContainerDetector.ParseMode.V2), Resource.Empty); + Assert.Null(containerDetector.ExtractContainerId(Path.GetTempPath(), ParseMode.V1)); + Assert.Null(containerDetector.ExtractContainerId(Path.GetTempPath(), ParseMode.V2)); } - private static string GetContainerId(Resource resource) + [Fact] + public async Task TestK8sContainerId() { - var resourceAttributes = resource.Attributes.ToDictionary(x => x.Key, x => x.Value); - return resourceAttributes[ContainerSemanticConventions.AttributeContainerId].ToString()!; + await using (_ = new MockK8sEndpoint("k8s/pod-response.json")) + { + var resourceAttributes = new ContainerDetector(new MockK8sMetadataFetcher()).Detect().Attributes.ToDictionary(x => x.Key, x => x.Value); + + Assert.Equal(resourceAttributes[ContainerSemanticConventions.AttributeContainerId], "96724c05fa1be8d313f6db0e9872ca542b076839c4fd51ea4912a670ef538cbd"); + } } private sealed class TestCase { - public TestCase(string name, string line, ContainerDetector.ParseMode cgroupVersion, string? expectedContainerId = null) + public TestCase(string name, string line, ParseMode cgroupVersion, string? expectedContainerId = null) { this.Name = name; this.Line = line; @@ -165,6 +173,83 @@ public TestCase(string name, string line, ContainerDetector.ParseMode cgroupVers public string? ExpectedContainerId { get; } - public ContainerDetector.ParseMode CgroupVersion { get; } + public ParseMode CgroupVersion { get; } + } + + private class MockK8sEndpoint : IAsyncDisposable + { + public readonly Uri Address; + private readonly IWebHost server; + + public MockK8sEndpoint(string responseJsonPath) + { + this.server = new WebHostBuilder() + .UseKestrel() + .UseUrls("http://127.0.0.1:5000") + .Configure(app => + { + app.Run(async context => + { + if (context.Request.Method == HttpMethods.Get && context.Request.Path == "/api/v1/namespaces/default/pods/pod1") + { + var content = await File.ReadAllTextAsync($"{Environment.CurrentDirectory}/{responseJsonPath}"); + var data = Encoding.UTF8.GetBytes(content); + context.Response.ContentType = "application/json"; + await context.Response.Body.WriteAsync(data); + } + else + { + context.Response.StatusCode = StatusCodes.Status404NotFound; + await context.Response.WriteAsync("Not found"); + } + }); + }).Build(); + this.server.Start(); + + this.Address = new Uri(this.server.ServerFeatures.Get()!.Addresses.First()); + } + + public async ValueTask DisposeAsync() + { + await this.DisposeAsyncCore(); + } + + protected virtual async ValueTask DisposeAsyncCore() + { + await this.server.StopAsync(); + } + } + + private sealed class MockK8sMetadataFetcher : IK8sMetadataFetcher + { + public string? GetApiCredential() + { + return Guid.NewGuid().ToString(); + } + + public string? GetContainerName() + { + return "my-container"; + } + + public string? GetHostname() + { + return "hostname"; + } + + public string? GetNamespace() + { + return "default"; + } + + public string? GetPodName() + { + return "pod1"; + } + + public string? GetServiceBaseUrl() + { + return "http://127.0.0.1:5000"; + } } } diff --git a/test/OpenTelemetry.Resources.Container.Tests/OpenTelemetry.Resources.Container.Tests.csproj b/test/OpenTelemetry.Resources.Container.Tests/OpenTelemetry.Resources.Container.Tests.csproj index 27337cec83..f903e1b7ab 100644 --- a/test/OpenTelemetry.Resources.Container.Tests/OpenTelemetry.Resources.Container.Tests.csproj +++ b/test/OpenTelemetry.Resources.Container.Tests/OpenTelemetry.Resources.Container.Tests.csproj @@ -6,8 +6,18 @@ Unit test project for Container Detector for OpenTelemetry. + + + + + + + PreserveNewest + + + diff --git a/test/OpenTelemetry.Resources.Container.Tests/k8s/pod-response.json b/test/OpenTelemetry.Resources.Container.Tests/k8s/pod-response.json new file mode 100644 index 0000000000..b82a3e228a --- /dev/null +++ b/test/OpenTelemetry.Resources.Container.Tests/k8s/pod-response.json @@ -0,0 +1,289 @@ +{ + "kind": "Pod", + "apiVersion": "v1", + "metadata": { + "name": "my-deployment-8d7c572790-46mgg", + "generateName": "my-deployment-8d7c572790-", + "namespace": "default", + "uid": "e393dfee-0ac3-4dd9-a10d-e748c0e3589a", + "resourceVersion": "14782", + "creationTimestamp": "2024-05-23T09:30:45Z", + "labels": { + "app": "my-app", + "pod-template-hash": "8d7c572790" + }, + "ownerReferences": [ + { + "apiVersion": "apps/v1", + "kind": "ReplicaSet", + "name": "my-deployment-8d7c572790", + "uid": "4aebb45f-c8b0-4406-8b81-3cfc9619d147", + "controller": true, + "blockOwnerDeletion": true + } + ], + "managedFields": [ + { + "manager": "kube-controller-manager", + "operation": "Update", + "apiVersion": "v1", + "time": "2024-05-23T09:30:45Z", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:metadata": { + "f:generateName": {}, + "f:labels": { + ".": {}, + "f:app": {}, + "f:pod-template-hash": {} + }, + "f:ownerReferences": { + ".": {}, + "k:{\"uid\":\"4aebb45f-c8b0-4406-8b81-3cfc9619d147\"}": {} + } + }, + "f:spec": { + "f:containers": { + "k:{\"name\":\"my-container\"}": { + ".": {}, + "f:env": { + ".": {}, + "k:{\"name\":\"KUBERNETES_CONTAINER_NAME\"}": { + ".": {}, + "f:name": {}, + "f:value": {} + } + }, + "f:image": {}, + "f:imagePullPolicy": {}, + "f:name": {}, + "f:resources": {}, + "f:terminationMessagePath": {}, + "f:terminationMessagePolicy": {} + } + }, + "f:dnsPolicy": {}, + "f:enableServiceLinks": {}, + "f:restartPolicy": {}, + "f:schedulerName": {}, + "f:securityContext": {}, + "f:serviceAccount": {}, + "f:serviceAccountName": {}, + "f:terminationGracePeriodSeconds": {} + } + } + }, + { + "manager": "kubelet", + "operation": "Update", + "apiVersion": "v1", + "time": "2024-05-23T09:30:47Z", + "fieldsType": "FieldsV1", + "fieldsV1": { + "f:status": { + "f:conditions": { + "k:{\"type\":\"ContainersReady\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Initialized\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"PodReadyToStartContainers\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + }, + "k:{\"type\":\"Ready\"}": { + ".": {}, + "f:lastProbeTime": {}, + "f:lastTransitionTime": {}, + "f:status": {}, + "f:type": {} + } + }, + "f:containerStatuses": {}, + "f:hostIP": {}, + "f:hostIPs": {}, + "f:phase": {}, + "f:podIP": {}, + "f:podIPs": { + ".": {}, + "k:{\"ip\":\"10.244.0.11\"}": { + ".": {}, + "f:ip": {} + } + }, + "f:startTime": {} + } + }, + "subresource": "status" + } + ] + }, + "spec": { + "volumes": [ + { + "name": "kube-api-access-npqxx", + "projected": { + "sources": [ + { + "serviceAccountToken": { + "expirationSeconds": 3607, + "path": "token" + } + }, + { + "configMap": { + "name": "kube-root-ca.crt", + "items": [ + { + "key": "ca.crt", + "path": "ca.crt" + } + ] + } + }, + { + "downwardAPI": { + "items": [ + { + "path": "namespace", + "fieldRef": { + "apiVersion": "v1", + "fieldPath": "metadata.namespace" + } + } + ] + } + } + ], + "defaultMode": 420 + } + } + ], + "containers": [ + { + "name": "my-container", + "image": "app:latest", + "env": [ + { + "name": "KUBERNETES_CONTAINER_NAME", + "value": "my-container" + } + ], + "resources": {}, + "volumeMounts": [ + { + "name": "kube-api-access-npqxx", + "readOnly": true, + "mountPath": "/var/run/secrets/kubernetes.io/serviceaccount" + } + ], + "terminationMessagePath": "/dev/termination-log", + "terminationMessagePolicy": "File", + "imagePullPolicy": "Never" + } + ], + "restartPolicy": "Always", + "terminationGracePeriodSeconds": 30, + "dnsPolicy": "ClusterFirst", + "serviceAccountName": "my-service-account", + "serviceAccount": "my-service-account", + "nodeName": "minikube", + "securityContext": {}, + "schedulerName": "default-scheduler", + "tolerations": [ + { + "key": "node.kubernetes.io/not-ready", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + }, + { + "key": "node.kubernetes.io/unreachable", + "operator": "Exists", + "effect": "NoExecute", + "tolerationSeconds": 300 + } + ], + "priority": 0, + "enableServiceLinks": true, + "preemptionPolicy": "PreemptLowerPriority" + }, + "status": { + "phase": "Running", + "conditions": [ + { + "type": "PodReadyToStartContainers", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2024-05-23T09:30:47Z" + }, + { + "type": "Initialized", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2024-05-23T09:30:45Z" + }, + { + "type": "Ready", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2024-05-23T09:30:47Z" + }, + { + "type": "ContainersReady", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2024-05-23T09:30:47Z" + }, + { + "type": "PodScheduled", + "status": "True", + "lastProbeTime": null, + "lastTransitionTime": "2024-05-23T09:30:45Z" + } + ], + "hostIP": "192.168.49.1", + "hostIPs": [ + { + "ip": "192.168.49.1" + } + ], + "podIP": "10.244.0.11", + "podIPs": [ + { + "ip": "10.244.0.11" + } + ], + "startTime": "2024-05-23T09:30:45Z", + "containerStatuses": [ + { + "name": "my-container", + "state": { + "running": { + "startedAt": "2024-05-23T09:30:46Z" + } + }, + "lastState": {}, + "ready": true, + "restartCount": 0, + "image": "app:latest", + "imageID": "docker://sha256:7f8ec1d205a9364bf760b398cd21ae547ea08fd6213b594c64da80735bcf1e29", + "containerID": "docker://96724c05fa1be8d313f6db0e9872ca542b076839c4fd51ea4912a670ef538cbd", + "started": true + } + ], + "qosClass": "BestEffort" + } +}