Файли
openidea/src-tauri/src/remote_container.rs
T
Andrea Basileтаandreabasile 7ecb452350 feat(remote): forward ports of a dev container (#2072)
Refs #1083

A dev container's ports can be forwarded: the Ports tool window shows in a dev container window, `forwardPorts` of devcontainer.json are forwarded on open (keeping the local number when free, falling back otherwise), and ports forwarded by hand are restored per folder and configuration. Each forwarded connection is a loopback listener whose connections run `docker exec … openidea-server --connect localhost <port>` through the container's own network, so no published ports or extra tools in the image are needed. The container tunnel lives in `ide-remote::container_tunnel`; tunnels own their connections and Docker children, so stopping a forward, closing the window or losing the connection leaves no listeners or processes behind (the same ownership fix applies to SSH forwards).

Co-authored-by: andreabasile <[email protected]>
2026-11-08 06:57:19 +08:00

423 рядки
19 KiB
Rust
Неформатований Анотація Історія

Цей файл містить неоднозначні символи Юнікоду
Цей файл містить символи Юнікоду, які можна сплутати з іншими символами. Якщо ви вважаєте, що це зроблено навмисно, можете сміливо ігнорувати це попередження. Щоб показати їх, скористайтеся кнопкою Escape.
//! Dev container transport of remote development: the Dev Containers CLI (`devcontainer up`)
//! builds or starts the container of a local folder, then `openidea-server --stdio` runs in it
//! through `docker exec`, installed there the way it is in a WSL distribution.
//!
//! The container id, user and folder come from the CLI's output, are checked, and only ever
//! reach `docker exec` as separate arguments; only fixed scripts reach `sh -c`.
//!
//! A forwarded port is a local listener whose every connection runs
//! `openidea-server --connect localhost <port>` in the container and pipes through it: the
//! container's own network is reached the same way on every Docker setup.
use crate::remote_dev::{Progress, RemoteStage, SERVER_BIN};
use crate::remote_wsl::{Probe, Tail, install_script, parse_probe, probe_script, upload, version_dir};
use ide_core::{IdeError, IdeResult};
use ide_jsonrpc::{Handler, Peer};
use ide_remote::container_tunnel::Exec;
use ide_remote::devcontainer::{Up, parse_up};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::AsyncBufReadExt;
/// Building an image (and its features) can take a while on a first open.
const UP_TIMEOUT: Duration = Duration::from_secs(30 * 60);
const PROBE_TIMEOUT: Duration = Duration::from_secs(60);
/// The Dev Containers CLI: the IDE-managed copy (Settings › Tools) or one on `PATH`.
fn cli(data_dir: &Path) -> IdeResult<PathBuf> {
ide_tools::Tools::new(data_dir)
.info("devcontainer", &[])
.ok()
.and_then(|t| t.path)
.map(ide_core::IpcPath::into_path_buf)
.ok_or_else(|| {
IdeError::unavailable(
"The Dev Containers CLI is not installed. Install it in Settings › Tools › Dev Containers CLI.",
)
})
}
/// `devcontainer up` for `folder` (with configuration `config`, relative to it; empty = the
/// default one). Its log goes to `tail` and the IDE log.
async fn up(cli: &Path, docker: &Path, folder: &Path, config: &str, tail: &Tail) -> IdeResult<Up> {
let mut cmd = tokio::process::Command::new(cli);
ide_core::process::prepare(cmd.as_std_mut());
cmd.arg("up").arg("--workspace-folder").arg(folder);
if !config.trim().is_empty() {
cmd.arg("--config").arg(folder.join(config.trim()));
}
cmd.arg("--docker-path")
.arg(docker)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot start devcontainer: {e}")))?;
let stderr = child.stderr.take();
let log = tail.clone();
let reader = tokio::spawn(async move {
let Some(stderr) = stderr else { return };
let mut lines = tokio::io::BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
tracing::info!(target: "devcontainer", "{line}");
log.push(line);
}
});
let out = tokio::time::timeout(UP_TIMEOUT, child.wait_with_output())
.await
.map_err(|_| IdeError::unavailable("devcontainer up did not finish in time"))?
.map_err(|e| IdeError::unavailable(format!("devcontainer up failed: {e}")))?;
let _ = reader.await;
let result = parse_up(&String::from_utf8_lossy(&out.stdout));
if out.status.success() {
return result;
}
let error = result
.err()
.unwrap_or_else(|| IdeError::unavailable(format!("devcontainer up exited with {status}", status = out.status)));
Err(match tail.text() {
t if t.is_empty() => error,
t => IdeError::unavailable(format!("{}\n{t}", error.message)),
})
}
/// `docker exec [-i] -u <user> <container> sh -c <script> sh <args>…`.
fn sh(docker: &Path, up: &Up, script: &str, args: &[&str], input: bool) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new(docker);
ide_core::process::prepare(cmd.as_std_mut());
cmd.arg("exec");
if input {
cmd.arg("-i");
}
cmd.args(["-u", &up.user, &up.container, "sh", "-c", script, "sh"])
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
cmd
}
fn failure(out: &std::process::Output) -> IdeError {
let text = String::from_utf8_lossy(if out.stderr.is_empty() { &out.stdout } else { &out.stderr });
let text: String = text.trim().chars().take(400).collect();
IdeError::unavailable(format!("cannot run a command in the dev container: {text}"))
}
async fn probe(docker: &Path, up: &Up, version: &str) -> IdeResult<Probe> {
let out = tokio::time::timeout(PROBE_TIMEOUT, sh(docker, up, &probe_script(), &[version], false).output())
.await
.map_err(|_| IdeError::unavailable("the dev container did not answer in time"))?
.map_err(|e| IdeError::unavailable(format!("cannot start docker: {e}")))?;
if !out.status.success() {
return Err(failure(&out));
}
parse_probe(&String::from_utf8_lossy(&out.stdout))
}
/// Makes sure this version of the server is installed in the container; returns its path there.
async fn ensure_server(
state: &crate::state::AppState,
docker: &Path,
up: &Up,
progress: &Arc<Progress>,
) -> IdeResult<String> {
use crate::remote_dev::{LocalServer, local_server, platform};
progress(RemoteStage::Checking, None);
let version = env!("CARGO_PKG_VERSION");
let probe = probe(docker, up, version).await?;
let (os, arch) = platform(&probe.uname);
if os != "linux" {
return Err(IdeError::unavailable(format!("the dev container reports {os} instead of linux")));
}
let (local, build) = match local_server(state, version, &os, &arch, &probe.installed, progress.clone()).await? {
LocalServer::Installed(bin) => return Ok(bin),
LocalServer::Binary(local, build) => (local, build),
};
if let Some(bin) = probe.installed.iter().find(|b| b.ends_with(&format!("/{build}/{SERVER_BIN}"))) {
return Ok(bin.clone());
}
install(docker, up, &version_dir(&probe.home, version), &build, &local, progress).await
}
/// Copies the server at `local` into `<version_dir>/<build>/openidea-server` in the container and
/// removes the other builds of the version. Returns the installed path.
async fn install(
docker: &Path,
up: &Up,
version_dir: &str,
build: &str,
local: &Path,
progress: &Arc<Progress>,
) -> IdeResult<String> {
progress(RemoteStage::Uploading, Some(0.0));
let data = tokio::fs::read(local).await?;
let build_dir = format!("{version_dir}/{build}");
let cmd = sh(docker, up, &install_script(), &[&build_dir, version_dir, build], true);
let out = upload(cmd, "docker", &data, progress)
.await
.map_err(|_| IdeError::unavailable(format!("installing {SERVER_BIN} in the dev container timed out")))??;
if out.status.success() { Ok(format!("{build_dir}/{SERVER_BIN}")) } else { Err(failure(&out)) }
}
/// The arguments of `docker` that start the server in the container: in the project folder, told
/// that the folder is trusted and that the CLI already ran the lifecycle commands.
fn server_args(up: &Up, bin: &str) -> Vec<String> {
let mut args: Vec<String> = ["exec", "-i", "-u", &up.user, "-w", &up.folder].map(str::to_owned).into();
for var in [
format!("{}={}", ide_host::trust::TRUSTED_ROOT_ENV, up.folder),
format!("{}=1", ide_host::environment::LIFECYCLE_DONE_ENV),
] {
args.extend(["-e".to_owned(), var]);
}
args.extend([up.container.clone(), bin.to_owned(), "--stdio".to_owned()]);
args
}
/// Opens the dev container of local `folder`: starts it with the CLI, installs and starts the
/// server in it. Returns the connection, the project folder in the container and how to run the
/// server there.
pub(crate) async fn connect(
state: &crate::state::AppState,
folder: &str,
config: &str,
handler: Arc<dyn Handler>,
tail: &Tail,
progress: &Arc<Progress>,
) -> IdeResult<(Peer, String, Exec)> {
let local = PathBuf::from(folder);
// `devcontainer up` runs the project's code on this machine (`initializeCommand`, the build).
if !state.host.trust.trusted(&local).unwrap_or(false) {
return Err(IdeError::unavailable(format!(
"Trust {} before reopening it in a container: building it runs the project's commands.",
local.display()
)));
}
if ide_workspace::devcontainer::find(&local).is_none() && config.trim().is_empty() {
return Err(IdeError::not_found(format!("{} has no devcontainer.json", local.display())));
}
let docker = ide_docker::docker_executable().ok_or_else(|| IdeError::unavailable("Docker is not installed"))?;
progress(RemoteStage::Container, None);
// The folder is bind-mounted: the daemon has to see this machine's files.
let is_local = tokio::task::spawn_blocking(|| ide_docker::endpoint_is_local(&ide_docker::DockerEndpoint::Default))
.await
.map_err(|e| IdeError::internal(e.to_string()))??;
if !is_local {
return Err(IdeError::unavailable(
"The Docker daemon of the current context is not on this machine; dev containers need a local one.",
));
}
let data = state.host.local_data_dir.clone();
let cli =
tokio::task::spawn_blocking(move || cli(&data)).await.map_err(|e| IdeError::internal(e.to_string()))??;
let up = up(&cli, &docker, &local, config, tail).await?;
let bin = ensure_server(state, &docker, &up, progress).await?;
progress(RemoteStage::Starting, None);
let peer = spawn(&docker, &up, &bin, handler, tail)?;
let folder = up.folder.clone();
Ok((peer, folder, Exec::new(docker, up, bin)))
}
fn spawn(docker: &Path, up: &Up, bin: &str, handler: Arc<dyn Handler>, tail: &Tail) -> IdeResult<Peer> {
let tail = tail.clone();
let program = docker.to_string_lossy();
Peer::spawn(&program, &server_args(up, bin), &std::env::temp_dir(), &[], handler, Box::new(move |l| tail.push(l)))
.map_err(|e| IdeError::unavailable(format!("cannot start docker: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
use ide_remote::container_tunnel::Tunnel;
const ID: &str = "3f1c2b9a7d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8";
#[test]
fn the_server_starts_in_the_folder_trusted_and_without_the_lifecycle() {
let up = Up { container: ID.into(), user: "vscode".into(), folder: "/workspaces/my app".into() };
let args = server_args(&up, "/home/vscode/.openidea/server/1/ab/openidea-server");
assert_eq!(
args,
[
"exec",
"-i",
"-u",
"vscode",
"-w",
"/workspaces/my app",
"-e",
"OPENIDEA_TRUSTED_ROOT=/workspaces/my app",
"-e",
"OPENIDEA_LIFECYCLE_DONE=1",
ID,
"/home/vscode/.openidea/server/1/ab/openidea-server",
"--stdio",
]
);
}
/// A stand-in for the Dev Containers CLI that checks its arguments and answers like the real one.
#[cfg(unix)]
#[tokio::test]
async fn runs_up_for_the_folder_and_reads_its_result() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let folder = dir.path().join("my app");
let cli = dir.path().join("devcontainer");
let script = format!(
r#"#!/bin/sh
[ "$1 $2 $3" = "up --workspace-folder {folder}" ] || {{ echo "unexpected arguments: $*" >&2; exit 2; }}
[ "$4 $5" = "--config {folder}/.devcontainer/python/devcontainer.json" ] || {{ echo "no config: $*" >&2; exit 2; }}
[ "$6 $7" = "--docker-path /usr/bin/docker" ] || {{ echo "no docker path: $*" >&2; exit 2; }}
echo "[1 ms] building" >&2
echo '{{"outcome":"success","containerId":"{ID}","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/my app"}}'
"#,
folder = folder.display()
);
std::fs::write(&cli, script).unwrap();
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
let tail = Tail::default();
let config = ".devcontainer/python/devcontainer.json";
let up = up(&cli, Path::new("/usr/bin/docker"), &folder, config, &tail).await.unwrap_or_else(|e| {
panic!("{e:?}; the CLI said: {}", tail.text());
});
assert_eq!(up.folder, "/workspaces/my app");
assert_eq!(tail.text(), "[1 ms] building");
}
#[cfg(unix)]
#[tokio::test]
async fn a_failed_up_shows_the_cli_log() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let cli = dir.path().join("devcontainer");
std::fs::write(&cli, "#!/bin/sh\necho 'Error: Dockerfile not found' >&2\nexit 1\n").unwrap();
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
let err = up(&cli, Path::new("docker"), dir.path(), "", &Tail::default()).await.unwrap_err();
assert!(err.message.contains("Dockerfile not found"), "{}", err.message);
}
#[cfg(unix)]
#[tokio::test]
async fn a_failed_exit_never_connects_even_with_a_success_result() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let cli = dir.path().join("devcontainer");
let output =
format!(r#"{{"outcome":"success","containerId":"{ID}","remoteUser":"root","remoteWorkspaceFolder":"/w"}}"#);
std::fs::write(&cli, format!("#!/bin/sh\ncat <<'RESULT'\n{output}\nRESULT\nexit 1\n")).unwrap();
std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o755)).unwrap();
let err = up(&cli, Path::new("docker"), dir.path(), "", &Tail::default()).await.unwrap_err();
assert!(err.message.contains("exited with"), "{}", err.message);
}
struct Silent;
impl Handler for Silent {
fn on_notification(&self, _: &str, _: serde_json::Value) {}
fn on_request(&self, method: &str, _: serde_json::Value) -> Result<serde_json::Value, (i64, String)> {
Err((ide_jsonrpc::codes::METHOD_NOT_FOUND, method.to_owned()))
}
}
fn docker_sh(docker: &Path, container: &str, script: &str) -> String {
let out = std::process::Command::new(docker).args(["exec", container, "sh", "-c", script]).output().unwrap();
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
/// Against a real daemon and the real Dev Containers CLI (`devcontainer` on `PATH`).
/// `OPENIDEA_DOCKER_TEST_DIR` names a folder the daemon can bind-mount at the same path, and
/// `OPENIDEA_SERVER_BIN` an `openidea-server` built for the container's platform.
#[tokio::test]
#[ignore = "requires Docker, the Dev Containers CLI, OPENIDEA_DOCKER_TEST_DIR and OPENIDEA_SERVER_BIN"]
async fn opens_a_real_dev_container() {
let base = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR"));
let server = PathBuf::from(std::env::var("OPENIDEA_SERVER_BIN").expect("OPENIDEA_SERVER_BIN"));
let root = base.join("devcontainer");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(root.join(".devcontainer")).unwrap();
std::fs::write(
root.join(".devcontainer/devcontainer.json"),
r#"{
// The CLI runs it once; the server must not run it again.
"image": "debian:bookworm-slim",
"postStartCommand": "date +%s%N >> /tmp/post-start",
"remoteEnv": { "FROM_DEVCONTAINER": "yes" }
}"#,
)
.unwrap();
std::fs::write(root.join("main.py"), "print('hello')\n").unwrap();
let docker = ide_docker::docker_executable().expect("docker");
let tail = Tail::default();
let up = up(Path::new("devcontainer"), &docker, &root, "", &tail)
.await
.unwrap_or_else(|e| panic!("{e:?}; the CLI said: {}", tail.text()));
assert_eq!(up.folder, "/workspaces/devcontainer");
assert_eq!(up.user, "root");
let found = probe(&docker, &up, "0.0.0-test").await.unwrap();
assert_eq!(platform_os(&found.uname), "linux");
assert!(found.installed.is_empty());
let progress: Arc<Progress> = Arc::new(|_, _| {});
let dir = version_dir(&found.home, "0.0.0-test");
let bin = install(&docker, &up, &dir, "b1", &server, &progress).await.unwrap();
assert_eq!(probe(&docker, &up, "0.0.0-test").await.unwrap().installed, std::slice::from_ref(&bin));
let peer = spawn(&docker, &up, &bin, Arc::new(Silent), &tail).unwrap();
let info: ide_host::rpc::ServerInfo = peer
.request("initialize", serde_json::json!({ "version": ide_host::rpc::PROTOCOL_VERSION }), None)
.await
.unwrap_or_else(|e| panic!("{e:?}; the server said: {}", tail.text()));
assert_eq!(info.os, "linux");
let invoke =
|cmd: &str, args: serde_json::Value| ide_host::rpc::Invoke { window: "main".into(), cmd: cmd.into(), args };
let project: serde_json::Value = peer
.request("invoke", invoke("open_project", serde_json::json!({ "path": up.folder })), None)
.await
.unwrap_or_else(|e| panic!("{e:?}; the server said: {}", tail.text()));
assert_eq!(project["root"], "/workspaces/devcontainer");
assert_eq!(project["trusted"], true, "the trust of the local folder is inherited");
let files: serde_json::Value = peer
.request("invoke", invoke("fs_read_dir", serde_json::json!({ "path": up.folder })), None)
.await
.unwrap_or_else(|e| panic!("{e:?}; the server said: {}", tail.text()));
assert!(files.to_string().contains("main.py"), "the project is the local folder: {files}");
tokio::time::sleep(Duration::from_secs(2)).await;
assert_eq!(docker_sh(&docker, &up.container, "wc -l < /tmp/post-start"), "1", "postStartCommand ran once");
// A port only the container's own network reaches (perl is in every Debian image).
let listen = "perl -MIO::Socket::INET -e '$s = IO::Socket::INET->new(LocalAddr => \"127.0.0.1:8000\", \
Listen => 5, ReuseAddr => 1) or die; while ($c = $s->accept) { print $c \"from the container\\n\"; close $c }'";
let started = std::process::Command::new(&docker)
.args(["exec", "-d", &up.container, "sh", "-c", listen])
.status()
.unwrap();
assert!(started.success());
let exec = Arc::new(Exec::new(
docker.clone(),
Up { container: up.container.clone(), user: up.user.clone(), folder: up.folder.clone() },
bin.clone(),
));
let tunnel = Tunnel::listen(exec, 0, 8000).await.unwrap();
let mut reply = String::new();
for _ in 0..50 {
use tokio::io::AsyncReadExt;
let mut socket = tokio::net::TcpStream::connect(("127.0.0.1", tunnel.port())).await.unwrap();
reply.clear();
let _ = socket.read_to_string(&mut reply).await;
if !reply.is_empty() {
break;
}
tokio::time::sleep(Duration::from_millis(200)).await;
}
assert_eq!(reply, "from the container\n");
drop(tunnel);
drop(peer);
let removed = std::process::Command::new(&docker).args(["rm", "-f", &up.container]).output().unwrap();
assert!(removed.status.success());
std::fs::remove_dir_all(&root).unwrap();
}
fn platform_os(uname: &str) -> String {
crate::remote_dev::platform(uname).0
}
}