Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 62 additions & 1 deletion bin/correctness/airlock/src/driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,15 @@ use std::{
use bollard::{
container::{Config, CreateContainerOptions, ListContainersOptions, LogOutput, LogsOptions},
errors::Error,
exec::{CreateExecOptions, StartExecResults},
image::CreateImageOptions,
models::{HealthConfig, HealthStatusEnum, HostConfig, Ipam},
network::CreateNetworkOptions,
secret::ContainerStateStatusEnum,
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},
Expand Down Expand Up @@ -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<String>) -> Result<String, GenericError> {
let exec_opts = CreateExecOptions {
attach_stdout: Some(true),
attach_stderr: Some(false),
cmd: Some(cmd.clone()),
..Default::default()
};

let exec = self
.docker
.create_exec::<String>(&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?;
Expand Down
73 changes: 73 additions & 0 deletions bin/correctness/panoramic/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>) {
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<String> {
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 {
Comment on lines +264 to +266
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why two impls?

/// Replaces `{{PANORAMIC_DYNAMIC_*}}` placeholders in all assertion configs within this step.
pub fn resolve_dynamic_vars(&mut self, vars: &HashMap<String, String>) {
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<String> {
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<String, String>) {
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<String> {
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
Expand Down
141 changes: 141 additions & 0 deletions bin/correctness/panoramic/src/dynamic_vars.rs
Original file line number Diff line number Diff line change
@@ -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_<KEY>` env var whose value is a shell
//! command. Reference the resolved value anywhere in the config with `{{PANORAMIC_DYNAMIC_<KEY>}}`:
//!
//! ```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/<KEY>`, 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/<KEY>`, 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_<KEY>}}`.
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<HashMap<String, String>, 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<String, String>) {
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<String>) {
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;
}
}
}
1 change: 1 addition & 0 deletions bin/correctness/panoramic/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ mod cli;
use self::cli::{Cli, Command};

mod config;
mod dynamic_vars;
use self::config::discover_tests;

mod events;
Expand Down
Loading
Loading