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:
Aaron Lehmann
2025-01-13 17:07:42 -08:00
committed by GitHub
parent 1aa9e32a2b
commit 8f02e633bf
2 changed files with 193 additions and 432 deletions
+76 -159
View File
@@ -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
View File
@@ -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
}