diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 4512e72f5b735..5ba5460edff93 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -1431,6 +1431,22 @@ func (q *fakeQuerier) GetWorkspaceResourcesByJobID(_ context.Context, jobID uuid return resources, nil } +func (q *fakeQuerier) GetWorkspaceResourcesByJobIDs(_ context.Context, jobIDs []uuid.UUID) ([]database.WorkspaceResource, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + resources := make([]database.WorkspaceResource, 0) + for _, resource := range q.provisionerJobResources { + for _, jobID := range jobIDs { + if resource.JobID != jobID { + continue + } + resources = append(resources, resource) + } + } + return resources, nil +} + func (q *fakeQuerier) GetWorkspaceResourcesCreatedAfter(_ context.Context, after time.Time) ([]database.WorkspaceResource, error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index c254a4ea62947..0129fca8f7a53 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -97,6 +97,7 @@ type querier interface { GetWorkspaceResourceMetadataByResourceIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResourceMetadatum, error) GetWorkspaceResourceMetadataCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResourceMetadatum, error) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uuid.UUID) ([]WorkspaceResource, error) + GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResource, error) GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 88dd2091a7718..1e258e483e65a 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -4528,6 +4528,47 @@ func (q *sqlQuerier) GetWorkspaceResourcesByJobID(ctx context.Context, jobID uui return items, nil } +const getWorkspaceResourcesByJobIDs = `-- name: GetWorkspaceResourcesByJobIDs :many +SELECT + id, created_at, job_id, transition, type, name, hide, icon +FROM + workspace_resources +WHERE + job_id = ANY($1 :: uuid [ ]) +` + +func (q *sqlQuerier) GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResource, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceResourcesByJobIDs, pq.Array(ids)) + if err != nil { + return nil, err + } + defer rows.Close() + var items []WorkspaceResource + for rows.Next() { + var i WorkspaceResource + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.JobID, + &i.Transition, + &i.Type, + &i.Name, + &i.Hide, + &i.Icon, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspaceResourcesCreatedAfter = `-- name: GetWorkspaceResourcesCreatedAfter :many SELECT id, created_at, job_id, transition, type, name, hide, icon FROM workspace_resources WHERE created_at > $1 ` diff --git a/coderd/database/queries/workspaceresources.sql b/coderd/database/queries/workspaceresources.sql index 8cb2219a7c42f..373090fbdeb9c 100644 --- a/coderd/database/queries/workspaceresources.sql +++ b/coderd/database/queries/workspaceresources.sql @@ -14,6 +14,14 @@ FROM WHERE job_id = $1; +-- name: GetWorkspaceResourcesByJobIDs :many +SELECT + * +FROM + workspace_resources +WHERE + job_id = ANY(@ids :: uuid [ ]); + -- name: GetWorkspaceResourcesCreatedAfter :many SELECT * FROM workspace_resources WHERE created_at > $1; diff --git a/coderd/httpapi/httpapi.go b/coderd/httpapi/httpapi.go index 5393a79bfc06c..79303adb5ea02 100644 --- a/coderd/httpapi/httpapi.go +++ b/coderd/httpapi/httpapi.go @@ -2,12 +2,16 @@ package httpapi import ( "bytes" + "context" "encoding/json" "errors" "fmt" + "io" "net/http" "reflect" "strings" + "sync" + "time" "github.com/go-playground/validator/v10" @@ -144,3 +148,75 @@ func WebsocketCloseSprintf(format string, vars ...any) string { return msg } + +func ServerSentEventSender(rw http.ResponseWriter, r *http.Request) (func(ctx context.Context, sse codersdk.ServerSentEvent) error, error) { + var mu sync.Mutex + h := rw.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Cache-Control", "no-cache") + h.Set("Connection", "keep-alive") + h.Set("X-Accel-Buffering", "no") + + f, ok := rw.(http.Flusher) + if !ok { + panic("http.ResponseWriter is not http.Flusher") + } + + // Send a heartbeat every 15 seconds to avoid the connection being killed. + go func() { + ticker := time.NewTicker(time.Second * 15) + defer ticker.Stop() + + for { + select { + case <-r.Context().Done(): + return + case <-ticker.C: + mu.Lock() + _, err := io.WriteString(rw, fmt.Sprintf("event: %s\n\n", codersdk.ServerSentEventTypePing)) + if err != nil { + mu.Unlock() + return + } + f.Flush() + mu.Unlock() + } + } + }() + + sendEvent := func(ctx context.Context, sse codersdk.ServerSentEvent) error { + if ctx.Err() != nil { + return ctx.Err() + } + + buf := &bytes.Buffer{} + enc := json.NewEncoder(buf) + + _, err := buf.Write([]byte(fmt.Sprintf("event: %s\ndata: ", sse.Type))) + if err != nil { + return err + } + + err = enc.Encode(sse.Data) + if err != nil { + return err + } + + err = buf.WriteByte('\n') + if err != nil { + return err + } + + mu.Lock() + defer mu.Unlock() + _, err = rw.Write(buf.Bytes()) + if err != nil { + return err + } + f.Flush() + + return nil + } + + return sendEvent, nil +} diff --git a/coderd/tracing/status_writer.go b/coderd/tracing/status_writer.go index 3caa6b9cb6263..18a362176c69e 100644 --- a/coderd/tracing/status_writer.go +++ b/coderd/tracing/status_writer.go @@ -72,3 +72,11 @@ func (w *StatusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) { func (w *StatusWriter) ResponseBody() []byte { return w.responseBody } + +func (w *StatusWriter) Flush() { + f, ok := w.ResponseWriter.(http.Flusher) + if !ok { + panic("http.ResponseWriter is not http.Flusher") + } + f.Flush() +} diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 7133e5f779779..aa73a64e0e63a 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -1,6 +1,7 @@ package coderd import ( + "context" "database/sql" "encoding/json" "errors" @@ -29,29 +30,34 @@ func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { return } - job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID) + data, err := api.workspaceBuildsData(r.Context(), []database.Workspace{workspace}, []database.WorkspaceBuild{workspaceBuild}) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching provisioner job.", + Message: "Internal error getting workspace build data.", Detail: err.Error(), }) return } - users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{ - IDs: []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID}, - }) + apiBuild, err := api.convertWorkspaceBuild( + workspaceBuild, + workspace, + data.jobs[0], + data.users, + data.resources, + data.metadata, + data.agents, + data.apps, + ) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching user.", + Message: "Internal error converting workspace build.", Detail: err.Error(), }) return } - httpapi.Write(rw, http.StatusOK, - convertWorkspaceBuild(findUser(workspace.OwnerID, users), findUser(workspaceBuild.InitiatorID, users), - workspace, workspaceBuild, job)) + httpapi.Write(rw, http.StatusOK, apiBuild) } func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { @@ -67,7 +73,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } - var builds []database.WorkspaceBuild + var workspaceBuilds []database.WorkspaceBuild // Ensure all db calls happen in the same tx err := api.Database.InTx(func(store database.Store) error { var err error @@ -95,7 +101,7 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { OffsetOpt: int32(paginationParams.Offset), LimitOpt: int32(paginationParams.Limit), } - builds, err = store.GetWorkspaceBuildByWorkspaceID(r.Context(), req) + workspaceBuilds, err = store.GetWorkspaceBuildByWorkspaceID(r.Context(), req) if xerrors.Is(err, sql.ErrNoRows) { err = nil } @@ -113,55 +119,33 @@ func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } - jobIDs := make([]uuid.UUID, 0, len(builds)) - for _, build := range builds { - jobIDs = append(jobIDs, build.JobID) - } - jobs, err := api.Database.GetProvisionerJobsByIDs(r.Context(), jobIDs) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } + data, err := api.workspaceBuildsData(r.Context(), []database.Workspace{workspace}, workspaceBuilds) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching provisioner jobs.", + Message: "Internal error getting workspace build data.", Detail: err.Error(), }) return } - jobByID := map[string]database.ProvisionerJob{} - for _, job := range jobs { - jobByID[job.ID.String()] = job - } - userIDs := []uuid.UUID{workspace.OwnerID} - for _, build := range builds { - userIDs = append(userIDs, build.InitiatorID) - } - users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{ - IDs: userIDs, - }) + apiBuilds, err := api.convertWorkspaceBuilds( + workspaceBuilds, + []database.Workspace{workspace}, + data.jobs, + data.users, + data.resources, + data.metadata, + data.agents, + data.apps, + ) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching user.", + Message: "Internal error converting workspace build.", Detail: err.Error(), }) return } - apiBuilds := make([]codersdk.WorkspaceBuild, 0) - for _, build := range builds { - job, exists := jobByID[build.JobID.String()] - if !exists { - httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: fmt.Sprintf("Job %q doesn't exist for build %q.", build.JobID, build.ID), - }) - return - } - apiBuilds = append(apiBuilds, - convertWorkspaceBuild(findUser(workspace.OwnerID, users), findUser(build.InitiatorID, users), - workspace, build, job)) - } - httpapi.Write(rw, http.StatusOK, apiBuilds) } @@ -216,29 +200,34 @@ func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Requ return } - job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID) + data, err := api.workspaceBuildsData(r.Context(), []database.Workspace{workspace}, []database.WorkspaceBuild{workspaceBuild}) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching provisioner job.", + Message: "Internal error getting workspace build data.", Detail: err.Error(), }) return } - users, err := api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{ - IDs: []uuid.UUID{workspace.OwnerID, workspaceBuild.InitiatorID}, - }) + apiBuild, err := api.convertWorkspaceBuild( + workspaceBuild, + workspace, + data.jobs[0], + data.users, + data.resources, + data.metadata, + data.agents, + data.apps, + ) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching user.", + Message: "Internal error converting workspace build.", Detail: err.Error(), }) return } - httpapi.Write(rw, http.StatusOK, - convertWorkspaceBuild(findUser(workspace.OwnerID, users), findUser(workspaceBuild.InitiatorID, users), - workspace, workspaceBuild, job)) + httpapi.Write(rw, http.StatusOK, apiBuild) } func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { @@ -496,9 +485,25 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } - httpapi.Write(rw, http.StatusCreated, - convertWorkspaceBuild(findUser(workspace.OwnerID, users), findUser(workspaceBuild.InitiatorID, users), - workspace, workspaceBuild, provisionerJob)) + apiBuild, err := api.convertWorkspaceBuild( + workspaceBuild, + workspace, + provisionerJob, + users, + []database.WorkspaceResource{}, + []database.WorkspaceResourceMetadatum{}, + []database.WorkspaceAgent{}, + []database.WorkspaceApp{}, + ) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error converting workspace build.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(rw, http.StatusCreated, apiBuild) } func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Request) { @@ -627,47 +632,220 @@ func (api *API) workspaceBuildState(rw http.ResponseWriter, r *http.Request) { _, _ = rw.Write(workspaceBuild.ProvisionerState) } -func convertWorkspaceBuild( - workspaceOwner *database.User, - buildInitiator *database.User, - workspace database.Workspace, - workspaceBuild database.WorkspaceBuild, - job database.ProvisionerJob, -) codersdk.WorkspaceBuild { - //nolint:unconvert - if workspace.ID != workspaceBuild.WorkspaceID { - panic("workspace and build do not match") +type workspaceBuildsData struct { + users []database.User + jobs []database.ProvisionerJob + resources []database.WorkspaceResource + metadata []database.WorkspaceResourceMetadatum + agents []database.WorkspaceAgent + apps []database.WorkspaceApp +} + +func (api *API) workspaceBuildsData(ctx context.Context, workspaces []database.Workspace, workspaceBuilds []database.WorkspaceBuild) (workspaceBuildsData, error) { + userIDs := make([]uuid.UUID, 0, len(workspaceBuilds)) + for _, build := range workspaceBuilds { + userIDs = append(userIDs, build.InitiatorID) + } + for _, workspace := range workspaces { + userIDs = append(userIDs, workspace.OwnerID) + } + users, err := api.Database.GetUsersByIDs(ctx, database.GetUsersByIDsParams{ + IDs: userIDs, + }) + if err != nil { + return workspaceBuildsData{}, xerrors.Errorf("get users: %w", err) + } + + jobIDs := make([]uuid.UUID, 0, len(workspaceBuilds)) + for _, build := range workspaceBuilds { + jobIDs = append(jobIDs, build.JobID) + } + jobs, err := api.Database.GetProvisionerJobsByIDs(ctx, jobIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceBuildsData{}, xerrors.Errorf("get provisioner jobs: %w", err) + } + + resources, err := api.Database.GetWorkspaceResourcesByJobIDs(ctx, jobIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceBuildsData{}, xerrors.Errorf("get workspace resources by job: %w", err) + } + + if len(resources) == 0 { + return workspaceBuildsData{ + users: users, + jobs: jobs, + }, nil + } + + resourceIDs := make([]uuid.UUID, 0) + for _, resource := range resources { + resourceIDs = append(resourceIDs, resource.ID) + } + + metadata, err := api.Database.GetWorkspaceResourceMetadataByResourceIDs(ctx, resourceIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceBuildsData{}, xerrors.Errorf("fetching resource metadata: %w", err) + } + + agents, err := api.Database.GetWorkspaceAgentsByResourceIDs(ctx, resourceIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceBuildsData{}, xerrors.Errorf("get workspace agents: %w", err) + } + + if len(resources) == 0 { + return workspaceBuildsData{ + users: users, + jobs: jobs, + resources: resources, + metadata: metadata, + }, nil + } + + agentIDs := make([]uuid.UUID, 0) + for _, agent := range agents { + agentIDs = append(agentIDs, agent.ID) + } + + apps, err := api.Database.GetWorkspaceAppsByAgentIDs(ctx, agentIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceBuildsData{}, xerrors.Errorf("fetching workspace apps: %w", err) } - // Both owner and initiator should always be present. But from a static - // code analysis POV, these could be nil. - ownerName := "unknown" - if workspaceOwner != nil { - ownerName = workspaceOwner.Username + return workspaceBuildsData{ + users: users, + jobs: jobs, + resources: resources, + metadata: metadata, + agents: agents, + apps: apps, + }, nil +} + +func (api *API) convertWorkspaceBuilds( + workspaceBuilds []database.WorkspaceBuild, + workspaces []database.Workspace, + jobs []database.ProvisionerJob, + users []database.User, + workspaceResources []database.WorkspaceResource, + resourceMetadata []database.WorkspaceResourceMetadatum, + resourceAgents []database.WorkspaceAgent, + agentApps []database.WorkspaceApp, +) ([]codersdk.WorkspaceBuild, error) { + workspaceByID := map[uuid.UUID]database.Workspace{} + for _, workspace := range workspaces { + workspaceByID[workspace.ID] = workspace + } + jobByID := map[uuid.UUID]database.ProvisionerJob{} + for _, job := range jobs { + jobByID[job.ID] = job } - initiatorName := "unknown" - if workspaceOwner != nil { - initiatorName = buildInitiator.Username + var apiBuilds []codersdk.WorkspaceBuild + for _, build := range workspaceBuilds { + job, exists := jobByID[build.JobID] + if !exists { + return nil, xerrors.New("build job not found") + } + workspace, exists := workspaceByID[build.WorkspaceID] + if !exists { + return nil, xerrors.New("workspace not found") + } + + apiBuild, err := api.convertWorkspaceBuild( + build, + workspace, + job, + users, + workspaceResources, + resourceMetadata, + resourceAgents, + agentApps, + ) + if err != nil { + return nil, xerrors.Errorf("converting workspace build: %w", err) + } + + apiBuilds = append(apiBuilds, apiBuild) + } + + return apiBuilds, nil +} + +func (api *API) convertWorkspaceBuild( + build database.WorkspaceBuild, + workspace database.Workspace, + job database.ProvisionerJob, + users []database.User, + workspaceResources []database.WorkspaceResource, + resourceMetadata []database.WorkspaceResourceMetadatum, + resourceAgents []database.WorkspaceAgent, + agentApps []database.WorkspaceApp, +) (codersdk.WorkspaceBuild, error) { + userByID := map[uuid.UUID]database.User{} + for _, user := range users { + userByID[user.ID] = user + } + resourcesByJobID := map[uuid.UUID][]database.WorkspaceResource{} + for _, resource := range workspaceResources { + resourcesByJobID[resource.JobID] = append(resourcesByJobID[resource.JobID], resource) + } + metadataByResourceID := map[uuid.UUID][]database.WorkspaceResourceMetadatum{} + for _, metadata := range resourceMetadata { + metadataByResourceID[metadata.WorkspaceResourceID] = append(metadataByResourceID[metadata.WorkspaceResourceID], metadata) + } + agentsByResourceID := map[uuid.UUID][]database.WorkspaceAgent{} + for _, agent := range resourceAgents { + agentsByResourceID[agent.ResourceID] = append(agentsByResourceID[agent.ResourceID], agent) + } + appsByAgentID := map[uuid.UUID][]database.WorkspaceApp{} + for _, app := range agentApps { + appsByAgentID[app.AgentID] = append(appsByAgentID[app.AgentID], app) + } + + owner, exists := userByID[workspace.OwnerID] + if !exists { + return codersdk.WorkspaceBuild{}, xerrors.Errorf("owner not found for workspace: %q", workspace.Name) + } + initiator, exists := userByID[build.InitiatorID] + if !exists { + return codersdk.WorkspaceBuild{}, xerrors.Errorf("build initiator not found for workspace: %q", workspace.Name) + } + + resources := resourcesByJobID[job.ID] + apiResources := make([]codersdk.WorkspaceResource, 0) + for _, resource := range resources { + agents := agentsByResourceID[resource.ID] + apiAgents := make([]codersdk.WorkspaceAgent, 0) + for _, agent := range agents { + apps := appsByAgentID[agent.ID] + apiAgent, err := convertWorkspaceAgent(api.DERPMap, api.TailnetCoordinator, agent, convertApps(apps), api.AgentInactiveDisconnectTimeout) + if err != nil { + return codersdk.WorkspaceBuild{}, xerrors.Errorf("converting workspace agent: %w", err) + } + apiAgents = append(apiAgents, apiAgent) + } + metadata := append(make([]database.WorkspaceResourceMetadatum, 0), metadataByResourceID[resource.ID]...) + apiResources = append(apiResources, convertWorkspaceResource(resource, apiAgents, metadata)) } return codersdk.WorkspaceBuild{ - ID: workspaceBuild.ID, - CreatedAt: workspaceBuild.CreatedAt, - UpdatedAt: workspaceBuild.UpdatedAt, + ID: build.ID, + CreatedAt: build.CreatedAt, + UpdatedAt: build.UpdatedAt, WorkspaceOwnerID: workspace.OwnerID, - WorkspaceOwnerName: ownerName, - WorkspaceID: workspaceBuild.WorkspaceID, + WorkspaceOwnerName: owner.Username, + WorkspaceID: build.WorkspaceID, WorkspaceName: workspace.Name, - TemplateVersionID: workspaceBuild.TemplateVersionID, - BuildNumber: workspaceBuild.BuildNumber, - Transition: codersdk.WorkspaceTransition(workspaceBuild.Transition), - InitiatorID: workspaceBuild.InitiatorID, - InitiatorUsername: initiatorName, + TemplateVersionID: build.TemplateVersionID, + BuildNumber: build.BuildNumber, + Transition: codersdk.WorkspaceTransition(build.Transition), + InitiatorID: build.InitiatorID, + InitiatorUsername: initiator.Username, Job: convertProvisionerJob(job), - Deadline: codersdk.NewNullTime(workspaceBuild.Deadline, !workspaceBuild.Deadline.IsZero()), - Reason: codersdk.BuildReason(workspaceBuild.Reason), - } + Deadline: codersdk.NewNullTime(build.Deadline, !build.Deadline.IsZero()), + Reason: codersdk.BuildReason(build.Reason), + Resources: apiResources, + }, nil } func convertWorkspaceResource(resource database.WorkspaceResource, agents []codersdk.WorkspaceAgent, metadata []database.WorkspaceResourceMetadatum) codersdk.WorkspaceResource { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 81b3038a742c5..9631499c29089 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -15,10 +15,7 @@ import ( "github.com/go-chi/chi/v5" "github.com/google/uuid" - "golang.org/x/sync/errgroup" "golang.org/x/xerrors" - "nhooyr.io/websocket" - "nhooyr.io/websocket/wsjson" "cdr.dev/slog" @@ -75,45 +72,21 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { return } - build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) + data, err := api.workspaceData(r.Context(), []database.Workspace{workspace}) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace build.", - Detail: err.Error(), - }) - return - } - var ( - group errgroup.Group - job database.ProvisionerJob - template database.Template - users []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) { - users, err = api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{ - IDs: []uuid.UUID{workspace.OwnerID, build.InitiatorID}, - }) - return err - }) - err = group.Wait() - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching resource.", + Message: "Internal error fetching workspace resources.", Detail: err.Error(), }) return } - httpapi.Write(rw, http.StatusOK, convertWorkspace(workspace, build, job, template, - findUser(workspace.OwnerID, users), findUser(build.InitiatorID, users))) + httpapi.Write(rw, http.StatusOK, convertWorkspace( + workspace, + data.builds[0], + data.templates[0], + findUser(workspace.OwnerID, data.users), + )) } // workspaces returns all workspaces a user can read. @@ -155,15 +128,25 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { return } - apiWorkspaces, err := convertWorkspaces(r.Context(), api.Database, workspaces) + data, err := api.workspaceData(r.Context(), workspaces) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error reading workspace.", + Message: "Internal error fetching workspace resources.", Detail: err.Error(), }) return } - httpapi.Write(rw, http.StatusOK, apiWorkspaces) + + wss, err := convertWorkspaces(workspaces, data) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error converting workspaces.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(rw, http.StatusOK, wss) } func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) { @@ -212,41 +195,21 @@ func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) return } - build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) + data, err := api.workspaceData(r.Context(), []database.Workspace{workspace}) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching workspace build.", - Detail: err.Error(), - }) - return - } - job, err := api.Database.GetProvisionerJobByID(r.Context(), build.JobID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching provisioner job.", - Detail: err.Error(), - }) - return - } - template, err := api.Database.GetTemplateByID(r.Context(), workspace.TemplateID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching template.", - Detail: err.Error(), - }) - return - } - - initiator, err := api.Database.GetUserByID(r.Context(), build.InitiatorID) - if err != nil { - httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Internal error fetching template.", + Message: "Internal error fetching workspace resources.", Detail: err.Error(), }) return } - httpapi.Write(rw, http.StatusOK, convertWorkspace(workspace, build, job, template, &owner, &initiator)) + httpapi.Write(rw, http.StatusOK, convertWorkspace( + workspace, + data.builds[0], + data.templates[0], + findUser(workspace.OwnerID, data.users), + )) } // Create a new workspace for the currently authenticated user. @@ -488,8 +451,30 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req WorkspaceBuilds: []telemetry.WorkspaceBuild{telemetry.ConvertWorkspaceBuild(workspaceBuild)}, }) - httpapi.Write(rw, http.StatusCreated, convertWorkspace(workspace, workspaceBuild, templateVersionJob, template, - findUser(apiKey.UserID, users), findUser(workspaceBuild.InitiatorID, users))) + apiBuild, err := api.convertWorkspaceBuild( + workspaceBuild, + workspace, + provisionerJob, + users, + []database.WorkspaceResource{}, + []database.WorkspaceResourceMetadatum{}, + []database.WorkspaceAgent{}, + []database.WorkspaceApp{}, + ) + if err != nil { + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error converting workspace build.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(rw, http.StatusCreated, convertWorkspace( + workspace, + apiBuild, + template, + findUser(apiKey.UserID, users), + )) } func (api *API) patchWorkspace(rw http.ResponseWriter, r *http.Request) { @@ -790,174 +775,125 @@ func (api *API) watchWorkspace(rw http.ResponseWriter, r *http.Request) { return } - 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, - }) + sendEvent, err := httpapi.ServerSentEventSender(rw, r) if err != nil { - api.Logger.Warn(r.Context(), "accept websocket connection", slog.Error(err)) + httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error setting up server-sent events.", + Detail: err.Error(), + }) 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 <-r.Context().Done(): + return case <-t.C: workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspace.ID) if err != nil { - _ = wsjson.Write(ctx, c, codersdk.Response{ - Message: "Internal error fetching workspace.", - Detail: err.Error(), - }) - return - } - build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) - if err != nil { - _ = wsjson.Write(ctx, c, codersdk.Response{ - Message: "Internal error fetching workspace build.", - Detail: err.Error(), + _ = sendEvent(r.Context(), codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeError, + Data: codersdk.Response{ + Message: "Internal error fetching workspace.", + Detail: err.Error(), + }, }) return } - var ( - group errgroup.Group - job database.ProvisionerJob - template database.Template - users []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) { - users, err = api.Database.GetUsersByIDs(r.Context(), database.GetUsersByIDsParams{ - IDs: []uuid.UUID{workspace.OwnerID, build.InitiatorID}, - }) - return err - }) - err = group.Wait() + + data, err := api.workspaceData(r.Context(), []database.Workspace{workspace}) if err != nil { - _ = wsjson.Write(ctx, c, codersdk.Response{ - Message: "Internal error fetching resource.", - Detail: err.Error(), + _ = sendEvent(r.Context(), codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeError, + Data: codersdk.Response{ + Message: "Internal error fetching workspace data.", + Detail: err.Error(), + }, }) return } - _ = wsjson.Write(ctx, c, convertWorkspace(workspace, build, job, template, - findUser(workspace.OwnerID, users), findUser(build.InitiatorID, users))) - case <-ctx.Done(): - return + _ = sendEvent(r.Context(), codersdk.ServerSentEvent{ + Type: codersdk.ServerSentEventTypeData, + Data: convertWorkspace( + workspace, + data.builds[0], + data.templates[0], + findUser(workspace.OwnerID, data.users), + ), + }) } } } -func convertWorkspaces(ctx context.Context, db database.Store, workspaces []database.Workspace) ([]codersdk.Workspace, error) { +type workspaceData struct { + templates []database.Template + builds []codersdk.WorkspaceBuild + users []database.User +} + +func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspace) (workspaceData, error) { workspaceIDs := make([]uuid.UUID, 0, len(workspaces)) templateIDs := make([]uuid.UUID, 0, len(workspaces)) - userIDs := make([]uuid.UUID, 0, len(workspaces)) for _, workspace := range workspaces { workspaceIDs = append(workspaceIDs, workspace.ID) templateIDs = append(templateIDs, workspace.TemplateID) - userIDs = append(userIDs, workspace.OwnerID) - } - workspaceBuilds, err := db.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, workspaceIDs) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } - for _, build := range workspaceBuilds { - userIDs = append(userIDs, build.InitiatorID) } - if err != nil { - return nil, xerrors.Errorf("get workspace builds: %w", err) - } - templates, err := db.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ + templates, err := api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{ IDs: templateIDs, }) - if errors.Is(err, sql.ErrNoRows) { - err = nil + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceData{}, xerrors.Errorf("get templates: %w", err) } - if err != nil { - return nil, xerrors.Errorf("get templates: %w", err) + + builds, err := api.Database.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, workspaceIDs) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return workspaceData{}, xerrors.Errorf("get workspace builds: %w", err) } - users, err := db.GetUsersByIDs(ctx, database.GetUsersByIDsParams{ - IDs: userIDs, - }) + + data, err := api.workspaceBuildsData(ctx, workspaces, builds) if err != nil { - return nil, xerrors.Errorf("get users: %w", err) - } - jobIDs := make([]uuid.UUID, 0, len(workspaceBuilds)) - for _, build := range workspaceBuilds { - jobIDs = append(jobIDs, build.JobID) - } - jobs, err := db.GetProvisionerJobsByIDs(ctx, jobIDs) - if errors.Is(err, sql.ErrNoRows) { - err = nil - } + return workspaceData{}, xerrors.Errorf("get workspace builds data: %w", err) + } + + apiBuilds, err := api.convertWorkspaceBuilds( + builds, + workspaces, + data.jobs, + data.users, + data.resources, + data.metadata, + data.agents, + data.apps, + ) if err != nil { - return nil, xerrors.Errorf("get provisioner jobs: %w", err) - } - - buildByWorkspaceID := map[uuid.UUID]database.WorkspaceBuild{} - for _, workspaceBuild := range workspaceBuilds { - buildByWorkspaceID[workspaceBuild.WorkspaceID] = database.WorkspaceBuild{ - ID: workspaceBuild.ID, - CreatedAt: workspaceBuild.CreatedAt, - UpdatedAt: workspaceBuild.UpdatedAt, - WorkspaceID: workspaceBuild.WorkspaceID, - TemplateVersionID: workspaceBuild.TemplateVersionID, - BuildNumber: workspaceBuild.BuildNumber, - Transition: workspaceBuild.Transition, - InitiatorID: workspaceBuild.InitiatorID, - ProvisionerState: workspaceBuild.ProvisionerState, - JobID: workspaceBuild.JobID, - Deadline: workspaceBuild.Deadline, - Reason: workspaceBuild.Reason, - } + return workspaceData{}, xerrors.Errorf("convert workspace builds: %w", err) + } + + return workspaceData{ + templates: templates, + builds: apiBuilds, + users: data.users, + }, nil +} + +func convertWorkspaces(workspaces []database.Workspace, data workspaceData) ([]codersdk.Workspace, error) { + buildByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceBuild{} + for _, workspaceBuild := range data.builds { + buildByWorkspaceID[workspaceBuild.WorkspaceID] = workspaceBuild } templateByID := map[uuid.UUID]database.Template{} - for _, template := range templates { + for _, template := range data.templates { templateByID[template.ID] = template } userByID := map[uuid.UUID]database.User{} - for _, user := range users { + for _, user := range data.users { userByID[user.ID] = user } - jobByID := map[uuid.UUID]database.ProvisionerJob{} - for _, job := range jobs { - jobByID[job.ID] = job - } + apiWorkspaces := make([]codersdk.Workspace, 0, len(workspaces)) for _, workspace := range workspaces { build, exists := buildByWorkspaceID[workspace.ID] @@ -968,19 +904,17 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data if !exists { return nil, xerrors.Errorf("template not found for workspace %q", workspace.Name) } - job, exists := jobByID[build.JobID] - if !exists { - return nil, xerrors.Errorf("build job not found for workspace: %w", err) - } owner, exists := userByID[workspace.OwnerID] if !exists { return nil, xerrors.Errorf("owner not found for workspace: %q", workspace.Name) } - initiator, exists := userByID[build.InitiatorID] - if !exists { - return nil, xerrors.Errorf("build initiator not found for workspace: %q", workspace.Name) - } - apiWorkspaces = append(apiWorkspaces, convertWorkspace(workspace, build, job, template, &owner, &initiator)) + + apiWorkspaces = append(apiWorkspaces, convertWorkspace( + workspace, + build, + template, + &owner, + )) } sort.Slice(apiWorkspaces, func(i, j int) bool { iw := apiWorkspaces[i] @@ -996,11 +930,9 @@ func convertWorkspaces(ctx context.Context, db database.Store, workspaces []data func convertWorkspace( workspace database.Workspace, - workspaceBuild database.WorkspaceBuild, - job database.ProvisionerJob, + workspaceBuild codersdk.WorkspaceBuild, template database.Template, owner *database.User, - initiator *database.User, ) codersdk.Workspace { var autostartSchedule *string if workspace.AutostartSchedule.Valid { @@ -1015,7 +947,7 @@ func convertWorkspace( OwnerID: workspace.OwnerID, OwnerName: owner.Username, TemplateID: workspace.TemplateID, - LatestBuild: convertWorkspaceBuild(owner, initiator, workspace, workspaceBuild, job), + LatestBuild: workspaceBuild, TemplateName: template.Name, TemplateIcon: template.Icon, Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(), diff --git a/codersdk/client.go b/codersdk/client.go index 6fa34e91b3e06..fd788d1d9f089 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -12,7 +12,6 @@ import ( "strings" "golang.org/x/xerrors" - "nhooyr.io/websocket" ) // These cookies are Coder-specific. If a new one is added or changed, the name @@ -95,41 +94,10 @@ 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(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 .Message, and // wraps it in a codersdk.Error type for easy marshaling. func readBodyAsError(res *http.Response) error { + defer res.Body.Close() contentType := res.Header.Get("Content-Type") var method, u string diff --git a/codersdk/sse.go b/codersdk/sse.go new file mode 100644 index 0000000000000..39aaf71decb58 --- /dev/null +++ b/codersdk/sse.go @@ -0,0 +1,89 @@ +package codersdk + +import ( + "bufio" + "fmt" + "io" + "strings" + + "golang.org/x/xerrors" +) + +type ServerSentEvent struct { + Type ServerSentEventType `json:"type"` + Data interface{} `json:"data"` +} + +type ServerSentEventType string + +const ( + ServerSentEventTypePing ServerSentEventType = "ping" + ServerSentEventTypeData ServerSentEventType = "data" + ServerSentEventTypeError ServerSentEventType = "error" +) + +func ServerSentEventReader(rc io.ReadCloser) func() (*ServerSentEvent, error) { + reader := bufio.NewReader(rc) + nextLineValue := func(prefix string) ([]byte, error) { + var ( + line string + err error + ) + for { + line, err = reader.ReadString('\n') + if err != nil { + return nil, xerrors.Errorf("reading next string: %w", err) + } + if strings.TrimSpace(line) != "" { + break + } + } + + if !strings.HasPrefix(line, fmt.Sprintf("%s: ", prefix)) { + return nil, xerrors.Errorf("expecting %s prefix, got: %s", prefix, line) + } + s := strings.TrimPrefix(line, fmt.Sprintf("%s: ", prefix)) + s = strings.TrimSpace(s) + return []byte(s), nil + } + + nextEvent := func() (*ServerSentEvent, error) { + for { + t, err := nextLineValue("event") + if err != nil { + return nil, xerrors.Errorf("reading next line value: %w", err) + } + + switch ServerSentEventType(t) { + case ServerSentEventTypePing: + return &ServerSentEvent{ + Type: ServerSentEventTypePing, + }, nil + case ServerSentEventTypeData: + d, err := nextLineValue("data") + if err != nil { + return nil, xerrors.Errorf("reading next line value: %w", err) + } + + return &ServerSentEvent{ + Type: ServerSentEventTypeData, + Data: d, + }, nil + case ServerSentEventTypeError: + d, err := nextLineValue("data") + if err != nil { + return nil, xerrors.Errorf("reading next line value: %w", err) + } + + return &ServerSentEvent{ + Type: ServerSentEventTypeError, + Data: d, + }, nil + default: + return nil, xerrors.Errorf("unknown event type: %s", t) + } + } + } + + return nextEvent +} diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index 8020926834c9d..1dc75e9239849 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -49,8 +49,9 @@ type WorkspaceBuild struct { InitiatorID uuid.UUID `json:"initiator_id"` InitiatorUsername string `json:"initiator_name"` Job ProvisionerJob `json:"job"` - Deadline NullTime `json:"deadline,omitempty"` Reason BuildReason `db:"reason" json:"reason"` + Resources []WorkspaceResource `json:"resources"` + Deadline NullTime `json:"deadline,omitempty"` } // WorkspaceBuild returns a single workspace build for a workspace. diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 53247b5b86ed1..8933908104bbb 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -10,8 +10,6 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" - "nhooyr.io/websocket" - "nhooyr.io/websocket/wsjson" ) // Workspace is a deployment of a template. It references a specific @@ -123,28 +121,42 @@ func (c *Client) CreateWorkspaceBuild(ctx context.Context, workspace uuid.UUID, } 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)) + //nolint:bodyclose + res, err := c.Request(ctx, http.MethodGet, fmt.Sprintf("/api/v2/workspaces/%s/watch", id), nil) if err != nil { return nil, err } - wc := make(chan Workspace, 256) + if res.StatusCode != http.StatusOK { + return nil, readBodyAsError(res) + } + nextEvent := ServerSentEventReader(res.Body) + wc := make(chan Workspace, 256) go func() { defer close(wc) - defer conn.Close(websocket.StatusNormalClosure, "") + defer res.Body.Close() for { select { case <-ctx.Done(): return default: - var ws Workspace - err := wsjson.Read(ctx, conn, &ws) + sse, err := nextEvent() if err != nil { - conn.Close(websocket.StatusInternalError, "failed to read workspace") return } - wc <- ws + if sse.Type == ServerSentEventTypeData { + var ws Workspace + b, ok := sse.Data.([]byte) + if !ok { + return + } + err = json.Unmarshal(b, &ws) + if err != nil { + return + } + wc <- ws + } } } }() diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index c5a90816563a4..8448099ea8678 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -368,6 +368,13 @@ export interface Role { readonly display_name: string } +// From codersdk/sse.go +export interface ServerSentEvent { + readonly type: ServerSentEventType + // eslint-disable-next-line + readonly data: any +} + // From codersdk/templates.go export interface Template { readonly id: string @@ -609,8 +616,9 @@ export interface WorkspaceBuild { readonly initiator_id: string readonly initiator_name: string readonly job: ProvisionerJob - readonly deadline?: string readonly reason: BuildReason + readonly resources: WorkspaceResource[] + readonly deadline?: string } // From codersdk/workspaces.go @@ -704,6 +712,9 @@ export type ResourceType = | "user" | "workspace" +// From codersdk/sse.go +export type ServerSentEventType = "data" | "error" | "ping" + // From codersdk/users.go export type UserStatus = "active" | "suspended" diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 0192196a96e2e..598d37753a71f 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -211,6 +211,7 @@ export const MockWorkspaceBuild: TypesGen.WorkspaceBuild = { workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3", deadline: "2022-05-17T23:39:00.00Z", reason: "initiator", + resources: [], } export const MockFailedWorkspaceBuild = ( @@ -231,6 +232,7 @@ export const MockFailedWorkspaceBuild = ( workspace_id: "759f1d46-3174-453d-aa60-980a9c1442f3", deadline: "2022-05-17T23:39:00.00Z", reason: "initiator", + resources: [], }) export const MockWorkspaceBuildStop: TypesGen.WorkspaceBuild = {