chore: Add watch workspace endpoint (#1493)

This commit is contained in:
Garrett Delfosse
2022-05-18 16:16:26 -05:00
committed by GitHub
parent b8ee939e52
commit 0706c60445
21 changed files with 259 additions and 37 deletions

View File

@@ -310,6 +310,7 @@ func New(options *Options) (http.Handler, func()) {
r.Route("/autostop", func(r chi.Router) {
r.Put("/", api.putWorkspaceAutostop)
})
r.Get("/watch", api.watchWorkspace)
})
})
r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) {

View File

@@ -128,6 +128,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) {
"PUT:/api/v2/workspaces/{workspace}/autostop": {NoAuthorize: true},
"GET:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true},
"POST:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true},
"GET:/api/v2/workspaces/{workspace}/watch": {NoAuthorize: true},
"POST:/api/v2/files": {NoAuthorize: true},
"GET:/api/v2/files/{hash}": {NoAuthorize: true},

View File

@@ -17,8 +17,8 @@ import (
"github.com/coder/coder/coderd/httpapi"
)
// AuthCookie represents the name of the cookie the API key is stored in.
const AuthCookie = "session_token"
// SessionTokenKey represents the name of the cookie or query paramater the API key is stored in.
const SessionTokenKey = "session_token"
type apiKeyContextKey struct{}
@@ -43,18 +43,24 @@ type OAuth2Configs struct {
func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(AuthCookie)
var cookieValue string
cookie, err := r.Cookie(SessionTokenKey)
if err != nil {
cookieValue = r.URL.Query().Get(SessionTokenKey)
} else {
cookieValue = cookie.Value
}
if cookieValue == "" {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: fmt.Sprintf("%q cookie must be provided", AuthCookie),
Message: fmt.Sprintf("%q cookie or query parameter must be provided", SessionTokenKey),
})
return
}
parts := strings.Split(cookie.Value, "-")
parts := strings.Split(cookieValue, "-")
// APIKeys are formatted: ID-SECRET
if len(parts) != 2 {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: fmt.Sprintf("invalid %q cookie api key format", AuthCookie),
Message: fmt.Sprintf("invalid %q cookie api key format", SessionTokenKey),
})
return
}
@@ -63,13 +69,13 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h
// Ensuring key lengths are valid.
if len(keyID) != 10 {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: fmt.Sprintf("invalid %q cookie api key id", AuthCookie),
Message: fmt.Sprintf("invalid %q cookie api key id", SessionTokenKey),
})
return
}
if len(keySecret) != 22 {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: fmt.Sprintf("invalid %q cookie api key secret", AuthCookie),
Message: fmt.Sprintf("invalid %q cookie api key secret", SessionTokenKey),
})
return
}

View File

@@ -56,7 +56,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: "test-wow-hello",
})
@@ -74,7 +74,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: "test-wow",
})
@@ -92,7 +92,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: "testtestid-wow",
})
@@ -111,7 +111,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})
@@ -130,7 +130,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})
@@ -157,7 +157,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})
@@ -182,7 +182,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})
@@ -209,6 +209,37 @@ func TestAPIKey(t *testing.T) {
require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt)
})
t.Run("QueryParameter", func(t *testing.T) {
t.Parallel()
var (
db = databasefake.New()
id, secret = randomAPIKeyParts()
hashed = sha256.Sum256([]byte(secret))
r = httptest.NewRequest("GET", "/", nil)
rw = httptest.NewRecorder()
)
q := r.URL.Query()
q.Add(httpmw.SessionTokenKey, fmt.Sprintf("%s-%s", id, secret))
r.URL.RawQuery = q.Encode()
_, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{
ID: id,
HashedSecret: hashed[:],
ExpiresAt: database.Now().AddDate(0, 0, 1),
})
require.NoError(t, err)
httpmw.ExtractAPIKey(db, nil)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
// Checks that it exists on the context!
_ = httpmw.APIKey(r)
httpapi.Write(rw, http.StatusOK, httpapi.Response{
Message: "it worked!",
})
})).ServeHTTP(rw, r)
res := rw.Result()
defer res.Body.Close()
require.Equal(t, http.StatusOK, res.StatusCode)
})
t.Run("ValidUpdateLastUsed", func(t *testing.T) {
t.Parallel()
var (
@@ -219,7 +250,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})
@@ -252,7 +283,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})
@@ -285,7 +316,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})
@@ -319,7 +350,7 @@ func TestAPIKey(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})

View File

@@ -94,7 +94,7 @@ func TestExtractUserRoles(t *testing.T) {
req := httptest.NewRequest("GET", "/", nil)
req.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: token,
})

View File

@@ -29,7 +29,7 @@ func TestOrganizationParam(t *testing.T) {
hashed = sha256.Sum256([]byte(secret))
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})

View File

@@ -29,7 +29,7 @@ func TestTemplateParam(t *testing.T) {
)
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})

View File

@@ -29,7 +29,7 @@ func TestTemplateVersionParam(t *testing.T) {
)
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})

View File

@@ -29,7 +29,7 @@ func TestUserParam(t *testing.T) {
rw = httptest.NewRecorder()
)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})

View File

@@ -28,10 +28,10 @@ func WorkspaceAgent(r *http.Request) database.WorkspaceAgent {
func ExtractWorkspaceAgent(db database.Store) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(AuthCookie)
cookie, err := r.Cookie(SessionTokenKey)
if err != nil {
httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{
Message: fmt.Sprintf("%q cookie must be provided", AuthCookie),
Message: fmt.Sprintf("%q cookie must be provided", SessionTokenKey),
})
return
}

View File

@@ -22,7 +22,7 @@ func TestWorkspaceAgent(t *testing.T) {
token := uuid.New()
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: token.String(),
})
return r, token

View File

@@ -29,7 +29,7 @@ func TestWorkspaceAgentParam(t *testing.T) {
)
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})

View File

@@ -29,7 +29,7 @@ func TestWorkspaceBuildParam(t *testing.T) {
)
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})

View File

@@ -29,7 +29,7 @@ func TestWorkspaceParam(t *testing.T) {
)
r := httptest.NewRequest("GET", "/", nil)
r.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: fmt.Sprintf("%s-%s", id, secret),
})

View File

@@ -690,7 +690,7 @@ func (*api) postLogout(rw http.ResponseWriter, _ *http.Request) {
cookie := &http.Cookie{
// MaxAge < 0 means to delete the cookie now
MaxAge: -1,
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Path: "/",
}
@@ -748,7 +748,7 @@ func (api *api) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat
// This format is consumed by the APIKey middleware.
sessionToken := fmt.Sprintf("%s-%s", keyID, keySecret)
http.SetCookie(rw, &http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: sessionToken,
Path: "/",
HttpOnly: true,

View File

@@ -122,7 +122,7 @@ func TestPostLogout(t *testing.T) {
cookies := response.Cookies()
require.Len(t, cookies, 1, "Exactly one cookie should be returned")
require.Equal(t, cookies[0].Name, httpmw.AuthCookie, "Cookie should be the auth cookie")
require.Equal(t, cookies[0].Name, httpmw.SessionTokenKey, "Cookie should be the auth cookie")
require.Equal(t, cookies[0].MaxAge, -1, "Cookie should be set to delete")
})
}

View File

@@ -7,12 +7,17 @@ import (
"errors"
"fmt"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/moby/moby/pkg/namesgenerator"
"golang.org/x/sync/errgroup"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
"cdr.dev/slog"
"github.com/coder/coder/coderd/autobuild/schedule"
"github.com/coder/coder/coderd/database"
@@ -535,6 +540,95 @@ func (api *api) putWorkspaceAutostop(rw http.ResponseWriter, r *http.Request) {
}
}
func (api *api) watchWorkspace(rw http.ResponseWriter, r *http.Request) {
workspace := httpmw.WorkspaceParam(r)
c, err := websocket.Accept(rw, r, &websocket.AcceptOptions{
// Fix for Safari 15.1:
// There is a bug in latest Safari in which compressed web socket traffic
// isn't handled correctly. Turning off compression is a workaround:
// https://github.com/nhooyr/websocket/issues/218
CompressionMode: websocket.CompressionDisabled,
})
if err != nil {
api.Logger.Warn(r.Context(), "accept websocket connection", slog.Error(err))
return
}
defer c.Close(websocket.StatusInternalError, "internal error")
// Makes the websocket connection write-only
ctx := c.CloseRead(r.Context())
// Send a heartbeat every 15 seconds to avoid the websocket being killed.
go func() {
ticker := time.NewTicker(time.Second * 15)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
err := c.Ping(ctx)
if err != nil {
return
}
}
}
}()
t := time.NewTicker(time.Second * 1)
defer t.Stop()
for {
select {
case <-t.C:
workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspace.ID)
if err != nil {
_ = wsjson.Write(ctx, c, httpapi.Response{
Message: fmt.Sprintf("get workspace: %s", err),
})
return
}
build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID)
if err != nil {
_ = wsjson.Write(ctx, c, httpapi.Response{
Message: fmt.Sprintf("get workspace build: %s", err),
})
return
}
var (
group errgroup.Group
job database.ProvisionerJob
template database.Template
owner database.User
)
group.Go(func() (err error) {
job, err = api.Database.GetProvisionerJobByID(r.Context(), build.JobID)
return err
})
group.Go(func() (err error) {
template, err = api.Database.GetTemplateByID(r.Context(), workspace.TemplateID)
return err
})
group.Go(func() (err error) {
owner, err = api.Database.GetUserByID(r.Context(), workspace.OwnerID)
return err
})
err = group.Wait()
if err != nil {
_ = wsjson.Write(ctx, c, httpapi.Response{
Message: fmt.Sprintf("fetch resource: %s", err),
})
return
}
_ = wsjson.Write(ctx, c, convertWorkspace(workspace, convertWorkspaceBuild(build, convertProvisionerJob(job)), template, owner))
case <-ctx.Done():
return
}
}
}
func convertWorkspaces(ctx context.Context, db database.Store, workspaces []database.Workspace) ([]codersdk.Workspace, error) {
workspaceIDs := make([]uuid.UUID, 0, len(workspaces))
templateIDs := make([]uuid.UUID, 0, len(workspaces))

View File

@@ -621,3 +621,27 @@ func mustLocation(t *testing.T, location string) *time.Location {
return loc
}
func TestWorkspaceWatcher(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, nil)
user := coderdtest.CreateFirstUser(t, client)
coderdtest.NewProvisionerDaemon(t, client)
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
w, err := client.Workspace(context.Background(), workspace.ID)
require.NoError(t, err)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
wc, err := client.WatchWorkspace(ctx, w.ID)
require.NoError(t, err)
for i := 0; i < 3; i++ {
_, more := <-wc
require.True(t, more)
}
cancel()
require.EqualValues(t, codersdk.Workspace{}, <-wc)
}

View File

@@ -12,6 +12,7 @@ import (
"strings"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
@@ -63,7 +64,7 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
return nil, xerrors.Errorf("create request: %w", err)
}
req.AddCookie(&http.Cookie{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: c.SessionToken,
})
if body != nil {
@@ -80,6 +81,38 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac
return resp, err
}
// dialWebsocket opens a dialWebsocket connection on that path provided.
// The caller is responsible for closing the dialWebsocket.Conn.
func (c *Client) dialWebsocket(ctx context.Context, path string) (*websocket.Conn, error) {
serverURL, err := c.URL.Parse(path)
if err != nil {
return nil, xerrors.Errorf("parse path: %w", err)
}
apiURL, err := url.Parse(serverURL.String())
if err != nil {
return nil, xerrors.Errorf("parse server url: %w", err)
}
apiURL.Scheme = "ws"
if serverURL.Scheme == "https" {
apiURL.Scheme = "wss"
}
apiURL.Path = path
q := apiURL.Query()
q.Add(httpmw.SessionTokenKey, c.SessionToken)
apiURL.RawQuery = q.Encode()
//nolint:bodyclose
conn, _, err := websocket.Dial(ctx, apiURL.String(), &websocket.DialOptions{
HTTPClient: c.HTTPClient,
})
if err != nil {
return nil, xerrors.Errorf("dial websocket: %w", err)
}
return conn, nil
}
// readBodyAsError reads the response as an httpapi.Message, and
// wraps it in a codersdk.Error type for easy marshaling.
func readBodyAsError(res *http.Response) error {

View File

@@ -188,7 +188,7 @@ func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) (
return agent.Metadata{}, nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: c.SessionToken,
}})
httpClient := &http.Client{
@@ -263,7 +263,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: c.SessionToken,
}})
httpClient := &http.Client{
@@ -351,7 +351,7 @@ func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, rec
return nil, xerrors.Errorf("create cookie jar: %w", err)
}
jar.SetCookies(serverURL, []*http.Cookie{{
Name: httpmw.AuthCookie,
Name: httpmw.SessionTokenKey,
Value: c.SessionToken,
}})
httpClient := &http.Client{

View File

@@ -9,6 +9,8 @@ import (
"github.com/google/uuid"
"golang.org/x/xerrors"
"nhooyr.io/websocket"
"nhooyr.io/websocket/wsjson"
"github.com/coder/coder/coderd/database"
)
@@ -98,6 +100,36 @@ func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID,
return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild)
}
func (c *Client) WatchWorkspace(ctx context.Context, id uuid.UUID) (<-chan Workspace, error) {
conn, err := c.dialWebsocket(ctx, fmt.Sprintf("/api/v2/workspaces/%s/watch", id))
if err != nil {
return nil, err
}
wc := make(chan Workspace, 256)
go func() {
defer close(wc)
defer conn.Close(websocket.StatusNormalClosure, "")
for {
select {
case <-ctx.Done():
return
default:
var ws Workspace
err := wsjson.Read(ctx, conn, &ws)
if err != nil {
conn.Close(websocket.StatusInternalError, "failed to read workspace")
return
}
wc <- ws
}
}
}()
return wc, nil
}
// UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule.
type UpdateWorkspaceAutostartRequest struct {
Schedule string `json:"schedule"`