diff --git a/Cargo.lock b/Cargo.lock index 831c0f1e..4ed4ec68 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4544,6 +4544,7 @@ dependencies = [ "ignore", "parking_lot", "serde", + "serde_json", "specta", "tempfile", "tokio", diff --git a/crates/ide-core/src/remote_settings.rs b/crates/ide-core/src/remote_settings.rs index 8e41bc14..62442a36 100644 --- a/crates/ide-core/src/remote_settings.rs +++ b/crates/ide-core/src/remote_settings.rs @@ -1,5 +1,5 @@ -//! Settings of remote work: SSH configurations, SFTP deployment and SSH run -//! targets. The fields hang off `Settings` in `settings.rs`. +//! Settings of remote work: SSH configurations, SFTP deployment and run +//! targets (SSH, Docker, Compose). The fields hang off `Settings` in `settings.rs`. use serde::{Deserialize, Serialize}; use specta::Type; @@ -140,22 +140,55 @@ pub struct DeploymentMapping { pub web: String, } -/// Build, Execution, Deployment › Run Targets: run configurations on another machine. +/// Where a run target runs configurations. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] +#[serde(rename_all = "camelCase")] +pub enum RunTargetKind { + /// Another machine over SSH, the project synchronised to its folder. + #[default] + Ssh, + /// A new container of an image (`docker run --rm`), the project bind-mounted. + Docker, + /// A new container of a Compose service (`docker compose run --rm`). + Compose, +} + +/// Build, Execution, Deployment › Run Targets: run configurations on another machine +/// or in a container. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)] #[serde(default, rename_all = "camelCase")] pub struct RunTarget { + pub kind: RunTargetKind, pub id: String, pub name: String, - /// [`SshConfiguration::id`]. + /// [`SshConfiguration::id`] (SSH). pub ssh_config: String, - /// Remote folder the project is synchronised to and run from. + /// Remote folder the project is synchronised to and run from (SSH). pub project_root: String, - /// Upload changed project files before each run. + /// Upload changed project files before each run (SSH). pub sync_before_run: bool, /// Interpreters on the target; empty = the one on the remote `PATH`. pub python: String, pub node: String, pub java: String, + /// Id of a Docker connection of the Services tool window (Docker, Compose); + /// empty = the CLI's own daemon. + pub docker_connection: String, + /// Image to run (Docker). + pub image: String, + /// Compose files, relative to the project; empty = the project's default one (Compose). + pub compose_files: Vec, + /// Service to run (Compose). + pub service: String, + /// Where the project is in the container; empty = `/workspaces/` (Docker), + /// the service's bind mount of the project (Compose). + pub container_root: String, + /// Published ports, as `docker run --publish` takes them (`8000:8000`). + pub ports: Vec, + /// More options for `docker run` / `docker compose run`, shell-like quoted. + pub run_options: String, + /// Run as the image's user instead of the project folder's owner (Linux). + pub image_user: bool, } #[cfg(test)] @@ -176,4 +209,24 @@ mod deployment_tests { ); } } + + #[test] + fn run_target_kind_defaults_to_ssh_and_round_trips() { + let legacy: RunTarget = + serde_json::from_str(r#"{"id":"t","name":"Build box","sshConfig":"ssh","projectRoot":"/srv"}"#).unwrap(); + assert_eq!((legacy.kind, legacy.ssh_config.as_str()), (RunTargetKind::Ssh, "ssh")); + let docker = RunTarget { + kind: RunTargetKind::Compose, + docker_connection: "local".into(), + compose_files: vec!["compose.dev.yaml".into()], + service: "web".into(), + ports: vec!["8000:8000".into()], + image_user: true, + ..legacy + }; + let json = serde_json::to_value(&docker).unwrap(); + assert_eq!(json["kind"], "compose"); + assert_eq!(json["composeFiles"], serde_json::json!(["compose.dev.yaml"])); + assert_eq!(serde_json::from_value::(json).unwrap(), docker); + } } diff --git a/crates/ide-docker/src/lib.rs b/crates/ide-docker/src/lib.rs index 65e23f81..8eb638e2 100644 --- a/crates/ide-docker/src/lib.rs +++ b/crates/ide-docker/src/lib.rs @@ -440,3 +440,89 @@ pub fn compose_op( }); run_streaming(ep, None, &args, cancel, on_output) } + +/// 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). +pub fn endpoint_is_local(ep: &DockerEndpoint) -> IdeResult { + let url = match ep { + DockerEndpoint::Socket { path } => path.trim().to_owned(), + DockerEndpoint::Tcp { host, .. } => { + 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::Context { name } => context_host(&DockerEndpoint::Default, Some(name.trim()))?, + }; + Ok(daemon_url_is_local(&url)) +} + +/// 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}}"]; + args.extend(name); + Ok(run(ep, &args, QUICK)?.trim().to_owned()) +} + +/// `unix://`, `npipe://`, a bare socket path and TCP on the loopback address are +/// this machine; `ssh://` and other hosts are not. +pub fn daemon_url_is_local(url: &str) -> bool { + let url = url.trim(); + let Some((scheme, rest)) = url.split_once("://") else { return true }; + match scheme { + "unix" | "npipe" => true, + "tcp" | "http" | "https" => { + let host = rest.split('/').next().unwrap_or_default(); + let host = match host.strip_prefix('[') { + Some(v6) => v6.split(']').next().unwrap_or_default(), + None => host.rsplit_once(':').map_or(host, |(h, _)| h), + }; + matches!(host, "localhost" | "127.0.0.1" | "::1") + } + _ => false, + } +} + +/// `docker compose config --format json` of the project at `dir` (`files` +/// relative to it; none = the project's default file). +pub fn compose_config(ep: &DockerEndpoint, dir: &Path, files: &[String]) -> IdeResult { + let mut args: Vec<&str> = vec!["compose"]; + for f in files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) { + args.extend(["--file", f]); + } + args.extend(["config", "--format", "json"]); + let mut cmd = command(ep, &args)?; + cmd.current_dir(dir); + let out = process::run(cmd, QUICK, None)?; + if out.success { Ok(out.stdout) } else { Err(failure(&args, &out.stderr, &out.stdout)) } +} + +#[cfg(test)] +mod local_tests { + use super::*; + + #[test] + fn local_daemon_urls() { + for url in [ + "unix:///var/run/docker.sock", + "/var/run/docker.sock", + "npipe:////./pipe/docker_engine", + "tcp://localhost:2375", + "tcp://127.0.0.1:2376", + "tcp://[::1]:2375", + "", + ] { + assert!(daemon_url_is_local(url), "{url}"); + } + for url in ["ssh://me@build", "tcp://build:2376", "tcp://10.0.0.5:2375", "https://docker.example.com"] { + assert!(!daemon_url_is_local(url), "{url}"); + } + assert!(endpoint_is_local(&DockerEndpoint::Socket { path: "/run/user/1000/docker.sock".into() }).unwrap()); + assert!(!endpoint_is_local(&DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None }).unwrap()); + } +} diff --git a/crates/ide-host/src/ops/run.rs b/crates/ide-host/src/ops/run.rs index 7409f0a3..b3477991 100644 --- a/crates/ide-host/src/ops/run.rs +++ b/crates/ide-host/src/ops/run.rs @@ -1,5 +1,6 @@ use super::Emit; use crate::Host; +use ide_core::remote_settings::RunTargetKind; use ide_core::{IdeError, IdeResult, RunId}; use ide_run::{OutputChunk, RunConfig, RunInfo, StartOptions}; use std::time::Duration; @@ -140,12 +141,33 @@ pub async fn start(host: &Host, window: &str, config: RunConfig, on_output: Emit if config.command.is_empty() { return Err(IdeError::invalid("run configuration has no command")); } - // Settings › Run Targets: over SSH. + // Settings › Run Targets: over SSH, or in a container. if config.kind == "tomcat" && config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) { return Err(IdeError::invalid("Tomcat Local requires the local machine")); } - if config.extras.target.as_deref().is_some_and(|t| !t.is_empty()) { + if let Some(id) = config.extras.target.as_deref().filter(|t| !t.is_empty()) { let settings = project.settings.effective(); + let target = ide_remote::service::run_target(&settings, id)?; + if target.kind != RunTargetKind::Ssh { + // A local run of the docker CLI: the command is resolved in the container, and + // this machine's toolchains and `PYTHONPATH` don't apply. + let config = container::prepare(host, &project.root, target, config).await?; + let encoding = settings.console.encoding; + let options = StartOptions { + encoding: Some(encoding).filter(|e| !e.trim().is_empty()), + python_paths: Vec::new(), + // A folder on this machine: not in the container. + ruby_reporter: None, + }; + project.require_trust()?; + return host.processes.start( + &project.root, + &config, + &options, + project.sink.clone(), + Box::new(move |c| on_output(c)), + ); + } let out = on_output.clone(); return host .remote @@ -271,4 +293,5 @@ pub fn list(host: &Host) -> Vec { } mod commands; +mod container; pub use commands::*; diff --git a/crates/ide-host/src/ops/run/container.rs b/crates/ide-host/src/ops/run/container.rs new file mode 100644 index 00000000..e8616bef --- /dev/null +++ b/crates/ide-host/src/ops/run/container.rs @@ -0,0 +1,108 @@ +//! Docker and Compose run targets: the configuration becomes a local run of the +//! docker CLI (see `ide_remote::docker`). + +use crate::Host; +use ide_core::remote_settings::{RunTarget, RunTargetKind}; +use ide_core::{IdeError, IdeResult}; +use ide_docker::DockerEndpoint; +use ide_remote::docker::{ + ContainerRun, compose_container_root, container_config, container_name, default_container_root, +}; +use ide_run::RunConfig; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +/// `config` rewritten to run in a new container of `target`'s image or service. +pub(super) async fn prepare(host: &Host, root: &Path, target: RunTarget, config: RunConfig) -> IdeResult { + if config.kind == "dotnet" || config.kind == "dotnet-test" { + return Err(IdeError::invalid(".NET Project requires the local machine")); + } + let endpoint = endpoint(&host.data_dir, &target)?; + let root = root.to_path_buf(); + host.blocking(move || { + if !ide_docker::endpoint_is_local(&endpoint)? { + return Err(IdeError::invalid(format!( + "Run target '{}' uses a Docker daemon on another machine: the project can only be mounted from a local one", + target.name + ))); + } + let docker = ide_docker::docker_executable().ok_or_else(|| { + IdeError::unavailable("docker executable not found; install Docker and make sure it is on PATH") + })?; + let container_root = match target.container_root.trim() { + "" if target.kind == RunTargetKind::Compose => { + let json = ide_docker::compose_config(&endpoint, &root, &target.compose_files)?; + compose_container_root(&json, target.service.trim(), &root)? + } + "" => default_container_root(&root), + folder => folder.to_owned(), + }; + let file_env = env_files(&root, &config)?; + let name = container_name(&config.id, &unique()); + container_config( + &config, + &ContainerRun { + target: &target, + root: &root, + container_root: &container_root, + docker: &docker.to_string_lossy(), + endpoint_env: &ide_docker::run_env(&endpoint), + file_env: &file_env, + name: &name, + user: user(&root, &target), + }, + ) + }) + .await +} + +/// The target's Docker connection (Services tool window), by id; none = the CLI's daemon. +fn endpoint(data_dir: &Path, target: &RunTarget) -> IdeResult { + let id = target.docker_connection.trim(); + if id.is_empty() { + return Ok(DockerEndpoint::Default); + } + ide_docker::load_settings(data_dir).connections.into_iter().find(|c| c.id == id).map(|c| c.endpoint).ok_or_else( + || { + IdeError::not_found(format!( + "Run target '{}' uses a Docker connection that no longer exists; choose one in Settings › Run Targets", + target.name + )) + }, + ) +} + +/// The configuration's `.env` files, read here: the container gets their variables. +fn env_files(root: &Path, config: &RunConfig) -> IdeResult> { + let extras = &config.extras; + if extras.env_files.is_empty() { + return Ok(BTreeMap::new()); + } + let paths: Vec = extras + .env_files + .iter() + .map(|f| if Path::new(f).is_absolute() { PathBuf::from(f) } else { root.join(f) }) + .collect(); + let base = if extras.include_system_env { ide_run::system_env() } else { BTreeMap::new() }; + ide_run::envfile::load(&paths, &base).map_err(|e| IdeError::not_found(format!("cannot read .env file: {e}"))) +} + +/// On Linux the container writes files as the project folder's owner, unless the +/// target keeps the image's user. Docker Desktop and OrbStack map ownership themselves. +fn user(root: &Path, target: &RunTarget) -> Option<(u32, u32)> { + #[cfg(target_os = "linux")] + if !target.image_user { + use std::os::unix::fs::MetadataExt; + return std::fs::metadata(root).ok().map(|m| (m.uid(), m.gid())); + } + let _ = (root, target); + None +} + +/// Distinguishes runs of the same configuration. +fn unique() -> String { + use std::sync::atomic::{AtomicU32, Ordering}; + static COUNT: AtomicU32 = AtomicU32::new(0); + let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_secs()); + format!("{:x}-{}", secs & 0xff_ffff, COUNT.fetch_add(1, Ordering::Relaxed)) +} diff --git a/crates/ide-host/tests/docker_run_targets.rs b/crates/ide-host/tests/docker_run_targets.rs new file mode 100644 index 00000000..4671ea27 --- /dev/null +++ b/crates/ide-host/tests/docker_run_targets.rs @@ -0,0 +1,122 @@ +//! Docker run targets through `ops::run::start`, with a stand-in `docker` +//! (`OPENIDEA_DOCKER`) that prints what it was given. +#![cfg(unix)] + +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use ide_run::{OutputChunk, RunConfig}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::os::unix::fs::PermissionsExt; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; + +fn host(base: &Path) -> Arc { + Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })) +} + +fn config(target: &str, root: &Path) -> RunConfig { + RunConfig { + id: "app".into(), + name: "app".into(), + kind: "python".into(), + command: root.join(".venv/bin/python").to_string_lossy().into_owned(), + args: vec![root.join("main.py").to_string_lossy().into_owned()], + cwd: None, + env: BTreeMap::from([("DATA".to_owned(), root.join("data").to_string_lossy().into_owned())]), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() }, + } +} + +async fn output_of(host: &Host, config: RunConfig) -> String { + let out = Arc::new(Mutex::new(String::new())); + let sink = out.clone(); + ops::run::start( + host, + "main", + config, + Arc::new(move |chunk| match chunk { + OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => { + sink.lock().push_str(&text) + } + }), + ) + .await + .unwrap(); + for _ in 0..200 { + if out.lock().contains("Process finished") { + break; + } + tokio::time::sleep(Duration::from_millis(25)).await; + } + out.lock().clone() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn docker_targets_run_through_the_docker_cli() { + let tmp = tempfile::tempdir().unwrap(); + let base = dunce::canonicalize(tmp.path()).unwrap(); + let docker = base.join("docker"); + std::fs::write( + &docker, + "#!/bin/sh\nif [ \"$1\" = context ]; then echo unix:///var/run/docker.sock; exit 0; fi\n\ + for a in \"$@\"; do echo \"arg:$a\"; done\necho \"DATA=$DATA\"\n", + ) + .unwrap(); + std::fs::set_permissions(&docker, std::fs::Permissions::from_mode(0o755)).unwrap(); + // SAFETY: the only test of this binary, before any thread looks docker up. + unsafe { + std::env::set_var("OPENIDEA_DOCKER", &docker); + std::env::remove_var("DOCKER_HOST"); + } + + let root = base.join("proj"); + std::fs::create_dir_all(&root).unwrap(); + let host = host(&base.join("app")); + std::fs::create_dir_all(&host.data_dir).unwrap(); + std::fs::write( + host.data_dir.join("docker.json"), + r#"{"connections": [{"id": "far", "name": "Build box", "endpoint": {"kind": "tcp", "host": "build:2376"}}]}"#, + ) + .unwrap(); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + let targets = serde_json::json!([ + { "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12" }, + { "kind": "docker", "id": "far", "name": "Far", "image": "python:3.12", "dockerConnection": "far" }, + ]); + ops::project::set_settings( + &host, + "main", + ide_workspace::settings::SettingsScope::Project, + "runTargets", + &targets.to_string(), + ) + .unwrap(); + + let out = output_of(&host, config("py", &root)).await; + let args: Vec<&str> = out.lines().filter_map(|l| l.strip_prefix("arg:")).collect(); + assert_eq!(&args[..4], ["run", "--rm", "--interactive", "--init"], "{out}"); + assert!(args.contains(&format!("{}:/workspaces/proj", root.display()).as_str()), "{out}"); + assert!(args.ends_with(&["python:3.12", "python3", "/workspaces/proj/main.py"]), "{out}"); + assert!(out.contains("DATA=/workspaces/proj/data"), "{out}"); + assert!(!args.iter().any(|a| a.contains("/workspaces/proj/data")), "values stay out of the command line"); + + let err = ops::run::start(&host, "main", config("far", &root), Arc::new(|_| {})).await.unwrap_err(); + assert!(err.message.contains("another machine"), "{}", err.message); +} diff --git a/crates/ide-host/tests/docker_run_targets_real.rs b/crates/ide-host/tests/docker_run_targets_real.rs new file mode 100644 index 00000000..16b7a1c6 --- /dev/null +++ b/crates/ide-host/tests/docker_run_targets_real.rs @@ -0,0 +1,159 @@ +//! Docker and Compose run targets against a real daemon. Ignored by default: +//! `OPENIDEA_DOCKER_TEST_DIR` names an empty folder the daemon can bind-mount +//! (on this machine, or at the same path in a VM such as OrbStack's), and +//! `docker` with the Compose plugin must be on `PATH`. +#![cfg(unix)] + +use ide_core::NullSink; +use ide_host::{Host, HostConfig, ops}; +use ide_run::{OutputChunk, RunConfig}; +use parking_lot::Mutex; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::time::Duration; + +fn host(base: &Path) -> Arc { + Arc::new(Host::new(HostConfig { + config_dir: base.join("config"), + data_dir: base.join("data"), + local_data_dir: base.join("data"), + languages: Arc::new(ide_language::LanguageRegistry::load(None).unwrap()), + syntax: Arc::default(), + sink_for: Box::new(|_| Arc::new(NullSink)), + owner: |w| w, + runtime: tokio::runtime::Handle::current(), + project_environment: false, + })) +} + +fn config(id: &str, target: &str, root: &Path, args: &[&str]) -> RunConfig { + RunConfig { + id: id.into(), + name: id.into(), + kind: "python".into(), + command: root.join(".venv/bin/python").to_string_lossy().into_owned(), + args: args.iter().map(|a| a.to_string()).collect(), + cwd: None, + env: BTreeMap::from([("GREETING".to_owned(), "hello".to_owned())]), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: ide_run::RunExtras { target: Some(target.into()), ..Default::default() }, + } +} + +type Output = Arc>; + +async fn start(host: &Host, config: RunConfig) -> (ide_core::RunId, Output) { + let out: Output = Arc::default(); + let sink = out.clone(); + let info = ops::run::start( + host, + "main", + config, + Arc::new(move |chunk| match chunk { + OutputChunk::Stdout { text } | OutputChunk::Stderr { text } | OutputChunk::System { text } => { + sink.lock().push_str(&text) + } + }), + ) + .await + .unwrap(); + (info.id, out) +} + +async fn finished(out: &Output, secs: u64) -> String { + for _ in 0..secs * 10 { + if out.lock().contains("Process finished") { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + out.lock().clone() +} + +fn containers(label: &str) -> String { + let out = Command::new("docker").args(["ps", "--all", "--quiet", "--filter", label]).output().unwrap(); + String::from_utf8_lossy(&out.stdout).trim().to_owned() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +#[ignore = "requires a Docker daemon and OPENIDEA_DOCKER_TEST_DIR"] +async fn runs_in_docker_and_compose_containers() { + let root = PathBuf::from(std::env::var("OPENIDEA_DOCKER_TEST_DIR").expect("OPENIDEA_DOCKER_TEST_DIR")); + let root = dunce::canonicalize(root).unwrap(); + let name = root.file_name().unwrap().to_string_lossy().into_owned(); + std::fs::write( + root.join("main.py"), + "import os\nprint('cwd=' + os.getcwd())\nprint('GREETING=' + os.environ['GREETING'])\n\ + open('out.txt', 'w').write('ok')\n", + ) + .unwrap(); + std::fs::write( + root.join("compose.yaml"), + "services:\n app:\n image: python:3.12-alpine\n volumes:\n - .:/app\n", + ) + .unwrap(); + let state = tempfile::tempdir().unwrap(); + let host = host(state.path()); + host.trust.decide(&root, true, false).unwrap(); + host.open_project("main", &root).unwrap(); + let targets = serde_json::json!([ + { "kind": "docker", "id": "py", "name": "Python", "image": "python:3.12-alpine" }, + { "kind": "compose", "id": "app", "name": "Compose", "service": "app" }, + ]); + ops::project::set_settings( + &host, + "main", + ide_workspace::settings::SettingsScope::Project, + "runTargets", + &targets.to_string(), + ) + .unwrap(); + + let (_, out) = start(&host, config("docker-main", "py", &root, &[&root.join("main.py").to_string_lossy()])).await; + let out = finished(&out, 120).await; + assert!(out.contains(&format!("cwd=/workspaces/{name}")), "{out}"); + assert!(out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}"); + let written = root.join("out.txt"); + assert_eq!(std::fs::read_to_string(&written).unwrap(), "ok"); + #[cfg(target_os = "linux")] + { + use std::os::unix::fs::MetadataExt; + let (file, folder) = (std::fs::metadata(&written).unwrap(), std::fs::metadata(&root).unwrap()); + assert_eq!((file.uid(), file.gid()), (folder.uid(), folder.gid()), "written as the folder's owner"); + } + std::fs::remove_file(&written).unwrap(); + assert_eq!(containers("label=openidea.run=docker-main"), "", "removed when the run ends"); + + let (_, out) = start(&host, config("compose-main", "app", &root, &["main.py"])).await; + let out = finished(&out, 120).await; + assert!(out.contains("cwd=/app") && out.contains("GREETING=hello") && out.contains("exit code 0"), "{out}"); + std::fs::remove_file(&written).unwrap(); + + // Stop: `docker stop` ends the container; `--rm` removes it. + let (id, out) = + start(&host, config("sleep", "py", &root, &["-c", "print('up', flush=True); import time; time.sleep(600)"])) + .await; + for _ in 0..600 { + if out.lock().contains("up\n") { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_ne!(containers("label=openidea.run=sleep"), "", "{}", out.lock()); + ops::run::stop(&host, "main", id).unwrap(); + let out = finished(&out, 30).await; + assert!(out.contains(" stop openidea-sleep-"), "{out}"); + for _ in 0..100 { + if containers("label=openidea.run=sleep").is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(100)).await; + } + assert_eq!(containers("label=openidea.run=sleep"), "", "{out}"); + Command::new("docker").args(["compose", "down"]).current_dir(&root).status().unwrap(); +} diff --git a/crates/ide-remote/Cargo.toml b/crates/ide-remote/Cargo.toml index 8442d6fe..eb9828c7 100644 --- a/crates/ide-remote/Cargo.toml +++ b/crates/ide-remote/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ide-remote" -description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets" +description = "Remote work over SSH: SFTP deployment (mappings, upload, download, sync) and run targets (SSH, Docker, Compose)" version.workspace = true edition.workspace = true license.workspace = true @@ -10,6 +10,7 @@ ide-core.workspace = true ide-ssh.workspace = true ide-run.workspace = true serde.workspace = true +serde_json.workspace = true specta.workspace = true tokio.workspace = true tracing.workspace = true diff --git a/crates/ide-remote/src/docker.rs b/crates/ide-remote/src/docker.rs new file mode 100644 index 00000000..b925bad8 --- /dev/null +++ b/crates/ide-remote/src/docker.rs @@ -0,0 +1,347 @@ +//! Run configurations on Docker and Compose run targets: rewritten into a local +//! `docker run --rm` / `docker compose run --rm` of the configuration's program, +//! with the project bind-mounted and its paths and interpreters swapped for the +//! container's. The console, stdin, Stop and test parsers then work as for any +//! local run. + +use crate::target::remote_command; +use ide_core::remote_settings::{RunTarget, RunTargetKind}; +use ide_core::{IdeError, IdeResult}; +use ide_run::RunConfig; +use std::collections::BTreeMap; +use std::path::Path; + +/// What the rewrite needs besides the configuration and its target. +pub struct ContainerRun<'a> { + pub target: &'a RunTarget, + /// The project on this machine. + pub root: &'a Path, + /// Where the project is in the container. + pub container_root: &'a str, + /// The docker executable. + pub docker: &'a str, + /// The connection's variables for the docker CLI (`ide_docker::run_env`). + pub endpoint_env: &'a BTreeMap, + /// Variables of the configuration's `.env` files, already read. + pub file_env: &'a BTreeMap, + /// Container name, unique per run. + pub name: &'a str, + /// `--user uid:gid`; `None` keeps the image's user. + pub user: Option<(u32, u32)>, +} + +/// The container folder of the project when a Docker target sets none. +pub fn default_container_root(root: &Path) -> String { + let name = root.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let name: String = + name.chars().map(|c| if c.is_alphanumeric() || matches!(c, '_' | '-' | '.') { c } else { '-' }).collect(); + format!("/workspaces/{}", if name.is_empty() { "project" } else { &name }) +} + +/// A container name for a run of `config_id`: Docker allows `[a-zA-Z0-9][a-zA-Z0-9_.-]*`. +pub fn container_name(config_id: &str, unique: &str) -> String { + let id: String = config_id + .chars() + .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '-' }) + .take(40) + .collect(); + format!("openidea-{}-{unique}", id.trim_matches('-')) +} + +/// `config` as a local run of the docker CLI that runs it in a new container. +pub fn container_config(config: &RunConfig, run: &ContainerRun) -> IdeResult { + let target = run.target; + let command = remote_command(config, target, run.root, run.container_root); + // Variables go by name (`-e NAME`) so their values stay out of the command line + // echoed in the console; the docker CLI reads them from its own environment. + let mut env: BTreeMap = run.file_env.clone(); + if matches!(config.kind.as_str(), "python" | "pytest" | "django-test") + && !command.env.contains_key("PYTHONUNBUFFERED") + { + env.insert("PYTHONUNBUFFERED".into(), "1".into()); + } + env.extend(command.env.clone()); + let pty = config.extras.emulate_terminal; + let mut args: Vec = Vec::new(); + match target.kind { + RunTargetKind::Docker => { + if target.image.trim().is_empty() { + return Err(IdeError::invalid(format!("Run target '{}' has no image", target.name))); + } + args.extend(["run", "--rm", "--interactive", "--init"].map(String::from)); + if pty { + args.push("--tty".into()); + } + let root = run.root.to_string_lossy(); + args.extend(["--volume".into(), format!("{root}:{}", run.container_root)]); + } + RunTargetKind::Compose => { + if target.service.trim().is_empty() { + return Err(IdeError::invalid(format!("Run target '{}' has no service", target.name))); + } + args.push("compose".into()); + for file in target.compose_files.iter().map(|f| f.trim()).filter(|f| !f.is_empty()) { + args.extend(["--file".into(), run.root.join(file).to_string_lossy().into_owned()]); + } + args.extend(["run", "--rm"].map(String::from)); + if !pty { + args.push("--no-TTY".into()); + } + } + RunTargetKind::Ssh => return Err(IdeError::internal("an SSH target does not run in a container")), + } + args.extend(["--name".into(), run.name.to_owned(), "--label".into(), format!("openidea.run={}", config.id)]); + args.extend(["--workdir".into(), command.cwd.clone()]); + let mut docker_env = run.endpoint_env.clone(); + for (key, value) in &env { + if run.endpoint_env.contains_key(key) { + // `PATH`, `DOCKER_HOST`…: the CLI's own value differs from the container's. + args.extend(["--env".into(), format!("{key}={value}")]); + } else { + args.extend(["--env".into(), key.clone()]); + docker_env.insert(key.clone(), value.clone()); + } + } + for port in target.ports.iter().map(|p| p.trim()).filter(|p| !p.is_empty()) { + args.extend(["--publish".into(), port.to_owned()]); + } + if let Some((uid, gid)) = run.user { + args.extend(["--user".into(), format!("{uid}:{gid}")]); + } + let options = target.run_options.trim(); + if !options.is_empty() { + args.extend(ide_run::import::split_args(options)); + } + args.push(match target.kind { + RunTargetKind::Compose => target.service.trim().to_owned(), + _ => target.image.trim().to_owned(), + }); + match command.shell_line { + Some(line) => args.extend(["sh".into(), "-c".into(), line]), + None => { + args.push(command.program); + args.extend(command.args); + } + } + let mut out = config.clone(); + out.command = run.docker.to_owned(); + out.args = args; + out.cwd = Some(run.root.to_path_buf().into()); + out.env = docker_env; + let extras = &mut out.extras; + extras.target = None; + extras.env_files.clear(); + // The docker CLI needs `HOME` (contexts, credentials); the container gets only `env`. + extras.include_system_env = true; + // `docker stop` signals the container's process (the CLI may not forward a signal). + extras.stop_command = Some(format!("{} stop {}", shell_word(run.docker), run.name)); + Ok(out) +} + +/// `word` for this machine's shell (the stop command runs there): the docker CLI found +/// outside `PATH` (Docker.app's bundle) may contain spaces. +fn shell_word(word: &str) -> String { + if !word.contains(|c: char| c.is_whitespace() || "'\"$`\\&;|<>()*?#~".contains(c)) || word.is_empty() { + word.to_owned() + } else if cfg!(windows) { + format!("\"{word}\"") + } else { + format!("'{}'", word.replace('\'', "'\\''")) + } +} + +/// The project's folder in the container of a Compose `service`, from the JSON of +/// `docker compose config --format json`: the target of a bind mount of the project +/// or a folder above it. +pub fn compose_container_root(config_json: &str, service: &str, root: &Path) -> IdeResult { + let model: serde_json::Value = serde_json::from_str(config_json) + .map_err(|e| IdeError::internal(format!("cannot read `docker compose config`: {e}")))?; + let Some(svc) = model.pointer(&format!("/services/{}", service.replace('~', "~0").replace('/', "~1"))) else { + return Err(IdeError::not_found(format!("the Compose files have no service '{service}'"))); + }; + let mounts = svc.get("volumes").and_then(|v| v.as_array()).into_iter().flatten(); + let mut best: Option<(usize, String)> = None; + for m in mounts { + if m.get("type").and_then(|t| t.as_str()) != Some("bind") { + continue; + } + let (Some(source), Some(target)) = + (m.get("source").and_then(|s| s.as_str()), m.get("target").and_then(|t| t.as_str())) + else { + continue; + }; + let Ok(rel) = root.strip_prefix(source) else { continue }; + let depth = Path::new(source).components().count(); + if best.as_ref().is_none_or(|(d, _)| depth > *d) { + let rel = crate::mapping::posix(rel); + let target = target.trim_end_matches('/'); + let folder = match (target.is_empty(), rel.is_empty()) { + (true, true) => "/".to_owned(), + (_, true) => target.to_owned(), + _ => format!("{target}/{rel}"), + }; + best = Some((depth, folder)); + } + } + best.map(|(_, folder)| folder).ok_or_else(|| { + IdeError::invalid(format!( + "service '{service}' does not mount the project folder: add a bind mount of it, or set the container folder of the run target" + )) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn config(command: &str, args: &[&str]) -> RunConfig { + RunConfig { + id: "app".into(), + name: "app".into(), + kind: "python".into(), + command: command.into(), + args: args.iter().map(|s| s.to_string()).collect(), + cwd: None, + env: BTreeMap::from([("DATA".to_owned(), "/work/proj/data".to_owned())]), + detected: false, + is_test: false, + debug_adapter: None, + allow_parallel: false, + extras: Default::default(), + } + } + + fn run<'a>( + target: &'a RunTarget, + endpoint: &'a BTreeMap, + files: &'a BTreeMap, + ) -> ContainerRun<'a> { + ContainerRun { + target, + root: Path::new("/work/proj"), + container_root: "/workspaces/proj", + docker: "/usr/local/bin/docker", + endpoint_env: endpoint, + file_env: files, + name: "openidea-app-1", + user: Some((501, 20)), + } + } + + // Unix paths are not absolute on Windows. + #[cfg(unix)] + #[test] + fn docker_target_runs_the_program_in_a_new_container() { + let target = RunTarget { + kind: RunTargetKind::Docker, + name: "py".into(), + image: "python:3.12".into(), + ports: vec!["8000:8000".into(), " ".into()], + run_options: r#"--network host --label "a b""#.into(), + ..Default::default() + }; + let endpoint = BTreeMap::from([ + ("DOCKER_CONTEXT".to_owned(), "orbstack".to_owned()), + ("PATH".to_owned(), "/usr/local/bin".to_owned()), + ]); + let files = BTreeMap::from([("SECRET".to_owned(), "s3".to_owned())]); + let mut c = config("/work/proj/.venv/bin/python", &["-u", "/work/proj/main.py"]); + c.env.insert("PATH".into(), "/opt/bin".into()); + c.extras.target = Some("t".into()); + c.extras.env_files = vec![".env".into()]; + let out = container_config(&c, &run(&target, &endpoint, &files)).unwrap(); + assert_eq!(out.command, "/usr/local/bin/docker"); + assert_eq!( + out.args.join(" "), + "run --rm --interactive --init --volume /work/proj:/workspaces/proj \ + --name openidea-app-1 --label openidea.run=app --workdir /workspaces/proj \ + --env DATA --env PATH=/opt/bin --env PYTHONUNBUFFERED --env SECRET \ + --publish 8000:8000 --user 501:20 --network host --label a b \ + python:3.12 python3 -u /workspaces/proj/main.py" + ); + assert_eq!(out.env["DATA"], "/workspaces/proj/data", "values reach the CLI's environment, mapped"); + assert_eq!(out.env["SECRET"], "s3"); + assert_eq!(out.env["PATH"], "/usr/local/bin", "the CLI keeps its own PATH"); + assert_eq!(out.env["DOCKER_CONTEXT"], "orbstack"); + assert_eq!(out.cwd.as_deref(), Some(Path::new("/work/proj"))); + assert_eq!(out.extras.target, None); + assert!(out.extras.env_files.is_empty() && out.extras.include_system_env); + assert_eq!(out.extras.stop_command.as_deref(), Some("/usr/local/bin/docker stop openidea-app-1")); + assert_eq!(out.kind, "python", "test and output parsers still apply"); + + let mut c = config("npm run dev", &[]); + c.kind = "npm".into(); + c.extras.emulate_terminal = true; + let target = RunTarget { kind: RunTargetKind::Docker, image: "node:22".into(), ..Default::default() }; + let mut r = run(&target, &endpoint, &files); + r.user = None; + let out = container_config(&c, &r).unwrap(); + assert!(out.args.starts_with(&["run", "--rm", "--interactive", "--init", "--tty"].map(String::from))); + assert!(!out.args.contains(&"--user".to_string()) && !out.args.contains(&"PYTHONUNBUFFERED".to_string())); + assert!(out.args.ends_with(&["node:22", "sh", "-c", "npm run dev"].map(String::from))); + + let target = RunTarget { kind: RunTargetKind::Docker, name: "none".into(), ..Default::default() }; + assert!(container_config(&c, &run(&target, &endpoint, &files)).unwrap_err().message.contains("no image")); + } + + #[cfg(unix)] + #[test] + fn compose_target_runs_in_a_new_container_of_the_service() { + let target = RunTarget { + kind: RunTargetKind::Compose, + compose_files: vec!["compose.yaml".into(), "compose.dev.yaml".into()], + service: "web".into(), + ..Default::default() + }; + let none = BTreeMap::new(); + let mut c = config("/work/proj/.venv/bin/python", &["manage.py", "test"]); + c.kind = "django-test".into(); + c.cwd = Some(PathBuf::from("/work/proj/backend").into()); + let mut r = run(&target, &none, &none); + r.container_root = "/app"; + let out = container_config(&c, &r).unwrap(); + assert_eq!( + out.args.join(" "), + "compose --file /work/proj/compose.yaml --file /work/proj/compose.dev.yaml run --rm --no-TTY \ + --name openidea-app-1 --label openidea.run=app --workdir /app/backend \ + --env DATA --env PYTHONUNBUFFERED --user 501:20 web python3 manage.py test" + ); + assert_eq!(out.env["DATA"], "/app/data"); + + let target = RunTarget { kind: RunTargetKind::Compose, name: "c".into(), ..Default::default() }; + assert!(container_config(&c, &run(&target, &none, &none)).unwrap_err().message.contains("no service")); + } + + #[cfg(unix)] + #[test] + fn compose_container_root_follows_the_project_bind_mount() { + let json = r#"{"services": { + "web": {"volumes": [ + {"type": "volume", "source": "data", "target": "/data"}, + {"type": "bind", "source": "/work", "target": "/src"}, + {"type": "bind", "source": "/work/proj", "target": "/app/"} + ]}, + "worker": {"volumes": [{"type": "bind", "source": "/work", "target": "/src"}]}, + "db": {} + }}"#; + let root = Path::new("/work/proj"); + assert_eq!(compose_container_root(json, "web", root).unwrap(), "/app", "the closest mount wins"); + assert_eq!(compose_container_root(json, "worker", root).unwrap(), "/src/proj"); + assert!(compose_container_root(json, "db", root).unwrap_err().message.contains("does not mount")); + assert_eq!(compose_container_root(json, "api", root).unwrap_err().kind, ide_core::error::ErrorKind::NotFound); + } + + #[test] + fn names_and_default_folder() { + assert_eq!(container_name("workspace:a/b:Run app", "k3x"), "openidea-workspace-a-b-Run-app-k3x"); + assert_eq!(default_container_root(Path::new("/work/my proj")), "/workspaces/my-proj"); + } + + #[cfg(unix)] + #[test] + fn stop_quotes_the_docker_cli_for_the_shell() { + assert_eq!(shell_word("/usr/local/bin/docker"), "/usr/local/bin/docker"); + assert_eq!(shell_word("/Volumes/My Apps/docker"), "'/Volumes/My Apps/docker'"); + } +} diff --git a/crates/ide-remote/src/lib.rs b/crates/ide-remote/src/lib.rs index 378aa5cc..e39e0b2f 100644 --- a/crates/ide-remote/src/lib.rs +++ b/crates/ide-remote/src/lib.rs @@ -1,7 +1,8 @@ //! Remote work over SSH: SFTP deployment ([`deploy`], [`mapping`]) and run -//! configurations on run targets ([`target`]). +//! configurations on run targets ([`target`]; Docker and Compose ones in [`docker`]). pub mod deploy; +pub mod docker; pub mod import; pub mod mapping; pub mod service; diff --git a/crates/ide-remote/src/service.rs b/crates/ide-remote/src/service.rs index 9b383611..7e5d9a4d 100644 --- a/crates/ide-remote/src/service.rs +++ b/crates/ide-remote/src/service.rs @@ -338,6 +338,9 @@ impl RemoteService { ) -> IdeResult { let target_id = config.extras.target.as_deref().unwrap_or_default(); let target = run_target(settings, target_id)?; + if target.kind != ide_core::remote_settings::RunTargetKind::Ssh { + return Err(IdeError::internal("container run targets start as local runs of the docker CLI")); + } let session = self.ssh.session(&target.ssh_config).await?; let fs = RemoteFs::new(session.sftp().await?); let folder = if target.project_root.trim().is_empty() { diff --git a/crates/ide-run/src/import/mod.rs b/crates/ide-run/src/import/mod.rs index 074bb405..d314f57e 100644 --- a/crates/ide-run/src/import/mod.rs +++ b/crates/ide-run/src/import/mod.rs @@ -241,7 +241,7 @@ pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec, /// Splits a command line into words: whitespace, `'…'` and `"…"` quoting, backslash escapes /// (like `parseArgs` in `src/platform/commandLine.ts`). -pub(crate) fn split_args(line: &str) -> Vec { +pub fn split_args(line: &str) -> Vec { let mut out = Vec::new(); let mut cur = String::new(); let mut has = false; diff --git a/src/bindings.ts b/src/bindings.ts index 1c0e231b..28ff07b2 100644 --- a/src/bindings.ts +++ b/src/bindings.ts @@ -6512,22 +6512,57 @@ export type RunSettings = { terminationTimeoutMs?: number, }; -/** Build, Execution, Deployment › Run Targets: run configurations on another machine. */ +/** + * Build, Execution, Deployment › Run Targets: run configurations on another machine + * or in a container. + */ export type RunTarget = { + kind?: RunTargetKind, id?: string, name?: string, - /** [`SshConfiguration::id`]. */ + /** [`SshConfiguration::id`] (SSH). */ sshConfig?: string, - /** Remote folder the project is synchronised to and run from. */ + /** Remote folder the project is synchronised to and run from (SSH). */ projectRoot?: string, - /** Upload changed project files before each run. */ + /** Upload changed project files before each run (SSH). */ syncBeforeRun?: boolean, /** Interpreters on the target; empty = the one on the remote `PATH`. */ python?: string, node?: string, java?: string, + /** + * Id of a Docker connection of the Services tool window (Docker, Compose); + * empty = the CLI's own daemon. + */ + dockerConnection?: string, + /** Image to run (Docker). */ + image?: string, + /** Compose files, relative to the project; empty = the project's default one (Compose). */ + composeFiles?: string[], + /** Service to run (Compose). */ + service?: string, + /** + * Where the project is in the container; empty = `/workspaces/` (Docker), + * the service's bind mount of the project (Compose). + */ + containerRoot?: string, + /** Published ports, as `docker run --publish` takes them (`8000:8000`). */ + ports?: string[], + /** More options for `docker run` / `docker compose run`, shell-like quoted. */ + runOptions?: string, + /** Run as the image's user instead of the project folder's owner (Linux). */ + imageUser?: boolean, }; +/** Where a run target runs configurations. */ +export type RunTargetKind = +/** Another machine over SSH, the project synchronised to its folder. */ +"ssh" | +/** A new container of an image (`docker run --rm`), the project bind-mounted. */ +"docker" | +/** A new container of a Compose service (`docker compose run --rm`). */ +"compose"; + export type RustSettings = { /** Directory holding `cargo` / `rustc`; empty = `PATH` / rustup default. */ toolchain?: string, diff --git a/src/features/run/executors.ts b/src/features/run/executors.ts index 9d8f5caf..6ac10ded 100644 --- a/src/features/run/executors.ts +++ b/src/features/run/executors.ts @@ -5,11 +5,13 @@ */ import type { RunConfig } from "@/platform/ipc"; import { notifications, useRunExecutors, type ExecutorLaunch } from "@/workbench/contributions"; +import { containerExecutorBlocked } from "./remote"; import { prepareLaunch, startRun } from "./store"; export async function runWithExecutor(config: RunConfig, executorId: string): Promise { const executor = useRunExecutors.getState().executors.get(executorId); if (!executor) return; + if (containerExecutorBlocked(config)) return; if (!(await prepareLaunch(config))) return; let plan: ExecutorLaunch | null; try { diff --git a/src/features/run/remote.test.tsx b/src/features/run/remote.test.tsx index 4c96812f..91a447b0 100644 --- a/src/features/run/remote.test.tsx +++ b/src/features/run/remote.test.tsx @@ -40,7 +40,7 @@ import { executeAction, registerActions } from "@/platform/actions"; import { setContext } from "@/platform/context"; import { IpcError, type RunConfig } from "@/platform/ipc"; import { useProject } from "@/platform/project"; -import type { RunTargetValid } from "@/platform/remoteSettings"; +import { DEFAULT_RUN_TARGET, type RunTargetValid } from "@/platform/remoteSettings"; import { provideNotificationService, provideToolWindowService, @@ -50,7 +50,15 @@ import { import { ConfigForm } from "./ConfigForm"; import { duplicateConfig, upsertConfig, userConfigs } from "./configs"; import { activate } from "./index"; -import { remoteDebugBlocked, remoteLabel, startRemedy, startWithRemedies, withTarget, type RemedyDeps } from "./remote"; +import { + containerExecutorBlocked, + remoteDebugBlocked, + remoteLabel, + startRemedy, + startWithRemedies, + withTarget, + type RemedyDeps, +} from "./remote"; import { resetRuns, saveUserConfigs, startRun, useRun } from "./store"; import { newConfigOfType } from "./types"; import { readRan } from "./services"; @@ -70,6 +78,7 @@ const cfg = (id: string, extra: Partial = {}): RunConfig => ({ }); const box: RunTargetValid = { + ...DEFAULT_RUN_TARGET, id: "box", name: "Build box", sshConfig: "ssh1", @@ -189,6 +198,29 @@ describe("remote debugging", () => { }); }); +describe("coverage and profiling", () => { + const container: RunTargetValid = { + ...DEFAULT_RUN_TARGET, + kind: "docker", + id: "py", + name: "Python", + image: "python:3.12", + }; + + it("are refused in containers, with a notification", () => { + expect(containerExecutorBlocked(cfg("app", { target: "py" }), [box, container])).toBe(true); + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ title: "Coverage and profiling are not supported in containers", level: "warning" }), + ); + }); + + it("are left alone locally and over SSH", () => { + expect(containerExecutorBlocked(cfg("app"), [box, container])).toBe(false); + expect(containerExecutorBlocked(cfg("app", { target: "box" }), [box, container])).toBe(false); + expect(notify).not.toHaveBeenCalled(); + }); +}); + describe("SSH remedies when starting", () => { it("recognise host key and secret errors", () => { expect(startRemedy("Unknown SSH host key for box:22 (ssh-ed25519 SHA256:x)")).toBe("trustHost"); diff --git a/src/features/run/remote.ts b/src/features/run/remote.ts index 611705af..bc2d64ff 100644 --- a/src/features/run/remote.ts +++ b/src/features/run/remote.ts @@ -1,11 +1,12 @@ /** * Run targets (Settings › Run Targets): configurations with a `target` run over - * SSH. Labels, the debug guard and the connection remedies of `runStart`. + * SSH or in a Docker / Compose container. Labels, the debug and executor guards, + * and the connection remedies of `runStart`. */ import { executeAction } from "@/platform/actions"; import { tx, txTemplate } from "@/platform/i18n"; import { call, commands, IpcError, type RunConfig } from "@/platform/ipc"; -import type { RunTargetValid, SshConfigValid } from "@/platform/remoteSettings"; +import { inContainer, type RunTargetValid, type SshConfigValid } from "@/platform/remoteSettings"; import { useWorkflowSettings, workflowSettings } from "@/platform/workflowSettings"; import { notifications } from "@/workbench/contributions"; import { confirm } from "@/workbench/messages"; @@ -51,8 +52,8 @@ export function withTarget(config: RunConfig, target: string): RunConfig { } /** - * Debugging over SSH isn't supported: tells so (offering Run) and returns true - * when `config` runs on a target. + * Debugging on run targets isn't supported: tells so (offering Run) and returns + * true when `config` runs on a target. */ export function remoteDebugBlocked(config: RunConfig): boolean { if (!config.target) return false; @@ -73,6 +74,33 @@ export function remoteDebugBlocked(config: RunConfig): boolean { return true; } +/** + * Coverage and profiling read their results on this machine: tells so (offering + * Run) and returns true when `config` runs in a container. + */ +export function containerExecutorBlocked( + config: RunConfig, + targets: readonly RunTargetValid[] = workflowSettings().runTargets, +): boolean { + const target = targetOf(config, targets); + if (!target || !inContainer(target)) return false; + const where = target.name.trim() || target.id; + notifications().notify({ + group: "run", + level: "warning", + key: "run.containerExecutor", + title: "Coverage and profiling are not supported in containers", + body: txTemplate( + `'${config.name}' runs in a container on ${where}. Run it, or choose “Local machine” under “Run on”.`, + ), + actions: [ + { label: "Run", run: () => void executeAction("run.runConfig", config.id) }, + { label: "Edit Configuration…", run: () => void executeAction("run.editConfigs", config.id) }, + ], + }); + return true; +} + export type StartRemedy = "trustHost" | "sshSecret" | null; /** What a failed remote `runStart` needs from the user. */ diff --git a/src/features/settings/__tests__/runTargets.test.ts b/src/features/settings/__tests__/runTargets.test.ts index 1dfa8e4a..57a7e7fe 100644 --- a/src/features/settings/__tests__/runTargets.test.ts +++ b/src/features/settings/__tests__/runTargets.test.ts @@ -1,12 +1,36 @@ import { describe, expect, it } from "vitest"; import { pageLabels } from "../pages/registry"; -import { parentPath } from "../pages/runTargets"; +import { validateRunTargets } from "@/platform/remoteSettings"; +import { parentPath, splitList } from "../pages/runTargets"; describe("Run Targets page", () => { it("is found by its fields", () => { expect(pageLabels("build.runTargets")).toEqual( expect.arrayContaining(["SSH configuration", "Project folder on target", "Python interpreter"]), ); + expect(pageLabels("build.runTargets")).toEqual( + expect.arrayContaining(["Docker connection", "Image", "Service", "Published ports"]), + ); + }); + + it("reads SSH targets saved before container ones existed, and container fields", () => { + const [ssh, compose] = validateRunTargets([ + { id: "box", name: "Box", sshConfig: "s" }, + { kind: "compose", id: "web", composeFiles: ["compose.yaml", 3], service: "web", ports: ["8000:8000"] }, + ]); + expect(ssh).toMatchObject({ kind: "ssh", sshConfig: "s", image: "", composeFiles: [], imageUser: false }); + expect(compose).toMatchObject({ + kind: "compose", + composeFiles: ["compose.yaml"], + service: "web", + ports: ["8000:8000"], + }); + expect(validateRunTargets([{ kind: "kubernetes", id: "k" }])[0]!.kind).toBe("ssh"); + }); + + it("splits ports and file lists", () => { + expect(splitList(" 8000:8000, 5678:5678;a.yaml ")).toEqual(["8000:8000", "5678:5678", "a.yaml"]); + expect(splitList(" ")).toEqual([]); }); it("walks up remote folders", () => { diff --git a/src/features/settings/pages/runTargets.tsx b/src/features/settings/pages/runTargets.tsx index 88e24d53..0ffe5f19 100644 --- a/src/features/settings/pages/runTargets.tsx +++ b/src/features/settings/pages/runTargets.tsx @@ -1,15 +1,21 @@ /** - * Build, Execution, Deployment › Run Targets: SSH hosts run configurations can - * run on (`RunConfig.target`). Consumed by `ide-run` and the run feature. + * Build, Execution, Deployment › Run Targets: SSH hosts and Docker / Compose + * containers run configurations can run on (`RunConfig.target`). Consumed by + * `ide-run`, `ide-remote` and the run feature. */ import { useCallback, useEffect, useId, useState } from "react"; import clsx from "clsx"; -import { call, commands, isTauri, type RemoteEntry } from "@/platform/ipc"; +import { call, commands, isTauri, type DockerConnection, type RemoteEntry } from "@/platform/ipc"; import { useTx, tx } from "@/platform/i18n"; import { useProject } from "@/platform/project"; -import { DEFAULT_RUN_TARGET, newRemoteId, type RunTargetValid } from "@/platform/remoteSettings"; -import { Button, Checkbox, Dialog, Icon, IconButton, Input, T } from "@/ui"; -import { Row, Section } from "../components/Form"; +import { + DEFAULT_RUN_TARGET, + newRemoteId, + type RunTargetKindValid, + type RunTargetValid, +} from "@/platform/remoteSettings"; +import { Button, Checkbox, Dialog, Icon, IconButton, Input, T, type SelectOption } from "@/ui"; +import { Row, Section, Select } from "../components/Form"; import { useSetting } from "../components/context"; import { SshConfigSelect, useSshConfigs } from "../components/SshConfigSelect"; import { BUILD_GROUP } from "./execution"; @@ -252,6 +258,161 @@ function ProjectFolderField({ target, onChange }: { target: RunTargetValid; onCh ); } +/** Settings typed as lists of words (`8000:8000 5678:5678`, `a.yaml; b.yaml`). */ +export const splitList = (text: string): string[] => + text + .split(/[\s,;]+/) + .map((s) => s.trim()) + .filter(Boolean); + +function ListField({ + label, + value, + onChange, + placeholder, + hint, +}: { + label: string; + value: readonly string[]; + onChange: (v: string[]) => void; + placeholder?: string; + hint?: string; +}) { + const id = useId(); + // Edited as text; the list is stored once the field loses focus. + const [draft, setDraft] = useState(null); + return ( + + setDraft(e.target.value)} + onBlur={() => { + if (draft !== null) onChange(splitList(draft)); + setDraft(null); + }} + /> + + ); +} + +/** The Services tool window's Docker connections (app-wide `docker.json`). */ +function useDockerConnections(): DockerConnection[] { + const [connections, setConnections] = useState([]); + useEffect(() => { + if (!isTauri()) return; + let live = true; + call(commands.dockerSettings()).then( + (s) => live && setConnections(s.connections ?? []), + () => {}, + ); + return () => { + live = false; + }; + }, []); + return connections; +} + +function DockerConnectionSelect({ value, onChange }: { value: string; onChange: (id: string) => void }) { + const connections = useDockerConnections(); + const options: SelectOption[] = [ + { value: "", label: tx("Docker CLI default") }, + ...connections.map((c) => ({ value: c.id, label: c.name })), + ]; + if (value && connections.length && !connections.some((c) => c.id === value)) + options.push({ value, label: tx`${value} (missing)` }); + return update({ containerRoot: e.target.value })} + /> + + update({ ports })} + placeholder="8000:8000" + /> + update({ runOptions })} + placeholder="--network host" + /> + update({ imageUser })} + label="Run as the image's user, not the project folder's owner (Linux)" + /> + + ); +} + function RunTargetsEditor() { const [raw, setTargets] = useSetting("runTargets"); const targets = raw ?? []; @@ -331,7 +492,9 @@ function RunTargetsEditor() { onMouseDown={() => setSelected(t.id)} > {t.name || t.id} - {t.sshConfig ? sshName(t.sshConfig) : ""} + + {t.kind === "ssh" ? (t.sshConfig ? sshName(t.sshConfig) : "") : containerSummary(t)} + ))} @@ -339,15 +502,29 @@ function RunTargetsEditor() { {current && (
update({ name })} /> - - update({ sshConfig })} /> + +