Skip to content
Open
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
212 changes: 212 additions & 0 deletions src/api/updateParameters.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import * as vscode from "vscode";

import { escapeShellArg } from "../util";

import type { Api } from "coder/site/src/api/api";
import type {
TemplateVersionParameter,
Workspace,
} from "coder/site/src/api/typesGenerated";

/** Thrown when the user dismisses a parameter prompt. */
export class WorkspaceUpdateCancelledError extends Error {
constructor() {
super("Workspace update cancelled");
this.name = "WorkspaceUpdateCancelledError";
}
}

/**
* Prompts the user for any newly-required template parameters and returns
* `--parameter name=value` args suitable for `coder update`. Throws
* `WorkspaceUpdateCancelledError` if the user dismisses a prompt.
*/
export async function collectUpdateParameters(
restClient: Api,
workspace: Workspace,
): Promise<string[]> {
const [newParams, currentValues] = await Promise.all([
restClient.getTemplateVersionRichParameters(
workspace.template_active_version_id,
),
restClient.getWorkspaceBuildParameters(workspace.latest_build.id),
]);
const candidates = newParams.filter((p) => p.required && !p.default_value);
Comment thread
EhabY marked this conversation as resolved.
if (candidates.length === 0) return [];

const existing = new Set(currentValues.map((p) => p.name));
const toPrompt = candidates.filter((p) => !existing.has(p.name));

const args: string[] = [];
for (let i = 0; i < toPrompt.length; i++) {
const param = toPrompt[i];
const value = await promptForParameter(param, i + 1, toPrompt.length);
if (value === undefined) {
throw new WorkspaceUpdateCancelledError();
}
args.push("--parameter", escapeShellArg(`${param.name}=${value}`));
}
return args;
}

function promptForParameter(
param: TemplateVersionParameter,
step: number,
totalSteps: number,
): Promise<string | undefined> {
const title = param.display_name || param.name;
const items = quickPickItems(param);

if (items) {
const multi = param.form_type === "multi-select";
const qp = vscode.window.createQuickPick<(typeof items)[number]>();
qp.title = title;
qp.step = step;
qp.totalSteps = totalSteps;
qp.placeholder = param.description_plaintext;
qp.items = items;
qp.canSelectMany = multi;
qp.ignoreFocusOut = true;
return collectInput(qp, () => {
if (multi) {
return qp.selectedItems.length > 0
? JSON.stringify(qp.selectedItems.map((i) => i.value))
: undefined;
}
return qp.selectedItems[0]?.value;
});
}

const input = vscode.window.createInputBox();
input.title = title;
input.step = step;
input.totalSteps = totalSteps;
input.prompt = param.description_plaintext;
input.placeholder = formatConstraint(param);
input.value = param.default_value;
input.ignoreFocusOut = true;
const validate = makeValidator(param);
const refresh = () => {
input.validationMessage = validate(input.value).message ?? "";
};
refresh();
input.onDidChangeValue(refresh);
return collectInput(input, () =>
validate(input.value).ok ? input.value : undefined,
);
}

/** Resolves with `onAccept()` on accept, or `undefined` when hidden. */
function collectInput<T>(
qi: vscode.InputBox | vscode.QuickPick<vscode.QuickPickItem>,
onAccept: () => T | undefined,
): Promise<T | undefined> {
return new Promise((resolve) => {
let done = false;
const finish = (value: T | undefined) => {
if (done) return;
done = true;
resolve(value);
qi.dispose();
};
qi.onDidAccept(() => {
const value = onAccept();
if (value !== undefined) finish(value);
});
qi.onDidHide(() => finish(undefined));
qi.show();
});
}

/**
* Returns picker items if the param needs a chooser, otherwise undefined.
* Anything that falls through gets a free-form text input.
*/
function quickPickItems(
param: TemplateVersionParameter,
): Array<vscode.QuickPickItem & { value: string }> | undefined {
if (param.type === "bool") {
return [
{ label: "True", value: "true" },
{ label: "False", value: "false" },
];
}
if (param.options.length > 0) {
return param.options.map((o) => ({
label: o.name,
description: o.description,
value: o.value,
}));
}
return undefined;
}

function isSet<T>(value: T | null | undefined): value is T {
return value !== null && value !== undefined;
}

function formatConstraint(param: TemplateVersionParameter): string {
if (param.type === "number") {
const lo = param.validation_min;
const hi = param.validation_max;
if (isSet(lo) && isSet(hi)) return `between ${lo} and ${hi}`;
if (isSet(lo)) return `at least ${lo}`;
if (isSet(hi)) return `at most ${hi}`;
return "a number";
}
if (param.validation_regex) {
return (
substituteTemplate(param.validation_error, param) ||
`must match ${param.validation_regex}`
);
}
return "";
}

/** Substitutes `{min}`, `{max}`, `{value}` placeholders in validation_error. */
function substituteTemplate(
template: string | undefined,
param: TemplateVersionParameter,
value?: string,
): string | undefined {
if (!template) return template;
return template
.replace(/{min}/g, String(param.validation_min ?? ""))
.replace(/{max}/g, String(param.validation_max ?? ""))
.replace(/{value}/g, value ?? "");
}

/**
* Returns `{ ok, message }`. Regex constraints are intentionally not tested
* client-side; server validates with RE2 (linear-time, ReDoS-safe).
*/
function makeValidator(
param: TemplateVersionParameter,
): (input: string) => { ok: boolean; message?: string } {
return (input) => {
if (!input) return { ok: !param.required };
if (param.type === "number") {
const n = Number(input);
if (!Number.isFinite(n)) {
return { ok: false, message: "Must be a number" };
}
if (isSet(param.validation_min) && n < param.validation_min) {
return {
ok: false,
message:
substituteTemplate(param.validation_error, param, input) ||
`Must be at least ${param.validation_min}`,
};
}
if (isSet(param.validation_max) && n > param.validation_max) {
return {
ok: false,
message:
substituteTemplate(param.validation_error, param, input) ||
`Must be at most ${param.validation_max}`,
};
}
}
return { ok: true };
};
}
77 changes: 43 additions & 34 deletions src/api/workspace.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import { type Api } from "coder/site/src/api/api";
import {
type WorkspaceAgentLog,
type ProvisionerJobLog,
type Workspace,
} from "coder/site/src/api/typesGenerated";
import { spawn } from "node:child_process";
import * as vscode from "vscode";

import { type FeatureSet } from "../featureSet";
import { getGlobalShellFlags, type CliAuth } from "../settings/cli";
import { escapeCommandArg } from "../util";
import { type UnidirectionalStream } from "../websocket/eventStreamConnection";
import { escapeCommandArg, escapeShellArg } from "../util";

import { errToStr, createWorkspaceIdentifier } from "./api-helper";
import { type CoderApi } from "./coderApi";
import { collectUpdateParameters } from "./updateParameters";

import type { Api } from "coder/site/src/api/api";
import type {
ProvisionerJobLog,
Workspace,
WorkspaceAgentLog,
} from "coder/site/src/api/typesGenerated";

import type { FeatureSet } from "../featureSet";
import type { UnidirectionalStream } from "../websocket/eventStreamConnection";

import type { CoderApi } from "./coderApi";

/** Opens a stream once; subsequent open() calls are no-ops until closed. */
export class LazyStream<T> {
Expand Down Expand Up @@ -54,42 +58,40 @@ interface CliContext {
featureSet: FeatureSet;
}

/**
* Spawn a Coder CLI subcommand and stream its output.
* Resolves when the process exits successfully; rejects on non-zero exit.
*/
/** Streams CLI output via `ctx.write`; rejects with stderr on non-zero exit. */
function runCliCommand(ctx: CliContext, args: string[]): Promise<void> {
Comment thread
EhabY marked this conversation as resolved.
return new Promise((resolve, reject) => {
const fullArgs = [
...getGlobalShellFlags(vscode.workspace.getConfiguration(), ctx.auth),
...args,
createWorkspaceIdentifier(ctx.workspace),
escapeShellArg(createWorkspaceIdentifier(ctx.workspace)),
];

const cmd = `${escapeCommandArg(ctx.binPath)} ${fullArgs.join(" ")}`;
const proc = spawn(cmd, { shell: true });
// Unexpected prompts EOF instead of hanging forever.
proc.stdin.end();

proc.stdout.on("data", (data: Buffer) => {
ctx.write(data.toString().replace(/\r?\n/g, "\r\n"));
ctx.write(data.toString());
});

let capturedStderr = "";
proc.stderr.on("data", (data: Buffer) => {
const text = data.toString();
ctx.write(text.replace(/\r?\n/g, "\r\n"));
ctx.write(text);
capturedStderr += text;
});

proc.on("close", (code: number) => {
proc.on("close", (code: number | null, signal: NodeJS.Signals | null) => {
if (code === 0) {
resolve();
} else {
let errorText = `"${fullArgs.join(" ")}" exited with code ${code}`;
if (capturedStderr !== "") {
errorText += `: ${capturedStderr}`;
}
reject(new Error(errorText));
return;
}
const exit =
code !== null ? `code ${code}` : `signal ${signal ?? "unknown"}`;
let msg = `"${fullArgs.join(" ")}" exited with ${exit}`;
if (capturedStderr) msg += `: ${capturedStderr}`;
reject(new Error(msg));
});
});
}
Expand All @@ -113,24 +115,31 @@ export async function startWorkspace(ctx: CliContext): Promise<Workspace> {
}

/**
* Update a workspace to the latest template version.
*
* Uses `coder update` when the CLI supports it (>= 2.24).
* Falls back to the REST API: stop, wait, then updateWorkspaceVersion.
* Update a workspace to the latest template version. Collects any newly-
* required parameters via VS Code prompts and passes them to the CLI as flags
* (the resolver phase can't render an interactive terminal). Falls back to
* the REST API for CLIs older than 2.24.
*/
export async function updateWorkspace(ctx: CliContext): Promise<Workspace> {
if (ctx.featureSet.cliUpdate) {
await runCliCommand(ctx, ["update"]);
return ctx.restClient.getWorkspace(ctx.workspace.id);
if (!ctx.featureSet.cliUpdate) {
return updateWorkspaceVersion(ctx);
}

// REST API fallback for older CLIs.
const paramArgs = await collectUpdateParameters(
ctx.restClient,
ctx.workspace,
);
await runCliCommand(ctx, ["update", ...paramArgs]);
return ctx.restClient.getWorkspace(ctx.workspace.id);
}

async function updateWorkspaceVersion(ctx: CliContext): Promise<Workspace> {
if (ctx.workspace.latest_build.status === "running") {
ctx.write("Stopping workspace for update...\r\n");
const stopBuild = await ctx.restClient.stopWorkspace(ctx.workspace.id);
const stoppedJob = await ctx.restClient.waitForBuild(stopBuild);
if (stoppedJob?.status === "canceled") {
throw new Error("Workspace update canceled during stop");
throw new Error("Workspace update cancelled during stop");
}
}

Expand Down
6 changes: 3 additions & 3 deletions src/remote/remote.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,9 @@ import { type LoginCoordinator } from "../login/loginCoordinator";
import { OAuthSessionManager } from "../oauth/sessionManager";
import {
type CliAuth,
getGlobalFlagsRaw,
getGlobalShellFlags,
getSshFlags,
getUserGlobalFlags,
resolveCliAuth,
} from "../settings/cli";
import { getHeaderCommand } from "../settings/headers";
Expand Down Expand Up @@ -436,7 +436,7 @@ export class Remote {
setting: "coder.globalFlags",
title: "Global Flags",
getValue: () =>
getGlobalFlagsRaw(vscode.workspace.getConfiguration()),
getUserGlobalFlags(vscode.workspace.getConfiguration()),
},
{
setting: "coder.headerCommand",
Expand Down Expand Up @@ -561,7 +561,7 @@ export class Remote {
const isReady = await stateMachine.processWorkspace(w, progress);
if (isReady) {
subscription.dispose();
resolve(w);
resolve(stateMachine.getWorkspace() ?? w);
return;
}
} catch (error: unknown) {
Expand Down
Loading
Loading