mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
feat: use wildcard Host entry in config-ssh (#16096)
Rather than create a separate `Host` entry for every workspace, configure a wildcard such as `coder.*` which can accomodate all of a user's workspaces. Depends on #16088.
This commit is contained in:
+76
-159
@@ -3,7 +3,6 @@ package cli
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -12,7 +11,6 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -22,11 +20,9 @@ import (
|
||||
"github.com/pkg/diff/write"
|
||||
"golang.org/x/exp/constraints"
|
||||
"golang.org/x/exp/slices"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
@@ -139,74 +135,6 @@ func (o sshConfigOptions) asList() (list []string) {
|
||||
return list
|
||||
}
|
||||
|
||||
type sshWorkspaceConfig struct {
|
||||
Name string
|
||||
Hosts []string
|
||||
}
|
||||
|
||||
func sshFetchWorkspaceConfigs(ctx context.Context, client *codersdk.Client) ([]sshWorkspaceConfig, error) {
|
||||
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var errGroup errgroup.Group
|
||||
workspaceConfigs := make([]sshWorkspaceConfig, len(res.Workspaces))
|
||||
for i, workspace := range res.Workspaces {
|
||||
i := i
|
||||
workspace := workspace
|
||||
errGroup.Go(func() error {
|
||||
resources, err := client.TemplateVersionResources(ctx, workspace.LatestBuild.TemplateVersionID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
wc := sshWorkspaceConfig{Name: workspace.Name}
|
||||
var agents []codersdk.WorkspaceAgent
|
||||
for _, resource := range resources {
|
||||
if resource.Transition != codersdk.WorkspaceTransitionStart {
|
||||
continue
|
||||
}
|
||||
agents = append(agents, resource.Agents...)
|
||||
}
|
||||
|
||||
// handle both WORKSPACE and WORKSPACE.AGENT syntax
|
||||
if len(agents) == 1 {
|
||||
wc.Hosts = append(wc.Hosts, workspace.Name)
|
||||
}
|
||||
for _, agent := range agents {
|
||||
hostname := workspace.Name + "." + agent.Name
|
||||
wc.Hosts = append(wc.Hosts, hostname)
|
||||
}
|
||||
|
||||
workspaceConfigs[i] = wc
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
err = errGroup.Wait()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return workspaceConfigs, nil
|
||||
}
|
||||
|
||||
func sshPrepareWorkspaceConfigs(ctx context.Context, client *codersdk.Client) (receive func() ([]sshWorkspaceConfig, error)) {
|
||||
wcC := make(chan []sshWorkspaceConfig, 1)
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
wc, err := sshFetchWorkspaceConfigs(ctx, client)
|
||||
wcC <- wc
|
||||
errC <- err
|
||||
}()
|
||||
return func() ([]sshWorkspaceConfig, error) {
|
||||
return <-wcC, <-errC
|
||||
}
|
||||
}
|
||||
|
||||
func (r *RootCmd) configSSH() *serpent.Command {
|
||||
var (
|
||||
sshConfigFile string
|
||||
@@ -254,8 +182,6 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
// warning at any time.
|
||||
_, _ = client.BuildInfo(ctx)
|
||||
|
||||
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(ctx, client)
|
||||
|
||||
out := inv.Stdout
|
||||
if dryRun {
|
||||
// Print everything except diff to stderr so
|
||||
@@ -371,11 +297,6 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
newline := len(before) > 0
|
||||
sshConfigWriteSectionHeader(buf, newline, sshConfigOpts)
|
||||
|
||||
workspaceConfigs, err := recvWorkspaceConfigs()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch workspace configs failed: %w", err)
|
||||
}
|
||||
|
||||
coderdConfig, err := client.SSHConfiguration(ctx)
|
||||
if err != nil {
|
||||
// If the error is 404, this deployment does not support
|
||||
@@ -394,91 +315,79 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
coderdConfig.HostnamePrefix = sshConfigOpts.userHostPrefix
|
||||
}
|
||||
|
||||
// Ensure stable sorting of output.
|
||||
slices.SortFunc(workspaceConfigs, func(a, b sshWorkspaceConfig) int {
|
||||
return slice.Ascending(a.Name, b.Name)
|
||||
})
|
||||
for _, wc := range workspaceConfigs {
|
||||
sort.Strings(wc.Hosts)
|
||||
// Write agent configuration.
|
||||
for _, workspaceHostname := range wc.Hosts {
|
||||
sshHostname := fmt.Sprintf("%s%s", coderdConfig.HostnamePrefix, workspaceHostname)
|
||||
defaultOptions := []string{
|
||||
"HostName " + sshHostname,
|
||||
"ConnectTimeout=0",
|
||||
"StrictHostKeyChecking=no",
|
||||
// Without this, the "REMOTE HOST IDENTITY CHANGED"
|
||||
// message will appear.
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
// This disables the "Warning: Permanently added 'hostname' (RSA) to the list of known hosts."
|
||||
// message from appearing on every SSH. This happens because we ignore the known hosts.
|
||||
"LogLevel ERROR",
|
||||
}
|
||||
// Write agent configuration.
|
||||
defaultOptions := []string{
|
||||
"ConnectTimeout=0",
|
||||
"StrictHostKeyChecking=no",
|
||||
// Without this, the "REMOTE HOST IDENTITY CHANGED"
|
||||
// message will appear.
|
||||
"UserKnownHostsFile=/dev/null",
|
||||
// This disables the "Warning: Permanently added 'hostname' (RSA) to the list of known hosts."
|
||||
// message from appearing on every SSH. This happens because we ignore the known hosts.
|
||||
"LogLevel ERROR",
|
||||
}
|
||||
|
||||
if !skipProxyCommand {
|
||||
rootFlags := fmt.Sprintf("--global-config %s", escapedGlobalConfig)
|
||||
for _, h := range sshConfigOpts.header {
|
||||
rootFlags += fmt.Sprintf(" --header %q", h)
|
||||
}
|
||||
if sshConfigOpts.headerCommand != "" {
|
||||
rootFlags += fmt.Sprintf(" --header-command %q", sshConfigOpts.headerCommand)
|
||||
}
|
||||
if !skipProxyCommand {
|
||||
rootFlags := fmt.Sprintf("--global-config %s", escapedGlobalConfig)
|
||||
for _, h := range sshConfigOpts.header {
|
||||
rootFlags += fmt.Sprintf(" --header %q", h)
|
||||
}
|
||||
if sshConfigOpts.headerCommand != "" {
|
||||
rootFlags += fmt.Sprintf(" --header-command %q", sshConfigOpts.headerCommand)
|
||||
}
|
||||
|
||||
flags := ""
|
||||
if sshConfigOpts.waitEnum != "auto" {
|
||||
flags += " --wait=" + sshConfigOpts.waitEnum
|
||||
}
|
||||
if sshConfigOpts.disableAutostart {
|
||||
flags += " --disable-autostart=true"
|
||||
}
|
||||
defaultOptions = append(defaultOptions, fmt.Sprintf(
|
||||
"ProxyCommand %s %s ssh --stdio%s %s",
|
||||
escapedCoderBinary, rootFlags, flags, workspaceHostname,
|
||||
))
|
||||
}
|
||||
flags := ""
|
||||
if sshConfigOpts.waitEnum != "auto" {
|
||||
flags += " --wait=" + sshConfigOpts.waitEnum
|
||||
}
|
||||
if sshConfigOpts.disableAutostart {
|
||||
flags += " --disable-autostart=true"
|
||||
}
|
||||
defaultOptions = append(defaultOptions, fmt.Sprintf(
|
||||
"ProxyCommand %s %s ssh --stdio%s --ssh-host-prefix %s %%h",
|
||||
escapedCoderBinary, rootFlags, flags, coderdConfig.HostnamePrefix,
|
||||
))
|
||||
}
|
||||
|
||||
// Create a copy of the options so we can modify them.
|
||||
configOptions := sshConfigOpts
|
||||
configOptions.sshOptions = nil
|
||||
// Create a copy of the options so we can modify them.
|
||||
configOptions := sshConfigOpts
|
||||
configOptions.sshOptions = nil
|
||||
|
||||
// User options first (SSH only uses the first
|
||||
// option unless it can be given multiple times)
|
||||
for _, opt := range sshConfigOpts.sshOptions {
|
||||
err := configOptions.addOptions(opt)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add flag config option %q: %w", opt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deployment options second, allow them to
|
||||
// override standard options.
|
||||
for k, v := range coderdConfig.SSHConfigOptions {
|
||||
opt := fmt.Sprintf("%s %s", k, v)
|
||||
err := configOptions.addOptions(opt)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add coderd config option %q: %w", opt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, add the standard options.
|
||||
err := configOptions.addOptions(defaultOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hostBlock := []string{
|
||||
"Host " + sshHostname,
|
||||
}
|
||||
// Prefix with '\t'
|
||||
for _, v := range configOptions.sshOptions {
|
||||
hostBlock = append(hostBlock, "\t"+v)
|
||||
}
|
||||
|
||||
_, _ = buf.WriteString(strings.Join(hostBlock, "\n"))
|
||||
_ = buf.WriteByte('\n')
|
||||
// User options first (SSH only uses the first
|
||||
// option unless it can be given multiple times)
|
||||
for _, opt := range sshConfigOpts.sshOptions {
|
||||
err := configOptions.addOptions(opt)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add flag config option %q: %w", opt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Deployment options second, allow them to
|
||||
// override standard options.
|
||||
for k, v := range coderdConfig.SSHConfigOptions {
|
||||
opt := fmt.Sprintf("%s %s", k, v)
|
||||
err := configOptions.addOptions(opt)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add coderd config option %q: %w", opt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Finally, add the standard options.
|
||||
if err := configOptions.addOptions(defaultOptions...); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hostBlock := []string{
|
||||
"Host " + coderdConfig.HostnamePrefix + "*",
|
||||
}
|
||||
// Prefix with '\t'
|
||||
for _, v := range configOptions.sshOptions {
|
||||
hostBlock = append(hostBlock, "\t"+v)
|
||||
}
|
||||
|
||||
_, _ = buf.WriteString(strings.Join(hostBlock, "\n"))
|
||||
_ = buf.WriteByte('\n')
|
||||
|
||||
sshConfigWriteSectionEnd(buf)
|
||||
|
||||
// Write the remainder of the users config file to buf.
|
||||
@@ -532,9 +441,17 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
_, _ = fmt.Fprintf(out, "Updated %q\n", sshConfigFile)
|
||||
}
|
||||
|
||||
if len(workspaceConfigs) > 0 {
|
||||
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch workspaces failed: %w", err)
|
||||
}
|
||||
|
||||
if len(res.Workspaces) > 0 {
|
||||
_, _ = fmt.Fprintln(out, "You should now be able to ssh into your workspace.")
|
||||
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh %s%s\n", coderdConfig.HostnamePrefix, workspaceConfigs[0].Name)
|
||||
_, _ = fmt.Fprintf(out, "For example, try running:\n\n\t$ ssh %s%s\n", coderdConfig.HostnamePrefix, res.Workspaces[0].Name)
|
||||
} else {
|
||||
_, _ = fmt.Fprint(out, "You don't have any workspaces yet, try creating one with:\n\n\t$ coder create <workspace>\n")
|
||||
}
|
||||
|
||||
+117
-273
@@ -1,8 +1,6 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -16,7 +14,6 @@ import (
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
@@ -27,7 +24,6 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/database/dbfake"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/provisionersdk/proto"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
@@ -194,7 +190,7 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
ssh string
|
||||
}
|
||||
type wantConfig struct {
|
||||
ssh string
|
||||
ssh []string
|
||||
regexMatch string
|
||||
}
|
||||
type match struct {
|
||||
@@ -215,10 +211,10 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
ssh: []string{
|
||||
headerStart,
|
||||
headerEnd,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -230,44 +226,19 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
ssh: []string{
|
||||
strings.Join([]string{
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
}, "\n"),
|
||||
headerStart,
|
||||
headerEnd,
|
||||
},
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Section is not moved on re-run",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
"",
|
||||
baseHeader,
|
||||
"",
|
||||
"Host otherhost",
|
||||
" HostName otherhost",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
"",
|
||||
baseHeader,
|
||||
"",
|
||||
"Host otherhost",
|
||||
" HostName otherhost",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Section is not moved on re-run with new options",
|
||||
writeConfig: writeConfig{
|
||||
@@ -283,20 +254,24 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
"",
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
"Host otherhost",
|
||||
" HostName otherhost",
|
||||
"",
|
||||
}, "\n"),
|
||||
ssh: []string{
|
||||
strings.Join([]string{
|
||||
"Host myhost",
|
||||
" HostName myhost",
|
||||
"",
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
}, "\n"),
|
||||
strings.Join([]string{
|
||||
headerEnd,
|
||||
"",
|
||||
"Host otherhost",
|
||||
" HostName otherhost",
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
},
|
||||
args: []string{
|
||||
"--ssh-option", "ForwardAgent=yes",
|
||||
@@ -314,10 +289,13 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
ssh: []string{
|
||||
headerStart,
|
||||
strings.Join([]string{
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Continue?", write: "yes"},
|
||||
@@ -329,14 +307,17 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
ssh: "",
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
ssh: []string{
|
||||
strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
}, "\n"),
|
||||
strings.Join([]string{
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n")},
|
||||
},
|
||||
args: []string{"--ssh-option", "ForwardAgent=yes"},
|
||||
matches: []match{
|
||||
@@ -351,14 +332,17 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
ssh: []string{
|
||||
strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
}, "\n"),
|
||||
strings.Join([]string{
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n")},
|
||||
},
|
||||
args: []string{"--ssh-option", "ForwardAgent=yes"},
|
||||
matches: []match{
|
||||
@@ -378,40 +362,19 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
ssh: []string{
|
||||
headerStart,
|
||||
strings.Join([]string{
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
},
|
||||
matches: []match{
|
||||
{match: "Use new options?", write: "yes"},
|
||||
{match: "Continue?", write: "yes"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "No prompt on no changes",
|
||||
writeConfig: writeConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
args: []string{"--ssh-option", "ForwardAgent=yes"},
|
||||
},
|
||||
{
|
||||
name: "No changes when continue = no",
|
||||
writeConfig: writeConfig{
|
||||
@@ -425,14 +388,14 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
ssh: []string{strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
}, "\n")},
|
||||
},
|
||||
args: []string{"--ssh-option", "ForwardAgent=no"},
|
||||
matches: []match{
|
||||
@@ -453,29 +416,32 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
// Last options overwritten.
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
ssh: []string{
|
||||
headerStart,
|
||||
headerEnd,
|
||||
},
|
||||
},
|
||||
args: []string{"--yes"},
|
||||
},
|
||||
{
|
||||
name: "Serialize supported flags",
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :wait=yes",
|
||||
"# :ssh-host-prefix=coder-test.",
|
||||
"# :header=X-Test-Header=foo",
|
||||
"# :header=X-Test-Header2=bar",
|
||||
"# :header-command=printf h1=v1 h2=\"v2\" h3='v3'",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
ssh: []string{
|
||||
strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :wait=yes",
|
||||
"# :ssh-host-prefix=coder-test.",
|
||||
"# :header=X-Test-Header=foo",
|
||||
"# :header=X-Test-Header2=bar",
|
||||
"# :header-command=printf h1=v1 h2=\"v2\" h3='v3'",
|
||||
"#",
|
||||
}, "\n"),
|
||||
strings.Join([]string{
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
},
|
||||
args: []string{
|
||||
"--yes",
|
||||
@@ -500,15 +466,20 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :wait=no",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
ssh: []string{
|
||||
strings.Join(
|
||||
[]string{
|
||||
headerStart,
|
||||
"# Last config-ssh options:",
|
||||
"# :wait=no",
|
||||
"# :ssh-option=ForwardAgent=yes",
|
||||
"#",
|
||||
}, "\n"),
|
||||
strings.Join([]string{
|
||||
headerEnd,
|
||||
"",
|
||||
}, "\n"),
|
||||
},
|
||||
},
|
||||
args: []string{
|
||||
"--use-previous-options",
|
||||
@@ -524,10 +495,10 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
}, "\n"),
|
||||
},
|
||||
wantConfig: wantConfig{
|
||||
ssh: strings.Join([]string{
|
||||
ssh: []string{strings.Join([]string{
|
||||
baseHeader,
|
||||
"",
|
||||
}, "\n"),
|
||||
}, "\n")},
|
||||
},
|
||||
args: []string{
|
||||
"--ssh-option", "ForwardAgent=yes",
|
||||
@@ -586,7 +557,7 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
wantErr: false,
|
||||
hasAgent: true,
|
||||
wantConfig: wantConfig{
|
||||
regexMatch: `ProxyCommand .* --header "X-Test-Header=foo" --header "X-Test-Header2=bar" ssh`,
|
||||
regexMatch: `ProxyCommand .* --header "X-Test-Header=foo" --header "X-Test-Header2=bar" ssh .* --ssh-host-prefix coder. %h`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -598,7 +569,7 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
wantErr: false,
|
||||
hasAgent: true,
|
||||
wantConfig: wantConfig{
|
||||
regexMatch: `ProxyCommand .* --header-command "printf h1=v1" ssh`,
|
||||
regexMatch: `ProxyCommand .* --header-command "printf h1=v1" ssh .* --ssh-host-prefix coder. %h`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -610,7 +581,7 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
wantErr: false,
|
||||
hasAgent: true,
|
||||
wantConfig: wantConfig{
|
||||
regexMatch: `ProxyCommand .* --header-command "printf h1=v1 h2=\\\"v2\\\"" ssh`,
|
||||
regexMatch: `ProxyCommand .* --header-command "printf h1=v1 h2=\\\"v2\\\"" ssh .* --ssh-host-prefix coder. %h`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -622,7 +593,7 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
wantErr: false,
|
||||
hasAgent: true,
|
||||
wantConfig: wantConfig{
|
||||
regexMatch: `ProxyCommand .* --header-command "printf h1=v1 h2='v2'" ssh`,
|
||||
regexMatch: `ProxyCommand .* --header-command "printf h1=v1 h2='v2'" ssh .* --ssh-host-prefix coder. %h`,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -686,10 +657,15 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
|
||||
<-done
|
||||
|
||||
if tt.wantConfig.ssh != "" || tt.wantConfig.regexMatch != "" {
|
||||
if len(tt.wantConfig.ssh) != 0 || tt.wantConfig.regexMatch != "" {
|
||||
got := sshConfigFileRead(t, sshConfigName)
|
||||
if tt.wantConfig.ssh != "" {
|
||||
assert.Equal(t, tt.wantConfig.ssh, got)
|
||||
// Require that the generated config has the expected snippets in order.
|
||||
for _, want := range tt.wantConfig.ssh {
|
||||
idx := strings.Index(got, want)
|
||||
if idx == -1 {
|
||||
require.Contains(t, got, want)
|
||||
}
|
||||
got = got[idx+len(want):]
|
||||
}
|
||||
if tt.wantConfig.regexMatch != "" {
|
||||
assert.Regexp(t, tt.wantConfig.regexMatch, got, "regex match")
|
||||
@@ -698,135 +674,3 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSSH_Hostnames(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type resourceSpec struct {
|
||||
name string
|
||||
agents []string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
resources []resourceSpec
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "one resource with one agent",
|
||||
resources: []resourceSpec{
|
||||
{name: "foo", agents: []string{"agent1"}},
|
||||
},
|
||||
expected: []string{"coder.@", "coder.@.agent1"},
|
||||
},
|
||||
{
|
||||
name: "one resource with two agents",
|
||||
resources: []resourceSpec{
|
||||
{name: "foo", agents: []string{"agent1", "agent2"}},
|
||||
},
|
||||
expected: []string{"coder.@.agent1", "coder.@.agent2"},
|
||||
},
|
||||
{
|
||||
name: "two resources with one agent",
|
||||
resources: []resourceSpec{
|
||||
{name: "foo", agents: []string{"agent1"}},
|
||||
{name: "bar"},
|
||||
},
|
||||
expected: []string{"coder.@", "coder.@.agent1"},
|
||||
},
|
||||
{
|
||||
name: "two resources with two agents",
|
||||
resources: []resourceSpec{
|
||||
{name: "foo", agents: []string{"agent1"}},
|
||||
{name: "bar", agents: []string{"agent2"}},
|
||||
},
|
||||
expected: []string{"coder.@.agent1", "coder.@.agent2"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var resources []*proto.Resource
|
||||
for _, resourceSpec := range tt.resources {
|
||||
resource := &proto.Resource{
|
||||
Name: resourceSpec.name,
|
||||
Type: "aws_instance",
|
||||
}
|
||||
for _, agentName := range resourceSpec.agents {
|
||||
resource.Agents = append(resource.Agents, &proto.Agent{
|
||||
Id: uuid.NewString(),
|
||||
Name: agentName,
|
||||
})
|
||||
}
|
||||
resources = append(resources, resource)
|
||||
}
|
||||
|
||||
client, db := coderdtest.NewWithDatabase(t, nil)
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
member, memberUser := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
|
||||
r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
|
||||
OrganizationID: owner.OrganizationID,
|
||||
OwnerID: memberUser.ID,
|
||||
}).Resource(resources...).Do()
|
||||
sshConfigFile := sshConfigFileName(t)
|
||||
|
||||
inv, root := clitest.New(t, "config-ssh", "--ssh-config-file", sshConfigFile)
|
||||
clitest.SetupConfig(t, member, root)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
inv.Stdin = pty.Input()
|
||||
inv.Stdout = pty.Output()
|
||||
clitest.Start(t, inv)
|
||||
|
||||
matches := []struct {
|
||||
match, write string
|
||||
}{
|
||||
{match: "Continue?", write: "yes"},
|
||||
}
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
|
||||
pty.ExpectMatch("Updated")
|
||||
|
||||
var expectedHosts []string
|
||||
for _, hostnamePattern := range tt.expected {
|
||||
hostname := strings.ReplaceAll(hostnamePattern, "@", r.Workspace.Name)
|
||||
expectedHosts = append(expectedHosts, hostname)
|
||||
}
|
||||
|
||||
hosts := sshConfigFileParseHosts(t, sshConfigFile)
|
||||
require.ElementsMatch(t, expectedHosts, hosts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// sshConfigFileParseHosts reads a file in the format of .ssh/config and extracts
|
||||
// the hostnames that are listed in "Host" directives.
|
||||
func sshConfigFileParseHosts(t *testing.T, name string) []string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(name)
|
||||
require.NoError(t, err)
|
||||
|
||||
var result []string
|
||||
lineScanner := bufio.NewScanner(bytes.NewBuffer(b))
|
||||
for lineScanner.Scan() {
|
||||
line := lineScanner.Text()
|
||||
line = strings.TrimSpace(line)
|
||||
|
||||
tokenScanner := bufio.NewScanner(bytes.NewBufferString(line))
|
||||
tokenScanner.Split(bufio.ScanWords)
|
||||
ok := tokenScanner.Scan()
|
||||
if ok && tokenScanner.Text() == "Host" {
|
||||
for tokenScanner.Scan() {
|
||||
result = append(result, tokenScanner.Text())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user