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)
}