mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add --no-wildcard flag to coder config-ssh (#26753)
Add `--no-wildcard` (`CODER_CONFIGSSH_NO_WILDCARD`) to `coder config-ssh` that generates an individual `Host` entry per workspace instead of a single wildcard block (`Host *.coder`). The wildcard approach cannot be enumerated by third-party SSH clients, the VS Code Remote-SSH sidebar, or scripts that parse `~/.ssh/config` to discover hosts. With `--no-wildcard`, each workspace gets its own entry so those tools work without Coder-specific extensions. The flag is persisted in the config section header so re-running without it prompts the user about the option change. Workspaces are fetched with pagination before writing so the diff shows actual hostnames. ## Manual testing **Unit tests (no server needed):** ```sh go test ./cli/ -run TestSSHConfigOptions_writeToBuffer -v go test ./cli/ -run TestConfigSSH_NoWildcard -v ``` **End-to-end with a dev server:** 1. Build: `go build -o ./coder .` 2. Start dev server in a separate terminal: `./scripts/develop.sh` 3. Log in: `./coder login http://localhost:3000` 4. Create two workspaces 5. Run both variants into temp files: ```sh ./coder config-ssh --no-wildcard --hostname-suffix coder --ssh-config-file /tmp/test-ssh-config --yes ./coder config-ssh --hostname-suffix coder --ssh-config-file /tmp/test-ssh-config-wildcard --yes diff /tmp/test-ssh-config-wildcard /tmp/test-ssh-config ``` <details> <summary>Output: <code>--no-wildcard</code></summary> ``` # ------------START-CODER----------- # This section is managed by coder. DO NOT EDIT. # # You should not hand-edit this section unless you are removing it, all # changes will be lost when running "coder config-ssh". # # Last config-ssh options: # :hostname-suffix=coder # :no-wildcard=true # Host coder.myworkspace ConnectTimeout=0 StrictHostKeyChecking=no UserKnownHostsFile=/dev/null LogLevel ERROR ProxyCommand <coder> --global-config <config> ssh --stdio --ssh-host-prefix coder. %h Host coder.myworkspace2 ConnectTimeout=0 StrictHostKeyChecking=no UserKnownHostsFile=/dev/null LogLevel ERROR ProxyCommand <coder> --global-config <config> ssh --stdio --ssh-host-prefix coder. %h Host myworkspace.coder ConnectTimeout=0 StrictHostKeyChecking=no UserKnownHostsFile=/dev/null LogLevel ERROR Match host myworkspace.coder !exec "<coder> connect exists %h" ProxyCommand <coder> --global-config <config> ssh --stdio --hostname-suffix coder %h Host myworkspace2.coder ConnectTimeout=0 StrictHostKeyChecking=no UserKnownHostsFile=/dev/null LogLevel ERROR Match host myworkspace2.coder !exec "<coder> connect exists %h" ProxyCommand <coder> --global-config <config> ssh --stdio --hostname-suffix coder %h # ------------END-CODER------------ ``` </details> <details> <summary>Output: wildcard (default)</summary> ``` # ------------START-CODER----------- # This section is managed by coder. DO NOT EDIT. # # You should not hand-edit this section unless you are removing it, all # changes will be lost when running "coder config-ssh". # # Last config-ssh options: # :hostname-suffix=coder # Host coder.* ConnectTimeout=0 StrictHostKeyChecking=no UserKnownHostsFile=/dev/null LogLevel ERROR ProxyCommand <coder> --global-config <config> ssh --stdio --ssh-host-prefix coder. %h Host *.coder ConnectTimeout=0 StrictHostKeyChecking=no UserKnownHostsFile=/dev/null LogLevel ERROR Match host *.coder !exec "<coder> connect exists %h" ProxyCommand <coder> --global-config <config> ssh --stdio --hostname-suffix coder %h # ------------END-CODER------------ ``` </details> <details> <summary>diff wildcard → --no-wildcard</summary> ```diff 8a9 > # :no-wildcard=true 10c11 < Host coder.* --- > Host coder.myworkspace 17c18 < Host *.coder --- > Host coder.myworkspace2 21a23 > ProxyCommand <coder> ssh --stdio --ssh-host-prefix coder. %h 23c25,31 < Match host *.coder !exec "<coder> connect exists %h" --- > Host myworkspace.coder > ConnectTimeout=0 > StrictHostKeyChecking=no > UserKnownHostsFile=/dev/null > LogLevel ERROR > > Match host myworkspace.coder !exec "<coder> connect exists %h" ``` </details> Closes https://github.com/coder/coder/issues/17153 (Phase 1: CLI flag)
This commit is contained in:
+131
-34
@@ -51,6 +51,7 @@ type sshConfigOptions struct {
|
||||
hostnameSuffix string
|
||||
sshOptions []string
|
||||
disableAutostart bool
|
||||
noWildcard bool
|
||||
header []string
|
||||
headerCommand string
|
||||
removedKeys map[string]bool
|
||||
@@ -58,6 +59,10 @@ type sshConfigOptions struct {
|
||||
coderBinaryPath string
|
||||
skipProxyCommand bool
|
||||
forceUnixSeparators bool
|
||||
// workspaceNames is populated when noWildcard is true. It holds the
|
||||
// workspace names used to generate individual host entries. It is not
|
||||
// persisted to the SSH config header.
|
||||
workspaceNames []string
|
||||
}
|
||||
|
||||
// addOptions expects options in the form of "option=value" or "option value".
|
||||
@@ -107,7 +112,8 @@ func (o sshConfigOptions) equal(other sshConfigOptions) bool {
|
||||
o.userHostPrefix == other.userHostPrefix &&
|
||||
o.disableAutostart == other.disableAutostart &&
|
||||
o.headerCommand == other.headerCommand &&
|
||||
o.hostnameSuffix == other.hostnameSuffix
|
||||
o.hostnameSuffix == other.hostnameSuffix &&
|
||||
o.noWildcard == other.noWildcard
|
||||
}
|
||||
|
||||
func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error {
|
||||
@@ -142,26 +148,51 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error {
|
||||
flags += " --disable-autostart=true"
|
||||
}
|
||||
|
||||
// TODO: this function has grown complex enough that it would benefit from
|
||||
// being rewritten using text/template rather than manual buf.WriteString
|
||||
// and fmt.Fprintf calls.
|
||||
|
||||
// Prefix block:
|
||||
if o.userHostPrefix != "" {
|
||||
_, _ = buf.WriteString("Host")
|
||||
if o.noWildcard {
|
||||
for i, wsName := range o.workspaceNames {
|
||||
if i > 0 {
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
_, _ = fmt.Fprintf(buf, "Host %s%s\n", o.userHostPrefix, wsName)
|
||||
for _, v := range o.sshOptions {
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = buf.WriteString(v)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
if !o.skipProxyCommand {
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = fmt.Fprintf(buf,
|
||||
"ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h",
|
||||
escapedCoderBinaryProxy, rootFlags, flags, o.userHostPrefix,
|
||||
)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_, _ = buf.WriteString("Host")
|
||||
_, _ = buf.WriteString(" ")
|
||||
_, _ = buf.WriteString(o.userHostPrefix)
|
||||
_, _ = buf.WriteString("*\n")
|
||||
|
||||
_, _ = buf.WriteString(" ")
|
||||
_, _ = buf.WriteString(o.userHostPrefix)
|
||||
_, _ = buf.WriteString("*\n")
|
||||
|
||||
for _, v := range o.sshOptions {
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = buf.WriteString(v)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
if !o.skipProxyCommand && o.userHostPrefix != "" {
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = fmt.Fprintf(buf,
|
||||
"ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h",
|
||||
escapedCoderBinaryProxy, rootFlags, flags, o.userHostPrefix,
|
||||
)
|
||||
_, _ = buf.WriteString("\n")
|
||||
for _, v := range o.sshOptions {
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = buf.WriteString(v)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
if !o.skipProxyCommand {
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = fmt.Fprintf(buf,
|
||||
"ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h",
|
||||
escapedCoderBinaryProxy, rootFlags, flags, o.userHostPrefix,
|
||||
)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -169,22 +200,46 @@ func (o sshConfigOptions) writeToBuffer(buf *bytes.Buffer) error {
|
||||
if o.hostnameSuffix == "" {
|
||||
return nil
|
||||
}
|
||||
_, _ = fmt.Fprintf(buf, "\nHost *.%s\n", o.hostnameSuffix)
|
||||
for _, v := range o.sshOptions {
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = buf.WriteString(v)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
// the ^^ options should always apply, but we only want to use the proxy command if Coder Connect is not running.
|
||||
if !o.skipProxyCommand {
|
||||
_, _ = fmt.Fprintf(buf, "\nMatch host *.%s !exec \"%s connect exists %%h\"\n",
|
||||
o.hostnameSuffix, escapedCoderBinaryMatchExec)
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = fmt.Fprintf(buf,
|
||||
"ProxyCommand %s %s ssh --stdio%s --hostname-suffix %s %%h",
|
||||
escapedCoderBinaryProxy, rootFlags, flags, o.hostnameSuffix,
|
||||
)
|
||||
_, _ = buf.WriteString("\n")
|
||||
|
||||
if o.noWildcard {
|
||||
for _, wsName := range o.workspaceNames {
|
||||
hostname := wsName + "." + o.hostnameSuffix
|
||||
_, _ = fmt.Fprintf(buf, "\nHost %s\n", hostname)
|
||||
for _, v := range o.sshOptions {
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = buf.WriteString(v)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
// Options always apply; only use the proxy command when Coder Connect is not running.
|
||||
if !o.skipProxyCommand {
|
||||
_, _ = fmt.Fprintf(buf, "\nMatch host %s !exec \"%s connect exists %%h\"\n",
|
||||
hostname, escapedCoderBinaryMatchExec)
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = fmt.Fprintf(buf,
|
||||
"ProxyCommand %s %s ssh --stdio%s --hostname-suffix %s %%h",
|
||||
escapedCoderBinaryProxy, rootFlags, flags, o.hostnameSuffix,
|
||||
)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(buf, "\nHost *.%s\n", o.hostnameSuffix)
|
||||
for _, v := range o.sshOptions {
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = buf.WriteString(v)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
// Options above always apply; only use the proxy command when Coder Connect is not running.
|
||||
if !o.skipProxyCommand {
|
||||
_, _ = fmt.Fprintf(buf, "\nMatch host *.%s !exec \"%s connect exists %%h\"\n",
|
||||
o.hostnameSuffix, escapedCoderBinaryMatchExec)
|
||||
_, _ = buf.WriteString("\t")
|
||||
_, _ = fmt.Fprintf(buf,
|
||||
"ProxyCommand %s %s ssh --stdio%s --hostname-suffix %s %%h",
|
||||
escapedCoderBinaryProxy, rootFlags, flags, o.hostnameSuffix,
|
||||
)
|
||||
_, _ = buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -214,6 +269,9 @@ func (o sshConfigOptions) asList() (list []string) {
|
||||
if o.disableAutostart {
|
||||
list = append(list, fmt.Sprintf("disable-autostart: %v", o.disableAutostart))
|
||||
}
|
||||
if o.noWildcard {
|
||||
list = append(list, "no-wildcard: true")
|
||||
}
|
||||
for _, opt := range o.sshOptions {
|
||||
list = append(list, fmt.Sprintf("ssh-option: %s", opt))
|
||||
}
|
||||
@@ -395,6 +453,32 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if configOptions.noWildcard {
|
||||
// Fetch all workspaces to generate individual host entries.
|
||||
var wsNames []string
|
||||
offset := 0
|
||||
const pageSize = 100
|
||||
for {
|
||||
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
Offset: offset,
|
||||
Limit: pageSize,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch workspaces: %w", err)
|
||||
}
|
||||
for _, ws := range res.Workspaces {
|
||||
wsNames = append(wsNames, ws.Name)
|
||||
}
|
||||
if len(res.Workspaces) < pageSize {
|
||||
break
|
||||
}
|
||||
offset += pageSize
|
||||
}
|
||||
configOptions.workspaceNames = wsNames
|
||||
}
|
||||
|
||||
err = configOptions.writeToBuffer(buf)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -559,6 +643,14 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
Value: serpent.BoolOf(&sshConfigOpts.disableAutostart),
|
||||
Default: "false",
|
||||
},
|
||||
{
|
||||
Flag: "no-wildcard",
|
||||
Env: "CODER_CONFIGSSH_NO_WILDCARD",
|
||||
Description: "Generate an individual host entry for each workspace instead of a wildcard host block. " +
|
||||
"This allows third-party tools and SSH clients to discover workspaces by reading the config file.",
|
||||
Value: serpent.BoolOf(&sshConfigOpts.noWildcard),
|
||||
Default: "false",
|
||||
},
|
||||
{
|
||||
Flag: "force-unix-filepaths",
|
||||
Env: "CODER_CONFIGSSH_UNIX_FILEPATHS",
|
||||
@@ -657,6 +749,9 @@ func sshConfigWriteSectionHeader(w io.Writer, addNewline bool, o sshConfigOption
|
||||
if o.disableAutostart {
|
||||
_, _ = fmt.Fprintf(&ow, "# :%s=%v\n", "disable-autostart", o.disableAutostart)
|
||||
}
|
||||
if o.noWildcard {
|
||||
_, _ = fmt.Fprintf(&ow, "# :%s=%v\n", "no-wildcard", o.noWildcard)
|
||||
}
|
||||
for _, opt := range o.sshOptions {
|
||||
_, _ = fmt.Fprintf(&ow, "# :%s=%s\n", "ssh-option", opt)
|
||||
}
|
||||
@@ -699,6 +794,8 @@ func sshConfigParseLastOptions(r io.Reader) (o sshConfigOptions) {
|
||||
o.sshOptions = append(o.sshOptions, parts[1])
|
||||
case "disable-autostart":
|
||||
o.disableAutostart, _ = strconv.ParseBool(parts[1])
|
||||
case "no-wildcard":
|
||||
o.noWildcard, _ = strconv.ParseBool(parts[1])
|
||||
case "header":
|
||||
o.header = append(o.header, parts[1])
|
||||
case "header-command":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -513,3 +515,181 @@ func Test_sshConfigOptions_addOption(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHConfigOptions_writeToBuffer(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
opts sshConfigOptions
|
||||
want []string // substrings that must appear
|
||||
notWant []string // substrings that must not appear
|
||||
}{
|
||||
{
|
||||
name: "wildcard suffix",
|
||||
opts: sshConfigOptions{
|
||||
coderBinaryPath: "/usr/bin/coder",
|
||||
globalConfigPath: "/tmp/coder",
|
||||
hostnameSuffix: "coder",
|
||||
waitEnum: "auto",
|
||||
},
|
||||
want: []string{"Host *.coder\n", "ProxyCommand", "--hostname-suffix coder %h"},
|
||||
notWant: []string{"Host workspace"},
|
||||
},
|
||||
{
|
||||
name: "wildcard prefix",
|
||||
opts: sshConfigOptions{
|
||||
coderBinaryPath: "/usr/bin/coder",
|
||||
globalConfigPath: "/tmp/coder",
|
||||
userHostPrefix: "coder.",
|
||||
waitEnum: "auto",
|
||||
},
|
||||
want: []string{"Host coder.*\n", "ProxyCommand", "--ssh-host-prefix coder. %h"},
|
||||
notWant: []string{"Host coder.workspace"},
|
||||
},
|
||||
{
|
||||
name: "no-wildcard suffix with workspaces",
|
||||
opts: sshConfigOptions{
|
||||
coderBinaryPath: "/usr/bin/coder",
|
||||
globalConfigPath: "/tmp/coder",
|
||||
hostnameSuffix: "coder",
|
||||
noWildcard: true,
|
||||
workspaceNames: []string{"workspace1", "workspace2"},
|
||||
waitEnum: "auto",
|
||||
},
|
||||
want: []string{
|
||||
"Host workspace1.coder\n",
|
||||
"Host workspace2.coder\n",
|
||||
"Match host workspace1.coder !exec",
|
||||
"Match host workspace2.coder !exec",
|
||||
"--hostname-suffix coder %h",
|
||||
},
|
||||
notWant: []string{"Host *.coder", "Match host *.coder"},
|
||||
},
|
||||
{
|
||||
name: "no-wildcard suffix with zero workspaces produces no host entries",
|
||||
opts: sshConfigOptions{
|
||||
coderBinaryPath: "/usr/bin/coder",
|
||||
globalConfigPath: "/tmp/coder",
|
||||
hostnameSuffix: "coder",
|
||||
noWildcard: true,
|
||||
workspaceNames: nil,
|
||||
waitEnum: "auto",
|
||||
},
|
||||
notWant: []string{"Host", "ProxyCommand", "Match"},
|
||||
},
|
||||
{
|
||||
name: "no-wildcard prefix with workspaces",
|
||||
opts: sshConfigOptions{
|
||||
coderBinaryPath: "/usr/bin/coder",
|
||||
globalConfigPath: "/tmp/coder",
|
||||
userHostPrefix: "coder.",
|
||||
noWildcard: true,
|
||||
workspaceNames: []string{"workspace1", "workspace2"},
|
||||
waitEnum: "auto",
|
||||
},
|
||||
want: []string{
|
||||
"Host coder.workspace1\n",
|
||||
"Host coder.workspace2\n",
|
||||
"--ssh-host-prefix coder. %h",
|
||||
},
|
||||
notWant: []string{"Host coder.*"},
|
||||
},
|
||||
{
|
||||
name: "no-wildcard suffix skips proxy command when skipProxyCommand is set",
|
||||
opts: sshConfigOptions{
|
||||
coderBinaryPath: "/usr/bin/coder",
|
||||
globalConfigPath: "/tmp/coder",
|
||||
hostnameSuffix: "coder",
|
||||
noWildcard: true,
|
||||
workspaceNames: []string{"workspace1"},
|
||||
skipProxyCommand: true,
|
||||
waitEnum: "auto",
|
||||
},
|
||||
want: []string{"Host workspace1.coder\n"},
|
||||
notWant: []string{"ProxyCommand", "Match host", "Host *.coder"},
|
||||
},
|
||||
{
|
||||
name: "no-wildcard prefix skips proxy command when skipProxyCommand is set",
|
||||
opts: sshConfigOptions{
|
||||
coderBinaryPath: "/usr/bin/coder",
|
||||
globalConfigPath: "/tmp/coder",
|
||||
userHostPrefix: "coder.",
|
||||
noWildcard: true,
|
||||
workspaceNames: []string{"workspace1"},
|
||||
skipProxyCommand: true,
|
||||
waitEnum: "auto",
|
||||
},
|
||||
want: []string{"Host coder.workspace1\n"},
|
||||
notWant: []string{"ProxyCommand", "Host coder.*"},
|
||||
},
|
||||
{
|
||||
name: "no-wildcard suffix SSH options appear in every workspace entry",
|
||||
opts: sshConfigOptions{
|
||||
coderBinaryPath: "/usr/bin/coder",
|
||||
globalConfigPath: "/tmp/coder",
|
||||
hostnameSuffix: "coder",
|
||||
noWildcard: true,
|
||||
workspaceNames: []string{"workspace1", "workspace2"},
|
||||
sshOptions: []string{"ForwardAgent=yes", "LogLevel=DEBUG"},
|
||||
waitEnum: "auto",
|
||||
},
|
||||
want: []string{
|
||||
"Host workspace1.coder\n",
|
||||
"\tForwardAgent=yes\n",
|
||||
"\tLogLevel=DEBUG\n",
|
||||
"Host workspace2.coder\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "wildcard suffix SSH options appear in host block",
|
||||
opts: sshConfigOptions{
|
||||
coderBinaryPath: "/usr/bin/coder",
|
||||
globalConfigPath: "/tmp/coder",
|
||||
hostnameSuffix: "coder",
|
||||
sshOptions: []string{"ForwardAgent=yes"},
|
||||
waitEnum: "auto",
|
||||
},
|
||||
want: []string{
|
||||
"Host *.coder\n",
|
||||
"\tForwardAgent=yes\n",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "no-wildcard with both prefix and suffix generates entries for both",
|
||||
opts: sshConfigOptions{
|
||||
coderBinaryPath: "/usr/bin/coder",
|
||||
globalConfigPath: "/tmp/coder",
|
||||
userHostPrefix: "coder.",
|
||||
hostnameSuffix: "testy",
|
||||
noWildcard: true,
|
||||
workspaceNames: []string{"workspace1"},
|
||||
waitEnum: "auto",
|
||||
},
|
||||
want: []string{
|
||||
"Host coder.workspace1\n",
|
||||
"Host workspace1.testy\n",
|
||||
"Match host workspace1.testy !exec",
|
||||
},
|
||||
notWant: []string{"Host coder.*", "Host *.testy"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := tt.opts.writeToBuffer(&buf)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := buf.String()
|
||||
for _, w := range tt.want {
|
||||
assert.Contains(t, got, w, "expected substring not found")
|
||||
}
|
||||
for _, nw := range tt.notWant {
|
||||
assert.NotContains(t, got, nw, "unexpected substring found")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -24,6 +25,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
sdkproto "github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/coder/v2/testutil/expecter"
|
||||
)
|
||||
@@ -554,6 +556,45 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
"--header-command", "echo h1=v1 h2=\"v2\" h3='v3'",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Serialize no-wildcard flag",
|
||||
wantConfig: wantConfig{
|
||||
ssh: []string{
|
||||
strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :hostname-suffix=coder-suffix",
|
||||
"# :no-wildcard=true",
|
||||
"#",
|
||||
}, "\n"),
|
||||
strings.Join([]string{
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
},
|
||||
args: []string{
|
||||
"--yes",
|
||||
"--hostname-suffix", "coder-suffix",
|
||||
"--no-wildcard",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "No wildcard generates per-workspace entries",
|
||||
args: []string{
|
||||
"--yes",
|
||||
"--hostname-suffix", "coder",
|
||||
"--no-wildcard",
|
||||
},
|
||||
hasAgent: true,
|
||||
wantConfig: wantConfig{
|
||||
ssh: []string{
|
||||
"# :hostname-suffix=coder",
|
||||
"# :no-wildcard=true",
|
||||
},
|
||||
regexMatch: `Host [a-z0-9_-]+\.coder`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Do not prompt for new options when prev opts flag is set",
|
||||
writeConfig: writeConfig{
|
||||
@@ -811,3 +852,91 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSSH_NoWildcard(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("See coder/internal#117")
|
||||
}
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
|
||||
// Create two workspaces with names in reverse lexical order so that we can
|
||||
// verify the SSH config entries are sorted by name, not by creation order.
|
||||
// ws1 sorts after ws2 alphabetically.
|
||||
ws1 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
Name: "ws-beta",
|
||||
}).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent {
|
||||
a[0].Name = "agent-beta"
|
||||
return a
|
||||
}).Do()
|
||||
ws2 := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: user.OrganizationID,
|
||||
OwnerID: user.UserID,
|
||||
Name: "ws-alpha",
|
||||
}).WithAgent(func(a []*sdkproto.Agent) []*sdkproto.Agent {
|
||||
a[0].Name = "agent-alpha"
|
||||
return a
|
||||
}).Do()
|
||||
|
||||
sshConfigPath := sshConfigFileName(t)
|
||||
|
||||
runConfigSSH := func() {
|
||||
inv, root := clitest.New(t,
|
||||
"config-ssh",
|
||||
"--ssh-config-file", sshConfigPath,
|
||||
"--hostname-suffix", "coder",
|
||||
"--no-wildcard",
|
||||
"--yes",
|
||||
)
|
||||
//nolint:gocritic // This has always ran with the admin user.
|
||||
clitest.SetupConfig(t, client, root)
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// hostLines extracts lines beginning with "Host " from the SSH config.
|
||||
// ProxyCommand lines embed a per-invocation temp path and are excluded so
|
||||
// that two runs with different global-config dirs can still be compared.
|
||||
hostLines := func(s string) []string {
|
||||
var out []string
|
||||
for line := range strings.SplitSeq(s, "\n") {
|
||||
if strings.HasPrefix(line, "Host ") {
|
||||
out = append(out, line)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
runConfigSSH()
|
||||
config := sshConfigFileRead(t, sshConfigPath)
|
||||
|
||||
// The server always injects a "coder." hostname prefix in addition to the
|
||||
// user-supplied "--hostname-suffix coder" entries. With stable workspace
|
||||
// names we can assert the complete, ordered host-entry list exactly.
|
||||
// ws-alpha sorts before ws-beta even though ws-alpha was created second.
|
||||
wantHosts := []string{
|
||||
"Host coder." + ws2.Workspace.Name, // coder.ws-alpha
|
||||
"Host coder." + ws1.Workspace.Name, // coder.ws-beta
|
||||
"Host " + ws2.Workspace.Name + ".coder", // ws-alpha.coder
|
||||
"Host " + ws1.Workspace.Name + ".coder", // ws-beta.coder
|
||||
}
|
||||
require.Empty(t, cmp.Diff(wantHosts, hostLines(config)))
|
||||
|
||||
// No wildcard entries must appear in the Coder section.
|
||||
require.NotContains(t, config, "Host *.coder")
|
||||
require.NotContains(t, config, "Host *.")
|
||||
|
||||
// The no-wildcard option must be persisted in the header.
|
||||
require.Contains(t, config, "# :no-wildcard=true")
|
||||
|
||||
// Running the command again must yield identical host entries, confirming
|
||||
// that the ordering is stable across runs.
|
||||
runConfigSSH()
|
||||
require.Empty(t, cmp.Diff(wantHosts, hostLines(sshConfigFileRead(t, sshConfigPath))))
|
||||
}
|
||||
|
||||
+5
@@ -36,6 +36,11 @@ OPTIONS:
|
||||
--hostname-suffix string, $CODER_CONFIGSSH_HOSTNAME_SUFFIX
|
||||
Override the default hostname suffix.
|
||||
|
||||
--no-wildcard bool, $CODER_CONFIGSSH_NO_WILDCARD (default: false)
|
||||
Generate an individual host entry for each workspace instead of a
|
||||
wildcard host block. This allows third-party tools and SSH clients to
|
||||
discover workspaces by reading the config file.
|
||||
|
||||
--ssh-config-file string, $CODER_SSH_CONFIG_FILE (default: ~/.ssh/config)
|
||||
Specifies the path to an SSH config.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user