дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 11:15:40 +02:00
feat(run): Docker and Compose run targets (#1640)
CI / Changes (push) Successful in 1s
Language smoke / Language smoke (go) (push) Failing after 6s
Language smoke / Language smoke (kotlin) (push) Failing after 6s
Language smoke / Language smoke (java) (push) Failing after 7s
Language smoke / Language smoke (python) (push) Failing after 3s
Language smoke / Language smoke (typescript) (push) Failing after 5s
Language smoke / Language smoke (rust) (push) Failing after 5s
CI / Plugins (SDK, official, fixtures) (push) Successful in 3m9s
CI / Cargo deny (push) Successful in 52s
CI / Remote server (linux musl) (push) Successful in 5m57s
CI / E2E (push) Failing after 15m55s
CI / Frontend (push) Successful in 25m38s
CI / Rust (ubuntu-22.04) (push) Failing after 46m4s
CI / Rust (macos-latest) (push) Canceled after 0s
CI / Rust (windows-latest) (push) Canceled after 0s
CI / Changes (push) Successful in 1s
Language smoke / Language smoke (go) (push) Failing after 6s
Language smoke / Language smoke (kotlin) (push) Failing after 6s
Language smoke / Language smoke (java) (push) Failing after 7s
Language smoke / Language smoke (python) (push) Failing after 3s
Language smoke / Language smoke (typescript) (push) Failing after 5s
Language smoke / Language smoke (rust) (push) Failing after 5s
CI / Plugins (SDK, official, fixtures) (push) Successful in 3m9s
CI / Cargo deny (push) Successful in 52s
CI / Remote server (linux musl) (push) Successful in 5m57s
CI / E2E (push) Failing after 15m55s
CI / Frontend (push) Successful in 25m38s
CI / Rust (ubuntu-22.04) (push) Failing after 46m4s
CI / Rust (macos-latest) (push) Canceled after 0s
CI / Rust (windows-latest) (push) Canceled after 0s
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 <[email protected]>
Цей коміт міститься в:
1 джерело
a5d1b16e59
коміт
589cf81bae
21 files changed
+1301
-39
No files matched your search
@@ -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,
|
||||
},
|
||||
|
||||
Reference in new issue
Заблокувати користувача