mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
chore: Add watch workspace endpoint (#1493)
This commit is contained in:
@ -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) {
|
||||
|
@ -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},
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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),
|
||||
})
|
||||
|
||||
|
@ -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,
|
||||
})
|
||||
|
||||
|
@ -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),
|
||||
})
|
||||
|
||||
|
@ -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),
|
||||
})
|
||||
|
||||
|
@ -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),
|
||||
})
|
||||
|
||||
|
@ -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),
|
||||
})
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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),
|
||||
})
|
||||
|
||||
|
@ -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),
|
||||
})
|
||||
|
||||
|
@ -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),
|
||||
})
|
||||
|
||||
|
@ -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,
|
||||
|
@ -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")
|
||||
})
|
||||
}
|
||||
|
@ -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))
|
||||
|
@ -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)
|
||||
}
|
||||
|
Reference in New Issue
Block a user