diff --git a/bin/correctness/airlock/src/driver.rs b/bin/correctness/airlock/src/driver.rs index 63136464c6..74f5235ed9 100644 --- a/bin/correctness/airlock/src/driver.rs +++ b/bin/correctness/airlock/src/driver.rs @@ -8,6 +8,7 @@ use std::{ use bollard::{ container::{Config, CreateContainerOptions, ListContainersOptions, LogOutput, LogsOptions}, errors::Error, + exec::{CreateExecOptions, StartExecResults}, image::CreateImageOptions, models::{HealthConfig, HealthStatusEnum, HostConfig, Ipam}, network::CreateNetworkOptions, @@ -15,7 +16,7 @@ use bollard::{ volume::CreateVolumeOptions, Docker, }; -use futures::StreamExt as _; +use futures::{StreamExt as _, TryStreamExt as _}; use saluki_error::{generic_error, ErrorContext as _, GenericError}; use tokio::{ io::{AsyncWriteExt as _, BufWriter}, @@ -825,6 +826,66 @@ impl Driver { Ok(exit_status) } + /// Executes a command inside the running container and returns its stdout. + /// + /// The command runs as root with no TTY. Stderr is discarded — only stdout is returned. If the command exits with a + /// nonzero status, an error is returned. + /// + /// # Errors + /// + /// If the exec creation, start, output collection, or command exit code indicates failure, an error is returned. + pub async fn exec_in_container(&self, cmd: Vec) -> Result { + let exec_opts = CreateExecOptions { + attach_stdout: Some(true), + attach_stderr: Some(false), + cmd: Some(cmd.clone()), + ..Default::default() + }; + + let exec = self + .docker + .create_exec::(&self.container_name, exec_opts) + .await + .with_error_context(|| format!("Failed to create exec instance for container {}.", self.container_name))?; + + let exec_id = exec.id.clone(); + + let output = self + .docker + .start_exec(&exec.id, None) + .await + .with_error_context(|| format!("Failed to start exec for container {}.", self.container_name))?; + + let mut stdout = String::new(); + if let StartExecResults::Attached { mut output, .. } = output { + while let Some(chunk) = output.try_next().await? { + if let LogOutput::StdOut { message } = chunk { + stdout.push_str(&String::from_utf8_lossy(&message)); + } + } + } + + // Check the command's exit code. + let inspect = self + .docker + .inspect_exec(&exec_id) + .await + .error_context("Failed to inspect exec result.")?; + + if let Some(code) = inspect.exit_code { + if code != 0 { + return Err(generic_error!( + "Command {:?} exited with code {} in container {}.", + cmd, + code, + self.container_name + )); + } + } + + Ok(stdout) + } + async fn cleanup_inner(&self, container_name: &str) -> Result<(), GenericError> { self.docker.stop_container(container_name, None).await?; self.docker.remove_container(container_name, None).await?; diff --git a/bin/correctness/panoramic/src/config.rs b/bin/correctness/panoramic/src/config.rs index 3a9fa45691..e9b8966309 100644 --- a/bin/correctness/panoramic/src/config.rs +++ b/bin/correctness/panoramic/src/config.rs @@ -227,7 +227,80 @@ pub enum LogStream { Both, } +impl AssertionConfig { + /// Replaces `{{PANORAMIC_DYNAMIC_*}}` placeholders in string fields with resolved values. + pub fn resolve_dynamic_vars(&mut self, vars: &HashMap) { + match self { + AssertionConfig::LogContains { pattern, .. } | AssertionConfig::LogNotContains { pattern, .. } => { + crate::dynamic_vars::resolve_placeholders(pattern, vars); + } + AssertionConfig::HealthCheck { endpoint, .. } => { + crate::dynamic_vars::resolve_placeholders(endpoint, vars); + } + AssertionConfig::PortListening { protocol, .. } => { + crate::dynamic_vars::resolve_placeholders(protocol, vars); + } + AssertionConfig::ProcessStableFor { .. } | AssertionConfig::ProcessExitsWith { .. } => {} + } + } + + /// Returns any unresolved `{{PANORAMIC_DYNAMIC_*}}` placeholders in string fields. + pub fn unresolved_placeholders(&self) -> Vec { + let mut out = Vec::new(); + match self { + AssertionConfig::LogContains { pattern, .. } | AssertionConfig::LogNotContains { pattern, .. } => { + crate::dynamic_vars::find_unresolved(pattern, &mut out); + } + AssertionConfig::HealthCheck { endpoint, .. } => { + crate::dynamic_vars::find_unresolved(endpoint, &mut out); + } + AssertionConfig::PortListening { protocol, .. } => { + crate::dynamic_vars::find_unresolved(protocol, &mut out); + } + AssertionConfig::ProcessStableFor { .. } | AssertionConfig::ProcessExitsWith { .. } => {} + } + out + } +} + +impl AssertionStep { + /// Replaces `{{PANORAMIC_DYNAMIC_*}}` placeholders in all assertion configs within this step. + pub fn resolve_dynamic_vars(&mut self, vars: &HashMap) { + match self { + AssertionStep::Single(config) => config.resolve_dynamic_vars(vars), + AssertionStep::Parallel { parallel } => { + for config in parallel { + config.resolve_dynamic_vars(vars); + } + } + } + } + + /// Returns any unresolved `{{PANORAMIC_DYNAMIC_*}}` placeholders in this step. + pub fn unresolved_placeholders(&self) -> Vec { + match self { + AssertionStep::Single(config) => config.unresolved_placeholders(), + AssertionStep::Parallel { parallel } => parallel.iter().flat_map(|c| c.unresolved_placeholders()).collect(), + } + } +} + impl TestCase { + /// Replaces `{{PANORAMIC_DYNAMIC_*}}` placeholders in all assertion steps. + pub fn resolve_dynamic_vars(&mut self, vars: &HashMap) { + for step in &mut self.assertions { + step.resolve_dynamic_vars(vars); + } + } + + /// Returns any unresolved `{{PANORAMIC_DYNAMIC_*}}` placeholders across all assertion steps. + pub fn unresolved_placeholders(&self) -> Vec { + self.assertions + .iter() + .flat_map(|s| s.unresolved_placeholders()) + .collect() + } + /// Count total individual assertions across all steps. pub fn total_assertion_count(&self) -> usize { self.assertions diff --git a/bin/correctness/panoramic/src/dynamic_vars.rs b/bin/correctness/panoramic/src/dynamic_vars.rs new file mode 100644 index 0000000000..f2b449d6ab --- /dev/null +++ b/bin/correctness/panoramic/src/dynamic_vars.rs @@ -0,0 +1,141 @@ +//! Runtime-resolved dynamic variables for panoramic integration tests. +//! +//! Some integration tests need values that only exist at container runtime — for example, the +//! container's Docker-assigned IP address. These values aren't known when the test config is +//! written, so they can't be hardcoded in YAML. +//! +//! Dynamic variables solve this with a two-sided mechanism: +//! +//! ## Defining a dynamic variable +//! +//! In a test's `config.yaml`, add a `PANORAMIC_DYNAMIC_` env var whose value is a shell +//! command. Reference the resolved value anywhere in the config with `{{PANORAMIC_DYNAMIC_}}`: +//! +//! ```yaml +//! container: +//! env: +//! PANORAMIC_DYNAMIC_CONTAINER_IP: "hostname -i | awk '{print $1}'" +//! DD_BIND_HOST: "{{PANORAMIC_DYNAMIC_CONTAINER_IP}}" +//! +//! assertions: +//! - type: log_contains +//! pattern: "listen_addr:{{PANORAMIC_DYNAMIC_CONTAINER_IP}}:8125" +//! timeout: 15s +//! ``` +//! +//! ## How resolution works +//! +//! Two independent resolvers perform the same substitution: +//! +//! **Inside the container** — the `00-panoramic-dynamic.sh` cont-init.d script runs before any +//! services start. It evaluates each `PANORAMIC_DYNAMIC_*` command, writes the result to +//! `/airlock/dynamic/`, resolves `{{PANORAMIC_DYNAMIC_*}}` references in `DD_*` env vars, and +//! writes the resolved values to `/run/adp/env/` for s6-envdir. ADP never sees placeholder strings. +//! +//! **Outside the container** — after the container starts, panoramic polls for +//! `/airlock/dynamic/.ready`, reads resolved values from `/airlock/dynamic/`, and substitutes +//! `{{PANORAMIC_DYNAMIC_*}}` in assertion patterns before evaluating them. +//! +//! Both sides derive values from the same commands in the same container, so they match. +//! +//! ## Naming conventions +//! +//! - `PANORAMIC_DYNAMIC_*` — test infrastructure, not application config. Consumed by the init +//! script; never visible to ADP or the core agent. +//! - `DD_*` — Datadog Agent and ADP config keys. May contain `{{PANORAMIC_DYNAMIC_*}}` references +//! that get resolved before ADP starts. +//! +//! ## Error handling +//! +//! - If a `PANORAMIC_DYNAMIC_*` command produces an empty result, panoramic fails the test +//! immediately with a clear message (the shell command likely failed). +//! - If an assertion pattern still contains `{{PANORAMIC_DYNAMIC_*}}` after substitution, panoramic +//! fails the test (the variable was referenced but never defined). +//! - The init script writes `/airlock/dynamic/.ready` via a bash `trap EXIT`, so the sentinel is +//! always written regardless of how the script exits. + +use std::{collections::HashMap, time::Duration, time::Instant}; + +use airlock::driver::Driver; +use saluki_error::{generic_error, ErrorContext as _, GenericError}; +use tracing::debug; + +use crate::config::TestCase; + +/// Prefix for dynamic variable env vars in the test config. +pub const ENV_PREFIX: &str = "PANORAMIC_DYNAMIC_"; + +/// Placeholder pattern: `{{PANORAMIC_DYNAMIC_}}`. +const PLACEHOLDER_NEEDLE: &str = "{{PANORAMIC_DYNAMIC_"; + +/// Returns `true` if the test case defines any `PANORAMIC_DYNAMIC_*` env vars. +pub fn has_dynamic_vars(test_case: &TestCase) -> bool { + test_case.container.env.keys().any(|k| k.starts_with(ENV_PREFIX)) +} + +/// Reads resolved dynamic variable values from `/airlock/dynamic/` inside the container. +/// +/// Polls for the `/airlock/dynamic/.ready` sentinel (up to 30 seconds), then reads each key file. +pub async fn read_resolved_vars(driver: &Driver) -> Result, GenericError> { + let deadline = Instant::now() + Duration::from_secs(30); + loop { + let result = driver + .exec_in_container(vec!["cat".to_string(), "/airlock/dynamic/.ready".to_string()]) + .await; + + if result.is_ok() { + break; + } + + if Instant::now() > deadline { + return Err(generic_error!( + "Timed out waiting for /airlock/dynamic/.ready after 30s." + )); + } + + tokio::time::sleep(Duration::from_millis(200)).await; + } + + let listing = driver + .exec_in_container(vec!["ls".to_string(), "/airlock/dynamic/".to_string()]) + .await + .error_context("Failed to list /airlock/dynamic/.")?; + + let mut vars = HashMap::new(); + for filename in listing.lines() { + let filename = filename.trim(); + if filename.is_empty() || filename == ".ready" { + continue; + } + + let value = driver + .exec_in_container(vec!["cat".to_string(), format!("/airlock/dynamic/{}", filename)]) + .await + .error_context(format!("Failed to read /airlock/dynamic/{}.", filename))?; + + debug!(key = filename, value = %value.trim(), "Resolved dynamic variable."); + vars.insert(filename.to_string(), value.trim().to_string()); + } + + Ok(vars) +} + +/// Replace all `{{PANORAMIC_DYNAMIC_*}}` placeholders in a string with resolved values. +pub fn resolve_placeholders(s: &mut String, vars: &HashMap) { + for (key, value) in vars { + *s = s.replace(&format!("{{{{PANORAMIC_DYNAMIC_{key}}}}}"), value); + } +} + +/// Collect any `{{PANORAMIC_DYNAMIC_*}}` placeholders still present in a string. +pub fn find_unresolved(s: &str, out: &mut Vec) { + let mut remaining = s; + while let Some(start) = remaining.find(PLACEHOLDER_NEEDLE) { + if let Some(end) = remaining[start..].find("}}") { + out.push(remaining[start..start + end + 2].to_string()); + remaining = &remaining[start + end + 2..]; + } else { + break; + } + } +} diff --git a/bin/correctness/panoramic/src/main.rs b/bin/correctness/panoramic/src/main.rs index 1d1b687fdb..86ccb5c776 100644 --- a/bin/correctness/panoramic/src/main.rs +++ b/bin/correctness/panoramic/src/main.rs @@ -16,6 +16,7 @@ mod cli; use self::cli::{Cli, Command}; mod config; +mod dynamic_vars; use self::config::discover_tests; mod events; diff --git a/bin/correctness/panoramic/src/runner.rs b/bin/correctness/panoramic/src/runner.rs index f4ac0f5704..0b8a8aaef5 100644 --- a/bin/correctness/panoramic/src/runner.rs +++ b/bin/correctness/panoramic/src/runner.rs @@ -322,6 +322,91 @@ impl TestRunner { // Build port mappings for assertions. let port_mappings = self.build_port_mappings(&details); + // Resolve dynamic variables if any PANORAMIC_DYNAMIC_* env vars are defined. + if crate::dynamic_vars::has_dynamic_vars(&self.test_case) { + let phase_start = Instant::now(); + debug!(test = %test_name, "Resolving dynamic variables..."); + + match crate::dynamic_vars::read_resolved_vars(&driver).await { + Ok(vars) => { + // Fail on empty values — indicates the init script command failed. + for (key, value) in &vars { + if value.is_empty() { + error!(test = %test_name, key = key, "Dynamic variable resolved to empty string."); + phase_timings.push(PhaseTiming { + phase: "dynamic_vars".to_string(), + duration: phase_start.elapsed(), + }); + let _ = self.cleanup(&driver).await; + return TestResult { + name: test_name, + passed: false, + duration: started.elapsed(), + assertion_results: vec![], + error: Some(format!( + "Dynamic variable PANORAMIC_DYNAMIC_{} resolved to an empty string. \ + The shell command in the test config likely failed.", + key + )), + phase_timings, + }; + } + } + + info!( + test = %test_name, + variable_count = vars.len(), + "Resolved dynamic variables." + ); + self.test_case.resolve_dynamic_vars(&vars); + + // Fail if any placeholders remain unresolved after substitution. + let unresolved = self.test_case.unresolved_placeholders(); + if !unresolved.is_empty() { + error!(test = %test_name, unresolved = ?unresolved, "Unresolved dynamic variable placeholders."); + phase_timings.push(PhaseTiming { + phase: "dynamic_vars".to_string(), + duration: phase_start.elapsed(), + }); + let _ = self.cleanup(&driver).await; + return TestResult { + name: test_name, + passed: false, + duration: started.elapsed(), + assertion_results: vec![], + error: Some(format!( + "Unresolved dynamic variable placeholders in assertions: {}. \ + Check that matching PANORAMIC_DYNAMIC_* env vars are defined.", + unresolved.join(", ") + )), + phase_timings, + }; + } + } + Err(e) => { + error!(test = %test_name, error = %e, "Failed to resolve dynamic variables."); + phase_timings.push(PhaseTiming { + phase: "dynamic_vars".to_string(), + duration: phase_start.elapsed(), + }); + let _ = self.cleanup(&driver).await; + return TestResult { + name: test_name, + passed: false, + duration: started.elapsed(), + assertion_results: vec![], + error: Some(format!("Failed to resolve dynamic variables: {}", e)), + phase_timings, + }; + } + } + + phase_timings.push(PhaseTiming { + phase: "dynamic_vars".to_string(), + duration: phase_start.elapsed(), + }); + } + // Run assertions with overall timeout. info!( test = %test_name, diff --git a/docker/cont-init.d/00-panoramic-dynamic.sh b/docker/cont-init.d/00-panoramic-dynamic.sh new file mode 100755 index 0000000000..f0ca7e62ec --- /dev/null +++ b/docker/cont-init.d/00-panoramic-dynamic.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# Container-side half of the panoramic dynamic variable system. +# +# Some integration tests need values that only exist at container runtime (e.g., the +# container's Docker-assigned IP). This script is the container-side resolver — it +# evaluates PANORAMIC_DYNAMIC_* env vars as shell commands, writes results to +# /airlock/dynamic/, and resolves {{PANORAMIC_DYNAMIC_*}} references in other +# env vars by writing the final values to /run/adp/env/ for s6-envdir. +# +# The panoramic-side resolver (see dynamic_vars.rs) reads from /airlock/dynamic/ and +# substitutes the same values into assertion patterns. +# +# This script is a no-op when no PANORAMIC_DYNAMIC_* vars are present. + +# Always signal readiness on exit, regardless of how we get there. +mkdir -p /airlock/dynamic +trap 'touch /airlock/dynamic/.ready' EXIT + +# Collect all PANORAMIC_DYNAMIC_* variable names. +dynamic_vars=$(env | grep '^PANORAMIC_DYNAMIC_' | cut -d= -f1) + +# Exit early if none are defined — nothing to do. +if [ -z "$dynamic_vars" ]; then + exit 0 +fi + +mkdir -p /run/adp/env + +# Phase 1: Evaluate each PANORAMIC_DYNAMIC_* command and store the result. +for var in $dynamic_vars; do + key="${var#PANORAMIC_DYNAMIC_}" + cmd="${!var}" + result=$(eval "$cmd" 2>/dev/null) + printf "%s" "$result" > "/airlock/dynamic/$key" +done + +# Phase 2: Resolve {{PANORAMIC_DYNAMIC_*}} references in all other env vars +# and write resolved values to /run/adp/env/ for s6-envdir. +env | while IFS='=' read -r var value; do + # Skip PANORAMIC_DYNAMIC_* vars themselves. + case "$var" in PANORAMIC_DYNAMIC_*) continue ;; esac + + # Skip vars that don't contain a template reference. + case "$value" in *'{{PANORAMIC_DYNAMIC_'*) ;; *) continue ;; esac + + resolved="$value" + for file in /airlock/dynamic/*; do + [ -f "$file" ] || continue + key=$(basename "$file") + val=$(cat "$file") + resolved="${resolved//\{\{PANORAMIC_DYNAMIC_${key}\}\}/$val}" + done + + printf "%s" "$resolved" > "/run/adp/env/$var" +done diff --git a/lib/saluki-components/src/sources/dogstatsd/mod.rs b/lib/saluki-components/src/sources/dogstatsd/mod.rs index 8c562bfba3..b618e54300 100644 --- a/lib/saluki-components/src/sources/dogstatsd/mod.rs +++ b/lib/saluki-components/src/sources/dogstatsd/mod.rs @@ -81,6 +81,9 @@ enum Error { #[snafu(display("No listeners configured. Please specify a port (`dogstatsd_port`) or a socket path (`dogstatsd_socket` or `dogstatsd_stream_socket`) to enable a listener."))] NoListenersConfigured, + + #[snafu(display("Invalid bind_host '{}': not a valid IP address.", address))] + InvalidBindHost { address: String }, } const fn default_buffer_size() -> usize { @@ -207,10 +210,20 @@ pub struct DogStatsDConfiguration { #[serde_as(as = "NoneAsEmptyString")] socket_stream_path: Option, + /// The host address to bind DogStatsD listeners to. + /// + /// When set, UDP and TCP listeners bind to this address instead of `localhost`. Ignored when + /// `dogstatsd_non_local_traffic` is `true` (which always binds to `0.0.0.0`). + /// + /// Defaults to unset, which resolves to `localhost` (`127.0.0.1`). + #[serde(rename = "bind_host", default)] + #[serde_as(as = "NoneAsEmptyString")] + bind_host: Option, + /// Whether or not to listen for non-local traffic in UDP mode. /// /// If set to `true`, the listener will accept packets from any interface/address. Otherwise, the source will only - /// listen on `localhost`. + /// listen on the address specified by `bind_host`, or `localhost` if `bind_host` is not set. /// /// Defaults to `false`. #[serde(rename = "dogstatsd_non_local_traffic", default)] @@ -386,12 +399,20 @@ impl DogStatsDConfiguration { async fn build_listeners(&self) -> Result, Error> { let mut listeners = Vec::new(); + // Resolve the bind address for UDP/TCP listeners. + // non_local_traffic=true always wins (0.0.0.0). Otherwise, use bind_host if set, falling + // back to localhost. This matches the core agent's behavior in GetBindHost(). + let bind_ip: std::net::IpAddr = if self.non_local_traffic { + [0, 0, 0, 0].into() + } else if let Some(ref host) = self.bind_host { + host.parse() + .map_err(|_| Error::InvalidBindHost { address: host.clone() })? + } else { + [127, 0, 0, 1].into() + }; + if self.port != 0 { - let address = if self.non_local_traffic { - ListenAddress::Udp(([0, 0, 0, 0], self.port).into()) - } else { - ListenAddress::Udp(([127, 0, 0, 1], self.port).into()) - }; + let address = ListenAddress::Udp(std::net::SocketAddr::new(bind_ip, self.port)); let listener = Listener::from_listen_address(address) .await @@ -400,11 +421,7 @@ impl DogStatsDConfiguration { } if self.tcp_port != 0 { - let address = if self.non_local_traffic { - ListenAddress::Tcp(([0, 0, 0, 0], self.tcp_port).into()) - } else { - ListenAddress::Tcp(([127, 0, 0, 1], self.tcp_port).into()) - }; + let address = ListenAddress::Tcp(std::net::SocketAddr::new(bind_ip, self.tcp_port)); let listener = Listener::from_listen_address(address) .await diff --git a/test/integration/cases/dogstatsd-bind-host/config.yaml b/test/integration/cases/dogstatsd-bind-host/config.yaml new file mode 100644 index 0000000000..4dee41a12d --- /dev/null +++ b/test/integration/cases/dogstatsd-bind-host/config.yaml @@ -0,0 +1,39 @@ +# Issue #1331: bind_host support — test 2 of 3 (bind_host is used) +# +# The core agent reads `bind_host` from config to determine which address +# DogStatsD binds to. When bind_host is set and non_local_traffic is false, +# the core agent binds to the specified address instead of localhost. +# +# ADP must match this behavior. This test sets bind_host to the container's +# eth0 IP (resolved at runtime via PANORAMIC_DYNAMIC_CONTAINER_IP) and +# verifies the listener binds to that address. The eth0 IP is guaranteed +# to be neither 127.0.0.1 nor 0.0.0.0, which proves bind_host was actually +# read from config and used — not just coincidentally matching a hardcoded +# default. + +name: "dogstatsd-bind-host" +description: "Verifies DogStatsD binds to the address specified by bind_host" +timeout: 60s + +container: + image: "saluki-images/datadog-agent:testing-devel" + env: + DD_API_KEY: "test-api-key" + DD_HOSTNAME: "integration-test-dsd-bind-host" + DD_DATA_PLANE_ENABLED: "true" + DD_DATA_PLANE_STANDALONE_MODE: "true" + DD_DATA_PLANE_DOGSTATSD_ENABLED: "true" + PANORAMIC_DYNAMIC_CONTAINER_IP: "hostname -i | awk '{print $1}'" + DD_BIND_HOST: "{{PANORAMIC_DYNAMIC_CONTAINER_IP}}" + +assertions: + - type: log_contains + pattern: 'listen_addr:"udp://{{PANORAMIC_DYNAMIC_CONTAINER_IP}}:8125"' + timeout: 15s + - parallel: + - type: process_stable_for + duration: 10s + - type: log_not_contains + pattern: "panic|PANIC" + regex: true + during: 10s diff --git a/test/integration/cases/dogstatsd-default-bind/config.yaml b/test/integration/cases/dogstatsd-default-bind/config.yaml new file mode 100644 index 0000000000..b054c041b3 --- /dev/null +++ b/test/integration/cases/dogstatsd-default-bind/config.yaml @@ -0,0 +1,34 @@ +# Issue #1331: bind_host support — test 1 of 3 (default behavior) +# +# The core agent reads `bind_host` from config to determine which address +# DogStatsD binds to. When bind_host is not set and non_local_traffic is +# false, the core agent defaults to localhost (127.0.0.1). +# +# ADP must match this behavior. This test verifies the default case: no +# bind_host configured, no non_local_traffic. The DogStatsD listener +# should bind to 127.0.0.1. + +name: "dogstatsd-default-bind" +description: "Verifies DogStatsD binds to 127.0.0.1 by default when bind_host is not configured" +timeout: 60s + +container: + image: "saluki-images/datadog-agent:testing-devel" + env: + DD_API_KEY: "test-api-key" + DD_HOSTNAME: "integration-test-dsd-default-bind" + DD_DATA_PLANE_ENABLED: "true" + DD_DATA_PLANE_STANDALONE_MODE: "true" + DD_DATA_PLANE_DOGSTATSD_ENABLED: "true" + +assertions: + - type: log_contains + pattern: 'listen_addr:"udp://127.0.0.1:8125"' + timeout: 15s + - parallel: + - type: process_stable_for + duration: 10s + - type: log_not_contains + pattern: "panic|PANIC" + regex: true + during: 10s diff --git a/test/integration/cases/dogstatsd-non-local-overrides-bind-host/config.yaml b/test/integration/cases/dogstatsd-non-local-overrides-bind-host/config.yaml new file mode 100644 index 0000000000..1c186c1090 --- /dev/null +++ b/test/integration/cases/dogstatsd-non-local-overrides-bind-host/config.yaml @@ -0,0 +1,41 @@ +# Issue #1331: bind_host support — precedence test +# +# The core agent reads `bind_host` from config to determine which address +# DogStatsD binds to. But when dogstatsd_non_local_traffic is true, the +# core agent ignores bind_host entirely and binds to 0.0.0.0. +# +# ADP must match this precedence. This test sets bind_host to an arbitrary +# value (10.9.8.7 — intentionally arbitrary) alongside non_local_traffic=true. +# +# Without this test, an implementation could incorrectly let bind_host +# override non_local_traffic, breaking the core agent's contract. + +name: "dogstatsd-non-local-overrides-bind-host" +description: "Verifies dogstatsd_non_local_traffic takes precedence over bind_host" +timeout: 60s + +container: + image: "saluki-images/datadog-agent:testing-devel" + env: + DD_API_KEY: "test-api-key" + DD_HOSTNAME: "integration-test-dsd-non-local-override" + DD_DATA_PLANE_ENABLED: "true" + DD_DATA_PLANE_STANDALONE_MODE: "true" + DD_DATA_PLANE_DOGSTATSD_ENABLED: "true" + DD_DOGSTATSD_NON_LOCAL_TRAFFIC: "true" + # Arbitrary, unreachable IP: non_local_traffic=true wins over bind_host, + # so the value is ignored. Using something obviously-not-a-real-binding + # address makes the "the value doesn't matter" intent unmistakable. + DD_BIND_HOST: "10.9.8.7" + +assertions: + - type: log_contains + pattern: 'listen_addr:"udp://0.0.0.0:8125"' + timeout: 15s + - parallel: + - type: process_stable_for + duration: 10s + - type: log_not_contains + pattern: "panic|PANIC" + regex: true + during: 10s