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

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]>
Цей коміт міститься в:
Andrea Basileтаandreabasile authored and GitHub committed 2026-11-06 07:34:00 +08:00
1 джерело a5d1b16e59
коміт 589cf81bae
21 files changed
+1301 -39

No files matched your search

+2
Переглянути файл
@@ -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 {
+34 -2
Переглянути файл
@@ -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");
+32 -4
Переглянути файл
@@ -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. */
+25 -1
Переглянути файл
@@ -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", () => {
+205 -16
Переглянути файл
@@ -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,
},