Skip to content

Commit dc5fab3

Browse files
committed
feat: add coder connect exists hidden subcommand
1 parent 0bc49ff commit dc5fab3

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)