Skip to content
Merged
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
89 changes: 86 additions & 3 deletions crates/integration-tests/src/harness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,38 @@ impl DevaipodHarness {
/// environment variables (`DEVAIPOD_INSTANCE`, `DEVAIPOD_HOST_MODE`).
/// Blocks until the token is captured from stdout and the health endpoint
/// responds with 200.
///
/// Agent containers run in mock mode (`DEVAIPOD_MOCK_AGENT=1`) so no real
/// AI provider is needed.
pub fn start() -> Result<Self> {
Self::start_inner(true)
}

/// Like [`start`] but without mock-agent mode.
///
/// Agent containers will attempt to run the real agent binary. Useful for
/// testing failure modes like missing binaries (exit code 42).
pub fn start_without_mock() -> Result<Self> {
Self::start_inner(false)
}

fn start_inner(mock_agent: bool) -> Result<Self> {
let port = find_free_port()?;
let binary = std::env::var("DEVAIPOD_PATH").unwrap_or_else(|_| "devaipod".to_string());

let mut cmd = Command::new(&binary);
cmd.args(["web", "--port", &port.to_string()])
.env("DEVAIPOD_INSTANCE", crate::INTEGRATION_TEST_INSTANCE)
.env("DEVAIPOD_HOST_MODE", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
if mock_agent {
// When the web server spawns `devaipod run` to create pods, this
// env var propagates to the child process, which passes it into
// the agent container so it runs mock-opencode instead of the
// real opencode server.
.env("DEVAIPOD_MOCK_AGENT", "1")
.stdout(Stdio::piped())
.stderr(Stdio::piped());
cmd.env("DEVAIPOD_MOCK_AGENT", "1");
}
// Propagate DEVAIPOD_CONTAINER_IMAGE so `detect_self_image()` in the
// web server uses the locally-built image instead of the published one.
if let Ok(img) = std::env::var("DEVAIPOD_CONTAINER_IMAGE") {
Expand Down Expand Up @@ -233,6 +250,72 @@ impl DevaipodHarness {
std::thread::sleep(Duration::from_secs(2));
}
}

/// Create a pod and wait for it to become Degraded (some containers exited).
///
/// Used to test failure modes like a missing agent binary: the workspace
/// and sidecar containers start, but the agent exits immediately, putting
/// the pod into Degraded state.
///
/// Returns the pod's JSON from the unified pod list once it reaches
/// Degraded (or Exited) status.
pub fn create_pod_expect_degraded(
&mut self,
source: &str,
pod_name: &str,
) -> Result<serde_json::Value> {
let body = serde_json::json!({
"source": source,
"name": pod_name,
});

let (status, resp) = self.post("/api/devaipod/run", &body.to_string())?;
if status != 200 {
bail!("POST /api/devaipod/run returned {status}: {resp}");
}

let full_name = if pod_name.starts_with("devaipod-") {
pod_name.to_string()
} else {
format!("devaipod-{pod_name}")
};
self.track_pod(&full_name);

// Poll until the pod appears with Degraded or Exited status.
// 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);
loop {
if Instant::now() > deadline {
let stderr = self.recent_stderr(30);
bail!(
"Pod '{full_name}' did not become Degraded within 120s\n\
=== web stderr ===\n{stderr}"
);
}

if let Ok((200, body)) = self.get("/api/devaipod/pods")
&& let Ok(pods) = serde_json::from_str::<Vec<serde_json::Value>>(&body)
&& let Some(pod) = pods.iter().find(|p| {
p.get("name")
.and_then(|n| n.as_str())
.map(|n| n == full_name)
.unwrap_or(false)
})
{
let pod_status = pod.get("status").and_then(|s| s.as_str()).unwrap_or("");
if pod_status.eq_ignore_ascii_case("degraded")
|| pod_status.eq_ignore_ascii_case("exited")
{
tracing::info!("Pod '{full_name}' is {pod_status}");
return Ok(pod.clone());
}
}

#[allow(clippy::disallowed_methods)]
std::thread::sleep(Duration::from_secs(2));
}
}
}

impl Drop for DevaipodHarness {
Expand Down
66 changes: 66 additions & 0 deletions crates/integration-tests/src/tests/controlplane.rs
Original file line number Diff line number Diff line change
Expand Up @@ -614,3 +614,69 @@ fn test_harness_pod_state_cache_survives_stop() -> Result<()> {
Ok(())
}
podman_integration_test!(test_harness_pod_state_cache_survives_stop);

/// Verify that a pod with a missing agent binary surfaces diagnostics.
///
/// Creates a pod WITHOUT mock-agent mode, so the agent container attempts to
/// run the real `opencode` binary. Since the test image doesn't have it, the
/// pre-flight check exits with code 42 and the backend should populate the
/// `diagnostics` field in the unified pod list response.
fn test_harness_missing_agent_binary_diagnostics() -> Result<()> {
let mut harness = DevaipodHarness::start_without_mock()?;
// Use a devcontainer image that has git (for init clone) but NOT opencode,
// so the agent pre-flight check fails with exit code 42.
let repo = TestRepo::new_with_devcontainer(
r#"{ "name": "no-agent-test", "image": "mcr.microsoft.com/devcontainers/base:ubuntu" }"#,
)?;

let pod_name = crate::unique_test_name("no-agent");
let short = crate::short_name(&pod_name);

let pod_json = harness.create_pod_expect_degraded(repo.repo_path.to_str().unwrap(), short)?;

// The diagnostics field should be present with the agent-binary-not-found code
let diagnostics = pod_json.get("diagnostics");
assert!(
diagnostics.is_some(),
"Degraded pod should have diagnostics field: {pod_json}"
);
let diag = diagnostics.unwrap();
assert_eq!(
diag.get("code").and_then(|v| v.as_str()),
Some("agent-binary-not-found"),
"Diagnostics code should be 'agent-binary-not-found': {diag}"
);
assert!(
diag.get("message")
.and_then(|v| v.as_str())
.is_some_and(|m| !m.is_empty()),
"Diagnostics should have a non-empty message: {diag}"
);
assert!(
diag.get("suggestion")
.and_then(|v| v.as_str())
.is_some_and(|s| !s.is_empty()),
"Diagnostics should have a non-empty suggestion: {diag}"
);

// Verify the agent container has exit code 42
let containers = pod_json.get("containers").and_then(|v| v.as_array());
if let Some(containers) = containers {
let agent = containers.iter().find(|c| {
c.get("Names")
.and_then(|n| n.as_str())
.is_some_and(|n| n.ends_with("-agent"))
});
if let Some(agent) = agent {
assert_eq!(
agent.get("ExitCode").and_then(|v| v.as_i64()),
Some(42),
"Agent container should have exit code 42: {agent}"
);
}
}

tracing::info!("Missing agent binary diagnostics test passed for pod '{pod_name}'");
Ok(())
}
podman_integration_test!(test_harness_missing_agent_binary_diagnostics);
32 changes: 17 additions & 15 deletions src/pod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2566,18 +2566,13 @@ exec sleep infinity
let startup_script = format!(
r#"mkdir -p {home}/.config/opencode {home}/.local/share {home}/.local/bin {home}/.cache

# Wait for devaipod to finish setup (dotfiles, task config) before starting
# opencode. The state file lives on the container overlay so it persists
# across stop/start but is absent after a container rebuild.
while [ ! -f {state} ]; do
sleep 0.1
done

# Mock mode: run inline mock server instead of the real opencode server.
# Used by integration tests to avoid needing a real AI provider.
# Uses Python3 (available in all devcontainer images) so no extra binary
# is required in the agent container.
if [ -n "${{DEVAIPOD_MOCK_AGENT}}" ]; then
# Wait for devaipod to finish setup before starting mock server.
while [ ! -f {state} ]; do sleep 0.1; done
exec python3 -u -c "
import json, http.server, socketserver

Expand All @@ -2604,13 +2599,20 @@ socketserver.TCPServer(('0.0.0.0',{opencode_port}),H).serve_forever()
fi

# Pre-flight: verify the agent binary is available in the container image.
# Exit with devaipod-specific code 42 if missing, so the backend can surface
# a diagnostic to the user instead of a generic "exited(127)" message.
# Check before waiting for the state file so the pod enters Degraded state
# immediately rather than blocking on setup that cannot succeed.
if ! command -v {agent_binary} >/dev/null 2>&1; then
echo "devaipod-error: agent-binary-not-found: {agent_binary}" >&2
exit 42
fi

# Wait for devaipod to finish setup (dotfiles, task config) before starting
# opencode. The state file lives on the container overlay so it persists
# across stop/start but is absent after a container rebuild.
while [ ! -f {state} ]; do
sleep 0.1
done

# Run opencode serve, bound to 0.0.0.0 so it's accessible from the published port
exec {agent_binary} serve --port {opencode_port} --hostname 0.0.0.0"#,
home = AGENT_HOME_PATH,
Expand Down Expand Up @@ -3058,6 +3060,12 @@ exec {agent_binary} serve --port {opencode_port} --hostname 0.0.0.0"#,
r#"set -e
mkdir -p {home}/.config {home}/.local/share {home}/.local/bin {home}/.cache

# Pre-flight: verify the agent binary is available before waiting for setup.
if ! command -v {agent_binary} >/dev/null 2>&1; then
echo "devaipod-error: agent-binary-not-found: {agent_binary}" >&2
exit 42
fi

# Wait for agent setup (dotfiles, task config) to complete before copying configs.
# The state file is written by devaipod after install_dotfiles_agent().
echo "Waiting for agent setup to complete..."
Expand Down Expand Up @@ -3090,12 +3098,6 @@ if [ -f /mnt/agent-home/.gitconfig ]; then
cp /mnt/agent-home/.gitconfig {home}/.gitconfig
fi

# Pre-flight: verify the agent binary is available
if ! command -v {agent_binary} >/dev/null 2>&1; then
echo "devaipod-error: agent-binary-not-found: {agent_binary}" >&2
exit 42
fi

# Run opencode serve in foreground
exec {agent_binary} serve --port {opencode_port} --hostname 127.0.0.1"#,
home = AGENT_HOME_PATH,
Expand Down
30 changes: 11 additions & 19 deletions src/web.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2297,32 +2297,24 @@ async fn refresh_pod_cache(
cache: &PodCache,
pod_state_cache: Option<&PodStateCache>,
) {
// Single Docker connection shared by enrichment map and exit-code inspection.
let docker = bollard::Docker::connect_with_unix(
&format!("unix://{}", socket_path.display()),
120,
bollard::API_DEFAULT_VERSION,
)
.ok();

let enrichment_future = async {
let docker = bollard::Docker::connect_with_unix(
&format!("unix://{}", socket_path.display()),
120,
bollard::API_DEFAULT_VERSION,
)
.ok();
match docker {
Some(d) => compute_enrichment_map(&d, self_image_id).await,
match &docker {
Some(d) => compute_enrichment_map(d, self_image_id).await,
None => HashMap::new(),
}
};

let (all_pods, enrichment_map) =
tokio::join!(fetch_podman_pods(socket_path), enrichment_future);

// Docker connection for inspecting exited agent containers (exit codes).
// This reuses the same socket but needs its own connection since the
// enrichment future consumed its one.
let inspect_docker = bollard::Docker::connect_with_unix(
&format!("unix://{}", socket_path.display()),
120,
bollard::API_DEFAULT_VERSION,
)
.ok();

let mut pods: Vec<CachedPodInfo> = all_pods
.into_iter()
.filter(|p| p.name.starts_with("devaipod-"))
Expand Down Expand Up @@ -2351,7 +2343,7 @@ async fn refresh_pod_cache(

// Inspect exited agent containers to detect known failure modes.
// Only bother for non-running pods to avoid unnecessary API calls.
if let Some(ref docker) = inspect_docker {
if let Some(ref docker) = docker {
for pod in pods.iter_mut() {
let dominated_by_exit = pod.status.eq_ignore_ascii_case("degraded")
|| pod.status.eq_ignore_ascii_case("exited");
Expand Down
Loading