diff --git a/.codespellrc b/.codespellrc index 9087e5816c..2a554b4841 100644 --- a/.codespellrc +++ b/.codespellrc @@ -1,4 +1,4 @@ [codespell] # skipping auto generated folders skip = ./.tox,./.mypy_cache,./docs/_build,./target,*/LICENSE,./venv,*/cassettes -ignore-words-list = ot +ignore-words-list = aks,ot diff --git a/resource/opentelemetry-resource-detector-azure/.changelog/5012.added b/resource/opentelemetry-resource-detector-azure/.changelog/5012.added new file mode 100644 index 0000000000..81646afd4d --- /dev/null +++ b/resource/opentelemetry-resource-detector-azure/.changelog/5012.added @@ -0,0 +1 @@ +`opentelemetry-resource-detector-azure`: add AKS resource detector diff --git a/resource/opentelemetry-resource-detector-azure/README.rst b/resource/opentelemetry-resource-detector-azure/README.rst index 49749f31a6..0515c6d5f3 100644 --- a/resource/opentelemetry-resource-detector-azure/README.rst +++ b/resource/opentelemetry-resource-detector-azure/README.rst @@ -7,6 +7,7 @@ OpenTelemetry Resource detectors for Azure :target: https://pypi.org/project/opentelemetry-resource-detector-azure/ This library contains OpenTelemetry `Resource Detectors `_ for the following Azure resources: + * `Azure Kubernetes Service `_ * `Azure App Service `_ * `Azure Virtual Machines `_ * `Azure Functions (Experimental) `_ @@ -26,10 +27,9 @@ Usage example for ``opentelemetry-resource-detector-azure`` from opentelemetry import trace from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.resource.detector.azure.app_service import ( + from opentelemetry.resource.detector.azure import ( + AzureAKSResourceDetector, AzureAppServiceResourceDetector, - ) - from opentelemetry.resource.detector.azure.vm import ( AzureVMResourceDetector, ) from opentelemetry.sdk.resources import get_aggregated_resources @@ -39,6 +39,7 @@ Usage example for ``opentelemetry-resource-detector-azure`` TracerProvider( resource=get_aggregated_resources( [ + AzureAKSResourceDetector(), AzureAppServiceResourceDetector(), AzureVMResourceDetector(), ] @@ -49,6 +50,43 @@ Usage example for ``opentelemetry-resource-detector-azure`` Mappings -------- +The Azure Kubernetes Service Resource Detector reads the cluster resource ID from the +``CLUSTER_RESOURCE_ID`` environment variable or from a mounted ``aks-cluster-metadata`` +ConfigMap at ``/etc/kubernetes/aks-cluster-metadata``. It sets the following Resource +Attributes: + + * ``cloud.platform`` set to ``azure_aks``. + * ``cloud.provider`` set to ``azure``. + * ``cloud.resource_id`` set to the full Azure Resource Manager cluster resource ID. + +The native AKS ConfigMap is named ``aks-cluster-metadata`` and contains a +``clusterResourceId`` key. It can be exposed to a pod as an environment variable: + +.. code-block:: yaml + + env: + - name: CLUSTER_RESOURCE_ID + valueFrom: + configMapKeyRef: + name: aks-cluster-metadata + key: clusterResourceId + +Alternatively, mount the ConfigMap as a volume: + +.. code-block:: yaml + + volumes: + - name: aks-cluster-metadata + configMap: + name: aks-cluster-metadata + volumeMounts: + - name: aks-cluster-metadata + mountPath: /etc/kubernetes/aks-cluster-metadata + +Kubernetes resolves ConfigMap references within the pod's namespace. Because the native +ConfigMap is in ``kube-public``, copy it into the workload namespace or use tooling such +as an init container to expose its value through one of the supported locations. + The Azure App Service Resource Detector sets the following Resource Attributes: * ``service.name`` set to the value of the ``WEBSITE_SITE_NAME`` environment variable. * ``cloud.platform`` set to ``azure_app_service``. diff --git a/resource/opentelemetry-resource-detector-azure/pyproject.toml b/resource/opentelemetry-resource-detector-azure/pyproject.toml index 214b2f7c1c..226e124945 100644 --- a/resource/opentelemetry-resource-detector-azure/pyproject.toml +++ b/resource/opentelemetry-resource-detector-azure/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ ] [project.entry-points.opentelemetry_resource_detector] +azure_aks = "opentelemetry.resource.detector.azure.aks:AzureAKSResourceDetector" azure_app_service = "opentelemetry.resource.detector.azure.app_service:AzureAppServiceResourceDetector" azure_functions = "opentelemetry.resource.detector.azure.functions:AzureFunctionsResourceDetector" azure_vm = "opentelemetry.resource.detector.azure.vm:AzureVMResourceDetector" diff --git a/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/__init__.py b/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/__init__.py index 56968843e5..08534b0439 100644 --- a/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/__init__.py +++ b/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/__init__.py @@ -3,12 +3,14 @@ # pylint: disable=import-error +from .aks import AzureAKSResourceDetector from .app_service import AzureAppServiceResourceDetector from .functions import AzureFunctionsResourceDetector from .version import __version__ from .vm import AzureVMResourceDetector __all__ = [ + "AzureAKSResourceDetector", "AzureAppServiceResourceDetector", "AzureFunctionsResourceDetector", "AzureVMResourceDetector", diff --git a/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/_constants.py b/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/_constants.py index 478ca97a24..35239735a7 100644 --- a/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/_constants.py +++ b/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/_constants.py @@ -8,6 +8,9 @@ # Azure Kubernetes _AKS_ARM_NAMESPACE_ID = "AKS_ARM_NAMESPACE_ID" +_AKS_CLUSTER_RESOURCE_ID = "CLUSTER_RESOURCE_ID" +_AKS_CLUSTER_RESOURCE_ID_KEY = "clusterResourceId" +_AKS_METADATA_FILE_PATH = "/etc/kubernetes/aks-cluster-metadata" # AppService diff --git a/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/_utils.py b/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/_utils.py index 30f516dc69..7dd483f19b 100644 --- a/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/_utils.py +++ b/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/_utils.py @@ -1,9 +1,12 @@ # Copyright The OpenTelemetry Authors # SPDX-License-Identifier: Apache-2.0 from os import environ +from pathlib import Path from ._constants import ( _AKS_ARM_NAMESPACE_ID, + _AKS_CLUSTER_RESOURCE_ID, + _AKS_METADATA_FILE_PATH, _FUNCTIONS_WORKER_RUNTIME, _WEBSITE_OWNER_NAME, _WEBSITE_RESOURCE_GROUP, @@ -12,7 +15,11 @@ def _is_on_aks() -> bool: - return environ.get(_AKS_ARM_NAMESPACE_ID) is not None + return ( + environ.get(_AKS_ARM_NAMESPACE_ID) is not None + or environ.get(_AKS_CLUSTER_RESOURCE_ID) is not None + or Path(_AKS_METADATA_FILE_PATH).exists() + ) def _is_on_app_service() -> bool: diff --git a/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/aks.py b/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/aks.py new file mode 100644 index 0000000000..d34ce31928 --- /dev/null +++ b/resource/opentelemetry-resource-detector-azure/src/opentelemetry/resource/detector/azure/aks.py @@ -0,0 +1,86 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +from logging import getLogger +from os import environ +from pathlib import Path + +from opentelemetry.sdk.resources import Resource, ResourceDetector + +from ._constants import ( + _AKS_CLUSTER_RESOURCE_ID, + _AKS_CLUSTER_RESOURCE_ID_KEY, + _AKS_METADATA_FILE_PATH, +) + +_logger = getLogger(__name__) + +_CLOUD_ACCOUNT_ID = "cloud.account.id" +_CLOUD_PLATFORM = "cloud.platform" +_CLOUD_PROVIDER = "cloud.provider" +_CLOUD_RESOURCE_ID = "cloud.resource_id" + + +def _extract_subscription_id(resource_id: str) -> str | None: + segments = resource_id.split("/") + for index, segment in enumerate(segments): + if segment.lower() == "subscriptions" and index < len(segments) - 1: + return segments[index + 1] or None + return None + + +def _parse_aks_metadata(content: str) -> str | None: + keyed_resource_id: str | None = None + bare_values: list[str] = [] + + for line in content.splitlines(): + stripped_line = line.strip().lstrip("\ufeff") + if not stripped_line or stripped_line.startswith("#"): + continue + + key, separator, value = stripped_line.partition("=") + if not separator: + bare_values.append(stripped_line) + elif key.strip() == _AKS_CLUSTER_RESOURCE_ID_KEY and value.strip(): + keyed_resource_id = value.strip() + + if keyed_resource_id: + return keyed_resource_id + if len(bare_values) == 1: + return bare_values[0] + return None + + +def _get_aks_metadata_from_file() -> str | None: + metadata_path = Path(_AKS_METADATA_FILE_PATH) + try: + if metadata_path.is_dir(): + metadata_path = metadata_path / _AKS_CLUSTER_RESOURCE_ID_KEY + content = metadata_path.read_text(encoding="utf-8") + except (OSError, UnicodeError): + _logger.debug( + "Failed to read AKS metadata from %s", + metadata_path, + exc_info=True, + ) + return None + + return _parse_aks_metadata(content) + + +class AzureAKSResourceDetector(ResourceDetector): + def detect(self) -> Resource: + resource_id = environ.get(_AKS_CLUSTER_RESOURCE_ID) or _get_aks_metadata_from_file() + if not resource_id: + return Resource({}) + + attributes = { + _CLOUD_PROVIDER: "azure", + _CLOUD_PLATFORM: "azure.aks", + _CLOUD_RESOURCE_ID: resource_id, + } + subscription_id = _extract_subscription_id(resource_id) + if subscription_id: + attributes[_CLOUD_ACCOUNT_ID] = subscription_id + + return Resource(attributes) diff --git a/resource/opentelemetry-resource-detector-azure/tests/test_aks.py b/resource/opentelemetry-resource-detector-azure/tests/test_aks.py new file mode 100644 index 0000000000..161958c98b --- /dev/null +++ b/resource/opentelemetry-resource-detector-azure/tests/test_aks.py @@ -0,0 +1,182 @@ +# Copyright The OpenTelemetry Authors +# SPDX-License-Identifier: Apache-2.0 + +import unittest +from collections.abc import Mapping +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +from opentelemetry.resource.detector.azure._utils import _is_on_aks +from opentelemetry.resource.detector.azure.aks import ( + AzureAKSResourceDetector, +) +from opentelemetry.resource.detector.azure.vm import AzureVMResourceDetector + +TEST_RESOURCE_ID = ( + "/subscriptions/test-sub/resourceGroups/test-rg/providers/" + "Microsoft.ContainerService/managedClusters/test-aks-cluster" +) + + +class TestAzureAKSResourceDetector(unittest.TestCase): + @patch.dict("os.environ", {"CLUSTER_RESOURCE_ID": TEST_RESOURCE_ID}, clear=True) + def test_detects_aks_from_environment(self) -> None: + attributes = AzureAKSResourceDetector().detect().attributes + + self.assertEqual(attributes["cloud.provider"], "azure") + self.assertEqual(attributes["cloud.platform"], "azure.aks") + self.assertEqual(attributes["cloud.resource_id"], TEST_RESOURCE_ID) + self.assertEqual(attributes["cloud.account.id"], "test-sub") + self.assertNotIn("k8s.cluster.name", attributes) + self.assertIsInstance(attributes["cloud.provider"], str) + self.assertIsInstance(attributes["cloud.platform"], str) + self.assertIsInstance(attributes["cloud.resource_id"], str) + self.assertIsInstance(attributes["cloud.account.id"], str) + + @patch.dict( + "os.environ", + { + "CLUSTER_RESOURCE_ID": ( + "/Subscriptions/test-sub/resourceGroups/test-rg/providers/" + "Microsoft.ContainerService/ManagedClusters/my-cluster" + ) + }, + clear=True, + ) + def test_subscription_segment_is_case_insensitive(self) -> None: + attributes = AzureAKSResourceDetector().detect().attributes + + self.assertEqual(attributes["cloud.account.id"], "test-sub") + + @patch.dict("os.environ", {"CLUSTER_RESOURCE_ID": "standalone-name"}, clear=True) + def test_omits_account_id_without_subscription_segment(self) -> None: + attributes = AzureAKSResourceDetector().detect().attributes + + self.assertNotIn("cloud.account.id", attributes) + + @patch.dict("os.environ", {}, clear=True) + @patch( + "opentelemetry.resource.detector.azure.aks._AKS_METADATA_FILE_PATH", + "/missing/aks-cluster-metadata", + ) + def test_returns_empty_resource_outside_aks(self) -> None: + resource = AzureAKSResourceDetector().detect() + + self.assertEqual(resource.attributes, {}) + + @patch.dict("os.environ", {}, clear=True) + def test_detects_aks_from_configmap_volume(self) -> None: + with TemporaryDirectory() as directory: + metadata_path = Path(directory) / "aks-cluster-metadata" + metadata_path.mkdir() + (metadata_path / "clusterResourceId").write_text(f"{TEST_RESOURCE_ID}\n", encoding="utf-8") + + with patch( + "opentelemetry.resource.detector.azure.aks._AKS_METADATA_FILE_PATH", + str(metadata_path), + ): + attributes = AzureAKSResourceDetector().detect().attributes + + self.assertEqual(attributes["cloud.resource_id"], TEST_RESOURCE_ID) + + @patch.dict("os.environ", {}, clear=True) + def test_detects_aks_from_subpath_mount(self) -> None: + with TemporaryDirectory() as directory: + metadata_path = Path(directory) / "aks-cluster-metadata" + metadata_path.write_text(f"{TEST_RESOURCE_ID}\n", encoding="utf-8") + + with patch( + "opentelemetry.resource.detector.azure.aks._AKS_METADATA_FILE_PATH", + str(metadata_path), + ): + attributes = AzureAKSResourceDetector().detect().attributes + + self.assertEqual(attributes["cloud.resource_id"], TEST_RESOURCE_ID) + + @patch.dict("os.environ", {}, clear=True) + def test_detects_aks_from_key_value_file(self) -> None: + content = f"\ufeff# AKS metadata\r\nclusterResourceId={TEST_RESOURCE_ID}\r\n" + + attributes = self._detect_from_file(content) + + self.assertEqual(attributes["cloud.resource_id"], TEST_RESOURCE_ID) + + @patch.dict("os.environ", {}, clear=True) + def test_explicit_key_wins_over_bare_lines(self) -> None: + content = f"clusterResourceId={TEST_RESOURCE_ID}\nstray-token\n// not a supported comment\n" + + attributes = self._detect_from_file(content) + + self.assertEqual(attributes["cloud.resource_id"], TEST_RESOURCE_ID) + + @patch.dict("os.environ", {}, clear=True) + def test_ignores_ambiguous_bare_values(self) -> None: + attributes = self._detect_from_file(f"{TEST_RESOURCE_ID}\nstray-token\n") + + self.assertEqual(attributes, {}) + + @patch.dict("os.environ", {}, clear=True) + def test_ignores_configmap_volume_without_resource_id(self) -> None: + with TemporaryDirectory() as directory: + metadata_path = Path(directory) / "aks-cluster-metadata" + metadata_path.mkdir() + (metadata_path / "somethingElse").write_text("value\n", encoding="utf-8") + + with patch( + "opentelemetry.resource.detector.azure.aks._AKS_METADATA_FILE_PATH", + str(metadata_path), + ): + attributes = AzureAKSResourceDetector().detect().attributes + + self.assertEqual(attributes, {}) + + @patch.dict( + "os.environ", + { + "CLUSTER_RESOURCE_ID": ( + "/subscriptions/test-sub/resourceGroups/test-rg/providers/" + "Microsoft.ContainerService/managedClusters/from-env" + ) + }, + clear=True, + ) + def test_environment_takes_precedence_over_file(self) -> None: + attributes = self._detect_from_file(TEST_RESOURCE_ID) + + self.assertEqual( + attributes["cloud.resource_id"], + "/subscriptions/test-sub/resourceGroups/test-rg/providers/" + "Microsoft.ContainerService/managedClusters/from-env", + ) + + @patch.dict("os.environ", {"CLUSTER_RESOURCE_ID": TEST_RESOURCE_ID}, clear=True) + @patch("opentelemetry.resource.detector.azure.vm.urlopen") + def test_vm_detection_is_skipped_on_aks(self, mock_urlopen) -> None: + resource = AzureVMResourceDetector().detect() + + self.assertEqual(resource.attributes, {}) + mock_urlopen.assert_not_called() + + @patch.dict("os.environ", {}, clear=True) + def test_mounted_metadata_marks_environment_as_aks(self) -> None: + with TemporaryDirectory() as directory: + metadata_path = Path(directory) / "aks-cluster-metadata" + metadata_path.write_text(TEST_RESOURCE_ID, encoding="utf-8") + + with patch( + "opentelemetry.resource.detector.azure._utils._AKS_METADATA_FILE_PATH", + str(metadata_path), + ): + self.assertTrue(_is_on_aks()) + + @staticmethod + def _detect_from_file(content: str) -> Mapping[str, object]: + with TemporaryDirectory() as directory: + metadata_path = Path(directory) / "aks-cluster-metadata" + metadata_path.write_text(content, encoding="utf-8") + with patch( + "opentelemetry.resource.detector.azure.aks._AKS_METADATA_FILE_PATH", + str(metadata_path), + ): + return AzureAKSResourceDetector().detect().attributes