From b3a7c3f77f225494dab73c799c8094f5e13beac4 Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Mon, 30 Jun 2025 13:48:36 +0200 Subject: [PATCH 01/14] Add helper container to translate http requests to API Gateway Events --- utils/build/build.sh | 9 +++++ utils/build/docker/lambda-proxy.Dockerfile | 12 +++++++ utils/build/docker/lambda_proxy/main.py | 35 +++++++++++++++++++ .../build/docker/lambda_proxy/pyproject.toml | 6 ++++ 4 files changed, 62 insertions(+) create mode 100644 utils/build/docker/lambda-proxy.Dockerfile create mode 100644 utils/build/docker/lambda_proxy/main.py create mode 100644 utils/build/docker/lambda_proxy/pyproject.toml diff --git a/utils/build/build.sh b/utils/build/build.sh index 4b43099bb2d..2b5f198920d 100755 --- a/utils/build/build.sh +++ b/utils/build/build.sh @@ -262,6 +262,15 @@ build() { docker save system_tests/weblog | gzip > $BINARIES_FILENAME fi fi + elif [[ $IMAGE_NAME == lambda-proxy ]]; then + docker buildx build \ + --build-arg BUILDKIT_INLINE_CACHE=1 \ + --load \ + --progress=plain \ + -f utils/build/docker/lambda-proxy.Dockerfile \ + -t datadog/system-tests:lambda-proxy \ + $EXTRA_DOCKER_ARGS \ + . else echo "Don't know how to build $IMAGE_NAME" exit 1 diff --git a/utils/build/docker/lambda-proxy.Dockerfile b/utils/build/docker/lambda-proxy.Dockerfile new file mode 100644 index 00000000000..c29204c078f --- /dev/null +++ b/utils/build/docker/lambda-proxy.Dockerfile @@ -0,0 +1,12 @@ +FROM python:3.13-alpine + +WORKDIR /app + +RUN apk add --no-cache curl + +COPY ./utils/build/docker/lambda_proxy/pyproject.toml ./ +RUN pip install --no-cache-dir . + +COPY utils/build/docker/lambda_proxy/main.py ./ + +ENTRYPOINT ["gunicorn", "--bind=0.0.0.0:7777", "--workers=1", "main:app"] diff --git a/utils/build/docker/lambda_proxy/main.py b/utils/build/docker/lambda_proxy/main.py new file mode 100644 index 00000000000..9848ea0494d --- /dev/null +++ b/utils/build/docker/lambda_proxy/main.py @@ -0,0 +1,35 @@ +import os +import sys +from samcli.local.apigw.event_constructor import construct_v1_event +from samcli.local.apigw.local_apigw_service import LocalApigwService + +# Create super simple catch-all flask app +from flask import Flask, request +from requests import post + +PORT = 7777 + +RIE_HOST = os.environ.get("RIE_HOST", "lambda-weblog") +RIE_PORT = os.environ.get("RIE_PORT", "8080") +FUNCTION_NAME = os.environ.get("FUNCTION_NAME", "function") +RIE_URL = f"http://{RIE_HOST}:{RIE_PORT}/2015-03-31/functions/{FUNCTION_NAME}/invocations" + +app = Flask(__name__) + + +@app.route("/") +@app.route("/") +def main(path): + converted_event = construct_v1_event(request, PORT, binary_types=[], stage_name="Prod") + + response = post( + RIE_URL, + json=converted_event, + headers={"Content-Type": "application/json"}, + ) + + (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( + response.content.decode("utf-8"), binary_types=[], flask_request=request, event_type="Api" + ) + + return app.response_class(response=body, status=status_code, headers=headers) diff --git a/utils/build/docker/lambda_proxy/pyproject.toml b/utils/build/docker/lambda_proxy/pyproject.toml new file mode 100644 index 00000000000..a709a26f7ea --- /dev/null +++ b/utils/build/docker/lambda_proxy/pyproject.toml @@ -0,0 +1,6 @@ +[project] +name = "lambda-proxy" +version = "0.1.0" +description = "Minimal Flask app to proxy requests to an AWS RIE endpoint" +requires-python = ">=3.13" +dependencies = ["aws-sam-cli<=1.141.0", "flask>=3.1.1", "gunicorn>=23.0.0"] From 1cc7b63c458d33b1371cb9f403bc8ad405f8cfe8 Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Mon, 30 Jun 2025 13:52:47 +0200 Subject: [PATCH 02/14] [python] Add aws apigw-rest weblog container --- .../python/aws_lambda/function/handler.py | 46 +++++++++++ .../docker/python/aws_lambda/requirements.txt | 1 + .../build/docker/python/build_lambda_layer.sh | 79 +++++++++++++++++++ .../lambda-python-apigw-rest.Dockerfile | 28 +++++++ 4 files changed, 154 insertions(+) create mode 100644 utils/build/docker/python/aws_lambda/function/handler.py create mode 100644 utils/build/docker/python/aws_lambda/requirements.txt create mode 100755 utils/build/docker/python/build_lambda_layer.sh create mode 100644 utils/build/docker/python/lambda-python-apigw-rest.Dockerfile diff --git a/utils/build/docker/python/aws_lambda/function/handler.py b/utils/build/docker/python/aws_lambda/function/handler.py new file mode 100644 index 00000000000..1d46556d28f --- /dev/null +++ b/utils/build/docker/python/aws_lambda/function/handler.py @@ -0,0 +1,46 @@ +from aws_lambda_powertools.event_handler import APIGatewayRestResolver +from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext +from aws_lambda_powertools.event_handler import Response + +import logging +import os + +import ddtrace + +logger = logging.getLogger(__name__) + + +app = APIGatewayRestResolver() + + +@app.get("/healthcheck") +def healthcheck(): + return Response( + status_code=200, + content_type="application/json", + body={ + "status": "ok", + "library": { + "name": "ddtrace", + "version": ddtrace.__version__, + }, + "extension": { + "name": "datadog-lambda-extension", + "version": os.environ.get("EXTENSION_VERSION", "unknown"), + }, + }, + ) + + +def lambda_handler(event, context: LambdaContext): + """ + Lambda function handler for AWS Lambda Powertools API Gateway integration. + + Args: + event (dict): The event data passed to the Lambda function. + context (LambdaContext): The context object provided by AWS Lambda. + + Returns: + dict: The response from the API Gateway resolver. + """ + return app.resolve(event, context) diff --git a/utils/build/docker/python/aws_lambda/requirements.txt b/utils/build/docker/python/aws_lambda/requirements.txt new file mode 100644 index 00000000000..56fd45918ce --- /dev/null +++ b/utils/build/docker/python/aws_lambda/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools diff --git a/utils/build/docker/python/build_lambda_layer.sh b/utils/build/docker/python/build_lambda_layer.sh new file mode 100755 index 00000000000..600eceba257 --- /dev/null +++ b/utils/build/docker/python/build_lambda_layer.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ARCH=${ARCH:-$(uname -m)} +PYTHON_VERSION=${PYTHON_VERSION:-3.13} + +cd binaries + +SKIP_BUILD=0 +CLONED_REPO=0 + +if [ -e "datadog-lambda-python" ]; then + echo "datadog-lambda-python already exists, skipping clone." +elif [ "$(find . -maxdepth 1 -name "*.zip" | wc -l)" = "1" ]; then + echo "Using provided datadog-lambda-python layer." + SKIP_BUILD=1 +else + echo "Cloning datadog-lambda-python repository..." + git clone --depth 1 https://github.com/DataDog/datadog-lambda-python.git + CLONED_REPO=1 +fi + +# Patch the ddtrace dependency in datadog-lambda-python based on the same rules as install_ddtrace.sh +if [[ $SKIP_BUILD -eq 0 ]]; then + if [ -e "dd-trace-py" ]; then + echo "Install from local folder /binaries/dd-trace-py" + sed -i '' 's|^ddtrace =.*$|ddtrace = { path = "./dd-trace-py" }|' datadog-lambda-python/pyproject.toml + cp -r dd-trace-py datadog-lambda-python/dd-trace-py + elif [ "$(find . -maxdepth 1 -name "*.whl" | wc -l)" = "1" ]; then + path=$(readlink -f "$(find . -maxdepth 1 -name "*.whl")") + echo "Install ddtrace from ${path}" + sed -i '' "s|^ddtrace =.*$|ddtrace = { path = \"file://${path}\" }|" datadog-lambda-python/pyproject.toml + cp -r ./*.whl datadog-lambda-python/ + elif [ "$(find . -maxdepth 1 -name "python-load-from-pip" | wc -l)" = "1" ]; then + echo "Install ddtrace from $(cat python-load-from-pip)" + + pip_spec=$(cat python-load-from-pip) + if [[ $pip_spec =~ ddtrace\ @\ git\+(.*)@(.*)$ ]]; then + # Format with revision: ddtrace @ git+https://...@revision + git_url="${BASH_REMATCH[1]}" + git_rev="${BASH_REMATCH[2]}" + sed -i '' "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\", rev = \"${git_rev}\" }|" datadog-lambda-python/pyproject.toml + elif [[ $pip_spec =~ ddtrace\ @\ git\+(.*)$ ]]; then + # Format without revision: ddtrace @ git+https://... (defaults to main) + git_url="${BASH_REMATCH[1]}" + sed -i '' "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\" }|" datadog-lambda-python/pyproject.toml + else + echo "ERROR: Unable to parse git URL from python-load-from-pip format: $pip_spec" + exit 1 + fi + elif [ "$(find . -maxdepth 1 -name "*.whl" | wc -l)" = "0" ]; then + echo "Install ddtrace from pypi" + # Keep the default ddtrace dependency in pyproject.toml + else + echo "ERROR: Found several wheel files in binaries/, abort." + exit 1 + fi + # Build the datadog-lambda-python package + cd datadog-lambda-python + ARCH=$ARCH PYTHON_VERSION=$PYTHON_VERSION ./scripts/build_layers.sh + + mv .layers/*.zip ../ + cd .. + + # Clean up the datadog-lambda-python directory + if [ "$CLONED_REPO" -eq 1 ]; then + echo "Removing datadog-lambda-python directory..." + rm -rf datadog-lambda-python + else + # Restore the original pyproject.toml if it was not cloned + cd datadog-lambda-python + git checkout -- pyproject.toml + rm -rf dd-trace-py ./*.whl + cd .. + fi +fi + +cd .. diff --git a/utils/build/docker/python/lambda-python-apigw-rest.Dockerfile b/utils/build/docker/python/lambda-python-apigw-rest.Dockerfile new file mode 100644 index 00000000000..d41f203283a --- /dev/null +++ b/utils/build/docker/python/lambda-python-apigw-rest.Dockerfile @@ -0,0 +1,28 @@ +ARG EXTENSION_VERSION=82 +ARG PYTHON_VERSION=3.13 +FROM public.ecr.aws/datadog/lambda-extension:${EXTENSION_VERSION} AS datadog-extension + +FROM public.ecr.aws/lambda/python:${PYTHON_VERSION} +ARG EXTENSION_VERSION +ENV EXTENSION_VERSION=${EXTENSION_VERSION} + +# Install the unzip utility +RUN dnf install -y unzip + +# Add the Datadog Extension +RUN mkdir -p /opt/extensions +COPY --from=datadog-extension /opt/. /opt/ + +# Add the Datadog Lambda Python Layer +COPY binaries/*.zip /binaries/ +RUN unzip /binaries/*.zip -d /opt + +# Setup the aws_lambda handler +COPY utils/build/docker/python/aws_lambda/requirements.txt ${LAMBDA_TASK_ROOT}/requirements.txt +RUN pip install -r ${LAMBDA_TASK_ROOT}/requirements.txt + +COPY utils/build/docker/python/aws_lambda/function/handler.py ${LAMBDA_TASK_ROOT} + +ENV DD_LAMBDA_HANDLER=handler.lambda_handler + +CMD ["datadog_lambda.handler.handler"] From d7948664358616380972266093b9c664ec241a4f Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Mon, 30 Jun 2025 15:07:29 +0200 Subject: [PATCH 03/14] Add scenario for running lambdas inside Docker --- tests/appsec/test_alpha.py | 1 + tests/appsec/test_only_python.py | 1 + tests/appsec/test_reports.py | 4 + tests/appsec/test_traces.py | 15 ++ tests/appsec/test_versions.py | 1 + tests/test_the_test/test_group_rules.py | 1 + utils/_context/_scenarios/__init__.py | 9 + utils/_context/_scenarios/aws_lambda.py | 193 ++++++++++++++++++ utils/_context/_scenarios/core.py | 1 + utils/_context/containers.py | 111 ++++++++++ utils/build/docker/lambda_proxy/main.py | 2 +- .../python/aws_lambda/function/handler.py | 113 ++++++++-- .../build/docker/python/build_lambda_layer.sh | 4 + .../lambda-python-apigw-rest.Dockerfile | 11 +- utils/proxy/core.py | 8 +- 15 files changed, 451 insertions(+), 24 deletions(-) create mode 100644 utils/_context/_scenarios/aws_lambda.py diff --git a/tests/appsec/test_alpha.py b/tests/appsec/test_alpha.py index 41db5ebb091..c2fcc6a00f5 100644 --- a/tests/appsec/test_alpha.py +++ b/tests/appsec/test_alpha.py @@ -9,6 +9,7 @@ @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_Basic: """Detect attacks on raw URI and headers with default rules""" diff --git a/tests/appsec/test_only_python.py b/tests/appsec/test_only_python.py index ce6cc45e51c..5cacf9b3e7e 100644 --- a/tests/appsec/test_only_python.py +++ b/tests/appsec/test_only_python.py @@ -10,6 +10,7 @@ @scenarios.appsec_runtime_activation @scenarios.appsec_standalone @scenarios.default +@scenarios.appsec_lambda_default @features.language_specifics @irrelevant(context.library != "python", reason="specific tests for python tracer") class Test_ImportError: diff --git a/tests/appsec/test_reports.py b/tests/appsec/test_reports.py index 239d261c229..613569dbcec 100644 --- a/tests/appsec/test_reports.py +++ b/tests/appsec/test_reports.py @@ -68,6 +68,7 @@ def _check_service(span, appsec_data): # noqa: ARG001 @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_RequestHeaders: """Request Headers for IP resolution""" @@ -107,6 +108,7 @@ def test_http_request_headers(self): @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_TagsFromRule: """Tags tags from the rule""" @@ -135,6 +137,7 @@ def test_category(self): @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_ExtraTagsFromRule: """Extra tags may be added to the rule match since libddwaf 1.10.0""" @@ -164,6 +167,7 @@ def _get_appsec_triggers(request): @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_AttackTimestamp: """Attack timestamp""" diff --git a/tests/appsec/test_traces.py b/tests/appsec/test_traces.py index 8a4ff04a050..53262925cbe 100644 --- a/tests/appsec/test_traces.py +++ b/tests/appsec/test_traces.py @@ -2,6 +2,7 @@ # This product includes software developed at Datadog (https://www.datadoghq.com/). # Copyright 2021 Datadog, Inc. +from utils._context._scenarios import scenario_groups from utils.dd_constants import PYTHON_RELEASE_GA_1_1 from utils import weblog, bug, context, interfaces, irrelevant, rfc, missing_feature, scenarios, features from utils.tools import nested_lookup @@ -16,6 +17,7 @@ @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_RetainTraces: """Retain trace (manual keep & appsec.event = true)""" @@ -59,6 +61,7 @@ def validate_appsec_event_span_tags(span): @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_AppSecEventSpanTags: """AppSec correctly fill span tags.""" @@ -66,6 +69,10 @@ def setup_custom_span_tags(self): weblog.get("/waf", params={"key": "\n :"}) # rules.http_protocol_violation.crs_921_160 weblog.get("/waf", headers={"random-key": "acunetix-user-agreement"}) # rules.security_scanner.crs_913_110 + @bug( + context.library.name == "python" and scenario_groups.appsec_lambda in context.scenario.scenario_groups, + reason="APPSEC-58201", + ) def test_custom_span_tags(self): """AppSec should store in all APM spans some tags when enabled.""" @@ -134,6 +141,7 @@ def test_root_span_coherence(self): @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_AppSecObfuscator: """AppSec obfuscates sensitive data.""" @@ -285,6 +293,7 @@ def validate_appsec_span_tags(span, appsec_data): # noqa: ARG001 @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_CollectRespondHeaders: """AppSec should collect some headers for http.response and store them in span tags.""" @@ -295,6 +304,10 @@ def setup_header_collection(self): context.scenario is scenarios.external_processing, reason="The endpoint /headers is not implemented in the weblog", ) + @bug( + scenario_groups.appsec_lambda in context.scenario.scenario_groups and context.library.name == "python", + reason="APPSEC-58202", + ) def test_header_collection(self): def assert_header_in_span_meta(span, header): if header not in span["meta"]: @@ -313,6 +326,7 @@ def validate_response_headers(span): @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_CollectDefaultRequestHeader: HEADERS = { "User-Agent": "MyBrowser", @@ -346,6 +360,7 @@ def test_collect_default_request_headers(self): @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_ExternalWafRequestsIdentification: def setup_external_wafs_header_collection(self): self.r = weblog.get( diff --git a/tests/appsec/test_versions.py b/tests/appsec/test_versions.py index 3d91c70c406..b752dff0e15 100644 --- a/tests/appsec/test_versions.py +++ b/tests/appsec/test_versions.py @@ -9,6 +9,7 @@ @features.envoy_external_processing @scenarios.external_processing @scenarios.default +@scenarios.appsec_lambda_default class Test_Events: """AppSec events uses events in span""" diff --git a/tests/test_the_test/test_group_rules.py b/tests/test_the_test/test_group_rules.py index 2201e8b22c8..f7c744749d4 100644 --- a/tests/test_the_test/test_group_rules.py +++ b/tests/test_the_test/test_group_rules.py @@ -62,6 +62,7 @@ def test_tracer_release(): scenarios.simple_installer_auto_injection, scenarios.multi_installer_auto_injection, scenarios.demo_aws, + scenarios.appsec_lambda_default, ] for scenario in get_all_scenarios(): diff --git a/utils/_context/_scenarios/__init__.py b/utils/_context/_scenarios/__init__.py index c6a6006643d..b027e8a39a1 100644 --- a/utils/_context/_scenarios/__init__.py +++ b/utils/_context/_scenarios/__init__.py @@ -1,5 +1,6 @@ import json +from utils._context._scenarios.aws_lambda import LambdaScenario from utils._context.header_tag_vars import VALID_CONFIGS, INVALID_CONFIGS, CONFIG_WILDCARD from utils.proxy.ports import ProxyPorts from utils.tools import update_environ_with_local_env @@ -187,6 +188,7 @@ class _Scenarios: doc="Misc tests for appsec blocking", scenario_groups=[scenario_groups.appsec, scenario_groups.essentials], ) + # This GraphQL scenario can be used for any GraphQL testing, not just AppSec graphql_appsec = EndToEndScenario( "GRAPHQL_APPSEC", @@ -1077,6 +1079,13 @@ class _Scenarios: doc="Test runtime metrics", ) + # Appsec Lambda Scenarios + appsec_lambda_default = LambdaScenario( + "APPSEC_LAMBDA_DEFAULT", + doc="Default Lambda scenario", + scenario_groups=[scenario_groups.appsec_lambda], + ) + scenarios = _Scenarios() diff --git a/utils/_context/_scenarios/aws_lambda.py b/utils/_context/_scenarios/aws_lambda.py new file mode 100644 index 00000000000..e36257b6983 --- /dev/null +++ b/utils/_context/_scenarios/aws_lambda.py @@ -0,0 +1,193 @@ +import os +import pytest +from utils import interfaces +from utils._context._scenarios.core import ScenarioGroup +from utils._context.containers import LambdaProxyContainer, LambdaWeblogContainer +from utils._logger import logger +from .endtoend import DockerScenario, ProxyBasedInterfaceValidator + + +class LambdaScenario(DockerScenario): + """Scenario for end-to-end testing of AWS Lambda HTTP Instrumentation. + + The `LambdaScenario` sets up an environment with the following components: + - A LambdaWeblog container that runs the application using AWS Lambda RIE (Runtime Interface Emulator). + - A LambdaProxy container that converts between http requests and lambda events to invoke the function. + + In this scenario, there is no agent container, but the LambdaWeblog contains the `datadog-lambda-extension` + which is the agent in the context of Lambda. + """ + + def __init__( + self, + name: str, + *, + github_workflow: str = "endtoend", + doc: str, + scenario_groups: list[ScenarioGroup] | None = None, + agent_interface_timeout: int = 5, + backend_interface_timeout: int = 0, + library_interface_timeout: int | None = None, + use_proxy_for_weblog: bool = True, + use_proxy_for_agent: bool = True, + require_api_key: bool = False, + weblog_env: dict[str, str | None] | None = None, + weblog_volumes: dict[str, dict[str, str]] | None = None, + ): + use_proxy = use_proxy_for_weblog or use_proxy_for_agent + self._require_api_key = require_api_key + + super().__init__( + name, github_workflow=github_workflow, doc=doc, use_proxy=use_proxy, scenario_groups=scenario_groups + ) + + self.lambda_weblog = LambdaWeblogContainer( + host_log_folder=self.host_log_folder, + environment=weblog_env or {}, + volumes=weblog_volumes or {}, + ) + + self.lambda_proxy_container = LambdaProxyContainer( + host_log_folder=self.host_log_folder, + lambda_weblog_host=self.lambda_weblog.name, + lambda_weblog_port=str(self.lambda_weblog.container_port), + ) + + self.lambda_proxy_container.depends_on.append(self.lambda_weblog) + + if use_proxy: + self.lambda_weblog.depends_on.append(self.proxy_container) + + if use_proxy_for_agent: + self.proxy_container.environment.update( + { + "PROXY_TRACING_AGENT_TARGET_HOST": self.lambda_weblog.name, + "PROXY_TRACING_AGENT_TARGET_PORT": "8126", + } + ) + + self._required_containers.extend((self.lambda_weblog, self.lambda_proxy_container)) + + self.agent_interface_timeout = agent_interface_timeout + self.backend_interface_timeout = backend_interface_timeout + self._library_interface_timeout = library_interface_timeout + + def configure(self, config: pytest.Config): + super().configure(config) + + if self._require_api_key and "DD_API_KEY" not in os.environ and not self.replay: + pytest.exit("DD_API_KEY is required for this scenario", 1) + + if config.option.force_dd_trace_debug: + self.lambda_weblog.environment["DD_TRACE_DEBUG"] = "true" + + if config.option.force_dd_iast_debug: + self.lambda_weblog.environment["_DD_IAST_DEBUG"] = "true" # probably not used anymore ? + self.lambda_weblog.environment["DD_IAST_DEBUG_ENABLED"] = "true" + + if config.option.force_dd_trace_debug: + self.lambda_weblog.environment["DD_TRACE_DEBUG"] = "true" + + interfaces.agent.configure(self.host_log_folder, replay=self.replay) + interfaces.library.configure(self.host_log_folder, replay=self.replay) + interfaces.backend.configure(self.host_log_folder, replay=self.replay) + interfaces.library_dotnet_managed.configure(self.host_log_folder, replay=self.replay) + interfaces.library_stdout.configure(self.host_log_folder, replay=self.replay) + interfaces.agent_stdout.configure(self.host_log_folder, replay=self.replay) + + library = self.lambda_weblog.image.labels["system-tests-library"] + + if self._library_interface_timeout is None: + if library == "java": + self.library_interface_timeout = 25 + elif library in ("golang",): + self.library_interface_timeout = 10 + elif library in ("nodejs", "ruby"): + self.library_interface_timeout = 0 + elif library in ("php",): + self.library_interface_timeout = 10 + elif library in ("python",): + self.library_interface_timeout = 5 + else: + self.library_interface_timeout = 40 + else: + self.library_interface_timeout = self._library_interface_timeout + + def _get_weblog_system_info(self): + try: + code, (stdout, stderr) = self.lambda_weblog.exec_run("uname -a", demux=True) + if code or stdout is None: + message = f"Failed to get weblog system info: [{code}] {stderr.decode()} {stdout.decode()}" + else: + message = stdout.decode() + except BaseException: + logger.exception("can't get weblog system info") + else: + logger.stdout(f"Weblog system: {message.strip()}") + + if self.lambda_weblog.environment.get("DD_TRACE_DEBUG") == "true": + logger.stdout("\t/!\\ Debug logs are activated in weblog") + + logger.stdout("") + + def _start_interfaces_watchdog(self): + return super().start_interfaces_watchdog([interfaces.library, interfaces.agent]) + + def _set_components(self): + self.components["libary"] = self.library.version + + def _wait_for_app_readiness(self): + logger.debug("Wait for app readiness") + + if not interfaces.library.ready.wait(40): + raise ValueError("Library not ready") + + logger.debug("Library ready") + + def get_warmups(self): + warmups = super().get_warmups() + + if not self.replay: + warmups.insert(1, self._start_interfaces_watchdog) + warmups.append(self._get_weblog_system_info) + warmups.append(self._wait_for_app_readiness) + warmups.append(self._set_components) + + return warmups + + def _wait_interface(self, interface: ProxyBasedInterfaceValidator, timeout: int): + logger.terminal.write_sep("-", f"Wait for {interface.name} interface ({timeout}s)") + logger.terminal.flush() + + interface.wait(timeout) + + def _wait_and_stop_containers(self, *, force_interface_timeout_to_zero: bool = False): + if self.replay: + logger.terminal.write_sep("-", "Load all data from logs") + logger.terminal.flush() + + interfaces.library.load_data_from_logs() + interfaces.library.check_deserialization_errors() + + interfaces.backend.load_data_from_logs() + else: + self._wait_interface( + interfaces.library, 0 if force_interface_timeout_to_zero else self.library_interface_timeout + ) + + self.lambda_weblog.stop() + interfaces.library.check_deserialization_errors() + + self._wait_interface(interfaces.backend, 0) + + def post_setup(self, session: pytest.Session): + is_empty_test_run = session.config.option.skip_empty_scenario and len(session.items) == 0 + + try: + self._wait_and_stop_containers(force_interface_timeout_to_zero=is_empty_test_run) + finally: + self.close_targets() + + @property + def library(self): + return self.lambda_weblog.library diff --git a/utils/_context/_scenarios/core.py b/utils/_context/_scenarios/core.py index e9c46c255e2..1e7587e3d87 100644 --- a/utils/_context/_scenarios/core.py +++ b/utils/_context/_scenarios/core.py @@ -26,6 +26,7 @@ def __call__(self, test_object): # noqa: ANN001 (tes_object can be a class or a class _ScenarioGroups: all = ScenarioGroup() appsec = ScenarioGroup() + appsec_lambda = ScenarioGroup() appsec_rasp = ScenarioGroup() debugger = ScenarioGroup() end_to_end = ScenarioGroup() diff --git a/utils/_context/containers.py b/utils/_context/containers.py index dc2adb78cca..4b5a6489e9c 100644 --- a/utils/_context/containers.py +++ b/utils/_context/containers.py @@ -605,6 +605,37 @@ def __init__( ) +class LambdaProxyContainer(TestedContainer): + def __init__( + self, + *, + host_log_folder: str, + lambda_weblog_host: str, + lambda_weblog_port: str, + ) -> None: + from utils import weblog + + self.host_port = weblog.port + self.container_port = "7777" + + super().__init__( + image_name="datadog/system-tests:lambda-proxy", + name="lambda-proxy", + host_log_folder=host_log_folder, + environment={ + "RIE_HOST": lambda_weblog_host, + "RIE_PORT": lambda_weblog_port, + }, + ports={ + f"{self.host_port}/tcp": self.container_port, + }, + healthcheck={ + "test": f"curl --fail --silent --show-error --max-time 2 localhost:{self.container_port}/healthcheck", + "retries": 60, + }, + ) + + class AgentContainer(TestedContainer): apm_receiver_port: int = 8127 dogstatsd_port: int = 8125 @@ -1015,6 +1046,86 @@ def telemetry_heartbeat_interval(self): return 2 +class LambdaWeblogContainer(WeblogContainer): + def __init__( + self, + host_log_folder: str, + *, + environment: dict[str, str | None] | None = None, + tracer_sampling_rate: float | None = None, + appsec_enabled: bool = True, + iast_enabled: bool = True, + runtime_metrics_enabled: bool = False, + additional_trace_header_tags: tuple[str, ...] = (), + use_proxy: bool = True, + volumes: dict | None = None, + ): + # overwrite values with those set in the scenario + environment = (environment or {}) | { + "DD_HOSTNAME": "test", + "DD_SITE": os.environ.get("DD_SITE", "datad0g.com"), + "DD_API_KEY": os.environ.get("DD_API_KEY", _FAKE_DD_API_KEY), + "DD_SERVERLESS_FLUSH_STRATEGY": "periodically,100", + } + + volumes = volumes or {} + + if use_proxy: + environment["DD_PROXY_HTTPS"] = f"http://proxy:{ProxyPorts.agent}" + environment["DD_PROXY_HTTP"] = f"http://proxy:{ProxyPorts.agent}" + environment["DD_APM_NON_LOCAL_TRAFFIC"] = ( + "true" # Required for the extension to receive traces from outside the container + ) + volumes.update( + { + "./utils/build/docker/agent/ca-certificates.crt": { + "bind": "/etc/ssl/certs/ca-certificates.crt", + "mode": "ro", + }, + "./utils/build/docker/agent/datadog.yaml": { + "bind": "/etc/datadog-agent/datadog.yaml", + "mode": "ro", + }, + }, + ) + + self.tracer_sampling_rate = tracer_sampling_rate + self.additional_trace_header_tags = additional_trace_header_tags + + super().__init__( + host_log_folder, + environment=environment, + tracer_sampling_rate=tracer_sampling_rate, + appsec_enabled=appsec_enabled, + iast_enabled=iast_enabled, + runtime_metrics_enabled=runtime_metrics_enabled, + additional_trace_header_tags=additional_trace_header_tags, + use_proxy=use_proxy, + volumes=volumes, + ) + + # Set the container port to the one used by the one of the Lambda RIE + self.container_port = 8080 + + # Replace healthcheck with a custom one for Lambda + healthcheck_event = json.dumps({"healthcheck": True}) + self.healthcheck = { + "test": f"curl --fail --silent --show-error --max-time 2 -XPOST -d '{healthcheck_event}' http://localhost:{self.container_port}/2015-03-31/functions/function/invocations", + "retries": 60, + } + # Remove port bindings, as only the LambdaProxyContainer needs to expose a server + self.ports = {} + + def configure(self, *, replay: bool): + super().configure(replay=replay) + + library = self.image.labels["system-tests-library"] + + if library == "python": + self.environment["DD_LAMBDA_HANDLER"] = "handler.lambda_handler" + self.command = "datadog_lambda.handler.handler" + + class PostgresContainer(SqlDbTestedContainer): def __init__(self, host_log_folder: str) -> None: super().__init__( diff --git a/utils/build/docker/lambda_proxy/main.py b/utils/build/docker/lambda_proxy/main.py index 9848ea0494d..f8add9f0188 100644 --- a/utils/build/docker/lambda_proxy/main.py +++ b/utils/build/docker/lambda_proxy/main.py @@ -19,7 +19,7 @@ @app.route("/") @app.route("/") -def main(path): +def main(path=""): converted_event = construct_v1_event(request, PORT, binary_types=[], stage_name="Prod") response = post( diff --git a/utils/build/docker/python/aws_lambda/function/handler.py b/utils/build/docker/python/aws_lambda/function/handler.py index 1d46556d28f..3f2966dc81e 100644 --- a/utils/build/docker/python/aws_lambda/function/handler.py +++ b/utils/build/docker/python/aws_lambda/function/handler.py @@ -1,3 +1,5 @@ +from typing import Any +import urllib.parse from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext from aws_lambda_powertools.event_handler import Response @@ -6,33 +8,116 @@ import os import ddtrace +from ddtrace.appsec import trace_utils as appsec_trace_utils +from ddtrace.trace import tracer +import urllib logger = logging.getLogger(__name__) app = APIGatewayRestResolver() +_TRACK_CUSTOM_APPSEC_EVENT_NAME = "system_tests_appsec_event" + + +def version_info(): + return { + "status": "ok", + "library": { + "name": "python", + "version": ddtrace.__version__, + }, + "extension": { + "name": "datadog-lambda-extension", + "version": os.environ.get("EXTENSION_VERSION", "unknown"), + }, + } + @app.get("/healthcheck") -def healthcheck(): +def healthcheck_route(): return Response( status_code=200, content_type="application/json", - body={ - "status": "ok", - "library": { - "name": "ddtrace", - "version": ddtrace.__version__, - }, - "extension": { - "name": "datadog-lambda-extension", - "version": os.environ.get("EXTENSION_VERSION", "unknown"), - }, - }, + body=version_info(), ) -def lambda_handler(event, context: LambdaContext): +@app.get("/waf/") +@app.post("/waf/") +def waf(): + return Response( + status_code=200, + content_type="text/plain", + body="Hello, World!\n", + ) + + +@app.get("/waf/") +@app.post("/waf/") +def waf_params(path: str): + return Response( + status_code=200, + content_type="text/plain", + body="Hello, World!\n", + ) + + +@app.get("/tag_value//") +@app.post("/tag_value//") +def tag_value(tag_value: str, status_code: int): + appsec_trace_utils.track_custom_event( + tracer, event_name=_TRACK_CUSTOM_APPSEC_EVENT_NAME, metadata={"value": tag_value} + ) + return Response( + status_code=status_code, + content_type="text/plain", + body="Value tagged", + headers=app.current_event.query_string_parameters, + ) + + +@app.post("/tag_value//") +def tag_value_post(tag_value: str, status_code: int): + appsec_trace_utils.track_custom_event( + tracer, event_name=_TRACK_CUSTOM_APPSEC_EVENT_NAME, metadata={"value": tag_value} + ) + if tag_value.startswith("payload_in_response_body"): + # Get form data from the current event + body = app.current_event.body or "" + if app.current_event.is_base64_encoded: + import base64 + + body = base64.b64decode(body).decode("utf-8") + + form_data = urllib.parse.parse_qs(body) + + return Response( + status_code=status_code, + content_type="application/json", + body={"payload": form_data}, + headers=app.current_event.query_string_parameters or {}, + ) + return Response( + status_code=status_code, + content_type="text/plain", + body="Value tagged", + headers=app.current_event.query_string_parameters or {}, + ) + + +@app.get("/headers") +def headers(): + return Response(status_code=200, body="OK", headers={"Content-Language": "en-US", "Content-Type": "text/plain"}) + + +@app.get("/") +@app.post("/") +def root(): + return Response(status_code=200, content_type="text/plain", body="Hello, World!\n") + + +def lambda_handler(event: dict[str, Any], context: LambdaContext): """ Lambda function handler for AWS Lambda Powertools API Gateway integration. @@ -43,4 +128,6 @@ def lambda_handler(event, context: LambdaContext): Returns: dict: The response from the API Gateway resolver. """ + if event.get("healthcheck"): + return version_info() return app.resolve(event, context) diff --git a/utils/build/docker/python/build_lambda_layer.sh b/utils/build/docker/python/build_lambda_layer.sh index 600eceba257..15558b266cc 100755 --- a/utils/build/docker/python/build_lambda_layer.sh +++ b/utils/build/docker/python/build_lambda_layer.sh @@ -5,6 +5,10 @@ set -euo pipefail ARCH=${ARCH:-$(uname -m)} PYTHON_VERSION=${PYTHON_VERSION:-3.13} +if [ "$ARCH" = "x86_64" ]; then + ARCH="amd64" +fi + cd binaries SKIP_BUILD=0 diff --git a/utils/build/docker/python/lambda-python-apigw-rest.Dockerfile b/utils/build/docker/python/lambda-python-apigw-rest.Dockerfile index d41f203283a..2dda402fe4f 100644 --- a/utils/build/docker/python/lambda-python-apigw-rest.Dockerfile +++ b/utils/build/docker/python/lambda-python-apigw-rest.Dockerfile @@ -1,17 +1,10 @@ -ARG EXTENSION_VERSION=82 -ARG PYTHON_VERSION=3.13 -FROM public.ecr.aws/datadog/lambda-extension:${EXTENSION_VERSION} AS datadog-extension +FROM public.ecr.aws/lambda/python:3.13 -FROM public.ecr.aws/lambda/python:${PYTHON_VERSION} -ARG EXTENSION_VERSION -ENV EXTENSION_VERSION=${EXTENSION_VERSION} - -# Install the unzip utility RUN dnf install -y unzip # Add the Datadog Extension RUN mkdir -p /opt/extensions -COPY --from=datadog-extension /opt/. /opt/ +COPY --from=public.ecr.aws/datadog/lambda-extension:latest /opt/. /opt/ # Add the Datadog Lambda Python Layer COPY binaries/*.zip /binaries/ diff --git a/utils/proxy/core.py b/utils/proxy/core.py index af230b87b45..b20333eea0e 100644 --- a/utils/proxy/core.py +++ b/utils/proxy/core.py @@ -48,6 +48,9 @@ def __init__(self) -> None: self.rc_api_enabled = os.environ.get("SYSTEM_TESTS_RC_API_ENABLED") == "True" self.span_meta_structs_disabled = os.environ.get("SYSTEM_TESTS_AGENT_SPAN_META_STRUCTS_DISABLED") == "True" + self.tracing_agent_target_host = os.environ.get("PROXY_TRACING_AGENT_TARGET_HOST", "agent") + self.tracing_agent_target_port = int(os.environ.get("PROXY_TRACING_AGENT_TARGET_PORT", "8127")) + span_events = os.environ.get("SYSTEM_TESTS_AGENT_SPAN_EVENTS") self.span_events = span_events != "False" @@ -122,7 +125,10 @@ def request(self, flow: HTTPFlow): ProxyPorts.golang_buddy, ProxyPorts.weblog, ): - flow.request.host, flow.request.port = "agent", 8127 + flow.request.host, flow.request.port = ( + self.tracing_agent_target_host, + self.tracing_agent_target_port, + ) flow.request.scheme = "http" logger.info(f" => reverse proxy to {flow.request.pretty_url}") From f319696ea79f3c1240f581f80245be691fefe3d7 Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Tue, 1 Jul 2025 13:19:19 +0200 Subject: [PATCH 04/14] Add lambda-proxy to build image job --- .github/workflows/ci.yml | 1 + .github/workflows/run-end-to-end.yml | 8 +++++++ .github/workflows/system-tests.yml | 6 +++++ .../build/docker/python/build_lambda_layer.sh | 24 +++++++++++++++---- 4 files changed, 35 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4c7ee14303e..c8167564eb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,6 +85,7 @@ jobs: build_python_base_images: ${{ contains(github.event.pull_request.labels.*.name, 'build-python-base-images') }} build_buddies_images: ${{ contains(github.event.pull_request.labels.*.name, 'build-buddies-images') }} build_proxy_image: ${{ contains(github.event.pull_request.labels.*.name, 'build-proxy-image') }} + build_lambda_proxy_image: ${{ contains(github.event.pull_request.labels.*.name, 'build-lambda-proxy-image') }} build_lib_injection_app_images: ${{ contains(github.event.pull_request.labels.*.name, 'build-lib-injection-app-images') }} parametric_job_count: ${{ matrix.version == 'dev' && 2 || 1 }} # test both use cases skip_empty_scenarios: true diff --git a/.github/workflows/run-end-to-end.yml b/.github/workflows/run-end-to-end.yml index 5913efc8f2f..0dd632c7871 100644 --- a/.github/workflows/run-end-to-end.yml +++ b/.github/workflows/run-end-to-end.yml @@ -54,6 +54,11 @@ on: default: false required: false type: boolean + build_lambda_proxy_image: + description: "Shall we build lambda proxy image" + default: false + required: false + type: boolean push_to_feature_parity_dashbaord: description: "Shall we push results to Feature Parity Dashbaord" default: false @@ -125,6 +130,9 @@ jobs: - name: Build proxy image if: inputs.build_proxy_image run: ./build.sh -i proxy + - name: Build lambda proxy image + if: inputs.build_lambda_proxy_image + run: ./build.sh -i lambda-proxy - name: Pull images uses: ./.github/actions/pull_images with: diff --git a/.github/workflows/system-tests.yml b/.github/workflows/system-tests.yml index 3f889c9ddd8..4acff697a7a 100644 --- a/.github/workflows/system-tests.yml +++ b/.github/workflows/system-tests.yml @@ -59,6 +59,11 @@ on: default: false required: false type: boolean + build_lambda_proxy_image: + description: "Shall we build lambda proxy image" + default: false + required: false + type: boolean build_lib_injection_app_images: description: "Shall we build and push k8s lib injection weblog images" default: false @@ -241,6 +246,7 @@ jobs: force_execute: ${{ inputs.force_execute }} build_buddies_images: ${{ inputs.build_buddies_images }} build_proxy_image: ${{ inputs.build_proxy_image }} + build_lambda_proxy_image: ${{ inputs.build_lambda_proxy_image }} binaries_artifact: binaries_${{ needs.compute_parameters.outputs.ci_environment }}_${{ inputs.library }}_${{ matrix.job.weblog }}_${{ needs.compute_parameters.outputs.unique_id }} ci_environment: ${{ needs.compute_parameters.outputs.ci_environment }} skip_empty_scenarios: ${{ inputs.skip_empty_scenarios }} diff --git a/utils/build/docker/python/build_lambda_layer.sh b/utils/build/docker/python/build_lambda_layer.sh index 15558b266cc..45e1826a569 100755 --- a/utils/build/docker/python/build_lambda_layer.sh +++ b/utils/build/docker/python/build_lambda_layer.sh @@ -29,12 +29,20 @@ fi if [[ $SKIP_BUILD -eq 0 ]]; then if [ -e "dd-trace-py" ]; then echo "Install from local folder /binaries/dd-trace-py" - sed -i '' 's|^ddtrace =.*$|ddtrace = { path = "./dd-trace-py" }|' datadog-lambda-python/pyproject.toml + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' 's|^ddtrace =.*$|ddtrace = { path = "./dd-trace-py" }|' datadog-lambda-python/pyproject.toml + else + sed -i 's|^ddtrace =.*$|ddtrace = { path = "./dd-trace-py" }|' datadog-lambda-python/pyproject.toml + fi cp -r dd-trace-py datadog-lambda-python/dd-trace-py elif [ "$(find . -maxdepth 1 -name "*.whl" | wc -l)" = "1" ]; then path=$(readlink -f "$(find . -maxdepth 1 -name "*.whl")") echo "Install ddtrace from ${path}" - sed -i '' "s|^ddtrace =.*$|ddtrace = { path = \"file://${path}\" }|" datadog-lambda-python/pyproject.toml + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s|^ddtrace =.*$|ddtrace = { path = \"file://${path}\" }|" datadog-lambda-python/pyproject.toml + else + sed -i "s|^ddtrace =.*$|ddtrace = { path = \"file://${path}\" }|" datadog-lambda-python/pyproject.toml + fi cp -r ./*.whl datadog-lambda-python/ elif [ "$(find . -maxdepth 1 -name "python-load-from-pip" | wc -l)" = "1" ]; then echo "Install ddtrace from $(cat python-load-from-pip)" @@ -44,11 +52,19 @@ if [[ $SKIP_BUILD -eq 0 ]]; then # Format with revision: ddtrace @ git+https://...@revision git_url="${BASH_REMATCH[1]}" git_rev="${BASH_REMATCH[2]}" - sed -i '' "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\", rev = \"${git_rev}\" }|" datadog-lambda-python/pyproject.toml + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\", rev = \"${git_rev}\" }|" datadog-lambda-python/pyproject.toml + else + sed -i "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\", rev = \"${git_rev}\" }|" datadog-lambda-python/pyproject.toml + fi elif [[ $pip_spec =~ ddtrace\ @\ git\+(.*)$ ]]; then # Format without revision: ddtrace @ git+https://... (defaults to main) git_url="${BASH_REMATCH[1]}" - sed -i '' "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\" }|" datadog-lambda-python/pyproject.toml + if [[ "$OSTYPE" == "darwin"* ]]; then + sed -i '' "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\" }|" datadog-lambda-python/pyproject.toml + else + sed -i "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\" }|" datadog-lambda-python/pyproject.toml + fi else echo "ERROR: Unable to parse git URL from python-load-from-pip format: $pip_spec" exit 1 From e455383ad54059e6949b7b968619b6cf74a84067 Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Fri, 4 Jul 2025 12:51:42 +0200 Subject: [PATCH 05/14] Move lambda weblog to a different library --- tests/appsec/test_traces.py | 7 +++---- utils/_context/containers.py | 2 +- utils/build/build.sh | 2 +- .../apigw-rest.Dockerfile} | 4 +--- .../{python => python_lambda}/build_lambda_layer.sh | 0 .../aws_lambda => python_lambda}/function/handler.py | 10 +++------- .../function}/requirements.txt | 0 7 files changed, 9 insertions(+), 16 deletions(-) rename utils/build/docker/{python/lambda-python-apigw-rest.Dockerfile => python_lambda/apigw-rest.Dockerfile} (72%) rename utils/build/docker/{python => python_lambda}/build_lambda_layer.sh (100%) rename utils/build/docker/{python/aws_lambda => python_lambda}/function/handler.py (93%) rename utils/build/docker/{python/aws_lambda => python_lambda/function}/requirements.txt (100%) diff --git a/tests/appsec/test_traces.py b/tests/appsec/test_traces.py index 53262925cbe..82aade8e3b9 100644 --- a/tests/appsec/test_traces.py +++ b/tests/appsec/test_traces.py @@ -2,7 +2,6 @@ # This product includes software developed at Datadog (https://www.datadoghq.com/). # Copyright 2021 Datadog, Inc. -from utils._context._scenarios import scenario_groups from utils.dd_constants import PYTHON_RELEASE_GA_1_1 from utils import weblog, bug, context, interfaces, irrelevant, rfc, missing_feature, scenarios, features from utils.tools import nested_lookup @@ -70,7 +69,7 @@ def setup_custom_span_tags(self): weblog.get("/waf", headers={"random-key": "acunetix-user-agreement"}) # rules.security_scanner.crs_913_110 @bug( - context.library.name == "python" and scenario_groups.appsec_lambda in context.scenario.scenario_groups, + context.library.name == "python_lambda", reason="APPSEC-58201", ) def test_custom_span_tags(self): @@ -120,7 +119,7 @@ def test_header_collection(self): @bug(context.library < "java@0.93.0", reason="APMRP-360") def test_root_span_coherence(self): """Appsec tags are not on span where type is not web, http or rpc""" - valid_appsec_span_types = ["web", "http", "rpc"] + valid_appsec_span_types = ["web", "http", "rpc", "serverless"] spans = [span for _, _, span in interfaces.library.get_spans()] assert spans, "No spans to validate" assert any("_dd.appsec.enabled" in s.get("metrics", {}) for s in spans), "No appsec-enabled spans found" @@ -305,7 +304,7 @@ def setup_header_collection(self): reason="The endpoint /headers is not implemented in the weblog", ) @bug( - scenario_groups.appsec_lambda in context.scenario.scenario_groups and context.library.name == "python", + context.library.name == "python_lambda", reason="APPSEC-58202", ) def test_header_collection(self): diff --git a/utils/_context/containers.py b/utils/_context/containers.py index 4b5a6489e9c..e02f43a106e 100644 --- a/utils/_context/containers.py +++ b/utils/_context/containers.py @@ -1121,7 +1121,7 @@ def configure(self, *, replay: bool): library = self.image.labels["system-tests-library"] - if library == "python": + if library == "python_lambda": self.environment["DD_LAMBDA_HANDLER"] = "handler.lambda_handler" self.command = "datadog_lambda.handler.handler" diff --git a/utils/build/build.sh b/utils/build/build.sh index 2b5f198920d..f69aa5c435b 100755 --- a/utils/build/build.sh +++ b/utils/build/build.sh @@ -283,7 +283,7 @@ COMMAND=build while [[ "$#" -gt 0 ]]; do case $1 in - cpp_nginx|cpp_httpd|dotnet|golang|java|java_otel|nodejs|nodejs_otel|php|python|python_otel|ruby) TEST_LIBRARY="$1";; + cpp_nginx|cpp_httpd|dotnet|golang|java|java_otel|nodejs|nodejs_otel|php|python|python_lambda|python_otel|ruby) TEST_LIBRARY="$1";; -l|--library) TEST_LIBRARY="$2"; shift ;; -i|--images) BUILD_IMAGES="$2"; shift ;; -d|--docker) DOCKER_MODE=1;; diff --git a/utils/build/docker/python/lambda-python-apigw-rest.Dockerfile b/utils/build/docker/python_lambda/apigw-rest.Dockerfile similarity index 72% rename from utils/build/docker/python/lambda-python-apigw-rest.Dockerfile rename to utils/build/docker/python_lambda/apigw-rest.Dockerfile index 2dda402fe4f..ccd0ad7131f 100644 --- a/utils/build/docker/python/lambda-python-apigw-rest.Dockerfile +++ b/utils/build/docker/python_lambda/apigw-rest.Dockerfile @@ -11,11 +11,9 @@ COPY binaries/*.zip /binaries/ RUN unzip /binaries/*.zip -d /opt # Setup the aws_lambda handler -COPY utils/build/docker/python/aws_lambda/requirements.txt ${LAMBDA_TASK_ROOT}/requirements.txt +COPY utils/build/docker/python_lambda/function/. ${LAMBDA_TASK_ROOT} RUN pip install -r ${LAMBDA_TASK_ROOT}/requirements.txt -COPY utils/build/docker/python/aws_lambda/function/handler.py ${LAMBDA_TASK_ROOT} - ENV DD_LAMBDA_HANDLER=handler.lambda_handler CMD ["datadog_lambda.handler.handler"] diff --git a/utils/build/docker/python/build_lambda_layer.sh b/utils/build/docker/python_lambda/build_lambda_layer.sh similarity index 100% rename from utils/build/docker/python/build_lambda_layer.sh rename to utils/build/docker/python_lambda/build_lambda_layer.sh diff --git a/utils/build/docker/python/aws_lambda/function/handler.py b/utils/build/docker/python_lambda/function/handler.py similarity index 93% rename from utils/build/docker/python/aws_lambda/function/handler.py rename to utils/build/docker/python_lambda/function/handler.py index 3f2966dc81e..e0e51f48077 100644 --- a/utils/build/docker/python/aws_lambda/function/handler.py +++ b/utils/build/docker/python_lambda/function/handler.py @@ -7,7 +7,7 @@ import logging import os -import ddtrace +import datadog_lambda from ddtrace.appsec import trace_utils as appsec_trace_utils from ddtrace.trace import tracer import urllib @@ -24,12 +24,8 @@ def version_info(): return { "status": "ok", "library": { - "name": "python", - "version": ddtrace.__version__, - }, - "extension": { - "name": "datadog-lambda-extension", - "version": os.environ.get("EXTENSION_VERSION", "unknown"), + "name": "python_lambda", + "version": datadog_lambda.__version__, }, } diff --git a/utils/build/docker/python/aws_lambda/requirements.txt b/utils/build/docker/python_lambda/function/requirements.txt similarity index 100% rename from utils/build/docker/python/aws_lambda/requirements.txt rename to utils/build/docker/python_lambda/function/requirements.txt From e0b9a0727d9669fea979b9530adb5b24226086d2 Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Fri, 4 Jul 2025 13:22:25 +0200 Subject: [PATCH 06/14] Add manifest for python_lambda --- .github/workflows/ci.yml | 2 +- .../workflows/compute-impacted-libraries.yml | 4 +- manifests/python_lambda.yml | 21 ++++++++++ utils/_decorators.py | 3 ++ utils/build/build.sh | 1 + .../python/aws_lambda/function/handler.py | 0 utils/scripts/compute-workflow-parameters.py | 1 + utils/scripts/compute_impacted_scenario.py | 1 + utils/scripts/get-image-list.py | 1 + utils/scripts/load-binary.sh | 38 +++++++++++++------ 10 files changed, 58 insertions(+), 14 deletions(-) create mode 100644 manifests/python_lambda.yml create mode 100644 utils/build/docker/python/aws_lambda/function/handler.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c8167564eb3..81751896e84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,7 @@ jobs: build_python_base_images: ${{ contains(github.event.pull_request.labels.*.name, 'build-python-base-images') }} build_buddies_images: ${{ contains(github.event.pull_request.labels.*.name, 'build-buddies-images') }} build_proxy_image: ${{ contains(github.event.pull_request.labels.*.name, 'build-proxy-image') }} - build_lambda_proxy_image: ${{ contains(github.event.pull_request.labels.*.name, 'build-lambda-proxy-image') }} + build_lambda_proxy_image: ${{ contains(github.event.pull_request.labels.*.name, 'build-lambda-proxy-image') && endsWith(matrix.library, '_lambda') }} build_lib_injection_app_images: ${{ contains(github.event.pull_request.labels.*.name, 'build-lib-injection-app-images') }} parametric_job_count: ${{ matrix.version == 'dev' && 2 || 1 }} # test both use cases skip_empty_scenarios: true diff --git a/.github/workflows/compute-impacted-libraries.yml b/.github/workflows/compute-impacted-libraries.yml index 6b6d4d4797a..481f2fdab5d 100644 --- a/.github/workflows/compute-impacted-libraries.yml +++ b/.github/workflows/compute-impacted-libraries.yml @@ -43,12 +43,12 @@ jobs: # temporary print to see what's hapenning on differents events print(json.dumps(github_context, indent=2)) - libraries = "cpp|cpp_httpd|cpp_nginx|dotnet|golang|java|nodejs|php|python|ruby|java_otel|python_otel|nodejs_otel" + libraries = "cpp|cpp_httpd|cpp_nginx|dotnet|golang|java|nodejs|php|python|ruby|java_otel|python_otel|nodejs_otel|python_lambda" result = set() # do not include otel in system-tests CI by default, as the staging backend is not stable enough # all_libraries = {"cpp", "dotnet", "golang", "java", "nodejs", "php", "python", "ruby", "java_otel", "python_otel", "nodejs_otel"} - all_libraries = {"cpp", "cpp_httpd", "cpp_nginx", "dotnet", "golang", "java", "nodejs", "php", "python", "ruby"} + all_libraries = {"cpp", "cpp_httpd", "cpp_nginx", "dotnet", "golang", "java", "nodejs", "php", "python", "ruby", "python_lambda"} if github_context["ref"] == "refs/heads/main": print("Merge commit to main => run all libraries") diff --git a/manifests/python_lambda.yml b/manifests/python_lambda.yml new file mode 100644 index 00000000000..2175c019aab --- /dev/null +++ b/manifests/python_lambda.yml @@ -0,0 +1,21 @@ +--- +tests/: + appsec/: + test_alpha.py: + Test_Basic: 6.111.0 + test_only_python.py: + Test_ImportError: 6.111.0 + test_reports.py: + Test_ExtraTagsFromRule: 6.111.0 + Test_Info: 6.111.0 + Test_RequestHeaders: 6.111.0 + Test_StatusCode: 6.111.0 + test_traces.py: + Test_AppSecEventSpanTags: 6.111.0 + Test_AppSecObfuscator: 6.111.0 + Test_CollectDefaultRequestHeader: 6.111.0 + Test_CollectRespondHeaders: 6.111.0 + Test_ExternalWafRequestsIdentification: 6.111.0 + Test_RetainTraces: 6.111.0 + test_versions.py: + Test_Events: 6.111.0 diff --git a/utils/_decorators.py b/utils/_decorators.py index 029dc49caba..d0fd0c1c156 100644 --- a/utils/_decorators.py +++ b/utils/_decorators.py @@ -101,6 +101,7 @@ def _expected_to_fail(condition: bool | None = None, library: str | None = None, "java_otel", "python_otel", "nodejs_otel", + "python_lambda", ): raise ValueError(f"Unknown library: {library}") @@ -242,6 +243,7 @@ def released( agent: str | None = None, dd_apm_inject: str | None = None, k8s_cluster_agent: str | None = None, + python_lambda: str | None = None, ): """Class decorator, allow to mark a test class with a version number of a component""" @@ -296,6 +298,7 @@ def compute_declaration( compute_declaration("php", "php", php, context.library.version), compute_declaration("python", "python", python, context.library.version), compute_declaration("python_otel", "python_otel", python_otel, context.library.version), + compute_declaration("python_lambda", "python_lambda", python_lambda, context.library.version), compute_declaration("ruby", "ruby", ruby, context.library.version), compute_declaration("*", "agent", agent, context.agent_version), compute_declaration("*", "dd_apm_inject", dd_apm_inject, context.dd_apm_inject_version), diff --git a/utils/build/build.sh b/utils/build/build.sh index f69aa5c435b..a6f16ef0247 100755 --- a/utils/build/build.sh +++ b/utils/build/build.sh @@ -37,6 +37,7 @@ readonly DEFAULT_dotnet=poc readonly DEFAULT_cpp=nginx readonly DEFAULT_cpp_httpd=httpd readonly DEFAULT_cpp_nginx=nginx +readonly DEFAULT_python_lambda=apigw-rest readonly SCRIPT_NAME="${0}" readonly SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" diff --git a/utils/build/docker/python/aws_lambda/function/handler.py b/utils/build/docker/python/aws_lambda/function/handler.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/utils/scripts/compute-workflow-parameters.py b/utils/scripts/compute-workflow-parameters.py index eaa7f1b02ef..7f11a4e8b78 100644 --- a/utils/scripts/compute-workflow-parameters.py +++ b/utils/scripts/compute-workflow-parameters.py @@ -237,6 +237,7 @@ def _get_workflow_map( "java_otel", "nodejs_otel", "python_otel", + "python_lambda", ], ) diff --git a/utils/scripts/compute_impacted_scenario.py b/utils/scripts/compute_impacted_scenario.py index b4d1a5ece26..8e1f9e8ef9b 100644 --- a/utils/scripts/compute_impacted_scenario.py +++ b/utils/scripts/compute_impacted_scenario.py @@ -164,6 +164,7 @@ def main() -> None: r"utils/build/docker/java_otel/.*": scenario_groups.open_telemetry, r"utils/build/docker/nodejs_otel/.*": scenario_groups.open_telemetry, r"utils/build/docker/python_otel/.*": scenario_groups.open_telemetry, + r"utils/build/docker/python_lambda/.*": scenario_groups.appsec_lambda, r"utils/build/docker/\w+/parametric/.*": scenarios.parametric, r"utils/build/docker/.*": [ scenario_groups.end_to_end, diff --git a/utils/scripts/get-image-list.py b/utils/scripts/get-image-list.py index 6b8a9f89b84..ead33f700d2 100644 --- a/utils/scripts/get-image-list.py +++ b/utils/scripts/get-image-list.py @@ -54,6 +54,7 @@ def main(scenarios: list[str], library: str, weblog: str) -> None: "java_otel", "python_otel", "nodejs_otel", + "python_lambda", "", ], ) diff --git a/utils/scripts/load-binary.sh b/utils/scripts/load-binary.sh index ffd9b361d19..88c1abd8728 100755 --- a/utils/scripts/load-binary.sh +++ b/utils/scripts/load-binary.sh @@ -10,17 +10,18 @@ # # Binaries sources: # -# * Agent: Docker hub datadog/agent-dev:master-py3 -# * cpp_httpd: Github action artifact -# * Golang: github.com/DataDog/dd-trace-go/v2@main -# * .NET: ghcr.io/datadog/dd-trace-dotnet -# * Java: S3 -# * PHP: ghcr.io/datadog/dd-trace-php -# * Node.js: Direct from github source -# * C++: Direct from github source -# * Python: Clone locally the githu repo -# * Ruby: Direct from github source -# * WAF: Direct from github source, but not working, as this repo is now private +# * Agent: Docker hub datadog/agent-dev:master-py3 +# * cpp_httpd: Github action artifact +# * Golang: github.com/DataDog/dd-trace-go/v2@main +# * .NET: ghcr.io/datadog/dd-trace-dotnet +# * Java: S3 +# * PHP: ghcr.io/datadog/dd-trace-php +# * Node.js: Direct from github source +# * C++: Direct from github source +# * Python: Clone locally the github repo +# * Ruby: Direct from github source +# * WAF: Direct from github source, but not working, as this repo is now private +# * Python Lambda: Clone locally the github repo ########################################################################################## set -eu @@ -322,6 +323,21 @@ elif [ "$TARGET" = "waf_rule_set" ]; then -H "Authorization: token $GH_TOKEN" \ -H "Accept: application/vnd.github.v3.raw" \ https://api.github.com/repos/DataDog/appsec-event-rules/contents/build/recommended.json +elif [ "$TARGET" = "python_lambda" ]; then + assert_version_is_dev + assert_target_branch_is_not_set + + rm -rf dd-trace-py/ + rm -rf datadog-lambda-python/ + # do not use `--depth 1`, setuptools_scm, does not like it + git clone https://github.com/DataDog/dd-trace-py.git + git clone https://github.com/DataDog/datadog-lambda-python.git + cd dd-trace-py + echo "Checking out the ddtrace ref" + git log -1 --format=%H + cd ../datadog-lambda-python + echo "Checking out the datadog_lambda ref" + git log -1 --format=%H else echo "Unknown target: $1" From 9ee01f00de9b175b5523c5835e3853022e021a8a Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Fri, 4 Jul 2025 14:05:33 +0200 Subject: [PATCH 07/14] Fix build lambda layer issue --- .github/workflows/ci.yml | 1 - .../workflows/compute-impacted-libraries.yml | 2 +- .../workflows/compute-workflow-parameters.yml | 2 +- .github/workflows/run-end-to-end.yml | 14 ++- .github/workflows/system-tests.yml | 6 -- utils/_context/_scenarios/aws_lambda.py | 2 +- utils/_context/containers.py | 3 +- utils/build/build.sh | 2 +- utils/build/docker/lambda_proxy/main.py | 34 +++++-- .../python/aws_lambda/function/handler.py | 0 .../python_lambda/build_lambda_layer.sh | 93 +++++++++---------- .../docker/python_lambda/function/handler.py | 90 +++++++++++------- .../scripts/ci_orchestrators/workflow_data.py | 4 + utils/scripts/compute-workflow-parameters.py | 1 + utils/scripts/load-binary.sh | 11 ++- 15 files changed, 148 insertions(+), 117 deletions(-) delete mode 100644 utils/build/docker/python/aws_lambda/function/handler.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81751896e84..4c7ee14303e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -85,7 +85,6 @@ jobs: build_python_base_images: ${{ contains(github.event.pull_request.labels.*.name, 'build-python-base-images') }} build_buddies_images: ${{ contains(github.event.pull_request.labels.*.name, 'build-buddies-images') }} build_proxy_image: ${{ contains(github.event.pull_request.labels.*.name, 'build-proxy-image') }} - build_lambda_proxy_image: ${{ contains(github.event.pull_request.labels.*.name, 'build-lambda-proxy-image') && endsWith(matrix.library, '_lambda') }} build_lib_injection_app_images: ${{ contains(github.event.pull_request.labels.*.name, 'build-lib-injection-app-images') }} parametric_job_count: ${{ matrix.version == 'dev' && 2 || 1 }} # test both use cases skip_empty_scenarios: true diff --git a/.github/workflows/compute-impacted-libraries.yml b/.github/workflows/compute-impacted-libraries.yml index 481f2fdab5d..ce562a3b921 100644 --- a/.github/workflows/compute-impacted-libraries.yml +++ b/.github/workflows/compute-impacted-libraries.yml @@ -48,7 +48,7 @@ jobs: # do not include otel in system-tests CI by default, as the staging backend is not stable enough # all_libraries = {"cpp", "dotnet", "golang", "java", "nodejs", "php", "python", "ruby", "java_otel", "python_otel", "nodejs_otel"} - all_libraries = {"cpp", "cpp_httpd", "cpp_nginx", "dotnet", "golang", "java", "nodejs", "php", "python", "ruby", "python_lambda"} + all_libraries = {"cpp", "cpp_httpd", "cpp_nginx", "dotnet", "golang", "java", "nodejs", "php", "python", "ruby"} if github_context["ref"] == "refs/heads/main": print("Merge commit to main => run all libraries") diff --git a/.github/workflows/compute-workflow-parameters.yml b/.github/workflows/compute-workflow-parameters.yml index f3a787cc966..cae59d1143e 100644 --- a/.github/workflows/compute-workflow-parameters.yml +++ b/.github/workflows/compute-workflow-parameters.yml @@ -175,7 +175,7 @@ jobs: with: name: binaries_dev_${{ inputs.library }} path: binaries/ - include-hidden-files: ${{ inputs.library == 'python' }} + include-hidden-files: ${{ inputs.library == 'python' || inputs.library == 'python_lambda' }} - name: Set unique ID id: unique_id run: echo "value=$(openssl rand -hex 8)" >> $GITHUB_OUTPUT diff --git a/.github/workflows/run-end-to-end.yml b/.github/workflows/run-end-to-end.yml index 0dd632c7871..bc69d19de54 100644 --- a/.github/workflows/run-end-to-end.yml +++ b/.github/workflows/run-end-to-end.yml @@ -54,11 +54,6 @@ on: default: false required: false type: boolean - build_lambda_proxy_image: - description: "Shall we build lambda proxy image" - default: false - required: false - type: boolean push_to_feature_parity_dashbaord: description: "Shall we push results to Feature Parity Dashbaord" default: false @@ -130,9 +125,6 @@ jobs: - name: Build proxy image if: inputs.build_proxy_image run: ./build.sh -i proxy - - name: Build lambda proxy image - if: inputs.build_lambda_proxy_image - run: ./build.sh -i lambda-proxy - name: Pull images uses: ./.github/actions/pull_images with: @@ -148,6 +140,9 @@ jobs: - name: Build weblog id: build run: SYSTEM_TEST_BUILD_ATTEMPTS=3 ./build.sh ${{ inputs.library }} -i weblog -w ${{ inputs.weblog }} + - name: Build Lambda Proxy + if: ${{ endsWith(inputs.library, '_lambda') }} + run: ./build.sh python_lambda -i lambda-proxy - name: Run APPSEC_STANDALONE scenario if: always() && steps.build.outcome == 'success' && contains(inputs.scenarios, '"APPSEC_STANDALONE"') @@ -428,6 +423,9 @@ jobs: DD_APP_KEY_2: ${{ secrets.DD_APP_KEY_2 }} DD_API_KEY_3: ${{ secrets.DD_API_KEY_3 }} DD_APP_KEY_3: ${{ secrets.DD_APP_KEY_3 }} + - name: Run APPSEC_LAMBDA_DEFAULT scenario + if: always() && steps.build.outcome == 'success' && contains(inputs.scenarios, '"APPSEC_LAMBDA_DEFAULT"') + run: ./run.sh APPSEC_LAMBDA_DEFAULT - name: Run all scenarios in replay mode if: success() && steps.build.outcome == 'success' && inputs.enable_replay_scenarios run: utils/scripts/replay_scenarios.sh diff --git a/.github/workflows/system-tests.yml b/.github/workflows/system-tests.yml index 4acff697a7a..3f889c9ddd8 100644 --- a/.github/workflows/system-tests.yml +++ b/.github/workflows/system-tests.yml @@ -59,11 +59,6 @@ on: default: false required: false type: boolean - build_lambda_proxy_image: - description: "Shall we build lambda proxy image" - default: false - required: false - type: boolean build_lib_injection_app_images: description: "Shall we build and push k8s lib injection weblog images" default: false @@ -246,7 +241,6 @@ jobs: force_execute: ${{ inputs.force_execute }} build_buddies_images: ${{ inputs.build_buddies_images }} build_proxy_image: ${{ inputs.build_proxy_image }} - build_lambda_proxy_image: ${{ inputs.build_lambda_proxy_image }} binaries_artifact: binaries_${{ needs.compute_parameters.outputs.ci_environment }}_${{ inputs.library }}_${{ matrix.job.weblog }}_${{ needs.compute_parameters.outputs.unique_id }} ci_environment: ${{ needs.compute_parameters.outputs.ci_environment }} skip_empty_scenarios: ${{ inputs.skip_empty_scenarios }} diff --git a/utils/_context/_scenarios/aws_lambda.py b/utils/_context/_scenarios/aws_lambda.py index e36257b6983..4ba8ee3a6f7 100644 --- a/utils/_context/_scenarios/aws_lambda.py +++ b/utils/_context/_scenarios/aws_lambda.py @@ -106,7 +106,7 @@ def configure(self, config: pytest.Config): self.library_interface_timeout = 0 elif library in ("php",): self.library_interface_timeout = 10 - elif library in ("python",): + elif library in ("python", "python_lambda"): self.library_interface_timeout = 5 else: self.library_interface_timeout = 40 diff --git a/utils/_context/containers.py b/utils/_context/containers.py index e02f43a106e..a96b979b248 100644 --- a/utils/_context/containers.py +++ b/utils/_context/containers.py @@ -619,7 +619,7 @@ def __init__( self.container_port = "7777" super().__init__( - image_name="datadog/system-tests:lambda-proxy", + image_name="system_tests/lambda-proxy", name="lambda-proxy", host_log_folder=host_log_folder, environment={ @@ -633,6 +633,7 @@ def __init__( "test": f"curl --fail --silent --show-error --max-time 2 localhost:{self.container_port}/healthcheck", "retries": 60, }, + local_image_only=True, ) diff --git a/utils/build/build.sh b/utils/build/build.sh index a6f16ef0247..6c5480de95e 100755 --- a/utils/build/build.sh +++ b/utils/build/build.sh @@ -269,7 +269,7 @@ build() { --load \ --progress=plain \ -f utils/build/docker/lambda-proxy.Dockerfile \ - -t datadog/system-tests:lambda-proxy \ + -t system_tests/lambda-proxy \ $EXTRA_DOCKER_ARGS \ . else diff --git a/utils/build/docker/lambda_proxy/main.py b/utils/build/docker/lambda_proxy/main.py index f8add9f0188..0568a963617 100644 --- a/utils/build/docker/lambda_proxy/main.py +++ b/utils/build/docker/lambda_proxy/main.py @@ -1,12 +1,11 @@ import os -import sys -from samcli.local.apigw.event_constructor import construct_v1_event -from samcli.local.apigw.local_apigw_service import LocalApigwService -# Create super simple catch-all flask app from flask import Flask, request from requests import post +from samcli.local.apigw.event_constructor import construct_v1_event +from samcli.local.apigw.local_apigw_service import LocalApigwService + PORT = 7777 RIE_HOST = os.environ.get("RIE_HOST", "lambda-weblog") @@ -16,11 +15,15 @@ app = Flask(__name__) +app.config["PROVIDE_AUTOMATIC_OPTIONS"] = False + -@app.route("/") -@app.route("/") -def main(path=""): - converted_event = construct_v1_event(request, PORT, binary_types=[], stage_name="Prod") +def invoke_lambda_function(): + """ + This function is used to invoke the Lambda function with the provided event. + It constructs a v1 event from the Flask request and sends it to the RIE URL. + """ + converted_event = construct_v1_event(request, PORT, binary_types=["application/octet-stream"], stage_name="Prod") response = post( RIE_URL, @@ -33,3 +36,18 @@ def main(path=""): ) return app.response_class(response=body, status=status_code, headers=headers) + + +@app.route("/", methods=["GET", "POST", "OPTIONS"]) +@app.route("/finger_print") +@app.get("/headers") +@app.get("/healthcheck") +@app.route("/params//", methods=["GET", "POST", "OPTIONS"]) +@app.route("/tag_value//", methods=["GET", "POST", "OPTIONS"]) +@app.get("/users") +@app.route("/waf", methods=["GET", "POST", "OPTIONS"]) +@app.route("/waf/", methods=["GET", "POST", "OPTIONS"]) +@app.route("/waf/", methods=["GET", "POST", "OPTIONS"]) +@app.get("/.git") +def main(**kwargs): + return invoke_lambda_function() diff --git a/utils/build/docker/python/aws_lambda/function/handler.py b/utils/build/docker/python/aws_lambda/function/handler.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/utils/build/docker/python_lambda/build_lambda_layer.sh b/utils/build/docker/python_lambda/build_lambda_layer.sh index 45e1826a569..61893691214 100755 --- a/utils/build/docker/python_lambda/build_lambda_layer.sh +++ b/utils/build/docker/python_lambda/build_lambda_layer.sh @@ -4,38 +4,72 @@ set -euo pipefail ARCH=${ARCH:-$(uname -m)} PYTHON_VERSION=${PYTHON_VERSION:-3.13} +PYTHON_VERSION_NO_DOT=$(echo "$PYTHON_VERSION" | tr -d '.') if [ "$ARCH" = "x86_64" ]; then ARCH="amd64" fi +if [ "$ARCH" = "aarch64" ]; then + ARCH="arm64" +fi cd binaries SKIP_BUILD=0 -CLONED_REPO=0 if [ -e "datadog-lambda-python" ]; then echo "datadog-lambda-python already exists, skipping clone." elif [ "$(find . -maxdepth 1 -name "*.zip" | wc -l)" = "1" ]; then echo "Using provided datadog-lambda-python layer." SKIP_BUILD=1 +elif command -v aws >/dev/null 2>&1 && aws sts get-caller-identity >/dev/null 2>&1; then + echo "AWS credentials detected. Downloading datadog-lambda-python layer from AWS..." + REGION=${AWS_DEFAULT_REGION:-us-east-1} + ARCH_SUFFIX=$(if [ "$ARCH" = "arm64" ]; then echo "-ARM"; else echo ""; fi) + LAMBDA_LAYER_NAME="arn:aws:lambda:$REGION:464622532012:layer:Datadog-Python$PYTHON_VERSION_NO_DOT$ARCH_SUFFIX" + + # The layer version cannot be retrieve directly from AWS but is present as the minor version in the datadog_lambda package + # in the PyPI index. + LAYER_VERSION=$(head -n 1 <(pip index versions datadog_lambda) | cut -d '.' -f2) + + if [ "$LAYER_VERSION" != "None" ] && [ -n "$LAYER_VERSION" ]; then + echo "Downloading layer version $LAYER_VERSION for $LAMBDA_LAYER_NAME from region $REGION" + + # Get the download URL + DOWNLOAD_URL=$(aws lambda get-layer-version \ + --layer-name "$LAMBDA_LAYER_NAME" \ + --version-number "$LAYER_VERSION" \ + --query 'Content.Location' \ + --output text \ + --region "$REGION") + + if [ -n "$DOWNLOAD_URL" ] && [ "$DOWNLOAD_URL" != "None" ]; then + # Download the layer + curl -L "$DOWNLOAD_URL" -o "datadog_lambda_py-${ARCH}-${PYTHON_VERSION}.zip" + echo "Successfully downloaded datadog-lambda-python layer from AWS" + SKIP_BUILD=1 + else + echo "Failed to get download URL for layer. Falling back to git clone..." + exit 1 + fi + else + echo "No layer version found. Falling back to git clone..." + exit 1 + fi else - echo "Cloning datadog-lambda-python repository..." - git clone --depth 1 https://github.com/DataDog/datadog-lambda-python.git - CLONED_REPO=1 + echo "Impossible to download datadog-lambda-python layer from AWS and no local layer provided, Aborting." + exit 1 fi -# Patch the ddtrace dependency in datadog-lambda-python based on the same rules as install_ddtrace.sh if [[ $SKIP_BUILD -eq 0 ]]; then - if [ -e "dd-trace-py" ]; then - echo "Install from local folder /binaries/dd-trace-py" + if [ -e "datdog-lambda-python/dd-trace-py" ]; then + echo "Install from local folder /binaries/datadog-lambda-python/dd-trace-py" if [[ "$OSTYPE" == "darwin"* ]]; then sed -i '' 's|^ddtrace =.*$|ddtrace = { path = "./dd-trace-py" }|' datadog-lambda-python/pyproject.toml else sed -i 's|^ddtrace =.*$|ddtrace = { path = "./dd-trace-py" }|' datadog-lambda-python/pyproject.toml fi - cp -r dd-trace-py datadog-lambda-python/dd-trace-py - elif [ "$(find . -maxdepth 1 -name "*.whl" | wc -l)" = "1" ]; then + elif [ "$(find . -maxdepth 1 -name "datadog-lambda-python/*.whl" | wc -l)" = "1" ]; then path=$(readlink -f "$(find . -maxdepth 1 -name "*.whl")") echo "Install ddtrace from ${path}" if [[ "$OSTYPE" == "darwin"* ]]; then @@ -43,32 +77,6 @@ if [[ $SKIP_BUILD -eq 0 ]]; then else sed -i "s|^ddtrace =.*$|ddtrace = { path = \"file://${path}\" }|" datadog-lambda-python/pyproject.toml fi - cp -r ./*.whl datadog-lambda-python/ - elif [ "$(find . -maxdepth 1 -name "python-load-from-pip" | wc -l)" = "1" ]; then - echo "Install ddtrace from $(cat python-load-from-pip)" - - pip_spec=$(cat python-load-from-pip) - if [[ $pip_spec =~ ddtrace\ @\ git\+(.*)@(.*)$ ]]; then - # Format with revision: ddtrace @ git+https://...@revision - git_url="${BASH_REMATCH[1]}" - git_rev="${BASH_REMATCH[2]}" - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\", rev = \"${git_rev}\" }|" datadog-lambda-python/pyproject.toml - else - sed -i "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\", rev = \"${git_rev}\" }|" datadog-lambda-python/pyproject.toml - fi - elif [[ $pip_spec =~ ddtrace\ @\ git\+(.*)$ ]]; then - # Format without revision: ddtrace @ git+https://... (defaults to main) - git_url="${BASH_REMATCH[1]}" - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\" }|" datadog-lambda-python/pyproject.toml - else - sed -i "s|^ddtrace =.*$|ddtrace = { git = \"${git_url}\" }|" datadog-lambda-python/pyproject.toml - fi - else - echo "ERROR: Unable to parse git URL from python-load-from-pip format: $pip_spec" - exit 1 - fi elif [ "$(find . -maxdepth 1 -name "*.whl" | wc -l)" = "0" ]; then echo "Install ddtrace from pypi" # Keep the default ddtrace dependency in pyproject.toml @@ -76,24 +84,13 @@ if [[ $SKIP_BUILD -eq 0 ]]; then echo "ERROR: Found several wheel files in binaries/, abort." exit 1 fi + # Build the datadog-lambda-python package cd datadog-lambda-python - ARCH=$ARCH PYTHON_VERSION=$PYTHON_VERSION ./scripts/build_layers.sh + ARCH=$ARCH PYTHON_VERSION=$PYTHON_VERSION bash ./scripts/build_layers.sh mv .layers/*.zip ../ cd .. - - # Clean up the datadog-lambda-python directory - if [ "$CLONED_REPO" -eq 1 ]; then - echo "Removing datadog-lambda-python directory..." - rm -rf datadog-lambda-python - else - # Restore the original pyproject.toml if it was not cloned - cd datadog-lambda-python - git checkout -- pyproject.toml - rm -rf dd-trace-py ./*.whl - cd .. - fi fi cd .. diff --git a/utils/build/docker/python_lambda/function/handler.py b/utils/build/docker/python_lambda/function/handler.py index e0e51f48077..81ec7d83e2d 100644 --- a/utils/build/docker/python_lambda/function/handler.py +++ b/utils/build/docker/python_lambda/function/handler.py @@ -1,16 +1,18 @@ -from typing import Any +import logging +import urllib import urllib.parse + +from typing import Any + from aws_lambda_powertools.event_handler import APIGatewayRestResolver from aws_lambda_powertools.utilities.typing.lambda_context import LambdaContext from aws_lambda_powertools.event_handler import Response -import logging -import os - import datadog_lambda from ddtrace.appsec import trace_utils as appsec_trace_utils +from ddtrace.contrib.trace_utils import set_user from ddtrace.trace import tracer -import urllib + logger = logging.getLogger(__name__) @@ -30,6 +32,32 @@ def version_info(): } +@app.get("/tag_value//") +@app.route("/tag_value//", method="OPTIONS") +def tag_value(tag_value: str, status_code: int): + appsec_trace_utils.track_custom_event( + tracer, event_name=_TRACK_CUSTOM_APPSEC_EVENT_NAME, metadata={"value": tag_value} + ) + return Response( + status_code=status_code, + content_type="text/plain", + body="Value tagged", + headers=app.current_event.query_string_parameters, + ) + + +@app.get("/") +@app.post("/") +@app.route("/", method="OPTIONS") +def root(): + return Response(status_code=200, content_type="text/plain", body="Hello, World!\n") + + +@app.get("/headers") +def headers(): + return Response(status_code=200, body="OK", headers={"Content-Language": "en-US", "Content-Type": "text/plain"}) + + @app.get("/healthcheck") def healthcheck_route(): return Response( @@ -39,18 +67,14 @@ def healthcheck_route(): ) +@app.get("/params/") +@app.post("/params/") +@app.route("/params/", method="OPTIONS") @app.get("/waf/") @app.post("/waf/") -def waf(): - return Response( - status_code=200, - content_type="text/plain", - body="Hello, World!\n", - ) - - @app.get("/waf/") @app.post("/waf/") +@app.route("/waf/", method="OPTIONS") def waf_params(path: str): return Response( status_code=200, @@ -59,20 +83,6 @@ def waf_params(path: str): ) -@app.get("/tag_value//") -@app.post("/tag_value//") -def tag_value(tag_value: str, status_code: int): - appsec_trace_utils.track_custom_event( - tracer, event_name=_TRACK_CUSTOM_APPSEC_EVENT_NAME, metadata={"value": tag_value} - ) - return Response( - status_code=status_code, - content_type="text/plain", - body="Value tagged", - headers=app.current_event.query_string_parameters, - ) - - @app.post("/tag_value//") def tag_value_post(tag_value: str, status_code: int): appsec_trace_utils.track_custom_event( @@ -102,15 +112,23 @@ def tag_value_post(tag_value: str, status_code: int): ) -@app.get("/headers") -def headers(): - return Response(status_code=200, body="OK", headers={"Content-Language": "en-US", "Content-Type": "text/plain"}) - - -@app.get("/") -@app.post("/") -def root(): - return Response(status_code=200, content_type="text/plain", body="Hello, World!\n") +@app.get("/users") +def users(): + user = app.current_event.query_string_parameters.get("user") + set_user( + tracer, + user_id=user, + email="usr.email", + name="usr.name", + session_id="usr.session_id", + role="usr.role", + scope="usr.scope", + ) + return Response( + status_code=200, + content_type="text/plain", + body="Ok", + ) def lambda_handler(event: dict[str, Any], context: LambdaContext): diff --git a/utils/scripts/ci_orchestrators/workflow_data.py b/utils/scripts/ci_orchestrators/workflow_data.py index f1465ef8f79..a8a8e6687d5 100644 --- a/utils/scripts/ci_orchestrators/workflow_data.py +++ b/utils/scripts/ci_orchestrators/workflow_data.py @@ -421,6 +421,10 @@ def _filter_scenarios(scenarios: list[str], library: str, weblog: str, ci_enviro def _is_supported(library: str, weblog: str, scenario: str, _ci_environment: str) -> bool: # this function will remove some couple scenarios/weblog that are not supported + # Only Allow Lambda scenarios for the lambda libraries + if ("lambda" in library) != ("LAMBDA" in scenario): + return False + # open-telemetry-automatic if scenario == "OTEL_INTEGRATIONS": possible_values: tuple = ( diff --git a/utils/scripts/compute-workflow-parameters.py b/utils/scripts/compute-workflow-parameters.py index 7f11a4e8b78..95f7224025f 100644 --- a/utils/scripts/compute-workflow-parameters.py +++ b/utils/scripts/compute-workflow-parameters.py @@ -87,6 +87,7 @@ def __init__( "job_matrix": list(range(1, parametric_job_count + 1)), "enable": len(scenario_map["parametric"]) > 0 and "otel" not in library + and "lambda" not in library and library not in ("cpp_nginx", "cpp_httpd"), } diff --git a/utils/scripts/load-binary.sh b/utils/scripts/load-binary.sh index 88c1abd8728..8ea4961a901 100755 --- a/utils/scripts/load-binary.sh +++ b/utils/scripts/load-binary.sh @@ -327,17 +327,18 @@ elif [ "$TARGET" = "python_lambda" ]; then assert_version_is_dev assert_target_branch_is_not_set - rm -rf dd-trace-py/ rm -rf datadog-lambda-python/ + + git clone https://github.com/DataDog/datadog-lambda-python.git + cd datadog-lambda-python + echo "Checking out the datadog_lambda ref" + git log -1 --format=%H + # do not use `--depth 1`, setuptools_scm, does not like it git clone https://github.com/DataDog/dd-trace-py.git - git clone https://github.com/DataDog/datadog-lambda-python.git cd dd-trace-py echo "Checking out the ddtrace ref" git log -1 --format=%H - cd ../datadog-lambda-python - echo "Checking out the datadog_lambda ref" - git log -1 --format=%H else echo "Unknown target: $1" From 00ccdf607f8405834f913b62fd1161d54e468045 Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Tue, 8 Jul 2025 13:53:06 +0200 Subject: [PATCH 08/14] Easy fixes after review --- .../workflows/compute-impacted-libraries.yml | 2 +- tests/appsec/test_traces.py | 10 +-- tests/test_the_test/test_group_rules.py | 1 - utils/_context/_scenarios/__init__.py | 1 - utils/_context/_scenarios/aws_lambda.py | 79 ++++--------------- utils/_context/_scenarios/core.py | 1 - utils/_context/containers.py | 58 ++++---------- .../python_lambda/apigw-rest.Dockerfile | 5 +- .../docker/python_lambda/function/app.sh | 7 ++ .../docker/python_lambda/function/handler.py | 2 +- .../scripts/ci_orchestrators/workflow_data.py | 4 +- utils/scripts/compute-workflow-parameters.py | 3 +- utils/scripts/compute_impacted_scenario.py | 3 +- 13 files changed, 51 insertions(+), 125 deletions(-) create mode 100644 utils/build/docker/python_lambda/function/app.sh diff --git a/.github/workflows/compute-impacted-libraries.yml b/.github/workflows/compute-impacted-libraries.yml index ce562a3b921..481f2fdab5d 100644 --- a/.github/workflows/compute-impacted-libraries.yml +++ b/.github/workflows/compute-impacted-libraries.yml @@ -48,7 +48,7 @@ jobs: # do not include otel in system-tests CI by default, as the staging backend is not stable enough # all_libraries = {"cpp", "dotnet", "golang", "java", "nodejs", "php", "python", "ruby", "java_otel", "python_otel", "nodejs_otel"} - all_libraries = {"cpp", "cpp_httpd", "cpp_nginx", "dotnet", "golang", "java", "nodejs", "php", "python", "ruby"} + all_libraries = {"cpp", "cpp_httpd", "cpp_nginx", "dotnet", "golang", "java", "nodejs", "php", "python", "ruby", "python_lambda"} if github_context["ref"] == "refs/heads/main": print("Merge commit to main => run all libraries") diff --git a/tests/appsec/test_traces.py b/tests/appsec/test_traces.py index 82aade8e3b9..5b0a067a2f6 100644 --- a/tests/appsec/test_traces.py +++ b/tests/appsec/test_traces.py @@ -68,10 +68,7 @@ def setup_custom_span_tags(self): weblog.get("/waf", params={"key": "\n :"}) # rules.http_protocol_violation.crs_921_160 weblog.get("/waf", headers={"random-key": "acunetix-user-agreement"}) # rules.security_scanner.crs_913_110 - @bug( - context.library.name == "python_lambda", - reason="APPSEC-58201", - ) + @bug(library="python_lambda", reason="APPSEC-58201") def test_custom_span_tags(self): """AppSec should store in all APM spans some tags when enabled.""" @@ -303,10 +300,7 @@ def setup_header_collection(self): context.scenario is scenarios.external_processing, reason="The endpoint /headers is not implemented in the weblog", ) - @bug( - context.library.name == "python_lambda", - reason="APPSEC-58202", - ) + @bug(library="python_lambda", reason="APPSEC-58202") def test_header_collection(self): def assert_header_in_span_meta(span, header): if header not in span["meta"]: diff --git a/tests/test_the_test/test_group_rules.py b/tests/test_the_test/test_group_rules.py index f7c744749d4..2201e8b22c8 100644 --- a/tests/test_the_test/test_group_rules.py +++ b/tests/test_the_test/test_group_rules.py @@ -62,7 +62,6 @@ def test_tracer_release(): scenarios.simple_installer_auto_injection, scenarios.multi_installer_auto_injection, scenarios.demo_aws, - scenarios.appsec_lambda_default, ] for scenario in get_all_scenarios(): diff --git a/utils/_context/_scenarios/__init__.py b/utils/_context/_scenarios/__init__.py index b027e8a39a1..dfcc1e97d83 100644 --- a/utils/_context/_scenarios/__init__.py +++ b/utils/_context/_scenarios/__init__.py @@ -1083,7 +1083,6 @@ class _Scenarios: appsec_lambda_default = LambdaScenario( "APPSEC_LAMBDA_DEFAULT", doc="Default Lambda scenario", - scenario_groups=[scenario_groups.appsec_lambda], ) diff --git a/utils/_context/_scenarios/aws_lambda.py b/utils/_context/_scenarios/aws_lambda.py index 4ba8ee3a6f7..fd23800f4dc 100644 --- a/utils/_context/_scenarios/aws_lambda.py +++ b/utils/_context/_scenarios/aws_lambda.py @@ -1,10 +1,10 @@ -import os import pytest from utils import interfaces from utils._context._scenarios.core import ScenarioGroup from utils._context.containers import LambdaProxyContainer, LambdaWeblogContainer from utils._logger import logger from .endtoend import DockerScenario, ProxyBasedInterfaceValidator +from .core import scenario_groups as all_scenario_groups class LambdaScenario(DockerScenario): @@ -25,21 +25,15 @@ def __init__( github_workflow: str = "endtoend", doc: str, scenario_groups: list[ScenarioGroup] | None = None, - agent_interface_timeout: int = 5, - backend_interface_timeout: int = 0, - library_interface_timeout: int | None = None, - use_proxy_for_weblog: bool = True, - use_proxy_for_agent: bool = True, - require_api_key: bool = False, weblog_env: dict[str, str | None] | None = None, weblog_volumes: dict[str, dict[str, str]] | None = None, ): - use_proxy = use_proxy_for_weblog or use_proxy_for_agent - self._require_api_key = require_api_key + scenario_groups = [ + all_scenario_groups.appsec, + all_scenario_groups.tracer_release, + ] + (scenario_groups or []) - super().__init__( - name, github_workflow=github_workflow, doc=doc, use_proxy=use_proxy, scenario_groups=scenario_groups - ) + super().__init__(name, github_workflow=github_workflow, doc=doc, scenario_groups=scenario_groups) self.lambda_weblog = LambdaWeblogContainer( host_log_folder=self.host_log_folder, @@ -54,64 +48,24 @@ def __init__( ) self.lambda_proxy_container.depends_on.append(self.lambda_weblog) + self.lambda_weblog.depends_on.append(self.proxy_container) - if use_proxy: - self.lambda_weblog.depends_on.append(self.proxy_container) - - if use_proxy_for_agent: - self.proxy_container.environment.update( - { - "PROXY_TRACING_AGENT_TARGET_HOST": self.lambda_weblog.name, - "PROXY_TRACING_AGENT_TARGET_PORT": "8126", - } - ) + self.proxy_container.environment.update( + { + "PROXY_TRACING_AGENT_TARGET_HOST": self.lambda_weblog.name, + "PROXY_TRACING_AGENT_TARGET_PORT": "8126", + } + ) self._required_containers.extend((self.lambda_weblog, self.lambda_proxy_container)) - self.agent_interface_timeout = agent_interface_timeout - self.backend_interface_timeout = backend_interface_timeout - self._library_interface_timeout = library_interface_timeout - def configure(self, config: pytest.Config): super().configure(config) - if self._require_api_key and "DD_API_KEY" not in os.environ and not self.replay: - pytest.exit("DD_API_KEY is required for this scenario", 1) - - if config.option.force_dd_trace_debug: - self.lambda_weblog.environment["DD_TRACE_DEBUG"] = "true" - - if config.option.force_dd_iast_debug: - self.lambda_weblog.environment["_DD_IAST_DEBUG"] = "true" # probably not used anymore ? - self.lambda_weblog.environment["DD_IAST_DEBUG_ENABLED"] = "true" - - if config.option.force_dd_trace_debug: - self.lambda_weblog.environment["DD_TRACE_DEBUG"] = "true" - interfaces.agent.configure(self.host_log_folder, replay=self.replay) interfaces.library.configure(self.host_log_folder, replay=self.replay) interfaces.backend.configure(self.host_log_folder, replay=self.replay) - interfaces.library_dotnet_managed.configure(self.host_log_folder, replay=self.replay) interfaces.library_stdout.configure(self.host_log_folder, replay=self.replay) - interfaces.agent_stdout.configure(self.host_log_folder, replay=self.replay) - - library = self.lambda_weblog.image.labels["system-tests-library"] - - if self._library_interface_timeout is None: - if library == "java": - self.library_interface_timeout = 25 - elif library in ("golang",): - self.library_interface_timeout = 10 - elif library in ("nodejs", "ruby"): - self.library_interface_timeout = 0 - elif library in ("php",): - self.library_interface_timeout = 10 - elif library in ("python", "python_lambda"): - self.library_interface_timeout = 5 - else: - self.library_interface_timeout = 40 - else: - self.library_interface_timeout = self._library_interface_timeout def _get_weblog_system_info(self): try: @@ -171,12 +125,11 @@ def _wait_and_stop_containers(self, *, force_interface_timeout_to_zero: bool = F interfaces.backend.load_data_from_logs() else: - self._wait_interface( - interfaces.library, 0 if force_interface_timeout_to_zero else self.library_interface_timeout - ) - + self._wait_interface(interfaces.library, 0 if force_interface_timeout_to_zero else 5) + self._wait_interface(interfaces.agent, 0 if force_interface_timeout_to_zero else 5) self.lambda_weblog.stop() interfaces.library.check_deserialization_errors() + interfaces.agent.check_deserialization_errors() self._wait_interface(interfaces.backend, 0) diff --git a/utils/_context/_scenarios/core.py b/utils/_context/_scenarios/core.py index 1e7587e3d87..e9c46c255e2 100644 --- a/utils/_context/_scenarios/core.py +++ b/utils/_context/_scenarios/core.py @@ -26,7 +26,6 @@ def __call__(self, test_object): # noqa: ANN001 (tes_object can be a class or a class _ScenarioGroups: all = ScenarioGroup() appsec = ScenarioGroup() - appsec_lambda = ScenarioGroup() appsec_rasp = ScenarioGroup() debugger = ScenarioGroup() end_to_end = ScenarioGroup() diff --git a/utils/_context/containers.py b/utils/_context/containers.py index a96b979b248..a7123dcb17e 100644 --- a/utils/_context/containers.py +++ b/utils/_context/containers.py @@ -1053,15 +1053,8 @@ def __init__( host_log_folder: str, *, environment: dict[str, str | None] | None = None, - tracer_sampling_rate: float | None = None, - appsec_enabled: bool = True, - iast_enabled: bool = True, - runtime_metrics_enabled: bool = False, - additional_trace_header_tags: tuple[str, ...] = (), - use_proxy: bool = True, volumes: dict | None = None, ): - # overwrite values with those set in the scenario environment = (environment or {}) | { "DD_HOSTNAME": "test", "DD_SITE": os.environ.get("DD_SITE", "datad0g.com"), @@ -1071,37 +1064,27 @@ def __init__( volumes = volumes or {} - if use_proxy: - environment["DD_PROXY_HTTPS"] = f"http://proxy:{ProxyPorts.agent}" - environment["DD_PROXY_HTTP"] = f"http://proxy:{ProxyPorts.agent}" - environment["DD_APM_NON_LOCAL_TRAFFIC"] = ( - "true" # Required for the extension to receive traces from outside the container - ) - volumes.update( - { - "./utils/build/docker/agent/ca-certificates.crt": { - "bind": "/etc/ssl/certs/ca-certificates.crt", - "mode": "ro", - }, - "./utils/build/docker/agent/datadog.yaml": { - "bind": "/etc/datadog-agent/datadog.yaml", - "mode": "ro", - }, + environment["DD_PROXY_HTTPS"] = f"http://proxy:{ProxyPorts.agent}" + environment["DD_PROXY_HTTP"] = f"http://proxy:{ProxyPorts.agent}" + environment["DD_APM_NON_LOCAL_TRAFFIC"] = ( + "true" # Required for the extension to receive traces from outside the container + ) + volumes.update( + { + "./utils/build/docker/agent/ca-certificates.crt": { + "bind": "/etc/ssl/certs/ca-certificates.crt", + "mode": "ro", }, - ) - - self.tracer_sampling_rate = tracer_sampling_rate - self.additional_trace_header_tags = additional_trace_header_tags + "./utils/build/docker/agent/datadog.yaml": { + "bind": "/etc/datadog-agent/datadog.yaml", + "mode": "ro", + }, + }, + ) super().__init__( host_log_folder, environment=environment, - tracer_sampling_rate=tracer_sampling_rate, - appsec_enabled=appsec_enabled, - iast_enabled=iast_enabled, - runtime_metrics_enabled=runtime_metrics_enabled, - additional_trace_header_tags=additional_trace_header_tags, - use_proxy=use_proxy, volumes=volumes, ) @@ -1117,15 +1100,6 @@ def __init__( # Remove port bindings, as only the LambdaProxyContainer needs to expose a server self.ports = {} - def configure(self, *, replay: bool): - super().configure(replay=replay) - - library = self.image.labels["system-tests-library"] - - if library == "python_lambda": - self.environment["DD_LAMBDA_HANDLER"] = "handler.lambda_handler" - self.command = "datadog_lambda.handler.handler" - class PostgresContainer(SqlDbTestedContainer): def __init__(self, host_log_folder: str) -> None: diff --git a/utils/build/docker/python_lambda/apigw-rest.Dockerfile b/utils/build/docker/python_lambda/apigw-rest.Dockerfile index ccd0ad7131f..cd9db998efd 100644 --- a/utils/build/docker/python_lambda/apigw-rest.Dockerfile +++ b/utils/build/docker/python_lambda/apigw-rest.Dockerfile @@ -6,8 +6,7 @@ RUN dnf install -y unzip RUN mkdir -p /opt/extensions COPY --from=public.ecr.aws/datadog/lambda-extension:latest /opt/. /opt/ -# Add the Datadog Lambda Python Layer -COPY binaries/*.zip /binaries/ +COPY binaries/* /binaries/ RUN unzip /binaries/*.zip -d /opt # Setup the aws_lambda handler @@ -16,4 +15,4 @@ RUN pip install -r ${LAMBDA_TASK_ROOT}/requirements.txt ENV DD_LAMBDA_HANDLER=handler.lambda_handler -CMD ["datadog_lambda.handler.handler"] +ENTRYPOINT ["/bin/sh"] diff --git a/utils/build/docker/python_lambda/function/app.sh b/utils/build/docker/python_lambda/function/app.sh new file mode 100644 index 00000000000..cd9fe3ccde4 --- /dev/null +++ b/utils/build/docker/python_lambda/function/app.sh @@ -0,0 +1,7 @@ +#!/bin/sh + +set -eu + +export DD_LAMBDA_HANDLER=handler.lambda_handler + +exec /lambda-entrypoint.sh datadog_lambda.handler.handler diff --git a/utils/build/docker/python_lambda/function/handler.py b/utils/build/docker/python_lambda/function/handler.py index 81ec7d83e2d..4adc5373ae8 100644 --- a/utils/build/docker/python_lambda/function/handler.py +++ b/utils/build/docker/python_lambda/function/handler.py @@ -75,7 +75,7 @@ def healthcheck_route(): @app.get("/waf/") @app.post("/waf/") @app.route("/waf/", method="OPTIONS") -def waf_params(path: str): +def waf_params(path: str = ""): return Response( status_code=200, content_type="text/plain", diff --git a/utils/scripts/ci_orchestrators/workflow_data.py b/utils/scripts/ci_orchestrators/workflow_data.py index a8a8e6687d5..fb5be614ba8 100644 --- a/utils/scripts/ci_orchestrators/workflow_data.py +++ b/utils/scripts/ci_orchestrators/workflow_data.py @@ -422,7 +422,9 @@ def _is_supported(library: str, weblog: str, scenario: str, _ci_environment: str # this function will remove some couple scenarios/weblog that are not supported # Only Allow Lambda scenarios for the lambda libraries - if ("lambda" in library) != ("LAMBDA" in scenario): + is_lambda_library = library in ("python_lambda",) + is_lambda_scenario = scenario in ("APPSEC_LAMBDA_DEFAULT",) + if is_lambda_library != is_lambda_scenario: return False # open-telemetry-automatic diff --git a/utils/scripts/compute-workflow-parameters.py b/utils/scripts/compute-workflow-parameters.py index 95f7224025f..677757dbe28 100644 --- a/utils/scripts/compute-workflow-parameters.py +++ b/utils/scripts/compute-workflow-parameters.py @@ -87,8 +87,7 @@ def __init__( "job_matrix": list(range(1, parametric_job_count + 1)), "enable": len(scenario_map["parametric"]) > 0 and "otel" not in library - and "lambda" not in library - and library not in ("cpp_nginx", "cpp_httpd"), + and library not in ("cpp_nginx", "cpp_httpd", "python_lambda"), } self.data["externalprocessing"] = {"scenarios": scenario_map.get("externalprocessing", [])} diff --git a/utils/scripts/compute_impacted_scenario.py b/utils/scripts/compute_impacted_scenario.py index 8e1f9e8ef9b..f1b4dcac931 100644 --- a/utils/scripts/compute_impacted_scenario.py +++ b/utils/scripts/compute_impacted_scenario.py @@ -153,6 +153,7 @@ def main() -> None: r"manifests/.*": None, # already handled by the manifest comparison r"repository\.datadog\.yml": None, r"utils/_context/_scenarios/appsec_low_waf_timeout\.py": scenarios.appsec_low_waf_timeout, + r"utils/_context/_scenarios/aws_lambda\.py": scenarios.appsec_lambda_default, r"utils/_context/_scenarios/auto_injection\.py": scenario_groups.onboarding, r"utils/_context/_scenarios/default\.py": scenarios.default, r"utils/_context/_scenarios/integrations\.py": scenario_groups.integrations, @@ -164,7 +165,7 @@ def main() -> None: r"utils/build/docker/java_otel/.*": scenario_groups.open_telemetry, r"utils/build/docker/nodejs_otel/.*": scenario_groups.open_telemetry, r"utils/build/docker/python_otel/.*": scenario_groups.open_telemetry, - r"utils/build/docker/python_lambda/.*": scenario_groups.appsec_lambda, + r"utils/build/docker/python_lambda/.*": scenarios.appsec_lambda_default, r"utils/build/docker/\w+/parametric/.*": scenarios.parametric, r"utils/build/docker/.*": [ scenario_groups.end_to_end, From c87c3232e576a38fccb89bcd9dbb318befb0dce4 Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Tue, 8 Jul 2025 14:40:10 +0200 Subject: [PATCH 09/14] Add aws_lambda documentation --- docs/scenarios/aws_lambda.md | 49 ++++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 docs/scenarios/aws_lambda.md diff --git a/docs/scenarios/aws_lambda.md b/docs/scenarios/aws_lambda.md new file mode 100644 index 00000000000..f9bef263048 --- /dev/null +++ b/docs/scenarios/aws_lambda.md @@ -0,0 +1,49 @@ +# Lambda Testing scenario + +The Lambda scenario is a variation on the [classical architecture](../architecture/overview.md#what-are-the-components-of-a-running-test) of the system-tests tailored to evaluate the `AWS Lambda` variants of the tracers when used to serve HTTP requests. + +To achieve this we simulate the following AWS deployment architecture inside the system-tests using AWS provided tools : + +```mermaid +graph LR + A[Incoming HTTP Request] -->|HTTP| B[AWS Managed Load Balancer] + B -->|event: request as JSON| C[AWS Lambda] +``` + +The AWS Managed Load Balancer could be any of the following ones: +- API Gateway +- Application Load Balancer +- Lambda function url service + +To do this, we rely on two tools from AWS to emulate Lambda and Load Balancers: +- [AWS Lambda Runtime Interface Emulator](https://github.com/aws/aws-lambda-runtime-interface-emulator) +- [AWS SAM cli](https://github.com/aws/aws-sam-cli) + +>Note: for now only the python variant ([`datadog_lambda`](https://github.com/DataDog/datadog-lambda-python)) is being tested simulating an `API Gateway` + +## Key differences with end to end scenarios + +To replace the **AWS Managed Load Balancer**, we run a dedicated container in front of the weblog named **Lambda Proxy**. It is responsible for converting the incoming request to a *lambda event* representation, invoking the lambda function running inside the weblog and converting back the return value of function to an http response. + +The **Lambda Function** runs inside the **Weblog Container** thanks to the *AWS Lambda Runtime Interface Emumlator*. + + +There is no **Agent Container**, the **Datadog Extension** (equivalent to the **Datadog Agent** in the context of lambda) needs to run inside the **Weblog Container**, the [**Application Proxy Container**](../architecture/overview.md#application-proxy-container) therefore needs to send traces back to the **Weblog Container**. + + +```mermaid +flowchart TD + TESTS[Tests Container] -->|Send Requests| LambdaProxy + LambdaProxy[Lambda Proxy] -->|Send Lambda Event| Application + subgraph APP[Application Container] + Extension[Extension *:8126] + Application[Application *:8080] + end + Application --> | Send Traces | APPPROXY + APPPROXY[Application Proxy] --> | Send back traces | Extension + APPPROXY -->|mitmdump| TESTS + Extension --> AGENTPROXY + AGENTPROXY[Agent Proxy] -->|remote request| BACKEND + AGENTPROXY -->|mitmdump| TESTS + BACKEND[Datadog] -->|trace API| TESTS +``` From f9ce6fe3058caf8b88f70902e1d7b1622cca0c45 Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Fri, 11 Jul 2025 12:59:22 +0200 Subject: [PATCH 10/14] [python_lambda] fetch artifact from github action workflow --- utils/scripts/load-binary.sh | 23 ++++++----------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/utils/scripts/load-binary.sh b/utils/scripts/load-binary.sh index 8ea4961a901..a5ac48dacc3 100755 --- a/utils/scripts/load-binary.sh +++ b/utils/scripts/load-binary.sh @@ -21,7 +21,7 @@ # * Python: Clone locally the github repo # * Ruby: Direct from github source # * WAF: Direct from github source, but not working, as this repo is now private -# * Python Lambda: Clone locally the github repo +# * Python Lambda: Fetch from GitHub Actions artifact ########################################################################################## set -eu @@ -124,7 +124,8 @@ get_github_action_artifact() { SLUG=$1 WORKFLOW=$2 BRANCH=$3 - PATTERN=$4 + ARTIFACT_NAME=$4 + PATTERN=$5 # query filter seems not to be working ?? WORKFLOWS=$(curl --silent --fail --show-error -H "Authorization: token $GH_TOKEN" "https://api.github.com/repos/$SLUG/actions/workflows/$WORKFLOW/runs?per_page=100") @@ -134,8 +135,7 @@ get_github_action_artifact() { HTML_URL=$(echo $WORKFLOWS | jq -r "$QUERY | .html_url") echo "Load artifact $HTML_URL" ARTIFACTS=$(curl --silent -H "Authorization: token $GH_TOKEN" $ARTIFACT_URL) - - ARCHIVE_URL=$(echo $ARTIFACTS | jq -r '.artifacts[0].archive_download_url') + ARCHIVE_URL=$(echo $ARTIFACTS | jq -r --arg ARTIFACT_NAME "$ARTIFACT_NAME" '.artifacts | map(select(.name | contains($ARTIFACT_NAME))).[0].archive_download_url') echo "Load archive $ARCHIVE_URL" curl -H "Authorization: token $GH_TOKEN" --output artifacts.zip -L $ARCHIVE_URL @@ -283,7 +283,7 @@ elif [ "$TARGET" = "cpp" ]; then elif [ "$TARGET" = "cpp_httpd" ]; then assert_version_is_dev - get_github_action_artifact "DataDog/httpd-datadog" "dev.yml" "main" "mod_datadog.so" + get_github_action_artifact "DataDog/httpd-datadog" "dev.yml" "main" "mod_datadog_artifact" "mod_datadog.so" elif [ "$TARGET" = "cpp_nginx" ]; then assert_version_is_dev @@ -327,18 +327,7 @@ elif [ "$TARGET" = "python_lambda" ]; then assert_version_is_dev assert_target_branch_is_not_set - rm -rf datadog-lambda-python/ - - git clone https://github.com/DataDog/datadog-lambda-python.git - cd datadog-lambda-python - echo "Checking out the datadog_lambda ref" - git log -1 --format=%H - - # do not use `--depth 1`, setuptools_scm, does not like it - git clone https://github.com/DataDog/dd-trace-py.git - cd dd-trace-py - echo "Checking out the ddtrace ref" - git log -1 --format=%H + get_github_action_artifact "DataDog/datadog-lambda-python" "build_layer.yml" "main" "datadog-lambda-python-3.13-amd64" "datadog_lambda_py-amd64-3.13.zip" else echo "Unknown target: $1" From f20a5de4d9b6cdb4f56edfa0e9c193b1d76b77cd Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Fri, 11 Jul 2025 15:37:45 +0200 Subject: [PATCH 11/14] [python_lambda] simplify installation of datadog_lambda --- .../python_lambda/apigw-rest.Dockerfile | 6 +- .../python_lambda/build_lambda_layer.sh | 96 ------------------- .../python_lambda/install_datadog_lambda.sh | 20 ++++ utils/scripts/load-binary.sh | 1 - 4 files changed, 23 insertions(+), 100 deletions(-) delete mode 100755 utils/build/docker/python_lambda/build_lambda_layer.sh create mode 100755 utils/build/docker/python_lambda/install_datadog_lambda.sh diff --git a/utils/build/docker/python_lambda/apigw-rest.Dockerfile b/utils/build/docker/python_lambda/apigw-rest.Dockerfile index cd9db998efd..9ee46e9fea6 100644 --- a/utils/build/docker/python_lambda/apigw-rest.Dockerfile +++ b/utils/build/docker/python_lambda/apigw-rest.Dockerfile @@ -1,13 +1,13 @@ FROM public.ecr.aws/lambda/python:3.13 -RUN dnf install -y unzip +RUN dnf install -y unzip findutils # Add the Datadog Extension RUN mkdir -p /opt/extensions COPY --from=public.ecr.aws/datadog/lambda-extension:latest /opt/. /opt/ -COPY binaries/* /binaries/ -RUN unzip /binaries/*.zip -d /opt +COPY utils/build/docker/python_lambda/install_datadog_lambda.sh binaries* /binaries/ +RUN /binaries/install_datadog_lambda.sh # Setup the aws_lambda handler COPY utils/build/docker/python_lambda/function/. ${LAMBDA_TASK_ROOT} diff --git a/utils/build/docker/python_lambda/build_lambda_layer.sh b/utils/build/docker/python_lambda/build_lambda_layer.sh deleted file mode 100755 index 61893691214..00000000000 --- a/utils/build/docker/python_lambda/build_lambda_layer.sh +++ /dev/null @@ -1,96 +0,0 @@ -#!/usr/bin/env bash - -set -euo pipefail - -ARCH=${ARCH:-$(uname -m)} -PYTHON_VERSION=${PYTHON_VERSION:-3.13} -PYTHON_VERSION_NO_DOT=$(echo "$PYTHON_VERSION" | tr -d '.') - -if [ "$ARCH" = "x86_64" ]; then - ARCH="amd64" -fi -if [ "$ARCH" = "aarch64" ]; then - ARCH="arm64" -fi - -cd binaries - -SKIP_BUILD=0 - -if [ -e "datadog-lambda-python" ]; then - echo "datadog-lambda-python already exists, skipping clone." -elif [ "$(find . -maxdepth 1 -name "*.zip" | wc -l)" = "1" ]; then - echo "Using provided datadog-lambda-python layer." - SKIP_BUILD=1 -elif command -v aws >/dev/null 2>&1 && aws sts get-caller-identity >/dev/null 2>&1; then - echo "AWS credentials detected. Downloading datadog-lambda-python layer from AWS..." - REGION=${AWS_DEFAULT_REGION:-us-east-1} - ARCH_SUFFIX=$(if [ "$ARCH" = "arm64" ]; then echo "-ARM"; else echo ""; fi) - LAMBDA_LAYER_NAME="arn:aws:lambda:$REGION:464622532012:layer:Datadog-Python$PYTHON_VERSION_NO_DOT$ARCH_SUFFIX" - - # The layer version cannot be retrieve directly from AWS but is present as the minor version in the datadog_lambda package - # in the PyPI index. - LAYER_VERSION=$(head -n 1 <(pip index versions datadog_lambda) | cut -d '.' -f2) - - if [ "$LAYER_VERSION" != "None" ] && [ -n "$LAYER_VERSION" ]; then - echo "Downloading layer version $LAYER_VERSION for $LAMBDA_LAYER_NAME from region $REGION" - - # Get the download URL - DOWNLOAD_URL=$(aws lambda get-layer-version \ - --layer-name "$LAMBDA_LAYER_NAME" \ - --version-number "$LAYER_VERSION" \ - --query 'Content.Location' \ - --output text \ - --region "$REGION") - - if [ -n "$DOWNLOAD_URL" ] && [ "$DOWNLOAD_URL" != "None" ]; then - # Download the layer - curl -L "$DOWNLOAD_URL" -o "datadog_lambda_py-${ARCH}-${PYTHON_VERSION}.zip" - echo "Successfully downloaded datadog-lambda-python layer from AWS" - SKIP_BUILD=1 - else - echo "Failed to get download URL for layer. Falling back to git clone..." - exit 1 - fi - else - echo "No layer version found. Falling back to git clone..." - exit 1 - fi -else - echo "Impossible to download datadog-lambda-python layer from AWS and no local layer provided, Aborting." - exit 1 -fi - -if [[ $SKIP_BUILD -eq 0 ]]; then - if [ -e "datdog-lambda-python/dd-trace-py" ]; then - echo "Install from local folder /binaries/datadog-lambda-python/dd-trace-py" - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' 's|^ddtrace =.*$|ddtrace = { path = "./dd-trace-py" }|' datadog-lambda-python/pyproject.toml - else - sed -i 's|^ddtrace =.*$|ddtrace = { path = "./dd-trace-py" }|' datadog-lambda-python/pyproject.toml - fi - elif [ "$(find . -maxdepth 1 -name "datadog-lambda-python/*.whl" | wc -l)" = "1" ]; then - path=$(readlink -f "$(find . -maxdepth 1 -name "*.whl")") - echo "Install ddtrace from ${path}" - if [[ "$OSTYPE" == "darwin"* ]]; then - sed -i '' "s|^ddtrace =.*$|ddtrace = { path = \"file://${path}\" }|" datadog-lambda-python/pyproject.toml - else - sed -i "s|^ddtrace =.*$|ddtrace = { path = \"file://${path}\" }|" datadog-lambda-python/pyproject.toml - fi - elif [ "$(find . -maxdepth 1 -name "*.whl" | wc -l)" = "0" ]; then - echo "Install ddtrace from pypi" - # Keep the default ddtrace dependency in pyproject.toml - else - echo "ERROR: Found several wheel files in binaries/, abort." - exit 1 - fi - - # Build the datadog-lambda-python package - cd datadog-lambda-python - ARCH=$ARCH PYTHON_VERSION=$PYTHON_VERSION bash ./scripts/build_layers.sh - - mv .layers/*.zip ../ - cd .. -fi - -cd .. diff --git a/utils/build/docker/python_lambda/install_datadog_lambda.sh b/utils/build/docker/python_lambda/install_datadog_lambda.sh new file mode 100755 index 00000000000..dc3a765ef0e --- /dev/null +++ b/utils/build/docker/python_lambda/install_datadog_lambda.sh @@ -0,0 +1,20 @@ +#!/bin/bash + +set -eu + +cd /binaries + +if [ "$(find . -maxdepth 1 -name "*.zip" | wc -l)" = "1" ]; then + path=$(readlink -f "$(find . -maxdepth 1 -name "*.zip")") + echo "Install datadog_lambda from ${path}" + unzip "${path}" -d /opt +else + echo "Fetching from latest GitHub release" + curl -fsSLO https://github.com/DataDog/datadog-lambda-python/releases/latest/download/datadog_lambda_py-amd64-3.13.zip + unzip -o datadog_lambda_py-amd64-3.13.zip -d /opt + + if [ ! -f datadog_lambda_py-amd64-3.13.zip ]; then + echo "Failed to download datadog_lambda_py-amd64-3.13.zip" + exit 1 + fi +fi diff --git a/utils/scripts/load-binary.sh b/utils/scripts/load-binary.sh index a5ac48dacc3..41efdc3e2e3 100755 --- a/utils/scripts/load-binary.sh +++ b/utils/scripts/load-binary.sh @@ -328,7 +328,6 @@ elif [ "$TARGET" = "python_lambda" ]; then assert_target_branch_is_not_set get_github_action_artifact "DataDog/datadog-lambda-python" "build_layer.yml" "main" "datadog-lambda-python-3.13-amd64" "datadog_lambda_py-amd64-3.13.zip" - else echo "Unknown target: $1" exit 1 From b51825f00839ebdcab8e1e6d2295e2bf0eb7c626 Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Wed, 23 Jul 2025 13:37:54 +0200 Subject: [PATCH 12/14] [python_lambda] update manifest --- manifests/python_lambda.yml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/manifests/python_lambda.yml b/manifests/python_lambda.yml index 2175c019aab..5568b6105a4 100644 --- a/manifests/python_lambda.yml +++ b/manifests/python_lambda.yml @@ -2,20 +2,20 @@ tests/: appsec/: test_alpha.py: - Test_Basic: 6.111.0 + Test_Basic: 7.112.0 test_only_python.py: - Test_ImportError: 6.111.0 + Test_ImportError: 7.112.0 test_reports.py: - Test_ExtraTagsFromRule: 6.111.0 - Test_Info: 6.111.0 - Test_RequestHeaders: 6.111.0 - Test_StatusCode: 6.111.0 + Test_ExtraTagsFromRule: 7.112.0 + Test_Info: 7.112.0 + Test_RequestHeaders: 7.112.0 + Test_StatusCode: 7.112.0 test_traces.py: - Test_AppSecEventSpanTags: 6.111.0 - Test_AppSecObfuscator: 6.111.0 - Test_CollectDefaultRequestHeader: 6.111.0 - Test_CollectRespondHeaders: 6.111.0 - Test_ExternalWafRequestsIdentification: 6.111.0 - Test_RetainTraces: 6.111.0 + Test_AppSecEventSpanTags: 7.112.0 + Test_AppSecObfuscator: 7.112.0 + Test_CollectDefaultRequestHeader: 7.112.0 + Test_CollectRespondHeaders: 7.112.0 + Test_ExternalWafRequestsIdentification: 7.112.0 + Test_RetainTraces: 7.112.0 test_versions.py: - Test_Events: 6.111.0 + Test_Events: 7.112.0 From 9c2310d466ae99edc92518fb71427ea018e53c4b Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Thu, 24 Jul 2025 15:28:23 +0200 Subject: [PATCH 13/14] [python_lambda] fix issues preventing some tests --- docs/scenarios/aws_lambda.md | 18 +++++++- tests/appsec/test_only_python.py | 4 +- utils/_context/_scenarios/__init__.py | 1 + utils/_context/_scenarios/aws_lambda.py | 3 +- utils/build/docker/lambda_proxy/main.py | 46 +++++++++++++------ .../python_lambda/apigw-rest.Dockerfile | 2 +- .../docker/python_lambda/function/app.sh | 2 + .../docker/python_lambda/function/handler.py | 28 +++++------ 8 files changed, 68 insertions(+), 36 deletions(-) diff --git a/docs/scenarios/aws_lambda.md b/docs/scenarios/aws_lambda.md index f9bef263048..a70244d4632 100644 --- a/docs/scenarios/aws_lambda.md +++ b/docs/scenarios/aws_lambda.md @@ -36,14 +36,28 @@ flowchart TD TESTS[Tests Container] -->|Send Requests| LambdaProxy LambdaProxy[Lambda Proxy] -->|Send Lambda Event| Application subgraph APP[Application Container] - Extension[Extension *:8126] + socat[socat *:8127] --> Extension + Extension[Extension localhost:8126] Application[Application *:8080] end Application --> | Send Traces | APPPROXY - APPPROXY[Application Proxy] --> | Send back traces | Extension + APPPROXY[Application Proxy] --> | Send back traces | socat APPPROXY -->|mitmdump| TESTS Extension --> AGENTPROXY AGENTPROXY[Agent Proxy] -->|remote request| BACKEND AGENTPROXY -->|mitmdump| TESTS BACKEND[Datadog] -->|trace API| TESTS ``` + +## Specific considerations for the weblogs + +On top of responding to the regular [`/healthcheck`](../weblog/README.md#get-healthcheck) endpoint. + +Lambda Weblogs should respond the same JSON dict response to the non HTTP event: +```json +{ + "healthcheck": true +} +``` + +This is because the healthcheck is sent by the Lambda Weblog container itself which has no knowledge of how to serialize it as the event type expected by the weblog. \ No newline at end of file diff --git a/tests/appsec/test_only_python.py b/tests/appsec/test_only_python.py index 5cacf9b3e7e..c840741def9 100644 --- a/tests/appsec/test_only_python.py +++ b/tests/appsec/test_only_python.py @@ -12,12 +12,12 @@ @scenarios.default @scenarios.appsec_lambda_default @features.language_specifics -@irrelevant(context.library != "python", reason="specific tests for python tracer") +@irrelevant(context.library not in ("python", "python_lambda"), reason="specific tests for python tracer") class Test_ImportError: """Tests to verify that we don't have import errors due to tracer instrumentation.""" @flaky(context.library == "python@3.2.1" and "flask" in context.weblog_variant, reason="APMRP-360") def test_circular_import(self): """Test to verify that we don't have a circular import in the weblog.""" - assert context.library == "python" + assert context.library in ("python", "python_lambda") interfaces.library_stdout.assert_absence("most likely due to a circular import") diff --git a/utils/_context/_scenarios/__init__.py b/utils/_context/_scenarios/__init__.py index dfcc1e97d83..f71366d6c5d 100644 --- a/utils/_context/_scenarios/__init__.py +++ b/utils/_context/_scenarios/__init__.py @@ -1083,6 +1083,7 @@ class _Scenarios: appsec_lambda_default = LambdaScenario( "APPSEC_LAMBDA_DEFAULT", doc="Default Lambda scenario", + scenario_groups=[scenario_groups.appsec], ) diff --git a/utils/_context/_scenarios/aws_lambda.py b/utils/_context/_scenarios/aws_lambda.py index fd23800f4dc..2451e072be0 100644 --- a/utils/_context/_scenarios/aws_lambda.py +++ b/utils/_context/_scenarios/aws_lambda.py @@ -29,7 +29,6 @@ def __init__( weblog_volumes: dict[str, dict[str, str]] | None = None, ): scenario_groups = [ - all_scenario_groups.appsec, all_scenario_groups.tracer_release, ] + (scenario_groups or []) @@ -53,7 +52,7 @@ def __init__( self.proxy_container.environment.update( { "PROXY_TRACING_AGENT_TARGET_HOST": self.lambda_weblog.name, - "PROXY_TRACING_AGENT_TARGET_PORT": "8126", + "PROXY_TRACING_AGENT_TARGET_PORT": "8127", } ) diff --git a/utils/build/docker/lambda_proxy/main.py b/utils/build/docker/lambda_proxy/main.py index 0568a963617..592bf04e413 100644 --- a/utils/build/docker/lambda_proxy/main.py +++ b/utils/build/docker/lambda_proxy/main.py @@ -23,7 +23,12 @@ def invoke_lambda_function(): This function is used to invoke the Lambda function with the provided event. It constructs a v1 event from the Flask request and sends it to the RIE URL. """ - converted_event = construct_v1_event(request, PORT, binary_types=["application/octet-stream"], stage_name="Prod") + converted_event = construct_v1_event( + request, + PORT, + binary_types=["application/octet-stream"], + stage_name="Prod", + ) response = post( RIE_URL, @@ -32,22 +37,33 @@ def invoke_lambda_function(): ) (status_code, headers, body) = LocalApigwService._parse_v1_payload_format_lambda_output( - response.content.decode("utf-8"), binary_types=[], flask_request=request, event_type="Api" + response.content.decode("utf-8"), + binary_types=[], + flask_request=request, + event_type="Api", ) return app.response_class(response=body, status=status_code, headers=headers) -@app.route("/", methods=["GET", "POST", "OPTIONS"]) -@app.route("/finger_print") -@app.get("/headers") -@app.get("/healthcheck") -@app.route("/params//", methods=["GET", "POST", "OPTIONS"]) -@app.route("/tag_value//", methods=["GET", "POST", "OPTIONS"]) -@app.get("/users") -@app.route("/waf", methods=["GET", "POST", "OPTIONS"]) -@app.route("/waf/", methods=["GET", "POST", "OPTIONS"]) -@app.route("/waf/", methods=["GET", "POST", "OPTIONS"]) -@app.get("/.git") -def main(**kwargs): - return invoke_lambda_function() +ROUTES = [ + ("/", ["GET", "POST", "OPTIONS"]), + ("/finger_print", ["GET"]), + ("/headers", ["GET"]), + ("/healthcheck", ["GET"]), + ("/params//", ["GET", "POST", "OPTIONS"]), + ("/tag_value//", ["GET", "POST", "OPTIONS"]), + ("/users", ["GET"]), + ("/waf", ["GET", "POST", "OPTIONS"]), + ("/waf/", ["GET", "POST", "OPTIONS"]), + ("/waf/", ["GET", "POST", "OPTIONS"]), + ("/.git", ["GET"]), +] + +for endpoint, methods in ROUTES: + app.add_url_rule( + endpoint, + endpoint, + lambda **kwargs: invoke_lambda_function(), + methods=methods, + ) diff --git a/utils/build/docker/python_lambda/apigw-rest.Dockerfile b/utils/build/docker/python_lambda/apigw-rest.Dockerfile index 9ee46e9fea6..a06e37ac51e 100644 --- a/utils/build/docker/python_lambda/apigw-rest.Dockerfile +++ b/utils/build/docker/python_lambda/apigw-rest.Dockerfile @@ -1,6 +1,6 @@ FROM public.ecr.aws/lambda/python:3.13 -RUN dnf install -y unzip findutils +RUN dnf install -y unzip findutils socat # Add the Datadog Extension RUN mkdir -p /opt/extensions diff --git a/utils/build/docker/python_lambda/function/app.sh b/utils/build/docker/python_lambda/function/app.sh index cd9fe3ccde4..eb958bd98f3 100644 --- a/utils/build/docker/python_lambda/function/app.sh +++ b/utils/build/docker/python_lambda/function/app.sh @@ -4,4 +4,6 @@ set -eu export DD_LAMBDA_HANDLER=handler.lambda_handler +socat TCP-LISTEN:8127,reuseaddr,fork,bind=0.0.0.0 TCP:127.0.0.1:8126 & + exec /lambda-entrypoint.sh datadog_lambda.handler.handler diff --git a/utils/build/docker/python_lambda/function/handler.py b/utils/build/docker/python_lambda/function/handler.py index 4adc5373ae8..5a07baa1296 100644 --- a/utils/build/docker/python_lambda/function/handler.py +++ b/utils/build/docker/python_lambda/function/handler.py @@ -32,20 +32,6 @@ def version_info(): } -@app.get("/tag_value//") -@app.route("/tag_value//", method="OPTIONS") -def tag_value(tag_value: str, status_code: int): - appsec_trace_utils.track_custom_event( - tracer, event_name=_TRACK_CUSTOM_APPSEC_EVENT_NAME, metadata={"value": tag_value} - ) - return Response( - status_code=status_code, - content_type="text/plain", - body="Value tagged", - headers=app.current_event.query_string_parameters, - ) - - @app.get("/") @app.post("/") @app.route("/", method="OPTIONS") @@ -83,6 +69,20 @@ def waf_params(path: str = ""): ) +@app.get("/tag_value//") +@app.route("/tag_value//", method="OPTIONS") +def tag_value(tag_value: str, status_code: int): + appsec_trace_utils.track_custom_event( + tracer, event_name=_TRACK_CUSTOM_APPSEC_EVENT_NAME, metadata={"value": tag_value} + ) + return Response( + status_code=status_code, + content_type="text/plain", + body="Value tagged", + headers=app.current_event.query_string_parameters, + ) + + @app.post("/tag_value//") def tag_value_post(tag_value: str, status_code: int): appsec_trace_utils.track_custom_event( From 219f30f724d70514b3b9f615af39f4d3bd98c322 Mon Sep 17 00:00:00 2001 From: Florentin Labelle Date: Mon, 28 Jul 2025 09:51:52 +0200 Subject: [PATCH 14/14] fix minor tweaks --- tests/appsec/api_security/test_schemas.py | 10 ++++++++++ tests/appsec/test_traces.py | 14 ++++++++++---- utils/_context/_scenarios/__init__.py | 18 +++++++++++++++++- utils/_context/containers.py | 10 ++++------ .../python_lambda/function/requirements.txt | 2 +- 5 files changed, 42 insertions(+), 12 deletions(-) diff --git a/tests/appsec/api_security/test_schemas.py b/tests/appsec/api_security/test_schemas.py index a1d31ba8399..8905165a1fa 100644 --- a/tests/appsec/api_security/test_schemas.py +++ b/tests/appsec/api_security/test_schemas.py @@ -43,6 +43,7 @@ def equal_value(t1, t2): @rfc("https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz") @scenarios.appsec_api_security +@scenarios.appsec_lambda_api_security @features.api_security_schemas class Test_Schema_Request_Headers: """Test API Security - Request Headers Schema""" @@ -63,6 +64,7 @@ def test_request_method(self): @rfc("https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz") @scenarios.appsec_api_security +@scenarios.appsec_lambda_api_security @features.api_security_schemas class Test_Schema_Request_Cookies: """Test API Security - Request Cookies Schema""" @@ -87,6 +89,7 @@ def test_request_method(self): @rfc("https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz") @scenarios.appsec_api_security +@scenarios.appsec_lambda_api_security @features.api_security_schemas class Test_Schema_Request_Query_Parameters: """Test API Security - Request Query Parameters Schema""" @@ -107,6 +110,7 @@ def test_request_method(self): @rfc("https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz") @scenarios.appsec_api_security +@scenarios.appsec_lambda_api_security @features.api_security_schemas class Test_Schema_Request_Path_Parameters: """Test API Security - Request Path Parameters Schema""" @@ -128,6 +132,7 @@ def test_request_method(self): @rfc("https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz") @scenarios.appsec_api_security +@scenarios.appsec_lambda_api_security @features.api_security_schemas class Test_Schema_Request_Json_Body: """Test API Security - Request Body and list length""" @@ -148,6 +153,7 @@ def test_request_method(self): @rfc("https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz") @scenarios.appsec_api_security +@scenarios.appsec_lambda_api_security @features.api_security_schemas class Test_Schema_Request_FormUrlEncoded_Body: """Test API Security - Request Body and list length""" @@ -188,6 +194,7 @@ def test_request_method(self): @rfc("https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz") @scenarios.appsec_api_security +@scenarios.appsec_lambda_api_security @features.api_security_schemas class Test_Schema_Response_Headers: """Test API Security - Response Header Schema""" @@ -207,6 +214,7 @@ def test_request_method(self): @rfc("https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz") @scenarios.appsec_api_security +@scenarios.appsec_lambda_api_security @features.api_security_schemas class Test_Schema_Response_Body: """Test API Security - Response Body Schema with urlencoded body""" @@ -233,6 +241,7 @@ def test_request_method(self): @rfc("https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz") @scenarios.appsec_api_security +@scenarios.appsec_lambda_api_security @features.api_security_schemas class Test_Schema_Response_on_Block: """Test API Security - Response Schemas with urlencoded body @@ -293,6 +302,7 @@ def test_request_method(self): @rfc("https://docs.google.com/document/d/1OCHPBCAErOL2FhLl64YAHB8woDyq66y5t-JGolxdf1Q/edit#heading=h.bth088vsbjrz") @scenarios.appsec_api_security +@scenarios.appsec_lambda_api_security @features.api_security_schemas class Test_Scanners: """Test API Security - Scanners""" diff --git a/tests/appsec/test_traces.py b/tests/appsec/test_traces.py index 5b0a067a2f6..097ba8e59f5 100644 --- a/tests/appsec/test_traces.py +++ b/tests/appsec/test_traces.py @@ -68,15 +68,20 @@ def setup_custom_span_tags(self): weblog.get("/waf", params={"key": "\n :"}) # rules.http_protocol_violation.crs_921_160 weblog.get("/waf", headers={"random-key": "acunetix-user-agreement"}) # rules.security_scanner.crs_913_110 - @bug(library="python_lambda", reason="APPSEC-58201") def test_custom_span_tags(self): """AppSec should store in all APM spans some tags when enabled.""" spans = [span for _, span in interfaces.library.get_root_spans()] assert spans, "No root spans to validate" - spans = [s for s in spans if s.get("type") == "web"] - assert spans, "No spans of type web to validate" + spans = [s for s in spans if s.get("type") in ("web", "serverless")] + assert spans, "No spans of type web or serverless to validate" for span in spans: + if span.get("type") == "serverless" and "_dd.appsec.unsupported_event_type" in span["metrics"]: + # For serverless, the `healthcheck` event is not supported + assert ( + span["metrics"]["_dd.appsec.unsupported_event_type"] == 1 + ), "_dd.appsec.unsupported_event_type should be 1 or 1.0" + continue assert "_dd.appsec.enabled" in span["metrics"], "Cannot find _dd.appsec.enabled in span metrics" assert span["metrics"]["_dd.appsec.enabled"] == 1, "_dd.appsec.enabled should be 1 or 1.0" assert "_dd.runtime_family" in span["meta"], "Cannot find _dd.runtime_family in span meta" @@ -87,6 +92,7 @@ def test_custom_span_tags(self): def setup_header_collection(self): self.r = weblog.get("/headers", headers={"User-Agent": "Arachni/v1", "Content-Type": "text/plain"}) + @bug(library="python_lambda", reason="APPSEC-58202") @bug(context.library < f"python@{PYTHON_RELEASE_GA_1_1}", reason="APMRP-360") @bug(context.library < "java@1.2.0", weblog_variant="spring-boot-openliberty", reason="APPSEC-6734") @bug( @@ -94,7 +100,7 @@ def setup_header_collection(self): weblog_variant="fastify", reason="APPSEC-57432", # Response headers collection not supported yet ) - @irrelevant(context.library not in ["golang", "nodejs", "java", "dotnet"], reason="test") + @irrelevant(context.library not in ["golang", "nodejs", "java", "dotnet", "python_lambda"], reason="test") @irrelevant(context.scenario is scenarios.external_processing, reason="Irrelevant tag set for golang") def test_header_collection(self): """AppSec should collect some headers for http.request and http.response and store them in span tags. diff --git a/utils/_context/_scenarios/__init__.py b/utils/_context/_scenarios/__init__.py index f71366d6c5d..0e60af99e91 100644 --- a/utils/_context/_scenarios/__init__.py +++ b/utils/_context/_scenarios/__init__.py @@ -1,10 +1,10 @@ import json -from utils._context._scenarios.aws_lambda import LambdaScenario from utils._context.header_tag_vars import VALID_CONFIGS, INVALID_CONFIGS, CONFIG_WILDCARD from utils.proxy.ports import ProxyPorts from utils.tools import update_environ_with_local_env +from .aws_lambda import LambdaScenario from .core import Scenario, scenario_groups from .default import DefaultScenario from .endtoend import DockerScenario, EndToEndScenario @@ -1085,6 +1085,22 @@ class _Scenarios: doc="Default Lambda scenario", scenario_groups=[scenario_groups.appsec], ) + appsec_lambda_api_security = LambdaScenario( + "APPSEC_LAMBDA_API_SECURITY", + weblog_env={ + "DD_API_SECURITY_ENABLED": "true", + "DD_API_SECURITY_REQUEST_SAMPLE_RATE": "1.0", + "DD_API_SECURITY_SAMPLE_DELAY": "0.0", + "DD_API_SECURITY_MAX_CONCURRENT_REQUESTS": "50", + "DD_API_SECURITY_ENDPOINT_COLLECTION_ENABLED": "true", + "DD_API_SECURITY_ENDPOINT_COLLECTION_MESSAGE_LIMIT": "30", + }, + doc=""" + Scenario for API Security feature in lambda, testing schema types sent into span tags if + DD_API_SECURITY_ENABLED is set to true. + """, + scenario_groups=[scenario_groups.appsec], + ) scenarios = _Scenarios() diff --git a/utils/_context/containers.py b/utils/_context/containers.py index a7123dcb17e..0a4c2e52643 100644 --- a/utils/_context/containers.py +++ b/utils/_context/containers.py @@ -1060,15 +1060,13 @@ def __init__( "DD_SITE": os.environ.get("DD_SITE", "datad0g.com"), "DD_API_KEY": os.environ.get("DD_API_KEY", _FAKE_DD_API_KEY), "DD_SERVERLESS_FLUSH_STRATEGY": "periodically,100", + "DD_TRACE_MANAGED_SERVICES": "false", } volumes = volumes or {} environment["DD_PROXY_HTTPS"] = f"http://proxy:{ProxyPorts.agent}" - environment["DD_PROXY_HTTP"] = f"http://proxy:{ProxyPorts.agent}" - environment["DD_APM_NON_LOCAL_TRAFFIC"] = ( - "true" # Required for the extension to receive traces from outside the container - ) + environment["DD_LOG_LEVEL"] = "debug" volumes.update( { "./utils/build/docker/agent/ca-certificates.crt": { @@ -1076,10 +1074,10 @@ def __init__( "mode": "ro", }, "./utils/build/docker/agent/datadog.yaml": { - "bind": "/etc/datadog-agent/datadog.yaml", + "bind": "/var/task/datadog.yaml", "mode": "ro", }, - }, + } ) super().__init__( diff --git a/utils/build/docker/python_lambda/function/requirements.txt b/utils/build/docker/python_lambda/function/requirements.txt index 56fd45918ce..be2c9d6827c 100644 --- a/utils/build/docker/python_lambda/function/requirements.txt +++ b/utils/build/docker/python_lambda/function/requirements.txt @@ -1 +1 @@ -aws-lambda-powertools +aws-lambda-powertools==3.17.0