mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
031d99288a
Backport of #23090 to `release/2.29`.
When a user creates a workspace, opens the web terminal, then the
workspace stops but the web terminal remains open, the web terminal will
retry the connection. Coder would issue a HTTP 502 Bad Gateway response
when this occurred because coderd could not connect to the workspace
agent, however this is problematic as any load balancer sitting in front
of Coder sees a 502 and thinks Coder is unhealthy.
This PR changes the response to a HTTP 404 after internal discussion.
Cherry-picked from merge commit
c33812a430. The conflict in
`coderd/workspaceapps/errors.go` was resolved by applying the status
code change (502 → 404) while keeping the existing
`RetryEnabled`/`DashboardURL` fields (the `Actions` refactor is not on
this branch).
394 lines
11 KiB
Go
394 lines
11 KiB
Go
package coderd_test
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/netip"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"github.com/davecgh/go-spew/spew"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.uber.org/goleak"
|
|
"tailscale.com/tailcfg"
|
|
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbfake"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
"github.com/coder/coder/v2/provisionersdk/proto"
|
|
|
|
"github.com/coder/coder/v2/agent/agenttest"
|
|
"github.com/coder/coder/v2/buildinfo"
|
|
"github.com/coder/coder/v2/coderd"
|
|
"github.com/coder/coder/v2/coderd/coderdtest"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/provisioner/echo"
|
|
"github.com/coder/coder/v2/tailnet"
|
|
tailnetproto "github.com/coder/coder/v2/tailnet/proto"
|
|
"github.com/coder/coder/v2/testutil"
|
|
)
|
|
|
|
// updateGoldenFiles is a flag that can be set to update golden files.
|
|
var updateGoldenFiles = flag.Bool("update", false, "Update golden files")
|
|
|
|
func TestMain(m *testing.M) {
|
|
goleak.VerifyTestMain(m, testutil.GoleakOptions...)
|
|
}
|
|
|
|
func TestBuildInfo(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
buildInfo, err := client.BuildInfo(ctx)
|
|
require.NoError(t, err)
|
|
require.Equal(t, buildinfo.ExternalURL(), buildInfo.ExternalURL, "external URL")
|
|
require.Equal(t, buildinfo.Version(), buildInfo.Version, "version")
|
|
}
|
|
|
|
func TestDERP(t *testing.T) {
|
|
t.Parallel()
|
|
ctx := testutil.Context(t, testutil.WaitMedium)
|
|
client := coderdtest.New(t, nil)
|
|
|
|
logger := testutil.Logger(t)
|
|
|
|
derpPort, err := strconv.Atoi(client.URL.Port())
|
|
require.NoError(t, err)
|
|
derpMap := &tailcfg.DERPMap{
|
|
Regions: map[int]*tailcfg.DERPRegion{
|
|
1: {
|
|
RegionID: 1,
|
|
RegionCode: "cdr",
|
|
RegionName: "Coder",
|
|
Nodes: []*tailcfg.DERPNode{{
|
|
Name: "1a",
|
|
RegionID: 1,
|
|
HostName: client.URL.Hostname(),
|
|
DERPPort: derpPort,
|
|
STUNPort: -1,
|
|
ForceHTTP: true,
|
|
}},
|
|
},
|
|
},
|
|
}
|
|
w1IP := tailnet.TailscaleServicePrefix.RandomAddr()
|
|
w1, err := tailnet.NewConn(&tailnet.Options{
|
|
Addresses: []netip.Prefix{netip.PrefixFrom(w1IP, 128)},
|
|
Logger: logger.Named("w1"),
|
|
DERPMap: derpMap,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
w2, err := tailnet.NewConn(&tailnet.Options{
|
|
Addresses: []netip.Prefix{tailnet.TailscaleServicePrefix.RandomPrefix()},
|
|
Logger: logger.Named("w2"),
|
|
DERPMap: derpMap,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
w1ID := uuid.New()
|
|
w1.SetNodeCallback(func(node *tailnet.Node) {
|
|
pn, err := tailnet.NodeToProto(node)
|
|
if !assert.NoError(t, err) {
|
|
return
|
|
}
|
|
w2.UpdatePeers([]*tailnetproto.CoordinateResponse_PeerUpdate{{
|
|
Id: w1ID[:],
|
|
Node: pn,
|
|
Kind: tailnetproto.CoordinateResponse_PeerUpdate_NODE,
|
|
}})
|
|
})
|
|
w2ID := uuid.New()
|
|
w2.SetNodeCallback(func(node *tailnet.Node) {
|
|
pn, err := tailnet.NodeToProto(node)
|
|
if !assert.NoError(t, err) {
|
|
return
|
|
}
|
|
w1.UpdatePeers([]*tailnetproto.CoordinateResponse_PeerUpdate{{
|
|
Id: w2ID[:],
|
|
Node: pn,
|
|
Kind: tailnetproto.CoordinateResponse_PeerUpdate_NODE,
|
|
}})
|
|
})
|
|
|
|
conn := make(chan struct{})
|
|
go func() {
|
|
listener, err := w1.Listen("tcp", ":35565")
|
|
assert.NoError(t, err)
|
|
defer listener.Close()
|
|
conn <- struct{}{}
|
|
nc, err := listener.Accept()
|
|
assert.NoError(t, err)
|
|
_ = nc.Close()
|
|
conn <- struct{}{}
|
|
}()
|
|
|
|
<-conn
|
|
w2.AwaitReachable(ctx, w1IP)
|
|
nc, err := w2.DialContextTCP(ctx, netip.AddrPortFrom(w1IP, 35565))
|
|
require.NoError(t, err)
|
|
_ = nc.Close()
|
|
<-conn
|
|
|
|
w1.Close()
|
|
w2.Close()
|
|
}
|
|
|
|
func TestDERPForceWebSockets(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
dv := coderdtest.DeploymentValues(t)
|
|
dv.DERP.Config.ForceWebSockets = true
|
|
dv.DERP.Config.BlockDirect = true // to ensure the test always uses DERP
|
|
|
|
// Manually create a server so we can influence the HTTP handler.
|
|
options := &coderdtest.Options{
|
|
DeploymentValues: dv,
|
|
}
|
|
setHandler, cancelFunc, serverURL, newOptions := coderdtest.NewOptions(t, options)
|
|
coderAPI := coderd.New(newOptions)
|
|
t.Cleanup(func() {
|
|
cancelFunc()
|
|
_ = coderAPI.Close()
|
|
})
|
|
|
|
// Set the HTTP handler to a custom one that ensures all /derp calls are
|
|
// WebSockets and not `Upgrade: derp`.
|
|
var upgradeCount int64
|
|
setHandler(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
|
|
if strings.HasPrefix(r.URL.Path, "/derp") {
|
|
up := r.Header.Get("Upgrade")
|
|
if up != "" && up != "websocket" {
|
|
t.Errorf("expected Upgrade: websocket, got %q", up)
|
|
} else {
|
|
atomic.AddInt64(&upgradeCount, 1)
|
|
}
|
|
}
|
|
|
|
coderAPI.RootHandler.ServeHTTP(rw, r)
|
|
}))
|
|
|
|
// Start a provisioner daemon.
|
|
provisionerCloser := coderdtest.NewProvisionerDaemon(t, coderAPI)
|
|
t.Cleanup(func() {
|
|
_ = provisionerCloser.Close()
|
|
})
|
|
|
|
client := codersdk.New(serverURL)
|
|
t.Cleanup(func() {
|
|
client.HTTPClient.CloseIdleConnections()
|
|
})
|
|
wsclient := workspacesdk.New(client)
|
|
user := coderdtest.CreateFirstUser(t, client)
|
|
|
|
gen, err := wsclient.AgentConnectionInfoGeneric(context.Background())
|
|
require.NoError(t, err)
|
|
t.Log(spew.Sdump(gen))
|
|
|
|
authToken := uuid.NewString()
|
|
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
|
Parse: echo.ParseComplete,
|
|
ProvisionPlan: echo.PlanComplete,
|
|
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
|
|
})
|
|
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
|
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
|
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
|
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
|
|
|
_ = agenttest.New(t, client.URL, authToken)
|
|
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
|
defer cancel()
|
|
|
|
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
|
conn, err := wsclient.DialAgent(ctx, resources[0].Agents[0].ID,
|
|
&workspacesdk.DialAgentOptions{
|
|
Logger: testutil.Logger(t).Named("client"),
|
|
},
|
|
)
|
|
require.NoError(t, err)
|
|
defer func() {
|
|
_ = conn.Close()
|
|
}()
|
|
conn.AwaitReachable(ctx)
|
|
|
|
require.GreaterOrEqual(t, atomic.LoadInt64(&upgradeCount), int64(1), "expected at least one /derp call")
|
|
}
|
|
|
|
func TestDERPLatencyCheck(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
res, err := client.Request(context.Background(), http.MethodGet, "/derp/latency-check", nil)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
}
|
|
|
|
func TestFastLatencyCheck(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
res, err := client.Request(context.Background(), http.MethodGet, "/latency-check", nil)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
}
|
|
|
|
func TestHealthz(t *testing.T) {
|
|
t.Parallel()
|
|
client := coderdtest.New(t, nil)
|
|
|
|
res, err := client.Request(context.Background(), http.MethodGet, "/healthz", nil)
|
|
require.NoError(t, err)
|
|
defer res.Body.Close()
|
|
|
|
require.Equal(t, http.StatusOK, res.StatusCode)
|
|
body, err := io.ReadAll(res.Body)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, "OK", string(body))
|
|
}
|
|
|
|
func TestSwagger(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
const swaggerEndpoint = "/swagger"
|
|
t.Run("endpoint enabled", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
SwaggerEndpoint: true,
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
|
defer cancel()
|
|
|
|
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, swaggerEndpoint, nil)
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Contains(t, string(body), "Swagger UI")
|
|
})
|
|
t.Run("doc.json exposed", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, &coderdtest.Options{
|
|
SwaggerEndpoint: true,
|
|
})
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
|
defer cancel()
|
|
|
|
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, swaggerEndpoint+"/doc.json", nil)
|
|
require.NoError(t, err)
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Contains(t, string(body), `"swagger": "2.0"`)
|
|
})
|
|
t.Run("endpoint disabled by default", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
|
defer cancel()
|
|
|
|
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, swaggerEndpoint, nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
})
|
|
t.Run("doc.json disabled by default", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client := coderdtest.New(t, nil)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
|
defer cancel()
|
|
|
|
resp, err := requestWithRetries(ctx, t, client, http.MethodGet, swaggerEndpoint+"/doc.json", nil)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
require.Equal(t, http.StatusNotFound, resp.StatusCode)
|
|
})
|
|
}
|
|
|
|
func TestCSRFExempt(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
// This test build a workspace with an agent and an app. The app is not
|
|
// a real http server, so it will fail to serve requests. We just want
|
|
// to make sure the failure is not a CSRF failure, as path based
|
|
// apps should be exempt.
|
|
t.Run("PathBasedApp", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
client, _, api := coderdtest.NewWithAPI(t, nil)
|
|
first := coderdtest.CreateFirstUser(t, client)
|
|
owner, err := client.User(context.Background(), "me")
|
|
require.NoError(t, err)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
|
defer cancel()
|
|
|
|
// Create a workspace.
|
|
const agentSlug = "james"
|
|
const appSlug = "web"
|
|
wrk := dbfake.WorkspaceBuild(t, api.Database, database.WorkspaceTable{
|
|
OwnerID: owner.ID,
|
|
OrganizationID: first.OrganizationID,
|
|
}).
|
|
WithAgent(func(agents []*proto.Agent) []*proto.Agent {
|
|
agents[0].Name = agentSlug
|
|
agents[0].Apps = []*proto.App{{
|
|
Slug: appSlug,
|
|
DisplayName: appSlug,
|
|
Subdomain: false,
|
|
Url: "/",
|
|
}}
|
|
|
|
return agents
|
|
}).
|
|
Do()
|
|
|
|
u := client.URL.JoinPath(fmt.Sprintf("/@%s/%s.%s/apps/%s", owner.Username, wrk.Workspace.Name, agentSlug, appSlug)).String()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, nil)
|
|
req.AddCookie(&http.Cookie{
|
|
Name: codersdk.SessionTokenCookie,
|
|
Value: client.SessionToken(),
|
|
Path: "/",
|
|
Domain: client.URL.String(),
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
resp, err := client.HTTPClient.Do(req)
|
|
require.NoError(t, err)
|
|
data, _ := io.ReadAll(resp.Body)
|
|
_ = resp.Body.Close()
|
|
|
|
// A StatusNotFound means Coderd tried to proxy to the agent and failed because the agent
|
|
// was not there. This means CSRF did not block the app request, which is what we want.
|
|
require.Equal(t, http.StatusNotFound, resp.StatusCode, "status code 500 is CSRF failure")
|
|
require.NotContains(t, string(data), "CSRF")
|
|
})
|
|
}
|