diff --git a/crates/ide-docker/src/lib.rs b/crates/ide-docker/src/lib.rs index 8eb638e24..6a254aa88 100644 --- a/crates/ide-docker/src/lib.rs +++ b/crates/ide-docker/src/lib.rs @@ -443,7 +443,7 @@ pub fn compose_op( /// Whether `ep` is a daemon on this machine, where a bind mount of the project /// is this machine's folder. The CLI's default daemon and a context are looked -/// up (`DOCKER_HOST`, else the context's endpoint). +/// up (`DOCKER_CONTEXT`, then `DOCKER_HOST`, else the current context). pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult { let url = match ep { DockerEndpoint::Socket { path } => path.trim().to_owned(), @@ -451,17 +451,31 @@ pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult { let host = host.trim(); if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") } } - DockerEndpoint::Default => { - match ide_core::process::host_var_os("DOCKER_HOST").map(|v| v.to_string_lossy().trim().to_owned()) { - Some(url) if !url.is_empty() => url, - _ => context_host(ep, None)?, - } - } + DockerEndpoint::Default => default_daemon_url( + ide_core::process::host_var_os("DOCKER_CONTEXT").as_deref().and_then(|v| v.to_str()), + ide_core::process::host_var_os("DOCKER_HOST").as_deref().and_then(|v| v.to_str()), + |name| context_host(&DockerEndpoint::Default, name), + )?, DockerEndpoint::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?, }; Ok(daemon_url_is_local(&url)) } +fn default_daemon_url( + context: Option<&str>, + host: Option<&str>, + lookup: impl FnOnce(Option<&str>) -> IdeResult, +) -> IdeResult { + let context = context.filter(|v| !v.is_empty()); + if context.is_some() { + return lookup(context); + } + match host.filter(|v| !v.is_empty()) { + Some(host) => Ok(host.to_owned()), + None => lookup(None), + } +} + /// The daemon URL of context `name` (the current one with `None`). fn context_host(ep: &DockerEndpoint, name: Option<&str>) -> IdeResult { let mut args = vec!["context", "inspect", "--format", "{{.Endpoints.docker.Host}}"]; @@ -506,6 +520,34 @@ pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeR mod local_tests { use super::*; + #[test] + fn the_selected_context_overrides_docker_host() { + let local = "unix:///var/run/docker.sock"; + let remote = "ssh://me@build"; + for host in [None, Some(""), Some(local)] { + let url = default_daemon_url(Some("build"), host, |name| { + assert_eq!(name, Some("build")); + Ok(remote.into()) + }) + .unwrap(); + assert!(!daemon_url_is_local(&url)); + } + for context in [None, Some("")] { + assert_eq!( + default_daemon_url(context, Some(local), |_| panic!("host overrides current context")).unwrap(), + local + ); + assert_eq!( + default_daemon_url(context, None, |name| { + assert_eq!(name, None); + Ok(remote.into()) + }) + .unwrap(), + remote + ); + } + } + #[test] fn local_daemon_urls() { for url in [ diff --git a/crates/ide-host/src/bin/server.rs b/crates/ide-host/src/bin/server.rs index 6a1f25785..f5c140f2a 100644 --- a/crates/ide-host/src/bin/server.rs +++ b/crates/ide-host/src/bin/server.rs @@ -78,6 +78,11 @@ async fn run(base: PathBuf) -> i32 { None } }; + if let Some(root) = std::env::var_os(ide_host::trust::TRUSTED_ROOT_ENV) + && let Err(e) = host.trust.inherit(std::path::Path::new(&root)) + { + tracing::warn!("cannot trust {}: {e}", root.to_string_lossy()); + } tracing::info!("openidea-server {} ready", env!("CARGO_PKG_VERSION")); let mut dispatcher = Dispatcher::standard(); if let Some(plugins) = &plugins { diff --git a/crates/ide-host/src/environment.rs b/crates/ide-host/src/environment.rs index 7180b5abd..eb34cfd89 100644 --- a/crates/ide-host/src/environment.rs +++ b/crates/ide-host/src/environment.rs @@ -22,6 +22,10 @@ use std::time::Duration; type Changes = Vec<(OsString, Option)>; +/// Set by the desktop when the Dev Containers CLI already ran the lifecycle commands +/// (`devcontainer up`): the server then doesn't run `postStartCommand` again. +pub const LIFECYCLE_DONE_ENV: &str = "OPENIDEA_LIFECYCLE_DONE"; + /// What Reload Environment changed. #[derive(Debug, Clone, Serialize, Deserialize, Type)] #[serde(rename_all = "camelCase")] @@ -72,7 +76,7 @@ impl Environment { return None; } let config = self.load_project(root, sink); - let post_start = config.and_then(|c| c.post_start); + let post_start = config.and_then(|c| c.post_start).filter(|_| std::env::var_os(LIFECYCLE_DONE_ENV).is_none()); let first = self.state.lock().post_started.insert(root.to_path_buf()); post_start.filter(|_| first) } diff --git a/crates/ide-host/src/trust.rs b/crates/ide-host/src/trust.rs index 2144c9d15..483a9b2bc 100644 --- a/crates/ide-host/src/trust.rs +++ b/crates/ide-host/src/trust.rs @@ -16,8 +16,14 @@ pub struct TrustDecisions { pub struct TrustStore { file: PathBuf, state: Mutex>, + /// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]). + inherited: Mutex>, } +/// Set by the desktop when it starts the server in a dev container of a folder the user trusts +/// there: the project folder in the container, trusted without asking again. Never persisted. +pub const TRUSTED_ROOT_ENV: &str = "OPENIDEA_TRUSTED_ROOT"; + impl TrustStore { pub fn load(config: &Path, data: &Path) -> Self { let file = data.join("project-trust.json"); @@ -34,7 +40,7 @@ impl TrustStore { } Err(e) => Err(e.into()), }; - Self { file, state: Mutex::new(state) } + Self { file, state: Mutex::new(state), inherited: Mutex::default() } } pub fn decisions(&self) -> IdeResult { @@ -45,7 +51,19 @@ impl TrustStore { let root = dunce::canonicalize(path)?; let decisions = self.decisions()?; Ok(!decisions.restricted.contains(&root) - && (decisions.projects.contains(&root) || decisions.locations.iter().any(|p| root.starts_with(p)))) + && (decisions.projects.contains(&root) + || decisions.locations.iter().any(|p| root.starts_with(p)) + || self.inherited.lock().contains(&root))) + } + + /// Trusts `path` for this process without recording a decision (see [`TRUSTED_ROOT_ENV`]). + pub fn inherit(&self, path: &Path) -> IdeResult<()> { + let root = dunce::canonicalize(path)?; + let mut inherited = self.inherited.lock(); + if !inherited.contains(&root) { + inherited.push(root); + } + Ok(()) } pub fn decide(&self, path: &Path, trusted: bool, folder: bool) -> IdeResult<()> { diff --git a/crates/ide-host/tests/trust.rs b/crates/ide-host/tests/trust.rs index 74766decd..baa798680 100644 --- a/crates/ide-host/tests/trust.rs +++ b/crates/ide-host/tests/trust.rs @@ -211,6 +211,24 @@ fn persists_user_decisions_locations_revocation_and_one_time_migration() { assert!(TrustStore::load(&config, &data).trusted(&a).is_err(), "corruption fails closed"); } +#[test] +fn an_inherited_folder_is_trusted_for_this_process_only() { + let tmp = tempfile::tempdir().unwrap(); + let (config, data) = (tmp.path().join("config"), tmp.path().join("data")); + let root = tmp.path().join("workspaces/app"); + std::fs::create_dir_all(&root).unwrap(); + let trust = TrustStore::load(&config, &data); + assert!(!trust.trusted(&root).unwrap()); + trust.inherit(&root).unwrap(); + assert!(trust.trusted(&root).unwrap()); + assert!(!trust.trusted(root.parent().unwrap()).unwrap(), "only that folder"); + assert!(trust.status(&root).unwrap().decided, "the trust prompt doesn't ask again"); + assert!(trust.decisions().unwrap().projects.is_empty(), "no decision is recorded"); + assert!(!TrustStore::load(&config, &data).trusted(&root).unwrap(), "not persisted"); + trust.decide(&root, false, false).unwrap(); + assert!(!trust.trusted(&root).unwrap(), "a decision to restrict it still wins"); +} + #[cfg(unix)] fn script(root: &Path, name: &str, marker: &str) { use std::os::unix::fs::PermissionsExt; diff --git a/crates/ide-remote/src/devcontainer.rs b/crates/ide-remote/src/devcontainer.rs new file mode 100644 index 000000000..cb99f22d9 --- /dev/null +++ b/crates/ide-remote/src/devcontainer.rs @@ -0,0 +1,102 @@ +//! Validated results of the Dev Containers CLI. + +use ide_core::{IdeError, IdeResult}; +use serde::Deserialize; + +/// The container `devcontainer up` started, as it reported it. +#[derive(Debug, PartialEq, Eq)] +pub struct Up { + pub container: String, + /// The user the IDE works as (`remoteUser`). + pub user: String, + /// The project folder in the container (`remoteWorkspaceFolder`). + pub folder: String, +} + +/// The JSON line `devcontainer up` ends with. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct UpResult { + outcome: String, + container_id: Option, + remote_user: Option, + remote_workspace_folder: Option, + message: Option, + description: Option, +} + +fn is_container_id(id: &str) -> bool { + (12..=64).contains(&id.len()) && id.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) +} + +/// A user name or `uid[:gid]`, never something `docker exec` could read as an option. +fn is_user(user: &str) -> bool { + !user.is_empty() + && !user.starts_with('-') + && user.len() <= 64 + && user.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '-' | '.' | ':')) +} + +fn is_container_path(path: &str) -> bool { + path.starts_with('/') && !path.chars().any(char::is_control) +} + +/// Reads the result of `devcontainer up` from its stdout (the last JSON line). +pub fn parse_up(stdout: &str) -> IdeResult { + let line = stdout.lines().rev().map(str::trim).find(|l| l.starts_with('{')); + let result: UpResult = line + .and_then(|l| serde_json::from_str(l).ok()) + .ok_or_else(|| IdeError::unavailable("devcontainer up did not report a result"))?; + if result.outcome != "success" { + let why = [result.message, result.description].into_iter().flatten().collect::>().join(": "); + return Err(IdeError::unavailable(format!("devcontainer up failed: {why}"))); + } + let container = result.container_id.filter(|id| is_container_id(id)); + let user = result.remote_user.filter(|u| is_user(u)); + let folder = result.remote_workspace_folder.filter(|f| is_container_path(f)); + match (container, user, folder) { + (Some(container), Some(user), Some(folder)) => Ok(Up { container, user, folder }), + _ => Err(IdeError::unavailable("devcontainer up reported an unexpected container, user or folder")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const ID: &str = "3f1c2b9a7d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8"; + + fn result(fields: &str) -> String { + format!("[1 ms] Start: Run: docker build\n{{\"outcome\":\"success\",{fields}}}\n") + } + + #[test] + fn reads_the_container_from_the_last_json_line() { + let out = + result(&format!(r#""containerId":"{ID}","remoteUser":"vscode","remoteWorkspaceFolder":"/workspaces/app""#)); + let up = parse_up(&out).unwrap(); + assert_eq!(up, Up { container: ID.into(), user: "vscode".into(), folder: "/workspaces/app".into() }); + } + + #[test] + fn a_failure_reports_the_cli_message() { + let out = r#"{"outcome":"error","message":"Command failed: docker build","description":"An error occurred"}"#; + let err = parse_up(out).unwrap_err(); + assert!(err.message.contains("Command failed: docker build: An error occurred"), "{}", err.message); + assert!(parse_up("no json at all").is_err()); + } + + #[test] + fn unexpected_values_never_reach_docker_exec() { + for fields in [ + r#""containerId":"--privileged","remoteUser":"vscode","remoteWorkspaceFolder":"/w""#.to_owned(), + format!(r#""containerId":"{ID}","remoteUser":"-u0","remoteWorkspaceFolder":"/w""#), + format!(r#""containerId":"{ID}","remoteUser":"a b","remoteWorkspaceFolder":"/w""#), + format!(r#""containerId":"{ID}","remoteUser":"vscode","remoteWorkspaceFolder":"relative""#), + format!(r#""containerId":"{ID}","remoteUser":"vscode""#), + ] { + assert!(parse_up(&result(&fields)).is_err(), "{fields}"); + } + assert!(is_user("1000:1000") && is_user("node") && is_user("dev.user")); + } +} diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs index e39e0b2f7..91a2c27cc 100644 --- a/crates/ide-remote/src/lib.rs +++ b/crates/ide-remote/src/lib.rs @@ -2,6 +2,7 @@ //! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]). pub mod deploy; +pub mod devcontainer; pub mod docker; pub mod import; pub mod mapping; diff --git a/crates/ide-tools/src/registry.rs b/crates/ide-tools/src/registry.rs index bbb9b5a36..73dbfb181 100644 --- a/crates/ide-tools/src/registry.rs +++ b/crates/ide-tools/src/registry.rs @@ -13,6 +13,8 @@ pub enum ToolKind { /// Loaded into another tool's process, e.g. a jdtls agent. Plugin, Formatter, + /// A command the IDE itself runs, e.g. the Dev Containers CLI. + Cli, } /// Where release archives come from. @@ -112,7 +114,7 @@ impl ToolSpec { use Arch::{Arm64, X64}; use Os::{Linux, Mac, Windows}; -use ToolKind::{Debugger, Formatter, LanguageServer, Plugin}; +use ToolKind::{Cli, Debugger, Formatter, LanguageServer, Plugin}; const V: Option<(&str, &[&str])> = None; const JAVA: &[(&str, &str)] = &[("java", "Java is required (https://adoptium.net)")]; @@ -140,6 +142,17 @@ pub static TOOLS: &[ToolSpec] = &[ needs: NODE, homepage: "https://prettier.io", }, + ToolSpec { + name: "devcontainer", + display: "Dev Containers CLI", + kind: Cli, + commands: &["devcontainer"], + method: Method::Npm { packages: &["@devcontainers/cli"] }, + fallback: None, + version: ver("devcontainer"), + needs: NODE, + homepage: "https://github.com/devcontainers/cli", + }, ToolSpec { name: "oxfmt", display: "oxfmt", @@ -1044,8 +1057,8 @@ mod tests { known.insert((d.adapter.clone(), d.command.clone())); } } - // The host selects the formatter of each file (Prettier or oxfmt). - for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter)) { + // The host selects the formatter of each file (Prettier or oxfmt) and runs its own CLIs. + for t in TOOLS.iter().filter(|t| !matches!(t.kind, Plugin | Formatter | Cli)) { assert!( known.contains(&(t.name.to_string(), t.commands[0].to_string())), "{} / {} is not referenced by languages/*.toml", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1da67b2d2..04c1f40db 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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; diff --git a/src-tauri/src/menu.rs b/src-tauri/src/menu.rs index afa5182c9..240946692 100644 --- a/src-tauri/src/menu.rs +++ b/src-tauri/src/menu.rs @@ -223,6 +223,7 @@ pub fn spec(os: Os) -> Vec { 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, diff --git a/src-tauri/src/remote_container.rs b/src-tauri/src/remote_container.rs new file mode 100644 index 000000000..b34646396 --- /dev/null +++ b/src-tauri/src/remote_container.rs @@ -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 { + 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 { + 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 sh -c