feat: use app tickets for web terminal (#6628)

This commit is contained in:
Dean Sheather
2023-03-31 00:24:51 +11:00
committed by GitHub
parent a07209efa1
commit 665b84de0d
18 changed files with 1011 additions and 403 deletions

View File

@ -3,11 +3,13 @@ package workspaceapps_test
import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
"net/http/httputil"
"net/url"
"strings"
"testing"
"time"
@ -36,6 +38,11 @@ func Test_ResolveRequest(t *testing.T) {
appNameAuthed = "app-authed"
appNamePublic = "app-public"
appNameInvalidURL = "app-invalid-url"
appNameUnhealthy = "app-unhealthy"
// This agent will never connect, so it will never become "connected".
agentNameUnhealthy = "agent-unhealthy"
appNameAgentUnhealthy = "app-agent-unhealthy"
// This is not a valid URL we listen on in the test, but it needs to be
// set to a value.
@ -43,6 +50,13 @@ func Test_ResolveRequest(t *testing.T) {
)
allApps := []string{appNameOwner, appNameAuthed, appNamePublic}
// Start a listener for a server that always responds with 500 for the
// unhealthy app.
unhealthySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
_, _ = w.Write([]byte("unhealthy"))
}))
deploymentValues := coderdtest.DeploymentValues(t)
deploymentValues.DisablePathApps = false
deploymentValues.Dangerous.AllowPathAppSharing = true
@ -86,39 +100,67 @@ func Test_ResolveRequest(t *testing.T) {
Resources: []*proto.Resource{{
Name: "example",
Type: "aws_instance",
Agents: []*proto.Agent{{
Id: uuid.NewString(),
Name: agentName,
Auth: &proto.Agent_Token{
Token: agentAuthToken,
},
Apps: []*proto.App{
{
Slug: appNameOwner,
DisplayName: appNameOwner,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: appURL,
Agents: []*proto.Agent{
{
Id: uuid.NewString(),
Name: agentName,
Auth: &proto.Agent_Token{
Token: agentAuthToken,
},
{
Slug: appNameAuthed,
DisplayName: appNameAuthed,
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
Url: appURL,
},
{
Slug: appNamePublic,
DisplayName: appNamePublic,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
{
Slug: appNameInvalidURL,
DisplayName: appNameInvalidURL,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: "test:path/to/app",
Apps: []*proto.App{
{
Slug: appNameOwner,
DisplayName: appNameOwner,
SharingLevel: proto.AppSharingLevel_OWNER,
Url: appURL,
},
{
Slug: appNameAuthed,
DisplayName: appNameAuthed,
SharingLevel: proto.AppSharingLevel_AUTHENTICATED,
Url: appURL,
},
{
Slug: appNamePublic,
DisplayName: appNamePublic,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
{
Slug: appNameInvalidURL,
DisplayName: appNameInvalidURL,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: "test:path/to/app",
},
{
Slug: appNameUnhealthy,
DisplayName: appNameUnhealthy,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
Healthcheck: &proto.Healthcheck{
Url: unhealthySrv.URL,
Interval: 1,
Threshold: 1,
},
},
},
},
}},
{
Id: uuid.NewString(),
Name: agentNameUnhealthy,
Auth: &proto.Agent_Token{
Token: uuid.NewString(),
},
Apps: []*proto.App{
{
Slug: appNameAgentUnhealthy,
DisplayName: appNameAgentUnhealthy,
SharingLevel: proto.AppSharingLevel_PUBLIC,
Url: appURL,
},
},
},
},
}},
},
},
@ -138,7 +180,7 @@ func Test_ResolveRequest(t *testing.T) {
t.Cleanup(func() {
_ = agentCloser.Close()
})
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID, agentName)
agentID := uuid.Nil
for _, resource := range resources {
@ -205,16 +247,12 @@ func Test_ResolveRequest(t *testing.T) {
_ = w.Body.Close()
require.Equal(t, &workspaceapps.Ticket{
AccessMethod: req.AccessMethod,
UsernameOrID: req.UsernameOrID,
WorkspaceNameOrID: req.WorkspaceNameOrID,
AgentNameOrID: req.AgentNameOrID,
AppSlugOrPort: req.AppSlugOrPort,
Expiry: ticket.Expiry, // ignored to avoid flakiness
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
Request: req,
Expiry: ticket.Expiry, // ignored to avoid flakiness
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}, ticket)
require.NotZero(t, ticket.Expiry)
require.InDelta(t, time.Now().Add(workspaceapps.TicketExpiry).Unix(), ticket.Expiry, time.Minute.Seconds())
@ -339,7 +377,7 @@ func Test_ResolveRequest(t *testing.T) {
ok bool
}{
{
name: "WorkspaecOnly",
name: "WorkspaceOnly",
workspaceAndAgent: workspace.Name,
workspace: workspace.Name,
agent: "",
@ -423,17 +461,20 @@ func Test_ResolveRequest(t *testing.T) {
t.Parallel()
badTicket := workspaceapps.Ticket{
AccessMethod: workspaceapps.AccessMethodPath,
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
// App name differs
AppSlugOrPort: appNamePublic,
Expiry: time.Now().Add(time.Minute).Unix(),
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
Request: workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
// App name differs
AppSlugOrPort: appNamePublic,
},
Expiry: time.Now().Add(time.Minute).Unix(),
UserID: me.ID,
WorkspaceID: workspace.ID,
AgentID: agentID,
AppURL: appURL,
}
badTicketStr, err := api.WorkspaceAppsProvider.GenerateTicket(badTicket)
require.NoError(t, err)
@ -510,7 +551,7 @@ func Test_ResolveRequest(t *testing.T) {
}
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r := httptest.NewRequest("GET", "/", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
@ -519,6 +560,30 @@ func Test_ResolveRequest(t *testing.T) {
require.Equal(t, "http://127.0.0.1:9090", ticket.AppURL)
})
t.Run("Terminal", func(t *testing.T) {
t.Parallel()
req := workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodTerminal,
BasePath: "/app",
AgentNameOrID: agentID.String(),
}
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
require.True(t, ok)
require.Equal(t, req.AccessMethod, ticket.AccessMethod)
require.Equal(t, req.BasePath, ticket.BasePath)
require.Empty(t, ticket.UsernameOrID)
require.Empty(t, ticket.WorkspaceNameOrID)
require.Equal(t, req.AgentNameOrID, ticket.Request.AgentNameOrID)
require.Empty(t, ticket.AppSlugOrPort)
require.Empty(t, ticket.AppURL)
})
t.Run("InsufficientPermissions", func(t *testing.T) {
t.Parallel()
@ -599,4 +664,89 @@ func Test_ResolveRequest(t *testing.T) {
require.Equal(t, "app.com", redirectURI.Host)
require.Equal(t, "/some-path", redirectURI.Path)
})
t.Run("UnhealthyAgent", func(t *testing.T) {
t.Parallel()
req := workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentNameUnhealthy,
AppSlugOrPort: appNameAgentUnhealthy,
}
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
require.False(t, ok, "request succeeded even though agent is not connected")
require.Nil(t, ticket)
w := rw.Result()
defer w.Body.Close()
require.Equal(t, http.StatusBadGateway, w.StatusCode)
body, err := io.ReadAll(w.Body)
require.NoError(t, err)
bodyStr := string(body)
bodyStr = strings.ReplaceAll(bodyStr, """, `"`)
// It'll either be "connecting" or "disconnected". Both are OK for this
// test.
require.Contains(t, bodyStr, `Agent state is "`)
})
t.Run("UnhealthyApp", func(t *testing.T) {
t.Parallel()
require.Eventually(t, func() bool {
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
defer cancel()
agent, err := client.WorkspaceAgent(ctx, agentID)
if err != nil {
t.Log("could not get agent", err)
return false
}
for _, app := range agent.Apps {
if app.Slug == appNameUnhealthy {
t.Log("app is", app.Health)
return app.Health == codersdk.WorkspaceAppHealthUnhealthy
}
}
t.Log("could not find app")
return false
}, testutil.WaitLong, testutil.IntervalFast, "wait for app to become unhealthy")
req := workspaceapps.Request{
AccessMethod: workspaceapps.AccessMethodPath,
BasePath: "/app",
UsernameOrID: me.Username,
WorkspaceNameOrID: workspace.Name,
AgentNameOrID: agentName,
AppSlugOrPort: appNameUnhealthy,
}
rw := httptest.NewRecorder()
r := httptest.NewRequest("GET", "/app", nil)
r.Header.Set(codersdk.SessionTokenHeader, client.SessionToken())
ticket, ok := api.WorkspaceAppsProvider.ResolveRequest(rw, r, req)
require.False(t, ok, "request succeeded even though app is unhealthy")
require.Nil(t, ticket)
w := rw.Result()
defer w.Body.Close()
require.Equal(t, http.StatusBadGateway, w.StatusCode)
body, err := io.ReadAll(w.Body)
require.NoError(t, err)
bodyStr := string(body)
bodyStr = strings.ReplaceAll(bodyStr, """, `"`)
require.Contains(t, bodyStr, `App health is "unhealthy"`)
})
}