Skip to content

Commit 61b9c07

Browse files
craigamcwclaude
andcommitted
test(e2e): add Podman-specific end-to-end tests
Add E2E tests that validate the Podman macOS support end-to-end: - doctor check succeeds with explicit DOCKER_HOST pointing at Podman - doctor check auto-discovers the Podman socket without DOCKER_HOST - doctor check respects CONTAINER_HOST as a fallback - full gateway lifecycle (start → status → destroy) under Podman with KubeletInUserNamespace and cgroups-per-qos flags All tests skip gracefully when Podman is not installed or not running, so they do not break CI on Docker-only environments. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Craig <craig@epic28.com>
1 parent 99185fd commit 61b9c07

File tree

1 file changed

+242
-0
lines changed

1 file changed

+242
-0
lines changed

e2e/rust/tests/podman_support.rs

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
//! Podman-specific E2E tests for macOS support.
5+
//!
6+
//! These tests verify the Podman socket discovery, runtime detection, and
7+
//! container configuration logic introduced for macOS Podman support.
8+
//!
9+
//! Gated behind the `e2e` feature flag (same as other E2E tests) and
10+
//! automatically skipped when Podman is not installed or not running.
11+
12+
#![cfg(feature = "e2e")]
13+
14+
use std::process::Stdio;
15+
16+
use openshell_e2e::harness::binary::openshell_cmd;
17+
use openshell_e2e::harness::output::strip_ansi;
18+
19+
/// Check whether Podman is available and a machine is running.
20+
/// Returns the socket path if available, None otherwise.
21+
async fn podman_socket() -> Option<String> {
22+
let output = tokio::process::Command::new("podman")
23+
.args([
24+
"machine",
25+
"inspect",
26+
"--format",
27+
"{{.ConnectionInfo.PodmanSocket.Path}}",
28+
])
29+
.stdout(Stdio::piped())
30+
.stderr(Stdio::piped())
31+
.output()
32+
.await
33+
.ok()?;
34+
35+
if !output.status.success() {
36+
return None;
37+
}
38+
39+
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
40+
if path.is_empty() || !path.starts_with('/') {
41+
return None;
42+
}
43+
44+
// Verify socket actually exists
45+
if !std::path::Path::new(&path).exists() {
46+
return None;
47+
}
48+
49+
Some(path)
50+
}
51+
52+
/// Run `openshell <args>` with DOCKER_HOST pointing at the Podman socket.
53+
/// Uses an isolated XDG_CONFIG_HOME per call (stateless).
54+
async fn run_with_podman(args: &[&str], socket: &str) -> (String, i32) {
55+
let tmpdir = tempfile::tempdir().expect("create config dir");
56+
run_with_podman_config(args, socket, tmpdir.path()).await
57+
}
58+
59+
/// Run `openshell <args>` with a shared config directory so gateway metadata
60+
/// persists across calls.
61+
async fn run_with_podman_config(
62+
args: &[&str],
63+
socket: &str,
64+
config_dir: &std::path::Path,
65+
) -> (String, i32) {
66+
let mut cmd = openshell_cmd();
67+
cmd.args(args)
68+
.env("DOCKER_HOST", format!("unix://{socket}"))
69+
.env("XDG_CONFIG_HOME", config_dir)
70+
.env_remove("OPENSHELL_GATEWAY")
71+
.stdout(Stdio::piped())
72+
.stderr(Stdio::piped());
73+
74+
let output = cmd.output().await.expect("spawn openshell");
75+
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
76+
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
77+
let combined = format!("{stdout}{stderr}");
78+
let code = output.status.code().unwrap_or(-1);
79+
(combined, code)
80+
}
81+
82+
// -------------------------------------------------------------------
83+
// doctor check — verifies Podman is detected as a reachable runtime
84+
// -------------------------------------------------------------------
85+
86+
/// `openshell doctor check` should succeed when DOCKER_HOST points at a
87+
/// running Podman socket.
88+
#[tokio::test]
89+
async fn doctor_check_succeeds_with_podman() {
90+
let Some(socket) = podman_socket().await else {
91+
eprintln!("SKIP: Podman not available");
92+
return;
93+
};
94+
95+
let (output, code) = run_with_podman(&["doctor", "check"], &socket).await;
96+
let clean = strip_ansi(&output);
97+
98+
assert_eq!(
99+
code, 0,
100+
"doctor check should succeed with Podman socket:\n{clean}"
101+
);
102+
// The output should mention the Docker/Podman version
103+
assert!(
104+
clean.contains("Docker") || clean.contains("Podman") || clean.contains("version"),
105+
"doctor check should report runtime info:\n{clean}"
106+
);
107+
}
108+
109+
// -------------------------------------------------------------------
110+
// Podman socket discovery — verifies auto-detection without DOCKER_HOST
111+
// -------------------------------------------------------------------
112+
113+
/// `openshell doctor check` should auto-discover the Podman socket when
114+
/// DOCKER_HOST is not set (macOS only).
115+
#[tokio::test]
116+
async fn doctor_check_auto_discovers_podman_socket() {
117+
if !cfg!(target_os = "macos") {
118+
eprintln!("SKIP: Podman auto-discovery is macOS only");
119+
return;
120+
}
121+
122+
let Some(_socket) = podman_socket().await else {
123+
eprintln!("SKIP: Podman not available");
124+
return;
125+
};
126+
127+
// Run WITHOUT DOCKER_HOST — should auto-discover via `podman machine inspect`.
128+
// Preserve HOME and XDG_CONFIG_HOME so both Podman (machine config) and
129+
// OpenShell (gateway metadata) can find their config directories.
130+
let mut cmd = openshell_cmd();
131+
cmd.args(["doctor", "check"])
132+
.env_remove("DOCKER_HOST")
133+
.env_remove("CONTAINER_HOST")
134+
.env_remove("OPENSHELL_GATEWAY")
135+
.stdout(Stdio::piped())
136+
.stderr(Stdio::piped());
137+
138+
let output = cmd.output().await.expect("spawn openshell");
139+
let combined = format!(
140+
"{}{}",
141+
String::from_utf8_lossy(&output.stdout),
142+
String::from_utf8_lossy(&output.stderr)
143+
);
144+
let clean = strip_ansi(&combined);
145+
146+
assert_eq!(
147+
output.status.code().unwrap_or(-1),
148+
0,
149+
"doctor check should auto-discover Podman socket:\n{clean}"
150+
);
151+
}
152+
153+
// -------------------------------------------------------------------
154+
// CONTAINER_HOST fallback — Podman convention
155+
// -------------------------------------------------------------------
156+
157+
/// `openshell doctor check` should respect CONTAINER_HOST when DOCKER_HOST
158+
/// is not set.
159+
#[tokio::test]
160+
async fn doctor_check_respects_container_host() {
161+
let Some(socket) = podman_socket().await else {
162+
eprintln!("SKIP: Podman not available");
163+
return;
164+
};
165+
166+
let tmpdir = tempfile::tempdir().expect("create config dir");
167+
let mut cmd = openshell_cmd();
168+
cmd.args(["doctor", "check"])
169+
.env_remove("DOCKER_HOST")
170+
.env("CONTAINER_HOST", format!("unix://{socket}"))
171+
.env("XDG_CONFIG_HOME", tmpdir.path())
172+
.env("HOME", tmpdir.path())
173+
.env_remove("OPENSHELL_GATEWAY")
174+
.stdout(Stdio::piped())
175+
.stderr(Stdio::piped());
176+
177+
let output = cmd.output().await.expect("spawn openshell");
178+
let combined = format!(
179+
"{}{}",
180+
String::from_utf8_lossy(&output.stdout),
181+
String::from_utf8_lossy(&output.stderr)
182+
);
183+
let clean = strip_ansi(&combined);
184+
185+
assert_eq!(
186+
output.status.code().unwrap_or(-1),
187+
0,
188+
"doctor check should work with CONTAINER_HOST:\n{clean}"
189+
);
190+
}
191+
192+
// -------------------------------------------------------------------
193+
// Gateway lifecycle — start, verify, destroy with Podman
194+
// -------------------------------------------------------------------
195+
196+
/// Full gateway lifecycle: start → status → destroy using Podman.
197+
/// This is the core E2E test that validates k3s runs correctly under Podman
198+
/// with the KubeletInUserNamespace and cgroups-per-qos flags.
199+
#[tokio::test]
200+
async fn gateway_lifecycle_with_podman() {
201+
let Some(socket) = podman_socket().await else {
202+
eprintln!("SKIP: Podman not available");
203+
return;
204+
};
205+
206+
let gw_name = "podman-e2e-test";
207+
208+
// Use a shared config dir so gateway metadata persists across commands
209+
let config_dir = tempfile::tempdir().expect("create shared config dir");
210+
let cfg = config_dir.path();
211+
212+
// Clean up any leftover from a previous run
213+
let _ = run_with_podman_config(&["gateway", "destroy", "-g", gw_name], &socket, cfg).await;
214+
215+
// Start gateway
216+
let (output, code) =
217+
run_with_podman_config(&["gateway", "start", "--name", gw_name], &socket, cfg).await;
218+
let clean = strip_ansi(&output);
219+
220+
if code != 0 {
221+
let _ =
222+
run_with_podman_config(&["gateway", "destroy", "-g", gw_name], &socket, cfg).await;
223+
panic!("gateway start failed with Podman:\n{clean}");
224+
}
225+
226+
// Verify gateway is healthy
227+
let (status_output, status_code) =
228+
run_with_podman_config(&["status", "-g", gw_name], &socket, cfg).await;
229+
let status_clean = strip_ansi(&status_output);
230+
231+
// Destroy gateway (always, even if status check fails)
232+
let _ = run_with_podman_config(&["gateway", "destroy", "-g", gw_name], &socket, cfg).await;
233+
234+
assert_eq!(
235+
status_code, 0,
236+
"gateway status should succeed:\n{status_clean}"
237+
);
238+
assert!(
239+
status_clean.contains("Connected"),
240+
"gateway should be connected:\n{status_clean}"
241+
);
242+
}

0 commit comments

Comments
 (0)