Skip to content

Commit 4201472

Browse files
authored
Fixup containers ssh UX papercuts (#11578)
* Add helper function to show or hide the cursor * Watch cli directory in wrangler "dev" script * containers: ssh UX improvements Fix a few papercuts with the new container ssh command: * Provide single-letter aliases for common SSH options (`-F` to specify a config file, `-o` to specify options, `-i` to set the private key) * Allow `-o` to be used multiple times for setting multiple options * Show the cursor when the SSH process starts. The library that Wrangler uses to draw the interactive UI (clack) hides the cursor, so it is not visible when the SSH shell starts. We need to manually make it visible. * Hard code some specific SSH options (ControlMaster, ControlPersist, UserKnownHostsFile, and StrictHostKeyChecking). These don't make sense to use with the way our SSH implementation works
1 parent 76b25cd commit 4201472

File tree

7 files changed

+100
-23
lines changed

7 files changed

+100
-23
lines changed

.changeset/pretty-teams-refuse.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"wrangler": patch
3+
---
4+
5+
Fixup UX papercuts in containers SSH

.changeset/shaky-women-help.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@cloudflare/cli": minor
3+
---
4+
5+
Add showCursor helper function to show or hide the cursor in the terminal

packages/cli/cursor.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import process from "node:process";
2+
3+
export function showCursor(show: boolean, stream = process.stderr) {
4+
if (!stream.isTTY) {
5+
return;
6+
}
7+
8+
if (show) {
9+
stream.write("\x1b[?25h");
10+
} else {
11+
stream.write("\x1b[?25l");
12+
}
13+
}

packages/cli/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -277,3 +277,4 @@ export const error = (
277277
};
278278

279279
export { checkMacOSVersion } from "./check-macos-version";
280+
export { showCursor } from "./cursor";

packages/wrangler/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@
5555
"check:lint": "eslint . --max-warnings=0",
5656
"check:type": "tsc -p ./tsconfig.json && tsc -p ./templates/tsconfig.json",
5757
"clean": "rimraf wrangler-dist miniflare-dist emitted-types",
58-
"dev": "pnpm run clean && concurrently -c black,blue --kill-others-on-fail false \"pnpm tsup --watch src --watch ../containers-shared/src\" \"pnpm run check:type --watch --preserveWatchOutput\"",
58+
"dev": "pnpm run clean && concurrently -c black,blue --kill-others-on-fail false \"pnpm tsup --watch src --watch ../containers-shared/src --watch ../cli\" \"pnpm run check:type --watch --preserveWatchOutput\"",
5959
"generate-json-schema": "node -r esbuild-register scripts/generate-json-schema.ts",
6060
"start": "pnpm run build && cross-env NODE_OPTIONS=--enable-source-maps ./bin/wrangler.js",
6161
"test": "dotenv -- pnpm run assert-git-version && dotenv -- vitest",

packages/wrangler/src/__tests__/containers/ssh.test.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ describe("containers ssh", () => {
2727
"wrangler containers ssh ID
2828
2929
POSITIONALS
30-
ID id of the container instance [string] [required]
30+
ID ID of the container instance [string] [required]
3131
3232
GLOBAL FLAGS
3333
-c, --config Path to Wrangler configuration file [string]
@@ -41,11 +41,11 @@ describe("containers ssh", () => {
4141
--cipher Sets \`ssh -c\`: Select the cipher specification for encrypting the session [string]
4242
--log-file Sets \`ssh -E\`: Append debug logs to log_file instead of standard error [string]
4343
--escape-char Sets \`ssh -e\`: Set the escape character for sessions with a pty (default: ‘~’) [string]
44-
--config-file Sets \`ssh -F\`: Specify an alternative per-user ssh configuration file [string]
45-
--pkcs11 \`Sets \`ssh -I\`: Specify the PKCS#11 shared library ssh should use to communicate with a PKCS#11 token providing keys for user authentication [string]
46-
--identity-file Sets \`ssh -i\`: Select a file from which the identity (private key) for public key authentication is read [string]
44+
-F, --config-file Sets \`ssh -F\`: Specify an alternative per-user ssh configuration file [string]
45+
--pkcs11 Sets \`ssh -I\`: Specify the PKCS#11 shared library ssh should use to communicate with a PKCS#11 token providing keys for user authentication [string]
46+
-i, --identity-file Sets \`ssh -i\`: Select a file from which the identity (private key) for public key authentication is read [string]
4747
--mac-spec Sets \`ssh -m\`: A comma-separated list of MAC (message authentication code) algorithms, specified in order of preference [string]
48-
--option Sets \`ssh -o\`: Can be used to give options in the format used in the ssh configuration file [string]
48+
-o, --option Sets \`ssh -o\`: Set options in the format used in the ssh configuration file. May be repeated [string]
4949
--tag Sets \`ssh -P\`: Specify a tag name that may be used to select configuration in ssh_config [string]"
5050
`);
5151
});
@@ -79,8 +79,7 @@ describe("containers ssh", () => {
7979
runWrangler(`containers ssh ${instanceId}`)
8080
).rejects.toThrowErrorMatchingInlineSnapshot(
8181
`
82-
[Error: There has been an error verifying SSH access.
83-
something happened]
82+
[Error: Error verifying SSH access: something happened]
8483
`
8584
);
8685
});
@@ -109,7 +108,8 @@ describe("containers ssh", () => {
109108
const mockWebSocket = new MockWebSocketServer(wsUrl);
110109
await expect(runWrangler(`containers ssh ${instanceId}`)).rejects
111110
.toMatchInlineSnapshot(`
112-
[Error: ssh exited unsuccessfully. Is the container running?]
111+
[Error: SSH exited unsuccessfully. Is the container running?
112+
NOTE: SSH does not automatically wake a container or count as activity to keep a container alive]
113113
`);
114114

115115
// We got a connection

packages/wrangler/src/containers/ssh.ts

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { spawn } from "node:child_process";
22
import { createServer } from "node:net";
3+
import { showCursor } from "@cloudflare/cli";
4+
import { bold } from "@cloudflare/cli/colors";
35
import { ApiError, DeploymentsService } from "@cloudflare/containers-shared";
46
import { UserError } from "@cloudflare/workers-utils";
57
import { WebSocket } from "ws";
@@ -17,7 +19,7 @@ export function sshYargs(args: CommonYargsArgv) {
1719
return (
1820
args
1921
.positional("ID", {
20-
describe: "id of the container instance",
22+
describe: "ID of the container instance",
2123
type: "string",
2224
demandOption: true,
2325
})
@@ -38,16 +40,18 @@ export function sshYargs(args: CommonYargsArgv) {
3840
type: "string",
3941
})
4042
.option("config-file", {
43+
alias: "F",
4144
describe:
4245
"Sets `ssh -F`: Specify an alternative per-user ssh configuration file",
4346
type: "string",
4447
})
4548
.option("pkcs11", {
4649
describe:
47-
"`Sets `ssh -I`: Specify the PKCS#11 shared library ssh should use to communicate with a PKCS#11 token providing keys for user authentication",
50+
"Sets `ssh -I`: Specify the PKCS#11 shared library ssh should use to communicate with a PKCS#11 token providing keys for user authentication",
4851
type: "string",
4952
})
5053
.option("identity-file", {
54+
alias: "i",
5155
describe:
5256
"Sets `ssh -i`: Select a file from which the identity (private key) for public key authentication is read",
5357
type: "string",
@@ -58,8 +62,9 @@ export function sshYargs(args: CommonYargsArgv) {
5862
type: "string",
5963
})
6064
.option("option", {
65+
alias: "o",
6166
describe:
62-
"Sets `ssh -o`: Can be used to give options in the format used in the ssh configuration file",
67+
"Sets `ssh -o`: Set options in the format used in the ssh configuration file. May be repeated",
6368
type: "string",
6469
})
6570
.option("tag", {
@@ -87,10 +92,18 @@ export async function sshCommand(
8792
);
8893
} catch (e) {
8994
if (e instanceof ApiError) {
90-
throw new Error(
91-
"There has been an error verifying SSH access.\n" + e.body.error
92-
);
95+
if (e.status === 404) {
96+
throw new Error(`Instance ${sshArgs.ID} not found`);
97+
}
98+
99+
let msg = `Error verifying SSH access`;
100+
if (e.body.error !== undefined) {
101+
msg += `: ${e.body.error}`;
102+
}
103+
104+
throw new Error(msg);
93105
}
106+
94107
throw e;
95108
}
96109

@@ -105,17 +118,25 @@ export async function sshCommand(
105118

106119
await verifySshInstalled("ssh");
107120

121+
// Get arguments passed to the SSH command itself. yargs includes
122+
// "containers" and "ssh" as the first two elements of the array, which
123+
// we don't want, so we don't include those.
124+
const [, , ...rest] = sshArgs._;
125+
108126
const child = spawn(
109127
"ssh",
110128
[
111129
"cloudchamber@127.0.0.1",
112130
"-p",
113131
`${proxyAddress.port}`,
114132
...buildSshArgs(sshArgs),
133+
"--",
134+
...rest.map((v) => v.toString()),
115135
],
116136
{
117137
stdio: ["inherit", "inherit", "inherit"],
118138
detached: true,
139+
signal: proxyController.signal,
119140
}
120141
);
121142

@@ -132,21 +153,31 @@ export async function sshCommand(
132153
resolve(undefined);
133154
} else {
134155
reject(
135-
new Error(`ssh exited unsuccessfully. Is the container running?`)
156+
new Error(
157+
[
158+
"SSH exited unsuccessfully. Is the container running?",
159+
`${bold("NOTE:")} SSH does not automatically wake a container or count as activity to keep a container alive`,
160+
].join("\n")
161+
)
136162
);
137163
}
138164
});
139165
});
166+
167+
// Ensure the cursor is visible.
168+
showCursor(true);
169+
140170
await childKilled;
141171

172+
// Hide the cursor again.
173+
showCursor(false);
174+
142175
proxyController.abort();
143176
}
144177

145178
export function verifySshInstalled(sshPath: string): Promise<undefined> {
146179
return new Promise<undefined>((resolve, reject) => {
147-
const child = spawn(sshPath, ["-V"], {
148-
detached: true,
149-
});
180+
const child = spawn(sshPath, ["-V"]);
150181

151182
let errorHandled = false;
152183
child.on("close", (code) => {
@@ -161,7 +192,7 @@ export function verifySshInstalled(sshPath: string): Promise<undefined> {
161192
child.on("error", (err) => {
162193
if (!errorHandled) {
163194
errorHandled = true;
164-
reject(new Error(`verifying ssh installation failed: ${err.message}`));
195+
reject(new Error(`Verifying SSH installation failed: ${err.message}`));
165196
}
166197
});
167198
});
@@ -195,7 +226,7 @@ export function createSshTcpProxy(sshResponse: WranglerSSHResponse): Server {
195226
ws.binaryType = "arraybuffer";
196227

197228
ws.addEventListener("error", (err) => {
198-
logger.error("Web socket error:", err.error);
229+
logger.error("Web socket error:", err.message);
199230
inbound.end();
200231
proxy.close();
201232
});
@@ -228,7 +259,26 @@ export function createSshTcpProxy(sshResponse: WranglerSSHResponse): Server {
228259
function buildSshArgs(
229260
sshArgs: StrictYargsOptionsToInterface<typeof sshYargs>
230261
): string[] {
231-
const flags: string[] = [];
262+
const flags = [
263+
// Never use a control socket.
264+
"-o",
265+
"ControlMaster=no",
266+
"-o",
267+
"ControlPersist=no",
268+
// Disable writing host keys to user known hosts file.
269+
"-o",
270+
"UserKnownHostsFile=/dev/null",
271+
// Do not perform strict host key checking: we use the same IP
272+
// address to connect to every container, all of which can have
273+
// separate host keys.
274+
"-o",
275+
"StrictHostKeyChecking=no",
276+
];
277+
278+
// Hide warnings from SSH unless debug logging is enabled
279+
if (process.env.WRANGLER_LOG !== "debug") {
280+
flags.push("-o", "LogLevel=ERROR");
281+
}
232282

233283
if (sshArgs.cipher !== undefined) {
234284
flags.push("-c", sshArgs.cipher);
@@ -259,7 +309,10 @@ function buildSshArgs(
259309
}
260310

261311
if (sshArgs.option !== undefined) {
262-
flags.push("-o", sshArgs.option);
312+
const options = Array.isArray(sshArgs.option)
313+
? sshArgs.option
314+
: [sshArgs.option];
315+
options.forEach((o) => flags.push("-o", o));
263316
}
264317

265318
if (sshArgs.tag !== undefined) {

0 commit comments

Comments
 (0)