diff --git a/src/ApplicationInsights.Kubernetes/ContainerIdProviders/ContainerIdNormalizer.cs b/src/ApplicationInsights.Kubernetes/ContainerIdProviders/ContainerIdNormalizer.cs new file mode 100644 index 00000000..3da7689a --- /dev/null +++ b/src/ApplicationInsights.Kubernetes/ContainerIdProviders/ContainerIdNormalizer.cs @@ -0,0 +1,57 @@ +#nullable enable + +using System; +using System.Text.RegularExpressions; +using Microsoft.ApplicationInsights.Kubernetes.Debugging; + +namespace Microsoft.ApplicationInsights.Kubernetes.ContainerIdProviders; + +/// +/// A simple container id normalizer that picks out 64 digits of GUID/UUID from a container id with prefix / suffix. +/// For example: +/// cri-containerd-5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06.scope will be normalized to +/// 5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06 +/// +internal class ContainerIdNormalizer : IContainerIdNormalizer +{ + // Simple rule: First 64-characters GUID/UUID. + private const string ContainerIdIdentifierPattern = @"([a-f\d]{64})"; + private readonly Regex _matcher = new Regex(ContainerIdIdentifierPattern, RegexOptions.IgnoreCase | RegexOptions.CultureInvariant, matchTimeout: TimeSpan.FromSeconds(1)); + private readonly ApplicationInsightsKubernetesDiagnosticSource _logger = ApplicationInsightsKubernetesDiagnosticSource.Instance; + + /// + /// Gets normalized container id. + /// + /// The original container id. String.Empty yields string.Empty with true. Null is not accepted. + /// The normalized container id. + /// True when the normalized succeeded. False otherwise. + public bool TryNormalize(string input, out string? normalized) + { + // Should not happen. Put here just in case. + if (input is null) + { + throw new ArgumentNullException(nameof(input)); + } + + // Special case: string.Empty in, string.Empty out. + if (input == string.Empty) + { + normalized = string.Empty; + return true; + } + + _logger.LogDebug($"Normalize container id: {input}"); + + Match match = _matcher.Match(input); + if (!match.Success) + { + _logger.LogDebug($"Failed match any container id by pattern: {ContainerIdIdentifierPattern}."); + normalized = null; + return false; + } + normalized = match.Groups[1].Value; + _logger.LogTrace($"Container id normalized to: {normalized}"); + return true; + } +} + diff --git a/src/ApplicationInsights.Kubernetes/ContainerIdProviders/IContainerIdNormalizer.cs b/src/ApplicationInsights.Kubernetes/ContainerIdProviders/IContainerIdNormalizer.cs new file mode 100644 index 00000000..fc56de7a --- /dev/null +++ b/src/ApplicationInsights.Kubernetes/ContainerIdProviders/IContainerIdNormalizer.cs @@ -0,0 +1,17 @@ +#nullable enable + +namespace Microsoft.ApplicationInsights.Kubernetes.ContainerIdProviders; + +/// +/// A service to normalize container id. +/// +internal interface IContainerIdNormalizer +{ + /// + /// Tries to normalize container id. + /// + /// The container id. + /// The normalized container id. + /// True when normalized. False otherwise. + bool TryNormalize(string input, out string? normalized); +} diff --git a/src/ApplicationInsights.Kubernetes/Extensions/KubernetesServiceCollectionBuilder.cs b/src/ApplicationInsights.Kubernetes/Extensions/KubernetesServiceCollectionBuilder.cs index 86ca5b76..a39c43e9 100644 --- a/src/ApplicationInsights.Kubernetes/Extensions/KubernetesServiceCollectionBuilder.cs +++ b/src/ApplicationInsights.Kubernetes/Extensions/KubernetesServiceCollectionBuilder.cs @@ -106,6 +106,8 @@ protected virtual void RegisterSettingsProvider(IServiceCollection serviceCollec _logger.LogError("Unsupported OS."); } + serviceCollection.TryAddSingleton(); + // Notes: pay attention to the order. Injecting uses the order of registering in this case. // For backward compatibility, $APPINSIGHTS_KUBERNETES_POD_NAME has been agreed upon to allow customize pod name with downward API. serviceCollection.TryAddEnumerable(ServiceDescriptor.Singleton()); diff --git a/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpClientSettingsBase.cs b/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpClientSettingsBase.cs index d947a66f..cebbc90b 100644 --- a/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpClientSettingsBase.cs +++ b/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpClientSettingsBase.cs @@ -15,12 +15,14 @@ namespace Microsoft.ApplicationInsights.Kubernetes internal abstract class KubeHttpClientSettingsBase : IKubeHttpClientSettingsProvider { private readonly IEnumerable _containerIdProviders; + private readonly IContainerIdNormalizer _containerIdNormalizer; protected readonly ApplicationInsightsKubernetesDiagnosticSource _logger = ApplicationInsightsKubernetesDiagnosticSource.Instance; public KubeHttpClientSettingsBase( string? kubernetesServiceHost, string? kubernetesServicePort, - IEnumerable containerIdProviders) + IEnumerable containerIdProviders, + IContainerIdNormalizer containerIdNormalizer) { kubernetesServiceHost = kubernetesServiceHost ?? Environment.GetEnvironmentVariable(@"KUBERNETES_SERVICE_HOST"); if (string.IsNullOrEmpty(kubernetesServiceHost)) @@ -38,7 +40,7 @@ public KubeHttpClientSettingsBase( _logger.LogDebug("Kubernetes base address: {0}", baseAddress); ServiceBaseAddress = new Uri(baseAddress, UriKind.Absolute); _containerIdProviders = containerIdProviders ?? throw new ArgumentNullException(nameof(containerIdProviders)); - + _containerIdNormalizer = containerIdNormalizer ?? throw new ArgumentNullException(nameof(containerIdNormalizer)); ContainerId = GetContainerIdOrThrow(); } @@ -162,7 +164,12 @@ private string GetContainerIdOrThrow() { throw new InvalidOperationException("Valid containerId can't be null."); } - return containerId; + + if (!_containerIdNormalizer.TryNormalize(containerId, out string? normalized)) + { + throw new InvalidOperationException($"Container id format can't be recognized: {containerId}"); + } + return normalized!; } } throw new InvalidOperationException("Failed fetching container id."); diff --git a/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpClientSettingsProvider.cs b/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpClientSettingsProvider.cs index 05f77cc0..54c85052 100644 --- a/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpClientSettingsProvider.cs +++ b/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpClientSettingsProvider.cs @@ -15,19 +15,20 @@ internal class KubeHttpClientSettingsProvider : KubeHttpClientSettingsBase, IKub private readonly string _certFilePath; private readonly string _tokenFilePath; - public KubeHttpClientSettingsProvider(IEnumerable containerIdProviders) - : this(containerIdProviders, kubernetesServiceHost: null) + public KubeHttpClientSettingsProvider(IEnumerable containerIdProviders, IContainerIdNormalizer containerIdNormalizer) + : this(containerIdProviders, containerIdNormalizer, kubernetesServiceHost: null) { } public KubeHttpClientSettingsProvider( IEnumerable containerIdProviders, + IContainerIdNormalizer containerIdNormalizer, string pathToToken = @"/var/run/secrets/kubernetes.io/serviceaccount/token", string pathToCert = @"/var/run/secrets/kubernetes.io/serviceaccount/ca.crt", string pathToNamespace = @"/var/run/secrets/kubernetes.io/serviceaccount/namespace", string? kubernetesServiceHost = null, string? kubernetesServicePort = null) - : base(kubernetesServiceHost, kubernetesServicePort, containerIdProviders) + : base(kubernetesServiceHost, kubernetesServicePort, containerIdProviders, containerIdNormalizer) { _tokenFilePath = Arguments.IsNotNullOrEmpty(pathToToken, nameof(pathToToken)); _certFilePath = Arguments.IsNotNullOrEmpty(pathToCert, nameof(pathToCert)); diff --git a/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpSettingsWinContainerProvider.cs b/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpSettingsWinContainerProvider.cs index a3391905..91444017 100644 --- a/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpSettingsWinContainerProvider.cs +++ b/src/ApplicationInsights.Kubernetes/K8sHttpClient/KubeHttpSettingsWinContainerProvider.cs @@ -13,13 +13,14 @@ internal class KubeHttpSettingsWinContainerProvider : KubeHttpClientSettingsBase public KubeHttpSettingsWinContainerProvider( IEnumerable containerIdProviders, + IContainerIdNormalizer containerIdNormalizer, string serviceAccountFolder = @"C:\var\run\secrets\kubernetes.io\serviceaccount", string tokenFileName = "token", string certFileName = "ca.crt", string namespaceFileName = "namespace", string kubernetesServiceHost = null, string kubernetesServicePort = null) - : base(kubernetesServiceHost, kubernetesServicePort, containerIdProviders) + : base(kubernetesServiceHost, kubernetesServicePort, containerIdProviders, containerIdNormalizer) { // Container id won't be fetched for windows container. DirectoryInfo serviceAccountDirectory = diff --git a/tests/UnitTests/ContainerIdNormalizerTests.cs b/tests/UnitTests/ContainerIdNormalizerTests.cs new file mode 100644 index 00000000..aee11073 --- /dev/null +++ b/tests/UnitTests/ContainerIdNormalizerTests.cs @@ -0,0 +1,65 @@ +using System; +using Xunit; + +namespace Microsoft.ApplicationInsights.Kubernetes.ContainerIdProviders.Tests; + +public class ContainerIdNormalizerTests +{ + [Theory] + // With prefix and suffix: + [InlineData(@"cri-containerd-5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06.scope", "5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06")] + // With prefix only: + [InlineData(@"docker://5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06", "5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06")] + // With suffix only: + [InlineData(@"5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06-scope", "5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06")] + // Same as normalized: + [InlineData(@"5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06", "5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06")] + // Longer than 64 digits - notes: so that the match regex is simplified. + [InlineData(@"5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06a", "5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f06")] + public void TryGetNormalizedShouldNormalizeContainerIds(string input, string expected) + { + ContainerIdNormalizer target = new ContainerIdNormalizer(); + bool result = target.TryNormalize(input, out string actual); + + Assert.True(result); + Assert.Equal(expected, actual); + } + + [Theory] + // Input has no container id + [InlineData("Input has no container id")] + // Short guid is not accepted + [InlineData("f78375b1c487")] + // Shorter than 64 digits + [InlineData("5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f0")] + // Not a valid guid with character of z + [InlineData("5146b2bcd77ab4f2624bc1fbd98cf9751741344a80b043dbd77a4e847bff4f0z")] + public void TryGetNormalizedShouldNotAcceptInvalidContainerIds(string input) + { + ContainerIdNormalizer target = new ContainerIdNormalizer(); + bool result = target.TryNormalize(input, out string actual); + + Assert.False(result); + Assert.Null(actual); + } + + [Fact] + public void TryGetNormalizedShouldHandleStringEmpty() + { + ContainerIdNormalizer target = new ContainerIdNormalizer(); + bool result = target.TryNormalize(string.Empty, out string actual); + + Assert.True(result); + Assert.Equal(string.Empty, actual); + } + + [Fact] + public void TryGetNormalizedShouldDoesNotAcceptNull() + { + ContainerIdNormalizer target = new ContainerIdNormalizer(); + Assert.Throws(() => + { + _ = target.TryNormalize(null, out string actual); + }); + } +} diff --git a/tests/UnitTests/KuteHttpClientSettingsProviderTests.cs b/tests/UnitTests/KuteHttpClientSettingsProviderTests.cs index f43e17e3..9706a264 100644 --- a/tests/UnitTests/KuteHttpClientSettingsProviderTests.cs +++ b/tests/UnitTests/KuteHttpClientSettingsProviderTests.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Security.Cryptography.X509Certificates; using Microsoft.ApplicationInsights.Kubernetes.ContainerIdProviders; +using Moq; using Xunit; namespace Microsoft.ApplicationInsights.Kubernetes @@ -11,8 +12,10 @@ public class KuteHttpClientSettingsProviderTests [Fact(DisplayName = "Base address is formed by constructor")] public void BaseAddressShouldBeFormed() { + IContainerIdNormalizer containerIdNormalizer = new ContainerIdNormalizer(); IKubeHttpClientSettingsProvider target = new KubeHttpClientSettingsProvider( GetConatinerIdProviders(), + containerIdNormalizer, pathToNamespace: "namespace", kubernetesServiceHost: "127.0.0.1", kubernetesServicePort: "8001"); @@ -25,8 +28,10 @@ public void BaseAddressShouldBeFormed() [Fact(DisplayName = "Base address is formed by constructor of windows kube settings provider")] public void BaseAddressShouldBeFormedWin() { + IContainerIdNormalizer containerIdNormalizer = new ContainerIdNormalizer(); IKubeHttpClientSettingsProvider target = new KubeHttpSettingsWinContainerProvider( GetConatinerIdProviders(), + containerIdNormalizer, serviceAccountFolder: ".", namespaceFileName: "namespace", kubernetesServiceHost: "127.0.0.1", @@ -39,8 +44,10 @@ public void BaseAddressShouldBeFormedWin() [Fact(DisplayName = "Container id is set to string.Empty for windows container settings")] public void ContainerIdIsAlwaysNullForWinSettings() { + IContainerIdNormalizer containerIdNormalizer = new ContainerIdNormalizer(); IKubeHttpClientSettingsProvider target = new KubeHttpSettingsWinContainerProvider( GetConatinerIdProviders(), + containerIdNormalizer, serviceAccountFolder: ".", namespaceFileName: "namespace", kubernetesServiceHost: "127.0.0.1", @@ -52,8 +59,10 @@ public void ContainerIdIsAlwaysNullForWinSettings() public void TokenShoudBeFetched() { + IContainerIdNormalizer containerIdNormalizer = new ContainerIdNormalizer(); IKubeHttpClientSettingsProvider target = new KubeHttpClientSettingsProvider( GetConatinerIdProviders(), + containerIdNormalizer, pathToNamespace: "namespace", pathToToken: "token", kubernetesServiceHost: "127.0.0.1", @@ -64,8 +73,10 @@ public void TokenShoudBeFetched() [Fact(DisplayName = "Token can be fetched by windows settings provider")] public void TokenShouldBeFetchedForWin() { + IContainerIdNormalizer containerIdNormalizer = new ContainerIdNormalizer(); IKubeHttpClientSettingsProvider target = new KubeHttpSettingsWinContainerProvider( GetConatinerIdProviders(), + containerIdNormalizer, serviceAccountFolder: ".", namespaceFileName: "namespace", tokenFileName:"token", @@ -78,8 +89,10 @@ public void TokenShouldBeFetchedForWin() [Fact(DisplayName = "Return true when certificate chain is valid")] public void TrueWhenValidCertificate() { + IContainerIdNormalizer containerIdNormalizer = new ContainerIdNormalizer(); KubeHttpClientSettingsProvider target = new KubeHttpClientSettingsProvider( GetConatinerIdProviders(), + containerIdNormalizer, pathToNamespace: "namespace", kubernetesServiceHost: "127.0.0.1", kubernetesServicePort: "8001"); @@ -95,8 +108,10 @@ public void TrueWhenValidCertificate() [Fact(DisplayName = "Return false when certificate chain is invalid")] public void FalseWhenInvalidCertificate() { + IContainerIdNormalizer containerIdNormalizer = new ContainerIdNormalizer(); KubeHttpClientSettingsProvider target = new KubeHttpClientSettingsProvider( GetConatinerIdProviders(), + containerIdNormalizer, pathToNamespace: "namespace", kubernetesServiceHost: "127.0.0.1", kubernetesServicePort: "8001"); @@ -112,8 +127,10 @@ public void FalseWhenInvalidCertificate() [Fact(DisplayName = "Return false when certificate out of date")] public void FalseWhenOutOfDateCertificate() { + IContainerIdNormalizer containerIdNormalizer = new ContainerIdNormalizer(); KubeHttpClientSettingsProvider target = new KubeHttpClientSettingsProvider( GetConatinerIdProviders(), + containerIdNormalizer, pathToNamespace: "namespace", kubernetesServiceHost: "127.0.0.1", kubernetesServicePort: "8001");