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

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: 75 additions & 0 deletions
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

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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy