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
51 changes: 47 additions & 4 deletions crates/integration-tests/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,26 @@ impl DevaipodHarness {
buf[start..].join("\n")
}

/// Fetch structured diagnostics for a pod via the API.
pub fn pod_diagnostics(&self, pod_name: &str) -> Result<String> {
let full_name = if pod_name.starts_with("devaipod-") {
pod_name.to_string()
} else {
format!("devaipod-{pod_name}")
};
let (status, body) = self.get(&format!("/api/devaipod/pods/{full_name}/diagnostics"))?;
if status == 200 {
// Pretty-print the JSON for readability in error messages
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&body) {
Ok(serde_json::to_string_pretty(&json).unwrap_or(body))
} else {
Ok(body)
}
} else {
Ok(format!("(diagnostics returned HTTP {status}: {body})"))
}
}

/// Create a pod from a local repo path and wait for it to appear in the
/// pod list as "Running".
///
Expand Down Expand Up @@ -225,9 +245,20 @@ impl DevaipodHarness {
// server spawns `devaipod run` in the background), so we need to
// wait for it to complete.
let deadline = Instant::now() + Duration::from_secs(120);
let mut last_status = String::new();
loop {
if Instant::now() > deadline {
bail!("Pod '{full_name}' did not become Running within 120s");
// Gather diagnostics before bailing
let diag = self
.pod_diagnostics(&full_name)
.unwrap_or_else(|_| "(failed to fetch diagnostics)".to_string());
let stderr = self.recent_stderr(30);
bail!(
"Pod '{full_name}' did not become Running within 120s\n\
Last seen status: {last_status}\n\
=== pod diagnostics ===\n{diag}\n\
=== web server stderr ===\n{stderr}"
);
}

if let Ok((200, body)) = self.get("/api/devaipod/pods")
Expand All @@ -240,8 +271,13 @@ impl DevaipodHarness {
})
{
let status = pod.get("status").and_then(|s| s.as_str()).unwrap_or("");
if status.eq_ignore_ascii_case("running") {
tracing::info!("Pod '{full_name}' is Running");
last_status = status.to_string();
// Accept both "Running" and "Degraded" — the latter happens when
// the service-gator container exits (expected for test repos with
// fake remote URLs), but the agent and api containers are healthy.
if status.eq_ignore_ascii_case("running") || status.eq_ignore_ascii_case("degraded")
{
tracing::info!("Pod '{full_name}' is {status}");
return Ok(());
}
}
Expand Down Expand Up @@ -285,12 +321,18 @@ impl DevaipodHarness {
// This takes longer than Running because we need to wait for the
// agent container to start and then exit.
let deadline = Instant::now() + Duration::from_secs(120);
let mut last_status = String::new();
loop {
if Instant::now() > deadline {
let diag = self
.pod_diagnostics(&full_name)
.unwrap_or_else(|_| "(failed to fetch diagnostics)".to_string());
let stderr = self.recent_stderr(30);
bail!(
"Pod '{full_name}' did not become Degraded within 120s\n\
=== web stderr ===\n{stderr}"
Last seen status: {last_status}\n\
=== pod diagnostics ===\n{diag}\n\
=== web server stderr ===\n{stderr}"
);
}

Expand All @@ -304,6 +346,7 @@ impl DevaipodHarness {
})
{
let pod_status = pod.get("status").and_then(|s| s.as_str()).unwrap_or("");
last_status = pod_status.to_string();
if pod_status.eq_ignore_ascii_case("degraded")
|| pod_status.eq_ignore_ascii_case("exited")
{
Expand Down
90 changes: 90 additions & 0 deletions crates/integration-tests/src/tests/controlplane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -680,3 +680,93 @@ fn test_harness_missing_agent_binary_diagnostics() -> Result<()> {
Ok(())
}
podman_integration_test!(test_harness_missing_agent_binary_diagnostics);

/// Verify the diagnostics API endpoint returns container logs and exit codes
/// for a degraded pod.
///
/// Uses the same failure mode as `test_harness_missing_agent_binary_diagnostics`
/// (missing agent binary → exit code 42) but exercises the
/// `GET /api/devaipod/pods/{name}/diagnostics` endpoint and validates its
/// response structure.
fn test_harness_diagnostics_endpoint_captures_failure() -> Result<()> {
let mut harness = DevaipodHarness::start_without_mock()?;
let repo = TestRepo::new_with_devcontainer(
r#"{ "name": "diag-test", "image": "mcr.microsoft.com/devcontainers/base:ubuntu" }"#,
)?;

let pod_name = crate::unique_test_name("diag-e2e");
let short = crate::short_name(&pod_name);

// Create a pod that will degrade (agent binary missing → exit 42)
let _pod_json = harness.create_pod_expect_degraded(repo.repo_path.to_str().unwrap(), short)?;

// Now call the diagnostics endpoint
let diag_json = harness.pod_diagnostics(&pod_name)?;
let diag: serde_json::Value = serde_json::from_str(&diag_json).map_err(|e| {
color_eyre::eyre::eyre!("Failed to parse diagnostics JSON: {e}\nraw: {diag_json}")
})?;

// Validate pod-level info
let pod_info = diag
.get("pod")
.expect("diagnostics should have 'pod' field");
assert!(
pod_info.get("name").and_then(|v| v.as_str()).is_some(),
"pod.name should be present: {pod_info}"
);
assert!(
pod_info.get("state").and_then(|v| v.as_str()).is_some(),
"pod.state should be present: {pod_info}"
);

// Validate containers array
let containers = diag
.get("containers")
.and_then(|v| v.as_array())
.expect("diagnostics should have 'containers' array");
assert!(
!containers.is_empty(),
"containers array should not be empty"
);

// Find the agent container and verify it has exit code and logs
let agent = containers.iter().find(|c| {
c.get("name")
.and_then(|n| n.as_str())
.is_some_and(|n| n.ends_with("-agent"))
});
assert!(
agent.is_some(),
"Should find an agent container in diagnostics: {containers:?}"
);
let agent = agent.unwrap();

// Agent should have exited with code 42
assert_eq!(
agent.get("exit_code").and_then(|v| v.as_i64()),
Some(42),
"Agent container exit_code should be 42: {agent}"
);

// Agent should have non-empty state
assert!(
agent
.get("state")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty()),
"Agent container should have a state: {agent}"
);

// logs_tail should be present (may be empty if agent exited immediately)
assert!(
agent.get("logs_tail").and_then(|v| v.as_str()).is_some(),
"Agent container should have logs_tail field: {agent}"
);

tracing::info!(
"Diagnostics endpoint test passed: {} containers reported",
containers.len()
);
Ok(())
}
podman_integration_test!(test_harness_diagnostics_endpoint_captures_failure);
Loading
Loading