diff --git a/.gitignore b/.gitignore index 1b477f8f..caf6d085 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,7 @@ __pycache__/ .DS_Store *.so* +/test/.venv/ +/test/injection/artifacts/ +/.pytest_cache/ +/test/.pytest_cache/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 5b67a47d..c99be9e5 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -14,6 +14,7 @@ include: - local: ".gitlab/build-and-test-all.yml" - local: ".gitlab/build-and-test-fast.yml" - local: ".gitlab/ssi-package.yml" + - local: ".gitlab/injection-tests.yml" - local: ".gitlab/one-pipeline.locked.yml" variables: diff --git a/.gitlab/injection-tests.yml b/.gitlab/injection-tests.yml new file mode 100644 index 00000000..a8320c4d --- /dev/null +++ b/.gitlab/injection-tests.yml @@ -0,0 +1,66 @@ +.injection-tests: + extends: .test + stage: shared-pipeline-test + timeout: 30m + variables: + DOCKER_LOOPBACK_SIZE: 80G + DOCKER_LOOPBACK_PATH: "" + INJECTION_ARCH: $ARCH + INJECTION_ARTIFACTS: test/injection/artifacts/ci + script: + - uv run --project test --group injection python -m test.injection.run test + retry: + max: 2 + when: [runner_system_failure, scheduler_failure, api_failure] + artifacts: + when: always + expire_in: 2 weeks + reports: + junit: test/injection/artifacts/ci/junit.xml + paths: + - test/injection/artifacts/ci/ + +.injection-checkout: + extends: .injection-tests + variables: + INJECTION_SOURCE: checkout + rules: + - if: $SKIP_SHARED_PIPELINE == "true" + when: never + - if: $CI_COMMIT_BRANCH || $CI_COMMIT_TAG + +# Explicit needs work with GitLab versions before matrix expressions. +injection-checkout-amd64: + extends: .injection-checkout + variables: + ARCH: amd64 + needs: + - job: package-oci + parallel: + matrix: + - OS: linux + ARCH: amd64 + +injection-checkout-arm64: + extends: .injection-checkout + variables: + ARCH: arm64 + needs: + - job: package-oci + parallel: + matrix: + - OS: linux + ARCH: arm64 + +injection-published: + extends: .injection-tests + needs: [] + parallel: + matrix: + - ARCH: [amd64, arm64] + variables: + INJECTION_SOURCE: published + rules: + - if: $CI_PIPELINE_SOURCE == "schedule" + - when: manual + allow_failure: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c01988f3..0a04a432 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -106,6 +106,9 @@ TEST_ARGS="cases.package.module.TestClass.test_method" NGINX_VERSION= m For more information on tests, see [test/README.md](test/README.md). +For installer-based host and Docker acceptance tests and runnable examples, see +[test/injection/README.md](test/injection/README.md). + ## Troubleshooting ### fatal error: 'pcre2.h' file not found on MacOS diff --git a/Makefile b/Makefile index 76b57e66..2fcb643a 100644 --- a/Makefile +++ b/Makefile @@ -290,6 +290,24 @@ build-musl-aux-ingress: # ----- Test +INJECTION_SOURCE ?= published +INJECTION_MODE ?= docker +INJECTION_ARTIFACTS ?= test/injection/artifacts/latest +INJECTION_PACKAGE_ARG = $(if $(INJECTION_PACKAGE),--package "$(INJECTION_PACKAGE)",) + +.PHONY: test-injection injection-example-up injection-example-down injection-example-request injection-example-traces +test-injection: + uv run --project test --group injection python -m test.injection.run test \ + --source "$(INJECTION_SOURCE)" $(INJECTION_PACKAGE_ARG) --artifacts "$(INJECTION_ARTIFACTS)" $(INJECTION_TEST_ARGS) + +injection-example-up: + uv run --project test --group injection python -m test.injection.run example-up \ + --source "$(INJECTION_SOURCE)" $(INJECTION_PACKAGE_ARG) --mode "$(INJECTION_MODE)" --artifacts "$(INJECTION_ARTIFACTS)" + +injection-example-down injection-example-request injection-example-traces: + uv run --project test --group injection python -m test.injection.run $(patsubst injection-%,%,$@) \ + --artifacts "$(INJECTION_ARTIFACTS)" + .PHONY: build-and-test build-and-test: build-musl test diff --git a/test/injection/README.md b/test/injection/README.md new file mode 100644 index 00000000..74922dac --- /dev/null +++ b/test/injection/README.md @@ -0,0 +1,177 @@ +# Nginx injection acceptance tests + +These tests install the real Datadog injector and language packages into a disposable +Linux host. They check HTTP responses, decoded spans, and Nginx → Flask parent links. +The same cases run against host processes, a Debian Nginx container, and an Alpine +Nginx container. A focused smoke test also runs an ordinary Nginx service in a +single-node Docker Swarm. Kubernetes is outside this suite. Stable-config tracing +cases are strict expected failures until the C++ tracer supports the feature. + +## Run + +Install Docker and `uv`, then run from the repository root: + +```sh +DOCKER_CONTEXT=orbstack make test-injection INJECTION_SOURCE=published +DOCKER_CONTEXT=orbstack make test-injection INJECTION_SOURCE=checkout +make test-injection INJECTION_PACKAGE=/path/to/package.tar +``` + +The Docker daemon must support privileged Linux containers. Orb uses its native ARM64 +architecture. `DOCKER_CONTEXT` and `DOCKER_HOST` also work with remote daemons; the +harness transfers files with Docker copy/exec and streams images with save/load. +It never needs a local bind mount or access to a remote daemon's published ports. +`INJECTION_ARCH=arm64` or `amd64` enforces the expected daemon architecture. + +Published packages need access to `install.datadoghq.com`, Docker Hub, and GHCR. +Checkout builds also need access to the repository's private toolchain and packaging +images on `registry.ddbuild.io`. The checkout command initializes submodules, runs +the existing musl build with `RUM=ON`, and uses `datadog-package create` with the same +`nginx//ngx_http_datadog_module.so` layout as `package-oci`. An explicit +package takes precedence over `INJECTION_SOURCE`. Its Linux architecture, OCI blob +digests, and installed module checksum must match. + +To investigate one case: + +```sh +DOCKER_CONTEXT=orbstack make test-injection \ + INJECTION_TEST_ARGS='--injection-mode docker-alpine -k test_proxy_trace_link -x' \ + INJECTION_ARTIFACTS=test/injection/artifacts/debug +``` + +To run the Swarm smoke test: + +```sh +DOCKER_CONTEXT=orbstack make test-injection \ + INJECTION_TEST_ARGS='--injection-mode docker-debian -k test_swarm_tracing_without_rum -x' +``` + +Tests run serially. Do not use pytest-xdist. A full run covers all three modes; +filtering is for local diagnosis. Skipped cases and unexpected passes fail the run. +Known stable-config gaps are marked as strict xfails. Positive conditions have a +30-second deadline. Negative checks follow graceful workload shutdown and observe a +three-second quiet period while checking collector health. Readiness uses `/ready`; +assertions match unique request URIs. + +## What gets installed + +| Component | Pin | +| --- | --- | +| Nginx | 1.31.5 | +| Docker binaries in the private host | 27.3.1 | +| Injector | 0.71.0-1 | +| Published Nginx library | 1.23.0-1 | +| Python library | 4.14.0-1 | +| APM test agent | v1.65.0 | +| Flask / Gunicorn | 3.1.2 / 23.0.0 | +| pytest | 8.4.2 | + +The installer bootstrap uses Datadog's install-only pattern with agent installation +disabled. Language packages and the pinned injector are installed with the installer's +package commands. Host tests require the resulting `/etc/ld.so.preload`; Docker +tests require the running private daemon's default runtime to be `dd-shim`. +Workload containers specify no runtime. Collectors explicitly use `runc`, have +their own readiness checks, and do not forward data. + +The application has no tracing imports, tracing launcher, manual preload, or Datadog +Nginx directives. Test settings reach Nginx through environment variables at master +startup, including in the environment-tags and reload cases. + +## Examples + +Both examples use the acceptance harness and keep the disposable host running: + +```sh +DOCKER_CONTEXT=orbstack make injection-example-up INJECTION_MODE=host +make injection-example-request +make injection-example-traces +make injection-example-down + +DOCKER_CONTEXT=orbstack make injection-example-up INJECTION_MODE=docker +make injection-example-request +make injection-example-traces +make injection-example-down +``` + +`docker-alpine` selects Alpine. `INJECTION_SOURCE=checkout` and `INJECTION_PACKAGE` +work for examples too. The state file records the Docker context, so later commands +use the same daemon. Keep `INJECTION_ARTIFACTS` consistent between commands. +Requests run through `docker exec` and return the backend's echoed propagation +headers. The traces command prints the test agent's decoded traces. Shutdown flushes +the workloads and saves diagnostics before removing the example resources. + +Set these variables on `injection-example-up` to change the Nginx environment: + +| Setting | Example | +| --- | --- | +| Trace agent URL | `DD_TRACE_AGENT_URL=http://127.0.0.1:8126` | +| Host and nondefault port | `DD_AGENT_HOST=127.0.0.1 DD_TRACE_AGENT_PORT=9126` | +| Unix socket | `DD_TRACE_AGENT_URL=unix:///var/run/datadog/apm.socket` | +| Service and version tags | `DD_SERVICE=frontend DD_ENV=dev DD_VERSION=example` | +| Extra tags | `DD_TAGS=team:nginx,example:injection` | +| Sampling rule | `DD_TRACE_SAMPLING_RULES='[{"service":"injection-nginx","sample_rate":0}]'` | +| W3C propagation | `DD_TRACE_PROPAGATION_STYLE_EXTRACT=tracecontext DD_TRACE_PROPAGATION_STYLE_INJECT=tracecontext` | +| Disable Nginx reporting | `DD_TRACE_ENABLED=false` | +| Opt Nginx out of injection | `DD_INSTRUMENT_SERVICE_WITH_APM=false` | + +`DD_TRACE_AGENT_URL` takes precedence over host/port. Collector A listens on 8126 +and the Unix socket; collector B listens on 9126. These addresses are inside the +disposable host. Flask remains traced when Nginx reporting or injection is disabled. +The sampling test checks the backend's received priority of `-1`; absence of raw +traces alone does not establish a sampling decision. + +Nginx captures supported Datadog environment variables when its master starts and +restores them in workers. No Nginx `env` directives are needed. Changing those +values requires restarting the master or recreating the container; a graceful +configuration reload retains the original values. This is separate from the +official image's Nginx configuration templating and from Datadog stable config. + +## Stable config + +The suite writes stable configuration to both supported locations: + +- `/etc/datadog-agent/application_monitoring.yaml` +- `/etc/datadog-agent/managed/datadog-agent/stable/application_monitoring.yaml` + +The Docker injector mounts existing files at those paths into ordinary workload +containers. Tests assert that the file is visible to Nginx before checking default +configuration, managed > environment > local precedence, environment-variable +targeting rules, service tags, and tracing disablement. These cases follow the +system-tests YAML shapes for `apm_configuration_default` and +`apm_configuration_rules`. + +RUM opt-out tests serve HTML while requiring a valid Nginx trace. They verify that +`DD_RUM_ENABLED=false` in the environment leaves tracing active. The equivalent +local and managed stable-config cases are strict expected failures until the Nginx +RUM integration consumes that setting. + +The Swarm smoke test creates one service replica in the private Docker daemon. It +uses the ordinary Nginx image and does not select a runtime or add Datadog Nginx +directives. It requires a loaded module, one decoded Nginx span, and unchanged HTML +when RUM injection is disabled. It records the Swarm node, service, task, container, +logs, process map, and trace before removing the service. This test does not cover +multi-node scheduling, overlay networking, routing mesh, rolling updates, or node +failure. + +Each known gap uses `xfail(strict=True)`. When support lands, an unexpected pass +fails CI and requires removing the marker. + +## Evidence and CI + +Artifacts default to `test/injection/artifacts/latest/`. They include JUnit, +resolved image IDs/digests, package manifest digests, installer version/output, +module checksums, daemon configuration, process maps, workload logs, injection +telemetry, request responses, and decoded traces from both collectors. Missing +required evidence fails the run. Failure cleanup also copies raw daemon/workload +logs. The suite removes only its own containers, Docker data volumes, and generated +image tags; it never prunes the daemon. + +`.gitlab/injection-tests.yml` extends the existing `.test` runner setup. Checkout +jobs consume the architecture-matched Linux `package-oci` artifact on branches +and tags. Published jobs run on schedules and are also available manually. +Both architectures run all modes serially, with a 30-minute timeout, infrastructure +retries, and artifacts published even on failure. + +Release acceptance requires full published and checkout runs on native ARM64 Orb, +both architectures in GitLab, and saved evidence of a linked Nginx → Flask trace. +Test collection or a filtered smoke run does not meet that requirement. diff --git a/test/injection/__init__.py b/test/injection/__init__.py new file mode 100644 index 00000000..65e4d767 --- /dev/null +++ b/test/injection/__init__.py @@ -0,0 +1,6 @@ +"""Installer acceptance tests and local examples.""" + + +def load_tests(loader, tests, pattern): + """Keep pytest cases out of the existing unittest suite.""" + return tests diff --git a/test/injection/conftest.py b/test/injection/conftest.py new file mode 100644 index 00000000..e2e4534b --- /dev/null +++ b/test/injection/conftest.py @@ -0,0 +1,150 @@ +import os +from pathlib import Path +import re + +import pytest + +from .harness import (Docker, Images, MODES, Sandbox, STABLE_CONFIG_PATHS, + SwarmWorkload, Workload) + + +def pytest_addoption(parser): + group = parser.getgroup("injection") + group.addoption("--injection-mode", choices=MODES, action="append") + group.addoption("--injection-package", + default=os.environ.get("INJECTION_PACKAGE")) + group.addoption("--injection-artifacts", + default=os.environ.get("INJECTION_ARTIFACTS", + "test/injection/artifacts/latest")) + + +def pytest_configure(config): + if os.environ.get("PYTEST_XDIST_WORKER") or getattr( + config.option, "numprocesses", 0): + raise pytest.UsageError("Injection acceptance tests must run serially") + + +def pytest_generate_tests(metafunc): + if "mode" in metafunc.fixturenames: + explicitly_parameterized = any( + "mode" in marker.args[0].replace(",", " ").split() + for marker in metafunc.definition.iter_markers("parametrize") + if marker.args and isinstance(marker.args[0], str)) + if explicitly_parameterized: + return + metafunc.parametrize("mode", + metafunc.config.getoption("--injection-mode") + or MODES, + scope="session") + + +@pytest.hookimpl(trylast=True) +def pytest_collection_modifyitems(config, items): + selected_modes = config.getoption("--injection-mode") + deselected = [] + selected = [] + for item in items: + mode = item.callspec.params.get("mode") if hasattr( + item, "callspec") else None + if selected_modes and mode and mode not in selected_modes: + deselected.append(item) + else: + selected.append(item) + if deselected: + config.hook.pytest_deselected(items=deselected) + items[:] = selected + items.sort( + key=lambda item: MODES.index(item.callspec.params["mode"]) if hasattr( + item, "callspec") and "mode" in item.callspec.params else -1) + + +def pytest_sessionfinish(session, exitstatus): + reporter = session.config.pluginmanager.getplugin("terminalreporter") + if reporter and reporter.stats.get("skipped"): + session.exitstatus = pytest.ExitCode.TESTS_FAILED + if reporter and os.environ.get("GITLAB_CI") and reporter.stats.get( + "deselected"): + session.exitstatus = pytest.ExitCode.TESTS_FAILED + + +@pytest.fixture(scope="session") +def images(request): + docker = Docker(request.config.getoption("--injection-artifacts")) + built = Images(docker) + try: + yield built + finally: + built.close() + + +@pytest.fixture(scope="session") +def sandbox_factory(images, request): + package = request.config.getoption("--injection-package") + if package: + package = str(Path(package).resolve(strict=True)) + + def create(mode, name): + return Sandbox(images, mode, images.docker.artifacts / name, package) + + return create + + +@pytest.fixture(scope="session") +def sandbox(sandbox_factory, mode): + instance = sandbox_factory(mode, mode) + try: + instance.start() + instance.install() + yield instance + finally: + instance.close() + + +@pytest.fixture +def workload(sandbox, request): + name = re.sub(r"[^a-zA-Z0-9_.-]", "_", request.node.name) + created = [] + + def start(nginx_env=None, backend_env=None, image=None): + instance = Workload(sandbox, name, nginx_env, backend_env, image) + created.append(instance) + return instance.start() + + try: + yield start + finally: + for instance in created: + instance.finish() + + +@pytest.fixture +def swarm_workload(sandbox, request): + name = re.sub(r"[^a-zA-Z0-9_.-]", "_", request.node.name) + created = [] + + def start(nginx_env=None): + instance = SwarmWorkload(sandbox, name, nginx_env) + created.append(instance) + return instance.start() + + try: + yield start + finally: + for instance in created: + instance.finish() + + +@pytest.fixture +def stable_config(sandbox): + written = set() + + def write(path, configuration): + assert path in STABLE_CONFIG_PATHS + written.add(path) + return sandbox.write_stable_config(path, configuration) + + try: + yield write + finally: + for path in written: + sandbox.host("rm", "-f", path) diff --git a/test/injection/harness.py b/test/injection/harness.py new file mode 100644 index 00000000..e2090d26 --- /dev/null +++ b/test/injection/harness.py @@ -0,0 +1,786 @@ +"""A private Linux host, Docker daemon, and trace collectors.""" + +import json +import os +from pathlib import Path +import shlex +import subprocess +import time +import uuid + +import yaml + +ROOT = Path(__file__).resolve().parents[2] +WORKLOADS = Path(__file__).with_name("workloads") +NGINX_VERSION = "1.31.5" +INJECTOR_VERSION = "0.71.0-1" +NGINX_PACKAGE_VERSION = "1.23.0-1" +PYTHON_PACKAGE_VERSION = "4.14.0-1" +AGENT_IMAGE = "ghcr.io/datadog/dd-apm-test-agent/ddapm-test-agent:v1.65.0" +UNSUPPORTED_IMAGE = "nginx:1.20.2" +MODES = ("host", "docker-debian", "docker-alpine") +STABLE_CONFIG_PATHS = ( + "/etc/datadog-agent/application_monitoring.yaml", + "/etc/datadog-agent/managed/datadog-agent/stable/application_monitoring.yaml", +) +LABEL = "com.datadog.nginx-injection" + + +def poll(action, description, timeout=30): + deadline = time.monotonic() + timeout + last_error = None + while time.monotonic() < deadline: + try: + result = action() + if result: + return result + except (AssertionError, RuntimeError) as error: + last_error = error + time.sleep(0.5) + raise AssertionError(f"Timed out waiting for {description}: {last_error}") + + +def flatten(chunks): + if isinstance(chunks, dict): + assert "span_id" in chunks, f"Unexpected trace object: {chunks}" + return [chunks] + assert isinstance(chunks, list), f"Unexpected trace collection: {chunks}" + return [span for chunk in chunks for span in flatten(chunk)] + + +def matching(spans, uri, service=None): + return [ + span for span in spans + if uri in span.get("meta", {}).get("http.url", "") and ( + service is None or span["service"] == service) + ] + + +def trace_id(span): + low = int(span["trace_id"]) + high = span.get("meta", {}).get("_dd.p.tid", "0") + return (int(high, 16) << 64) | low + + +class Docker: + + def __init__(self, artifacts, context=None): + context = context or os.environ.get("DOCKER_CONTEXT") + host = os.environ.get("DOCKER_HOST") + if not context and not host: + context = subprocess.check_output(["docker", "context", "show"], + text=True).strip() + self.prefix = ["docker", "--context", context + ] if context else ["docker", "--host", host] + self.artifacts = Path(artifacts).resolve() + self.artifacts.mkdir(parents=True, exist_ok=True) + + def run(self, *args, check=True, timeout=300, input=None): + command = [*self.prefix, *map(str, args)] + try: + result = subprocess.run(command, + input=input, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=timeout) + except subprocess.TimeoutExpired as error: + with (self.artifacts / "commands.log").open("a") as log: + log.write( + f"$ {shlex.join(command)}\nTimed out after {timeout}s\n") + for output in (error.stdout, error.stderr): + log.write( + output.decode(errors="replace") if isinstance( + output, bytes) else output or "") + raise RuntimeError( + f"Command timed out; see {self.artifacts / 'commands.log'}" + ) from error + with (self.artifacts / "commands.log").open("a") as log: + log.write( + f"$ {shlex.join(command)}\n{result.stdout}{result.stderr}\n") + if check and result.returncode: + raise RuntimeError( + f"{shlex.join(command)} failed ({result.returncode}):\n" + f"{(result.stdout + result.stderr)[-6000:]}\n" + f"Full output: {self.artifacts / 'commands.log'}") + return result.stdout.strip() if check else result + + def transfer(self, image, container): + with (self.artifacts / "image-transfer.log").open("a") as log: + save = subprocess.Popen([*self.prefix, "save", image], + stdout=subprocess.PIPE, + stderr=log) + try: + loaded = subprocess.run( + [*self.prefix, "exec", "-i", container, "docker", "load"], + stdin=save.stdout, + stdout=log, + stderr=log, + timeout=300) + save.stdout.close() + saved = save.wait(timeout=30) + assert saved == 0, f"docker save failed for {image}: {saved}" + assert loaded.returncode == 0, f"docker load failed for {image}" + finally: + save.stdout.close() + if save.poll() is None: + save.kill() + save.wait() + + +class Images: + + def __init__(self, docker): + self.docker = docker + self.host = f"nginx-injection-host:{uuid.uuid4().hex[:12]}" + self.loaded = set() + info = json.loads(docker.run("info", "--format", "{{json .}}")) + self.arch = { + "aarch64": "arm64", + "x86_64": "amd64" + }.get(info["Architecture"], info["Architecture"]) + assert self.arch in ("arm64", "amd64"), info["Architecture"] + expected = os.environ.get("INJECTION_ARCH") + assert not expected or self.arch == expected, (self.arch, expected) + docker.run("build", + "--platform", + f"linux/{self.arch}", + "--pull", + "--label", + LABEL, + "-t", + self.host, + WORKLOADS, + timeout=600) + try: + self.record(self.host) + for image in (f"nginx:{NGINX_VERSION}", + f"nginx:{NGINX_VERSION}-alpine", AGENT_IMAGE): + self.pull(image) + except BaseException: + self.close() + raise + + def record(self, image): + self.loaded.add(image) + data = [ + json.loads(self.docker.run("image", "inspect", name))[0] + for name in sorted(self.loaded) + ] + (self.docker.artifacts / "images.json").write_text( + json.dumps(data, indent=2)) + + def pull(self, image): + if image not in self.loaded: + self.docker.run("pull", "--platform", f"linux/{self.arch}", image) + self.record(image) + + def close(self): + self.docker.run("image", "rm", self.host) + + +class Sandbox: + + def __init__(self, images, mode, artifacts, package=None): + self.images = images + self.docker = images.docker + self.mode = mode + self.package = package + self.artifacts = Path(artifacts) + self.artifacts.mkdir(parents=True, exist_ok=True) + self.name = f"nginx-injection-{uuid.uuid4().hex[:12]}" + self.container = None + self.installed = False + self.workload = None + self.collectors = [] + self.transferred = set() + + def start(self): + self.container = self.docker.run("run", "-d", "--platform", + f"linux/{self.images.arch}", + "--privileged", "--label", LABEL, + "--name", self.name, "-v", + "/var/lib/docker", self.images.host) + self.host("sh", + "-c", "dockerd --host=unix:///var/run/docker.sock " + "--storage-driver=overlay2 > /evidence/dockerd.log 2>&1", + detach=True) + poll(lambda: self.inner("info", check=False).returncode == 0, + "private Docker daemon") + self.load(AGENT_IMAGE) + for name, port, directory in (("a", 8126, "/var/run/datadog"), + ("b", 9126, "/var/run/datadog-b")): + self.inner("run", "-d", "--runtime=runc", "--network=host", + "--name", f"collector-{name}", "-v", + f"{directory}:{directory}", "-e", f"PORT={port}", "-e", + "DD_AGENT_URL=", "-e", + "DISABLE_LLMOBS_DATA_FORWARDING=true", "-e", + "DD_INSTRUMENT_SERVICE_WITH_APM=false", "-e", + f"DD_APM_RECEIVER_SOCKET={directory}/apm.socket", + AGENT_IMAGE, "ddapm-test-agent", "--otlp-http-port", + str(port + 1), "--otlp-grpc-port", str(port + 2)) + self.collectors.append(name) + poll(lambda: self.agent("/info", name), f"collector {name}") + self.host("chmod", "777", "/var/run/datadog/apm.socket") + return self + + def host(self, *command, env=None, detach=False, **kwargs): + args = ["exec"] + if detach: + args.append("-d") + if kwargs.get("input") is not None: + args.append("-i") + for key, value in (env or {}).items(): + if value is not None: + args.extend(["-e", f"{key}={value}"]) + result = self.docker.run(*args, self.container, *command, **kwargs) + if command[0] == "datadog-installer" or command[:1] == ("bash", ): + with (self.artifacts / "installer.log").open("a") as log: + log.write(f"$ {shlex.join(command)}\n{result}\n") + return result + + def inner(self, *command, **kwargs): + return self.host("docker", + *command, + env={"DD_INSTRUMENT_SERVICE_WITH_APM": "false"}, + **kwargs) + + def load(self, image): + if image not in self.transferred: + self.docker.transfer(image, self.container) + self.transferred.add(image) + + def agent(self, path, collector="a"): + port = 8126 if collector == "a" else 9126 + data = self.host("curl", "--fail", "--silent", "--show-error", + "--max-time", "3", f"http://127.0.0.1:{port}{path}") + return json.loads(data) + + def spans(self, collector="a"): + return flatten(self.agent("/test/traces", collector)) + + def assert_injection_telemetry(self): + + def received(): + return [ + event for event in self.agent("/test/apmtelemetry") + if event.get("application", {}).get("language_name") == "nginx" + and any( + series.get("metric") == "inject.success" + for series in event.get("payload", {}).get("series", [])) + ] + + return poll(received, "Nginx injection success telemetry") + + def wait_spans(self, uri, service, collector="a", count=1): + + def received(): + spans = matching(self.spans(collector), uri, service) + return spans if len(spans) >= count else None + + return poll(received, + f"{service} spans for {uri} at collector {collector}") + + def quiet(self, uri, service=None, collector="a", seconds=3): + assert self.workload is None or not self.workload.running, "Flush workloads first" + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + assert self.agent("/info", collector) + assert not matching(self.spans(collector), uri, service) + time.sleep(0.5) + + def install(self, nginx=True): + method = "host" if self.mode == "host" else "docker" + environment = { + "DD_APM_INSTRUMENTATION_ENABLED": method, + "DD_APM_INSTRUMENTATION_LIBRARIES": "none", + "DD_NO_AGENT_INSTALL": "true", + "DD_INSTALL_ONLY": "true", + "DD_INSTRUMENTATION_TELEMETRY_ENABLED": "false", + } + script = "/evidence/install-agent.sh" + self.host( + "curl", "-fsSL", "--retry", "3", + "https://install.datadoghq.com/scripts/install_script_agent7.sh", + "-o", script) + self.host("bash", script, env=environment, timeout=300) + self.host("ln", "-sf", + "/opt/datadog-packages/run/datadog-installer-ssi", + "/usr/local/bin/datadog-installer") + self.host("datadog-installer", + "remove", + "datadog-apm-inject", + env=environment) + self.install_published("apm-library-python-package", + PYTHON_PACKAGE_VERSION, environment) + if nginx: + self.install_nginx(environment) + self.install_published("apm-inject-package", INJECTOR_VERSION, + environment) + version = self.host("datadog-installer", "version") + (self.artifacts / "installer-version.txt").write_text(version) + if method == "host": + preload = self.host("cat", "/etc/ld.so.preload") + assert "/opt/datadog-packages/" in preload or "/opt/datadog/" in preload, preload + assert "launcher.preload.so" in preload, preload + else: + poll( + lambda: self.inner("info", "--format", "{{.DefaultRuntime}}") + == "dd-shim", "installer-configured default dd-shim runtime") + self.installed = True + + def install_published(self, repository, version, environment): + registry = "install.datadoghq.com" + self.host("curl", "-fsSL", "--retry", "3", + f"https://{registry}/v2/{repository}/manifests/{version}", + "-o", f"/evidence/{repository}-index.json") + digest = self.host("sha256sum", + f"/evidence/{repository}-index.json").split()[0] + data = json.loads( + self.host("cat", f"/evidence/{repository}-index.json")) + selected = [ + entry for entry in data["manifests"] if entry["platform"] == { + "architecture": self.images.arch, + "os": "linux" + } + ] + assert len(selected) == 1, data + (self.artifacts / f"{repository}.json").write_text( + json.dumps( + { + "version": version, + "index_digest": f"sha256:{digest}", + "manifest": selected[0] + }, + indent=2)) + self.host("datadog-installer", + "install", + f"oci://{registry}/{repository}@sha256:{digest}", + env=environment) + + def install_nginx(self, environment): + if self.package: + from .package import module_checksum + expected = module_checksum(Path(self.package), self.images.arch) + self.host("mkdir", "-p", "/tmp/nginx-package") + self.docker.run("cp", self.package, + f"{self.container}:/tmp/nginx-package.tar") + self.host("tar", "-xf", "/tmp/nginx-package.tar", "-C", + "/tmp/nginx-package") + environment = { + **environment, "DD_INSTALLER_REGISTRY_URL": + "file:///tmp/nginx-package" + } + url = "file:///tmp/nginx-package" + self.host("datadog-installer", "install", url, env=environment) + else: + self.install_published("apm-library-nginx-package", + NGINX_PACKAGE_VERSION, environment) + installed = self.host( + "readlink", "-f", + "/opt/datadog-packages/datadog-apm-library-nginx/stable") + checksum = self.host( + "sha256sum", + f"{installed}/nginx/{NGINX_VERSION}/ngx_http_datadog_module.so") + (self.artifacts / "module-checksum.txt").write_text(checksum) + if self.package: + assert checksum.split()[0] == expected, (checksum, expected) + self.host("sh", "-c", + "find /opt/datadog-packages -name '*.db' -o -name '*json'") + + def write_stable_config(self, path, configuration): + content = yaml.safe_dump(configuration, sort_keys=True) + self.host("mkdir", "-p", str(Path(path).parent)) + self.host("sh", "-c", 'cat > "$1"', "sh", path, input=content) + name = path.removeprefix("/").replace("/", "-") + (self.artifacts / name).write_text(content) + return content + + def save(self, name): + target = self.artifacts / name + target.mkdir(exist_ok=True) + self.docker.run("cp", f"{self.container}:/evidence", target) + if self.workload and self.workload.running and self.mode != "host": + for container in self.workload.containers: + logs = self.inner("logs", container, check=False) + (target / f"{container}.log").write_text(logs.stdout + + logs.stderr) + for collector in self.collectors: + logs = self.inner("logs", f"collector-{collector}", check=False) + (target / f"{collector}.log").write_text(logs.stdout + logs.stderr) + for collector in self.collectors: + for endpoint, filename in (("/test/traces", "traces"), + ("/test/apmtelemetry", + "telemetry"), ("/info", "health")): + (target / f"{collector}-{filename}.json").write_text( + json.dumps(self.agent(endpoint, collector), indent=2)) + (target / "daemon.json").write_text( + self.host("cat", "/etc/docker/daemon.json", check=False).stdout + or "{}") + (target / "docker-info.json").write_text( + self.inner("info", "--format", "{{json .}}")) + + def close(self): + if not self.container: + return + try: + try: + if self.workload: + self.workload.stop() + finally: + self.save("final") + finally: + self.docker.run("rm", "-f", "-v", self.container) + self.container = None + + +class Workload: + + def __init__(self, + sandbox, + name, + nginx_env=None, + backend_env=None, + image=None): + self.sandbox = sandbox + self.name = name + self.directory = sandbox.artifacts / name + self.directory.mkdir(parents=True, exist_ok=True) + self.nginx_env = {"DD_SERVICE": "injection-nginx", **(nginx_env or {})} + self.backend_env = { + "DD_SERVICE": "injection-backend", + **(backend_env or {}) + } + self.image = image or f"nginx:{NGINX_VERSION}" + ( + "-alpine" if sandbox.mode == "docker-alpine" else "") + self.running = False + self.containers = [] + self.generation = 0 + sandbox.workload = self + + def start(self): + assert not self.running + self.running = True + self.generation += 1 + sandbox = self.sandbox + if sandbox.mode == "host": + self.start_host("backend", [ + "/opt/flask/bin/gunicorn", "--chdir", "/workload", "--bind", + "127.0.0.1:8081", "--workers", "1", "app:app" + ], self.backend_env) + self.start_host( + "nginx", + ["nginx", "-c", "/workload/nginx.conf", "-g", "daemon off;"], + self.nginx_env) + else: + sandbox.load(sandbox.images.host) + sandbox.load(self.image) + self.start_container( + "backend", sandbox.images.host, self.backend_env, [ + "/opt/flask/bin/gunicorn", "--chdir", "/workload", + "--bind", "127.0.0.1:8081", "--workers", "1", "app:app" + ]) + self.start_container( + "nginx", self.image, self.nginx_env, + ["nginx", "-c", "/workload/nginx.conf", "-g", "daemon off;"]) + poll(lambda: self.request("/ready", port=8081), "Flask readiness") + poll(lambda: self.request("/ready"), "Nginx readiness") + return self + + def environment(self, overrides): + return { + "DD_TRACE_AGENT_URL": "http://127.0.0.1:8126", + "DD_APM_INSTRUMENTATION_DEBUG": "true", + "DD_TRACE_STARTUP_LOGS": "false", + **overrides + } + + def start_host(self, service, command, overrides): + script = f"echo $$ > /tmp/{service}-launcher.pid; exec {shlex.join(command)} " \ + f"> /evidence/{service}.log 2>&1" + self.sandbox.host("sh", + "-c", + script, + env=self.environment(overrides), + detach=True) + + def start_container(self, name, image, overrides, command): + args = [ + "run", "-d", "--network=host", "--name", name, "-v", + "/var/run/datadog:/var/run/datadog", "-v", + "/workload:/workload:ro", "-v", "/srv/site:/srv/site:ro" + ] + for key, value in self.environment(overrides).items(): + if value is not None: + args.extend(["-e", f"{key}={value}"]) + self.sandbox.inner(*args, image, *command) + self.containers.append(name) + + def request(self, + uri, + headers=None, + port=8080, + content=None, + include_content_type=False): + if uri.startswith("/static/"): + self.sandbox.host( + "sh", + "-c", + 'cat > "$1"', + "sh", + f"/srv/site{uri}", + input=content if content is not None else "injection test\n") + args = [ + "curl", "--fail", "--silent", "--show-error", "--max-time", "3", + "-w", + "\n__DD_STATUS__%{http_code}\n__DD_CONTENT_TYPE__%{content_type}" + ] + for key, value in (headers or {}).items(): + args.extend(["-H", f"{key}: {value}"]) + response = self.sandbox.host(*args, f"http://127.0.0.1:{port}{uri}") + body, marker, metadata = response.rpartition("__DD_STATUS__") + assert marker, response + body = body.removesuffix("\n") + status, marker, content_type = metadata.partition( + "\n__DD_CONTENT_TYPE__") + assert marker, response + assert int(status) == (204 if port == 8080 and uri == "/ready" else + 200), response + if uri != "/ready": + with (self.directory / "requests.jsonl").open("a") as output: + output.write( + json.dumps( + dict(uri=uri, + port=port, + status=int(status), + content_type=content_type, + body=body)) + "\n") + result = body or status + return (result, content_type) if include_content_type else result + + def evidence(self): + data = json.loads( + self.sandbox.host("python3", "/workload/processes.py")) + assert data, "No running Nginx process" + (self.directory / f"processes-{self.generation}.json").write_text( + json.dumps(data, indent=2)) + return data + + def assert_module(self, loaded=True): + processes = self.evidence() + assert any("master process" in item["command"] for item in processes) + assert any("worker process" in item["command"] for item in processes) + for item in processes: + assert len(item["modules"]) == int(loaded), item + + def assert_file(self, path, content): + if self.sandbox.mode == "host": + actual = self.sandbox.host("cat", path) + else: + actual = self.sandbox.inner("exec", "nginx", "cat", path) + assert actual == content.rstrip(), actual + + def reload(self): + before = self.evidence() + master = next(item["pid"] for item in before + if "master process" in item["command"]) + workers = { + item["pid"] + for item in before if "worker process" in item["command"] + } + self.sandbox.host("kill", "-HUP", str(master)) + self.generation += 1 + poll( + lambda: { + item["pid"] + for item in self.evidence() + if "worker process" in item["command"] + }.isdisjoint(workers), "new Nginx workers") + + def stop(self): + if not self.running: + return + sandbox = self.sandbox + self.evidence() + if sandbox.mode == "host": + sandbox.host("sh", "-c", + "kill -QUIT $(cat /tmp/injection-nginx.pid)") + poll( + lambda: not json.loads( + sandbox.host("python3", "/workload/processes.py")), + "Nginx graceful shutdown") + sandbox.host("sh", "-c", + "kill -TERM $(cat /tmp/backend-launcher.pid)") + poll( + lambda: sandbox.host( + "sh", + "-c", + "kill -0 $(cat /tmp/backend-launcher.pid)", + check=False).returncode != 0, "Flask graceful shutdown") + for name in ("nginx", "backend"): + self.directory.joinpath( + f"{name}-{self.generation}.log").write_text( + sandbox.host("cat", f"/evidence/{name}.log")) + else: + for name in reversed(self.containers): + sandbox.inner("stop", "--signal", + "SIGQUIT" if name == "nginx" else "SIGTERM", + "--time", "30", name) + state = json.loads( + sandbox.inner("inspect", "--format", "{{json .State}}", + name)) + assert state["ExitCode"] == 0, state + logs = sandbox.inner("logs", name, check=False) + self.directory.joinpath( + f"{name}-{self.generation}.log").write_text(logs.stdout + + logs.stderr) + sandbox.inner("rm", name) + self.containers.clear() + self.running = False + + def finish(self): + self.stop() + self.sandbox.save(self.name) + + +class SwarmWorkload(Workload): + + def __init__(self, sandbox, name, nginx_env=None): + assert sandbox.mode == "docker-debian" + super().__init__(sandbox, name, nginx_env) + self.service = f"nginx-swarm-{uuid.uuid4().hex[:8]}" + self.service_created = False + self.swarm_created = False + self.task_container = None + self.task = None + + def start(self): + assert not self.running + self.running = True + self.generation += 1 + self.sandbox.load(self.image) + state = self.sandbox.inner("info", "--format", + "{{.Swarm.LocalNodeState}}") + assert state == "inactive", state + self.sandbox.inner("swarm", "init", "--advertise-addr", "eth0") + self.swarm_created = True + args = [ + "service", "create", "--detach", "--no-resolve-image", "--name", + self.service, "--replicas", "1", "--network", "host", + "--restart-condition", "none", "--stop-signal", "SIGQUIT", + "--stop-grace-period", "30s", "--container-label", LABEL, + "--mount", "type=bind,source=/workload,target=/workload,readonly", + "--mount", "type=bind,source=/srv/site,target=/srv/site,readonly" + ] + for key, value in self.environment(self.nginx_env).items(): + if value is not None: + args.extend(["--env", f"{key}={value}"]) + self.sandbox.inner(*args, self.image, "nginx", "-c", + "/workload/nginx.conf", "-g", "daemon off;") + self.service_created = True + poll(self.running_task, "running Nginx Swarm task") + self.task_container = poll(self.container_id, + "Nginx Swarm task container") + self.record_swarm("running") + poll(lambda: self.request("/ready"), "Nginx Swarm readiness") + return self + + def running_task(self): + result = self.sandbox.inner("service", + "ps", + "--no-trunc", + "--format", + "{{.CurrentState}} {{.Error}}", + self.service, + check=False) + assert result.returncode == 0, result.stderr + states = result.stdout.strip() + assert not any(state in states + for state in ("Failed", "Rejected")), states + return states if any( + line.startswith("Running") + for line in states.splitlines()) else None + + def container_id(self): + containers = self.sandbox.inner( + "ps", "-q", "--filter", + f"label=com.docker.swarm.service.name={self.service}").splitlines( + ) + return containers[0] if len(containers) == 1 else None + + def record_swarm(self, state): + self.directory.joinpath(f"nodes-{state}.jsonl").write_text( + self.sandbox.inner("node", "ls", "--format", "{{json .}}") + "\n") + self.directory.joinpath(f"service-{state}.json").write_text( + self.sandbox.inner("service", "inspect", self.service) + "\n") + self.directory.joinpath(f"tasks-{state}.jsonl").write_text( + self.sandbox.inner("service", "ps", "--no-trunc", "--format", + "{{json .}}", self.service) + "\n") + if self.task_container: + inspected = self.sandbox.inner("inspect", + self.task_container, + check=False) + if inspected.returncode == 0: + self.directory.joinpath(f"container-{state}.json").write_text( + inspected.stdout) + self.task = json.loads(inspected.stdout)[0] + + def assert_file(self, path, content): + actual = self.sandbox.inner("exec", self.task_container, "cat", path) + assert actual == content.rstrip(), actual + + def stop(self): + if not self.running: + return + try: + if self.service_created: + self.evidence() + self.record_swarm("before-stop") + self.sandbox.inner("service", "scale", f"{self.service}=0") + poll( + lambda: not json.loads( + self.sandbox.host("python3", "/workload/processes.py") + ), "Nginx Swarm graceful shutdown") + logs = self.sandbox.inner("service", + "logs", + "--raw", + self.service, + check=False) + self.directory.joinpath("service.log").write_text(logs.stdout + + logs.stderr) + self.record_swarm("stopped") + finally: + try: + if self.service_created: + self.sandbox.inner("service", + "rm", + self.service, + check=False) + self.service_created = False + finally: + if self.swarm_created: + self.sandbox.inner("swarm", + "leave", + "--force", + check=False) + self.swarm_created = False + self.running = False + + def proof(self, uri, body, content_type, span): + labels = self.task["Config"]["Labels"] + data = { + "uri": uri, + "body": body, + "content_type": content_type, + "service": self.service, + "service_id": labels["com.docker.swarm.service.id"], + "task_id": labels["com.docker.swarm.task.id"], + "container_id": self.task_container, + "nginx_span": span, + } + self.directory.joinpath("swarm-proof.json").write_text( + json.dumps(data, indent=2)) + + +def unique_uri(proxy=False): + return f"/{'proxy' if proxy else 'static'}/{uuid.uuid4().hex}" diff --git a/test/injection/package.py b/test/injection/package.py new file mode 100644 index 00000000..60d5c286 --- /dev/null +++ b/test/injection/package.py @@ -0,0 +1,152 @@ +"""Use the same RUM module layout and OCI packager as package-oci.""" + +import hashlib +import io +import json +from pathlib import Path, PurePosixPath +import subprocess +import tarfile +import tempfile + +import yaml +import zstandard + +from .harness import LABEL, NGINX_VERSION, ROOT + +PACKAGER = "registry.ddbuild.io/ci/libdatadog-build/packaging:135928035" + + +def module_checksum(archive, arch): + with tarfile.open(archive) as package: + members = { + member.name.removeprefix("./"): member + for member in package + } + for path, member in members.items(): + assert not PurePosixPath( + path).is_absolute() and ".." not in PurePosixPath(path).parts + assert member.isfile() or member.isdir( + ), f"Invalid OCI archive entry: {path}" + + def read(path): + return package.extractfile(members[path]).read() + + def blob(descriptor): + algorithm, digest = descriptor["digest"].split(":") + assert algorithm == "sha256" + data = read(f"blobs/{algorithm}/{digest}") + assert hashlib.sha256(data).hexdigest() == digest + return data + + index = json.loads(read("index.json")) + manifests = [ + entry for entry in index["manifests"] + if entry.get("platform", {}).get("os") == "linux" + and entry.get("platform", {}).get("architecture") == arch + ] + assert len( + manifests) == 1, f"Expected one Linux {arch} manifest: {index}" + manifest = json.loads(blob(manifests[0])) + checksums = [] + for layer in manifest["layers"]: + stream = io.BytesIO(blob(layer)) + if layer.get("mediaType", "").endswith("+zstd"): + stream = zstandard.ZstdDecompressor().stream_reader(stream) + with stream, tarfile.open(fileobj=stream, mode="r|*") as contents: + for member in contents: + if member.name.removeprefix( + "./" + ) == f"nginx/{NGINX_VERSION}/ngx_http_datadog_module.so": + checksums.append( + hashlib.sha256( + contents.extractfile( + member).read()).hexdigest()) + assert len(checksums + ) == 1, f"Missing or duplicate Nginx {NGINX_VERSION} module" + return checksums[0] + + +def build(docker, arch): + subprocess.run(["git", "submodule", "update", "--init", "--recursive"], + cwd=ROOT, + check=True) + settings = yaml.safe_load( + (ROOT / ".gitlab/common.yml").read_text())["variables"] + toolchain = settings["MUSL_TOOLCHAIN_IMAGE"].replace( + "$CI_REGISTRY", "registry.ddbuild.io/ci/nginx-datadog") + docker.run("pull", "--platform", f"linux/{arch}", toolchain) + builder = docker.run("run", "-d", "--cpus", "6", "--platform", + f"linux/{arch}", "--label", LABEL, toolchain, "sleep", + "infinity") + output = Path(tempfile.mkdtemp(prefix="package-", dir=docker.artifacts)) + try: + with tempfile.TemporaryDirectory() as temporary: + archive = Path(temporary) / "checkout.tar" + tracked = subprocess.check_output(["git", "ls-files", "-z"], + cwd=ROOT).decode().split("\0") + with tarfile.open(archive, "w") as sources: + for name in filter(None, tracked): + + def include(member): + parts = PurePosixPath(member.name).parts + return None if any(part in (".git", ".venv", + "artifacts", "__pycache__", + ".pytest_cache") + for part in parts) else member + + sources.add(ROOT / name, arcname=name, filter=include) + docker.run("cp", archive, f"{builder}:/tmp/checkout.tar") + docker.run("exec", builder, "mkdir", "/checkout") + docker.run("exec", builder, "tar", "-xf", "/tmp/checkout.tar", "-C", + "/checkout") + normalized = {"amd64": "x86_64", "arm64": "aarch64"}[arch] + docker.run("exec", + "-w", + "/checkout", + builder, + "make", + "build-musl-aux", + f"ARCH={normalized}", + f"NGINX_VERSION={NGINX_VERSION}", + "RUM=ON", + "WAF=OFF", + "MAKE_JOB_COUNT=8", + timeout=1200) + sources = output / "sources" + module_dir = sources / "nginx" / NGINX_VERSION + module_dir.mkdir(parents=True, exist_ok=True) + docker.run( + "cp", + f"{builder}:/checkout/.musl-build/ngx_http_datadog_module.so", + module_dir) + commit = subprocess.check_output( + ["git", "rev-parse", "--short", "HEAD"], cwd=ROOT, + text=True).strip() + version = f"0.0.1-{commit}" + (sources / "version").write_text(version + "\n") + return assemble(docker, arch, sources, version, output) + finally: + docker.run("rm", "-f", "-v", builder) + + +def assemble(docker, arch, sources, version, output): + docker.run("pull", "--platform", "linux/amd64", PACKAGER) + packager = docker.run("run", "-d", "--platform", "linux/amd64", "--label", + LABEL, "--entrypoint", "sleep", PACKAGER, "infinity") + try: + docker.run("exec", packager, "mkdir", "/package") + docker.run("cp", sources, f"{packager}:/package/sources") + docker.run("exec", "-w", "/package", packager, "datadog-package", + "create", f"--version={version}", + "--package=datadog-apm-library-nginx", f"--arch={arch}", + "--os=linux", "./sources") + docker.run("cp", f"{packager}:/package/.", output) + packages = list(output.glob("*.tar")) + assert len(packages) == 1, packages + expected = hashlib.sha256( + (sources / "nginx" / NGINX_VERSION / + "ngx_http_datadog_module.so").read_bytes()).hexdigest() + assert module_checksum(packages[0], arch) == expected + return packages[0] + finally: + docker.run("rm", "-f", "-v", packager) diff --git a/test/injection/run.py b/test/injection/run.py new file mode 100644 index 00000000..fabb8ed3 --- /dev/null +++ b/test/injection/run.py @@ -0,0 +1,160 @@ +"""Entry points shared by make, GitLab, and the examples.""" + +import argparse +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys + +from .harness import Docker, Images, MODES, ROOT, Sandbox, Workload, unique_uri +from .package import build, module_checksum + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("action", + choices=("test", "example-up", "example-down", + "example-request", "example-traces")) + parser.add_argument("--source", + choices=("published", "checkout"), + default=os.environ.get("INJECTION_SOURCE", + "published")) + parser.add_argument("--package", + default=os.environ.get("INJECTION_PACKAGE")) + parser.add_argument("--mode", + choices=("docker", *MODES), + default=os.environ.get("INJECTION_MODE", "docker")) + parser.add_argument("--artifacts", + default=os.environ.get( + "INJECTION_ARTIFACTS", + "test/injection/artifacts/latest")) + args, extra = parser.parse_known_args() + os.chdir(ROOT) + docker = Docker(args.artifacts) + if args.action.startswith("example-") and args.action != "example-up": + return existing_example(docker, args.action) + info = json.loads(docker.run("info", "--format", "{{json .}}")) + arch = { + "aarch64": "arm64", + "x86_64": "amd64" + }.get(info["Architecture"], info["Architecture"]) + package = Path(args.package).resolve(strict=True) if args.package else None + if not package and args.source == "checkout" and os.environ.get( + "GITLAB_CI"): + packages = list((ROOT / "packaging").glob(f"*linux*{arch}*.tar")) + assert len( + packages + ) == 1, f"Expected one package-oci Linux {arch} artifact: {packages}" + package = packages[0] + if not package and args.source == "checkout": + package = build(docker, arch) + if package: + checksum = module_checksum(package, arch) + (docker.artifacts / "input-package.json").write_text( + json.dumps( + { + "path": + str(package), + "package_sha256": + hashlib.sha256(package.read_bytes()).hexdigest(), + "module_sha256": + checksum, + "architecture": + arch + }, + indent=2)) + if args.action == "example-up": + example_up(docker, args, package) + return 0 + command = [ + sys.executable, "-m", "pytest", "-c", "test/pyproject.toml", + "test/injection", "-v", "--strict-markers", "--injection-artifacts", + str(docker.artifacts), f"--junitxml={docker.artifacts / 'junit.xml'}" + ] + if package: + command.extend(["--injection-package", str(package)]) + return subprocess.call([*command, *extra]) + + +def example_up(docker, args, package): + state_path = docker.artifacts / "example.json" + assert not state_path.exists(), f"Run example-down first: {state_path}" + images = Images(docker) + mode = "docker-debian" if args.mode == "docker" else args.mode + sandbox = Sandbox(images, mode, docker.artifacts / "example", package) + try: + sandbox.start() + sandbox.install() + environment = { + key: value + for key, value in os.environ.items() + if key in ("DD_SERVICE", "DD_ENV", "DD_VERSION", "DD_TAGS", + "DD_TRACE_AGENT_URL", "DD_AGENT_HOST", + "DD_TRACE_AGENT_PORT", "DD_TRACE_SAMPLING_RULES", + "DD_TRACE_PROPAGATION_STYLE_EXTRACT", + "DD_TRACE_PROPAGATION_STYLE_INJECT", "DD_TRACE_ENABLED", + "DD_INSTRUMENT_SERVICE_WITH_APM") + } + if "DD_AGENT_HOST" in environment and "DD_TRACE_AGENT_URL" not in environment: + environment["DD_TRACE_AGENT_URL"] = None + workload = Workload(sandbox, "example", nginx_env=environment).start() + print(workload.request(unique_uri(True))) + state_path.write_text( + json.dumps( + { + "container": sandbox.container, + "image": images.host, + "mode": mode, + "context": docker.prefix + }, + indent=2)) + print( + "Example is running. Use make injection-example-request, injection-example-traces, and injection-example-down." + ) + except BaseException: + try: + sandbox.close() + finally: + images.close() + raise + + +def existing_example(docker, action): + state_path = docker.artifacts / "example.json" + state = json.loads(state_path.read_text()) + docker.prefix = state["context"] + container = state["container"] + labels = json.loads( + docker.run("inspect", "--format", "{{json .Config.Labels}}", + container)) + assert "com.datadog.nginx-injection" in labels, "Container is not an injection example" + if action == "example-request": + print( + docker.run("exec", container, "curl", "-fsS", + f"http://127.0.0.1:8080{unique_uri(True)}")) + elif action == "example-traces": + print( + docker.run("exec", container, "curl", "-fsS", + "http://127.0.0.1:8126/test/traces")) + else: + images = object.__new__(Images) + images.docker, images.host = docker, state["image"] + sandbox = Sandbox(images, state["mode"], docker.artifacts / "example") + sandbox.container, sandbox.collectors = container, ["a", "b"] + workload = Workload(sandbox, "example") + workload.running = True + workload.containers = [] if state["mode"] == "host" else [ + "backend", "nginx" + ] + try: + sandbox.close() + finally: + images.close() + state_path.unlink() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/injection/test_injection.py b/test/injection/test_injection.py new file mode 100644 index 00000000..09328960 --- /dev/null +++ b/test/injection/test_injection.py @@ -0,0 +1,481 @@ +import json +import re + +import pytest + +from .harness import NGINX_VERSION, STABLE_CONFIG_PATHS, Workload, poll, trace_id, unique_uri, UNSUPPORTED_IMAGE + +stable_config_xfail = pytest.mark.xfail( + reason="C++ tracer stable configuration support is not implemented", + strict=True) +rum_stable_config_xfail = pytest.mark.xfail( + reason= + "Nginx RUM does not consume DD_RUM_ENABLED from stable configuration", + strict=True) +RUM_HTML = "RUM opt outtracing only" + + +def nginx_span(sandbox, uri, collector="a", service="injection-nginx"): + spans = sandbox.wait_spans(uri, service, collector) + assert len(spans) == 1, spans + span = spans[0] + assert span["name"] == "nginx.request", span + assert int(span["trace_id"]) and int(span["span_id"]), span + assert span["meta"]["component"] == "nginx" + assert span["meta"]["http.method"] == "GET" + assert span["meta"]["http.status_code"] == "200" + assert span["meta"]["http.url"].endswith(uri) + return span + + +def linked_spans(sandbox, uri, expected_trace=None, parent=None): + nginx = nginx_span(sandbox, uri) + backend_spans = sandbox.wait_spans(uri, "injection-backend") + backend = [ + span for span in backend_spans if span["name"] == "flask.request" + ] + assert len(backend) == 1, backend_spans + backend = backend[0] + assert trace_id(backend) == trace_id(nginx), (nginx, backend) + assert int(backend["parent_id"]) == int(nginx["span_id"]), (nginx, backend) + if expected_trace is not None: + assert trace_id(nginx) == expected_trace + if parent is not None: + assert int(nginx["parent_id"]) == parent + with (sandbox.artifacts / "linked-traces.jsonl").open("a") as evidence: + evidence.write( + json.dumps({ + "uri": uri, + "nginx": nginx, + "backend": backend + }) + "\n") + return nginx, backend + + +def test_before_and_after_install(sandbox_factory, mode): + sandbox = sandbox_factory(mode, f"{mode}-before-after") + try: + sandbox.start() + files = ("/workload/app.py", "/workload/nginx.conf") + before = sandbox.host("sha256sum", *files) + workload = Workload(sandbox, "before").start() + uris = [unique_uri(), unique_uri(True)] + for uri in uris: + workload.request(uri) + workload.assert_module(loaded=False) + workload.finish() + for uri in uris: + sandbox.quiet(uri) + sandbox.install() + assert sandbox.host("sha256sum", *files) == before + workload = Workload(sandbox, "after").start() + for proxy in (False, True): + uri = unique_uri(proxy) + workload.request(uri) + if proxy: + linked_spans(sandbox, uri) + else: + nginx_span(sandbox, uri) + workload.assert_module() + workload.finish() + assert sandbox.host("sha256sum", *files) == before + finally: + sandbox.close() + + +def test_static_request(sandbox, workload): + application = workload() + uri = unique_uri() + assert application.request(uri) == "injection test\n" + application.assert_module() + application.stop() + nginx_span(sandbox, uri) + sandbox.assert_injection_telemetry() + + +def test_proxy_trace_link(sandbox, workload): + application = workload() + uri = unique_uri(True) + application.request( + uri, { + "x-datadog-trace-id": "123456789", + "x-datadog-parent-id": "987654321", + "x-datadog-sampling-priority": "1" + }) + application.stop() + linked_spans(sandbox, uri, 123456789, 987654321) + + +def test_environment_tags(sandbox, workload): + application = workload({ + "DD_SERVICE": "tagged-nginx", + "DD_ENV": "injection-test", + "DD_VERSION": "test-version", + "DD_TAGS": "team:nginx,test:injection" + }) + uri = unique_uri() + application.request(uri) + application.stop() + span = nginx_span(sandbox, uri, service="tagged-nginx") + assert { + key: span["meta"][key] + for key in ("env", "version", "team", "test") + } == { + "env": "injection-test", + "version": "test-version", + "team": "nginx", + "test": "injection" + } + + +@pytest.mark.stable_config +@stable_config_xfail +@pytest.mark.parametrize("path", STABLE_CONFIG_PATHS, ids=("local", "managed")) +def test_stable_config_defaults(sandbox, workload, stable_config, path): + content = stable_config( + path, { + "config_id": "nginx-stable-defaults", + "apm_configuration_default": { + "DD_SERVICE": "stable-nginx", + "DD_ENV": "stable-env", + "DD_VERSION": "stable-version", + "DD_TAGS": "team:stable,test:configuration" + } + }) + application = workload({"DD_SERVICE": None}) + application.assert_file(path, content) + uri = unique_uri() + application.request(uri) + application.stop() + span = nginx_span(sandbox, uri, service=None) + assert span["service"] == "stable-nginx", span + assert { + key: span["meta"][key] + for key in ("env", "version", "team", "test") + } == { + "env": "stable-env", + "version": "stable-version", + "team": "stable", + "test": "configuration" + } + + +@pytest.mark.stable_config +@stable_config_xfail +def test_stable_config_precedence(sandbox, workload, stable_config): + local = stable_config( + STABLE_CONFIG_PATHS[0], { + "config_id": "nginx-stable-local", + "apm_configuration_default": { + "DD_SERVICE": "local-service", + "DD_VERSION": "local-version" + } + }) + managed = stable_config( + STABLE_CONFIG_PATHS[1], { + "config_id": "nginx-stable-managed", + "apm_configuration_default": { + "DD_SERVICE": "managed-service" + } + }) + application = workload({ + "DD_SERVICE": "environment-service", + "DD_ENV": "environment" + }) + application.assert_file(STABLE_CONFIG_PATHS[0], local) + application.assert_file(STABLE_CONFIG_PATHS[1], managed) + uri = unique_uri() + application.request(uri) + application.stop() + span = nginx_span(sandbox, uri, service=None) + assert span["service"] == "managed-service", span + assert span["meta"]["env"] == "environment", span + assert span["meta"]["version"] == "local-version", span + + +@pytest.mark.stable_config +@stable_config_xfail +def test_stable_config_targeting_rule(sandbox, workload, stable_config): + path = STABLE_CONFIG_PATHS[1] + content = stable_config( + path, { + "config_id": + "nginx-stable-rule", + "apm_configuration_rules": [{ + "selectors": [{ + "origin": "environment_variables", + "key": "STABLE_CONFIG_SELECTOR", + "operator": "equals", + "matches": ["true"] + }], + "configuration": { + "DD_SERVICE": "targeted-service" + } + }] + }) + application = workload({ + "STABLE_CONFIG_SELECTOR": "true", + "DD_SERVICE": "environment-service" + }) + application.assert_file(path, content) + uri = unique_uri() + application.request(uri) + application.stop() + span = nginx_span(sandbox, uri, service=None) + assert span["service"] == "targeted-service", span + + +@pytest.mark.stable_config +@stable_config_xfail +@pytest.mark.parametrize("path", STABLE_CONFIG_PATHS, ids=("local", "managed")) +def test_stable_config_tracing_disabled(sandbox, workload, stable_config, + path): + content = stable_config( + path, { + "config_id": "nginx-stable-disabled", + "apm_configuration_default": { + "DD_TRACE_ENABLED": False + } + }) + application = workload() + application.assert_file(path, content) + uri = unique_uri() + application.request(uri) + application.assert_module() + application.stop() + sandbox.quiet(uri, "injection-nginx") + + +@pytest.mark.stable_config +def test_rum_disabled_by_environment(sandbox, workload, stable_config): + path = STABLE_CONFIG_PATHS[0] + content = stable_config( + path, { + "config_id": "nginx-rum-environment-opt-out", + "apm_configuration_default": { + "DD_RUM_ENABLED": True, + "DD_RUM_APPLICATION_ID": "stable-application", + "DD_RUM_CLIENT_TOKEN": "stable-token" + } + }) + application = workload({"DD_RUM_ENABLED": "false"}) + application.assert_file(path, content) + uri = f"{unique_uri()}.html" + body, content_type = application.request(uri, + content=RUM_HTML, + include_content_type=True) + application.assert_module() + application.stop() + nginx_span(sandbox, uri) + assert content_type.startswith("text/html") + assert body == RUM_HTML + assert "datadog-rum.js" not in body + + +@pytest.mark.stable_config +@rum_stable_config_xfail +@pytest.mark.parametrize("path", STABLE_CONFIG_PATHS, ids=("local", "managed")) +def test_rum_disabled_by_stable_config(sandbox, workload, stable_config, path): + content = stable_config( + path, { + "config_id": "nginx-rum-stable-opt-out", + "apm_configuration_default": { + "DD_RUM_ENABLED": False, + "DD_RUM_APPLICATION_ID": "stable-application", + "DD_RUM_CLIENT_TOKEN": "stable-token" + } + }) + application = workload() + application.assert_file(path, content) + uri = f"{unique_uri()}.html" + body, content_type = application.request(uri, + content=RUM_HTML, + include_content_type=True) + application.assert_module() + application.stop() + nginx_span(sandbox, uri) + assert content_type.startswith("text/html") + assert body == RUM_HTML + assert "datadog-rum.js" not in body + + +@pytest.mark.parametrize("mode", ["docker-debian"], scope="session") +def test_swarm_tracing_without_rum(sandbox, swarm_workload, mode): + application = swarm_workload({ + "DD_SERVICE": "injection-swarm-nginx", + "DD_RUM_ENABLED": "false", + "DD_RUM_APPLICATION_ID": "swarm-application", + "DD_RUM_CLIENT_TOKEN": "swarm-token", + }) + uri = f"{unique_uri()}.html" + body, content_type = application.request(uri, + content=RUM_HTML, + include_content_type=True) + application.assert_module() + application.stop() + span = nginx_span(sandbox, uri, service="injection-swarm-nginx") + assert content_type.startswith("text/html") + assert body == RUM_HTML + assert "datadog-rum.js" not in body + application.proof(uri, body, content_type, span) + sandbox.assert_injection_telemetry() + + +@pytest.mark.parametrize("routing", ["url", "host-port"]) +def test_agent_routing(sandbox, workload, routing): + environment = { + "DD_TRACE_AGENT_URL": "http://127.0.0.1:9126" + } if routing == "url" else { + "DD_TRACE_AGENT_URL": None, + "DD_AGENT_HOST": "127.0.0.1", + "DD_TRACE_AGENT_PORT": "9126" + } + application = workload(environment) + uri = unique_uri() + application.request(uri) + application.stop() + nginx_span(sandbox, uri, "b") + + +def test_agent_url_precedence(sandbox, workload): + application = workload({ + "DD_TRACE_AGENT_URL": "http://127.0.0.1:8126", + "DD_AGENT_HOST": "127.0.0.1", + "DD_TRACE_AGENT_PORT": "9126" + }) + uri = unique_uri() + application.request(uri) + application.stop() + nginx_span(sandbox, uri) + sandbox.quiet(uri, "injection-nginx", collector="b") + + +def test_agent_unix_socket(sandbox, workload): + environment = {"DD_TRACE_AGENT_URL": "unix:///var/run/datadog/apm.socket"} + application = workload(environment, environment) + uri = unique_uri(True) + application.request(uri) + application.stop() + linked_spans(sandbox, uri) + + +def test_w3c_propagation(sandbox, workload): + environment = { + "DD_TRACE_PROPAGATION_STYLE_EXTRACT": "tracecontext", + "DD_TRACE_PROPAGATION_STYLE_INJECT": "tracecontext" + } + application = workload(environment, environment) + uri = unique_uri(True) + incoming = "1234567890abcdef1234567890abcdef" + parent = "1234567890abcdef" + response = json.loads( + application.request(uri, + {"traceparent": f"00-{incoming}-{parent}-01"})) + application.stop() + nginx, _ = linked_spans(sandbox, uri, int(incoming, 16), int(parent, 16)) + assert response["headers"][ + "traceparent"] == f"00-{incoming}-{int(nginx['span_id']):016x}-01" + + +def test_sampling_rule(sandbox, workload): + uri = unique_uri(True) + rules = [{ + "sample_rate": 0, + "service": "injection-nginx", + "resource": f"GET {uri}" + }] + application = workload({ + "DD_TRACE_SAMPLING_RULES": json.dumps(rules), + "DD_TRACE_PROPAGATION_STYLE_INJECT": "datadog" + }) + response = json.loads(application.request(uri)) + assert response["headers"]["x-datadog-sampling-priority"] == "-1", response + + +def assert_disabled(sandbox, workload, disabled): + variable = "DD_TRACE_ENABLED" if disabled == "reporting" else "DD_INSTRUMENT_SERVICE_WITH_APM" + application = workload({variable: "false"}) + uri = unique_uri(True) + application.request(uri) + application.assert_module(loaded=disabled == "reporting") + control = unique_uri(True) + application.request(control, port=8081) + sandbox.wait_spans(control, "injection-backend") + application.stop() + sandbox.quiet(uri, "injection-nginx") + + +def test_reporting_disabled(sandbox, workload): + assert_disabled(sandbox, workload, "reporting") + + +def test_injection_opt_out(sandbox, workload): + assert_disabled(sandbox, workload, "injection") + + +def test_reload_and_restart(sandbox, workload): + application = workload({ + "DD_ENV": "restart-test", + "DD_VERSION": "restart-version" + }) + uris = [] + for action in (lambda: None, application.reload, lambda: + (application.stop(), application.start())): + action() + application.assert_module() + uri = unique_uri() + uris.append(uri) + application.request(uri) + application.stop() + for uri in uris: + span = nginx_span(sandbox, uri) + assert span["meta"]["env"] == "restart-test" + assert span["meta"]["version"] == "restart-version" + + +@pytest.mark.parametrize("failure", ["missing-package", "unsupported-version"]) +def test_unavailable_module(sandbox_factory, mode, failure): + sandbox = sandbox_factory(mode, f"{mode}-{failure}") + try: + sandbox.start() + sandbox.install(nginx=failure != "missing-package") + image = None + if failure == "unsupported-version": + image = UNSUPPORTED_IMAGE + ("-alpine" + if mode == "docker-alpine" else "") + sandbox.images.pull(image) + if mode == "host": + sandbox.load(image) + sandbox.inner("create", "--runtime=runc", "--name", + "old-nginx", image) + sandbox.inner("cp", "old-nginx:/usr/sbin/nginx", + "/tmp/unsupported-nginx") + dependencies = sandbox.inner("run", "--rm", "--runtime=runc", + image, "ldd", "/usr/sbin/nginx") + for library in re.findall(r"=> (/\S+)", dependencies): + if sandbox.host("test", "-e", library, + check=False).returncode: + sandbox.inner("cp", "-L", f"old-nginx:{library}", + library) + sandbox.host("cp", "/tmp/unsupported-nginx", "/usr/sbin/nginx") + application = Workload(sandbox, failure, image=image).start() + uri = unique_uri(True) + application.request(uri) + application.assert_module(loaded=False) + sandbox.wait_spans(uri, "injection-backend") + application.finish() + sandbox.quiet(uri, "injection-nginx") + diagnostics = poll( + lambda: [ + event for event in sandbox.agent("/test/apmtelemetry") + if event.get("request_type") == "injection-metadata" and event. + get("application", {}).get("language_name") == "nginx" + ], "Nginx skipped-injection diagnostic") + version = "1.20.2" if failure == "unsupported-version" else NGINX_VERSION + for event in diagnostics: + assert event["application"]["language_version"] == version, event + assert event["payload"]["result"] == "abort", event + assert event["payload"]["result_reason"] == \ + "the tracing library is not installed for the language", event + finally: + sandbox.close() diff --git a/test/injection/unit/test_helpers.py b/test/injection/unit/test_helpers.py new file mode 100644 index 00000000..7f5ae877 --- /dev/null +++ b/test/injection/unit/test_helpers.py @@ -0,0 +1,131 @@ +import hashlib +import io +import json +import subprocess +import sys +import tarfile + +import pytest +import zstandard + +from test.injection.harness import flatten, matching, trace_id, NGINX_VERSION, ROOT +from test.injection.package import module_checksum + + +def test_unittest_discovery_without_pytest(): + result = subprocess.run([ + sys.executable, "-S", "-c", + "import unittest; loader = unittest.TestLoader(); " + "suite = loader.discover('test/injection', top_level_dir='test'); " + "assert not loader.errors, loader.errors; " + "assert suite.countTestCases() == 0" + ], + cwd=ROOT, + text=True, + capture_output=True) + assert result.returncode == 0, result.stdout + result.stderr + + +def test_match_separate_trace_chunks(): + nginx = { + "span_id": 2, + "service": "nginx", + "meta": { + "http.url": "http://nginx/proxy/unique" + } + } + backend = { + "span_id": 3, + "parent_id": 2, + "service": "backend", + "meta": { + "http.url": "http://backend/proxy/unique" + } + } + readiness = { + "span_id": 4, + "service": "nginx", + "meta": { + "http.url": "http://nginx/ready" + } + } + spans = flatten([[nginx], [], [[backend]], [readiness]]) + assert matching(spans, "/proxy/unique", "nginx") == [nginx] + assert matching(spans, "/proxy/unique", "backend") == [backend] + assert matching(spans, "/missing") == [] + + +def test_preserve_duplicate_spans(): + span = {"span_id": 1, "meta": {"http.url": "/static/unique"}} + assert len(matching(flatten([[span], [span]]), "/static/unique")) == 2 + + +@pytest.mark.parametrize("value", + [None, { + "error": "collector failed" + }, "invalid"]) +def test_reject_invalid_collector_response(value): + with pytest.raises(AssertionError): + flatten(value) + + +def test_reconstruct_w3c_id(): + span = { + "trace_id": 0x0123456789ABCDEF, + "meta": { + "_dd.p.tid": "fedcba9876543210" + } + } + assert trace_id(span) == 0xFEDCBA98765432100123456789ABCDEF + assert trace_id({"trace_id": 123}) == 123 + + +def make_package(path, corrupt=False, compression="gzip"): + blobs = {} + + def add(data): + digest = hashlib.sha256(data).hexdigest() + blobs[f"blobs/sha256/{digest}"] = data + return {"digest": f"sha256:{digest}"} + + layer = io.BytesIO() + with tarfile.open(fileobj=layer, mode="w:gz") as archive: + entry = tarfile.TarInfo( + f"nginx/{NGINX_VERSION}/ngx_http_datadog_module.so") + entry.size = len(b"module") + archive.addfile(entry, io.BytesIO(b"module")) + layer_bytes = layer.getvalue() + if compression == "zstd": + import gzip + layer_bytes = zstandard.ZstdCompressor().compress( + gzip.decompress(layer_bytes)) + descriptor = add(layer_bytes) + descriptor[ + "mediaType"] = "application/vnd.datadog.package.layer.v1.tar+" + compression + manifest = add(json.dumps({"layers": [descriptor]}).encode()) + manifest["platform"] = {"os": "linux", "architecture": "arm64"} + blobs["index.json"] = json.dumps({"manifests": [manifest]}).encode() + if corrupt: + blobs[next(iter(blobs))] = b"changed" + with tarfile.open(path, "w") as archive: + for name, data in blobs.items(): + entry = tarfile.TarInfo(name) + entry.size = len(data) + archive.addfile(entry, io.BytesIO(data)) + + +@pytest.mark.parametrize("compression", ["gzip", "zstd"]) +def test_package_module_checksum(tmp_path, compression): + package = tmp_path / "package.tar" + make_package(package, compression=compression) + assert module_checksum(package, + "arm64") == hashlib.sha256(b"module").hexdigest() + with pytest.raises(AssertionError, match="Linux amd64"): + module_checksum(package, "amd64") + + +def test_reject_corrupt_oci_blob(tmp_path): + package = tmp_path / "package.tar" + make_package(package, corrupt=True) + with pytest.raises(AssertionError): + module_checksum(package, "arm64") diff --git a/test/injection/workloads/Dockerfile b/test/injection/workloads/Dockerfile new file mode 100644 index 00000000..5f7ade1d --- /dev/null +++ b/test/injection/workloads/Dockerfile @@ -0,0 +1,13 @@ +ARG NGINX_VERSION=1.31.5 +FROM docker:27.3.1-dind AS docker +FROM nginx:${NGINX_VERSION} +COPY --from=docker /usr/local/bin/ /usr/local/bin/ +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates curl iptables procps python3 python3-venv \ + && rm -rf /var/lib/apt/lists/* \ + && python3 -m venv /opt/flask \ + && /opt/flask/bin/pip install --no-cache-dir Flask==3.1.2 gunicorn==23.0.0 +COPY app.py nginx.conf processes.py /workload/ +RUN mkdir -p /srv/site/static /var/run/datadog /var/run/datadog-b /evidence +ENTRYPOINT [] +CMD ["sleep", "infinity"] diff --git a/test/injection/workloads/app.py b/test/injection/workloads/app.py new file mode 100644 index 00000000..188e62a4 --- /dev/null +++ b/test/injection/workloads/app.py @@ -0,0 +1,17 @@ +from flask import Flask, jsonify, request + +app = Flask(__name__) + + +@app.get("/ready") +def ready(): + return "ready" + + +@app.get("/proxy/") +def echo(route): + return jsonify(path=request.path, + headers={ + key.lower(): value + for key, value in request.headers.items() + }) diff --git a/test/injection/workloads/nginx.conf b/test/injection/workloads/nginx.conf new file mode 100644 index 00000000..b88755fe --- /dev/null +++ b/test/injection/workloads/nginx.conf @@ -0,0 +1,16 @@ +worker_processes 1; +pid /tmp/injection-nginx.pid; +error_log /dev/stderr info; + +events { worker_connections 128; } + +http { + include /etc/nginx/mime.types; + access_log /dev/stdout; + server { + listen 8080; + location = /ready { return 204; } + location /static/ { root /srv/site; } + location /proxy/ { proxy_pass http://127.0.0.1:8081; } + } +} diff --git a/test/injection/workloads/processes.py b/test/injection/workloads/processes.py new file mode 100644 index 00000000..61afaf63 --- /dev/null +++ b/test/injection/workloads/processes.py @@ -0,0 +1,26 @@ +"""Read process evidence without starting another Nginx process.""" + +import json +from pathlib import Path + +processes = [] +for process in Path("/proc").glob("[0-9]*"): + try: + command = (process / "cmdline").read_bytes().replace(b"\0", + b" ").decode() + if (process / "comm").read_text().strip() != "nginx": + continue + maps = (process / "maps").read_text() + paths = sorted({ + line.split()[-1] + for line in maps.splitlines() + if "ngx_http_datadog_module.so" in line + }) + processes.append( + dict(pid=int(process.name), + command=command, + modules=paths, + maps=maps)) + except (FileNotFoundError, ProcessLookupError): + continue +print(json.dumps(processes)) diff --git a/test/pyproject.toml b/test/pyproject.toml index 1e445043..6053f938 100644 --- a/test/pyproject.toml +++ b/test/pyproject.toml @@ -1,8 +1,18 @@ [project] name = "nginx-datadog-test" version = "1.0.0" +requires-python = ">=3.11" dependencies = [ "PyYAML==6.0.2", "werkzeug==3.1.8", "yapf==0.43.0", ] + +[dependency-groups] +injection = ["pytest==8.4.2", "zstandard==0.25.0"] + +[tool.pytest.ini_options] +xfail_strict = true +markers = [ + "stable_config: Datadog stable configuration coverage", +] diff --git a/test/uv.lock b/test/uv.lock new file mode 100644 index 00000000..b913b8c2 --- /dev/null +++ b/test/uv.lock @@ -0,0 +1,309 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "markupsafe" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" }, + { url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" }, + { url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" }, + { url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" }, + { url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" }, + { url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" }, + { url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" }, + { url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" }, + { url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" }, + { url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" }, + { url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" }, + { url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" }, + { url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" }, + { url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" }, + { url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" }, + { url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" }, + { url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" }, + { url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" }, + { url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" }, + { url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" }, + { url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" }, + { url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" }, + { url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" }, + { url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" }, + { url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" }, + { url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" }, + { url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" }, + { url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" }, + { url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" }, + { url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" }, + { url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" }, + { url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" }, + { url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" }, + { url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" }, + { url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" }, + { url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" }, + { url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" }, + { url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" }, + { url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" }, + { url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" }, + { url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" }, + { url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" }, + { url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" }, + { url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" }, + { url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" }, + { url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" }, + { url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" }, + { url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" }, + { url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" }, + { url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" }, + { url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" }, + { url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" }, + { url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" }, + { url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, +] + +[[package]] +name = "nginx-datadog-test" +version = "1.0.0" +source = { virtual = "." } +dependencies = [ + { name = "pyyaml" }, + { name = "werkzeug" }, + { name = "yapf" }, +] + +[package.dev-dependencies] +injection = [ + { name = "pytest" }, + { name = "zstandard" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyyaml", specifier = "==6.0.2" }, + { name = "werkzeug", specifier = "==3.1.8" }, + { name = "yapf", specifier = "==0.43.0" }, +] + +[package.metadata.requires-dev] +injection = [ + { name = "pytest", specifier = "==8.4.2" }, + { name = "zstandard", specifier = "==0.25.0" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/18/f3bb8ef0d3b930692343da8aa4d3cbcd6749477c053959395ac81965a6e9/platformdirs-4.11.8.tar.gz", hash = "sha256:f23abafea7dd4276d1f29104b83598d7dcc567cafd07c9c951e66665645437fc", size = 37182, upload-time = "2026-09-08T22:20:42.866Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/e1/5b7b8bbb55084d1425bcb9bc823ff519e1b2be05f6ebb0089e2eacc38413/platformdirs-4.11.8-py3-none-any.whl", hash = "sha256:52f2f181bbfde907966932cc8312d967d02976422d66d537ea16092b8e291081", size = 24027, upload-time = "2026-09-08T22:20:41.537Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.21.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/aa/7af4e81f7acba21a4c6be026da38fd2b872ca46226673c89a758ebdc4fd2/PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774", size = 184612, upload-time = "2024-08-06T20:32:03.408Z" }, + { url = "https://files.pythonhosted.org/packages/8b/62/b9faa998fd185f65c1371643678e4d58254add437edb764a08c5a98fb986/PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee", size = 172040, upload-time = "2024-08-06T20:32:04.926Z" }, + { url = "https://files.pythonhosted.org/packages/ad/0c/c804f5f922a9a6563bab712d8dcc70251e8af811fce4524d57c2c0fd49a4/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c", size = 736829, upload-time = "2024-08-06T20:32:06.459Z" }, + { url = "https://files.pythonhosted.org/packages/51/16/6af8d6a6b210c8e54f1406a6b9481febf9c64a3109c541567e35a49aa2e7/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317", size = 764167, upload-time = "2024-08-06T20:32:08.338Z" }, + { url = "https://files.pythonhosted.org/packages/75/e4/2c27590dfc9992f73aabbeb9241ae20220bd9452df27483b6e56d3975cc5/PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85", size = 762952, upload-time = "2024-08-06T20:32:14.124Z" }, + { url = "https://files.pythonhosted.org/packages/9b/97/ecc1abf4a823f5ac61941a9c00fe501b02ac3ab0e373c3857f7d4b83e2b6/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4", size = 735301, upload-time = "2024-08-06T20:32:16.17Z" }, + { url = "https://files.pythonhosted.org/packages/45/73/0f49dacd6e82c9430e46f4a027baa4ca205e8b0a9dce1397f44edc23559d/PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e", size = 756638, upload-time = "2024-08-06T20:32:18.555Z" }, + { url = "https://files.pythonhosted.org/packages/22/5f/956f0f9fc65223a58fbc14459bf34b4cc48dec52e00535c79b8db361aabd/PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5", size = 143850, upload-time = "2024-08-06T20:32:19.889Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/8da0bbe2ab9dcdd11f4f4557ccaf95c10b9811b13ecced089d43ce59c3c8/PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44", size = 161980, upload-time = "2024-08-06T20:32:21.273Z" }, + { url = "https://files.pythonhosted.org/packages/86/0c/c581167fc46d6d6d7ddcfb8c843a4de25bdd27e4466938109ca68492292c/PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab", size = 183873, upload-time = "2024-08-06T20:32:25.131Z" }, + { url = "https://files.pythonhosted.org/packages/a8/0c/38374f5bb272c051e2a69281d71cba6fdb983413e6758b84482905e29a5d/PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725", size = 173302, upload-time = "2024-08-06T20:32:26.511Z" }, + { url = "https://files.pythonhosted.org/packages/c3/93/9916574aa8c00aa06bbac729972eb1071d002b8e158bd0e83a3b9a20a1f7/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5", size = 739154, upload-time = "2024-08-06T20:32:28.363Z" }, + { url = "https://files.pythonhosted.org/packages/95/0f/b8938f1cbd09739c6da569d172531567dbcc9789e0029aa070856f123984/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425", size = 766223, upload-time = "2024-08-06T20:32:30.058Z" }, + { url = "https://files.pythonhosted.org/packages/b9/2b/614b4752f2e127db5cc206abc23a8c19678e92b23c3db30fc86ab731d3bd/PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476", size = 767542, upload-time = "2024-08-06T20:32:31.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/00/dd137d5bcc7efea1836d6264f049359861cf548469d18da90cd8216cf05f/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48", size = 731164, upload-time = "2024-08-06T20:32:37.083Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/4f998c900485e5c0ef43838363ba4a9723ac0ad73a9dc42068b12aaba4e4/PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b", size = 756611, upload-time = "2024-08-06T20:32:38.898Z" }, + { url = "https://files.pythonhosted.org/packages/df/d1/f5a275fdb252768b7a11ec63585bc38d0e87c9e05668a139fea92b80634c/PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4", size = 140591, upload-time = "2024-08-06T20:32:40.241Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e8/4f648c598b17c3d06e8753d7d13d57542b30d56e6c2dedf9c331ae56312e/PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8", size = 156338, upload-time = "2024-08-06T20:32:41.93Z" }, + { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" }, + { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" }, + { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" }, + { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" }, + { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" }, + { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" }, + { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" }, + { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" }, + { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" }, +] + +[[package]] +name = "werkzeug" +version = "3.1.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/dd/b2/381be8cfdee792dd117872481b6e378f85c957dd7c5bca38897b08f765fd/werkzeug-3.1.8.tar.gz", hash = "sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44", size = 875852, upload-time = "2026-04-02T18:49:14.268Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/8c/2e650f2afeb7ee576912636c23ddb621c91ac6a98e66dc8d29c3c69446e1/werkzeug-3.1.8-py3-none-any.whl", hash = "sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50", size = 226459, upload-time = "2026-04-02T18:49:12.72Z" }, +] + +[[package]] +name = "yapf" +version = "0.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/97/b6f296d1e9cc1ec25c7604178b48532fa5901f721bcf1b8d8148b13e5588/yapf-0.43.0.tar.gz", hash = "sha256:00d3aa24bfedff9420b2e0d5d9f5ab6d9d4268e72afbf59bb3fa542781d5218e", size = 254907, upload-time = "2024-11-14T00:11:41.584Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/37/81/6acd6601f61e31cfb8729d3da6d5df966f80f374b78eff83760714487338/yapf-0.43.0-py3-none-any.whl", hash = "sha256:224faffbc39c428cb095818cf6ef5511fdab6f7430a10783fdfb292ccf2852ca", size = 256158, upload-time = "2024-11-14T00:11:39.37Z" }, +] + +[[package]] +name = "zstandard" +version = "0.25.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/fd/aa/3e0508d5a5dd96529cdc5a97011299056e14c6505b678fd58938792794b1/zstandard-0.25.0.tar.gz", hash = "sha256:7713e1179d162cf5c7906da876ec2ccb9c3a9dcbdffef0cc7f70c3667a205f0b", size = 711513, upload-time = "2025-09-14T22:15:54.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/83/c3ca27c363d104980f1c9cee1101cc8ba724ac8c28a033ede6aab89585b1/zstandard-0.25.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:933b65d7680ea337180733cf9e87293cc5500cc0eb3fc8769f4d3c88d724ec5c", size = 795254, upload-time = "2025-09-14T22:16:26.137Z" }, + { url = "https://files.pythonhosted.org/packages/ac/4d/e66465c5411a7cf4866aeadc7d108081d8ceba9bc7abe6b14aa21c671ec3/zstandard-0.25.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a3f79487c687b1fc69f19e487cd949bf3aae653d181dfb5fde3bf6d18894706f", size = 640559, upload-time = "2025-09-14T22:16:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/12/56/354fe655905f290d3b147b33fe946b0f27e791e4b50a5f004c802cb3eb7b/zstandard-0.25.0-cp311-cp311-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:0bbc9a0c65ce0eea3c34a691e3c4b6889f5f3909ba4822ab385fab9057099431", size = 5348020, upload-time = "2025-09-14T22:16:29.523Z" }, + { url = "https://files.pythonhosted.org/packages/3b/13/2b7ed68bd85e69a2069bcc72141d378f22cae5a0f3b353a2c8f50ef30c1b/zstandard-0.25.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:01582723b3ccd6939ab7b3a78622c573799d5d8737b534b86d0e06ac18dbde4a", size = 5058126, upload-time = "2025-09-14T22:16:31.811Z" }, + { url = "https://files.pythonhosted.org/packages/c9/dd/fdaf0674f4b10d92cb120ccff58bbb6626bf8368f00ebfd2a41ba4a0dc99/zstandard-0.25.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5f1ad7bf88535edcf30038f6919abe087f606f62c00a87d7e33e7fc57cb69fcc", size = 5405390, upload-time = "2025-09-14T22:16:33.486Z" }, + { url = "https://files.pythonhosted.org/packages/0f/67/354d1555575bc2490435f90d67ca4dd65238ff2f119f30f72d5cde09c2ad/zstandard-0.25.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:06acb75eebeedb77b69048031282737717a63e71e4ae3f77cc0c3b9508320df6", size = 5452914, upload-time = "2025-09-14T22:16:35.277Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/e9cfd801a3f9190bf3e759c422bbfd2247db9d7f3d54a56ecde70137791a/zstandard-0.25.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9300d02ea7c6506f00e627e287e0492a5eb0371ec1670ae852fefffa6164b072", size = 5559635, upload-time = "2025-09-14T22:16:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/21/88/5ba550f797ca953a52d708c8e4f380959e7e3280af029e38fbf47b55916e/zstandard-0.25.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:bfd06b1c5584b657a2892a6014c2f4c20e0db0208c159148fa78c65f7e0b0277", size = 5048277, upload-time = "2025-09-14T22:16:38.807Z" }, + { url = "https://files.pythonhosted.org/packages/46/c0/ca3e533b4fa03112facbe7fbe7779cb1ebec215688e5df576fe5429172e0/zstandard-0.25.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f373da2c1757bb7f1acaf09369cdc1d51d84131e50d5fa9863982fd626466313", size = 5574377, upload-time = "2025-09-14T22:16:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/12/9b/3fb626390113f272abd0799fd677ea33d5fc3ec185e62e6be534493c4b60/zstandard-0.25.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:6c0e5a65158a7946e7a7affa6418878ef97ab66636f13353b8502d7ea03c8097", size = 4961493, upload-time = "2025-09-14T22:16:43.3Z" }, + { url = "https://files.pythonhosted.org/packages/cb/d3/23094a6b6a4b1343b27ae68249daa17ae0651fcfec9ed4de09d14b940285/zstandard-0.25.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:c8e167d5adf59476fa3e37bee730890e389410c354771a62e3c076c86f9f7778", size = 5269018, upload-time = "2025-09-14T22:16:45.292Z" }, + { url = "https://files.pythonhosted.org/packages/8c/a7/bb5a0c1c0f3f4b5e9d5b55198e39de91e04ba7c205cc46fcb0f95f0383c1/zstandard-0.25.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:98750a309eb2f020da61e727de7d7ba3c57c97cf6213f6f6277bb7fb42a8e065", size = 5443672, upload-time = "2025-09-14T22:16:47.076Z" }, + { url = "https://files.pythonhosted.org/packages/27/22/503347aa08d073993f25109c36c8d9f029c7d5949198050962cb568dfa5e/zstandard-0.25.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:22a086cff1b6ceca18a8dd6096ec631e430e93a8e70a9ca5efa7561a00f826fa", size = 5822753, upload-time = "2025-09-14T22:16:49.316Z" }, + { url = "https://files.pythonhosted.org/packages/e2/be/94267dc6ee64f0f8ba2b2ae7c7a2df934a816baaa7291db9e1aa77394c3c/zstandard-0.25.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:72d35d7aa0bba323965da807a462b0966c91608ef3a48ba761678cb20ce5d8b7", size = 5366047, upload-time = "2025-09-14T22:16:51.328Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a3/732893eab0a3a7aecff8b99052fecf9f605cf0fb5fb6d0290e36beee47a4/zstandard-0.25.0-cp311-cp311-win32.whl", hash = "sha256:f5aeea11ded7320a84dcdd62a3d95b5186834224a9e55b92ccae35d21a8b63d4", size = 436484, upload-time = "2025-09-14T22:16:55.005Z" }, + { url = "https://files.pythonhosted.org/packages/43/a3/c6155f5c1cce691cb80dfd38627046e50af3ee9ddc5d0b45b9b063bfb8c9/zstandard-0.25.0-cp311-cp311-win_amd64.whl", hash = "sha256:daab68faadb847063d0c56f361a289c4f268706b598afbf9ad113cbe5c38b6b2", size = 506183, upload-time = "2025-09-14T22:16:52.753Z" }, + { url = "https://files.pythonhosted.org/packages/8c/3e/8945ab86a0820cc0e0cdbf38086a92868a9172020fdab8a03ac19662b0e5/zstandard-0.25.0-cp311-cp311-win_arm64.whl", hash = "sha256:22a06c5df3751bb7dc67406f5374734ccee8ed37fc5981bf1ad7041831fa1137", size = 462533, upload-time = "2025-09-14T22:16:53.878Z" }, + { url = "https://files.pythonhosted.org/packages/82/fc/f26eb6ef91ae723a03e16eddb198abcfce2bc5a42e224d44cc8b6765e57e/zstandard-0.25.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7b3c3a3ab9daa3eed242d6ecceead93aebbb8f5f84318d82cee643e019c4b73b", size = 795738, upload-time = "2025-09-14T22:16:56.237Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1c/d920d64b22f8dd028a8b90e2d756e431a5d86194caa78e3819c7bf53b4b3/zstandard-0.25.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:913cbd31a400febff93b564a23e17c3ed2d56c064006f54efec210d586171c00", size = 640436, upload-time = "2025-09-14T22:16:57.774Z" }, + { url = "https://files.pythonhosted.org/packages/53/6c/288c3f0bd9fcfe9ca41e2c2fbfd17b2097f6af57b62a81161941f09afa76/zstandard-0.25.0-cp312-cp312-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:011d388c76b11a0c165374ce660ce2c8efa8e5d87f34996aa80f9c0816698b64", size = 5343019, upload-time = "2025-09-14T22:16:59.302Z" }, + { url = "https://files.pythonhosted.org/packages/1e/15/efef5a2f204a64bdb5571e6161d49f7ef0fffdbca953a615efbec045f60f/zstandard-0.25.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6dffecc361d079bb48d7caef5d673c88c8988d3d33fb74ab95b7ee6da42652ea", size = 5063012, upload-time = "2025-09-14T22:17:01.156Z" }, + { url = "https://files.pythonhosted.org/packages/b7/37/a6ce629ffdb43959e92e87ebdaeebb5ac81c944b6a75c9c47e300f85abdf/zstandard-0.25.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7149623bba7fdf7e7f24312953bcf73cae103db8cae49f8154dd1eadc8a29ecb", size = 5394148, upload-time = "2025-09-14T22:17:03.091Z" }, + { url = "https://files.pythonhosted.org/packages/e3/79/2bf870b3abeb5c070fe2d670a5a8d1057a8270f125ef7676d29ea900f496/zstandard-0.25.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6a573a35693e03cf1d67799fd01b50ff578515a8aeadd4595d2a7fa9f3ec002a", size = 5451652, upload-time = "2025-09-14T22:17:04.979Z" }, + { url = "https://files.pythonhosted.org/packages/53/60/7be26e610767316c028a2cbedb9a3beabdbe33e2182c373f71a1c0b88f36/zstandard-0.25.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5a56ba0db2d244117ed744dfa8f6f5b366e14148e00de44723413b2f3938a902", size = 5546993, upload-time = "2025-09-14T22:17:06.781Z" }, + { url = "https://files.pythonhosted.org/packages/85/c7/3483ad9ff0662623f3648479b0380d2de5510abf00990468c286c6b04017/zstandard-0.25.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:10ef2a79ab8e2974e2075fb984e5b9806c64134810fac21576f0668e7ea19f8f", size = 5046806, upload-time = "2025-09-14T22:17:08.415Z" }, + { url = "https://files.pythonhosted.org/packages/08/b3/206883dd25b8d1591a1caa44b54c2aad84badccf2f1de9e2d60a446f9a25/zstandard-0.25.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:aaf21ba8fb76d102b696781bddaa0954b782536446083ae3fdaa6f16b25a1c4b", size = 5576659, upload-time = "2025-09-14T22:17:10.164Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/76c0779101453e6c117b0ff22565865c54f48f8bd807df2b00c2c404b8e0/zstandard-0.25.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1869da9571d5e94a85a5e8d57e4e8807b175c9e4a6294e3b66fa4efb074d90f6", size = 4953933, upload-time = "2025-09-14T22:17:11.857Z" }, + { url = "https://files.pythonhosted.org/packages/18/e1/97680c664a1bf9a247a280a053d98e251424af51f1b196c6d52f117c9720/zstandard-0.25.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:809c5bcb2c67cd0ed81e9229d227d4ca28f82d0f778fc5fea624a9def3963f91", size = 5268008, upload-time = "2025-09-14T22:17:13.627Z" }, + { url = "https://files.pythonhosted.org/packages/1e/73/316e4010de585ac798e154e88fd81bb16afc5c5cb1a72eeb16dd37e8024a/zstandard-0.25.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:f27662e4f7dbf9f9c12391cb37b4c4c3cb90ffbd3b1fb9284dadbbb8935fa708", size = 5433517, upload-time = "2025-09-14T22:17:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/5b/60/dd0f8cfa8129c5a0ce3ea6b7f70be5b33d2618013a161e1ff26c2b39787c/zstandard-0.25.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:99c0c846e6e61718715a3c9437ccc625de26593fea60189567f0118dc9db7512", size = 5814292, upload-time = "2025-09-14T22:17:17.827Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5f/75aafd4b9d11b5407b641b8e41a57864097663699f23e9ad4dbb91dc6bfe/zstandard-0.25.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:474d2596a2dbc241a556e965fb76002c1ce655445e4e3bf38e5477d413165ffa", size = 5360237, upload-time = "2025-09-14T22:17:19.954Z" }, + { url = "https://files.pythonhosted.org/packages/ff/8d/0309daffea4fcac7981021dbf21cdb2e3427a9e76bafbcdbdf5392ff99a4/zstandard-0.25.0-cp312-cp312-win32.whl", hash = "sha256:23ebc8f17a03133b4426bcc04aabd68f8236eb78c3760f12783385171b0fd8bd", size = 436922, upload-time = "2025-09-14T22:17:24.398Z" }, + { url = "https://files.pythonhosted.org/packages/79/3b/fa54d9015f945330510cb5d0b0501e8253c127cca7ebe8ba46a965df18c5/zstandard-0.25.0-cp312-cp312-win_amd64.whl", hash = "sha256:ffef5a74088f1e09947aecf91011136665152e0b4b359c42be3373897fb39b01", size = 506276, upload-time = "2025-09-14T22:17:21.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/6b/8b51697e5319b1f9ac71087b0af9a40d8a6288ff8025c36486e0c12abcc4/zstandard-0.25.0-cp312-cp312-win_arm64.whl", hash = "sha256:181eb40e0b6a29b3cd2849f825e0fa34397f649170673d385f3598ae17cca2e9", size = 462679, upload-time = "2025-09-14T22:17:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/35/0b/8df9c4ad06af91d39e94fa96cc010a24ac4ef1378d3efab9223cc8593d40/zstandard-0.25.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec996f12524f88e151c339688c3897194821d7f03081ab35d31d1e12ec975e94", size = 795735, upload-time = "2025-09-14T22:17:26.042Z" }, + { url = "https://files.pythonhosted.org/packages/3f/06/9ae96a3e5dcfd119377ba33d4c42a7d89da1efabd5cb3e366b156c45ff4d/zstandard-0.25.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a1a4ae2dec3993a32247995bdfe367fc3266da832d82f8438c8570f989753de1", size = 640440, upload-time = "2025-09-14T22:17:27.366Z" }, + { url = "https://files.pythonhosted.org/packages/d9/14/933d27204c2bd404229c69f445862454dcc101cd69ef8c6068f15aaec12c/zstandard-0.25.0-cp313-cp313-manylinux2010_i686.manylinux2014_i686.manylinux_2_12_i686.manylinux_2_17_i686.whl", hash = "sha256:e96594a5537722fdfb79951672a2a63aec5ebfb823e7560586f7484819f2a08f", size = 5343070, upload-time = "2025-09-14T22:17:28.896Z" }, + { url = "https://files.pythonhosted.org/packages/6d/db/ddb11011826ed7db9d0e485d13df79b58586bfdec56e5c84a928a9a78c1c/zstandard-0.25.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:bfc4e20784722098822e3eee42b8e576b379ed72cca4a7cb856ae733e62192ea", size = 5063001, upload-time = "2025-09-14T22:17:31.044Z" }, + { url = "https://files.pythonhosted.org/packages/db/00/87466ea3f99599d02a5238498b87bf84a6348290c19571051839ca943777/zstandard-0.25.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:457ed498fc58cdc12fc48f7950e02740d4f7ae9493dd4ab2168a47c93c31298e", size = 5394120, upload-time = "2025-09-14T22:17:32.711Z" }, + { url = "https://files.pythonhosted.org/packages/2b/95/fc5531d9c618a679a20ff6c29e2b3ef1d1f4ad66c5e161ae6ff847d102a9/zstandard-0.25.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:fd7a5004eb1980d3cefe26b2685bcb0b17989901a70a1040d1ac86f1d898c551", size = 5451230, upload-time = "2025-09-14T22:17:34.41Z" }, + { url = "https://files.pythonhosted.org/packages/63/4b/e3678b4e776db00f9f7b2fe58e547e8928ef32727d7a1ff01dea010f3f13/zstandard-0.25.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e735494da3db08694d26480f1493ad2cf86e99bdd53e8e9771b2752a5c0246a", size = 5547173, upload-time = "2025-09-14T22:17:36.084Z" }, + { url = "https://files.pythonhosted.org/packages/4e/d5/ba05ed95c6b8ec30bd468dfeab20589f2cf709b5c940483e31d991f2ca58/zstandard-0.25.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3a39c94ad7866160a4a46d772e43311a743c316942037671beb264e395bdd611", size = 5046736, upload-time = "2025-09-14T22:17:37.891Z" }, + { url = "https://files.pythonhosted.org/packages/50/d5/870aa06b3a76c73eced65c044b92286a3c4e00554005ff51962deef28e28/zstandard-0.25.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:172de1f06947577d3a3005416977cce6168f2261284c02080e7ad0185faeced3", size = 5576368, upload-time = "2025-09-14T22:17:40.206Z" }, + { url = "https://files.pythonhosted.org/packages/5d/35/398dc2ffc89d304d59bc12f0fdd931b4ce455bddf7038a0a67733a25f550/zstandard-0.25.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3c83b0188c852a47cd13ef3bf9209fb0a77fa5374958b8c53aaa699398c6bd7b", size = 4954022, upload-time = "2025-09-14T22:17:41.879Z" }, + { url = "https://files.pythonhosted.org/packages/9a/5c/36ba1e5507d56d2213202ec2b05e8541734af5f2ce378c5d1ceaf4d88dc4/zstandard-0.25.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:1673b7199bbe763365b81a4f3252b8e80f44c9e323fc42940dc8843bfeaf9851", size = 5267889, upload-time = "2025-09-14T22:17:43.577Z" }, + { url = "https://files.pythonhosted.org/packages/70/e8/2ec6b6fb7358b2ec0113ae202647ca7c0e9d15b61c005ae5225ad0995df5/zstandard-0.25.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:0be7622c37c183406f3dbf0cba104118eb16a4ea7359eeb5752f0794882fc250", size = 5433952, upload-time = "2025-09-14T22:17:45.271Z" }, + { url = "https://files.pythonhosted.org/packages/7b/01/b5f4d4dbc59ef193e870495c6f1275f5b2928e01ff5a81fecb22a06e22fb/zstandard-0.25.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:5f5e4c2a23ca271c218ac025bd7d635597048b366d6f31f420aaeb715239fc98", size = 5814054, upload-time = "2025-09-14T22:17:47.08Z" }, + { url = "https://files.pythonhosted.org/packages/b2/e5/fbd822d5c6f427cf158316d012c5a12f233473c2f9c5fe5ab1ae5d21f3d8/zstandard-0.25.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f187a0bb61b35119d1926aee039524d1f93aaf38a9916b8c4b78ac8514a0aaf", size = 5360113, upload-time = "2025-09-14T22:17:48.893Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e0/69a553d2047f9a2c7347caa225bb3a63b6d7704ad74610cb7823baa08ed7/zstandard-0.25.0-cp313-cp313-win32.whl", hash = "sha256:7030defa83eef3e51ff26f0b7bfb229f0204b66fe18e04359ce3474ac33cbc09", size = 436936, upload-time = "2025-09-14T22:17:52.658Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/b9c06c870f3bd8767c201f1edbdf9e8dc34be5b0fbc5682c4f80fe948475/zstandard-0.25.0-cp313-cp313-win_amd64.whl", hash = "sha256:1f830a0dac88719af0ae43b8b2d6aef487d437036468ef3c2ea59c51f9d55fd5", size = 506232, upload-time = "2025-09-14T22:17:50.402Z" }, + { url = "https://files.pythonhosted.org/packages/d4/57/60c3c01243bb81d381c9916e2a6d9e149ab8627c0c7d7abb2d73384b3c0c/zstandard-0.25.0-cp313-cp313-win_arm64.whl", hash = "sha256:85304a43f4d513f5464ceb938aa02c1e78c2943b29f44a750b48b25ac999a049", size = 462671, upload-time = "2025-09-14T22:17:51.533Z" }, + { url = "https://files.pythonhosted.org/packages/3d/5c/f8923b595b55fe49e30612987ad8bf053aef555c14f05bb659dd5dbe3e8a/zstandard-0.25.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e29f0cf06974c899b2c188ef7f783607dbef36da4c242eb6c82dcd8b512855e3", size = 795887, upload-time = "2025-09-14T22:17:54.198Z" }, + { url = "https://files.pythonhosted.org/packages/8d/09/d0a2a14fc3439c5f874042dca72a79c70a532090b7ba0003be73fee37ae2/zstandard-0.25.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:05df5136bc5a011f33cd25bc9f506e7426c0c9b3f9954f056831ce68f3b6689f", size = 640658, upload-time = "2025-09-14T22:17:55.423Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/8b6b71b1ddd517f68ffb55e10834388d4f793c49c6b83effaaa05785b0b4/zstandard-0.25.0-cp314-cp314-manylinux2010_i686.manylinux_2_12_i686.manylinux_2_28_i686.whl", hash = "sha256:f604efd28f239cc21b3adb53eb061e2a205dc164be408e553b41ba2ffe0ca15c", size = 5379849, upload-time = "2025-09-14T22:17:57.372Z" }, + { url = "https://files.pythonhosted.org/packages/a4/86/a48e56320d0a17189ab7a42645387334fba2200e904ee47fc5a26c1fd8ca/zstandard-0.25.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223415140608d0f0da010499eaa8ccdb9af210a543fac54bce15babbcfc78439", size = 5058095, upload-time = "2025-09-14T22:17:59.498Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ad/eb659984ee2c0a779f9d06dbfe45e2dc39d99ff40a319895df2d3d9a48e5/zstandard-0.25.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e54296a283f3ab5a26fc9b8b5d4978ea0532f37b231644f367aa588930aa043", size = 5551751, upload-time = "2025-09-14T22:18:01.618Z" }, + { url = "https://files.pythonhosted.org/packages/61/b3/b637faea43677eb7bd42ab204dfb7053bd5c4582bfe6b1baefa80ac0c47b/zstandard-0.25.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ca54090275939dc8ec5dea2d2afb400e0f83444b2fc24e07df7fdef677110859", size = 6364818, upload-time = "2025-09-14T22:18:03.769Z" }, + { url = "https://files.pythonhosted.org/packages/31/dc/cc50210e11e465c975462439a492516a73300ab8caa8f5e0902544fd748b/zstandard-0.25.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e09bb6252b6476d8d56100e8147b803befa9a12cea144bbe629dd508800d1ad0", size = 5560402, upload-time = "2025-09-14T22:18:05.954Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ae/56523ae9c142f0c08efd5e868a6da613ae76614eca1305259c3bf6a0ed43/zstandard-0.25.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a9ec8c642d1ec73287ae3e726792dd86c96f5681eb8df274a757bf62b750eae7", size = 4955108, upload-time = "2025-09-14T22:18:07.68Z" }, + { url = "https://files.pythonhosted.org/packages/98/cf/c899f2d6df0840d5e384cf4c4121458c72802e8bda19691f3b16619f51e9/zstandard-0.25.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4089a10e598eae6393756b036e0f419e8c1d60f44a831520f9af41c14216cf2", size = 5269248, upload-time = "2025-09-14T22:18:09.753Z" }, + { url = "https://files.pythonhosted.org/packages/1b/c0/59e912a531d91e1c192d3085fc0f6fb2852753c301a812d856d857ea03c6/zstandard-0.25.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f67e8f1a324a900e75b5e28ffb152bcac9fbed1cc7b43f99cd90f395c4375344", size = 5430330, upload-time = "2025-09-14T22:18:11.966Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1d/7e31db1240de2df22a58e2ea9a93fc6e38cc29353e660c0272b6735d6669/zstandard-0.25.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9654dbc012d8b06fc3d19cc825af3f7bf8ae242226df5f83936cb39f5fdc846c", size = 5811123, upload-time = "2025-09-14T22:18:13.907Z" }, + { url = "https://files.pythonhosted.org/packages/f6/49/fac46df5ad353d50535e118d6983069df68ca5908d4d65b8c466150a4ff1/zstandard-0.25.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4203ce3b31aec23012d3a4cf4a2ed64d12fea5269c49aed5e4c3611b938e4088", size = 5359591, upload-time = "2025-09-14T22:18:16.465Z" }, + { url = "https://files.pythonhosted.org/packages/c2/38/f249a2050ad1eea0bb364046153942e34abba95dd5520af199aed86fbb49/zstandard-0.25.0-cp314-cp314-win32.whl", hash = "sha256:da469dc041701583e34de852d8634703550348d5822e66a0c827d39b05365b12", size = 444513, upload-time = "2025-09-14T22:18:20.61Z" }, + { url = "https://files.pythonhosted.org/packages/3a/43/241f9615bcf8ba8903b3f0432da069e857fc4fd1783bd26183db53c4804b/zstandard-0.25.0-cp314-cp314-win_amd64.whl", hash = "sha256:c19bcdd826e95671065f8692b5a4aa95c52dc7a02a4c5a0cac46deb879a017a2", size = 516118, upload-time = "2025-09-14T22:18:17.849Z" }, + { url = "https://files.pythonhosted.org/packages/f0/ef/da163ce2450ed4febf6467d77ccb4cd52c4c30ab45624bad26ca0a27260c/zstandard-0.25.0-cp314-cp314-win_arm64.whl", hash = "sha256:d7541afd73985c630bafcd6338d2518ae96060075f9463d7dc14cfb33514383d", size = 476940, upload-time = "2025-09-14T22:18:19.088Z" }, +]