From 77e97dd7f39a21f9007bef6fe49c33d6a6d21cbb Mon Sep 17 00:00:00 2001 From: Thiago Bertoldi Date: Thu, 8 Oct 2026 13:50:22 -0300 Subject: [PATCH] Handle optional OpenTelemetry resource identity in native mappings --- .github/workflows/stackpacks-ci.yml | 50 ++++- .../component-mappings/namespaces.sty | 38 ++-- .../component-mappings/service-instances.sty | 36 ++-- .../settings/component-mappings/services.sty | 27 ++- .../relation-mappings/executes-ecs-task.sty | 11 +- .../relation-mappings/executes-function.sty | 11 +- .../relation-mappings/executes-host.sty | 11 +- .../relation-mappings/k8s-pod-to-otel.sty | 18 +- .../relation-mappings/provided-by.sty | 9 +- stackpacks/open-telemetry/stackpack.yaml | 2 +- tests/open-telemetry/requirements.txt | 2 + .../open-telemetry/test_resource_identity.py | 175 ++++++++++++++++++ 12 files changed, 316 insertions(+), 74 deletions(-) create mode 100644 tests/open-telemetry/requirements.txt create mode 100644 tests/open-telemetry/test_resource_identity.py diff --git a/.github/workflows/stackpacks-ci.yml b/.github/workflows/stackpacks-ci.yml index ab4a90a..226f90f 100644 --- a/.github/workflows/stackpacks-ci.yml +++ b/.github/workflows/stackpacks-ci.yml @@ -26,6 +26,21 @@ concurrency: jobs: + test-resource-identity: + name: Test OpenTelemetry resource identity + runs-on: ubuntu-24.04 + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Evaluate mapping regression cases + run: | + python3 -m venv /tmp/otel-mapping-tests + /tmp/otel-mapping-tests/bin/pip install -r tests/open-telemetry/requirements.txt + /tmp/otel-mapping-tests/bin/python -m unittest discover -s tests/open-telemetry -v + discover-stackpacks: name: Discover stackpacks runs-on: ubuntu-24.04 @@ -80,20 +95,41 @@ jobs: STACKPACK: ${{ matrix.stackpack }} # Pinned snapshot of stackstate-server providing /opt/docker/bin/stack-pack-validator. # Bumped manually when validating a newer stackstate-server release. - VALIDATOR_IMAGE: quay.io/stackstate/stackstate-server:b7c3604-2.5 + VALIDATOR_IMAGE: quay.io/stackstate/stackstate-server:7.0.0-snapshot.20260919173254-master-5674676-2.5@sha256:237a6ffc5510c11b6e68aa0228365cac511b38a7d708df991e51bffa1f271b38 run: | - docker run --rm \ - -v "${PWD}:/workspace" \ - -w /workspace \ + docker run --rm --network none \ + --entrypoint /opt/docker/bin/stack-pack-validator \ + -v "${PWD}:/workspace:ro" \ "${VALIDATOR_IMAGE}" \ - /opt/docker/bin/stack-pack-validator -directory "/workspace/stackpacks/${STACKPACK}" + -directory "/workspace/stackpacks/${STACKPACK}" + + - name: Verify invalid CEL is rejected + if: matrix.stackpack == 'open-telemetry' + env: + VALIDATOR_IMAGE: quay.io/stackstate/stackstate-server:7.0.0-snapshot.20260919173254-master-5674676-2.5@sha256:237a6ffc5510c11b6e68aa0228365cac511b38a7d708df991e51bffa1f271b38 + run: | + probe=$(mktemp -d) + trap 'rm -rf "${probe}"' EXIT + cp -r stackpacks/open-telemetry "${probe}/stackpack" + sed -i 's/name: "vars.service"/name: "undeclared_otel_variable"/' \ + "${probe}/stackpack/settings/component-mappings/services.sty" + if docker run --rm --network none \ + --entrypoint /opt/docker/bin/stack-pack-validator \ + -v "${probe}/stackpack:/stackpack:ro" \ + "${VALIDATOR_IMAGE}" -directory /stackpack > "${probe}/validation.log" 2>&1; then + cat "${probe}/validation.log" + echo 'Validator accepted an invalid mapping expression' >&2 + exit 1 + fi + grep -F 'undeclared_otel_variable' "${probe}/validation.log" package: name: package (${{ matrix.stackpack }}) - needs: [discover-stackpacks, validate] + needs: [discover-stackpacks, validate, test-resource-identity] if: | always() && - needs.validate.result == 'success' + needs.validate.result == 'success' && + needs.test-resource-identity.result == 'success' runs-on: ubuntu-24.04 strategy: fail-fast: false diff --git a/stackpacks/open-telemetry/settings/component-mappings/namespaces.sty b/stackpacks/open-telemetry/settings/component-mappings/namespaces.sty index eebe21b..db8742f 100644 --- a/stackpacks/open-telemetry/settings/component-mappings/namespaces.sty +++ b/stackpacks/open-telemetry/settings/component-mappings/namespaces.sty @@ -1,7 +1,7 @@ nodes: - _type: "OtelComponentMapping" - name: "Kubernetes Namespace" - description: "Represents a logical service namespace in Kubernetes" + name: "OTel Service Namespace" + description: "Represents a logical service namespace" identifier: "urn:stackpack:open-telemetry:otel-component-mapping:namespace" input: signal: @@ -9,12 +9,16 @@ nodes: - "METRICS" resource: action: "'CREATE'" - condition: "'service.namespace' in resource.attributes" + condition: >- + ('service.name' in resource.attributes && resource.attributes['service.name'] != '') || + ('service.namespace' in resource.attributes && resource.attributes['service.namespace'] != '') vars: - name: "namespace" - value: "resource.attributes['service.namespace']" - - name: "clusterName" - value: "resource.attributes['k8s.cluster.name']" + value: >- + 'service.namespace' in resource.attributes && + resource.attributes['service.namespace'] != '' ? + resource.attributes['service.namespace'] : + 'default' output: identifier: "'urn:opentelemetry:namespace/' + vars.namespace" name: "vars.namespace" @@ -23,16 +27,26 @@ nodes: tags: - source: "'open-telemetry'" target: "stackpack" - - source: "vars.clusterName" - target: "cluster-name" optional: tags: - source: "resource.attributes['deployment.environment']" target: "deployment-environment" - - source: "vars.clusterName + '/' + resource.attributes['k8s.namespace.name']" - target: "k8s-scope" - - source: "resource.attributes['k8s.namespace.name']" - target: "namespace" + - source: >- + 'k8s.cluster.name' in resource.attributes && resource.attributes['k8s.cluster.name'] != '' ? + {'cluster-name': resource.attributes['k8s.cluster.name']} : {} + pattern: "(.*)" + target: "${1}" + - source: >- + 'k8s.cluster.name' in resource.attributes && resource.attributes['k8s.cluster.name'] != '' && + 'k8s.namespace.name' in resource.attributes && resource.attributes['k8s.namespace.name'] != '' ? + {'k8s-scope': resource.attributes['k8s.cluster.name'] + '/' + resource.attributes['k8s.namespace.name']} : {} + pattern: "(.*)" + target: "${1}" + - source: >- + 'k8s.namespace.name' in resource.attributes && resource.attributes['k8s.namespace.name'] != '' ? + {'namespace': resource.attributes['k8s.namespace.name']} : {} + pattern: "(.*)" + target: "${1}" expireAfter: 900000 rank: specificity: 100 diff --git a/stackpacks/open-telemetry/settings/component-mappings/service-instances.sty b/stackpacks/open-telemetry/settings/component-mappings/service-instances.sty index 07a4bc0..1be31d9 100644 --- a/stackpacks/open-telemetry/settings/component-mappings/service-instances.sty +++ b/stackpacks/open-telemetry/settings/component-mappings/service-instances.sty @@ -9,10 +9,16 @@ nodes: - "METRICS" resource: action: "'CREATE'" - condition: "'service.instance.id' in resource.attributes" + condition: >- + 'service.name' in resource.attributes && resource.attributes['service.name'] != '' && + 'service.instance.id' in resource.attributes && resource.attributes['service.instance.id'] != '' vars: - name: "namespace" - value: "resource.attributes['service.namespace']" + value: >- + 'service.namespace' in resource.attributes && + resource.attributes['service.namespace'] != '' ? + resource.attributes['service.namespace'] : + 'default' - name: "service" value: "resource.attributes['service.name']" - name: "version" @@ -24,8 +30,6 @@ nodes: value: "resource.attributes['service.instance.id']" - name: "instanceName" value: "resource.attributes['service.name'] + ' - ' + resource.attributes['service.instance.id']" - - name: "clusterName" - value: "resource.attributes['k8s.cluster.name']" output: identifier: >- 'urn:opentelemetry:namespace/' + vars.namespace + ':service/' + vars.service + ':serviceInstance/' + vars.instanceId @@ -40,19 +44,29 @@ nodes: target: "service.name" - source: "vars.namespace" target: "service.namespace" - - source: "vars.clusterName" - target: "cluster-name" - - source: "resource.attributes" + - source: "omit(resource.attributes, ['service.name', 'service.namespace'])" pattern: "(.*)" target: "${1}" optional: tags: - source: "resource.attributes['deployment.environment']" target: "deployment-environment" - - source: "vars.clusterName + '/' + resource.attributes['k8s.namespace.name']" - target: "k8s-scope" - - source: "resource.attributes['k8s.namespace.name']" - target: "namespace" + - source: >- + 'k8s.cluster.name' in resource.attributes && resource.attributes['k8s.cluster.name'] != '' ? + {'cluster-name': resource.attributes['k8s.cluster.name']} : {} + pattern: "(.*)" + target: "${1}" + - source: >- + 'k8s.cluster.name' in resource.attributes && resource.attributes['k8s.cluster.name'] != '' && + 'k8s.namespace.name' in resource.attributes && resource.attributes['k8s.namespace.name'] != '' ? + {'k8s-scope': resource.attributes['k8s.cluster.name'] + '/' + resource.attributes['k8s.namespace.name']} : {} + pattern: "(.*)" + target: "${1}" + - source: >- + 'k8s.namespace.name' in resource.attributes && resource.attributes['k8s.namespace.name'] != '' ? + {'namespace': resource.attributes['k8s.namespace.name']} : {} + pattern: "(.*)" + target: "${1}" expireAfter: 900000 rank: specificity: 210 diff --git a/stackpacks/open-telemetry/settings/component-mappings/services.sty b/stackpacks/open-telemetry/settings/component-mappings/services.sty index f1a1485..9d41b49 100644 --- a/stackpacks/open-telemetry/settings/component-mappings/services.sty +++ b/stackpacks/open-telemetry/settings/component-mappings/services.sty @@ -9,10 +9,11 @@ nodes: - "METRICS" resource: action: "'CREATE'" - condition: "'service.name' in resource.attributes" + condition: "'service.name' in resource.attributes && resource.attributes['service.name'] != ''" vars: - name: "namespace" value: >- + 'service.namespace' in resource.attributes && resource.attributes['service.namespace'] != '' ? resource.attributes['service.namespace'] : 'default' @@ -23,8 +24,6 @@ nodes: 'service.version' in resource.attributes ? resource.attributes['service.version'] : 'undefined' - - name: "clusterName" - value: "resource.attributes['k8s.cluster.name']" output: identifier: >- 'urn:opentelemetry:namespace/' + vars.namespace + ':service/' + vars.service @@ -41,8 +40,6 @@ nodes: target: "service.namespace" - source: "vars.version" target: "service.version" - - source: "vars.clusterName" - target: "cluster-name" - source: "resource.attributes" pattern: "telemetry.sdk\\.(.*)" target: "telemetry.sdk.${1}" @@ -50,10 +47,22 @@ nodes: tags: - source: "resource.attributes['deployment.environment']" target: "deployment-environment" - - source: "vars.clusterName + '/' + resource.attributes['k8s.namespace.name']" - target: "k8s-scope" - - source: "resource.attributes['k8s.namespace.name']" - target: "namespace" + - source: >- + 'k8s.cluster.name' in resource.attributes && resource.attributes['k8s.cluster.name'] != '' ? + {'cluster-name': resource.attributes['k8s.cluster.name']} : {} + pattern: "(.*)" + target: "${1}" + - source: >- + 'k8s.cluster.name' in resource.attributes && resource.attributes['k8s.cluster.name'] != '' && + 'k8s.namespace.name' in resource.attributes && resource.attributes['k8s.namespace.name'] != '' ? + {'k8s-scope': resource.attributes['k8s.cluster.name'] + '/' + resource.attributes['k8s.namespace.name']} : {} + pattern: "(.*)" + target: "${1}" + - source: >- + 'k8s.namespace.name' in resource.attributes && resource.attributes['k8s.namespace.name'] != '' ? + {'namespace': resource.attributes['k8s.namespace.name']} : {} + pattern: "(.*)" + target: "${1}" expireAfter: 900000 rank: specificity: 200 diff --git a/stackpacks/open-telemetry/settings/relation-mappings/executes-ecs-task.sty b/stackpacks/open-telemetry/settings/relation-mappings/executes-ecs-task.sty index b48aad8..28f5141 100644 --- a/stackpacks/open-telemetry/settings/relation-mappings/executes-ecs-task.sty +++ b/stackpacks/open-telemetry/settings/relation-mappings/executes-ecs-task.sty @@ -9,7 +9,10 @@ nodes: - "METRICS" resource: action: "'CREATE'" - condition: "'aws.ecs.task.id' in resource.attributes" + condition: >- + 'aws.ecs.task.id' in resource.attributes && resource.attributes['aws.ecs.task.id'] != '' && + 'service.name' in resource.attributes && resource.attributes['service.name'] != '' && + 'service.instance.id' in resource.attributes && resource.attributes['service.instance.id'] != '' vars: - name: "awsEcsTaskId" value: "resource.attributes['aws.ecs.task.id']" @@ -22,11 +25,7 @@ nodes: - name: "service" value: "resource.attributes['service.name']" - name: "instanceId" - value: >- - 'service.instance.id' in resource.attributes && - resource.attributes['service.instance.id'] != '' ? - resource.attributes['service.instance.id'] : - resource.attributes['service.name'] + value: "resource.attributes['service.instance.id']" output: sourceId: "'urn:opentelemetry:task/' + vars.awsEcsTaskId" targetId: >- diff --git a/stackpacks/open-telemetry/settings/relation-mappings/executes-function.sty b/stackpacks/open-telemetry/settings/relation-mappings/executes-function.sty index dc35c52..9531f33 100644 --- a/stackpacks/open-telemetry/settings/relation-mappings/executes-function.sty +++ b/stackpacks/open-telemetry/settings/relation-mappings/executes-function.sty @@ -9,7 +9,10 @@ nodes: - "METRICS" resource: action: "'CREATE'" - condition: "'faas.id' in resource.attributes" + condition: >- + 'faas.id' in resource.attributes && resource.attributes['faas.id'] != '' && + 'service.name' in resource.attributes && resource.attributes['service.name'] != '' && + 'service.instance.id' in resource.attributes && resource.attributes['service.instance.id'] != '' vars: - name: "faasId" value: "resource.attributes['faas.id']" @@ -22,11 +25,7 @@ nodes: - name: "service" value: "resource.attributes['service.name']" - name: "instanceId" - value: >- - 'service.instance.id' in resource.attributes && - resource.attributes['service.instance.id'] != '' ? - resource.attributes['service.instance.id'] : - resource.attributes['service.name'] + value: "resource.attributes['service.instance.id']" output: sourceId: "'urn:opentelemetry:function/' + vars.faasId" targetId: >- diff --git a/stackpacks/open-telemetry/settings/relation-mappings/executes-host.sty b/stackpacks/open-telemetry/settings/relation-mappings/executes-host.sty index b91cef5..3eff628 100644 --- a/stackpacks/open-telemetry/settings/relation-mappings/executes-host.sty +++ b/stackpacks/open-telemetry/settings/relation-mappings/executes-host.sty @@ -9,7 +9,10 @@ nodes: - "METRICS" resource: action: "'CREATE'" - condition: "'host.id' in resource.attributes" + condition: >- + 'host.id' in resource.attributes && resource.attributes['host.id'] != '' && + 'service.name' in resource.attributes && resource.attributes['service.name'] != '' && + 'service.instance.id' in resource.attributes && resource.attributes['service.instance.id'] != '' vars: - name: "hostId" value: "resource.attributes['host.id']" @@ -22,11 +25,7 @@ nodes: - name: "service" value: "resource.attributes['service.name']" - name: "instanceId" - value: >- - 'service.instance.id' in resource.attributes && - resource.attributes['service.instance.id'] != '' ? - resource.attributes['service.instance.id'] : - resource.attributes['service.name'] + value: "resource.attributes['service.instance.id']" output: sourceId: "'urn:opentelemetry:host/' + vars.hostId" targetId: >- diff --git a/stackpacks/open-telemetry/settings/relation-mappings/k8s-pod-to-otel.sty b/stackpacks/open-telemetry/settings/relation-mappings/k8s-pod-to-otel.sty index da973a0..637fd20 100644 --- a/stackpacks/open-telemetry/settings/relation-mappings/k8s-pod-to-otel.sty +++ b/stackpacks/open-telemetry/settings/relation-mappings/k8s-pod-to-otel.sty @@ -10,9 +10,11 @@ nodes: resource: action: "'CREATE'" condition: >- - 'k8s.cluster.name' in resource.attributes && - 'k8s.namespace.name' in resource.attributes && - 'k8s.pod.name' in resource.attributes + 'k8s.cluster.name' in resource.attributes && resource.attributes['k8s.cluster.name'] != '' && + 'k8s.namespace.name' in resource.attributes && resource.attributes['k8s.namespace.name'] != '' && + 'k8s.pod.name' in resource.attributes && resource.attributes['k8s.pod.name'] != '' && + 'service.name' in resource.attributes && resource.attributes['service.name'] != '' && + 'service.instance.id' in resource.attributes && resource.attributes['service.instance.id'] != '' vars: - name: "clusterName" value: "resource.attributes['k8s.cluster.name']" @@ -27,15 +29,9 @@ nodes: resource.attributes['service.namespace'] : 'default' - name: "service" - value: >- - 'service.name' in resource.attributes ? - resource.attributes['service.name'] : - '' + value: "resource.attributes['service.name']" - name: "instanceId" - value: >- - 'service.instance.id' in resource.attributes ? - resource.attributes['service.instance.id'] : - resource.attributes['service.name'] + value: "resource.attributes['service.instance.id']" output: sourceId: >- 'urn:opentelemetry:kubernetes:/' + vars.clusterName + ':' + vars.namespaceName + ':pod/' + vars.podName diff --git a/stackpacks/open-telemetry/settings/relation-mappings/provided-by.sty b/stackpacks/open-telemetry/settings/relation-mappings/provided-by.sty index 1162cdf..fa96ce5 100644 --- a/stackpacks/open-telemetry/settings/relation-mappings/provided-by.sty +++ b/stackpacks/open-telemetry/settings/relation-mappings/provided-by.sty @@ -9,7 +9,9 @@ nodes: - "METRICS" resource: action: "'CREATE'" - condition: "'service.name' in resource.attributes" + condition: >- + 'service.name' in resource.attributes && resource.attributes['service.name'] != '' && + 'service.instance.id' in resource.attributes && resource.attributes['service.instance.id'] != '' vars: - name: "namespace" value: >- @@ -20,10 +22,7 @@ nodes: - name: "service" value: "resource.attributes['service.name']" - name: "instanceId" - value: >- - 'service.instance.id' in resource.attributes ? - resource.attributes['service.instance.id'] : - resource.attributes['service.name'] + value: "resource.attributes['service.instance.id']" output: sourceId: >- 'urn:opentelemetry:namespace/' + vars.namespace + ':service/' + vars.service diff --git a/stackpacks/open-telemetry/stackpack.yaml b/stackpacks/open-telemetry/stackpack.yaml index 43859e5..fde883a 100644 --- a/stackpacks/open-telemetry/stackpack.yaml +++ b/stackpacks/open-telemetry/stackpack.yaml @@ -1,5 +1,5 @@ name: "open-telemetry" -version: "0.1.5" +version: "0.1.6" schemaVersion: "2.0" displayName: "Open Telemetry" categories: [ "Open Telemetry" ] diff --git a/tests/open-telemetry/requirements.txt b/tests/open-telemetry/requirements.txt new file mode 100644 index 0000000..b335011 --- /dev/null +++ b/tests/open-telemetry/requirements.txt @@ -0,0 +1,2 @@ +cel-python==0.4.0 +PyYAML==6.0.3 diff --git a/tests/open-telemetry/test_resource_identity.py b/tests/open-telemetry/test_resource_identity.py new file mode 100644 index 0000000..adaed23 --- /dev/null +++ b/tests/open-telemetry/test_resource_identity.py @@ -0,0 +1,175 @@ +"""Evaluate the shipped CEL against optional and complete OTel resource identities.""" + +import re +import unittest +from functools import lru_cache +from pathlib import Path + +import yaml +from celpy import CELEvalError, Environment, celtypes, json_to_cel + +SETTINGS = Path(__file__).resolve().parents[2] / "stackpacks/open-telemetry/settings" +ENV = Environment() + + +def omit_fields(attributes, keys): + # The platform's documented omit(map, keys) extension to CEL. + return celtypes.MapType({key: value for key, value in attributes.items() if key not in keys}) + + +@lru_cache(maxsize=None) +def compile_expression(expression): + return ENV.program(ENV.compile(expression), functions={"omit": omit_fields}) + + +def evaluate(expression, context): + return compile_expression(expression).evaluate({key: json_to_cel(value) for key, value in context.items()}) + + +def load_mapping(folder, name): + return yaml.safe_load((SETTINGS / folder / (name + ".sty")).read_text())["nodes"][0] + + +def map_resource(mapping, attributes): + context = {"resource": {"attributes": attributes}} + if not evaluate(mapping["input"]["resource"]["condition"], context): + return None + context["vars"] = {item["name"]: evaluate(item["value"], context) for item in mapping.get("vars", [])} + output = mapping["output"] + result = {key: str(evaluate(output[key], context)) for key in + ("identifier", "name", "typeName", "sourceId", "targetId") if key in output} + tags = set() + for group in ("required", "optional"): + fields = output.get(group, {}) + if "version" in fields: + result["version"] = str(evaluate(fields["version"], context)) + for tag in fields.get("tags", []): + try: + source = evaluate(tag["source"], context) + except CELEvalError: + if group == "required": + raise + continue + if "pattern" not in tag: + tags.add((tag["target"], str(source))) + else: + for key, value in source.items(): + match = re.fullmatch(tag["pattern"], str(key)) + if match: + target = tag["target"] + for index, capture in enumerate(match.groups(), 1): + target = target.replace("${" + str(index) + "}", capture) + tags.add((target, str(value))) + result["tags"] = tags + return result + + +class ResourceIdentityTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.service = load_mapping("component-mappings", "services") + cls.instance = load_mapping("component-mappings", "service-instances") + cls.namespace = load_mapping("component-mappings", "namespaces") + cls.provided_by = load_mapping("relation-mappings", "provided-by") + cls.infrastructure = [ + (load_mapping("relation-mappings", name), attributes) + for name, attributes in [ + ("executes-host", {"host.id": "host-123"}), + ("executes-function", {"faas.id": "function-123"}), + ("executes-ecs-task", {"aws.ecs.task.id": "task-123"}), + ("k8s-pod-to-otel", {"k8s.cluster.name": "cluster-1", "k8s.namespace.name": "apps", "k8s.pod.name": "pod-1"}), + ] + ] + + def test_optional_namespace_and_cluster_keep_identity_and_relation_consistent(self): + for namespace in (None, "", "orders"): + for cluster in (None, "", "cluster-1"): + with self.subTest(namespace=namespace, cluster=cluster): + attributes = {"service.name": "checkout", "service.instance.id": "instance-1"} + if namespace is not None: + attributes["service.namespace"] = namespace + if cluster is not None: + attributes["k8s.cluster.name"] = cluster + service = map_resource(self.service, attributes) + instance = map_resource(self.instance, attributes) + relation = map_resource(self.provided_by, attributes) + namespace_node = map_resource(self.namespace, attributes) + expected_namespace = namespace or "default" + expected = "urn:opentelemetry:namespace/" + expected_namespace + ":service/checkout" + self.assertEqual(service["identifier"], expected) + self.assertEqual(instance["identifier"], expected + ":serviceInstance/instance-1") + self.assertEqual(relation["sourceId"], service["identifier"]) + self.assertEqual(relation["targetId"], instance["identifier"]) + self.assertEqual(namespace_node["identifier"], "urn:opentelemetry:namespace/" + expected_namespace) + for component in (service, instance): + self.assertEqual({value for key, value in component["tags"] if key == "service.namespace"}, {expected_namespace}) + for component in (service, instance, namespace_node): + self.assertNotIn("k8s-scope", {key for key, _ in component["tags"]}) + clusters = {value for key, value in component["tags"] if key == "cluster-name"} + self.assertEqual(clusters, {cluster} if cluster else set()) + + def test_platform_self_scrape_without_kubernetes_metadata(self): + # Prometheus self-scrapes supply these two resource attributes, but no namespace/cluster. + attributes = {"service.name": "sts-opentelemetry-collector", "service.instance.id": "127.0.0.1:8888"} + service = map_resource(self.service, attributes) + instance = map_resource(self.instance, attributes) + self.assertIn(":namespace/default:", service["identifier"]) + self.assertTrue(instance["identifier"].endswith(":serviceInstance/127.0.0.1:8888")) + for component in (service, instance): + self.assertFalse({"cluster-name", "k8s-scope", "namespace"} & {key for key, _ in component["tags"]}) + + def test_service_without_instance_is_kept_without_inventing_an_instance(self): + for value in (None, ""): + attributes = {"service.name": "checkout"} + if value is not None: + attributes["service.instance.id"] = value + self.assertIsNotNone(map_resource(self.service, attributes)) + self.assertIsNone(map_resource(self.instance, attributes)) + self.assertIsNone(map_resource(self.provided_by, attributes)) + for mapping, extra in self.infrastructure: + self.assertIsNone(map_resource(mapping, attributes | extra)) + + def test_no_service_name_skips_services_instances_and_instance_relations(self): + for value in (None, ""): + attributes = {"service.instance.id": "instance-1"} + if value is not None: + attributes["service.name"] = value + for mapping in (self.service, self.instance, self.provided_by): + self.assertIsNone(map_resource(mapping, attributes)) + for mapping, extra in self.infrastructure: + self.assertIsNone(map_resource(mapping, attributes | extra)) + + def test_complete_kubernetes_identity_and_metadata_are_preserved(self): + attributes = {"service.name": "checkout", "service.namespace": "orders", "service.instance.id": "pod-uid-123", + "service.version": "1.2.3", "k8s.cluster.name": "cluster-1", "k8s.namespace.name": "apps", + "k8s.pod.name": "pod-1", "telemetry.sdk.language": "python", "deployment.environment": "production"} + service = map_resource(self.service, attributes) + instance = map_resource(self.instance, attributes) + for component in (service, instance, map_resource(self.namespace, attributes)): + self.assertTrue({("cluster-name", "cluster-1"), ("namespace", "apps"), ("k8s-scope", "cluster-1/apps")} <= component["tags"]) + self.assertEqual(service["version"], "1.2.3") + self.assertIn(("telemetry.sdk.language", "python"), service["tags"]) + self.assertIn(("k8s.pod.name", "pod-1"), instance["tags"]) + for mapping, extra in self.infrastructure: + relation = map_resource(mapping, attributes | extra) + self.assertEqual(relation["targetId"], instance["identifier"]) + + def test_partial_kubernetes_context_does_not_invent_a_scope(self): + for attributes in ({"k8s.namespace.name": "apps"}, {"k8s.namespace.name": "apps", "k8s.cluster.name": ""}, + {"k8s.namespace.name": "", "k8s.cluster.name": "cluster-1"}): + for mapping in (self.service, self.instance, self.namespace): + component = map_resource(mapping, attributes | {"service.name": "checkout", "service.instance.id": "instance-1"}) + self.assertNotIn("k8s-scope", {key for key, _ in component["tags"]}) + + def test_explicit_namespace_without_a_service_is_still_a_namespace(self): + self.assertEqual(map_resource(self.namespace, {"service.namespace": "orders"})["identifier"], "urn:opentelemetry:namespace/orders") + self.assertIsNone(map_resource(self.namespace, {})) + self.assertIsNone(map_resource(self.namespace, {"service.namespace": ""})) + + def test_required_expression_failures_are_not_suppressed(self): + with self.assertRaises(CELEvalError): + map_resource(self.service, {"service.name": 42}) + + +if __name__ == "__main__": + unittest.main()