Files
coder/coderd/workspacebuilds.go
Kyle Carberry 56b963a940 feat: Make workspace watching realtime instead of polling (#4922)
* feat: Make workspace watching realtime instead of polling

This was leading to performance issues on the frontend, where
the page should only be rendered if changes occur. While this
could be changed on the frontend, it was always the intention
to make this socket ~realtime anyways.

* Fix workspace tests waiting, erroring on workspace update, and add comments to workspace events
2022-11-07 15:25:18 +00:00

1012 lines
31 KiB
Go

package coderd
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"golang.org/x/exp/slices"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/coderd/audit"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/httpmw"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/codersdk"
)
func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(r, rbac.ActionRead, workspace) {
httpapi.ResourceNotFound(rw)
return
}
data, err := api.workspaceBuildsData(ctx, []database.Workspace{workspace}, []database.WorkspaceBuild{workspaceBuild})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error getting workspace build data.",
Detail: err.Error(),
})
return
}
apiBuild, err := api.convertWorkspaceBuild(
workspaceBuild,
workspace,
data.jobs[0],
data.users,
data.resources,
data.metadata,
data.agents,
data.apps,
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting workspace build.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, apiBuild)
}
func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspace := httpmw.WorkspaceParam(r)
if !api.Authorize(r, rbac.ActionRead, workspace) {
httpapi.ResourceNotFound(rw)
return
}
paginationParams, ok := parsePagination(rw, r)
if !ok {
return
}
var since time.Time
sinceParam := r.URL.Query().Get("since")
if sinceParam != "" {
var err error
since, err = time.Parse(time.RFC3339, sinceParam)
if err != nil {
httpapi.Write(r.Context(), rw, http.StatusBadRequest, codersdk.Response{
Message: "bad `since` format, must be RFC3339",
Detail: err.Error(),
})
return
}
}
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
if paginationParams.AfterID != uuid.Nil {
// See if the record exists first. If the record does not exist, the pagination
// query will not work.
_, err := store.GetWorkspaceBuildByID(ctx, paginationParams.AfterID)
if err != nil && xerrors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: fmt.Sprintf("Record at \"after_id\" (%q) does not exist.", paginationParams.AfterID.String()),
})
return err
} else if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build at \"after_id\".",
Detail: err.Error(),
})
return err
}
}
req := database.GetWorkspaceBuildsByWorkspaceIDParams{
WorkspaceID: workspace.ID,
AfterID: paginationParams.AfterID,
OffsetOpt: int32(paginationParams.Offset),
LimitOpt: int32(paginationParams.Limit),
Since: database.Time(since),
}
workspaceBuilds, err = store.GetWorkspaceBuildsByWorkspaceID(ctx, req)
if xerrors.Is(err, sql.ErrNoRows) {
err = nil
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build.",
Detail: err.Error(),
})
return err
}
return nil
})
if err != nil {
return
}
data, err := api.workspaceBuildsData(ctx, []database.Workspace{workspace}, workspaceBuilds)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error getting workspace build data.",
Detail: err.Error(),
})
return
}
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(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting workspace build.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, apiBuilds)
}
func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
owner := httpmw.UserParam(r)
workspaceName := chi.URLParam(r, "workspacename")
buildNumber, err := strconv.ParseInt(chi.URLParam(r, "buildnumber"), 10, 32)
if err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Failed to parse build number as integer.",
Detail: err.Error(),
})
return
}
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
OwnerID: owner.ID,
Name: workspaceName,
})
if errors.Is(err, sql.ErrNoRows) {
httpapi.ResourceNotFound(rw)
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace by name.",
Detail: err.Error(),
})
return
}
if !api.Authorize(r, rbac.ActionRead, workspace) {
httpapi.ResourceNotFound(rw)
return
}
workspaceBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{
WorkspaceID: workspace.ID,
BuildNumber: int32(buildNumber),
})
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
Message: fmt.Sprintf("Workspace %q Build %d does not exist.", workspaceName, buildNumber),
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace build.",
Detail: err.Error(),
})
return
}
data, err := api.workspaceBuildsData(ctx, []database.Workspace{workspace}, []database.WorkspaceBuild{workspaceBuild})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error getting workspace build data.",
Detail: err.Error(),
})
return
}
apiBuild, err := api.convertWorkspaceBuild(
workspaceBuild,
workspace,
data.jobs[0],
data.users,
data.resources,
data.metadata,
data.agents,
data.apps,
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting workspace build.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusOK, apiBuild)
}
func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
apiKey := httpmw.APIKey(r)
workspace := httpmw.WorkspaceParam(r)
var createBuild codersdk.CreateWorkspaceBuildRequest
if !httpapi.Read(ctx, rw, r, &createBuild) {
return
}
// Rbac action depends on the transition
var action rbac.Action
switch createBuild.Transition {
case codersdk.WorkspaceTransitionDelete:
action = rbac.ActionDelete
case codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop:
action = rbac.ActionUpdate
default:
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: fmt.Sprintf("Transition %q not supported.", createBuild.Transition),
})
return
}
if !api.Authorize(r, action, workspace) {
httpapi.ResourceNotFound(rw)
return
}
auditor := api.Auditor.Load()
// if user deletes a workspace, audit the workspace
if action == rbac.ActionDelete {
aReq, commitAudit := audit.InitRequest[database.Workspace](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: database.AuditActionDelete,
})
defer commitAudit()
aReq.Old = workspace
}
latestBuild, latestBuildErr := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
// if a user starts/stops a workspace, audit the workspace build
if action == rbac.ActionUpdate {
var auditAction database.AuditAction
if createBuild.Transition == codersdk.WorkspaceTransitionStart {
auditAction = database.AuditActionStart
} else if createBuild.Transition == codersdk.WorkspaceTransitionStop {
auditAction = database.AuditActionStop
} else {
auditAction = database.AuditActionWrite
}
// We pass the workspace name to the Auditor so that it
// can form a friendly string for the user.
workspaceResourceInfo := map[string]string{
"workspaceName": workspace.Name,
}
wriBytes, err := json.Marshal(workspaceResourceInfo)
if err != nil {
api.Logger.Error(ctx, "could not marshal workspace name", slog.Error(err))
}
aReq, commitAudit := audit.InitRequest[database.WorkspaceBuild](rw, &audit.RequestParams{
Audit: *auditor,
Log: api.Logger,
Request: r,
Action: auditAction,
AdditionalFields: wriBytes,
})
defer commitAudit()
aReq.Old = latestBuild
}
if createBuild.TemplateVersionID == uuid.Nil {
if latestBuildErr != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching the latest workspace build.",
Detail: latestBuildErr.Error(),
})
return
}
createBuild.TemplateVersionID = latestBuild.TemplateVersionID
}
templateVersion, err := api.Database.GetTemplateVersionByID(ctx, createBuild.TemplateVersionID)
if errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Template version not found.",
Validations: []codersdk.ValidationError{{
Field: "template_version_id",
Detail: "template version not found",
}},
})
return
}
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching template version.",
Detail: err.Error(),
})
return
}
template, err := api.Database.GetTemplateByID(ctx, templateVersion.TemplateID.UUID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to get template",
Detail: err.Error(),
})
return
}
var state []byte
// If custom state, deny request since user could be corrupting or leaking
// cloud state.
if createBuild.ProvisionerState != nil || createBuild.Orphan {
if !api.Authorize(r, rbac.ActionUpdate, template.RBACObject()) {
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
Message: "Only template managers may provide custom state",
})
return
}
state = createBuild.ProvisionerState
}
if createBuild.Orphan {
if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Orphan is only permitted when deleting a workspace.",
Detail: err.Error(),
})
return
}
if createBuild.ProvisionerState != nil && createBuild.Orphan {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
})
return
}
state = []byte{}
}
templateVersionJob, err := api.Database.GetProvisionerJobByID(ctx, templateVersion.JobID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: err.Error(),
})
return
}
templateVersionJobStatus := convertProvisionerJob(templateVersionJob).Status
switch templateVersionJobStatus {
case codersdk.ProvisionerJobPending, codersdk.ProvisionerJobRunning:
httpapi.Write(ctx, rw, http.StatusNotAcceptable, codersdk.Response{
Message: fmt.Sprintf("The provided template version is %s. Wait for it to complete importing!", templateVersionJobStatus),
})
return
case codersdk.ProvisionerJobFailed:
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: fmt.Sprintf("The provided template version %q has failed to import: %q. You cannot build workspaces with it!", templateVersion.Name, templateVersionJob.Error.String),
})
return
case codersdk.ProvisionerJobCanceled:
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: "The provided template version was canceled during import. You cannot builds workspaces with it!",
})
return
}
// Store prior build number to compute new build number
var priorBuildNum int32
priorHistory, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err == nil {
priorJob, err := api.Database.GetProvisionerJobByID(ctx, priorHistory.JobID)
if err == nil && convertProvisionerJob(priorJob).Status.Active() {
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
Message: "A workspace build is already active.",
})
return
}
priorBuildNum = priorHistory.BuildNumber
} else if !errors.Is(err, sql.ErrNoRows) {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching prior workspace build.",
Detail: err.Error(),
})
return
}
if state == nil {
state = priorHistory.ProvisionerState
}
var workspaceBuild database.WorkspaceBuild
var provisionerJob database.ProvisionerJob
// This must happen in a transaction to ensure history can be inserted, and
// the prior history can update it's "after" column to point at the new.
err = api.Database.InTx(func(db database.Store) error {
existing, err := db.ParameterValues(ctx, database.ParameterValuesParams{
Scopes: []database.ParameterScope{database.ParameterScopeWorkspace},
ScopeIds: []uuid.UUID{workspace.ID},
})
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("Fetch previous parameters: %w", err)
}
// Write/Update any new params
now := database.Now()
for _, param := range createBuild.ParameterValues {
for _, exists := range existing {
// If the param exists, delete the old param before inserting the new one
if exists.Name == param.Name {
err = db.DeleteParameterValueByID(ctx, exists.ID)
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
return xerrors.Errorf("Failed to delete old param %q: %w", exists.Name, err)
}
}
}
_, err = db.InsertParameterValue(ctx, database.InsertParameterValueParams{
ID: uuid.New(),
Name: param.Name,
CreatedAt: now,
UpdatedAt: now,
Scope: database.ParameterScopeWorkspace,
ScopeID: workspace.ID,
SourceScheme: database.ParameterSourceScheme(param.SourceScheme),
SourceValue: param.SourceValue,
DestinationScheme: database.ParameterDestinationScheme(param.DestinationScheme),
})
if err != nil {
return xerrors.Errorf("insert parameter value: %w", err)
}
}
workspaceBuildID := uuid.New()
input, err := json.Marshal(workspaceProvisionJob{
WorkspaceBuildID: workspaceBuildID,
})
if err != nil {
return xerrors.Errorf("marshal provision job: %w", err)
}
provisionerJob, err = db.InsertProvisionerJob(ctx, database.InsertProvisionerJobParams{
ID: uuid.New(),
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
InitiatorID: apiKey.UserID,
OrganizationID: template.OrganizationID,
Provisioner: template.Provisioner,
Type: database.ProvisionerJobTypeWorkspaceBuild,
StorageMethod: templateVersionJob.StorageMethod,
FileID: templateVersionJob.FileID,
Input: input,
})
if err != nil {
return xerrors.Errorf("insert provisioner job: %w", err)
}
workspaceBuild, err = db.InsertWorkspaceBuild(ctx, database.InsertWorkspaceBuildParams{
ID: workspaceBuildID,
CreatedAt: database.Now(),
UpdatedAt: database.Now(),
WorkspaceID: workspace.ID,
TemplateVersionID: templateVersion.ID,
BuildNumber: priorBuildNum + 1,
ProvisionerState: state,
InitiatorID: apiKey.UserID,
Transition: database.WorkspaceTransition(createBuild.Transition),
JobID: provisionerJob.ID,
Reason: database.BuildReasonInitiator,
})
if err != nil {
return xerrors.Errorf("insert workspace build: %w", err)
}
return nil
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error inserting workspace build.",
Detail: err.Error(),
})
return
}
users, err := api.Database.GetUsersByIDs(ctx, []uuid.UUID{
workspace.OwnerID,
workspaceBuild.InitiatorID,
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error getting user.",
Detail: err.Error(),
})
return
}
apiBuild, err := api.convertWorkspaceBuild(
workspaceBuild,
workspace,
provisionerJob,
users,
[]database.WorkspaceResource{},
[]database.WorkspaceResourceMetadatum{},
[]database.WorkspaceAgent{},
[]database.WorkspaceApp{},
)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error converting workspace build.",
Detail: err.Error(),
})
return
}
api.publishWorkspaceUpdate(ctx, workspace.ID)
httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
}
func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "No workspace exists for this job.",
})
return
}
if !api.Authorize(r, rbac.ActionUpdate, workspace) {
httpapi.ResourceNotFound(rw)
return
}
job, err := api.Database.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: err.Error(),
})
return
}
if job.CompletedAt.Valid {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: "Job has already completed!",
})
return
}
if job.CanceledAt.Valid {
httpapi.Write(ctx, rw, http.StatusPreconditionFailed, codersdk.Response{
Message: "Job has already been marked as canceled!",
})
return
}
err = api.Database.UpdateProvisionerJobWithCancelByID(ctx, database.UpdateProvisionerJobWithCancelByIDParams{
ID: job.ID,
CanceledAt: sql.NullTime{
Time: database.Now(),
Valid: true,
},
CompletedAt: sql.NullTime{
Time: database.Now(),
// If the job is running, don't mark it completed!
Valid: !job.WorkerID.Valid,
},
})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error updating provisioner job.",
Detail: err.Error(),
})
return
}
api.publishWorkspaceUpdate(ctx, workspace.ID)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
Message: "Job has been marked as canceled...",
})
}
func (api *API) workspaceBuildResources(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "No workspace exists for this job.",
})
return
}
if !api.Authorize(r, rbac.ActionRead, workspace) {
httpapi.ResourceNotFound(rw)
return
}
job, err := api.Database.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: err.Error(),
})
return
}
api.provisionerJobResources(rw, r, job)
}
func (api *API) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "No workspace exists for this job.",
})
return
}
if !api.Authorize(r, rbac.ActionRead, workspace) {
httpapi.ResourceNotFound(rw)
return
}
job, err := api.Database.GetProvisionerJobByID(ctx, workspaceBuild.JobID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching provisioner job.",
Detail: err.Error(),
})
return
}
api.provisionerJobLogs(rw, r, job)
}
func (api *API) workspaceBuildState(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceBuild := httpmw.WorkspaceBuildParam(r)
workspace, err := api.Database.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID)
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "No workspace exists for this job.",
})
return
}
if !api.Authorize(r, rbac.ActionRead, workspace) {
httpapi.ResourceNotFound(rw)
return
}
rw.Header().Set("Content-Type", "application/json")
rw.WriteHeader(http.StatusOK)
_, _ = rw.Write(workspaceBuild.ProvisionerState)
}
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, 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,
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.Load(), 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))
}
apiJob := convertProvisionerJob(job)
transition := codersdk.WorkspaceTransition(build.Transition)
return codersdk.WorkspaceBuild{
ID: build.ID,
CreatedAt: build.CreatedAt,
UpdatedAt: build.UpdatedAt,
WorkspaceOwnerID: workspace.OwnerID,
WorkspaceOwnerName: owner.Username,
WorkspaceID: build.WorkspaceID,
WorkspaceName: workspace.Name,
TemplateVersionID: build.TemplateVersionID,
BuildNumber: build.BuildNumber,
Transition: transition,
InitiatorID: build.InitiatorID,
InitiatorUsername: initiator.Username,
Job: apiJob,
Deadline: codersdk.NewNullTime(build.Deadline, !build.Deadline.IsZero()),
Reason: codersdk.BuildReason(build.Reason),
Resources: apiResources,
Status: convertWorkspaceStatus(apiJob.Status, transition),
}, nil
}
func convertWorkspaceResource(resource database.WorkspaceResource, agents []codersdk.WorkspaceAgent, metadata []database.WorkspaceResourceMetadatum) codersdk.WorkspaceResource {
metadataMap := map[string]database.WorkspaceResourceMetadatum{}
// implicit metadata fields come first
metadataMap["type"] = database.WorkspaceResourceMetadatum{
Key: "type",
Value: sql.NullString{String: resource.Type, Valid: true},
Sensitive: false,
}
// explicit metadata fields come afterward, and can override implicit ones
for _, field := range metadata {
metadataMap[field.Key] = field
}
var convertedMetadata []codersdk.WorkspaceResourceMetadata
for _, field := range metadataMap {
if field.Value.Valid {
convertedField := codersdk.WorkspaceResourceMetadata{
Key: field.Key,
Value: field.Value.String,
Sensitive: field.Sensitive,
}
convertedMetadata = append(convertedMetadata, convertedField)
}
}
slices.SortFunc(convertedMetadata, func(a, b codersdk.WorkspaceResourceMetadata) bool {
return a.Key < b.Key
})
return codersdk.WorkspaceResource{
ID: resource.ID,
CreatedAt: resource.CreatedAt,
JobID: resource.JobID,
Transition: codersdk.WorkspaceTransition(resource.Transition),
Type: resource.Type,
Name: resource.Name,
Hide: resource.Hide,
Icon: resource.Icon,
Agents: agents,
Metadata: convertedMetadata,
}
}
func convertWorkspaceStatus(jobStatus codersdk.ProvisionerJobStatus, transition codersdk.WorkspaceTransition) codersdk.WorkspaceStatus {
switch jobStatus {
case codersdk.ProvisionerJobPending:
return codersdk.WorkspaceStatusPending
case codersdk.ProvisionerJobRunning:
switch transition {
case codersdk.WorkspaceTransitionStart:
return codersdk.WorkspaceStatusStarting
case codersdk.WorkspaceTransitionStop:
return codersdk.WorkspaceStatusStopping
case codersdk.WorkspaceTransitionDelete:
return codersdk.WorkspaceStatusDeleting
}
case codersdk.ProvisionerJobSucceeded:
switch transition {
case codersdk.WorkspaceTransitionStart:
return codersdk.WorkspaceStatusRunning
case codersdk.WorkspaceTransitionStop:
return codersdk.WorkspaceStatusStopped
case codersdk.WorkspaceTransitionDelete:
return codersdk.WorkspaceStatusDeleted
}
case codersdk.ProvisionerJobCanceling:
return codersdk.WorkspaceStatusCanceling
case codersdk.ProvisionerJobCanceled:
return codersdk.WorkspaceStatusCanceled
case codersdk.ProvisionerJobFailed:
return codersdk.WorkspaceStatusFailed
}
// return error status since we should never get here
return codersdk.WorkspaceStatusFailed
}