Content-Length: 15181 | pFad | http://github.com/coder/coder/pull/17418.diff
thub.com
diff --git a/cli/configssh.go b/cli/configssh.go
index 6a0f41c2a2fbc..c089141846d39 100644
--- a/cli/configssh.go
+++ b/cli/configssh.go
@@ -22,9 +22,10 @@ import (
"golang.org/x/exp/constraints"
"golang.org/x/xerrors"
+ "github.com/coder/serpent"
+
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/codersdk"
- "github.com/coder/serpent"
)
const (
diff --git a/cli/connect.go b/cli/connect.go
new file mode 100644
index 0000000000000..d1245147f3848
--- /dev/null
+++ b/cli/connect.go
@@ -0,0 +1,47 @@
+package cli
+
+import (
+ "github.com/coder/serpent"
+
+ "github.com/coder/coder/v2/codersdk/workspacesdk"
+)
+
+func (r *RootCmd) connectCmd() *serpent.Command {
+ cmd := &serpent.Command{
+ Use: "connect",
+ Short: "Commands related to Coder Connect (OS-level tunneled connection to workspaces).",
+ Handler: func(i *serpent.Invocation) error {
+ return i.Command.HelpHandler(i)
+ },
+ Hidden: true,
+ Children: []*serpent.Command{
+ r.existsCmd(),
+ },
+ }
+ return cmd
+}
+
+func (*RootCmd) existsCmd() *serpent.Command {
+ cmd := &serpent.Command{
+ Use: "exists ",
+ Short: "Checks if the given hostname exists via Coder Connect.",
+ Long: "This command is designed to be used in scripts to check if the given hostname exists via Coder " +
+ "Connect. It prints no output. It returns exit code 0 if it does exist and code 1 if it does not.",
+ Middleware: serpent.Chain(
+ serpent.RequireNArgs(1),
+ ),
+ Handler: func(inv *serpent.Invocation) error {
+ hostname := inv.Args[0]
+ exists, err := workspacesdk.ExistsViaCoderConnect(inv.Context(), hostname)
+ if err != nil {
+ return err
+ }
+ if !exists {
+ // we don't want to print any output, since this command is designed to be a check in scripts / SSH config.
+ return ErrSilent
+ }
+ return nil
+ },
+ }
+ return cmd
+}
diff --git a/cli/connect_test.go b/cli/connect_test.go
new file mode 100644
index 0000000000000..031cd2f95b1f9
--- /dev/null
+++ b/cli/connect_test.go
@@ -0,0 +1,76 @@
+package cli_test
+
+import (
+ "bytes"
+ "context"
+ "net"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+ "tailscale.com/net/tsaddr"
+
+ "github.com/coder/serpent"
+
+ "github.com/coder/coder/v2/cli"
+ "github.com/coder/coder/v2/codersdk/workspacesdk"
+ "github.com/coder/coder/v2/testutil"
+)
+
+func TestConnectExists_Running(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ var root cli.RootCmd
+ cmd, err := root.Command(root.AGPL())
+ require.NoError(t, err)
+
+ inv := (&serpent.Invocation{
+ Command: cmd,
+ Args: []string{"connect", "exists", "test.example"},
+ }).WithContext(withCoderConnectRunning(ctx))
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ inv.Stdout = stdout
+ inv.Stderr = stderr
+ err = inv.Run()
+ require.NoError(t, err)
+}
+
+func TestConnectExists_NotRunning(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ var root cli.RootCmd
+ cmd, err := root.Command(root.AGPL())
+ require.NoError(t, err)
+
+ inv := (&serpent.Invocation{
+ Command: cmd,
+ Args: []string{"connect", "exists", "test.example"},
+ }).WithContext(withCoderConnectNotRunning(ctx))
+ stdout := new(bytes.Buffer)
+ stderr := new(bytes.Buffer)
+ inv.Stdout = stdout
+ inv.Stderr = stderr
+ err = inv.Run()
+ require.ErrorIs(t, err, cli.ErrSilent)
+}
+
+type fakeResolver struct {
+ shouldReturnSuccess bool
+}
+
+func (f *fakeResolver) LookupIP(_ context.Context, _, _ string) ([]net.IP, error) {
+ if f.shouldReturnSuccess {
+ return []net.IP{net.ParseIP(tsaddr.CoderServiceIPv6().String())}, nil
+ }
+ return nil, &net.DNSError{IsNotFound: true}
+}
+
+func withCoderConnectRunning(ctx context.Context) context.Context {
+ return workspacesdk.WithTestOnlyCoderContextResolver(ctx, &fakeResolver{shouldReturnSuccess: true})
+}
+
+func withCoderConnectNotRunning(ctx context.Context) context.Context {
+ return workspacesdk.WithTestOnlyCoderContextResolver(ctx, &fakeResolver{shouldReturnSuccess: false})
+}
diff --git a/cli/root.go b/cli/root.go
index 75cbb4dd2ca1a..5c70379b75a44 100644
--- a/cli/root.go
+++ b/cli/root.go
@@ -31,6 +31,8 @@ import (
"github.com/coder/pretty"
+ "github.com/coder/serpent"
+
"github.com/coder/coder/v2/buildinfo"
"github.com/coder/coder/v2/cli/cliui"
"github.com/coder/coder/v2/cli/config"
@@ -38,7 +40,6 @@ import (
"github.com/coder/coder/v2/cli/telemetry"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
- "github.com/coder/serpent"
)
var (
@@ -49,6 +50,10 @@ var (
workspaceCommand = map[string]string{
"workspaces": "",
}
+
+ // ErrSilent is a sentinel error that tells the command handler to just exit with a non-zero error, but not print
+ // anything.
+ ErrSilent = xerrors.New("silent error")
)
const (
@@ -122,6 +127,7 @@ func (r *RootCmd) CoreSubcommands() []*serpent.Command {
r.whoami(),
// Hidden
+ r.connectCmd(),
r.expCmd(),
r.gitssh(),
r.support(),
@@ -175,6 +181,10 @@ func (r *RootCmd) RunWithSubcommands(subcommands []*serpent.Command) {
//nolint:revive,gocritic
os.Exit(code)
}
+ if errors.Is(err, ErrSilent) {
+ //nolint:revive,gocritic
+ os.Exit(code)
+ }
f := PrettyErrorFormatter{w: os.Stderr, verbose: r.verbose}
if err != nil {
f.Format(err)
diff --git a/cli/root_test.go b/cli/root_test.go
index ac1454152672e..698c9aff60186 100644
--- a/cli/root_test.go
+++ b/cli/root_test.go
@@ -10,12 +10,13 @@ import (
"sync/atomic"
"testing"
+ "github.com/coder/serpent"
+
"github.com/coder/coder/v2/coderd"
"github.com/coder/coder/v2/coderd/coderdtest"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/pty/ptytest"
"github.com/coder/coder/v2/testutil"
- "github.com/coder/serpent"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
diff --git a/codersdk/workspacesdk/workspacesdk.go b/codersdk/workspacesdk/workspacesdk.go
index 25188917dafc9..83f236a215b56 100644
--- a/codersdk/workspacesdk/workspacesdk.go
+++ b/codersdk/workspacesdk/workspacesdk.go
@@ -20,11 +20,12 @@ import (
"cdr.dev/slog"
+ "github.com/coder/quartz"
+ "github.com/coder/websocket"
+
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/tailnet/proto"
- "github.com/coder/quartz"
- "github.com/coder/websocket"
)
var ErrSkipClose = xerrors.New("skip tailnet close")
@@ -128,19 +129,16 @@ func init() {
}
}
-type resolver interface {
+type Resolver interface {
LookupIP(ctx context.Context, network, host string) ([]net.IP, error)
}
type Client struct {
client *codersdk.Client
-
- // overridden in tests
- resolver resolver
}
func New(c *codersdk.Client) *Client {
- return &Client{client: c, resolver: net.DefaultResolver}
+ return &Client{client: c}
}
// AgentConnectionInfo returns required information for establishing
@@ -392,6 +390,12 @@ func (c *Client) AgentReconnectingPTY(ctx context.Context, opts WorkspaceAgentRe
return websocket.NetConn(context.Background(), conn, websocket.MessageBinary), nil
}
+func WithTestOnlyCoderContextResolver(ctx context.Context, r Resolver) context.Context {
+ return context.WithValue(ctx, dnsResolverContextKey{}, r)
+}
+
+type dnsResolverContextKey struct{}
+
type CoderConnectQueryOptions struct {
HostnameSuffix string
}
@@ -409,15 +413,32 @@ func (c *Client) IsCoderConnectRunning(ctx context.Context, o CoderConnectQueryO
suffix = info.HostnameSuffix
}
domainName := fmt.Sprintf(tailnet.IsCoderConnectEnabledFmtString, suffix)
+ return ExistsViaCoderConnect(ctx, domainName)
+}
+
+func testOrDefaultResolver(ctx context.Context) Resolver {
+ // check the context for a non-default resolver. This is only used in testing.
+ resolver, ok := ctx.Value(dnsResolverContextKey{}).(Resolver)
+ if !ok || resolver == nil {
+ resolver = net.DefaultResolver
+ }
+ return resolver
+}
+
+// ExistsViaCoderConnect checks if the given hostname exists via Coder Connect. This doesn't guarantee the
+// workspace is actually reachable, if, for example, its agent is unhealthy, but rather that Coder Connect knows about
+// the workspace and advertises the hostname via DNS.
+func ExistsViaCoderConnect(ctx context.Context, hostname string) (bool, error) {
+ resolver := testOrDefaultResolver(ctx)
var dnsError *net.DNSError
- ips, err := c.resolver.LookupIP(ctx, "ip6", domainName)
+ ips, err := resolver.LookupIP(ctx, "ip6", hostname)
if xerrors.As(err, &dnsError) {
if dnsError.IsNotFound {
return false, nil
}
}
if err != nil {
- return false, xerrors.Errorf("lookup DNS %s: %w", domainName, err)
+ return false, xerrors.Errorf("lookup DNS %s: %w", hostname, err)
}
// The returned IP addresses are probably from the Coder Connect DNS server, but there are sometimes weird captive
diff --git a/codersdk/workspacesdk/workspacesdk_internal_test.go b/codersdk/workspacesdk/workspacesdk_internal_test.go
deleted file mode 100644
index 1b98ebdc2e671..0000000000000
--- a/codersdk/workspacesdk/workspacesdk_internal_test.go
+++ /dev/null
@@ -1,86 +0,0 @@
-package workspacesdk
-
-import (
- "context"
- "fmt"
- "net"
- "net/http"
- "net/http/httptest"
- "net/url"
- "testing"
-
- "github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
- "golang.org/x/xerrors"
-
- "github.com/coder/coder/v2/coderd/httpapi"
- "github.com/coder/coder/v2/codersdk"
- "github.com/coder/coder/v2/testutil"
-
- "tailscale.com/net/tsaddr"
-
- "github.com/coder/coder/v2/tailnet"
-)
-
-func TestClient_IsCoderConnectRunning(t *testing.T) {
- t.Parallel()
- ctx := testutil.Context(t, testutil.WaitShort)
-
- srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
- assert.Equal(t, "/api/v2/workspaceagents/connection", r.URL.Path)
- httpapi.Write(ctx, rw, http.StatusOK, AgentConnectionInfo{
- HostnameSuffix: "test",
- })
- }))
- defer srv.Close()
-
- apiURL, err := url.Parse(srv.URL)
- require.NoError(t, err)
- sdkClient := codersdk.New(apiURL)
- client := New(sdkClient)
-
- // Right name, right IP
- expectedName := fmt.Sprintf(tailnet.IsCoderConnectEnabledFmtString, "test")
- client.resolver = &fakeResolver{t: t, hostMap: map[string][]net.IP{
- expectedName: {net.ParseIP(tsaddr.CoderServiceIPv6().String())},
- }}
-
- result, err := client.IsCoderConnectRunning(ctx, CoderConnectQueryOptions{})
- require.NoError(t, err)
- require.True(t, result)
-
- // Wrong name
- result, err = client.IsCoderConnectRunning(ctx, CoderConnectQueryOptions{HostnameSuffix: "coder"})
- require.NoError(t, err)
- require.False(t, result)
-
- // Not found
- client.resolver = &fakeResolver{t: t, err: &net.DNSError{IsNotFound: true}}
- result, err = client.IsCoderConnectRunning(ctx, CoderConnectQueryOptions{})
- require.NoError(t, err)
- require.False(t, result)
-
- // Some other error
- client.resolver = &fakeResolver{t: t, err: xerrors.New("a bad thing happened")}
- _, err = client.IsCoderConnectRunning(ctx, CoderConnectQueryOptions{})
- require.Error(t, err)
-
- // Right name, wrong IP
- client.resolver = &fakeResolver{t: t, hostMap: map[string][]net.IP{
- expectedName: {net.ParseIP("2001::34")},
- }}
- result, err = client.IsCoderConnectRunning(ctx, CoderConnectQueryOptions{})
- require.NoError(t, err)
- require.False(t, result)
-}
-
-type fakeResolver struct {
- t testing.TB
- hostMap map[string][]net.IP
- err error
-}
-
-func (f *fakeResolver) LookupIP(_ context.Context, network, host string) ([]net.IP, error) {
- assert.Equal(f.t, "ip6", network)
- return f.hostMap[host], f.err
-}
diff --git a/codersdk/workspacesdk/workspacesdk_test.go b/codersdk/workspacesdk/workspacesdk_test.go
index e7ccd96e208fa..16a523b2d4d53 100644
--- a/codersdk/workspacesdk/workspacesdk_test.go
+++ b/codersdk/workspacesdk/workspacesdk_test.go
@@ -1,12 +1,18 @@
package workspacesdk_test
import (
+ "context"
+ "fmt"
+ "net"
"net/http"
"net/http/httptest"
"net/url"
"testing"
+ "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "golang.org/x/xerrors"
+ "tailscale.com/net/tsaddr"
"tailscale.com/tailcfg"
"github.com/coder/websocket"
@@ -15,6 +21,7 @@ import (
"github.com/coder/coder/v2/codersdk"
"github.com/coder/coder/v2/codersdk/agentsdk"
"github.com/coder/coder/v2/codersdk/workspacesdk"
+ "github.com/coder/coder/v2/tailnet"
"github.com/coder/coder/v2/testutil"
)
@@ -72,3 +79,70 @@ func TestWorkspaceDialerFailure(t *testing.T) {
// Then: an error indicating a database issue is returned, to conditionalize the behavior of the caller.
require.ErrorIs(t, err, codersdk.ErrDatabaseNotReachable)
}
+
+func TestClient_IsCoderConnectRunning(t *testing.T) {
+ t.Parallel()
+ ctx := testutil.Context(t, testutil.WaitShort)
+
+ srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+ assert.Equal(t, "/api/v2/workspaceagents/connection", r.URL.Path)
+ httpapi.Write(ctx, rw, http.StatusOK, workspacesdk.AgentConnectionInfo{
+ HostnameSuffix: "test",
+ })
+ }))
+ defer srv.Close()
+
+ apiURL, err := url.Parse(srv.URL)
+ require.NoError(t, err)
+ sdkClient := codersdk.New(apiURL)
+ client := workspacesdk.New(sdkClient)
+
+ // Right name, right IP
+ expectedName := fmt.Sprintf(tailnet.IsCoderConnectEnabledFmtString, "test")
+ ctxResolveExpected := workspacesdk.WithTestOnlyCoderContextResolver(ctx,
+ &fakeResolver{t: t, hostMap: map[string][]net.IP{
+ expectedName: {net.ParseIP(tsaddr.CoderServiceIPv6().String())},
+ }})
+
+ result, err := client.IsCoderConnectRunning(ctxResolveExpected, workspacesdk.CoderConnectQueryOptions{})
+ require.NoError(t, err)
+ require.True(t, result)
+
+ // Wrong name
+ result, err = client.IsCoderConnectRunning(ctxResolveExpected, workspacesdk.CoderConnectQueryOptions{HostnameSuffix: "coder"})
+ require.NoError(t, err)
+ require.False(t, result)
+
+ // Not found
+ ctxResolveNotFound := workspacesdk.WithTestOnlyCoderContextResolver(ctx,
+ &fakeResolver{t: t, err: &net.DNSError{IsNotFound: true}})
+ result, err = client.IsCoderConnectRunning(ctxResolveNotFound, workspacesdk.CoderConnectQueryOptions{})
+ require.NoError(t, err)
+ require.False(t, result)
+
+ // Some other error
+ ctxResolverErr := workspacesdk.WithTestOnlyCoderContextResolver(ctx,
+ &fakeResolver{t: t, err: xerrors.New("a bad thing happened")})
+ _, err = client.IsCoderConnectRunning(ctxResolverErr, workspacesdk.CoderConnectQueryOptions{})
+ require.Error(t, err)
+
+ // Right name, wrong IP
+ ctxResolverWrongIP := workspacesdk.WithTestOnlyCoderContextResolver(ctx,
+ &fakeResolver{t: t, hostMap: map[string][]net.IP{
+ expectedName: {net.ParseIP("2001::34")},
+ }})
+ result, err = client.IsCoderConnectRunning(ctxResolverWrongIP, workspacesdk.CoderConnectQueryOptions{})
+ require.NoError(t, err)
+ require.False(t, result)
+}
+
+type fakeResolver struct {
+ t testing.TB
+ hostMap map[string][]net.IP
+ err error
+}
+
+func (f *fakeResolver) LookupIP(_ context.Context, network, host string) ([]net.IP, error) {
+ assert.Equal(f.t, "ip6", network)
+ return f.hostMap[host], f.err
+}
--- a PPN by Garber Painting Akron. With Image Size Reduction included!Fetched URL: http://github.com/coder/coder/pull/17418.diff
Alternative Proxies:
Alternative Proxy
pFad Proxy
pFad v3 Proxy
pFad v4 Proxy