Skip to content

Commit 169ab02

Browse files
committed
feat: add coder connect exists hidden subcommand
1 parent d78215c commit 169ab02

File tree

7 files changed

+240
-99
lines changed

7 files changed

+240
-99
lines changed

cli/connect.go

+47
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

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

cli/root.go

+11-1
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

+2-1
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

+30-9
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

-86
This file was deleted.

0 commit comments

Comments
 (0)