Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,6 @@ These features are not supported, but might be added in the future.
- config: `hostRequirements`
- config: `waitFor`
- named config files `.devcontainer/<name>/devcontainer.json`
- devcontainer on a remote machine (wip!)

While this extension might work with `vscode`, it is not supported.

Expand Down
13 changes: 4 additions & 9 deletions src/common/cmd.ts
Original file line number Diff line number Diff line change
@@ -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);
}
21 changes: 21 additions & 0 deletions src/common/ctx/ctx.ts
Original file line number Diff line number Diff line change
@@ -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;
}
26 changes: 26 additions & 0 deletions src/common/ctx/execCtx.ts
Original file line number Diff line number Diff line change
@@ -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<vscode.FileStat>,
read(filePath: vscode.Uri): Promise<string>,
write(filePath: vscode.Uri, content: string): Promise<void>,
};

export interface ExecCtx {
kind: ExecCtxKind,
fs: FsCtx,
run(cmdArgs: string[], opts: RunOpts): Promise<CmdResult>,
getHostUserInfo(): Promise<HostUserInfo>,
env(): Promise<vscode.ExecEnvironment>,
getSettings(): Promise<Settings>,
}
86 changes: 86 additions & 0 deletions src/common/ctx/localCtx.ts
Original file line number Diff line number Diff line change
@@ -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<vscode.FileStat> {
return vscode.workspace.fs.stat(path);
}

async read(path: vscode.Uri): Promise<string> {
const bytes = await vscode.workspace.fs.readFile(path);
return new TextDecoder("utf-8").decode(bytes);
}

async write(path: vscode.Uri, content: string): Promise<void> {
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<CmdResult> {
if (this.execServer) {
return await spawnRemote(this.execServer, cmdArgs, opts);
}
return await spawn(cmdArgs[0], cmdArgs.slice(1), { ...opts, log: getLogSink() });
}

async getHostUserInfo(): Promise<HostUserInfo> {
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<vscode.ExecEnvironment> {
return {
env: process.env as vscode.ProcessEnv,
osPlatform: process.platform,
};
}

// eslint-disable-next-line @typescript-eslint/require-await
async getSettings(): Promise<settings.Settings> {
// 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<string>("dockerPath"),
extraArgs: settings.getConfig<string[]>("extraArgs"),
defaultExtensions: settings.getConfig<string[]>("defaultExtensions"),
});

getLogSink().debug(`LocalExecCtx.getSettings(): ${JSON.stringify(ret)}`);
return ret;
}
}
157 changes: 157 additions & 0 deletions src/common/ctx/remoteCtx.ts
Original file line number Diff line number Diff line change
@@ -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<vscode.FileStat> {
return this.remoteFs.stat(path.fsPath);
}

async read(path: vscode.Uri): Promise<string> {
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<void> {
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<CmdResult> {
return spawnRemote(this.execServer, cmdArgs, opts);
}

public async getHostUserInfo(): Promise<HostUserInfo> {
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<settings.Settings> {
// 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<string, string> = 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 };
}
22 changes: 22 additions & 0 deletions src/common/opts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { LogOutputChannel } from "vscode";

export type Envs = Record<string, string | undefined>;

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,
};
Loading
Loading