#![cfg(feature = "e2e")]
mod common;
use std::collections::BTreeMap;
use std::fs;
use std::os::unix::fs::MetadataExt;
use std::path::Path;
use std::process::{Command, Output};
use std::time::Duration;
use outrig::container::{Container, ContainerLaunchSpec};
use outrig::image::ImageTag;
use tokio::io::AsyncReadExt;
const ALPINE: &str = "docker.io/library/alpine:latest";
async fn pull_alpine() {
run_capture(Command::new("podman").arg("pull").arg(ALPINE));
}
async fn install_shadow(name: &str) {
run_capture(
Command::new("podman")
.args(["exec", "--user=0:0"])
.arg(name)
.args(["apk", "add", "--no-cache", "shadow"]),
);
}
async fn start_alpine(host_ws: &Path) -> Container {
let tag = ImageTag(ALPINE.to_string());
Container::start(
&tag,
ContainerLaunchSpec::workspace(host_ws, Path::new("/workspace")),
)
.await
.expect("start")
}
async fn read_stdout(child: &mut tokio::process::Child) -> String {
let mut out = String::new();
child
.stdout
.as_mut()
.expect("stdout was piped")
.read_to_string(&mut out)
.await
.expect("read stdout");
let status = child.wait().await.expect("wait child");
assert!(status.success(), "child exited non-zero: {status:?}");
out
}
fn run_capture(cmd: &mut Command) -> Output {
let output = cmd.output().expect("spawn command");
assert!(
output.status.success(),
"command exited non-zero: {:?}\nstderr: {}",
output.status,
String::from_utf8_lossy(&output.stderr)
);
output
}
fn try_capture(cmd: &mut Command) -> Output {
cmd.output().expect("spawn command")
}
#[tokio::test]
async fn bootstrap_then_id_matches_host() {
common::init_tracing();
pull_alpine().await;
let host_ws = tempfile::tempdir().expect("tempdir");
let mut container = start_alpine(host_ws.path()).await;
install_shadow(container.name()).await;
container.bootstrap_user().await.expect("bootstrap_user");
assert!(container.user_name().is_some());
assert!(container.group_name().is_some());
let mut child = container
.exec_stdio(&["id".to_string()], &BTreeMap::new())
.await
.expect("exec_stdio id");
let out = read_stdout(&mut child).await;
let expect_uid = format!("uid={}", container.uid());
let expect_gid = format!("gid={}", container.gid());
assert!(
out.contains(&expect_uid),
"expected `{expect_uid}` in `id` output, got: {out}"
);
assert!(
out.contains(&expect_gid),
"expected `{expect_gid}` in `id` output, got: {out}"
);
container.stop(Duration::from_secs(2)).await.expect("stop");
}
#[tokio::test]
async fn workspace_writes_have_host_ownership() {
common::init_tracing();
pull_alpine().await;
let host_ws = tempfile::tempdir().expect("tempdir");
let mut container = start_alpine(host_ws.path()).await;
install_shadow(container.name()).await;
container.bootstrap_user().await.expect("bootstrap_user");
let mut child = container
.exec_stdio(
&[
"sh".to_string(),
"-c".to_string(),
"echo hello > /workspace/test.txt".to_string(),
],
&BTreeMap::new(),
)
.await
.expect("exec_stdio sh");
let status = child.wait().await.expect("wait child");
assert!(status.success(), "writer exited non-zero: {status:?}");
let host_file = host_ws.path().join("test.txt");
let meta = fs::metadata(&host_file).expect("stat host file");
assert_eq!(
meta.uid(),
container.uid(),
"file should be owned by host UID"
);
assert_eq!(
meta.gid(),
container.gid(),
"file should be owned by host GID"
);
assert_eq!(
fs::read_to_string(&host_file).expect("read"),
"hello\n",
"file contents should round-trip via the bind-mount"
);
container.stop(Duration::from_secs(2)).await.expect("stop");
}
async fn first_name_in_db(name: &str, db: &str, id: u32) -> Option<String> {
let probe = try_capture(
Command::new("podman")
.args(["exec", "--user=0:0"])
.arg(name)
.arg("getent")
.arg(db)
.arg(id.to_string()),
);
if !probe.status.success() {
return None;
}
let stdout = String::from_utf8_lossy(&probe.stdout);
Some(stdout.lines().next()?.split(':').next()?.to_string())
}
#[tokio::test]
async fn bootstrap_reuses_existing_entry() {
common::init_tracing();
pull_alpine().await;
let host_ws = tempfile::tempdir().expect("tempdir");
let mut container = start_alpine(host_ws.path()).await;
install_shadow(container.name()).await;
let expected_grp = match first_name_in_db(container.name(), "group", container.gid()).await {
Some(existing) => existing,
None => {
let planted = "preexisting_grp";
run_capture(
Command::new("podman")
.args(["exec", "--user=0:0"])
.arg(container.name())
.arg("groupadd")
.arg("--gid")
.arg(container.gid().to_string())
.arg(planted),
);
planted.to_string()
}
};
let expected_usr = match first_name_in_db(container.name(), "passwd", container.uid()).await {
Some(existing) => existing,
None => {
let planted = "preexisting_usr";
run_capture(
Command::new("podman")
.args(["exec", "--user=0:0"])
.arg(container.name())
.arg("useradd")
.arg("-u")
.arg(container.uid().to_string())
.arg("-g")
.arg(container.gid().to_string())
.arg(planted),
);
planted.to_string()
}
};
container.bootstrap_user().await.expect("bootstrap_user");
assert_eq!(
container.group_name(),
Some(expected_grp.as_str()),
"bootstrap should reuse the pre-existing group at the host GID"
);
assert_eq!(
container.user_name(),
Some(expected_usr.as_str()),
"bootstrap should reuse the pre-existing user at the host UID"
);
container.stop(Duration::from_secs(2)).await.expect("stop");
}