mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-14 16:07:52 +00:00
feat(docker): pick an SSH configuration for TCP/SSH Docker connections (#2737)
The TCP / SSH host kind of a Docker connection now offers the IDE's SSH Configurations (with … to open Settings › Tools › SSH Configurations), as IntelliJ IDEA does. Picking a configuration with OpenSSH config authentication fills the Engine API URL with ssh://[user@]host[:port], keeping the alias so the system ssh docker runs applies the entry's key, user and jump hosts; an existing ssh:// URL shows its configuration again. Password and key-pair configurations are listed but explained as unusable for now, since docker over SSH goes through the system ssh. Refs #939 Co-authored-by: Inf1mum <8135225+Inf1mum@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
/** Imperative prompt dialogs of the Services view, rendered by one overlay. */
|
||||
import { Fragment, useState, type ReactNode } from "react";
|
||||
import { create } from "zustand";
|
||||
import { SshConfigSelect } from "@/features/settings/components/SshConfigSelect";
|
||||
import { executeAction } from "@/platform/actions";
|
||||
import type { DockerConnection, DockerEndpoint, DockerRegistry } from "@/platform/ipc";
|
||||
import type { SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { useWorkflowSettings } from "@/platform/workflowSettings";
|
||||
import { Button, Dialog, Input, Select, T } from "@/ui";
|
||||
import { errorMessage, newId, saveConnection, saveRegistry, testRegistryLogin } from "./store";
|
||||
import styles from "./Services.module.css";
|
||||
@@ -128,14 +132,53 @@ export function openConnectionDialog(existing?: DockerConnection) {
|
||||
open((close) => <ConnectionDialog existing={existing} onDone={close} />);
|
||||
}
|
||||
|
||||
/** `ssh://[user@]host[:port]` of an SSH configuration whose authentication is the OpenSSH
|
||||
* config: the alias stays in the URL so the system `ssh` that docker runs applies the
|
||||
* entry's user, key and jump hosts. */
|
||||
export function sshConfigUrl(c: Pick<SshConfigValid, "user" | "host" | "port">): string {
|
||||
return `ssh://${c.user ? `${c.user}@` : ""}${c.host}${c.port ? `:${c.port}` : ""}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The id of the SSH configuration an Engine API URL was filled from, to show the selection:
|
||||
* an exact `ssh://[user@]host[:port]` URL matching an OpenSSH-config configuration's user,
|
||||
* host and port. Anything else (a `tcp://` URL, a different user or port) is manual.
|
||||
*/
|
||||
export function sshConfigForUrl(url: string, configs: readonly SshConfigValid[]): string {
|
||||
const m = /^ssh:\/\/(?:([^@/\s]+)@)?([A-Za-z0-9._-]+)(?::(\d+))?$/i.exec(url.trim());
|
||||
if (!m) return "";
|
||||
const config = configs.find(
|
||||
(c) =>
|
||||
c.auth === "openSsh" &&
|
||||
c.host.trim().toLowerCase() === (m[2] ?? "").toLowerCase() &&
|
||||
(!m[1] || c.user === m[1]) &&
|
||||
(!m[3] || c.port === Number(m[3])),
|
||||
);
|
||||
return config?.id ?? "";
|
||||
}
|
||||
|
||||
/** Settings › Tools › SSH Configurations, where the TCP/SSH kind's picker is edited. */
|
||||
const SSH_SETTINGS_PAGE = "tools.sshConfigurations";
|
||||
|
||||
function ConnectionDialog({ existing, onDone }: { existing?: DockerConnection; onDone: () => void }) {
|
||||
const ep = existing?.endpoint;
|
||||
const sshConfigs = useWorkflowSettings((w) => w.ssh.configs);
|
||||
const [name, setName] = useState(existing?.name ?? "Docker");
|
||||
const [kind, setKind] = useState<EndpointKind>(ep?.kind ?? "socket");
|
||||
const [path, setPath] = useState(ep?.kind === "socket" ? ep.path : "/var/run/docker.sock");
|
||||
const [host, setHost] = useState(ep?.kind === "tcp" ? ep.host : "");
|
||||
const [tls, setTls] = useState(ep?.kind === "tcp" ? (ep.tlsCertDir ?? "") : "");
|
||||
const [context, setContext] = useState(ep?.kind === "context" ? ep.name : "");
|
||||
// The configuration picked in the dropdown; shown even when docker cannot use it yet.
|
||||
const [picked, setPicked] = useState(() => (ep?.kind === "tcp" ? sshConfigForUrl(ep.host, sshConfigs) : ""));
|
||||
const fromUrl = sshConfigForUrl(host, sshConfigs);
|
||||
const shown = fromUrl || picked;
|
||||
const unusable = !fromUrl && sshConfigs.find((c) => c.id === picked && c.auth !== "openSsh");
|
||||
const pickSsh = (id: string) => {
|
||||
setPicked(id);
|
||||
const config = sshConfigs.find((c) => c.id === id);
|
||||
if (config?.auth === "openSsh" && config.host.trim()) setHost(sshConfigUrl(config));
|
||||
};
|
||||
const endpoint = endpointFrom(kind, { path, host, tls, context });
|
||||
const ok = !!endpoint && name.trim().length > 0;
|
||||
const submit = async () => {
|
||||
@@ -184,6 +227,29 @@ function ConnectionDialog({ existing, onDone }: { existing?: DockerConnection; o
|
||||
)}
|
||||
{kind === "tcp" && (
|
||||
<>
|
||||
<span className={styles.fieldLabel}>
|
||||
<T>SSH configuration:</T>
|
||||
</span>
|
||||
<div className={styles.fieldRow}>
|
||||
<SshConfigSelect
|
||||
value={shown}
|
||||
onChange={pickSsh}
|
||||
none="None — type the Engine API URL below"
|
||||
aria-label="SSH configuration"
|
||||
/>
|
||||
<Button
|
||||
title="Settings › Tools › SSH Configurations"
|
||||
aria-label="Edit SSH Configurations"
|
||||
onClick={() => void executeAction("file.settings", SSH_SETTINGS_PAGE)}
|
||||
>
|
||||
…
|
||||
</Button>
|
||||
</div>
|
||||
{unusable && (
|
||||
<span className={styles.hint}>
|
||||
<T>Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication.</T>
|
||||
</span>
|
||||
)}
|
||||
<label className={styles.fieldLabel} htmlFor="docker-conn-host">
|
||||
<T>Engine API URL:</T>
|
||||
</label>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import type { DockerContainer, DockerSnapshot } from "@/platform/ipc";
|
||||
import type { SshConfigValid } from "@/platform/remoteSettings";
|
||||
import { fileIconName } from "@/ui/icons";
|
||||
import {
|
||||
composeFileIcon,
|
||||
@@ -54,7 +55,7 @@ import {
|
||||
splitLayerInstruction,
|
||||
unitsToBytes,
|
||||
} from "./dashboard";
|
||||
import { endpointFrom } from "./dialogs";
|
||||
import { endpointFrom, sshConfigForUrl, sshConfigUrl } from "./dialogs";
|
||||
import { dockerItems } from "./docker";
|
||||
import { setDockerFilters, useServices } from "./store";
|
||||
import { addServiceMenu, expandableKeys, indexTree, setProviderRoots, useServicesTree } from "./tree";
|
||||
@@ -209,6 +210,36 @@ describe("services tree host", () => {
|
||||
expect(endpointFrom("context", { ...f, context: "orbstack" })).toEqual({ kind: "context", name: "orbstack" });
|
||||
expect(endpointFrom("default", f)).toEqual({ kind: "default" });
|
||||
});
|
||||
|
||||
it("fills the Engine API URL from an OpenSSH-config SSH configuration and shows the selection", () => {
|
||||
const box: SshConfigValid = {
|
||||
id: "ssh1",
|
||||
name: "Build server",
|
||||
host: "build-server",
|
||||
port: 2222,
|
||||
user: "ops",
|
||||
auth: "openSsh",
|
||||
keyFile: "",
|
||||
saveSecret: false,
|
||||
};
|
||||
const bare: SshConfigValid = { ...box, id: "ssh2", name: "", host: "bare", port: null, user: "", auth: "openSsh" };
|
||||
const password: SshConfigValid = { ...box, id: "ssh3", host: "vault", user: "root", port: null, auth: "password" };
|
||||
const configs = [box, bare, password];
|
||||
expect(sshConfigUrl(box)).toBe("ssh://ops@build-server:2222");
|
||||
expect(sshConfigUrl(bare)).toBe("ssh://bare");
|
||||
// The selection follows the URL the field holds; only OpenSSH-config configurations match.
|
||||
expect(sshConfigForUrl("ssh://ops@build-server:2222", configs)).toBe("ssh1");
|
||||
expect(sshConfigForUrl(" ssh://build-server:2222 ", configs)).toBe("ssh1");
|
||||
expect(sshConfigForUrl("ssh://build-server", configs)).toBe("ssh1");
|
||||
expect(sshConfigForUrl("ssh://bare", configs)).toBe("ssh2");
|
||||
// Another user or port than the configuration's, a tcp URL or a password configuration is not a selection.
|
||||
expect(sshConfigForUrl("ssh://root@build-server", configs)).toBe("");
|
||||
expect(sshConfigForUrl("ssh://build-server:22", configs)).toBe("");
|
||||
expect(sshConfigForUrl("ssh://root@vault", configs)).toBe("");
|
||||
expect(sshConfigForUrl("tcp://build-host:2376", configs)).toBe("");
|
||||
expect(sshConfigForUrl("ssh://", configs)).toBe("");
|
||||
expect(sshConfigForUrl("", configs)).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("docker run targets", () => {
|
||||
|
||||
@@ -14,15 +14,20 @@ export function SshConfigSelect({
|
||||
value,
|
||||
onChange,
|
||||
none,
|
||||
configs: given,
|
||||
"aria-label": ariaLabel = "SSH configuration",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (id: string) => void;
|
||||
/** Label of an empty choice (e.g. "Local machine"); omitted: no empty choice. */
|
||||
none?: string;
|
||||
/** The configurations to pick from; omitted: the global `ssh.configs` setting. Forms
|
||||
* outside the settings window pass the live list (the staged base is not loaded there). */
|
||||
configs?: SshConfigValid[];
|
||||
"aria-label"?: string;
|
||||
}) {
|
||||
const configs = useSshConfigs();
|
||||
const staged = useSshConfigs();
|
||||
const configs = given ?? staged;
|
||||
const options: SelectOption[] = [
|
||||
...(none !== undefined ? [{ value: "", label: none }] : []),
|
||||
...configs.map((c) => ({ value: c.id, label: c.name.trim() || sshLabel(c), detail: sshLabel(c) })),
|
||||
|
||||
@@ -7652,5 +7652,8 @@
|
||||
"Suggested for This Project": "为当前项目推荐",
|
||||
"Featured": "精选",
|
||||
"All Plugins": "全部插件",
|
||||
"Examples": "示例插件"
|
||||
"Examples": "示例插件",
|
||||
"Settings › Tools › SSH Configurations": "设置 › 工具 › SSH 配置",
|
||||
"Edit SSH Configurations": "编辑 SSH 配置",
|
||||
"Docker over SSH uses the system ssh: choose a configuration with OpenSSH config authentication.": "通过 SSH 连接 Docker 使用系统 ssh:请选择认证方式为“OpenSSH 配置和身份验证代理”的 SSH 配置。"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user