Skip to content

Commit b9bb8dd

Browse files
committed
feat: Add feature to allow config-ssh value overrides
1 parent 4e471cb commit b9bb8dd

File tree

2 files changed

+90
-3
lines changed

2 files changed

+90
-3
lines changed

src/sshConfig.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,3 +167,41 @@ Host coder-vscode--*
167167
mode: 384,
168168
})
169169
})
170+
171+
it("override values", async () => {
172+
mockFileSystem.readFile.mockRejectedValueOnce("No file found")
173+
const sshConfig = new SSHConfig(sshFilePath, mockFileSystem, {
174+
ssh_config_options: {
175+
loglevel: "DEBUG", // This tests case insensitive
176+
ConnectTimeout: "500",
177+
ExtraKey: "ExtraValue",
178+
Foo: "bar",
179+
Buzz: "baz",
180+
},
181+
hostname_prefix: "",
182+
})
183+
await sshConfig.load()
184+
await sshConfig.update({
185+
Host: "coder-vscode--*",
186+
ProxyCommand: "some-command-here",
187+
ConnectTimeout: "0",
188+
StrictHostKeyChecking: "no",
189+
UserKnownHostsFile: "/dev/null",
190+
LogLevel: "ERROR",
191+
})
192+
193+
const expectedOutput = `# --- START CODER VSCODE ---
194+
Host coder-vscode--*
195+
ProxyCommand some-command-here
196+
ConnectTimeout 500
197+
StrictHostKeyChecking no
198+
UserKnownHostsFile /dev/null
199+
LogLevel DEBUG
200+
Buzz baz
201+
ExtraKey ExtraValue
202+
Foo bar
203+
# --- END CODER VSCODE ---`
204+
205+
expect(mockFileSystem.readFile).toBeCalledWith(sshFilePath, expect.anything())
206+
expect(mockFileSystem.writeFile).toBeCalledWith(sshFilePath, expectedOutput, expect.anything())
207+
})

src/sshConfig.ts

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { SSHConfigResponse } from "coder/site/src/api/typesGenerated"
12
import { writeFile, readFile } from "fs/promises"
23
import { ensureDir } from "fs-extra"
34
import path from "path"
@@ -30,16 +31,27 @@ const defaultFileSystem: FileSystem = {
3031
writeFile,
3132
}
3233

34+
const defaultSSHConfigResponse: SSHConfigResponse = {
35+
ssh_config_options: {},
36+
hostname_prefix: "coder.",
37+
}
38+
3339
export class SSHConfig {
3440
private filePath: string
3541
private fileSystem: FileSystem
42+
private deploymentConfig: SSHConfigResponse
3643
private raw: string | undefined
3744
private startBlockComment = "# --- START CODER VSCODE ---"
3845
private endBlockComment = "# --- END CODER VSCODE ---"
3946

40-
constructor(filePath: string, fileSystem: FileSystem = defaultFileSystem) {
47+
constructor(
48+
filePath: string,
49+
fileSystem: FileSystem = defaultFileSystem,
50+
sshConfig: SSHConfigResponse = defaultSSHConfigResponse,
51+
) {
4152
this.filePath = filePath
4253
this.fileSystem = fileSystem
54+
this.deploymentConfig = sshConfig
4355
}
4456

4557
async load() {
@@ -59,7 +71,7 @@ export class SSHConfig {
5971
if (block) {
6072
this.eraseBlock(block)
6173
}
62-
this.appendBlock(values)
74+
this.appendBlock(values, this.deploymentConfig.ssh_config_options)
6375
await this.save()
6476
}
6577

@@ -102,12 +114,49 @@ export class SSHConfig {
102114
this.raw = this.getRaw().replace(block.raw, "")
103115
}
104116

105-
private appendBlock({ Host, ...otherValues }: SSHValues) {
117+
/**
118+
*
119+
* appendBlock builds the ssh config block. The order of the keys is determinstic based on the input.
120+
* Expected values are always in a consistent order followed by any additional overrides in sorted order.
121+
*
122+
* @param param0 - SSHValues are the expected SSH values for using ssh with coder.
123+
* @param overrides - Overrides typically come from the deployment api and are used to override the default values.
124+
* The overrides are given as key:value pairs where the key is the ssh config file key.
125+
* If the key matches an expected value, the expected value is overridden. If it does not
126+
* match an expected value, it is appended to the end of the block.
127+
*/
128+
private appendBlock({ Host, ...otherValues }: SSHValues, overrides: Record<string, string>) {
106129
const lines = [this.startBlockComment, `Host ${Host}`]
130+
// We need to do a case insensitive match for the overrides as ssh config keys are case insensitive.
131+
// To get the correct key:value, use:
132+
// key = caseInsensitiveOverrides[key.toLowerCase()]
133+
// value = overrides[key]
134+
const caseInsensitiveOverrides: Record<string, string> = {}
135+
Object.keys(overrides).forEach((key) => {
136+
caseInsensitiveOverrides[key.toLowerCase()] = key
137+
})
138+
107139
const keys = Object.keys(otherValues) as Array<keyof typeof otherValues>
108140
keys.forEach((key) => {
141+
const lower = key.toLowerCase()
142+
if (caseInsensitiveOverrides[lower]) {
143+
const correctCaseKey = caseInsensitiveOverrides[lower]
144+
// If the key is in overrides, use the override value.
145+
// Doing it this way maintains the default order of the keys.
146+
lines.push(this.withIndentation(`${key} ${overrides[correctCaseKey]}`))
147+
// Remove the key from the overrides so we don't write it again.
148+
delete caseInsensitiveOverrides[lower]
149+
return
150+
}
109151
lines.push(this.withIndentation(`${key} ${otherValues[key]}`))
110152
})
153+
// Write remaining overrides that have not been written yet. Sort to maintain deterministic order.
154+
const remainingKeys = (Object.keys(caseInsensitiveOverrides) as Array<keyof typeof caseInsensitiveOverrides>).sort()
155+
remainingKeys.forEach((key) => {
156+
const correctKey = caseInsensitiveOverrides[key]
157+
lines.push(this.withIndentation(`${key} ${overrides[correctKey]}`))
158+
})
159+
111160
lines.push(this.endBlockComment)
112161
const raw = this.getRaw()
113162

0 commit comments

Comments
 (0)