mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 10:16:03 -10: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]>
This commit is contained in:
1 parent
7479b7ad9d
commit
60d7dcdca0
29 files changed
+908
-69
No files matched your search
@@ -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<bool> {
|
||||
let url = match ep {
|
||||
DockerEndpoint::Socket { path } => path.trim().to_owned(),
|
||||
@@ -451,17 +451,31 @@ pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult<bool> {
|
||||
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<String>,
|
||||
) -> IdeResult<String> {
|
||||
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<String> {
|
||||
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 [
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -22,6 +22,10 @@ use std::time::Duration;
|
||||
|
||||
type Changes = Vec<(OsString, Option<OsString>)>;
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
@@ -16,8 +16,14 @@ pub struct TrustDecisions {
|
||||
pub struct TrustStore {
|
||||
file: PathBuf,
|
||||
state: Mutex<IdeResult<TrustDecisions>>,
|
||||
/// Folders trusted for this process only, by the client that started it ([`TRUSTED_ROOT_ENV`]).
|
||||
inherited: Mutex<Vec<PathBuf>>,
|
||||
}
|
||||
|
||||
/// 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<TrustDecisions> {
|
||||
@@ -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<()> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
remote_user: Option<String>,
|
||||
remote_workspace_folder: Option<String>,
|
||||
message: Option<String>,
|
||||
description: Option<String>,
|
||||
}
|
||||
|
||||
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<Up> {
|
||||
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::<Vec<_>>().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"));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user