mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-06 00:07:50 +00:00
feat(run): Docker and Compose run targets (#1640)
A run target can be a Docker image or a Compose service: the configuration runs as a local docker run --rm / docker compose run --rm with the project bind-mounted, so the console, PTY, stdin, Stop and test parsers work unchanged. Only daemons on this machine are accepted; on Linux the container runs as the project folder's owner; Stop runs docker stop with the same docker CLI. Debugging, coverage and profiling stay on this machine. Refs #1083 (step A1). Co-authored-by: andreabasile <7715018+andreabasile@users.noreply.github.com>
This commit is contained in:
co-authored by
andreabasile
parent
a5d1b16e59
commit
589cf81bae
Generated
+1
@@ -4544,6 +4544,7 @@ dependencies = [
|
||||
"ignore",
|
||||
"parking_lot",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"specta",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
|
||||
@@ -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<String>,
|
||||
/// Service to run (Compose).
|
||||
pub service: String,
|
||||
/// Where the project is in the container; empty = `/workspaces/<folder>` (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<String>,
|
||||
/// 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::<RunTarget>(json).unwrap(), docker);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<RunInfo> {
|
||||
}
|
||||
|
||||
mod commands;
|
||||
mod container;
|
||||
pub use commands::*;
|
||||
|
||||
@@ -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<RunConfig> {
|
||||
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<DockerEndpoint> {
|
||||
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<BTreeMap<String, String>> {
|
||||
let extras = &config.extras;
|
||||
if extras.env_files.is_empty() {
|
||||
return Ok(BTreeMap::new());
|
||||
}
|
||||
let paths: Vec<PathBuf> = 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))
|
||||
}
|
||||
@@ -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<Host> {
|
||||
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);
|
||||
}
|
||||
@@ -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<Host> {
|
||||
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<Mutex<String>>;
|
||||
|
||||
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();
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<String, String>,
|
||||
/// Variables of the configuration's `.env` files, already read.
|
||||
pub file_env: &'a BTreeMap<String, String>,
|
||||
/// 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<RunConfig> {
|
||||
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<String, String> = 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<String> = 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<String> {
|
||||
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<String, String>,
|
||||
files: &'a BTreeMap<String, String>,
|
||||
) -> 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'");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -338,6 +338,9 @@ impl RemoteService {
|
||||
) -> IdeResult<RunInfo> {
|
||||
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() {
|
||||
|
||||
@@ -241,7 +241,7 @@ pub(crate) fn config(name: &str, kind: &str, command: String, args: Vec<String>,
|
||||
|
||||
/// 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<String> {
|
||||
pub fn split_args(line: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
let mut cur = String::new();
|
||||
let mut has = false;
|
||||
|
||||
+39
-4
@@ -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/<folder>` (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,
|
||||
|
||||
@@ -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<void> {
|
||||
const executor = useRunExecutors.getState().executors.get(executorId);
|
||||
if (!executor) return;
|
||||
if (containerExecutorBlocked(config)) return;
|
||||
if (!(await prepareLaunch(config))) return;
|
||||
let plan: ExecutorLaunch | null;
|
||||
try {
|
||||
|
||||
@@ -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> = {}): 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");
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
return (
|
||||
<Row label={label} htmlFor={id} className={styles.alignedRow} hint={hint}>
|
||||
<Input
|
||||
id={id}
|
||||
mono
|
||||
className={styles.wide}
|
||||
value={draft ?? value.join(" ")}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (draft !== null) onChange(splitList(draft));
|
||||
setDraft(null);
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
|
||||
/** The Services tool window's Docker connections (app-wide `docker.json`). */
|
||||
function useDockerConnections(): DockerConnection[] {
|
||||
const [connections, setConnections] = useState<DockerConnection[]>([]);
|
||||
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 <Select value={value} options={options} onChange={onChange} aria-label="Docker connection" />;
|
||||
}
|
||||
|
||||
const KIND_OPTIONS: SelectOption[] = [
|
||||
{ value: "ssh", label: "SSH" },
|
||||
{ value: "docker", label: "Docker" },
|
||||
{ value: "compose", label: "Docker Compose" },
|
||||
];
|
||||
|
||||
/** Image or service of a container target, for the list. */
|
||||
function containerSummary(t: RunTargetValid): string {
|
||||
if (t.kind === "docker") return t.image;
|
||||
if (t.kind === "compose") return t.service ? `compose:${t.service}` : "compose";
|
||||
return "";
|
||||
}
|
||||
|
||||
function ContainerFields({ target, update }: { target: RunTargetValid; update: (p: Partial<RunTargetValid>) => void }) {
|
||||
const projectName = useProject((s) => s.project?.name ?? "project");
|
||||
const compose = target.kind === "compose";
|
||||
return (
|
||||
<>
|
||||
<Row
|
||||
label="Docker connection"
|
||||
className={styles.alignedRow}
|
||||
hint="Only daemons on this machine can mount the project."
|
||||
>
|
||||
<DockerConnectionSelect
|
||||
value={target.dockerConnection}
|
||||
onChange={(dockerConnection) => update({ dockerConnection })}
|
||||
/>
|
||||
</Row>
|
||||
{compose ? (
|
||||
<>
|
||||
<ListField
|
||||
label="Compose files"
|
||||
value={target.composeFiles}
|
||||
onChange={(composeFiles) => update({ composeFiles })}
|
||||
placeholder="compose.yaml"
|
||||
hint="Relative to the project; empty uses the project's default file."
|
||||
/>
|
||||
<TextField label="Service" value={target.service} onChange={(service) => update({ service })} />
|
||||
</>
|
||||
) : (
|
||||
<TextField
|
||||
label="Image"
|
||||
value={target.image}
|
||||
onChange={(image) => update({ image })}
|
||||
placeholder="python:3.12"
|
||||
/>
|
||||
)}
|
||||
<Row
|
||||
label="Container folder"
|
||||
className={styles.alignedRow}
|
||||
hint={
|
||||
compose
|
||||
? "Empty: where the service mounts the project."
|
||||
: "The project is mounted here; paths and the working directory are mapped into it."
|
||||
}
|
||||
>
|
||||
<Input
|
||||
mono
|
||||
aria-label="Container folder"
|
||||
className={styles.wide}
|
||||
value={target.containerRoot}
|
||||
placeholder={compose ? "" : `/workspaces/${projectName}`}
|
||||
onChange={(e) => update({ containerRoot: e.target.value })}
|
||||
/>
|
||||
</Row>
|
||||
<ListField
|
||||
label="Published ports"
|
||||
value={target.ports}
|
||||
onChange={(ports) => update({ ports })}
|
||||
placeholder="8000:8000"
|
||||
/>
|
||||
<TextField
|
||||
label="Run options"
|
||||
value={target.runOptions}
|
||||
onChange={(runOptions) => update({ runOptions })}
|
||||
placeholder="--network host"
|
||||
/>
|
||||
<Checkbox
|
||||
checked={target.imageUser}
|
||||
onChange={(imageUser) => update({ imageUser })}
|
||||
label="Run as the image's user, not the project folder's owner (Linux)"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RunTargetsEditor() {
|
||||
const [raw, setTargets] = useSetting<RunTargetValid[] | undefined>("runTargets");
|
||||
const targets = raw ?? [];
|
||||
@@ -331,7 +492,9 @@ function RunTargetsEditor() {
|
||||
onMouseDown={() => setSelected(t.id)}
|
||||
>
|
||||
<span>{t.name || t.id}</span>
|
||||
<span className={styles.toolGroup}>{t.sshConfig ? sshName(t.sshConfig) : ""}</span>
|
||||
<span className={styles.toolGroup}>
|
||||
{t.kind === "ssh" ? (t.sshConfig ? sshName(t.sshConfig) : "") : containerSummary(t)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -339,15 +502,29 @@ function RunTargetsEditor() {
|
||||
{current && (
|
||||
<Section title="Target Settings">
|
||||
<TextField label="Name" value={current.name} onChange={(name) => update({ name })} />
|
||||
<Row label="SSH configuration" className={styles.alignedRow}>
|
||||
<SshConfigSelect value={current.sshConfig} onChange={(sshConfig) => update({ sshConfig })} />
|
||||
<Row label="Type" className={styles.alignedRow}>
|
||||
<Select
|
||||
value={current.kind}
|
||||
options={KIND_OPTIONS}
|
||||
onChange={(kind) => update({ kind: kind as RunTargetKindValid })}
|
||||
aria-label="Type"
|
||||
/>
|
||||
</Row>
|
||||
<ProjectFolderField target={current} onChange={(projectRoot) => update({ projectRoot })} />
|
||||
<Checkbox
|
||||
checked={current.syncBeforeRun}
|
||||
onChange={(syncBeforeRun) => update({ syncBeforeRun })}
|
||||
label="Upload changed project files before each run"
|
||||
/>
|
||||
{current.kind === "ssh" ? (
|
||||
<>
|
||||
<Row label="SSH configuration" className={styles.alignedRow}>
|
||||
<SshConfigSelect value={current.sshConfig} onChange={(sshConfig) => update({ sshConfig })} />
|
||||
</Row>
|
||||
<ProjectFolderField target={current} onChange={(projectRoot) => update({ projectRoot })} />
|
||||
<Checkbox
|
||||
checked={current.syncBeforeRun}
|
||||
onChange={(syncBeforeRun) => update({ syncBeforeRun })}
|
||||
label="Upload changed project files before each run"
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<ContainerFields target={current} update={update} />
|
||||
)}
|
||||
<TextField
|
||||
label="Python interpreter"
|
||||
value={current.python}
|
||||
@@ -373,6 +550,11 @@ function RunTargetsEditor() {
|
||||
{
|
||||
"Choose a target under “Run on” in a run configuration. Interpreters left empty are looked up on the target's PATH. SSH configurations are managed in Tools › SSH Configurations."
|
||||
}
|
||||
</T>{" "}
|
||||
<T>
|
||||
{
|
||||
"Docker connections come from the Services tool window. Each run gets a new container, removed when it ends; debugging, coverage and profiling stay on this machine."
|
||||
}
|
||||
</T>
|
||||
</div>
|
||||
</div>
|
||||
@@ -386,7 +568,7 @@ export const RUN_TARGET_PAGES: PageSpec[] = [
|
||||
group: BUILD_GROUP,
|
||||
order: 364,
|
||||
projectScope: true,
|
||||
keywords: ["ssh", "remote", "target", "run on", "interpreter", "upload", "sync"],
|
||||
keywords: ["ssh", "remote", "target", "run on", "interpreter", "upload", "sync", "docker", "compose", "container"],
|
||||
sections: [
|
||||
{
|
||||
options: [
|
||||
@@ -400,6 +582,13 @@ export const RUN_TARGET_PAGES: PageSpec[] = [
|
||||
"Python interpreter",
|
||||
"Node.js interpreter",
|
||||
"Java executable",
|
||||
"Docker connection",
|
||||
"Image",
|
||||
"Compose files",
|
||||
"Service",
|
||||
"Container folder",
|
||||
"Published ports",
|
||||
"Run options",
|
||||
],
|
||||
component: RunTargetsEditor,
|
||||
},
|
||||
|
||||
@@ -6310,5 +6310,17 @@
|
||||
"Composer dependencies are not installed": "未安装 Composer 依赖",
|
||||
"Installed Composer packages differ from composer.lock": "已安装的 Composer 包与 composer.lock 不一致",
|
||||
"Composer Install": "安装 Composer 依赖",
|
||||
"Input for the program (pause to evaluate)": "程序输入(暂停后可计算)"
|
||||
"Input for the program (pause to evaluate)": "程序输入(暂停后可计算)",
|
||||
"Coverage and profiling are not supported in containers": "容器中不支持覆盖率和性能分析",
|
||||
"'{name}' runs in a container on {where}. Run it, or choose “Local machine” under “Run on”.": "'{name}' 在 {where} 的容器中运行。请直接运行,或在“运行于”中选择“本地计算机”。",
|
||||
"Docker connection": "Docker 连接",
|
||||
"Only daemons on this machine can mount the project.": "只有本机上的守护进程才能挂载项目。",
|
||||
"Relative to the project; empty uses the project's default file.": "相对于项目;留空则使用项目的默认文件。",
|
||||
"Service": "服务",
|
||||
"Container folder": "容器文件夹",
|
||||
"Empty: where the service mounts the project.": "留空:使用服务挂载项目的位置。",
|
||||
"The project is mounted here; paths and the working directory are mapped into it.": "项目挂载在此处;路径和工作目录会映射到其中。",
|
||||
"Published ports": "发布的端口",
|
||||
"Run as the image's user, not the project folder's owner (Linux)": "以镜像的用户运行,而不是项目文件夹的所有者(Linux)",
|
||||
"Docker connections come from the Services tool window. Each run gets a new container, removed when it ends; debugging, coverage and profiling stay on this machine.": "Docker 连接来自“服务”工具窗口。每次运行都会使用一个新容器,运行结束后删除;调试、覆盖率和性能分析仍在本机进行。"
|
||||
}
|
||||
|
||||
@@ -95,6 +95,9 @@ const UNTRANSLATED = new Set<string>([
|
||||
"Ruff",
|
||||
"IntelliJ IDEA",
|
||||
"PHPStan",
|
||||
// Docker run target placeholder values
|
||||
"compose.yaml",
|
||||
"python:3.12",
|
||||
// Go environment variables and placeholder values
|
||||
"CGO",
|
||||
"GOPROXY",
|
||||
|
||||
@@ -42,7 +42,10 @@ export interface DeploymentSettingsValid {
|
||||
exclude: string[];
|
||||
}
|
||||
|
||||
export type RunTargetKindValid = "ssh" | "docker" | "compose";
|
||||
|
||||
export interface RunTargetValid {
|
||||
kind: RunTargetKindValid;
|
||||
id: string;
|
||||
name: string;
|
||||
sshConfig: string;
|
||||
@@ -51,6 +54,15 @@ export interface RunTargetValid {
|
||||
python: string;
|
||||
node: string;
|
||||
java: string;
|
||||
/** Services tool window Docker connection id; empty = the CLI's daemon. */
|
||||
dockerConnection: string;
|
||||
image: string;
|
||||
composeFiles: string[];
|
||||
service: string;
|
||||
containerRoot: string;
|
||||
ports: string[];
|
||||
runOptions: string;
|
||||
imageUser: boolean;
|
||||
}
|
||||
|
||||
export interface RemoteSettings {
|
||||
@@ -91,6 +103,7 @@ export const DEFAULT_DEPLOYMENT: DeploymentSettingsValid = {
|
||||
};
|
||||
|
||||
export const DEFAULT_RUN_TARGET: RunTargetValid = {
|
||||
kind: "ssh",
|
||||
id: "",
|
||||
name: "",
|
||||
sshConfig: "",
|
||||
@@ -99,6 +112,14 @@ export const DEFAULT_RUN_TARGET: RunTargetValid = {
|
||||
python: "",
|
||||
node: "",
|
||||
java: "",
|
||||
dockerConnection: "",
|
||||
image: "",
|
||||
composeFiles: [],
|
||||
service: "",
|
||||
containerRoot: "",
|
||||
ports: [],
|
||||
runOptions: "",
|
||||
imageUser: false,
|
||||
};
|
||||
|
||||
export const DEFAULT_REMOTE: RemoteSettings = {
|
||||
@@ -181,6 +202,7 @@ export function validateDeployment(raw: unknown): DeploymentSettingsValid {
|
||||
export function validateRunTargets(raw: unknown): RunTargetValid[] {
|
||||
const d = DEFAULT_RUN_TARGET;
|
||||
return withIds(raw, "target", (o) => ({
|
||||
kind: oneOf(o.kind, ["ssh", "docker", "compose"] as const, d.kind),
|
||||
id: text(o.id, ""),
|
||||
name: text(o.name, ""),
|
||||
sshConfig: text(o.sshConfig, ""),
|
||||
@@ -189,6 +211,14 @@ export function validateRunTargets(raw: unknown): RunTargetValid[] {
|
||||
python: text(o.python, ""),
|
||||
node: text(o.node, ""),
|
||||
java: text(o.java, ""),
|
||||
dockerConnection: text(o.dockerConnection, ""),
|
||||
image: text(o.image, ""),
|
||||
composeFiles: strings(o.composeFiles, []),
|
||||
service: text(o.service, ""),
|
||||
containerRoot: text(o.containerRoot, ""),
|
||||
ports: strings(o.ports, []),
|
||||
runOptions: text(o.runOptions, ""),
|
||||
imageUser: bool(o.imageUser, d.imageUser),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -212,6 +242,9 @@ let seq = 0;
|
||||
/** A fresh id for a new SSH configuration, server or target. */
|
||||
export const newRemoteId = (prefix: string) => `${prefix}-${Date.now().toString(36)}-${++seq}`;
|
||||
|
||||
/** Whether `target` runs configurations in a container (Docker, Compose) rather than over SSH. */
|
||||
export const inContainer = (target: Pick<RunTargetValid, "kind">) => target.kind !== "ssh";
|
||||
|
||||
/** Backend connection descriptor for deployment and folder browsing. */
|
||||
export function deploymentConnection(server: DeploymentServerValid): { kind: "local" } | { kind: "sftp"; ssh: string } {
|
||||
return server.kind === "local" ? { kind: "local" } : { kind: "sftp", ssh: server.sshConfig };
|
||||
|
||||
Reference in New Issue
Block a user