дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
feat(remote): reopen a local project in its dev container (#1843)
Refs #1083 (step B). A local project with a devcontainer.json can be reopened in its dev container (File › Reopen in Container, Find Action): the Dev Containers CLI brings the container up and the window connects to openidea-server inside it, as for WSL, so terminal, language servers, run, debug and git work in the container. The project's identity stays the local folder, so reopening from Recent Projects finds the same container. Missing CLI gives install guidance; trust of the local project carries over. Pure CLI output parsing lives in ide-remote; a failed connection no longer closes the current backend early, saved locations keep the container configuration, and Docker context precedence follows the CLI. Co-authored-by: andreabasile <[email protected]>
Цей коміт міститься в:
1 джерело
7479b7ad9d
коміт
60d7dcdca0
29 files changed
+908
-69
No files matched your search
@@ -22,6 +22,7 @@ mod page_swipe;
|
||||
mod plugin_web;
|
||||
#[cfg(target_os = "macos")]
|
||||
mod relaunch;
|
||||
mod remote_container;
|
||||
mod remote_dev;
|
||||
mod remote_wsl;
|
||||
|
||||
|
||||
@@ -223,6 +223,7 @@ pub fn spec(os: Os) -> Vec<Entry> {
|
||||
act("file.renameProject", "Rename Project…"),
|
||||
SEP,
|
||||
act("remote.connect", "Remote Development…"),
|
||||
act("remote.reopenInContainer", "Reopen in Container"),
|
||||
act("remote.disconnect", "Disconnect from Remote Host"),
|
||||
act("remote.reloadEnvironment", "Reload Environment"),
|
||||
SEP,
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
//! 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`.
|
||||
|
||||
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::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 and the project folder in the container.
|
||||
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)> {
|
||||
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)?;
|
||||
Ok((peer, up.folder))
|
||||
}
|
||||
|
||||
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::*;
|
||||
|
||||
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");
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,9 @@ pub enum RemoteTarget {
|
||||
Ssh { config: String },
|
||||
/// A WSL distribution on this machine.
|
||||
Wsl { distro: String },
|
||||
/// The dev container of local folder `folder`, built and started with the Dev Containers CLI;
|
||||
/// `config` is its `devcontainer.json`, relative to the folder (empty = the default one).
|
||||
DevContainer { folder: String, config: String },
|
||||
}
|
||||
|
||||
/// Where the server is installed on the remote host (per version).
|
||||
@@ -66,7 +69,7 @@ pub struct Connection {
|
||||
/// connection with its parent).
|
||||
channels: Channels,
|
||||
next_channel: std::sync::atomic::AtomicU64,
|
||||
/// Keeps the SSH connection alive; `None` for WSL.
|
||||
/// Keeps the SSH connection alive; `None` for WSL and dev containers.
|
||||
session: Option<Arc<ide_ssh::Session>>,
|
||||
/// Remote ports forwarded to this machine (Ports tool window), by remote port.
|
||||
forwards: Mutex<Vec<(u16, ide_ssh::Tunnel)>>,
|
||||
@@ -110,7 +113,11 @@ impl Connections {
|
||||
self.0
|
||||
.lock()
|
||||
.iter()
|
||||
.find(|(_, c)| c.target == *target && c.root.trim_end_matches('/') == root)
|
||||
// A dev container is one project: its folder there is only known once connected.
|
||||
.find(|(_, c)| {
|
||||
c.target == *target
|
||||
&& (matches!(target, RemoteTarget::DevContainer { .. }) || c.root.trim_end_matches('/') == root)
|
||||
})
|
||||
.map(|(label, _)| label.clone())
|
||||
}
|
||||
|
||||
@@ -131,6 +138,8 @@ impl Connections {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum RemoteStage {
|
||||
Connecting,
|
||||
/// `devcontainer up`: building or starting the container.
|
||||
Container,
|
||||
Checking,
|
||||
Downloading,
|
||||
Uploading,
|
||||
@@ -403,15 +412,16 @@ fn build_id(path: &std::path::Path) -> IdeResult<String> {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// File › Remote Development: opens `path` on `target` (an SSH host or a WSL distribution)
|
||||
/// in this window (installs / starts the server as needed).
|
||||
/// File › Remote Development: opens `path` on `target` (an SSH host, a WSL distribution, or a
|
||||
/// dev container, whose project folder is the container's own) in this window (installs /
|
||||
/// starts the server as needed).
|
||||
#[tauri::command]
|
||||
#[specta::specta]
|
||||
pub async fn remote_connect(
|
||||
window: Window,
|
||||
state: State<'_, AppState>,
|
||||
target: RemoteTarget,
|
||||
path: String,
|
||||
mut path: String,
|
||||
on_progress: Channel<RemoteProgress>,
|
||||
) -> IdeResult<RemoteProject> {
|
||||
let progress: Arc<Progress> = Arc::new(move |stage, fraction| {
|
||||
@@ -419,8 +429,6 @@ pub async fn remote_connect(
|
||||
});
|
||||
progress(RemoteStage::Connecting, None);
|
||||
let label = AppState::owner_window(window.label()).to_owned();
|
||||
state.remote_dev.disconnect(&label);
|
||||
state.host.close_project(&label);
|
||||
let channels: Channels = Arc::default();
|
||||
let handler =
|
||||
Arc::new(ClientHandler { app: window.app_handle().clone(), window: label.clone(), channels: channels.clone() });
|
||||
@@ -444,6 +452,15 @@ pub async fn remote_connect(
|
||||
let peer = crate::remote_wsl::spawn(distro, &bin, handler, &tail)?;
|
||||
(peer, None, distro.clone(), format!("WSL: {distro}"))
|
||||
}
|
||||
RemoteTarget::DevContainer { folder, config } => {
|
||||
let (peer, root) =
|
||||
crate::remote_container::connect(&state, folder, config, handler, &tail, &progress).await?;
|
||||
path = root;
|
||||
let name = std::path::Path::new(folder)
|
||||
.file_name()
|
||||
.map_or_else(|| folder.clone(), |n| n.to_string_lossy().into_owned());
|
||||
(peer, None, name.clone(), format!("Dev Container: {name}"))
|
||||
}
|
||||
};
|
||||
// A server that dies on start-up says why on stderr (`wsl.exe` prints its own errors too).
|
||||
let with_output = |e: IdeError| match tail.text() {
|
||||
@@ -490,6 +507,8 @@ pub async fn remote_connect(
|
||||
session,
|
||||
forwards: Mutex::new(Vec::new()),
|
||||
});
|
||||
state.remote_dev.disconnect(&label);
|
||||
state.host.close_project(&label);
|
||||
state.remote_dev.0.lock().insert(label.clone(), connection.clone());
|
||||
let plugins = state.plugins.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -767,4 +786,12 @@ mod tests {
|
||||
assert_eq!(platform("Darwin arm64"), ("macos".into(), "aarch64".into()));
|
||||
assert_eq!(platform("Linux aarch64"), ("linux".into(), "aarch64".into()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dev_container_target_is_its_local_folder_and_configuration() {
|
||||
let target = RemoteTarget::DevContainer { folder: "/Users/me/app".into(), config: String::new() };
|
||||
let json = serde_json::to_value(&target).unwrap();
|
||||
assert_eq!(json, serde_json::json!({ "kind": "devContainer", "folder": "/Users/me/app", "config": "" }));
|
||||
assert_eq!(serde_json::from_value::<RemoteTarget>(json).unwrap(), target);
|
||||
}
|
||||
}
|
||||
@@ -71,8 +71,15 @@ async fn output(distro: &str, mut cmd: tokio::process::Command, limit: Duration)
|
||||
if out.status.success() { Ok(out) } else { Err(failure(distro, &out)) }
|
||||
}
|
||||
|
||||
/// Prints [`PROBE_MARK`], `$HOME`, `uname -sm` and the installed builds of version `$1`.
|
||||
pub(crate) fn probe_script() -> String {
|
||||
format!(
|
||||
r#"echo {PROBE_MARK}; echo "$HOME"; uname -sm; ls -d "$HOME/{SERVER_DIR}/$1"/*/{SERVER_BIN} 2>/dev/null; true"#
|
||||
)
|
||||
}
|
||||
|
||||
/// Splits the probe output (after its marker) into [`Probe`].
|
||||
fn parse_probe(text: &str) -> IdeResult<Probe> {
|
||||
pub(crate) fn parse_probe(text: &str) -> IdeResult<Probe> {
|
||||
// Between markers: `wsl.exe` may print notices to stdout before the script runs.
|
||||
let text = text.split_once(PROBE_MARK).map_or("", |(_, rest)| rest);
|
||||
let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty());
|
||||
@@ -87,10 +94,7 @@ fn parse_probe(text: &str) -> IdeResult<Probe> {
|
||||
|
||||
/// Home directory, platform and installed server builds of `distro` (starts it if stopped).
|
||||
pub(crate) async fn probe(distro: &str, version: &str) -> IdeResult<Probe> {
|
||||
let script = format!(
|
||||
r#"echo {PROBE_MARK}; echo "$HOME"; uname -sm; ls -d "$HOME/{SERVER_DIR}/$1"/*/{SERVER_BIN} 2>/dev/null; true"#
|
||||
);
|
||||
let out = output(distro, sh(distro, &script, &[version])?, PROBE_TIMEOUT).await?;
|
||||
let out = output(distro, sh(distro, &probe_script(), &[version])?, PROBE_TIMEOUT).await?;
|
||||
parse_probe(&String::from_utf8_lossy(&out.stdout))
|
||||
}
|
||||
|
||||
@@ -109,14 +113,37 @@ pub(crate) async fn install(
|
||||
progress: &Arc<Progress>,
|
||||
) -> IdeResult<String> {
|
||||
let build_dir = format!("{version_dir}/{build}");
|
||||
// Per install: another window may be installing the same build right now.
|
||||
let script = format!(
|
||||
let cmd = sh(distro, &install_script(), &[&build_dir, version_dir, build])?;
|
||||
let out = upload(cmd, "wsl.exe", data, progress)
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable(format!("installing {SERVER_BIN} in '{distro}' timed out")))??;
|
||||
if out.status.success() { Ok(format!("{build_dir}/{SERVER_BIN}")) } else { Err(failure(distro, &out)) }
|
||||
}
|
||||
|
||||
/// Reads the server from stdin into `$1/openidea-server` and removes the builds of version folder
|
||||
/// `$2` other than `$3`. Per install file: another window may be installing the same build.
|
||||
pub(crate) fn install_script() -> String {
|
||||
format!(
|
||||
r#"set -e; mkdir -p "$1"; p="$1/{SERVER_BIN}.part.$$"; trap 'rm -f "$p"' EXIT; cat > "$p"; chmod 755 "$p"; mv -f "$p" "$1/{SERVER_BIN}"; find "$2" -mindepth 1 -maxdepth 1 ! -name "$3" -exec rm -rf {{}} +"#
|
||||
);
|
||||
let mut cmd = sh(distro, &script, &[&build_dir, version_dir, build])?;
|
||||
)
|
||||
}
|
||||
|
||||
/// Runs `cmd` (an [`install_script`]) with `data` on its stdin, reporting the upload. `Err` when it
|
||||
/// timed out; else the finished command's output (or why it couldn't run), whatever its status.
|
||||
pub(crate) async fn upload(
|
||||
mut cmd: tokio::process::Command,
|
||||
program: &str,
|
||||
data: &[u8],
|
||||
progress: &Arc<Progress>,
|
||||
) -> Result<IdeResult<std::process::Output>, tokio::time::error::Elapsed> {
|
||||
cmd.stdin(Stdio::piped());
|
||||
let mut child = cmd.spawn().map_err(|e| IdeError::unavailable(format!("cannot start wsl.exe: {e}")))?;
|
||||
let mut stdin = child.stdin.take().ok_or_else(|| IdeError::internal("wsl.exe stdin unavailable"))?;
|
||||
let mut child = match cmd.spawn() {
|
||||
Ok(child) => child,
|
||||
Err(e) => return Ok(Err(IdeError::unavailable(format!("cannot start {program}: {e}")))),
|
||||
};
|
||||
let Some(mut stdin) = child.stdin.take() else {
|
||||
return Ok(Err(IdeError::internal(format!("{program} stdin unavailable"))));
|
||||
};
|
||||
// `stdin` drops at the end of the upload: that is the EOF `cat` waits for.
|
||||
let upload = async move {
|
||||
for (i, chunk) in data.chunks(UPLOAD_CHUNK).enumerate() {
|
||||
@@ -125,17 +152,16 @@ pub(crate) async fn install(
|
||||
}
|
||||
stdin.flush().await
|
||||
};
|
||||
let result = tokio::time::timeout(INSTALL_TIMEOUT, async {
|
||||
tokio::time::timeout(INSTALL_TIMEOUT, async {
|
||||
let uploaded = upload.await;
|
||||
(uploaded, child.wait_with_output().await)
|
||||
let failed = |e: std::io::Error| IdeError::unavailable(format!("installing {SERVER_BIN} failed: {e}"));
|
||||
match (uploaded, child.wait_with_output().await) {
|
||||
// Never a success without the whole binary.
|
||||
(Err(e), Ok(out)) if out.status.success() => Err(failed(e)),
|
||||
(_, out) => out.map_err(failed),
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|_| IdeError::unavailable(format!("installing {SERVER_BIN} in '{distro}' timed out")))?;
|
||||
match result {
|
||||
(Ok(()), Ok(out)) if out.status.success() => Ok(format!("{build_dir}/{SERVER_BIN}")),
|
||||
(_, Ok(out)) => Err(failure(distro, &out)),
|
||||
(_, Err(e)) => Err(IdeError::unavailable(format!("installing {SERVER_BIN} failed: {e}"))),
|
||||
}
|
||||
}
|
||||
|
||||
/// The last lines the server (or `wsl.exe`) wrote outside the protocol.
|
||||
@@ -143,7 +169,7 @@ pub(crate) async fn install(
|
||||
pub(crate) struct Tail(Arc<Mutex<VecDeque<String>>>);
|
||||
|
||||
impl Tail {
|
||||
fn push(&self, line: String) {
|
||||
pub fn push(&self, line: String) {
|
||||
let mut lines = self.0.lock();
|
||||
if lines.len() == KEPT_LINES {
|
||||
lines.pop_front();
|
||||
|
||||
@@ -112,7 +112,7 @@ pub struct FilePosition {
|
||||
pub column: u32,
|
||||
}
|
||||
|
||||
/// A project folder on an SSH host or in a WSL distribution.
|
||||
/// A project folder on an SSH host, in a WSL distribution or in a dev container.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Type)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct RemoteLaunch {
|
||||
@@ -819,7 +819,7 @@ pub enum Holds {
|
||||
distro: String,
|
||||
path: String,
|
||||
},
|
||||
/// An SSH remote project.
|
||||
/// An SSH or dev container remote project.
|
||||
Other,
|
||||
}
|
||||
|
||||
@@ -827,7 +827,7 @@ impl Holds {
|
||||
fn of_remote(remote: &RemoteLaunch) -> Self {
|
||||
match &remote.target {
|
||||
RemoteTarget::Wsl { distro } => Holds::Wsl { distro: distro.clone(), path: remote.path.clone() },
|
||||
RemoteTarget::Ssh { .. } => Holds::Other,
|
||||
RemoteTarget::Ssh { .. } | RemoteTarget::DevContainer { .. } => Holds::Other,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Заблокувати користувача