diff --git a/README.md b/README.md index 6c3f78b..9679f02 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,6 @@ These features are not supported, but might be added in the future. - config: `hostRequirements` - config: `waitFor` - named config files `.devcontainer//devcontainer.json` -- devcontainer on a remote machine (wip!) While this extension might work with `vscode`, it is not supported. diff --git a/src/common/cmd.ts b/src/common/cmd.ts index 4e4be8e..2825626 100644 --- a/src/common/cmd.ts +++ b/src/common/cmd.ts @@ -1,13 +1,8 @@ -import * as common from "./spawn"; +import { RunOpts } from "./opts"; import { getLogSink } from "../extension/log"; - -export interface RunOpts { - cwd?: string, - env?: common.Envs, - stdin?: string | undefined, -} +import { getExecCtx } from "./ctx/ctx"; export async function run(cmdArgs: string[], opts: RunOpts) { - getLogSink().trace(`cmd.run: [${cmdArgs.join(", ")}]: opts: ${JSON.stringify(opts)}`); - return await common.spawn(cmdArgs[0], cmdArgs.slice(1), { ...opts, log: getLogSink() }); + getLogSink().debug(`cmd.run: [${cmdArgs.join(", ")}]: opts: ${JSON.stringify(opts)}`); + return await getExecCtx().run(cmdArgs, opts); } diff --git a/src/common/ctx/ctx.ts b/src/common/ctx/ctx.ts new file mode 100644 index 0000000..4c8faea --- /dev/null +++ b/src/common/ctx/ctx.ts @@ -0,0 +1,21 @@ +import { getLogSink } from "../../extension/log"; +import * as localCtx from "./localCtx"; +import { ExecCtx } from "./execCtx"; + +let EXEC_CTX: ExecCtx | undefined; +let callCount: number = 0; + +export function setExecCtx(ctx: ExecCtx) { + EXEC_CTX = ctx; +} + +export function getExecCtx(): ExecCtx { + callCount++; + + getLogSink().debug(`getExecCtx: ${callCount.toString().padStart(4, "0")}`); + + if (!EXEC_CTX) { + EXEC_CTX = new localCtx.LocalExecCtx(); + } + return EXEC_CTX; +} diff --git a/src/common/ctx/execCtx.ts b/src/common/ctx/execCtx.ts new file mode 100644 index 0000000..05a1274 --- /dev/null +++ b/src/common/ctx/execCtx.ts @@ -0,0 +1,26 @@ +import * as vscode from "vscode"; + +import { HostUserInfo } from "../utils"; +import { CmdResult, RunOpts } from "../opts"; +import { Settings } from "../settings"; + +export enum ExecCtxKind { + Local = "Local", + LocalSSH = "LocalSSH", // counterintuitive, but this means "local workspace" is on a remote machine + ExecServer = "ExecServer", // devcontainer over chained resolver (devcontainer over ssh remote) +} + +export interface FsCtx { + stat(filePath: vscode.Uri): Promise, + read(filePath: vscode.Uri): Promise, + write(filePath: vscode.Uri, content: string): Promise, +}; + +export interface ExecCtx { + kind: ExecCtxKind, + fs: FsCtx, + run(cmdArgs: string[], opts: RunOpts): Promise, + getHostUserInfo(): Promise, + env(): Promise, + getSettings(): Promise, +} diff --git a/src/common/ctx/localCtx.ts b/src/common/ctx/localCtx.ts new file mode 100644 index 0000000..19bf671 --- /dev/null +++ b/src/common/ctx/localCtx.ts @@ -0,0 +1,86 @@ +import * as vscode from "vscode"; + +import { ExecCtx, ExecCtxKind, FsCtx } from "./execCtx"; +import { HostUserInfo } from "../utils"; +import { formatCmdErr, spawn } from "../spawn"; +import { getLogSink } from "../../extension/log"; +import { SpawnError } from "../../extension/error"; +import { CmdResult, RunOpts } from "../opts"; +import { spawnRemote } from "./remoteCtx"; +import * as settings from "../settings"; + +class LocalFsCtx implements FsCtx { + async stat(path: vscode.Uri): Promise { + return vscode.workspace.fs.stat(path); + } + + async read(path: vscode.Uri): Promise { + const bytes = await vscode.workspace.fs.readFile(path); + return new TextDecoder("utf-8").decode(bytes); + } + + async write(path: vscode.Uri, content: string): Promise { + await vscode.workspace.fs.writeFile( + path, Buffer.from(content)); + } +} + +export class LocalExecCtx implements ExecCtx { + kind: ExecCtxKind; + execServer: vscode.ExecServer | undefined; + fs: FsCtx; + + constructor(execServer?: vscode.ExecServer) { + // By ensuring file paths are vscode.Uri, remote-local files are resolved automatically by vscode + // nothing special to do here + this.kind = (execServer === undefined) ? ExecCtxKind.Local : ExecCtxKind.LocalSSH; + this.execServer = execServer; + this.fs = new LocalFsCtx(); + } + + async run(cmdArgs: string[], opts: RunOpts): Promise { + if (this.execServer) { + return await spawnRemote(this.execServer, cmdArgs, opts); + } + return await spawn(cmdArgs[0], cmdArgs.slice(1), { ...opts, log: getLogSink() }); + } + + async getHostUserInfo(): Promise { + const userName = await spawn("id", ["-nu"], { log: getLogSink() }); + /* eslint-disable @typescript-eslint/no-non-null-assertion */ + if (userName.exit === 0) { + return { + uid: process.getuid!(), + gid: process.getgid!(), + name: userName.stdout.trim(), + }; + } + else { + throw new SpawnError(`Could not query host user info (uid, gid, name): ${formatCmdErr(userName)}`); + } + /* eslint-enable @typescript-eslint/no-non-null-assertion */ + } + + // eslint-disable-next-line @typescript-eslint/require-await + async env(): Promise { + return { + env: process.env as vscode.ProcessEnv, + osPlatform: process.platform, + }; + } + + // eslint-disable-next-line @typescript-eslint/require-await + async getSettings(): Promise { + // for user-local or remote-local workspace, settings is just + // querying it via vscode's API it does the right thing even + // if on a remote machine + const ret = settings.withDefaults({ + dockerPath: settings.getConfig("dockerPath"), + extraArgs: settings.getConfig("extraArgs"), + defaultExtensions: settings.getConfig("defaultExtensions"), + }); + + getLogSink().debug(`LocalExecCtx.getSettings(): ${JSON.stringify(ret)}`); + return ret; + } +} diff --git a/src/common/ctx/remoteCtx.ts b/src/common/ctx/remoteCtx.ts new file mode 100644 index 0000000..35bdc5b --- /dev/null +++ b/src/common/ctx/remoteCtx.ts @@ -0,0 +1,157 @@ +import * as vscode from "vscode"; + +import { ExecCtx, ExecCtxKind, FsCtx } from "./execCtx"; +import { HostUserInfo } from "../utils"; +import { formatCmdErr } from "../spawn"; +import { getLogSink } from "../../extension/log"; +import { SpawnError } from "../../extension/error"; +import { CmdResult, RunOpts } from "../opts"; +import { getRemoteserverConfiguration } from "../remoteSettings"; +import * as settings from "../settings"; + +let cmdCount: number = 0; + +class RemoteFsCtx implements FsCtx { + constructor( + private readonly remoteFs: vscode.RemoteFileSystem, + ) { } + + async stat(path: vscode.Uri): Promise { + return this.remoteFs.stat(path.fsPath); + } + + async read(path: vscode.Uri): Promise { + const stream = await this.remoteFs.read(path.fsPath); + const chunks: Uint8Array[] = []; + + stream.onDidReceiveMessage((data) => { + chunks.push(data); + }); + + await stream.onEnd; + + const decoder = new TextDecoder("utf-8"); + return decoder.decode(Buffer.concat(chunks)); + } + + async write(path: vscode.Uri, content: string): Promise { + const { stream, done } = await this.remoteFs.write(path.fsPath); + stream.write(new TextEncoder().encode(content)); + stream.end(); + await done; + } +} + +export class RemoteExecCtx implements ExecCtx { + kind: ExecCtxKind; + fs: RemoteFsCtx; + + constructor( + private readonly execServer: vscode.ExecServer, + ) { + this.kind = ExecCtxKind.ExecServer; + this.fs = new RemoteFsCtx(execServer.fs); + } + + public async run(cmdArgs: string[], opts: RunOpts): Promise { + return spawnRemote(this.execServer, cmdArgs, opts); + } + + public async getHostUserInfo(): Promise { + const uid = await this.run(["id", "-u"], {}); + const gid = await this.run(["id", "-g"], {}); + const username = await this.run(["id", "-un"], {}); + + if (uid.exit !== 0 || gid.exit !== 0) { + throw new SpawnError(`RemoteExecCtx.getHostUserInfo: Could not query remote host user info: uid: ${formatCmdErr(uid)} gid: ${formatCmdErr(gid)}`); + } + + if (username.exit !== 0) { + getLogSink().warn(`RemoteExecCtx.getHostUserInfo: Could not query remote host user info: ${formatCmdErr(username)}`); + } + + return { + uid: parseInt(uid.stdout.trim(), 10), + gid: parseInt(gid.stdout.trim(), 10), + name: username.stdout.trim(), + }; + } + + async env() { + return this.execServer.env(); + } + + async getSettings(): Promise { + // using the vscode api is fine iff the remote is already connected. + // This is called mostly if not exclusively during exec-chained connection, + // so we need to parse settings ourselves from the remote machine. + // Since we're already doing it ourselves anyway, this is what will be used + // even after a remote connection instead of the vscode api + // TODO: Note that this doesn't combine { ...local, ...machine, ...user }, + // fetches exactly the machine's global config (the ssh host's settings) + const ret = await getRemoteserverConfiguration(); + getLogSink().debug(`RemoteExecCtx.getSettings(): ${JSON.stringify(ret)}`); + return ret; + } +} + +export async function spawnRemote(execServer: vscode.ExecServer, cmdArgs: string[], opts: RunOpts) { + cmdCount += 1; + const cmdId = cmdCount; + + const [cmd, ...args] = cmdArgs; + const log = getLogSink(); + + const cmdStr = () => `CMD${String(cmdId).padStart(4, "0")}`; + log.info(`RemoteExecCtx.spawn[${cmdStr()}]: [${cmdArgs.join(", ")}]`); + + const env: Record = opts.env + ? Object.fromEntries( + Object.entries(opts.env) + .filter((e): e is [string, string] => e[1] !== undefined), + ) + : {}; + + const spawned = await execServer.spawn(cmd, args, { + cwd: opts.cwd, + env: { ...env, BUILDKIT_PROGRESS: "plain" }, + }); + + if (opts.stdin !== undefined) { + spawned.stdin.write(new TextEncoder().encode(opts.stdin)); + spawned.stdin.end(); + } + + let stdout = ""; + let stderr = ""; + const stdoutDecoder = new TextDecoder("utf-8"); + const stderrDecoder = new TextDecoder("utf-8"); + + spawned.stdout.onDidReceiveMessage((data: Uint8Array) => { + const chunk = stdoutDecoder.decode(data, { stream: true }); + stdout += chunk; + log.debug(`RemoteExecCtx.stdout.onDidReceiveMessage[${cmdStr()}]: len ${chunk.length}, chunk: '${chunk.trim()}'`); + }); + + spawned.stderr.onDidReceiveMessage((data: Uint8Array) => { + const chunk = stderrDecoder.decode(data, { stream: true }); + stderr += chunk; + log.debug(`RemoteExecCtx.stderr.onDidReceiveMessage[${cmdStr()}]: len ${chunk.length}, chunk: '${chunk.trim()}'`); + }); + + const exit = await spawned.onExit; + const info: CmdResult = { + exit: exit.status, + stdout: stdout, + stderr: stderr, + }; + + if (exit.status === 0) { + log.info(`RemoteExecCtx[${cmdStr()}]: stdout: ${info.stdout.trim()}`); + } + else { + log.error(`RemoteExecCtx[${cmdStr()}]: ${formatCmdErr(info)}`); + } + + return { exit: exit.status, stdout, stderr }; +} diff --git a/src/common/opts.ts b/src/common/opts.ts new file mode 100644 index 0000000..0fc3444 --- /dev/null +++ b/src/common/opts.ts @@ -0,0 +1,22 @@ +import { LogOutputChannel } from "vscode"; + +export type Envs = Record; + +export interface SpawnOpts { + env?: Envs, + cwd?: string, + stdin?: string | undefined, + log: LogOutputChannel, +} + +export interface RunOpts { + cwd?: string, + env?: Envs, + stdin?: string | undefined, +} + +export interface CmdResult { + exit: number | NodeJS.Signals, + stdout: string, + stderr: string, +}; diff --git a/src/common/remoteSettings.ts b/src/common/remoteSettings.ts new file mode 100644 index 0000000..17b5c25 --- /dev/null +++ b/src/common/remoteSettings.ts @@ -0,0 +1,95 @@ +import * as vscode from "vscode"; +import path from "node:path"; + +import * as jc from "jsonc-parser"; + +import { getExecCtx } from "./ctx/ctx"; +import { getProductJson, getRemoteAuthorities, fmtErr } from "./utils"; +import { InternalError } from "../extension/error"; +import { getConfig, Settings } from "./settings"; +import { findSSHServerInstallPath, SSHDestination } from "../extension/ssh"; +import { getLogSink } from "../extension/log"; + +/** + * + * `vscode.workspace.getConfiguration` during execServer chaining reads only the + * user config, not remote machine's. User may have configured different extension + * settings there, so we must read those ourselves. + * By default, for vscodium, it's at `$HOME/.vscodium-server/data/Machine/settings.json` + * This path can be configured with `remote.SSH.serverInstallPath`, a key-value pair + * of regex keys (for server/hostnames) and values as paths. + * + * TODO: Windows support, and merge local settings + * merged = { ...local, ...machine, ...user }? + * + * @returns `Settings` on the remote machine + */ +export async function getRemoteserverConfiguration(): Promise { + getLogSink().info(`Fetching remote settings '${vscode.env.remoteAuthority}'`); + const productJson = await getProductJson(); + + // get hostname from ssh + // and chained is always at inx 1, that we chain to + const auths = getRemoteAuthorities(); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (auths === undefined || auths[1] === undefined) { + // man i dunno + throw new InternalError("getRemoteserverConfiguration: Expected SSH authority to exist"); + } + + const sshRemote = auths[1]; + const [sshAuthority, encodedSSH] = sshRemote.split("+"); + const hostname = SSHDestination.parseEncoded(encodedSSH).hostname; + const remoteSysenv = await getExecCtx().env(); + + getLogSink().info(`SSH authority is '${sshAuthority}'+'${hostname}' (encoded: '${encodedSSH}')`); + + if (!("HOME" in remoteSysenv.env)) { + throw new InternalError("HOME was undefined on remote env"); + } + const homedir = remoteSysenv.env.HOME; + + const DEFAULT_DIR = path.posix.join(homedir, productJson.serverDataFolderName); + + const readConfig = async (installPath: string): Promise => { + getLogSink().info(`getRemoteserverConfiguration: remote SSH install at: ${installPath}`); + const cfgPath = path.posix.join(installPath, "data/Machine/settings.json"); + const raw = (await getExecCtx().fs.read(vscode.Uri.file(cfgPath))).trim(); + getLogSink().debug(`getRemoteserverConfiguration: raw cfg: ${raw}`); + + /* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + const remoteSettings = jc.parse(raw); + return { + dockerPath: remoteSettings["dev.containers.dockerPath"] ?? "docker", + extraArgs: remoteSettings["dev.containers.extraArgs"] ?? [], + defaultExtensions: remoteSettings["dev.containers.defaultExtensions"] ?? [], + }; + /* eslint-enable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access */ + }; + + const serverInstallPathMap = vscode.workspace + .getConfiguration("remote.SSH") + .get>("serverInstallPath"); + + // default is home if not configured + const remoteInstallPath = findSSHServerInstallPath(hostname, serverInstallPathMap ?? {}); + + const logmsg = remoteInstallPath + ? `serverInstallPath for host ${hostname} = ${remoteInstallPath}` + : `no serverInstallPath for host ${hostname}, using default ${DEFAULT_DIR}`; + + getLogSink().info(`getRemoteserverConfiguration: remote.SSH.serverInstallPath[${hostname}]: ${logmsg}`); + + try { + return await readConfig(remoteInstallPath ?? DEFAULT_DIR); + } + catch (e: unknown) { + getLogSink().error(`getRemoteserverConfiguration: returning local cfg, reading remote config failed ${fmtErr(e)}`); + return { + dockerPath: getConfig("dockerPath") ?? "docker", + extraArgs: getConfig("extraArgs") ?? [], + defaultExtensions: getConfig("defaultExtensions") ?? [], + }; + } +} diff --git a/src/common/settings.ts b/src/common/settings.ts new file mode 100644 index 0000000..5ccf4ef --- /dev/null +++ b/src/common/settings.ts @@ -0,0 +1,29 @@ +import * as vscode from "vscode"; + +export interface Settings { + dockerPath: string, + extraArgs: string[], + defaultExtensions: string[], +} + +function getDefaults(): Settings { + return { + dockerPath: "docker", + extraArgs: [], + defaultExtensions: [], + }; +} + +export function withDefaults(queried: Partial): Settings { + const defaults = getDefaults(); + return { + dockerPath: queried.dockerPath ?? defaults.dockerPath, + extraArgs: queried.extraArgs ?? defaults.extraArgs, + defaultExtensions: queried.defaultExtensions ?? defaults.defaultExtensions, + }; +} + +// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters +export function getConfig(key: string): T | undefined { + return vscode.workspace.getConfiguration("dev.containers").get(key); +} diff --git a/src/common/spawn.ts b/src/common/spawn.ts index 66d4b7e..2b90165 100644 --- a/src/common/spawn.ts +++ b/src/common/spawn.ts @@ -1,24 +1,9 @@ -import { LogOutputChannel } from "vscode"; import * as chproc from "node:child_process"; - -export type Envs = Record; - -export interface CmdResult { - exit: number | NodeJS.Signals, - stdout: string, - stderr: string, -}; +import { CmdResult, SpawnOpts } from "./opts"; let cmdCount: number = 0; /* eslint-disable @typescript-eslint/no-confusing-void-expression */ -export interface SpawnOpts { - env?: Envs, - cwd?: string, - stdin?: string | undefined, - log: LogOutputChannel, -} - export function spawn( cmd: string, args: string[], diff --git a/src/common/tests/spawn.test.ts b/src/common/tests/spawn.test.ts index db191cb..02909b3 100644 --- a/src/common/tests/spawn.test.ts +++ b/src/common/tests/spawn.test.ts @@ -4,7 +4,7 @@ import { spawn } from "../spawn"; import { getLogSink } from "../../extension/log"; import { ContainerInspectResult } from "../../engine/lifecycle"; -import { initMocks, jsonFormat, getMockSettings } from "../../tests/common"; +import { initMocks, jsonFormat, getMockSettings, getTestTimeout } from "../../tests/common"; function getcwd() { return __dirname; @@ -43,7 +43,7 @@ describe.skipIf(!SETTINGS.dockerPath)("cmd spawn tests", () => { const remove = await spawn(engine, ["rm", create.stdout.trim()], opts()); expect(create.exit).eq(0); expect(remove.exit).eq(0); - }, 10_000); + }, getTestTimeout(10)); test("run and exec command", async () => { const create = await spawn(engine, ["run", "--name", "turtles", "-d", "ubuntu:24.04", ...bashSleepCmd], opts()); @@ -69,5 +69,5 @@ describe.skipIf(!SETTINGS.dockerPath)("cmd spawn tests", () => { const rm = await spawn(engine, ["rm", "turtles"], opts()); expect(rm.exit).eq(0); } - }, 30_000); + }, getTestTimeout(30)); }); diff --git a/src/common/utils.ts b/src/common/utils.ts index c692f5d..45a833e 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -2,10 +2,17 @@ import * as vscode from "vscode"; import * as fs from "node:fs/promises"; import path from "node:path"; -import { run } from "../common/cmd"; -import { formatCmdErr } from "../common/spawn"; -import { SpawnError } from "../extension/error"; -import { Settings } from "../extension/settings"; +import { Settings } from "./settings"; +import { getExecCtx } from "./ctx/ctx"; + +export function fmtErr(e: unknown): string { + if (e instanceof Error) { + return e.stack ?? e.message; + } + else { + return `unknown error: ${JSON.stringify(e)}`; + } +} export interface HostUserInfo { uid: number, @@ -52,19 +59,7 @@ export function getEngineCmd(s: Settings): string[] { } export async function getHostUserInfo(): Promise { - const userName = await run(["/bin/sh", "-c", "id -n -u $UID"], {}); - /* eslint-disable @typescript-eslint/no-non-null-assertion */ - if (userName.exit === 0) { - return { - uid: process.getuid!(), - gid: process.getgid!(), - name: userName.stdout.trim(), - }; - } - else { - throw new SpawnError(`Could not query host user info (uid, gid, name): ${formatCmdErr(userName)}`); - } - /* eslint-enable @typescript-eslint/no-non-null-assertion */ + return getExecCtx().getHostUserInfo(); } export interface ProductJson { @@ -91,3 +86,20 @@ export async function getProductJson(): Promise { serverDownloadUrlTemplate: jsonData.serverDownloadUrlTemplate, }; } + +export function getRemoteAuthorities(): string[] | undefined { + const auth = vscode.env.remoteAuthority; + + if (!auth) { return undefined; } + + const inx = auth.indexOf("@"); + + if (inx > 0) { + return auth.split("@"); + } + else { return [auth]; } +} + +export function consume(..._: unknown[]) { + void (_); +} diff --git a/src/engine/lifecycle.ts b/src/engine/lifecycle.ts index b2f2bda..c15980b 100644 --- a/src/engine/lifecycle.ts +++ b/src/engine/lifecycle.ts @@ -9,7 +9,7 @@ import { ContainerConfig, ContainerEngine, LifecycleCmd } from "./container"; import { EngineError, InstallError, InternalError } from "../extension/error"; import { NotificationLevel, showNotification } from "../extension/workspace"; -import * as settings from "../extension/settings"; +import * as settings from "../common/settings"; import * as server from "../remote/installServer"; import { EXTENSION_ID, DEVCONTAINER_SERVER_LISTEN_PORT, CAT_PIPE_STDIN_WORKAROUND } from "../common/constants"; diff --git a/src/engine/tests/stage2.test.ts b/src/engine/tests/stage2.test.ts index 3face53..1a44522 100644 --- a/src/engine/tests/stage2.test.ts +++ b/src/engine/tests/stage2.test.ts @@ -9,7 +9,7 @@ import { HostUserInfo } from "../../common/utils"; import * as schema from "../../parser/schema"; import * as lf from "../lifecycle"; -import { getMockSettings, initMocks } from "../../tests/common"; +import { getMockSettings, getTestTimeout, initMocks } from "../../tests/common"; import { CAT_PIPE_STDIN_WORKAROUND } from "../../common/constants"; function testWsf(label: string) { @@ -78,7 +78,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { const warnMsg = Array.from(output.matchAll(lf.STAGE2_WARN_MSG_REGEX)); expect(warnMsg.length).eq(1); expect(warnMsg[0][1]).includes("Using user root"); - }, 45_000); + }, getTestTimeout(45)); test("remaps existing user UID/GID to host values", async () => { const { localWsf, cfgPath } = testWsf("remap-basic"); @@ -89,7 +89,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { const { uid, gid } = await getRemoteUserInfo(cc, "ubuntu"); expect(uid).eq("5000"); expect(gid).eq("5000"); - }, 45_000); + }, getTestTimeout(45)); test("remaps UID when host GID already exists in container", async () => { // Regression test for upstream bugs: @@ -104,7 +104,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { const { uid, gid } = await getRemoteUserInfo(cc, "ubuntu"); expect(uid).eq("2345"); expect(gid).eq("100"); - }, 45_000); + }, getTestTimeout(45)); test("UPDATE_REMOTE_UID=false skips remapping", async () => { const { localWsf, cfgPath } = testWsf("skip-remap"); @@ -118,7 +118,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { const { uid } = await getRemoteUserInfo(cc, "ubuntu"); expect(uid).not.eq("9999"); - }, 45_000); + }, getTestTimeout(45)); test("nonexistent user fails with descriptive error", async () => { const { localWsf, cfgPath } = testWsf("no-user"); @@ -130,7 +130,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { const errMsg = Array.from(output.matchAll(lf.STAGE2_ERR_MSG_REGEX)); expect(errMsg.length).toBeGreaterThanOrEqual(1); expect(errMsg[0][1]).includes("does not exist in container"); - }, 45_000); + }, getTestTimeout(45)); test("UID conflict: moves colliding user before remapping", async () => { const { localWsf, cfgPath } = testWsf("uid-conflict"); @@ -159,7 +159,7 @@ describe.skipIf(!SETTINGS.dockerPath)("stage2 UID remapping", () => { // original owner of uid 1000 moved to 1000+1234 const { uid: movedUid } = await getRemoteUserInfo(cc, "ubuntu"); expect(movedUid).eq("2234"); - }, 45_000); + }, getTestTimeout(45)); }); describe.skipIf(SETTINGS.dockerPath !== "podman")("podman: --userns=keep-id", () => { @@ -192,7 +192,7 @@ describe.skipIf(SETTINGS.dockerPath !== "podman")("podman: --userns=keep-id", () const cleanup = await container.engineExec(["rm", `${wsDir}/${marker}`]); expect(cleanup.exit).eq(0); - }, 60_000); + }, getTestTimeout(60)); test("non-root remoteUser: uid inside container matches host uid", async () => { const { localWsf, cfgPath } = testWsf("podman-keepid-uid"); @@ -204,7 +204,7 @@ describe.skipIf(SETTINGS.dockerPath !== "podman")("podman: --userns=keep-id", () const res = await container.engineExec(["id", "-u"]); expect(res.exit).eq(0); expect(res.stdout.trim()).eq(String(process.getuid?.())); - }, 60_000); + }, getTestTimeout(60)); test("root remoteUser: no keep-id, workspace still accessible", async () => { const { localWsf, cfgPath } = testWsf("podman-root"); @@ -223,5 +223,5 @@ describe.skipIf(SETTINGS.dockerPath !== "podman")("podman: --userns=keep-id", () const cleanup = await container.engineExec(["rm", `${wsDir}/podman-root-test`]); expect(cleanup.exit).eq(0); - }, 60_000); + }, getTestTimeout(60)); }); diff --git a/src/extension.ts b/src/extension.ts index c187af3..45b28d0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -4,9 +4,12 @@ import * as cmds from "./extension/commands"; import { BuildOpts } from "./engine/lifecycle"; import { getLogSink, initLogs } from "./extension/log"; import { AUTHORITY_BASE, DevContainerResolver } from "./remote/resolver"; -import { createDevcontainerConfigWatcher, isRemoteDevcontainerSession, onWorkspaceReady } from "./extension/workspace"; +import { isRemoteDevcontainerSession, onWorkspaceReady } from "./extension/workspace"; import { checkVersionAndNotify } from "./extension/releaseNotes"; import { checkLegacySettings } from "./extension/settings"; +import { setExecCtx } from "./common/ctx/ctx"; +import { LocalExecCtx } from "./common/ctx/localCtx"; +import { fmtErr } from "./common/utils"; export function activate(ctx: vscode.ExtensionContext) { const logger = initLogs(ctx); @@ -27,24 +30,38 @@ export function activate(ctx: vscode.ExtensionContext) { logger, ); - void createDevcontainerConfigWatcher(ctx).then((watcher) => { - ctx.subscriptions.push(watcher); - }).catch((e: unknown) => { - getLogSink().error(`createDevcontainerConfigWatcher failed: ${JSON.stringify(e)}`); - }); - if (isRemoteDevcontainerSession()) { onWorkspaceReady()?.then(async () => { getLogSink().info("onWorkspaceReady: workspace ready"); + ctx.subscriptions.push(await cmds.setupConfigWatcher(ctx)); try { await cmds.onRemoteReady(ctx); getLogSink().info("onRemoteReady: OK"); } catch (e: unknown) { - getLogSink().error(`onRemoteReady: Error ${JSON.stringify(e)}`); + getLogSink().error(`onRemoteReady: Error ${fmtErr(e)}`); } }); } + else { + void cmds.setupConfigWatcher(ctx).then((watcher) => { + ctx.subscriptions.push(watcher); + }); + + // by "local", we mean "workspace-local". So on a remote machine (say ssh), local + // means workspace-local. So spawn has to use the underlying resolver's exec server + // plead it for one + if (vscode.env.remoteAuthority /* && !isRemoteDevcontainerSession() */) { + vscode.workspace.getRemoteExecServer(vscode.env.remoteAuthority).then( + (server: vscode.ExecServer | undefined) => { + if (server) { setExecCtx(new LocalExecCtx(server)); } + }, + (err: unknown) => { + getLogSink().error(`Could not getRemoteExecServer on '${vscode.env.remoteAuthority}'. Error: ${fmtErr(err)}`); + }, + ); + } + } checkVersionAndNotify(ctx); checkLegacySettings(); diff --git a/src/extension/commands.ts b/src/extension/commands.ts index 16c2a6c..3e3036e 100644 --- a/src/extension/commands.ts +++ b/src/extension/commands.ts @@ -1,17 +1,17 @@ import * as vscode from "vscode"; import * as cmd from "../common/cmd"; -import { getSettings } from "./settings"; +import * as workspace from "./workspace"; import { ContainerConfig, LifecycleCmd } from "../engine/container"; import { parseDevcontainer } from "../parser/parser"; import { encodeRemoteAuthority } from "../remote/resolver"; -import { findDevcontainerJson, getLocalWorkspaceFolder, isRemoteDevcontainerSession, NotificationLevel, showNotification } from "./workspace"; import { BuildOpts, queryContainerConfigId } from "../engine/lifecycle"; import { BuildOptIntent } from "../common/globalState"; import { getLogfilePath, getLogSink } from "./log"; import { EXTENSION_ID } from "../common/constants"; import { InternalError } from "./error"; -import { getEngineCmd } from "../common/utils"; +import { fmtErr, getEngineCmd } from "../common/utils"; +import { getExecCtx } from "../common/ctx/ctx"; enum RebuildPrompt { RebuildNoCache = "Yes (Rebuild without cache)", @@ -23,15 +23,25 @@ export function getCmd(suffix: string) { return `${EXTENSION_ID}.${suffix}`; } +export async function setupConfigWatcher(ctx: vscode.ExtensionContext): Promise { + try { + return await workspace.createDevcontainerConfigWatcher(ctx); + } + catch (e: unknown) { + getLogSink().error(`createDevcontainerConfigWatcher failed: ${fmtErr(e)}`); + return new vscode.Disposable(() => { }); + } +} + export async function getContainerEngineVersion() { - const engine = getSettings().dockerPath; + const engine = (await getExecCtx().getSettings()).dockerPath; const { stdout } = await cmd.run([engine, "--version"], {}); vscode.window.showInformationMessage(`${engine} version: ${stdout}`); } export async function openRemote(ctx: vscode.ExtensionContext, opts: BuildOpts = BuildOpts.Default) { - const localWsf = getLocalWorkspaceFolder(); - const devcontainerJson = await findDevcontainerJson(localWsf); + const localWsf = workspace.getLocalWorkspaceFolder(); + const devcontainerJson = await workspace.findDevcontainerJson(localWsf); const parsedConfig = await parseDevcontainer(devcontainerJson); const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig); @@ -55,13 +65,17 @@ export async function openRemote(ctx: vscode.ExtensionContext, opts: BuildOpts = export async function openLocal() { await vscode.commands.executeCommand( "vscode.openFolder", - getLocalWorkspaceFolder(), + workspace.getLocalWorkspaceFolder(), ); } export async function showDevcontainerFile() { - const file = await findDevcontainerJson(getLocalWorkspaceFolder()); - vscode.commands.executeCommand("vscode.open", file); + // TODO: optimize this, since we're having to read the file + const localWsf = workspace.getLocalWorkspaceFolder(); + const file = await workspace.findDevcontainerJson(localWsf); + const parsedConfig = await parseDevcontainer(file); + const cc = ContainerConfig.create(localWsf.fsPath, file.fsPath, parsedConfig); + vscode.commands.executeCommand("vscode.open", workspace.remapToCurrentAuthority(localWsf, file, cc.getRemoteMountDir())); } export function showLogFile() { @@ -71,10 +85,11 @@ export function showLogFile() { export async function onRemoteReady(ctx: vscode.ExtensionContext): Promise { getLogSink().info("onRemoteReady"); - const localWsf = getLocalWorkspaceFolder(); - const devcontainerJson = await findDevcontainerJson(localWsf); + const localWsf = workspace.getLocalWorkspaceFolder(); + const devcontainerJson = await workspace.findDevcontainerJson(localWsf); const parsedConfig = await parseDevcontainer(devcontainerJson); - const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig); + const hostEnv = await getExecCtx().env(); + const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig, hostEnv.env); const cmds = cc.getLifecycleCmd(LifecycleCmd.postAttach); @@ -115,11 +130,11 @@ async function _openRemote(ctx: vscode.ExtensionContext, localWsf: vscode.Uri, c getLogSink().info(`_openRemote: localWsf: ${localWsf.toString(true)} cc.Id: ${cc.getConfigId()} opts: ${opts}`); BuildOptIntent.set(ctx, opts); - if (isRemoteDevcontainerSession() && opts === BuildOpts.Default) { + if (workspace.isRemoteDevcontainerSession() && opts === BuildOpts.Default) { // nothing to do, already on remote return; } - if (isRemoteDevcontainerSession() && opts !== BuildOpts.Default) { + if (workspace.isRemoteDevcontainerSession() && opts !== BuildOpts.Default) { // this is when rebuild/nocache options are used from a remote session // force a window reload so that the intent is picked up on next load await vscode.commands.executeCommand("workbench.action.reloadWindow"); @@ -148,7 +163,7 @@ function promptBuildOpt() { } export async function isConfigStale(localWsf: vscode.Uri, cc: ContainerConfig): Promise { - const cmd = getEngineCmd(getSettings()); + const cmd = getEngineCmd(await getExecCtx().getSettings()); const containerConfigId = await queryContainerConfigId(cmd, localWsf.fsPath); if (!containerConfigId) { @@ -159,14 +174,15 @@ export async function isConfigStale(localWsf: vscode.Uri, cc: ContainerConfig): } export async function remotePromptRebuildIfStale(ctx: vscode.ExtensionContext) { - if (!isRemoteDevcontainerSession()) { + if (!workspace.isRemoteDevcontainerSession()) { throw new InternalError("checkRemoteIsStale: Expected remote session."); } - const localWsf = getLocalWorkspaceFolder(); - const devcontainerJson = await findDevcontainerJson(localWsf); + const localWsf = workspace.getLocalWorkspaceFolder(); + const devcontainerJson = await workspace.findDevcontainerJson(localWsf); const parsedConfig = await parseDevcontainer(devcontainerJson); - const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig); + const hostEnv = await getExecCtx().env(); + const cc = ContainerConfig.create(localWsf.fsPath, devcontainerJson.fsPath, parsedConfig, hostEnv.env); isConfigStale(localWsf, cc).then((isStale) => { if (isStale) { @@ -187,7 +203,7 @@ export async function remotePromptRebuildIfStale(ctx: vscode.ExtensionContext) { } return; }).catch((e: unknown) => { - showNotification(NotificationLevel.Error, `Could not reopen: ${JSON.stringify(e)}`); + workspace.showNotification(workspace.NotificationLevel.Error, `Could not reopen: ${fmtErr(e)}`); }); } diff --git a/src/extension/settings.ts b/src/extension/settings.ts index d8fb70c..3c3cc9e 100644 --- a/src/extension/settings.ts +++ b/src/extension/settings.ts @@ -1,24 +1,5 @@ import * as vscode from "vscode"; -export interface Settings { - dockerPath: string, - extraArgs: string[], - defaultExtensions: string[], -} - -// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -export function getConfig(key: string): T | undefined { - return vscode.workspace.getConfiguration("dev.containers").get(key); -} - -export function getSettings(): Settings { - return { - dockerPath: getConfig("dockerPath") ?? "docker", - extraArgs: getConfig("extraArgs") ?? [], - defaultExtensions: getConfig("defaultExtensions") ?? [], - }; -} - // TODO: remove after 0.8.0 const LEGACY_KEYS = ["engine", "extraArgs", "containerBinary"]; @@ -40,7 +21,3 @@ export function checkLegacySettings() { }); } } - -export function getExtensionList(): string[] { - return (getConfig("defaultExtensions") ?? []); -} diff --git a/src/extension/ssh.ts b/src/extension/ssh.ts new file mode 100644 index 0000000..db6f672 --- /dev/null +++ b/src/extension/ssh.ts @@ -0,0 +1,143 @@ +// These are copied and adapted from https://github.com/jeanp413/open-remote-ssh/ +// +// https://github.com/jeanp413/open-remote-ssh/blob/41e50958631e8bb127d4786b65ba8e35e3fb1245/src/serverSetup.ts#L28-L75 +// https://github.com/jeanp413/open-remote-ssh/blob/41e50958631e8bb127d4786b65ba8e35e3fb1245/src/ssh/sshDestination.ts +// https://github.com/jeanp413/open-remote-ssh/blob/41e50958631e8bb127d4786b65ba8e35e3fb1245/LICENSE.txt +// +// MIT License +// +// Copyright (c) 2022 +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: + +// The above copyright notice and this permission notice shall be included in all +// copies or substantial portions of the Software. + +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +// SOFTWARE. + +/* eslint-disable @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment */ +/** + * Matches a hostname against a pattern that may contain wildcards. + * Returns a specificity score: higher scores indicate more specific matches. + * Returns -1 if no match. + */ +function matchHostnamePattern(hostname: string, pattern: string): number { + // Exact match has highest priority + if (hostname === pattern) { + return 1000; + } + + // Catch-all wildcard has lowest priority + if (pattern === "*") { + return 1; + } + + // Convert wildcard pattern to regex + // Escape special regex characters except * + const regexPattern = pattern + .replace(/[.+?^${}()|[\]\\]/g, "\\$&") + .replace(/\*/g, ".*"); + + const regex = new RegExp(`^${regexPattern}$`); + + if (regex.test(hostname)) { + // Calculate specificity based on the number of non-wildcard characters + // More specific patterns (more characters) get higher scores + const nonWildcardChars = pattern.replace(/\*/g, "").length; + return 10 + nonWildcardChars; + } + + return -1; +} + +/** + * Finds the best matching path for a hostname from a map of patterns to paths. + * Supports wildcards with priority: exact match > specific wildcard > general wildcard. + */ +export function findSSHServerInstallPath(hostname: string, pathMap: Record): string | undefined { + let bestMatch: { pattern: string, path: string, score: number } | undefined; + + for (const [pattern, path] of Object.entries(pathMap)) { + const score = matchHostnamePattern(hostname, pattern); + + if (score > 0) { + if (!bestMatch || score > bestMatch.score) { + bestMatch = { pattern, path, score }; + } + } + } + + return bestMatch?.path; +} + +// MIT + +export class SSHDestination { + constructor( + public readonly hostname: string, + public readonly user?: string, + public readonly port?: number, + ) { + } + + static parse(dest: string): SSHDestination { + let user: string | undefined; + const atPos = dest.lastIndexOf("@"); + if (atPos !== -1) { + user = dest.substring(0, atPos); + } + + let port: number | undefined; + const colonPos = dest.lastIndexOf(":"); + if (colonPos !== -1) { + port = parseInt(dest.substring(colonPos + 1), 10); + } + + const start = atPos !== -1 ? atPos + 1 : 0; + const end = colonPos !== -1 ? colonPos : dest.length; + const hostname = dest.substring(start, end); + + return new SSHDestination(hostname, user, port); + } + + toString(): string { + let result = this.hostname; + if (this.user) { + result = `${this.user}@` + result; + } + if (this.port) { + result = result + `:${this.port}`; + } + return result; + } + + // vscode.uri implementation lowercases the authority, so when reopen or restore + // a remote session from the recently openend list the connection fails + static parseEncoded(dest: string): SSHDestination { + try { + const data = JSON.parse(Buffer.from(dest, "hex").toString()); + return new SSHDestination(data.hostName, data.user, data.port); + } + catch { + // ignore + } + + return SSHDestination.parse(dest.replace(/\\x([0-9a-f]{2})/g, (_, charCode) => String.fromCharCode(parseInt(charCode, 16)))); + } + + toEncodedString(): string { + return this.toString().replace(/[A-Z]/g, ch => `\\x${ch.charCodeAt(0).toString(16).toLowerCase()}`); + } +} +/* eslint-enable @typescript-eslint/no-unsafe-argument,@typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-assignment */ diff --git a/src/extension/workspace.ts b/src/extension/workspace.ts index 8aa9cf5..128eee2 100644 --- a/src/extension/workspace.ts +++ b/src/extension/workspace.ts @@ -6,6 +6,10 @@ import { getLogSink } from "./log"; import { ConfigError } from "./error"; import { AUTHORITY_BASE, decodeRemoteAuthority } from "../remote/resolver"; import * as cmds from "../extension/commands"; +import { getExecCtx } from "../common/ctx/ctx"; +import { fmtErr } from "../common/utils"; +import { parseDevcontainer } from "../parser/parser"; +import { ContainerConfig } from "../engine/container"; export enum NotificationLevel { Info, @@ -33,8 +37,9 @@ export function getWorkspaceId(localWsp: string): string { } async function fileExists(filePath: vscode.Uri): Promise { + getLogSink().debug(`fileExists check: '${filePath.toString(true)}'`); try { - await vscode.workspace.fs.stat(filePath); + await getExecCtx().fs.stat(filePath); return true; } catch { @@ -43,10 +48,11 @@ async function fileExists(filePath: vscode.Uri): Promise { } export async function findDevcontainerJson(dir: vscode.Uri): Promise { + getLogSink().debug(`Searching '${dir.toString(true)}' for config files ...`); const filePaths = ConfigPaths(dir); for (const f of filePaths) { if (await fileExists(f)) { - getLogSink().info(`Using devcontainer.json at ${f.toString(true)}`); + getLogSink().info(`Using devcontainer.json at '${f.toString(true)}'`); return f; } } @@ -59,9 +65,10 @@ export function isRemoteDevcontainerSession(): boolean { } export function getLocalWorkspaceFolder(): vscode.Uri { - const remote = vscode.env.remoteAuthority; - if (remote?.startsWith(AUTHORITY_BASE)) { - return decodeRemoteAuthority(remote); + getLogSink().info(`getLocalWorkspaceFolder: remote is ${vscode.env.remoteAuthority}`); + if (isRemoteDevcontainerSession()) { + const remote = vscode.env.remoteAuthority; + return decodeRemoteAuthority(remote!); // eslint-disable-line @typescript-eslint/no-non-null-assertion } // for remotes that aren't devcontainer (say, ssh), the workspace // is "local" from extension's pov, so use "local" workspace @@ -113,30 +120,29 @@ export async function createDevcontainerConfigWatcher(ctx: vscode.ExtensionConte getLogSink().error(`Watcher: No workspace or config found: ${e.message}`); } else { - getLogSink().error(`Unknown error: ${JSON.stringify(e)}`); + getLogSink().error(`Unknown error: ${fmtErr(e)}`); } return new vscode.Disposable(() => { }); } - const configName = path.posix.basename(configPath.path); - const configDir = vscode.Uri.joinPath(configPath, ".."); + const localWsf = getLocalWorkspaceFolder(); + const parsedConfig = await parseDevcontainer(configPath); + const cc = ContainerConfig.create(localWsf.fsPath, configPath.fsPath, parsedConfig); + const watchPath = remapToCurrentAuthority(localWsf, configPath, cc.getRemoteMountDir()); + const configName = path.posix.basename(watchPath.path); + const configDir = vscode.Uri.joinPath(watchPath, ".."); const pattern = new vscode.RelativePattern(configDir, configName); const watcher = vscode.workspace.createFileSystemWatcher(pattern); - watcher.onDidChange(async () => { - if (isRemoteDevcontainerSession()) { - try { - await cmds.remotePromptRebuildIfStale(ctx); - } - catch (e: unknown) { - if (e instanceof Error) { - getLogSink().error(`remotePromptRebuildIfStale failed ${e.message}`); - } - else { - getLogSink().error(`remotePromptRebuildIfStale failed ${JSON.stringify(e)}`); - } - } - } + let debounceTimer: ReturnType | undefined; + watcher.onDidChange(() => { + if (!isRemoteDevcontainerSession()) { return; } + if (debounceTimer) { clearTimeout(debounceTimer); } + debounceTimer = setTimeout(() => { + cmds.remotePromptRebuildIfStale(ctx).catch((e: unknown) => { + getLogSink().error(`remotePromptRebuildIfStale failed: ${fmtErr(e)}`); + }); + }, 250); }); watcher.onDidCreate(() => { updateHasConfigContext(true); }); watcher.onDidDelete(() => { updateHasConfigContext(false); }); @@ -144,6 +150,21 @@ export async function createDevcontainerConfigWatcher(ctx: vscode.ExtensionConte return watcher; } +export function remapToCurrentAuthority(localWsf: vscode.Uri, filePath: vscode.Uri, remoteMountDir: string): vscode.Uri { + if (!isRemoteDevcontainerSession()) { + return filePath; + } + const authority = vscode.env.remoteAuthority; + if (!authority) { return filePath; } + + const relativePath = path.posix.relative(localWsf.path, filePath.path); + return vscode.Uri.from({ + scheme: "vscode-remote", + authority, + path: path.posix.join(remoteMountDir, relativePath), + }); +} + export function onWorkspaceReady() { // wait for remote workspace const wsf = vscode.workspace.workspaceFolders; diff --git a/src/parser/parser.ts b/src/parser/parser.ts index 9b1cf91..112edae 100644 --- a/src/parser/parser.ts +++ b/src/parser/parser.ts @@ -3,12 +3,16 @@ import * as jc from "jsonc-parser"; import * as schema from "./schema"; import { ParseError } from "../extension/error"; +import { getExecCtx } from "../common/ctx/ctx"; +import { getLogSink } from "../extension/log"; +import { fmtErr } from "../common/utils"; /* eslint-disable @typescript-eslint/unified-signatures */ export async function parseDevcontainer(fpath: vscode.Uri): Promise; export async function parseDevcontainer(contents: string): Promise; export async function parseDevcontainer(arg: string | vscode.Uri): Promise { if (typeof arg === "string") { + getLogSink().debug(`parseDevcontainer(${arg.length}: string):`); const parseInfo = schema.ConfigSchema.safeParse(jc.parse(arg)); if (parseInfo.success) { return parseInfo.data; @@ -18,13 +22,13 @@ export async function parseDevcontainer(arg: string | vscode.Uri): Promise { try { - const bytes = await vscode.workspace.fs.readFile(arg); - return new TextDecoder("utf-8").decode(bytes); + return await getExecCtx().fs.read(arg); } catch (e) { - throw new ParseError(`Could not read devcontainer.json file at ${arg.toString(true)}: ${JSON.stringify(e)}`); + throw new ParseError(`Could not read devcontainer.json file at ${arg.toString(true)}: ${fmtErr(e)}`); }; })(); diff --git a/src/remote/resolver.ts b/src/remote/resolver.ts index eadcd2e..15a37a4 100644 --- a/src/remote/resolver.ts +++ b/src/remote/resolver.ts @@ -8,8 +8,11 @@ import { ContainerConfig, ContainerEngine } from "../engine/container"; import { parseDevcontainer } from "../parser/parser"; import { BuildOptIntent } from "../common/globalState"; import { EngineError, InstallError, InternalError } from "../extension/error"; -import { getExtensionList, getSettings } from "../extension/settings"; import { DEVCONTAINER_SERVER_LISTEN_PORT } from "../common/constants"; +import { setExecCtx, getExecCtx } from "../common/ctx/ctx"; +import { RemoteExecCtx } from "../common/ctx/remoteCtx"; +import { SSHDestination } from "../extension/ssh"; +import * as utils from "../common/utils"; export const AUTHORITY_BASE: string = "devcontainer-remote"; @@ -88,10 +91,19 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc void this.extensionCtx; // TODO } - resolve(authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable { - this.localWsf = decodeRemoteAuthority(authority); + resolve(_authority: string, context: vscode.RemoteAuthorityResolverContext): Thenable { + const fullAuthority = vscode.env.remoteAuthority!; // eslint-disable-line @typescript-eslint/no-non-null-assertion - getLogSink().info(`Starting remote session from ${this.localWsf.toString(true)} (authority ${authority}, attempt #${context.resolveAttempt})...`); + if (context.execServer) { + getLogSink().info(`Exec server was provided: '${fullAuthority}'`); + setExecCtx(new RemoteExecCtx(context.execServer)); + // if exec server, any commands below run on the remote machine + } + + // authority has just our part, but we need the entirety for full Uri + this.localWsf = decodeRemoteAuthority(fullAuthority); + + getLogSink().info(`Starting remote session from ${this.localWsf.toString(true)} (authority ${fullAuthority}, attempt #${context.resolveAttempt})...`); const localWsfBasename = path.parse(this.localWsf.fsPath).base; @@ -110,7 +122,7 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc cancellationToken: vscode.CancellationToken, ): Promise { try { - return await this.createWindowTask(progress, cancellationToken); + return await this.createWindowTask(context, progress, cancellationToken); } catch (e: unknown) { const msg = e instanceof Error ? e.message : String(e); @@ -127,9 +139,9 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc } } - private async createWindowTask(progress: vscode.Progress<{ message?: string, increment?: number }>, _2: vscode.CancellationToken): Promise { + private async createWindowTask(context: vscode.RemoteAuthorityResolverContext, progress: vscode.Progress<{ message?: string, increment?: number }>, _2: vscode.CancellationToken): Promise { const buildOpt = BuildOptIntent.get(this.extensionCtx) ?? BuildOpts.Default; - const settings = getSettings(); + const settings = await getExecCtx().getSettings(); const engine = (() => { const e = path.parse(settings.dockerPath).base; if (e === "podman") { return ContainerEngine.podman; } @@ -144,7 +156,8 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc const devcontainerJson = await findDevcontainerJson(this.localWsf); const parsedConfig = await parseDevcontainer(devcontainerJson); - const containerConfig = ContainerConfig.create(this.localWsf.fsPath, devcontainerJson.fsPath, parsedConfig, process.env, { engine: engine }); + const hostEnv = await getExecCtx().env(); + const containerConfig = ContainerConfig.create(this.localWsf.fsPath, devcontainerJson.fsPath, parsedConfig, hostEnv.env, { engine: engine }); progress.report({ message: "Building image and starting container...", increment: 15 }); this.containerState = await ContainerState.create(this.localWsf.fsPath, containerConfig, settings, buildOpt); @@ -152,6 +165,26 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc const containerId = await this.containerState.getContainerId(); const localWsfBasename = path.parse(this.localWsf.fsPath).base; + const remoteHost = (() => { + const auth = vscode.env.remoteAuthority ?? ""; + if (!auth.includes("@")) { + return undefined; + } + + const [_devc, ssh, ..._rest] = auth.split("@"); + const [_sshAuthority, sshEncoded] = ssh.split("+"); + + utils.consume(_devc, _rest, _sshAuthority); + + return SSHDestination.parseEncoded(sshEncoded).hostname; + })(); + + // '📦 (container)' for local + // '📦 (host:container)' for ssh-chained + const containerTag = remoteHost + ? `${remoteHost}:${containerId.slice(0, 8)}` + : containerId.slice(0, 8); + // set status bar item this.statusItemFormatter = vscode.workspace.registerResourceLabelFormatter({ @@ -161,14 +194,14 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc label: "${path}", separator: "/", tildify: true, - workspaceSuffix: `📦 ${localWsfBasename} (${containerId.slice(0, 8)})`, + workspaceSuffix: `📦 ${localWsfBasename} (${containerTag})`, }, }); progress.report({ message: "Installing server...", increment: 30 }); const { host, port } = await this.containerState.installServer([ - ...getExtensionList(), + ...settings.defaultExtensions, ...parsedConfig.customizations?.vscode?.extensions ?? [], ]); this.serverHostPort = port; @@ -180,7 +213,15 @@ export class DevContainerResolver implements vscode.RemoteAuthorityResolver, vsc vscode.commands.executeCommand("setContext", "forwardedPortsViewEnabled", true); vscode.commands.executeCommand("setContext", "forwardedPortsFeaturesEnabled", true); - return new vscode.ResolvedAuthority(host, port, ctkn); + // if exec server, host and port are on the remote machine. forward it out to the local machine + if (context.execServer) { + return new vscode.ManagedResolvedAuthority(() => { + return connectToRemote(context.execServer!, host, port); // eslint-disable-line @typescript-eslint/no-non-null-assertion + }, ctkn); + } + else { + return new vscode.ResolvedAuthority(host, port, ctkn); + } } // getCanonicalURI?(uri: vscode.Uri): vscode.ProviderResult { @@ -231,3 +272,30 @@ async function notifyResolverError(message: string) { return; } } + +// better name lol +async function connectToRemote(execServer: vscode.ExecServer, host: string, port: number): Promise { + const { stream, done } = await execServer.tcpConnect(host, port); + + const onDidClose = new vscode.EventEmitter(); + const onDidEnd = new vscode.EventEmitter(); + + done.then( + () => { + onDidEnd.fire(); + onDidClose.fire(undefined); + }, + (err: unknown) => { + onDidEnd.fire(); + onDidClose.fire(err instanceof Error ? err : new Error(String(err))); + }, + ); + + return { + onDidReceiveMessage: stream.onDidReceiveMessage, + onDidClose: onDidClose.event, + onDidEnd: onDidEnd.event, + send: (data: Uint8Array) => { stream.write(data); }, + end: () => { stream.end(); }, + }; +} diff --git a/src/tests/common.ts b/src/tests/common.ts index 67047a2..f805abb 100644 --- a/src/tests/common.ts +++ b/src/tests/common.ts @@ -11,7 +11,7 @@ import { findDevcontainerJson } from "../extension/workspace"; import { parseDevcontainer } from "../parser/parser"; import { ContainerConfig } from "../engine/container"; import { Config } from "../parser/schema"; -import { Settings } from "../extension/settings"; +import { Settings } from "../common/settings"; import * as utils from "../common/utils"; const DEBUG_TESTS: boolean = process.env.DEBUG_TESTS !== undefined @@ -135,3 +135,9 @@ export async function setupFixture(opts: { name: string, testDir: string }): Pro return { localWsf: testDir, localWsfBasename: path.parse(testDir).base, config: config }; } /* eslint-enable @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-assignment */ + +export function getTestTimeout(defaultDurSeconds: number): number { + const envTimeout = process.env.TEST_TIMEOUT; + const timeout = envTimeout ? Number.parseInt(envTimeout) : defaultDurSeconds; + return timeout * 1000; +} diff --git a/src/tests/fixtures/img-basic/test.ts b/src/tests/fixtures/img-basic/test.ts index c844212..4674783 100644 --- a/src/tests/fixtures/img-basic/test.ts +++ b/src/tests/fixtures/img-basic/test.ts @@ -7,7 +7,7 @@ import { ContainerInspectResult, ContainerState } from "../../../engine/lifecycl import { ContainerConfig } from "../../../engine/container"; import { parseEnv } from "../../../common/utils"; -import { initMocks, setupFixture, jsonFormat, getMockSettings, TEST_CODIUM_INFO } from "../../common"; +import { initMocks, setupFixture, jsonFormat, getMockSettings, TEST_CODIUM_INFO, getTestTimeout } from "../../common"; const SETTINGS = getMockSettings(); initMocks(); @@ -32,7 +32,7 @@ describe.skipIf(!SETTINGS.dockerPath)("integration: lifecycle: img-basic", async cc = ContainerConfig.create(localWsf, devcPath, config, localEnv); container = await ContainerState.create(localWsf, cc, SETTINGS); - }, 60 * 1000); + }, getTestTimeout(60)); test("workspace mounts exists", async () => { const inspectResult = await run([engine, "inspect", container.getContainerName(), ...jsonFormat], { cwd: localWsf, env: localEnv }); @@ -127,7 +127,7 @@ describe.skipIf(!SETTINGS.dockerPath)("integration: lifecycle: img-basic", async })(); expect(healthVersion).eq(TEST_CODIUM_INFO.commit); - }, 60 * 1000); + }, getTestTimeout(60)); test("connection token is stable across calls", async () => { const token1 = await container.getConnectionToken(); diff --git a/src/tests/fixtures/img-pull/test.ts b/src/tests/fixtures/img-pull/test.ts index 4ec68af..0ae2153 100644 --- a/src/tests/fixtures/img-pull/test.ts +++ b/src/tests/fixtures/img-pull/test.ts @@ -6,7 +6,7 @@ import { ContainerState } from "../../../engine/lifecycle"; import { ContainerConfig } from "../../../engine/container"; import * as schema from "../../../parser/schema"; -import { initMocks, setupFixture, jsonFormat, getMockSettings } from "../../common"; +import { initMocks, setupFixture, jsonFormat, getMockSettings, getTestTimeout } from "../../common"; const SETTINGS = getMockSettings(); initMocks(); @@ -51,7 +51,7 @@ describe.skipIf(!SETTINGS.dockerPath)("integration: lifecycle: img-pull", async const cId = await container.getContainerId(); expect(cId.length).toBeGreaterThan(16); - }, 60_000); + }, getTestTimeout(60)); test("getConnectionToken returns correct token over multiple install (without force)", async () => { const firstInstall = await container.installServer(); @@ -100,7 +100,7 @@ describe.skipIf(!SETTINGS.dockerPath)("integration: lifecycle: img-pull", async const iterTok = await container.getConnectionToken(); expect(iterTok).eq(token); } - }, 60_000); + }, getTestTimeout(60)); test("getConnectionToken dies on stopped container", async () => { const installServer = await container.installServer(); @@ -108,5 +108,5 @@ describe.skipIf(!SETTINGS.dockerPath)("integration: lifecycle: img-pull", async await container.stopContainer(); await expect(container.getConnectionToken()).rejects.toThrow(Error); - }, 60_000); + }, getTestTimeout(60)); });