mirror of https://github.com/coder/coder.git
chore: watch workspace endpoint (#4060)
This commit is contained in:
parent
b340634aaa
commit
63fd4945a2
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
`
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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,53 +119,31 @@ 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.",
|
||||
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,
|
||||
})
|
||||
if err != nil {
|
||||
httpapi.Write(rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Internal error fetching user.",
|
||||
Message: "Internal error getting workspace build data.",
|
||||
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))
|
||||
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 converting workspace build.",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
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,
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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,
|
||||
workspaceBuild database.WorkspaceBuild,
|
||||
job database.ProvisionerJob,
|
||||
) codersdk.WorkspaceBuild {
|
||||
//nolint:unconvert
|
||||
if workspace.ID != workspaceBuild.WorkspaceID {
|
||||
panic("workspace and build do not match")
|
||||
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)
|
||||
}
|
||||
|
||||
// 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
|
||||
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)
|
||||
}
|
||||
|
||||
initiatorName := "unknown"
|
||||
if workspaceOwner != nil {
|
||||
initiatorName = buildInitiator.Username
|
||||
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 {
|
||||
|
|
|
@ -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.",
|
||||
Message: "Internal error fetching workspace resources.",
|
||||
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.",
|
||||
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(),
|
||||
})
|
||||
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 {
|
||||
_ = 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.",
|
||||
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
|
||||
data, err := api.workspaceData(r.Context(), []database.Workspace{workspace})
|
||||
if err != nil {
|
||||
_ = sendEvent(r.Context(), codersdk.ServerSentEvent{
|
||||
Type: codersdk.ServerSentEventTypeError,
|
||||
Data: codersdk.Response{
|
||||
Message: "Internal error fetching workspace data.",
|
||||
Detail: err.Error(),
|
||||
},
|
||||
})
|
||||
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 {
|
||||
return nil, xerrors.Errorf("get templates: %w", err)
|
||||
}
|
||||
users, err := db.GetUsersByIDs(ctx, database.GetUsersByIDsParams{
|
||||
IDs: userIDs,
|
||||
})
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get provisioner jobs: %w", err)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return workspaceData{}, xerrors.Errorf("get templates: %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,
|
||||
}
|
||||
builds, err := api.Database.GetLatestWorkspaceBuildsByWorkspaceIDs(ctx, workspaceIDs)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return workspaceData{}, xerrors.Errorf("get workspace builds: %w", err)
|
||||
}
|
||||
|
||||
data, err := api.workspaceBuildsData(ctx, workspaces, builds)
|
||||
if 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 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(),
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
|
|
@ -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"
|
||||
|
||||
|
|
|
@ -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 = {
|
||||
|
|
Loading…
Reference in New Issue