use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
use std::time::{Duration, Instant};
use super::super::config::{
devshell_repo_root_from_path, devshell_repo_root_with_containerfile,
ENV_DEVSHELL_VM_CONTAINER_IMAGE, ENV_DEVSHELL_VM_LINUX_BINARY,
ENV_DEVSHELL_VM_PULL_TIMEOUT_SECS, ENV_DEVSHELL_VM_REPO_ROOT,
ENV_DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP, ENV_DEVSHELL_VM_STDIO_TRANSPORT,
};
use super::super::VmError;
use super::WindowsStdioTransport;
mod bootstrap;
use bootstrap::{
podman_command, podman_not_available_error, podman_version_succeeds,
try_install_podman_via_winget, try_podman_engine_ready,
};
const MSG_PODMAN_INSTALL: &str = "\
dev_shell (beta VM): Podman is not available or not on PATH.
Try in order:
1) Install: winget install -e --id Podman.Podman
2) Verify: podman version
3) If needed: podman machine start
4) Docs: https://podman.io/getting-started/installation
5) Host-only: set DEVSHELL_VM_BACKEND=host (no VM sidecar)";
pub(super) fn windows_host_path_to_vm_mnt(host: &Path) -> Option<String> {
let s = host.to_str()?;
let norm = s.trim_start_matches(r"\\?\").replace('\\', "/");
if norm.len() < 2 {
return None;
}
let b = norm.as_bytes();
if b[1] != b':' {
return None;
}
let drive = norm.chars().next()?.to_ascii_lowercase();
let rest = &norm[2..];
let rest = rest.trim_start_matches('/');
Some(format!("/mnt/{drive}/{rest}"))
}
fn push_repo_candidate(out: &mut Vec<PathBuf>, p: PathBuf) {
if out.iter().any(|x| x == &p) {
return;
}
out.push(p);
}
fn workspace_parent_is_ephemeral_export(host_workspace: &Path) -> bool {
host_workspace
.to_string_lossy()
.to_ascii_lowercase()
.contains("cargo-devshell-exports")
}
fn default_container_image() -> String {
std::env::var(ENV_DEVSHELL_VM_CONTAINER_IMAGE)
.ok()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| {
format!(
"ghcr.io/tangcan/xtask_todo/devshell-vm:v{}",
env!("CARGO_PKG_VERSION")
)
})
}
enum StdioTransportPref {
Auto,
MachineSsh,
PodmanRun,
}
fn stdio_transport_pref() -> StdioTransportPref {
match std::env::var(ENV_DEVSHELL_VM_STDIO_TRANSPORT) {
Ok(s) if s.trim().eq_ignore_ascii_case("machine-ssh") => StdioTransportPref::MachineSsh,
Ok(s) if s.trim().eq_ignore_ascii_case("podman-run") => StdioTransportPref::PodmanRun,
_ => StdioTransportPref::Auto,
}
}
fn find_host_elf_in_repos(workspace_root: &Path) -> Option<PathBuf> {
let mut repos: Vec<PathBuf> = Vec::new();
if let Some(p) = devshell_repo_root_with_containerfile() {
push_repo_candidate(&mut repos, p);
}
if let Ok(s) = std::env::var(ENV_DEVSHELL_VM_REPO_ROOT) {
let p = PathBuf::from(s.trim());
if p.is_dir() {
push_repo_candidate(&mut repos, p);
}
}
if let Some(p) = devshell_repo_root_from_path(workspace_root) {
push_repo_candidate(&mut repos, p);
}
if !workspace_parent_is_ephemeral_export(workspace_root) {
push_repo_candidate(&mut repos, workspace_root.to_path_buf());
}
let rel = Path::new("target/x86_64-unknown-linux-gnu/release/devshell-vm");
for wr in &repos {
let p = wr.join(rel);
if p.is_file() {
return Some(p);
}
}
None
}
fn machine_ssh_unavailable_err(workspace_root: &Path) -> VmError {
let rel = Path::new("target/x86_64-unknown-linux-gnu/release/devshell-vm");
let show = find_host_elf_in_repos(workspace_root)
.map(|p| p.display().to_string())
.unwrap_or_else(|| workspace_root.join(rel).display().to_string());
VmError::Ipc(format!(
"DEVSHELL_VM_STDIO_TRANSPORT=machine-ssh but no Linux devshell-vm ELF found (tried {show}).\n\
Build: rustup target add x86_64-unknown-linux-gnu && cargo build -p devshell-vm --release --target x86_64-unknown-linux-gnu\n\
Or unset {} to use automatic OCI image fallback, or set {}.",
ENV_DEVSHELL_VM_STDIO_TRANSPORT,
ENV_DEVSHELL_VM_LINUX_BINARY
))
}
pub(super) fn resolve_stdio_transport(
workspace_root: &Path,
) -> Result<WindowsStdioTransport, VmError> {
if let Ok(s) = std::env::var(ENV_DEVSHELL_VM_LINUX_BINARY) {
let t = s.trim();
if !t.is_empty() {
let p = PathBuf::from(t);
if p.is_file() {
return Ok(WindowsStdioTransport::MachineSsh { host_bin: p });
}
return Err(VmError::Ipc(format!(
"{} points to a missing file: {}",
ENV_DEVSHELL_VM_LINUX_BINARY,
p.display()
)));
}
}
match stdio_transport_pref() {
StdioTransportPref::MachineSsh => find_host_elf_in_repos(workspace_root)
.map(|host_bin| WindowsStdioTransport::MachineSsh { host_bin })
.ok_or_else(|| machine_ssh_unavailable_err(workspace_root)),
StdioTransportPref::PodmanRun => Ok(WindowsStdioTransport::PodmanRun {
image: default_container_image(),
}),
StdioTransportPref::Auto => Ok(
if let Some(host_bin) = find_host_elf_in_repos(workspace_root) {
WindowsStdioTransport::MachineSsh { host_bin }
} else {
WindowsStdioTransport::PodmanRun {
image: default_container_image(),
}
},
),
}
}
pub(super) fn stdio_guest_mount(workspace_parent: &Path) -> String {
match resolve_stdio_transport(workspace_parent) {
Ok(WindowsStdioTransport::MachineSsh { .. }) => {
if let Ok(staging) = std::fs::canonicalize(workspace_parent) {
if let Some(m) = windows_host_path_to_vm_mnt(&staging) {
return m;
}
}
"/workspace".to_string()
}
Ok(WindowsStdioTransport::PodmanRun { .. }) => "/workspace".to_string(),
Err(_) => "/workspace".to_string(),
}
}
fn podman_pull(image: &str) -> Result<(), VmError> {
let timeout_secs = podman_pull_timeout_secs();
let first = podman_pull_with_timeout(image, timeout_secs)?;
if matches!(first, PullResult::Success) {
return Ok(());
}
if let Some(mirror_image) = image.strip_prefix("ghcr.io/").map(|rest| {
format!("ghcr.nju.edu.cn/{rest}")
}) {
eprintln!(
"dev_shell: podman pull {image} failed; retrying once with mirror: {mirror_image}"
);
let retry = podman_pull_with_timeout(&mirror_image, timeout_secs)?;
if matches!(retry, PullResult::Success) {
eprintln!("dev_shell: tagging mirror image as {image} for podman run");
let tag = podman_command()
.args(["tag", &mirror_image, image])
.status()
.map_err(|e| {
VmError::Ipc(format!(
"podman pull {mirror_image} succeeded but `podman tag {mirror_image} {image}` failed: {e}\n{MSG_PODMAN_INSTALL}"
))
})?;
if tag.success() {
return Ok(());
}
return Err(VmError::Ipc(format!(
"podman pull {mirror_image} succeeded but `podman tag {mirror_image} {image}` did not succeed.\n{MSG_PODMAN_INSTALL}"
)));
}
}
Err(VmError::Ipc(format!(
"podman pull {image} failed (offline, timeout, auth, or image not published).\n\
Retry policy: if image starts with ghcr.io/, we retry once via ghcr.nju.edu.cn.\n\
Pull timeout: {} seconds per attempt (override with {}).\n\
Options: set {} to a Linux devshell-vm ELF path, or {} to an image you can pull, or {}=machine-ssh with a built ELF.\n\
GHCR tags vs crates.io version: see docs/devshell-vm-oci-release.md",
timeout_secs,
ENV_DEVSHELL_VM_PULL_TIMEOUT_SECS,
ENV_DEVSHELL_VM_LINUX_BINARY,
ENV_DEVSHELL_VM_CONTAINER_IMAGE,
ENV_DEVSHELL_VM_STDIO_TRANSPORT
)))
}
const DEFAULT_PODMAN_PULL_TIMEOUT_SECS: u64 = 180;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PullResult {
Success,
FailedOrTimedOut,
}
fn podman_pull_timeout_secs() -> u64 {
std::env::var(ENV_DEVSHELL_VM_PULL_TIMEOUT_SECS)
.ok()
.and_then(|s| s.trim().parse::<u64>().ok())
.filter(|v| *v > 0)
.unwrap_or(DEFAULT_PODMAN_PULL_TIMEOUT_SECS)
}
fn wait_child_with_timeout(
child: &mut std::process::Child,
timeout: Duration,
what: &str,
) -> Result<Option<ExitStatus>, VmError> {
let start = Instant::now();
loop {
if let Some(st) = child
.try_wait()
.map_err(|e| VmError::Ipc(format!("{what}: try_wait failed: {e}")))?
{
return Ok(Some(st));
}
if start.elapsed() >= timeout {
let _ = child.kill();
let _ = child.wait();
return Ok(None);
}
std::thread::sleep(Duration::from_millis(200));
}
}
fn podman_pull_with_timeout(image: &str, timeout_secs: u64) -> Result<PullResult, VmError> {
let mut child = podman_command()
.args(["pull", image])
.spawn()
.map_err(|e| VmError::Ipc(format!("podman pull {image}: {e}\n{MSG_PODMAN_INSTALL}")))?;
let timeout = Duration::from_secs(timeout_secs);
let st = wait_child_with_timeout(&mut child, timeout, &format!("podman pull {image}"))?;
match st {
Some(status) if status.success() => Ok(PullResult::Success),
Some(_) => Ok(PullResult::FailedOrTimedOut),
None => {
eprintln!(
"dev_shell: podman pull {image} timed out after {timeout_secs}s; terminating and continuing fallback logic"
);
Ok(PullResult::FailedOrTimedOut)
}
}
}
fn spawn_machine_ssh_elf(host_bin: &Path) -> Result<std::process::Child, VmError> {
let host_bin = host_bin
.canonicalize()
.map_err(|e| VmError::Ipc(format!("canonicalize {}: {e}", host_bin.display())))?;
let vm_path = windows_host_path_to_vm_mnt(&host_bin).ok_or_else(|| {
VmError::Ipc(format!(
"could not map host path {} to a /mnt/... path inside Podman Machine",
host_bin.display()
))
})?;
let escaped = vm_path.replace('\'', "'\"'\"'");
let script = format!("exec '{escaped}' --serve-stdio");
let mut cmd = podman_command();
cmd.args(["machine", "ssh", "-T", "--", "sh", "-c", &script]);
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::inherit());
cmd.spawn()
.map_err(|e| VmError::Ipc(format!("podman machine ssh: {e}\n{MSG_PODMAN_INSTALL}")))
}
fn spawn_podman_run_stdio(
workspace_root: &Path,
image: &str,
) -> Result<std::process::Child, VmError> {
std::fs::create_dir_all(workspace_root).map_err(|e| {
VmError::Ipc(format!(
"create workspace dir {} for podman run: {e}",
workspace_root.display()
))
})?;
let ws = workspace_root.canonicalize().map_err(|e| {
VmError::Ipc(format!(
"canonicalize workspace {} for podman run: {e}",
workspace_root.display()
))
})?;
let ws_s = ws
.to_str()
.ok_or_else(|| VmError::Ipc("workspace path is not valid UTF-8 for podman -v".into()))?;
let mut cmd = podman_command();
cmd.arg("run");
cmd.arg("--rm");
cmd.arg("-i");
cmd.arg("--volume");
cmd.arg(format!("{ws_s}:/workspace:Z"));
cmd.arg("--workdir");
cmd.arg("/workspace");
cmd.arg(image);
cmd.arg("--serve-stdio");
cmd.stdin(Stdio::piped());
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::inherit());
cmd.spawn().map_err(|e| {
VmError::Ipc(format!(
"podman run (β OCI stdio): {e}\n{MSG_PODMAN_INSTALL}"
))
})
}
pub(super) fn ensure(workspace_parent: &Path) -> Result<(), VmError> {
if std::env::var(ENV_DEVSHELL_VM_SKIP_PODMAN_BOOTSTRAP).is_ok() {
return Ok(());
}
if !podman_version_succeeds() {
try_install_podman_via_winget();
}
if !podman_version_succeeds() {
return Err(podman_not_available_error());
}
try_podman_engine_ready();
match resolve_stdio_transport(workspace_parent)? {
WindowsStdioTransport::MachineSsh { .. } => Ok(()),
WindowsStdioTransport::PodmanRun { image } => {
eprintln!("dev_shell: β VM using OCI image (automatic fallback; no host devshell-vm ELF): {image}");
podman_pull(&image)
}
}
}
pub(super) fn spawn_devshell_vm_stdio(
workspace_root: &Path,
) -> Result<std::process::Child, VmError> {
match resolve_stdio_transport(workspace_root)? {
WindowsStdioTransport::MachineSsh { host_bin } => spawn_machine_ssh_elf(&host_bin),
WindowsStdioTransport::PodmanRun { image } => {
spawn_podman_run_stdio(workspace_root, &image)
}
}
}