дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
Project & files - Project view modes (Project Files / Open Files / All Changed Files / Scratches); External Libraries for Python, JDK (src.zip), Node, Cargo, Go, Maven/Gradle jars; read-only jar/zip browsing - ⌘N New popup with type search and more file kinds; New Project, Rename Project, Reload All from Disk, Export/Import Settings, Power Save Mode - File type associations, extra content roots and exclude patterns (indexed and watched without reopening the project) Editor & navigation - Find bar: search scope (comments/strings), Preserve Case, Exclude, multiline, history; Find in Files: Module scope, custom pattern scopes, multiline - Search Everywhere filters, Assign Shortcut, settings results; named bookmark lists, file/folder bookmarks; Structure filters and Show Inherited; Recent Locations timestamps and breadcrumbs; Code Vision author - Completion options, editor tab placement/layout, inline completion (LSP 3.18) - Spell checking (Typo inspection, ide-spell) and Inspections settings with custom profiles; Code Style schemes, formatter markers, Do not format Run, VCS, services, database - Typed run configurations (Shell, Program, npm, Gradle, Maven, Docker, Compound), Modify options, Before launch, env dialog, Current File, Emulate terminal (PTY) - Git log graph/view options, Go to Hash/Branch/Tag, date filter keeps the graph; Pull/Merge/Rebase dialogs, Manage Remotes, Worktrees - Generic Services host (Docker connections/registries, run sessions); TODO scope/changes tabs with changelists; Coverage report import - Data sources: wire-compatible vendors, URL import, SSL certs, Options, Schemas and Advanced tabs, color and comment Workbench - Run Anything, Customize Main Toolbar, named tool window layouts, menu completeness, Code Cleanup, macros, "Don't Show Again", editor banners - Chinese translations for the new UI Claude-Session: https://claude.ai/code/session_01SaPPsVPuk6ZTNsoUCNCbMv
403 рядки
16 KiB
Rust
403 рядки
16 KiB
Rust
//! Docker connections (which daemon the CLI talks to) and registries, as the
|
|
//! Services tool window's "Add Service" menu creates them. Both are app-wide
|
|
//! like in IDEA: `docker.json` in the app data directory; registry passwords
|
|
//! live beside it in an owner-only `docker-passwords.json`.
|
|
|
|
use ide_core::{IdeError, IdeResult};
|
|
use serde::{Deserialize, Serialize};
|
|
use specta::Type;
|
|
use std::collections::BTreeMap;
|
|
use std::path::{Path, PathBuf};
|
|
use std::process::Command;
|
|
|
|
/// Which daemon a docker command talks to.
|
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, Type)]
|
|
#[serde(tag = "kind", rename_all = "camelCase", rename_all_fields = "camelCase")]
|
|
pub enum DockerEndpoint {
|
|
/// Whatever the CLI is configured for (`DOCKER_HOST`, the current context).
|
|
#[default]
|
|
Default,
|
|
/// A local daemon socket, e.g. `/var/run/docker.sock` (or a `npipe://` URL on Windows).
|
|
Socket { path: String },
|
|
/// A remote daemon, e.g. `tcp://build:2376` or `ssh://me@build`; with TLS when
|
|
/// `tls_cert_dir` holds `ca.pem`, `cert.pem` and `key.pem`.
|
|
Tcp { host: String, tls_cert_dir: Option<String> },
|
|
/// A named `docker context`.
|
|
Context { name: String },
|
|
}
|
|
|
|
impl DockerEndpoint {
|
|
/// Global CLI options selecting this endpoint (before the subcommand).
|
|
pub fn global_args(&self) -> Vec<String> {
|
|
match self {
|
|
Self::Default => Vec::new(),
|
|
Self::Socket { path } => {
|
|
let path = path.trim();
|
|
let url = if path.contains("://") { path.to_owned() } else { format!("unix://{path}") };
|
|
vec!["--host".into(), url]
|
|
}
|
|
Self::Tcp { host, tls_cert_dir } => {
|
|
let host = host.trim();
|
|
let url = if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") };
|
|
let mut args = vec!["--host".into(), url];
|
|
if let Some(dir) = tls_cert_dir.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
|
|
let file = |name: &str| Path::new(dir).join(name).to_string_lossy().into_owned();
|
|
args.extend([
|
|
"--tlsverify".into(),
|
|
"--tlscacert".into(),
|
|
file("ca.pem"),
|
|
"--tlscert".into(),
|
|
file("cert.pem"),
|
|
"--tlskey".into(),
|
|
file("key.pem"),
|
|
]);
|
|
}
|
|
args
|
|
}
|
|
Self::Context { name } => vec!["--context".into(), name.trim().to_owned()],
|
|
}
|
|
}
|
|
|
|
/// Adds the global options to `cmd`; an explicit endpoint also drops the
|
|
/// inherited `DOCKER_HOST` / `DOCKER_CONTEXT`, which would conflict.
|
|
pub(crate) fn apply(&self, cmd: &mut Command) {
|
|
if *self != Self::Default {
|
|
cmd.env_remove("DOCKER_HOST").env_remove("DOCKER_CONTEXT").env_remove("DOCKER_TLS_VERIFY");
|
|
}
|
|
cmd.args(self.global_args());
|
|
}
|
|
|
|
/// The same selection as environment variables, for docker command lines run by
|
|
/// others (run configurations). An empty value clears an inherited variable: the
|
|
/// CLI treats `DOCKER_HOST=""` as unset.
|
|
pub fn env_vars(&self) -> BTreeMap<String, String> {
|
|
let mut env = BTreeMap::new();
|
|
let mut set = |k: &str, v: String| {
|
|
env.insert(k.to_owned(), v);
|
|
};
|
|
match self {
|
|
Self::Default => {}
|
|
Self::Socket { path } => {
|
|
let path = path.trim();
|
|
set("DOCKER_HOST", if path.contains("://") { path.to_owned() } else { format!("unix://{path}") });
|
|
set("DOCKER_CONTEXT", String::new());
|
|
set("DOCKER_TLS_VERIFY", String::new());
|
|
}
|
|
Self::Tcp { host, tls_cert_dir } => {
|
|
let host = host.trim();
|
|
set("DOCKER_HOST", if host.contains("://") { host.to_owned() } else { format!("tcp://{host}") });
|
|
set("DOCKER_CONTEXT", String::new());
|
|
match tls_cert_dir.as_deref().map(str::trim).filter(|d| !d.is_empty()) {
|
|
// `DOCKER_CERT_PATH` holds `ca.pem`, `cert.pem` and `key.pem`, as `global_args` expects.
|
|
Some(dir) => {
|
|
set("DOCKER_TLS_VERIFY", "1".into());
|
|
set("DOCKER_CERT_PATH", dir.to_owned());
|
|
}
|
|
None => set("DOCKER_TLS_VERIFY", String::new()),
|
|
}
|
|
}
|
|
Self::Context { name } => {
|
|
set("DOCKER_CONTEXT", name.trim().to_owned());
|
|
set("DOCKER_HOST", String::new());
|
|
set("DOCKER_TLS_VERIFY", String::new());
|
|
}
|
|
}
|
|
env
|
|
}
|
|
|
|
pub fn validate(&self) -> IdeResult<()> {
|
|
let empty = match self {
|
|
Self::Default => false,
|
|
Self::Socket { path } => path.trim().is_empty(),
|
|
Self::Tcp { host, .. } => host.trim().is_empty(),
|
|
Self::Context { name } => name.trim().is_empty() || name.trim().starts_with('-'),
|
|
};
|
|
if empty { Err(IdeError::invalid("the Docker connection needs a socket, host or context")) } else { Ok(()) }
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DockerConnection {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub endpoint: DockerEndpoint,
|
|
}
|
|
|
|
/// A registry to log in to (Docker Hub, GHCR, a private one). The password is stored separately.
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DockerRegistry {
|
|
pub id: String,
|
|
pub name: String,
|
|
/// `docker.io`, `ghcr.io`, `registry.example.com:5000`.
|
|
pub address: String,
|
|
#[serde(default)]
|
|
pub username: String,
|
|
}
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, Type)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct DockerSettings {
|
|
#[serde(default = "default_connections")]
|
|
pub connections: Vec<DockerConnection>,
|
|
#[serde(default)]
|
|
pub registries: Vec<DockerRegistry>,
|
|
}
|
|
|
|
impl Default for DockerSettings {
|
|
fn default() -> Self {
|
|
Self { connections: default_connections(), registries: Vec::new() }
|
|
}
|
|
}
|
|
|
|
/// Before anything is configured the CLI's own daemon is shown, as before connections existed.
|
|
fn default_connections() -> Vec<DockerConnection> {
|
|
vec![DockerConnection { id: "default".into(), name: "Docker".into(), endpoint: DockerEndpoint::Default }]
|
|
}
|
|
|
|
const SETTINGS_FILE: &str = "docker.json";
|
|
const PASSWORDS_FILE: &str = "docker-passwords.json";
|
|
|
|
pub fn load_settings(data_dir: &Path) -> DockerSettings {
|
|
match std::fs::read(data_dir.join(SETTINGS_FILE)) {
|
|
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|e| {
|
|
tracing::warn!("{SETTINGS_FILE}: {e}");
|
|
DockerSettings::default()
|
|
}),
|
|
Err(_) => DockerSettings::default(),
|
|
}
|
|
}
|
|
|
|
pub fn save_settings(data_dir: &Path, settings: &DockerSettings) -> IdeResult<()> {
|
|
for c in &settings.connections {
|
|
if c.id.is_empty() || c.name.trim().is_empty() {
|
|
return Err(IdeError::invalid("every Docker connection needs a name"));
|
|
}
|
|
c.endpoint.validate()?;
|
|
}
|
|
for r in &settings.registries {
|
|
if r.id.is_empty() || r.address.trim().is_empty() {
|
|
return Err(IdeError::invalid("every Docker registry needs an address"));
|
|
}
|
|
}
|
|
let bytes = serde_json::to_vec_pretty(settings).map_err(|e| IdeError::internal(e.to_string()))?;
|
|
write_file(&data_dir.join(SETTINGS_FILE), &bytes, false)?;
|
|
// Forget passwords of registries that are gone.
|
|
let mut passwords = read_passwords(data_dir);
|
|
let before = passwords.len();
|
|
passwords.retain(|id, _| settings.registries.iter().any(|r| &r.id == id));
|
|
if passwords.len() != before { write_passwords(data_dir, &passwords) } else { Ok(()) }
|
|
}
|
|
|
|
fn read_passwords(data_dir: &Path) -> BTreeMap<String, String> {
|
|
std::fs::read(data_dir.join(PASSWORDS_FILE)).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or_default()
|
|
}
|
|
|
|
fn write_passwords(data_dir: &Path, all: &BTreeMap<String, String>) -> IdeResult<()> {
|
|
let bytes = serde_json::to_vec_pretty(all).map_err(|e| IdeError::internal(e.to_string()))?;
|
|
write_file(&data_dir.join(PASSWORDS_FILE), &bytes, true)
|
|
}
|
|
|
|
pub fn registry_password(data_dir: &Path, id: &str) -> Option<String> {
|
|
read_passwords(data_dir).remove(id)
|
|
}
|
|
|
|
/// Saves (or with `None` forgets) the password of registry `id`.
|
|
pub fn set_registry_password(data_dir: &Path, id: &str, password: Option<&str>) -> IdeResult<()> {
|
|
let mut all = read_passwords(data_dir);
|
|
match password {
|
|
Some(p) => all.insert(id.to_owned(), p.to_owned()),
|
|
None => all.remove(id),
|
|
};
|
|
write_passwords(data_dir, &all)
|
|
}
|
|
|
|
fn write_file(path: &Path, bytes: &[u8], private: bool) -> IdeResult<()> {
|
|
if let Some(dir) = path.parent() {
|
|
std::fs::create_dir_all(dir)?;
|
|
}
|
|
let tmp = path.with_extension("json.tmp");
|
|
let mut options = std::fs::OpenOptions::new();
|
|
options.write(true).create(true).truncate(true);
|
|
#[cfg(unix)]
|
|
if private {
|
|
std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
|
|
}
|
|
#[cfg(not(unix))]
|
|
let _ = private;
|
|
std::io::Write::write_all(&mut options.open(&tmp)?, bytes)?;
|
|
std::fs::rename(&tmp, path)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Canonical registry host: no scheme or path; Docker Hub's aliases become `docker.io`.
|
|
pub fn registry_host(address: &str) -> String {
|
|
let a = address.trim();
|
|
let a = a.split_once("://").map_or(a, |(_, rest)| rest);
|
|
let host = a.split('/').next().unwrap_or_default().to_ascii_lowercase();
|
|
match host.as_str() {
|
|
"index.docker.io" | "registry-1.docker.io" | "registry.hub.docker.com" | "hub.docker.com" => "docker.io".into(),
|
|
_ => host,
|
|
}
|
|
}
|
|
|
|
/// Registry host an image reference pulls from (`postgres:16` → `docker.io`).
|
|
pub fn reference_registry(reference: &str) -> String {
|
|
match reference.split_once('/') {
|
|
Some((first, _)) if first.contains('.') || first.contains(':') || first == "localhost" => registry_host(first),
|
|
_ => "docker.io".into(),
|
|
}
|
|
}
|
|
|
|
/// Credentials for a `docker login`.
|
|
#[derive(Clone, Debug)]
|
|
pub struct RegistryAuth {
|
|
pub address: String,
|
|
pub username: String,
|
|
pub password: String,
|
|
}
|
|
|
|
/// The saved registry (with a password) matching `reference`, if any.
|
|
pub fn auth_for(data_dir: &Path, reference: &str) -> Option<RegistryAuth> {
|
|
let host = reference_registry(reference);
|
|
let settings = load_settings(data_dir);
|
|
let registry =
|
|
settings.registries.into_iter().find(|r| registry_host(&r.address) == host && !r.username.is_empty())?;
|
|
let password = registry_password(data_dir, ®istry.id)?;
|
|
Some(RegistryAuth { address: registry.address, username: registry.username, password })
|
|
}
|
|
|
|
/// A throwaway docker config directory: logins happen there, never in the user's
|
|
/// `~/.docker/config.json`. Contexts are copied so a context endpoint still resolves.
|
|
pub(crate) struct TempConfig {
|
|
pub dir: PathBuf,
|
|
}
|
|
|
|
impl TempConfig {
|
|
pub fn new() -> IdeResult<Self> {
|
|
let nanos =
|
|
std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
|
|
let dir = std::env::temp_dir().join(format!("openidea-docker-{}-{nanos}", std::process::id()));
|
|
std::fs::create_dir_all(&dir)?;
|
|
#[cfg(unix)]
|
|
{
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let _ = std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700));
|
|
}
|
|
if let Some(contexts) = user_config_dir().map(|d| d.join("contexts")).filter(|d| d.is_dir()) {
|
|
let _ = copy_dir(&contexts, &dir.join("contexts"));
|
|
}
|
|
Ok(Self { dir })
|
|
}
|
|
}
|
|
|
|
impl Drop for TempConfig {
|
|
fn drop(&mut self) {
|
|
let _ = std::fs::remove_dir_all(&self.dir);
|
|
}
|
|
}
|
|
|
|
fn user_config_dir() -> Option<PathBuf> {
|
|
if let Some(dir) = std::env::var_os("DOCKER_CONFIG") {
|
|
return Some(PathBuf::from(dir));
|
|
}
|
|
let home = std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" })?;
|
|
Some(PathBuf::from(home).join(".docker"))
|
|
}
|
|
|
|
fn copy_dir(from: &Path, to: &Path) -> std::io::Result<()> {
|
|
std::fs::create_dir_all(to)?;
|
|
for e in std::fs::read_dir(from)?.flatten() {
|
|
let target = to.join(e.file_name());
|
|
if e.file_type()?.is_dir() {
|
|
copy_dir(&e.path(), &target)?;
|
|
} else {
|
|
std::fs::copy(e.path(), target)?;
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn endpoint_args() {
|
|
assert!(DockerEndpoint::Default.global_args().is_empty());
|
|
assert_eq!(
|
|
DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }.global_args(),
|
|
["--host", "unix:///var/run/docker.sock"]
|
|
);
|
|
let tls = DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: Some("/certs".into()) }.global_args();
|
|
assert_eq!(tls[..3], ["--host", "tcp://build:2376", "--tlsverify"]);
|
|
assert!(tls.contains(&Path::new("/certs").join("key.pem").to_string_lossy().into_owned()));
|
|
assert_eq!(
|
|
DockerEndpoint::Tcp { host: "ssh://me@box".into(), tls_cert_dir: Some(" ".into()) }.global_args(),
|
|
["--host", "ssh://me@box"]
|
|
);
|
|
assert_eq!(DockerEndpoint::Context { name: "orbstack".into() }.global_args(), ["--context", "orbstack"]);
|
|
assert!(DockerEndpoint::Context { name: "--x".into() }.validate().is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn endpoint_env() {
|
|
assert!(DockerEndpoint::Default.env_vars().is_empty());
|
|
let socket = DockerEndpoint::Socket { path: "/var/run/docker.sock".into() }.env_vars();
|
|
assert_eq!(socket["DOCKER_HOST"], "unix:///var/run/docker.sock");
|
|
assert_eq!(socket["DOCKER_CONTEXT"], "");
|
|
let tls = DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: Some("/certs".into()) }.env_vars();
|
|
assert_eq!(
|
|
(tls["DOCKER_HOST"].as_str(), tls["DOCKER_TLS_VERIFY"].as_str(), tls["DOCKER_CERT_PATH"].as_str()),
|
|
("tcp://build:2376", "1", "/certs")
|
|
);
|
|
let ssh = DockerEndpoint::Tcp { host: "ssh://me@box".into(), tls_cert_dir: None }.env_vars();
|
|
assert_eq!((ssh["DOCKER_HOST"].as_str(), ssh["DOCKER_TLS_VERIFY"].as_str()), ("ssh://me@box", ""));
|
|
assert!(!ssh.contains_key("DOCKER_CERT_PATH"));
|
|
let ctx = DockerEndpoint::Context { name: " orbstack ".into() }.env_vars();
|
|
assert_eq!((ctx["DOCKER_CONTEXT"].as_str(), ctx["DOCKER_HOST"].as_str()), ("orbstack", ""));
|
|
}
|
|
|
|
#[test]
|
|
fn registries_match_references() {
|
|
assert_eq!(reference_registry("postgres:16"), "docker.io");
|
|
assert_eq!(reference_registry("library/postgres"), "docker.io");
|
|
assert_eq!(reference_registry("ghcr.io/org/app:1"), "ghcr.io");
|
|
assert_eq!(reference_registry("localhost:5000/app"), "localhost:5000");
|
|
assert_eq!(registry_host("https://index.docker.io/v1/"), "docker.io");
|
|
assert_eq!(registry_host("GHCR.io"), "ghcr.io");
|
|
}
|
|
|
|
#[test]
|
|
fn settings_round_trip_and_passwords() {
|
|
let tmp = tempfile::tempdir().unwrap();
|
|
let dir = tmp.path();
|
|
assert_eq!(load_settings(dir).connections[0].endpoint, DockerEndpoint::Default);
|
|
let settings = DockerSettings {
|
|
connections: vec![DockerConnection {
|
|
id: "c1".into(),
|
|
name: "Build box".into(),
|
|
endpoint: DockerEndpoint::Tcp { host: "build:2376".into(), tls_cert_dir: None },
|
|
}],
|
|
registries: vec![DockerRegistry {
|
|
id: "r1".into(),
|
|
name: "GHCR".into(),
|
|
address: "ghcr.io".into(),
|
|
username: "me".into(),
|
|
}],
|
|
};
|
|
save_settings(dir, &settings).unwrap();
|
|
assert_eq!(load_settings(dir), settings);
|
|
set_registry_password(dir, "r1", Some("s3cret")).unwrap();
|
|
let auth = auth_for(dir, "ghcr.io/org/app").unwrap();
|
|
assert_eq!((auth.username.as_str(), auth.password.as_str()), ("me", "s3cret"));
|
|
assert!(auth_for(dir, "postgres").is_none());
|
|
save_settings(dir, &DockerSettings { registries: Vec::new(), ..settings }).unwrap();
|
|
assert_eq!(registry_password(dir, "r1"), None);
|
|
// An empty list stays empty (the default connection is only for a missing file).
|
|
save_settings(dir, &DockerSettings { connections: Vec::new(), registries: Vec::new() }).unwrap();
|
|
assert!(load_settings(dir).connections.is_empty());
|
|
}
|
|
}
|