mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
36665e17b2
This PR adds a `WatchAllWorkspaces` function with `watch-all-workspaces` endpoint, which can be used to listen on a single global pubsub channel for _all_ workspace build updates, and makes use of it in the autostart scaletest. This negates the need to use a workspace watch pubsub channel _per_ workspace, which has auth overhead associated with each call. This is especially relevant in situations such as the autostart scaletest, where we need to start/stop a set of workspaces before we can configure their autostart config. The overhead associated with all the watch requests skews the scaletest results and makes it harder to reason about the performance of the autostart feature itself. The autostart scaletest also no longer generates its own metrics nor does it wait for all the workspaces to actually start via autostart. We should update the scaletest dashboard after both PRs are merged to measure autostart performance via the new metrics. The new function/endpoint and its usage in the autostart scaletest are gated behind an experiment feature flag, this is something we should discuss whether we want to enable the endpoint in prod by default or not. If so, we can remove the experiment. --------- Signed-off-by: Callum Styan <callumstyan@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: Callum Styan <callum@coder.com>
1449 lines
51 KiB
Go
1449 lines
51 KiB
Go
package coderd
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"net/http"
|
|
"slices"
|
|
"sort"
|
|
"strconv"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/sync/errgroup"
|
|
"golang.org/x/xerrors"
|
|
|
|
"cdr.dev/slog/v3"
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/db2sdk"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpapi/httperror"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/notifications"
|
|
"github.com/coder/coder/v2/coderd/provisionerdserver"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/wsbuilder"
|
|
"github.com/coder/coder/v2/coderd/wspubsub"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
)
|
|
|
|
// @Summary Get workspace build
|
|
// @ID get-workspace-build
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Builds
|
|
// @Param workspacebuild path string true "Workspace build ID"
|
|
// @Success 200 {object} codersdk.WorkspaceBuild
|
|
// @Router /workspacebuilds/{workspacebuild} [get]
|
|
func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
workspaceBuild := httpmw.WorkspaceBuildParam(r)
|
|
workspace := httpmw.WorkspaceParam(r)
|
|
|
|
data, err := api.workspaceBuildsData(ctx, []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
|
|
}
|
|
|
|
// Ensure we have the job and template version for the workspace build.
|
|
// Otherwise we risk a panic in the api.convertWorkspaceBuild call below.
|
|
if len(data.jobs) == 0 {
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
Message: "Internal error getting workspace build data.",
|
|
Detail: "No job found for workspace build.",
|
|
})
|
|
return
|
|
}
|
|
|
|
if len(data.templateVersions) == 0 {
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
Message: "Internal error getting workspace build data.",
|
|
Detail: "No template version found for workspace build.",
|
|
})
|
|
return
|
|
}
|
|
|
|
apiBuild, err := api.convertWorkspaceBuild(
|
|
workspaceBuild,
|
|
workspace,
|
|
data.jobs[0],
|
|
data.resources,
|
|
data.metadata,
|
|
data.agents,
|
|
data.apps,
|
|
data.appStatuses,
|
|
data.scripts,
|
|
data.logSources,
|
|
data.templateVersions[0],
|
|
nil,
|
|
)
|
|
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)
|
|
}
|
|
|
|
// @Summary Get workspace builds by workspace ID
|
|
// @ID get-workspace-builds-by-workspace-id
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Builds
|
|
// @Param workspace path string true "Workspace ID" format(uuid)
|
|
// @Param after_id query string false "After ID" format(uuid)
|
|
// @Param limit query int false "Page limit"
|
|
// @Param offset query int false "Page offset"
|
|
// @Param since query string false "Since timestamp" format(date-time)
|
|
// @Success 200 {array} codersdk.WorkspaceBuild
|
|
// @Router /workspaces/{workspace}/builds [get]
|
|
func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
workspace := httpmw.WorkspaceParam(r)
|
|
|
|
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,
|
|
// #nosec G115 - Pagination offsets are small and fit in int32
|
|
OffsetOpt: int32(paginationParams.Offset),
|
|
// #nosec G115 - Pagination limits are small and fit in int32
|
|
LimitOpt: int32(paginationParams.Limit),
|
|
Since: dbtime.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
|
|
}, nil)
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
data, err := api.workspaceBuildsData(ctx, 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.resources,
|
|
data.metadata,
|
|
data.agents,
|
|
data.apps,
|
|
data.appStatuses,
|
|
data.scripts,
|
|
data.logSources,
|
|
data.templateVersions,
|
|
data.provisionerDaemons,
|
|
)
|
|
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)
|
|
}
|
|
|
|
// @Summary Get workspace build by user, workspace name, and build number
|
|
// @ID get-workspace-build-by-user-workspace-name-and-build-number
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Builds
|
|
// @Param user path string true "User ID, name, or me"
|
|
// @Param workspacename path string true "Workspace name"
|
|
// @Param buildnumber path string true "Build number" format(number)
|
|
// @Success 200 {object} codersdk.WorkspaceBuild
|
|
// @Router /users/{user}/workspace/{workspacename}/builds/{buildnumber} [get]
|
|
func (api *API) workspaceBuildByBuildNumber(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
mems := httpmw.OrganizationMembersParam(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: mems.UserID(),
|
|
Name: workspaceName,
|
|
})
|
|
if httpapi.Is404Error(err) {
|
|
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
|
|
}
|
|
|
|
workspaceBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx, database.GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams{
|
|
WorkspaceID: workspace.ID,
|
|
BuildNumber: int32(buildNumber),
|
|
})
|
|
if httpapi.Is404Error(err) {
|
|
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.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.resources,
|
|
data.metadata,
|
|
data.agents,
|
|
data.apps,
|
|
data.appStatuses,
|
|
data.scripts,
|
|
data.logSources,
|
|
data.templateVersions[0],
|
|
data.provisionerDaemons,
|
|
)
|
|
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)
|
|
}
|
|
|
|
// Azure supports instance identity verification:
|
|
// https://docs.microsoft.com/en-us/azure/virtual-machines/windows/instance-metadata-service?tabs=linux#tabgroup_14
|
|
//
|
|
// @Summary Create workspace build
|
|
// @ID create-workspace-build
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Builds
|
|
// @Param workspace path string true "Workspace ID" format(uuid)
|
|
// @Param request body codersdk.CreateWorkspaceBuildRequest true "Create workspace build request"
|
|
// @Success 200 {object} codersdk.WorkspaceBuild
|
|
// @Router /workspaces/{workspace}/builds [post]
|
|
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
|
|
}
|
|
|
|
// We want to allow a delete build for a deleted workspace, but not a start or stop build.
|
|
if workspace.Deleted && createBuild.Transition != codersdk.WorkspaceTransitionDelete {
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: fmt.Sprintf("Cannot %s a deleted workspace!", createBuild.Transition),
|
|
Detail: "This workspace has been deleted and cannot be modified.",
|
|
})
|
|
return
|
|
}
|
|
|
|
apiBuild, err := api.postWorkspaceBuildsInternal(
|
|
ctx,
|
|
apiKey,
|
|
workspace,
|
|
createBuild,
|
|
func(action policy.Action, object rbac.Objecter) bool {
|
|
return api.Authorize(r, action, object)
|
|
},
|
|
audit.WorkspaceBuildBaggageFromRequest(r),
|
|
)
|
|
if err != nil {
|
|
httperror.WriteWorkspaceBuildError(ctx, rw, err)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
|
|
}
|
|
|
|
// postWorkspaceBuildsInternal handles the internal logic for creating
|
|
// workspace builds, can be called by other handlers and must not
|
|
// reference httpmw.
|
|
func (api *API) postWorkspaceBuildsInternal(
|
|
ctx context.Context,
|
|
apiKey database.APIKey,
|
|
workspace database.Workspace,
|
|
createBuild codersdk.CreateWorkspaceBuildRequest,
|
|
authorize func(action policy.Action, object rbac.Objecter) bool,
|
|
workspaceBuildBaggage audit.WorkspaceBuildBaggage,
|
|
) (
|
|
codersdk.WorkspaceBuild,
|
|
error,
|
|
) {
|
|
transition := database.WorkspaceTransition(createBuild.Transition)
|
|
builder := wsbuilder.New(workspace, transition, *api.BuildUsageChecker.Load()).
|
|
Initiator(apiKey.UserID).
|
|
RichParameterValues(createBuild.RichParameterValues).
|
|
LogLevel(string(createBuild.LogLevel)).
|
|
DeploymentValues(api.Options.DeploymentValues).
|
|
Experiments(api.Experiments).
|
|
TemplateVersionPresetID(createBuild.TemplateVersionPresetID).
|
|
BuildMetrics(api.WorkspaceBuilderMetrics)
|
|
|
|
if (transition == database.WorkspaceTransitionStart || transition == database.WorkspaceTransitionStop) && createBuild.Reason != "" {
|
|
builder = builder.Reason(database.BuildReason(createBuild.Reason))
|
|
}
|
|
|
|
var (
|
|
previousWorkspaceBuild database.WorkspaceBuild
|
|
workspaceBuild *database.WorkspaceBuild
|
|
provisionerJob *database.ProvisionerJob
|
|
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
|
|
)
|
|
|
|
err := api.Database.InTx(func(tx database.Store) error {
|
|
var err error
|
|
|
|
// #20925: if the workspace is dormant and we are starting the workspace,
|
|
// we need to unset that status before inserting a new build.
|
|
// This is done inside the transaction for consistency, but it could also be
|
|
// done outside the transaction so that an attempt to start a workspace will
|
|
// also unset dormancy.
|
|
if workspace.DormantAt.Valid && transition == database.WorkspaceTransitionStart {
|
|
if _, err := tx.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
|
|
ID: workspace.ID,
|
|
DormantAt: sql.NullTime{Valid: false},
|
|
}); err != nil {
|
|
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error unsetting workspace dormant status",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
// We need to audit this change separately.
|
|
updatedWorkspace := workspace.WorkspaceTable()
|
|
updatedWorkspace.DormantAt = sql.NullTime{Valid: false}
|
|
auditor := api.Auditor.Load()
|
|
bag := audit.BaggageFromContext(ctx)
|
|
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceTable]{
|
|
Audit: *auditor,
|
|
Old: workspace.WorkspaceTable(),
|
|
New: updatedWorkspace,
|
|
Log: api.Logger,
|
|
UserID: apiKey.UserID,
|
|
OrganizationID: workspace.OrganizationID,
|
|
RequestID: workspace.ID,
|
|
IP: bag.IP,
|
|
Action: database.AuditActionWrite,
|
|
Status: http.StatusOK,
|
|
})
|
|
}
|
|
|
|
previousWorkspaceBuild, err = tx.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
|
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
|
api.Logger.Error(ctx, "failed fetching previous workspace build", slog.F("workspace_id", workspace.ID), slog.Error(err))
|
|
return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching previous workspace build",
|
|
Detail: err.Error(),
|
|
})
|
|
}
|
|
|
|
if createBuild.TemplateVersionID != uuid.Nil {
|
|
builder = builder.VersionID(createBuild.TemplateVersionID)
|
|
}
|
|
|
|
if createBuild.Orphan {
|
|
if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "Orphan is only permitted when deleting a workspace.",
|
|
})
|
|
}
|
|
if len(createBuild.ProvisionerState) > 0 {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
|
|
})
|
|
}
|
|
builder = builder.Orphan()
|
|
}
|
|
if len(createBuild.ProvisionerState) > 0 {
|
|
builder = builder.State(createBuild.ProvisionerState)
|
|
}
|
|
|
|
workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build(
|
|
ctx,
|
|
tx,
|
|
api.FileCache,
|
|
func(action policy.Action, object rbac.Objecter) bool {
|
|
if auth := authorize(action, object); auth {
|
|
return true
|
|
}
|
|
// Special handling for prebuilt workspace deletion
|
|
if action == policy.ActionDelete {
|
|
if workspaceObj, ok := object.(database.PrebuiltWorkspaceResource); ok && workspaceObj.IsPrebuild() {
|
|
return authorize(action, workspaceObj.AsPrebuild())
|
|
}
|
|
}
|
|
return false
|
|
},
|
|
workspaceBuildBaggage,
|
|
)
|
|
return err
|
|
}, nil)
|
|
if err != nil {
|
|
return codersdk.WorkspaceBuild{}, err
|
|
}
|
|
|
|
var queuePos database.GetProvisionerJobsByIDsWithQueuePositionRow
|
|
if provisionerJob != nil {
|
|
queuePos.ProvisionerJob = *provisionerJob
|
|
queuePos.QueuePosition = 0
|
|
if err := provisionerjobs.PostJob(api.Pubsub, *provisionerJob); err != nil {
|
|
// Client probably doesn't care about this error, so just log it.
|
|
api.Logger.Error(ctx, "failed to post provisioner job to pubsub", slog.Error(err))
|
|
}
|
|
|
|
// We may need to complete the audit if wsbuilder determined that
|
|
// no provisioner could handle an orphan-delete job and completed it.
|
|
if createBuild.Orphan && createBuild.Transition == codersdk.WorkspaceTransitionDelete && provisionerJob.CompletedAt.Valid {
|
|
api.Logger.Warn(ctx, "orphan delete handled by wsbuilder due to no eligible provisioners",
|
|
slog.F("workspace_id", workspace.ID),
|
|
slog.F("workspace_build_id", workspaceBuild.ID),
|
|
slog.F("provisioner_job_id", provisionerJob.ID),
|
|
)
|
|
buildResourceInfo := audit.AdditionalFields{
|
|
WorkspaceName: workspace.Name,
|
|
BuildNumber: strconv.Itoa(int(workspaceBuild.BuildNumber)),
|
|
BuildReason: workspaceBuild.Reason,
|
|
WorkspaceID: workspace.ID,
|
|
WorkspaceOwner: workspace.OwnerName,
|
|
}
|
|
briBytes, err := json.Marshal(buildResourceInfo)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "failed to marshal build resource info for audit", slog.Error(err))
|
|
}
|
|
auditor := api.Auditor.Load()
|
|
bag := audit.BaggageFromContext(ctx)
|
|
audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceBuild]{
|
|
Audit: *auditor,
|
|
Log: api.Logger,
|
|
UserID: provisionerJob.InitiatorID,
|
|
OrganizationID: workspace.OrganizationID,
|
|
RequestID: provisionerJob.ID,
|
|
IP: bag.IP,
|
|
Action: database.AuditActionDelete,
|
|
Old: previousWorkspaceBuild,
|
|
New: *workspaceBuild,
|
|
Status: http.StatusOK,
|
|
AdditionalFields: briBytes,
|
|
})
|
|
}
|
|
}
|
|
|
|
apiBuild, err := api.convertWorkspaceBuild(
|
|
*workspaceBuild,
|
|
workspace,
|
|
queuePos,
|
|
[]database.WorkspaceResource{},
|
|
[]database.WorkspaceResourceMetadatum{},
|
|
[]database.WorkspaceAgent{},
|
|
[]database.WorkspaceApp{},
|
|
[]database.WorkspaceAppStatus{},
|
|
[]database.WorkspaceAgentScript{},
|
|
[]database.WorkspaceAgentLogSource{},
|
|
database.TemplateVersion{},
|
|
provisionerDaemons,
|
|
)
|
|
if err != nil {
|
|
return codersdk.WorkspaceBuild{}, httperror.NewResponseError(
|
|
http.StatusInternalServerError,
|
|
codersdk.Response{
|
|
Message: "Internal error converting workspace build.",
|
|
Detail: err.Error(),
|
|
},
|
|
)
|
|
}
|
|
|
|
// If this workspace build has a different template version ID to the previous build
|
|
// we can assume it has just been updated.
|
|
if createBuild.TemplateVersionID != uuid.Nil && createBuild.TemplateVersionID != previousWorkspaceBuild.TemplateVersionID {
|
|
// nolint:gocritic // Need system context to fetch admins
|
|
admins, err := findTemplateAdmins(dbauthz.AsSystemRestricted(ctx), api.Database)
|
|
if err != nil {
|
|
api.Logger.Error(ctx, "find template admins", slog.Error(err))
|
|
} else {
|
|
for _, admin := range admins {
|
|
// Don't send notifications to user which initiated the event.
|
|
if admin.ID == apiKey.UserID {
|
|
continue
|
|
}
|
|
|
|
api.notifyWorkspaceUpdated(ctx, apiKey.UserID, admin.ID, workspace, createBuild.RichParameterValues)
|
|
}
|
|
}
|
|
}
|
|
|
|
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
|
|
Kind: wspubsub.WorkspaceEventKindStateChange,
|
|
WorkspaceID: workspace.ID,
|
|
})
|
|
|
|
return apiBuild, nil
|
|
}
|
|
|
|
func (api *API) notifyWorkspaceUpdated(
|
|
ctx context.Context,
|
|
initiatorID uuid.UUID,
|
|
receiverID uuid.UUID,
|
|
workspace database.Workspace,
|
|
parameters []codersdk.WorkspaceBuildParameter,
|
|
) {
|
|
log := api.Logger.With(slog.F("workspace_id", workspace.ID))
|
|
|
|
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
|
if err != nil {
|
|
log.Warn(ctx, "failed to fetch template for workspace creation notification", slog.F("template_id", workspace.TemplateID), slog.Error(err))
|
|
return
|
|
}
|
|
|
|
version, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID)
|
|
if err != nil {
|
|
log.Warn(ctx, "failed to fetch template version for workspace creation notification", slog.F("template_id", workspace.TemplateID), slog.Error(err))
|
|
return
|
|
}
|
|
|
|
initiator, err := api.Database.GetUserByID(ctx, initiatorID)
|
|
if err != nil {
|
|
log.Warn(ctx, "failed to fetch user for workspace update notification", slog.F("initiator_id", initiatorID), slog.Error(err))
|
|
return
|
|
}
|
|
|
|
owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
|
|
if err != nil {
|
|
log.Warn(ctx, "failed to fetch user for workspace update notification", slog.F("owner_id", workspace.OwnerID), slog.Error(err))
|
|
return
|
|
}
|
|
|
|
buildParameters := make([]map[string]any, len(parameters))
|
|
for idx, parameter := range parameters {
|
|
buildParameters[idx] = map[string]any{
|
|
"name": parameter.Name,
|
|
"value": parameter.Value,
|
|
}
|
|
}
|
|
|
|
if _, err := api.NotificationsEnqueuer.EnqueueWithData(
|
|
// nolint:gocritic // Need notifier actor to enqueue notifications
|
|
dbauthz.AsNotifier(ctx),
|
|
receiverID,
|
|
notifications.TemplateWorkspaceManuallyUpdated,
|
|
map[string]string{
|
|
"organization": template.OrganizationName,
|
|
"initiator": initiator.Name,
|
|
"workspace": workspace.Name,
|
|
"template": template.Name,
|
|
"version": version.Name,
|
|
"workspace_owner_username": owner.Username,
|
|
},
|
|
map[string]any{
|
|
"workspace": map[string]any{"id": workspace.ID, "name": workspace.Name},
|
|
"template": map[string]any{"id": template.ID, "name": template.Name},
|
|
"template_version": map[string]any{"id": version.ID, "name": version.Name},
|
|
"owner": map[string]any{"id": owner.ID, "name": owner.Name, "email": owner.Email},
|
|
"parameters": buildParameters,
|
|
},
|
|
"api-workspaces-updated",
|
|
// Associate this notification with all the related entities
|
|
workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID,
|
|
); err != nil {
|
|
log.Warn(ctx, "failed to notify of workspace update", slog.Error(err))
|
|
}
|
|
}
|
|
|
|
// @Summary Cancel workspace build
|
|
// @ID cancel-workspace-build
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Builds
|
|
// @Param workspacebuild path string true "Workspace build ID"
|
|
// @Param expect_status query string false "Expected status of the job. If expect_status is supplied, the request will be rejected with 412 Precondition Failed if the job doesn't match the state when performing the cancellation." Enums(running, pending)
|
|
// @Success 200 {object} codersdk.Response
|
|
// @Router /workspacebuilds/{workspacebuild}/cancel [patch]
|
|
func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
var expectStatus database.ProvisionerJobStatus
|
|
expectStatusParam := r.URL.Query().Get("expect_status")
|
|
if expectStatusParam != "" {
|
|
if expectStatusParam != "running" && expectStatusParam != "pending" {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("Invalid expect_status %q. Only 'running' or 'pending' are allowed.", expectStatusParam),
|
|
})
|
|
return
|
|
}
|
|
expectStatus = database.ProvisionerJobStatus(expectStatusParam)
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
code := http.StatusInternalServerError
|
|
resp := codersdk.Response{
|
|
Message: "Internal error canceling workspace build.",
|
|
}
|
|
err = api.Database.InTx(func(db database.Store) error {
|
|
valid, err := verifyUserCanCancelWorkspaceBuilds(ctx, db, httpmw.APIKey(r).UserID, workspace.TemplateID, expectStatus)
|
|
if err != nil {
|
|
code = http.StatusInternalServerError
|
|
resp.Message = "Internal error verifying permission to cancel workspace build."
|
|
resp.Detail = err.Error()
|
|
|
|
return xerrors.Errorf("verify user can cancel workspace builds: %w", err)
|
|
}
|
|
if !valid {
|
|
code = http.StatusForbidden
|
|
resp.Message = "User is not allowed to cancel workspace builds. Owner role is required."
|
|
|
|
return xerrors.New("user is not allowed to cancel workspace builds")
|
|
}
|
|
|
|
job, err := db.GetProvisionerJobByIDWithLock(ctx, workspaceBuild.JobID)
|
|
if err != nil {
|
|
code = http.StatusInternalServerError
|
|
resp.Message = "Internal error fetching provisioner job."
|
|
resp.Detail = err.Error()
|
|
|
|
return xerrors.Errorf("get provisioner job: %w", err)
|
|
}
|
|
if job.CompletedAt.Valid {
|
|
code = http.StatusBadRequest
|
|
resp.Message = "Job has already completed!"
|
|
|
|
return xerrors.New("job has already completed")
|
|
}
|
|
if job.CanceledAt.Valid {
|
|
code = http.StatusBadRequest
|
|
resp.Message = "Job has already been marked as canceled!"
|
|
|
|
return xerrors.New("job has already been marked as canceled")
|
|
}
|
|
|
|
if expectStatus != "" && job.JobStatus != expectStatus {
|
|
code = http.StatusPreconditionFailed
|
|
resp.Message = "Job is not in the expected state."
|
|
|
|
return xerrors.Errorf("job is not in the expected state: expected: %q, got %q", expectStatus, job.JobStatus)
|
|
}
|
|
|
|
err = db.UpdateProvisionerJobWithCancelByID(ctx, database.UpdateProvisionerJobWithCancelByIDParams{
|
|
ID: job.ID,
|
|
CanceledAt: sql.NullTime{
|
|
Time: dbtime.Now(),
|
|
Valid: true,
|
|
},
|
|
CompletedAt: sql.NullTime{
|
|
Time: dbtime.Now(),
|
|
// If the job is running, don't mark it completed!
|
|
Valid: !job.WorkerID.Valid,
|
|
},
|
|
})
|
|
if err != nil {
|
|
code = http.StatusInternalServerError
|
|
resp.Message = "Internal error updating provisioner job."
|
|
resp.Detail = err.Error()
|
|
|
|
return xerrors.Errorf("update provisioner job: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, code, resp)
|
|
return
|
|
}
|
|
|
|
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
|
|
Kind: wspubsub.WorkspaceEventKindStateChange,
|
|
WorkspaceID: workspace.ID,
|
|
})
|
|
|
|
// Publish workspace build update to the all builds channel if the experiment is enabled.
|
|
if api.Experiments.Enabled(codersdk.ExperimentWorkspaceBuildUpdates) {
|
|
err = wspubsub.PublishWorkspaceBuildUpdate(ctx, api.Pubsub, codersdk.WorkspaceBuildUpdate{
|
|
WorkspaceID: workspace.ID,
|
|
WorkspaceName: workspace.Name,
|
|
BuildID: workspaceBuild.ID,
|
|
Transition: string(workspaceBuild.Transition),
|
|
JobStatus: string(database.ProvisionerJobStatusCanceled),
|
|
BuildNumber: workspaceBuild.BuildNumber,
|
|
})
|
|
if err != nil {
|
|
api.Logger.Warn(ctx, "failed to publish workspace build update", slog.Error(err))
|
|
}
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
|
Message: "Job has been marked as canceled...",
|
|
})
|
|
}
|
|
|
|
func verifyUserCanCancelWorkspaceBuilds(ctx context.Context, store database.Store, userID uuid.UUID, templateID uuid.UUID, jobStatus database.ProvisionerJobStatus) (bool, error) {
|
|
// If the jobStatus is pending, we always allow cancellation regardless of
|
|
// the template setting as it's non-destructive to Terraform resources.
|
|
if jobStatus == database.ProvisionerJobStatusPending {
|
|
return true, nil
|
|
}
|
|
|
|
template, err := store.GetTemplateByID(ctx, templateID)
|
|
if err != nil {
|
|
return false, xerrors.New("no template exists for this workspace")
|
|
}
|
|
|
|
if template.AllowUserCancelWorkspaceJobs {
|
|
return true, nil // all users can cancel workspace builds
|
|
}
|
|
|
|
user, err := store.GetUserByID(ctx, userID)
|
|
if err != nil {
|
|
return false, xerrors.New("user does not exist")
|
|
}
|
|
return slices.Contains(user.RBACRoles, rbac.RoleOwner().String()), nil // only user with "owner" role can cancel workspace builds
|
|
}
|
|
|
|
// @Summary Get build parameters for workspace build
|
|
// @ID get-build-parameters-for-workspace-build
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Builds
|
|
// @Param workspacebuild path string true "Workspace build ID"
|
|
// @Success 200 {array} codersdk.WorkspaceBuildParameter
|
|
// @Router /workspacebuilds/{workspacebuild}/parameters [get]
|
|
func (api *API) workspaceBuildParameters(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
workspaceBuild := httpmw.WorkspaceBuildParam(r)
|
|
|
|
parameters, err := api.Database.GetWorkspaceBuildParameters(ctx, workspaceBuild.ID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching workspace build parameters.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
apiParameters := db2sdk.WorkspaceBuildParameters(parameters)
|
|
httpapi.Write(ctx, rw, http.StatusOK, apiParameters)
|
|
}
|
|
|
|
// @Summary Get workspace build logs
|
|
// @ID get-workspace-build-logs
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Builds
|
|
// @Param workspacebuild path string true "Workspace build ID"
|
|
// @Param before query int false "Before log id"
|
|
// @Param after query int false "After log id"
|
|
// @Param follow query bool false "Follow log stream"
|
|
// @Param format query string false "Log output format. Accepted: 'json' (default), 'text' (plain text with RFC3339 timestamps and ANSI colors). Not supported with follow=true." Enums(json,text)
|
|
// @Success 200 {array} codersdk.ProvisionerJobLog
|
|
// @Router /workspacebuilds/{workspacebuild}/logs [get]
|
|
func (api *API) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
workspaceBuild := httpmw.WorkspaceBuildParam(r)
|
|
|
|
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)
|
|
}
|
|
|
|
// @Summary Get provisioner state for workspace build
|
|
// @ID get-provisioner-state-for-workspace-build
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Builds
|
|
// @Param workspacebuild path string true "Workspace build ID"
|
|
// @Success 200 {object} codersdk.WorkspaceBuild
|
|
// @Router /workspacebuilds/{workspacebuild}/state [get]
|
|
func (api *API) workspaceBuildState(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
workspaceBuild := httpmw.WorkspaceBuildParam(r)
|
|
|
|
// The dbauthz layer enforces policy.ActionUpdate on the template.
|
|
row, err := api.Database.GetWorkspaceBuildProvisionerStateByID(ctx, workspaceBuild.ID)
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching provisioner state.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
rw.Header().Set("Content-Type", "application/json")
|
|
rw.WriteHeader(http.StatusOK)
|
|
_, _ = rw.Write(row.ProvisionerState)
|
|
}
|
|
|
|
// @Summary Update workspace build state
|
|
// @ID update-workspace-build-state
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Tags Builds
|
|
// @Param workspacebuild path string true "Workspace build ID" format(uuid)
|
|
// @Param request body codersdk.UpdateWorkspaceBuildStateRequest true "Request body"
|
|
// @Success 204
|
|
// @Router /workspacebuilds/{workspacebuild}/state [put]
|
|
func (api *API) workspaceBuildUpdateState(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
|
|
}
|
|
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to get template",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// You must have update permissions on the template to update the state.
|
|
if !api.Authorize(r, policy.ActionUpdate, template.RBACObject()) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
var req codersdk.UpdateWorkspaceBuildStateRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
// Use system context since we've already verified authorization via template permissions.
|
|
// nolint:gocritic // System access required for provisioner state update.
|
|
err = api.Database.UpdateWorkspaceBuildProvisionerStateByID(dbauthz.AsSystemRestricted(ctx), database.UpdateWorkspaceBuildProvisionerStateByIDParams{
|
|
ID: workspaceBuild.ID,
|
|
ProvisionerState: req.State,
|
|
UpdatedAt: dbtime.Now(),
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Failed to update workspace build state.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
rw.WriteHeader(http.StatusNoContent)
|
|
}
|
|
|
|
// @Summary Get workspace build timings by ID
|
|
// @ID get-workspace-build-timings-by-id
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Builds
|
|
// @Param workspacebuild path string true "Workspace build ID" format(uuid)
|
|
// @Success 200 {object} codersdk.WorkspaceBuildTimings
|
|
// @Router /workspacebuilds/{workspacebuild}/timings [get]
|
|
func (api *API) workspaceBuildTimings(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
build = httpmw.WorkspaceBuildParam(r)
|
|
)
|
|
|
|
timings, err := api.buildTimings(ctx, build)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching timings.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, timings)
|
|
}
|
|
|
|
type workspaceBuildsData struct {
|
|
jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow
|
|
templateVersions []database.TemplateVersion
|
|
resources []database.WorkspaceResource
|
|
metadata []database.WorkspaceResourceMetadatum
|
|
agents []database.WorkspaceAgent
|
|
apps []database.WorkspaceApp
|
|
appStatuses []database.WorkspaceAppStatus
|
|
scripts []database.WorkspaceAgentScript
|
|
logSources []database.WorkspaceAgentLogSource
|
|
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
|
|
}
|
|
|
|
func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []database.WorkspaceBuild) (workspaceBuildsData, error) {
|
|
jobIDs := make([]uuid.UUID, 0, len(workspaceBuilds))
|
|
for _, build := range workspaceBuilds {
|
|
jobIDs = append(jobIDs, build.JobID)
|
|
}
|
|
jobs, err := api.Database.GetProvisionerJobsByIDsWithQueuePosition(ctx, database.GetProvisionerJobsByIDsWithQueuePositionParams{
|
|
IDs: jobIDs,
|
|
StaleIntervalMS: provisionerdserver.StaleInterval.Milliseconds(),
|
|
})
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return workspaceBuildsData{}, xerrors.Errorf("get provisioner jobs: %w", err)
|
|
}
|
|
pendingJobIDs := []uuid.UUID{}
|
|
for _, job := range jobs {
|
|
if job.ProvisionerJob.JobStatus == database.ProvisionerJobStatusPending {
|
|
pendingJobIDs = append(pendingJobIDs, job.ProvisionerJob.ID)
|
|
}
|
|
}
|
|
|
|
pendingJobProvisioners, err := api.Database.GetEligibleProvisionerDaemonsByProvisionerJobIDs(ctx, pendingJobIDs)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return workspaceBuildsData{}, xerrors.Errorf("get provisioner daemons: %w", err)
|
|
}
|
|
|
|
templateVersionIDs := make([]uuid.UUID, 0, len(workspaceBuilds))
|
|
for _, build := range workspaceBuilds {
|
|
templateVersionIDs = append(templateVersionIDs, build.TemplateVersionID)
|
|
}
|
|
|
|
// nolint:gocritic // Getting template versions by ID is a system function.
|
|
templateVersions, err := api.Database.GetTemplateVersionsByIDs(dbauthz.AsSystemRestricted(ctx), templateVersionIDs)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return workspaceBuildsData{}, xerrors.Errorf("get template versions: %w", err)
|
|
}
|
|
|
|
// nolint:gocritic // Getting workspace resources by job ID is a system function.
|
|
resources, err := api.Database.GetWorkspaceResourcesByJobIDs(dbauthz.AsSystemRestricted(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{
|
|
jobs: jobs,
|
|
templateVersions: templateVersions,
|
|
provisionerDaemons: pendingJobProvisioners,
|
|
}, nil
|
|
}
|
|
|
|
resourceIDs := make([]uuid.UUID, 0)
|
|
for _, resource := range resources {
|
|
resourceIDs = append(resourceIDs, resource.ID)
|
|
}
|
|
|
|
// nolint:gocritic // Getting workspace resource metadata by resource ID is a system function.
|
|
metadata, err := api.Database.GetWorkspaceResourceMetadataByResourceIDs(dbauthz.AsSystemRestricted(ctx), resourceIDs)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return workspaceBuildsData{}, xerrors.Errorf("fetching resource metadata: %w", err)
|
|
}
|
|
|
|
// nolint:gocritic // Getting workspace agents by resource IDs is a system function.
|
|
agents, err := api.Database.GetWorkspaceAgentsByResourceIDs(dbauthz.AsSystemRestricted(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{
|
|
jobs: jobs,
|
|
templateVersions: templateVersions,
|
|
resources: resources,
|
|
metadata: metadata,
|
|
provisionerDaemons: pendingJobProvisioners,
|
|
}, nil
|
|
}
|
|
|
|
agentIDs := make([]uuid.UUID, 0)
|
|
for _, agent := range agents {
|
|
agentIDs = append(agentIDs, agent.ID)
|
|
}
|
|
|
|
var (
|
|
apps []database.WorkspaceApp
|
|
scripts []database.WorkspaceAgentScript
|
|
logSources []database.WorkspaceAgentLogSource
|
|
)
|
|
|
|
var eg errgroup.Group
|
|
eg.Go(func() (err error) {
|
|
// nolint:gocritic // Getting workspace apps by agent IDs is a system function.
|
|
apps, err = api.Database.GetWorkspaceAppsByAgentIDs(dbauthz.AsSystemRestricted(ctx), agentIDs)
|
|
return err
|
|
})
|
|
eg.Go(func() (err error) {
|
|
// nolint:gocritic // Getting workspace scripts by agent IDs is a system function.
|
|
scripts, err = api.Database.GetWorkspaceAgentScriptsByAgentIDs(dbauthz.AsSystemRestricted(ctx), agentIDs)
|
|
return err
|
|
})
|
|
eg.Go(func() error {
|
|
// nolint:gocritic // Getting workspace agent log sources by agent IDs is a system function.
|
|
logSources, err = api.Database.GetWorkspaceAgentLogSourcesByAgentIDs(dbauthz.AsSystemRestricted(ctx), agentIDs)
|
|
return err
|
|
})
|
|
err = eg.Wait()
|
|
if err != nil {
|
|
return workspaceBuildsData{}, err
|
|
}
|
|
|
|
appIDs := make([]uuid.UUID, 0)
|
|
for _, app := range apps {
|
|
appIDs = append(appIDs, app.ID)
|
|
}
|
|
|
|
// nolint:gocritic // Getting workspace app statuses by app IDs is a system function.
|
|
statuses, err := api.Database.GetWorkspaceAppStatusesByAppIDs(dbauthz.AsSystemRestricted(ctx), appIDs)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return workspaceBuildsData{}, xerrors.Errorf("get workspace app statuses: %w", err)
|
|
}
|
|
|
|
return workspaceBuildsData{
|
|
jobs: jobs,
|
|
templateVersions: templateVersions,
|
|
resources: resources,
|
|
metadata: metadata,
|
|
agents: agents,
|
|
apps: apps,
|
|
appStatuses: statuses,
|
|
scripts: scripts,
|
|
logSources: logSources,
|
|
provisionerDaemons: pendingJobProvisioners,
|
|
}, nil
|
|
}
|
|
|
|
func (api *API) convertWorkspaceBuilds(
|
|
workspaceBuilds []database.WorkspaceBuild,
|
|
workspaces []database.Workspace,
|
|
jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow,
|
|
workspaceResources []database.WorkspaceResource,
|
|
resourceMetadata []database.WorkspaceResourceMetadatum,
|
|
resourceAgents []database.WorkspaceAgent,
|
|
agentApps []database.WorkspaceApp,
|
|
agentAppStatuses []database.WorkspaceAppStatus,
|
|
agentScripts []database.WorkspaceAgentScript,
|
|
agentLogSources []database.WorkspaceAgentLogSource,
|
|
templateVersions []database.TemplateVersion,
|
|
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow,
|
|
) ([]codersdk.WorkspaceBuild, error) {
|
|
workspaceByID := map[uuid.UUID]database.Workspace{}
|
|
for _, workspace := range workspaces {
|
|
workspaceByID[workspace.ID] = workspace
|
|
}
|
|
jobByID := map[uuid.UUID]database.GetProvisionerJobsByIDsWithQueuePositionRow{}
|
|
for _, job := range jobs {
|
|
jobByID[job.ProvisionerJob.ID] = job
|
|
}
|
|
templateVersionByID := map[uuid.UUID]database.TemplateVersion{}
|
|
for _, templateVersion := range templateVersions {
|
|
templateVersionByID[templateVersion.ID] = templateVersion
|
|
}
|
|
|
|
// Should never be nil for API consistency
|
|
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")
|
|
}
|
|
templateVersion, exists := templateVersionByID[build.TemplateVersionID]
|
|
if !exists {
|
|
return nil, xerrors.New("template version not found")
|
|
}
|
|
|
|
apiBuild, err := api.convertWorkspaceBuild(
|
|
build,
|
|
workspace,
|
|
job,
|
|
workspaceResources,
|
|
resourceMetadata,
|
|
resourceAgents,
|
|
agentApps,
|
|
agentAppStatuses,
|
|
agentScripts,
|
|
agentLogSources,
|
|
templateVersion,
|
|
provisionerDaemons,
|
|
)
|
|
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.GetProvisionerJobsByIDsWithQueuePositionRow,
|
|
workspaceResources []database.WorkspaceResource,
|
|
resourceMetadata []database.WorkspaceResourceMetadatum,
|
|
resourceAgents []database.WorkspaceAgent,
|
|
agentApps []database.WorkspaceApp,
|
|
agentAppStatuses []database.WorkspaceAppStatus,
|
|
agentScripts []database.WorkspaceAgentScript,
|
|
agentLogSources []database.WorkspaceAgentLogSource,
|
|
templateVersion database.TemplateVersion,
|
|
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow,
|
|
) (codersdk.WorkspaceBuild, error) {
|
|
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)
|
|
}
|
|
scriptsByAgentID := map[uuid.UUID][]database.WorkspaceAgentScript{}
|
|
for _, script := range agentScripts {
|
|
scriptsByAgentID[script.WorkspaceAgentID] = append(scriptsByAgentID[script.WorkspaceAgentID], script)
|
|
}
|
|
logSourcesByAgentID := map[uuid.UUID][]database.WorkspaceAgentLogSource{}
|
|
for _, logSource := range agentLogSources {
|
|
logSourcesByAgentID[logSource.WorkspaceAgentID] = append(logSourcesByAgentID[logSource.WorkspaceAgentID], logSource)
|
|
}
|
|
provisionerDaemonsForThisWorkspaceBuild := []database.ProvisionerDaemon{}
|
|
for _, provisionerDaemon := range provisionerDaemons {
|
|
if provisionerDaemon.JobID != job.ProvisionerJob.ID {
|
|
continue
|
|
}
|
|
provisionerDaemonsForThisWorkspaceBuild = append(provisionerDaemonsForThisWorkspaceBuild, provisionerDaemon.ProvisionerDaemon)
|
|
}
|
|
matchedProvisioners := db2sdk.MatchedProvisioners(provisionerDaemonsForThisWorkspaceBuild, job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval)
|
|
statusesByAgentID := map[uuid.UUID][]database.WorkspaceAppStatus{}
|
|
for _, status := range agentAppStatuses {
|
|
statusesByAgentID[status.AgentID] = append(statusesByAgentID[status.AgentID], status)
|
|
}
|
|
|
|
resources := resourcesByJobID[job.ProvisionerJob.ID]
|
|
apiResources := make([]codersdk.WorkspaceResource, 0)
|
|
resourceAgentsMinOrder := map[uuid.UUID]int32{} // map[resource.ID]minOrder
|
|
for _, resource := range resources {
|
|
agents := agentsByResourceID[resource.ID]
|
|
sort.Slice(agents, func(i, j int) bool {
|
|
if agents[i].DisplayOrder != agents[j].DisplayOrder {
|
|
return agents[i].DisplayOrder < agents[j].DisplayOrder
|
|
}
|
|
return agents[i].Name < agents[j].Name
|
|
})
|
|
|
|
apiAgents := make([]codersdk.WorkspaceAgent, 0)
|
|
resourceAgentsMinOrder[resource.ID] = math.MaxInt32
|
|
|
|
for _, agent := range agents {
|
|
resourceAgentsMinOrder[resource.ID] = min(resourceAgentsMinOrder[resource.ID], agent.DisplayOrder)
|
|
|
|
apps := appsByAgentID[agent.ID]
|
|
scripts := scriptsByAgentID[agent.ID]
|
|
statuses := statusesByAgentID[agent.ID]
|
|
logSources := logSourcesByAgentID[agent.ID]
|
|
apiAgent, err := db2sdk.WorkspaceAgent(
|
|
api.DERPMap(), *api.TailnetCoordinator.Load(), agent, db2sdk.Apps(apps, statuses, agent, workspace.OwnerUsername, workspace.WorkspaceTable()), convertScripts(scripts), convertLogSources(logSources), api.AgentInactiveDisconnectTimeout,
|
|
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
|
|
)
|
|
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))
|
|
}
|
|
sort.Slice(apiResources, func(i, j int) bool {
|
|
orderI := resourceAgentsMinOrder[apiResources[i].ID]
|
|
orderJ := resourceAgentsMinOrder[apiResources[j].ID]
|
|
if orderI != orderJ {
|
|
return orderI < orderJ
|
|
}
|
|
return apiResources[i].Name < apiResources[j].Name
|
|
})
|
|
|
|
var presetID *uuid.UUID
|
|
if build.TemplateVersionPresetID.Valid {
|
|
presetID = &build.TemplateVersionPresetID.UUID
|
|
}
|
|
var hasAITask *bool
|
|
if build.HasAITask.Valid {
|
|
hasAITask = &build.HasAITask.Bool
|
|
}
|
|
var hasExternalAgent *bool
|
|
if build.HasExternalAgent.Valid {
|
|
hasExternalAgent = &build.HasExternalAgent.Bool
|
|
}
|
|
|
|
apiJob := convertProvisionerJob(job)
|
|
transition := codersdk.WorkspaceTransition(build.Transition)
|
|
return codersdk.WorkspaceBuild{
|
|
ID: build.ID,
|
|
CreatedAt: build.CreatedAt,
|
|
UpdatedAt: build.UpdatedAt,
|
|
WorkspaceOwnerID: workspace.OwnerID,
|
|
WorkspaceOwnerName: workspace.OwnerUsername,
|
|
WorkspaceOwnerAvatarURL: workspace.OwnerAvatarUrl,
|
|
WorkspaceID: build.WorkspaceID,
|
|
WorkspaceName: workspace.Name,
|
|
TemplateVersionID: build.TemplateVersionID,
|
|
TemplateVersionName: templateVersion.Name,
|
|
BuildNumber: build.BuildNumber,
|
|
Transition: transition,
|
|
InitiatorID: build.InitiatorID,
|
|
InitiatorUsername: build.InitiatorByUsername,
|
|
Job: apiJob,
|
|
Deadline: codersdk.NewNullTime(build.Deadline, !build.Deadline.IsZero()),
|
|
MaxDeadline: codersdk.NewNullTime(build.MaxDeadline, !build.MaxDeadline.IsZero()),
|
|
Reason: codersdk.BuildReason(build.Reason),
|
|
Resources: apiResources,
|
|
Status: codersdk.ConvertWorkspaceStatus(apiJob.Status, transition),
|
|
DailyCost: build.DailyCost,
|
|
MatchedProvisioners: &matchedProvisioners,
|
|
TemplateVersionPresetID: presetID,
|
|
HasAITask: hasAITask,
|
|
HasExternalAgent: hasExternalAgent,
|
|
}, nil
|
|
}
|
|
|
|
func convertWorkspaceResource(resource database.WorkspaceResource, agents []codersdk.WorkspaceAgent, metadata []database.WorkspaceResourceMetadatum) codersdk.WorkspaceResource {
|
|
var convertedMetadata []codersdk.WorkspaceResourceMetadata
|
|
for _, field := range metadata {
|
|
convertedMetadata = append(convertedMetadata, codersdk.WorkspaceResourceMetadata{
|
|
Key: field.Key,
|
|
Value: field.Value.String,
|
|
Sensitive: field.Sensitive,
|
|
})
|
|
}
|
|
|
|
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,
|
|
DailyCost: resource.DailyCost,
|
|
}
|
|
}
|
|
|
|
func (api *API) buildTimings(ctx context.Context, build database.WorkspaceBuild) (codersdk.WorkspaceBuildTimings, error) {
|
|
provisionerTimings, err := api.Database.GetProvisionerJobTimingsByJobID(ctx, build.JobID)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return codersdk.WorkspaceBuildTimings{}, xerrors.Errorf("fetching provisioner job timings: %w", err)
|
|
}
|
|
|
|
//nolint:gocritic // Already checked if the build can be fetched.
|
|
agentScriptTimings, err := api.Database.GetWorkspaceAgentScriptTimingsByBuildID(dbauthz.AsSystemRestricted(ctx), build.ID)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return codersdk.WorkspaceBuildTimings{}, xerrors.Errorf("fetching workspace agent script timings: %w", err)
|
|
}
|
|
|
|
resources, err := api.Database.GetWorkspaceResourcesByJobID(ctx, build.JobID)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return codersdk.WorkspaceBuildTimings{}, xerrors.Errorf("fetching workspace resources: %w", err)
|
|
}
|
|
resourceIDs := make([]uuid.UUID, 0, len(resources))
|
|
for _, resource := range resources {
|
|
resourceIDs = append(resourceIDs, resource.ID)
|
|
}
|
|
//nolint:gocritic // Already checked if the build can be fetched.
|
|
agents, err := api.Database.GetWorkspaceAgentsByResourceIDs(dbauthz.AsSystemRestricted(ctx), resourceIDs)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return codersdk.WorkspaceBuildTimings{}, xerrors.Errorf("fetching workspace agents: %w", err)
|
|
}
|
|
|
|
res := codersdk.WorkspaceBuildTimings{
|
|
ProvisionerTimings: make([]codersdk.ProvisionerTiming, 0, len(provisionerTimings)),
|
|
AgentScriptTimings: make([]codersdk.AgentScriptTiming, 0, len(agentScriptTimings)),
|
|
AgentConnectionTimings: make([]codersdk.AgentConnectionTiming, 0, len(agents)),
|
|
}
|
|
|
|
for _, t := range provisionerTimings {
|
|
// Ref: #15432: agent script timings must not have a zero start or end time.
|
|
if t.StartedAt.IsZero() || t.EndedAt.IsZero() {
|
|
api.Logger.Debug(ctx, "ignoring provisioner timing with zero start or end time",
|
|
slog.F("workspace_id", build.WorkspaceID),
|
|
slog.F("workspace_build_id", build.ID),
|
|
slog.F("provisioner_job_id", t.JobID),
|
|
)
|
|
continue
|
|
}
|
|
|
|
res.ProvisionerTimings = append(res.ProvisionerTimings, codersdk.ProvisionerTiming{
|
|
JobID: t.JobID,
|
|
Stage: codersdk.TimingStage(t.Stage),
|
|
Source: t.Source,
|
|
Action: t.Action,
|
|
Resource: t.Resource,
|
|
StartedAt: t.StartedAt,
|
|
EndedAt: t.EndedAt,
|
|
})
|
|
}
|
|
for _, t := range agentScriptTimings {
|
|
// Ref: #15432: agent script timings must not have a zero start or end time.
|
|
if t.StartedAt.IsZero() || t.EndedAt.IsZero() {
|
|
api.Logger.Debug(ctx, "ignoring agent script timing with zero start or end time",
|
|
slog.F("workspace_id", build.WorkspaceID),
|
|
slog.F("workspace_agent_id", t.WorkspaceAgentID),
|
|
slog.F("workspace_build_id", build.ID),
|
|
slog.F("workspace_agent_script_id", t.ScriptID),
|
|
)
|
|
continue
|
|
}
|
|
|
|
res.AgentScriptTimings = append(res.AgentScriptTimings, codersdk.AgentScriptTiming{
|
|
StartedAt: t.StartedAt,
|
|
EndedAt: t.EndedAt,
|
|
ExitCode: t.ExitCode,
|
|
Stage: codersdk.TimingStage(t.Stage),
|
|
Status: string(t.Status),
|
|
DisplayName: t.DisplayName,
|
|
WorkspaceAgentID: t.WorkspaceAgentID.String(),
|
|
WorkspaceAgentName: t.WorkspaceAgentName,
|
|
})
|
|
}
|
|
for _, agent := range agents {
|
|
if agent.FirstConnectedAt.Time.IsZero() {
|
|
api.Logger.Debug(ctx, "ignoring agent connection timing with zero first connected time",
|
|
slog.F("workspace_id", build.WorkspaceID),
|
|
slog.F("workspace_agent_id", agent.ID),
|
|
slog.F("workspace_build_id", build.ID),
|
|
)
|
|
continue
|
|
}
|
|
res.AgentConnectionTimings = append(res.AgentConnectionTimings, codersdk.AgentConnectionTiming{
|
|
WorkspaceAgentID: agent.ID.String(),
|
|
WorkspaceAgentName: agent.Name,
|
|
StartedAt: agent.CreatedAt,
|
|
Stage: codersdk.TimingStageConnect,
|
|
EndedAt: agent.FirstConnectedAt.Time,
|
|
})
|
|
}
|
|
|
|
return res, nil
|
|
}
|