Skip to content

Commit 3b54254

Browse files
authored
feat: add coder connect exists hidden subcommand (#17418)
Adds a new hidden subcommand `coder connect exists <hostname>` that checks if the name exists via Coder Connect. This will be used in SSH config to match only if Coder Connect is unavailable for the hostname in question, so that the SSH client will directly dial the workspace over an existing Coder Connect tunnel. Also refactors the way we inject a test DNS resolver into the lookup functions so that we can test from outside the `workspacesdk` package.
1 parent 6f5da1e commit 3b54254

File tree

8 files changed

+242
-98
lines changed

8 files changed

+242
-98
lines changed

cli/configssh.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,9 +22,10 @@ import (
2222
"golang.org/x/exp/constraints"
2323
"golang.org/x/xerrors"
2424

25+
"github.com/coder/serpent"
26+
2527
"github.com/coder/coder/v2/cli/cliui"
2628
"github.com/coder/coder/v2/codersdk"
27-
"github.com/coder/serpent"
2829
)
2930

3031
const (

cli/connect.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package cli
2+
3+
import (
4+
"github.com/coder/serpent"
5+
6+
"github.com/coder/coder/v2/codersdk/workspacesdk"
7+
)
8+
9+
func (r *RootCmd) connectCmd() *serpent.Command {
10+
cmd := &serpent.Command{
11+
Use: "connect",
12+
Short: "Commands related to Coder Connect (OS-level tunneled connection to workspaces).",
13+
Handler: func(i *serpent.Invocation) error {
14+
return i.Command.HelpHandler(i)
15+
},
16+
Hidden: true,
17+
Children: []*serpent.Command{
18+
r.existsCmd(),
19+
},
20+
}
21+
return cmd
22+
}
23+
24+
func (*RootCmd) existsCmd() *serpent.Command {
25+
cmd := &serpent.Command{
26+
Use: "exists <hostname>",
27+
Short: "Checks if the given hostname exists via Coder Connect.",
28+
Long: "This command is designed to be used in scripts to check if the given hostname exists via Coder " +
29+
"Connect. It prints no output. It returns exit code 0 if it does exist and code 1 if it does not.",
30+
Middleware: serpent.Chain(
31+
serpent.RequireNArgs(1),
32+
),
33+
Handler: func(inv *serpent.Invocation) error {
34+
hostname := inv.Args[0]
35+
exists, err := workspacesdk.ExistsViaCoderConnect(inv.Context(), hostname)
36+
if err != nil {
37+
return err
38+
}
39+
if !exists {
40+
// we don't want to print any output, since this command is designed to be a check in scripts / SSH config.
41+
return ErrSilent
42+
}
43+
return nil
44+
},
45+
}
46+
return cmd
47+
}

cli/connect_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package cli_test
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"net"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
"tailscale.com/net/tsaddr"
11+
12+
"github.com/coder/serpent"
13+
14+
"github.com/coder/coder/v2/cli"
15+
"github.com/coder/coder/v2/codersdk/workspacesdk"
16+
"github.com/coder/coder/v2/testutil"
17+
)
18+
19+
func TestConnectExists_Running(t *testing.T) {
20+
t.Parallel()
21+
ctx := testutil.Context(t, testutil.WaitShort)
22+
23+
var root cli.RootCmd
24+
cmd, err := root.Command(root.AGPL())
25+
require.NoError(t, err)
26+
27+
inv := (&serpent.Invocation{
28+
Command: cmd,
29+
Args: []string{"connect", "exists", "test.example"},
30+
}).WithContext(withCoderConnectRunning(ctx))
31+
stdout := new(bytes.Buffer)
32+
stderr := new(bytes.Buffer)
33+
inv.Stdout = stdout
34+
inv.Stderr = stderr
35+
err = inv.Run()
36+
require.NoError(t, err)
37+
}
38+
39+
func TestConnectExists_NotRunning(t *testing.T) {
40+
t.Parallel()
41+
ctx := testutil.Context(t, testutil.WaitShort)
42+
43+
var root cli.RootCmd
44+
cmd, err := root.Command(root.AGPL())
45+
require.NoError(t, err)
46+
47+
inv := (&serpent.Invocation{
48+
Command: cmd,
49+
Args: []string{"connect", "exists", "test.example"},
50+
}).WithContext(withCoderConnectNotRunning(ctx))
51+
stdout := new(bytes.Buffer)
52+
stderr := new(bytes.Buffer)
53+
inv.Stdout = stdout
54+
inv.Stderr = stderr
55+
err = inv.Run()
56+
require.ErrorIs(t, err, cli.ErrSilent)
57+
}
58+
59+
type fakeResolver struct {
60+
shouldReturnSuccess bool
61+
}
62+
63+
func (f *fakeResolver) LookupIP(_ context.Context, _, _ string) ([]net.IP, error) {
64+
if f.shouldReturnSuccess {
65+
return []net.IP{net.ParseIP(tsaddr.CoderServiceIPv6().String())}, nil
66+
}
67+
return nil, &net.DNSError{IsNotFound: true}
68+
}
69+
70+
func withCoderConnectRunning(ctx context.Context) context.Context {
71+
return workspacesdk.WithTestOnlyCoderContextResolver(ctx, &fakeResolver{shouldReturnSuccess: true})
72+
}
73+
74+
func withCoderConnectNotRunning(ctx context.Context) context.Context {
75+
return workspacesdk.WithTestOnlyCoderContextResolver(ctx, &fakeResolver{shouldReturnSuccess: false})
76+
}

cli/root.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,14 +31,15 @@ import (
3131

3232
"github.com/coder/pretty"
3333

34+
"github.com/coder/serpent"
35+
3436
"github.com/coder/coder/v2/buildinfo"
3537
"github.com/coder/coder/v2/cli/cliui"
3638
"github.com/coder/coder/v2/cli/config"
3739
"github.com/coder/coder/v2/cli/gitauth"
3840
"github.com/coder/coder/v2/cli/telemetry"
3941
"github.com/coder/coder/v2/codersdk"
4042
"github.com/coder/coder/v2/codersdk/agentsdk"
41-
"github.com/coder/serpent"
4243
)
4344

4445
var (
@@ -49,6 +50,10 @@ var (
4950
workspaceCommand = map[string]string{
5051
"workspaces": "",
5152
}
53+
54+
// ErrSilent is a sentinel error that tells the command handler to just exit with a non-zero error, but not print
55+
// anything.
56+
ErrSilent = xerrors.New("silent error")
5257
)
5358

5459
const (
@@ -122,6 +127,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
122127
r.whoami(),
123128

124129
// Hidden
130+
r.connectCmd(),
125131
r.expCmd(),
126132
r.gitssh(),
127133
r.support(),
@@ -175,6 +181,10 @@ func (r *RootCmd) RunWithSubcommands(subcommands []*serpent.Command) {
175181
//nolint:revive,gocritic
176182
os.Exit(code)
177183
}
184+
if errors.Is(err, ErrSilent) {
185+
//nolint:revive,gocritic
186+
os.Exit(code)
187+
}
178188
f := PrettyErrorFormatter{w: os.Stderr, verbose: r.verbose}
179189
if err != nil {
180190
f.Format(err)

cli/root_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,12 +10,13 @@ import (
1010
"sync/atomic"
1111
"testing"
1212

13+
"github.com/coder/serpent"
14+
1315
"github.com/coder/coder/v2/coderd"
1416
"github.com/coder/coder/v2/coderd/coderdtest"
1517
"github.com/coder/coder/v2/codersdk"
1618
"github.com/coder/coder/v2/pty/ptytest"
1719
"github.com/coder/coder/v2/testutil"
18-
"github.com/coder/serpent"
1920

2021
"github.com/stretchr/testify/assert"
2122
"github.com/stretchr/testify/require"

codersdk/workspacesdk/workspacesdk.go

Lines changed: 30 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,12 @@ import (
2020

2121
"cdr.dev/slog"
2222

23+
"github.com/coder/quartz"
24+
"github.com/coder/websocket"
25+
2326
"github.com/coder/coder/v2/codersdk"
2427
"github.com/coder/coder/v2/tailnet"
2528
"github.com/coder/coder/v2/tailnet/proto"
26-
"github.com/coder/quartz"
27-
"github.com/coder/websocket"
2829
)
2930

3031
var ErrSkipClose = xerrors.New("skip tailnet close")
@@ -128,19 +129,16 @@ func init() {
128129
}
129130
}
130131

131-
type resolver interface {
132+
type Resolver interface {
132133
LookupIP(ctx context.Context, network, host string) ([]net.IP, error)
133134
}
134135

135136
type Client struct {
136137
client *codersdk.Client
137-
138-
// overridden in tests
139-
resolver resolver
140138
}
141139

142140
func New(c *codersdk.Client) *Client {
143-
return &Client{client: c, resolver: net.DefaultResolver}
141+
return &Client{client: c}
144142
}
145143

146144
// AgentConnectionInfo returns required information for establishing
@@ -392,6 +390,12 @@ func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentRe
392390
return websocket.NetConn(context.Background(), conn, websocket.MessageBinary), nil
393391
}
394392

393+
func WithTestOnlyCoderContextResolver(ctx context.Context, r Resolver) context.Context {
394+
return context.WithValue(ctx, dnsResolverContextKey{}, r)
395+
}
396+
397+
type dnsResolverContextKey struct{}
398+
395399
type CoderConnectQueryOptions struct {
396400
HostnameSuffix string
397401
}
@@ -409,15 +413,32 @@ func (c *Client) IsCoderConnectRunning(ctx context.Context, o CoderConnectQueryO
409413
suffix = info.HostnameSuffix
410414
}
411415
domainName := fmt.Sprintf(tailnet.IsCoderConnectEnabledFmtString, suffix)
416+
return ExistsViaCoderConnect(ctx, domainName)
417+
}
418+
419+
func testOrDefaultResolver(ctx context.Context) Resolver {
420+
// check the context for a non-default resolver. This is only used in testing.
421+
resolver, ok := ctx.Value(dnsResolverContextKey{}).(Resolver)
422+
if !ok || resolver == nil {
423+
resolver = net.DefaultResolver
424+
}
425+
return resolver
426+
}
427+
428+
// ExistsViaCoderConnect checks if the given hostname exists via Coder Connect. This doesn't guarantee the
429+
// workspace is actually reachable, if, for example, its agent is unhealthy, but rather that Coder Connect knows about
430+
// the workspace and advertises the hostname via DNS.
431+
func ExistsViaCoderConnect(ctx context.Context, hostname string) (bool, error) {
432+
resolver := testOrDefaultResolver(ctx)
412433
var dnsError *net.DNSError
413-
ips, err := c.resolver.LookupIP(ctx, "ip6", domainName)
434+
ips, err := resolver.LookupIP(ctx, "ip6", hostname)
414435
if xerrors.As(err, &dnsError) {
415436
if dnsError.IsNotFound {
416437
return false, nil
417438
}
418439
}
419440
if err != nil {
420-
return false, xerrors.Errorf("lookup DNS %s: %w", domainName, err)
441+
return false, xerrors.Errorf("lookup DNS %s: %w", hostname, err)
421442
}
422443

423444
// The returned IP addresses are probably from the Coder Connect DNS server, but there are sometimes weird captive

codersdk/workspacesdk/workspacesdk_internal_test.go

Lines changed: 0 additions & 86 deletions
This file was deleted.

0 commit comments

Comments
 (0)