mirror of
https://github.com/coder/coder.git
synced 2026-10-31 02:43:21 +08:00
This is the first of three PRs implementing lite `codersdk.Workspace` responses ([GRU-82](https://linear.app/codercom/issue/GRU-82), [RFC](https://app.notion.com/p/coderhq/Lite-Workspace-API-Requests-3aad579be5928060b5e7cc65c539717f)). A fully populated workspace fans out into a large tree of DB queries (template, latest build, provisioner job + queue position, resources, metadata, agents, apps, statuses, scripts, log sources, template version). Most callers do not need most of that data, and one of these queries, `GetProvisionerJobsByIDsWithQueuePosition`, is consistently the top resource consumer in scale tests. This PR adds the internal plumbing to load only a selected subset of that tree. It introduces a `workspaceRelated` selection type and threads it through `workspaceData` and `workspaceBuildsData` down to the individual query call sites, skipping any query whose data was not selected. The selection is a pointer tree that mirrors the parent/child relationships between the objects: branch nodes are pointers (non-nil when selected) and leaf nodes are bools. Modeling it this way makes selecting a child without its parent unrepresentable, which matches how loading works (a parent must be queried to learn its children's identifiers), so there is no normalization step to forget. No behavior change: every existing caller passes a fully populated selection (`allWorkspaceRelated` / `allLatestBuildRelated`), so responses are identical. Wiring the HTTP `include_related` query parameter (Phase 2) and narrowing individual callers (Phase 3) come in follow-up PRs. Two notes on the current mapping: - `queue_position` is modeled as its own node, but only one job query exists and it always computes the ranking, so selecting `job` runs the full query today. Splitting off a cheaper queue-position-free job query is deferred (it needs a new SQL query + `make gen`). - The workspace-level `LatestAppStatus` is gated on the `latest_build.resources.agents.apps.statuses` node. It is queried directly by workspace ID, but a caller selecting it also pulls in the build chain as ancestors. Happy to decouple this if preferred. <details> <summary>Implementation plan</summary> # Lite Workspace API — Phase 1 Plan Add an internal configuration structure to the Coderd method that fetches a workspace with its related data (`workspaceData` /`workspaceBuildsData`), thread it down to the SQL query call sites, and skip ("squash") queries that are not requested. Server-internal only. No HTTP query-string parsing (Phase 2) and no `wsrelated` struct tags or field nil-ing (Phase 3). ## Scope - In: config struct mapping the RFC related-data hierarchy; threading through `workspaceData` and `workspaceBuildsData`; gating every related-data query; comprehensive unit tests with a mocked `database.Store` (gomock/dbmock). - Out: HTTP `include_related` parsing, codersdk changes, `wsrelated` tags, docs autogen, and changing `convertWorkspaces` skip-on-missing-data behavior. All existing callers pass an "include all" config, so Phase 1 is a behavior- preserving refactor. ## Design New file `coderd/wsrelateddata.go` with a pointer tree that mirrors the RFC hierarchy. Branch nodes are pointers (present when non-nil); leaf nodes are bools. Because a child cannot be expressed without its parent, the RFC "includes all ancestors" rule is a structural invariant of the type: an illegal selection is unrepresentable, so no normalization pass exists or is needed. ```go type workspaceRelated struct { Template bool LatestBuild *latestBuildRelated } type latestBuildRelated struct { Job *jobRelated Resources *resourcesRelated TemplateVersion bool } type jobRelated struct{ QueuePosition bool } type resourcesRelated struct { Metadata bool Agents *agentsRelated } type agentsRelated struct { Apps *appsRelated Scripts bool LogSources bool } type appsRelated struct{ Statuses bool } ``` Helpers: - `allWorkspaceRelated()` / `allLatestBuildRelated()` construct a fully populated tree. Passed by all current callers so behavior is unchanged. - Zero value (nil subtree) includes nothing but the workspace itself. - `(*latestBuildRelated).appStatuses()` is a nil-safe accessor for the deep `apps.statuses` leaf so call sites descend the tree without a guard chain. Rationale for the pointer tree over a flat struct plus `withAncestors()`: the invariant holds by construction (illegal state is unrepresentable) instead of relying on a normalization step a caller must remember. `workspaceBuildsData` takes the `*latestBuildRelated` subtree directly. ### Query gating `workspaceData(ctx, workspaces, cfg)`: - `GetTemplatesWithFilter` gated on `Template`. - `GetLatestWorkspaceBuildsByWorkspaceIDs` gated on `LatestBuild`; when off, skip `workspaceBuildsData` entirely (empty builds). - `GetLatestWorkspaceAppStatusesByWorkspaceIDs` (workspace-level LatestAppStatus) gated on `AppStatuses`. `workspaceBuildsData(ctx, builds, cfg)` gates each stage: - jobs (`GetProvisionerJobsByIDsWithQueuePosition`) + eligible provisioner daemons gated on `Job`. Only one job query exists and it always computes the queue position, so `QueuePosition` does not gate a separate query yet; it is modeled for hierarchy/Phase 2 completeness. A cheaper job query without the window-function ranking is a worthwhile follow-up but is out of scope for a simple Phase 1 (needs new SQL + sqlc gen). - resources gated on `Resources`; metadata on `Metadata`; agents on `Agents`; apps on `Apps`; app statuses on `AppStatuses`; scripts on `Scripts`; log sources on `LogSources`; template versions on `TemplateVersion`. Downstream `convertWorkspaceBuilds` already tolerates empty slices, so squashing queries yields zero/nil fields without extra work. ## Testing (comprehensive, mocked database.Store) Internal tests (`coderd/wsrelateddata_internal_test.go`) build `&API{Options: &Options{Database: dbmock.NewMockStore(ctrl)}}` and call `workspaceData` directly. gomock is strict, so setting EXPECT only on permitted queries proves the rest are squashed. Cases: include-all (every query runs); zero value (no related queries run); each single node (template only, latest_build only, job, resources, metadata, agents, apps, statuses, scripts, log_sources, template_version, workspace-level app statuses); the empty-resources short-circuit; and the constructors plus the nil-safe `appStatuses()` accessor at every depth. ## Steps 1. Add `coderd/wsrelateddata.go` (selection tree, constructors, `appStatuses`). 2. Thread the selection through `workspaceData` and `workspaceBuildsData`; gate queries by descending the tree. 3. Update the callers to pass `allWorkspaceRelated()` / `allLatestBuildRelated()`. 4. Add unit tests; run `make fmt`, `make lint`, targeted `go test`. ## Resolved decisions 1. Pointer tree over a flat struct + normalizer, so the ancestor rule is a structural invariant. 2. Cheaper queue-position-free job query deferred to a follow-up. </details> --- *Generated by Coder Agents on behalf of @spikecurtis.*
1647 lines
58 KiB
Go
1647 lines
58 KiB
Go
package coderd
|
|
|
|
import (
|
|
"cmp"
|
|
"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 /api/v2/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}, allLatestBuildRelated())
|
|
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],
|
|
newWorkspaceBuildIndex(
|
|
data.resources,
|
|
data.metadata,
|
|
data.agents,
|
|
data.apps,
|
|
data.appStatuses,
|
|
data.scripts,
|
|
data.logSources,
|
|
nil,
|
|
),
|
|
data.templateVersions[0],
|
|
)
|
|
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 /api/v2/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, allLatestBuildRelated())
|
|
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 /api/v2/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}, allLatestBuildRelated())
|
|
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],
|
|
newWorkspaceBuildIndex(
|
|
data.resources,
|
|
data.metadata,
|
|
data.agents,
|
|
data.apps,
|
|
data.appStatuses,
|
|
data.scripts,
|
|
data.logSources,
|
|
data.provisionerDaemons,
|
|
),
|
|
data.templateVersions[0],
|
|
)
|
|
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 201 {object} codersdk.WorkspaceBuild
|
|
// @Router /api/v2/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,
|
|
) {
|
|
if err := validateCreateWorkspaceBuildOnSuccess(createBuild); err != nil {
|
|
return codersdk.WorkspaceBuild{}, err
|
|
}
|
|
|
|
var childParameterValuesJSON json.RawMessage
|
|
if createBuild.OnSuccess != nil {
|
|
childParameterValues := createBuild.OnSuccess.RichParameterValues
|
|
if childParameterValues == nil {
|
|
childParameterValues = []codersdk.WorkspaceBuildParameter{}
|
|
}
|
|
|
|
var err error
|
|
childParameterValuesJSON, err = json.Marshal(childParameterValues)
|
|
if err != nil {
|
|
return codersdk.WorkspaceBuild{}, httperror.NewResponseError(
|
|
http.StatusInternalServerError,
|
|
codersdk.Response{
|
|
Message: "Internal error preparing follow-up workspace build parameters",
|
|
Detail: err.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,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if createBuild.OnSuccess != nil {
|
|
onSuccessReq := createBuild.OnSuccess
|
|
// Reuse the parent build's timestamps so the orchestration row and
|
|
// the parent build agree on created_at.
|
|
_, err = tx.InsertWorkspaceBuildOrchestration(ctx, database.InsertWorkspaceBuildOrchestrationParams{
|
|
ID: uuid.New(),
|
|
CreatedAt: workspaceBuild.CreatedAt,
|
|
UpdatedAt: workspaceBuild.UpdatedAt,
|
|
ParentBuildID: workspaceBuild.ID,
|
|
ChildTransition: database.WorkspaceTransition(onSuccessReq.Transition),
|
|
ChildTemplateVersionID: uuid.NullUUID{
|
|
UUID: onSuccessReq.TemplateVersionID,
|
|
Valid: onSuccessReq.TemplateVersionID != uuid.Nil,
|
|
},
|
|
ChildTemplateVersionPresetID: uuid.NullUUID{
|
|
UUID: onSuccessReq.TemplateVersionPresetID,
|
|
Valid: onSuccessReq.TemplateVersionPresetID != uuid.Nil,
|
|
},
|
|
ChildRichParameterValues: childParameterValuesJSON,
|
|
ChildLogLevel: string(createBuild.LogLevel),
|
|
ChildReason: database.NullBuildReason{
|
|
BuildReason: database.BuildReason(createBuild.Reason),
|
|
Valid: createBuild.Reason != "",
|
|
},
|
|
})
|
|
if err != nil {
|
|
if dbauthz.IsNotAuthorizedError(err) {
|
|
detail := "Queuing the follow-up workspace build requires permission to start the workspace."
|
|
if onSuccessReq.TemplateVersionID != uuid.Nil {
|
|
detail = "Pinning a template version on the follow-up build requires template update permission. Omit template_version_id to use the active version."
|
|
}
|
|
return httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
|
|
Message: "Unauthorized to queue follow-up workspace build.",
|
|
Detail: detail,
|
|
})
|
|
}
|
|
api.Logger.Error(ctx, "failed to queue follow-up workspace build",
|
|
slog.F("workspace_id", workspace.ID),
|
|
slog.F("workspace_build_id", workspaceBuild.ID),
|
|
slog.Error(err),
|
|
)
|
|
return httperror.NewResponseError(
|
|
http.StatusInternalServerError,
|
|
codersdk.Response{
|
|
Message: "Internal error queueing follow-up workspace build",
|
|
Detail: err.Error(),
|
|
},
|
|
)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}, 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,
|
|
newWorkspaceBuildIndex(nil, nil, nil, nil, nil, nil, nil, provisionerDaemons),
|
|
database.TemplateVersion{},
|
|
)
|
|
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
|
|
}
|
|
|
|
// validateCreateWorkspaceBuildOnSuccess enforces the subset of build options
|
|
// that currently has well-defined stop-then-start semantics.
|
|
func validateCreateWorkspaceBuildOnSuccess(createBuild codersdk.CreateWorkspaceBuildRequest) error {
|
|
onSuccess := createBuild.OnSuccess
|
|
if onSuccess == nil {
|
|
return nil
|
|
}
|
|
|
|
if createBuild.Transition != codersdk.WorkspaceTransitionStop {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "OnSuccess is only permitted when stopping a workspace.",
|
|
})
|
|
}
|
|
if onSuccess.Transition != codersdk.WorkspaceTransitionStart {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "OnSuccess transition must be start.",
|
|
})
|
|
}
|
|
if createBuild.DryRun {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "OnSuccess cannot be set alongside DryRun.",
|
|
})
|
|
}
|
|
if createBuild.Orphan {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "OnSuccess cannot be set alongside Orphan.",
|
|
})
|
|
}
|
|
if len(createBuild.ProvisionerState) > 0 {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "OnSuccess cannot be set alongside ProvisionerState.",
|
|
})
|
|
}
|
|
if onSuccess.TemplateVersionPresetID != uuid.Nil && onSuccess.TemplateVersionID == uuid.Nil {
|
|
return httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
|
Message: "OnSuccess TemplateVersionPresetID requires TemplateVersionID.",
|
|
})
|
|
}
|
|
|
|
return 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 /api/v2/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,
|
|
})
|
|
err = wspubsub.PublishWorkspaceBuildOrchestrationWake(ctx, api.Pubsub)
|
|
if err != nil {
|
|
api.Logger.Warn(ctx, "failed to publish workspace build orchestration wake", slog.Error(err))
|
|
}
|
|
|
|
// 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 /api/v2/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 /api/v2/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 /api/v2/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 /api/v2/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 /api/v2/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.GetWorkspaceAgentScriptsByAgentIDsRow
|
|
logSources []database.WorkspaceAgentLogSource
|
|
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
|
|
}
|
|
|
|
func (api *API) workspaceBuildsData(ctx context.Context, workspaceBuilds []database.WorkspaceBuild, cfg latestBuildRelated) (workspaceBuildsData, error) {
|
|
jobIDs := make([]uuid.UUID, 0, len(workspaceBuilds))
|
|
for _, build := range workspaceBuilds {
|
|
jobIDs = append(jobIDs, build.JobID)
|
|
}
|
|
|
|
var (
|
|
jobs []database.GetProvisionerJobsByIDsWithQueuePositionRow
|
|
pendingJobProvisioners []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
|
|
)
|
|
if cfg.Job != nil {
|
|
var err error
|
|
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)
|
|
}
|
|
}
|
|
|
|
var templateVersions []database.TemplateVersion
|
|
if cfg.TemplateVersion {
|
|
templateVersionIDs := make([]uuid.UUID, 0, len(workspaceBuilds))
|
|
for _, build := range workspaceBuilds {
|
|
templateVersionIDs = append(templateVersionIDs, build.TemplateVersionID)
|
|
}
|
|
|
|
var err error
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
if cfg.Resources == nil {
|
|
return workspaceBuildsData{
|
|
jobs: jobs,
|
|
templateVersions: templateVersions,
|
|
provisionerDaemons: pendingJobProvisioners,
|
|
}, nil
|
|
}
|
|
|
|
// 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)
|
|
}
|
|
|
|
var (
|
|
metadata []database.WorkspaceResourceMetadatum
|
|
agents []database.WorkspaceAgent
|
|
egRes errgroup.Group
|
|
)
|
|
if cfg.Resources.Metadata {
|
|
egRes.Go(func() (err error) {
|
|
// 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 xerrors.Errorf("fetching resource metadata: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
if cfg.Resources.Agents != nil {
|
|
egRes.Go(func() (err error) {
|
|
// 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 xerrors.Errorf("get workspace agents: %w", err)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
if err := egRes.Wait(); err != nil {
|
|
return workspaceBuildsData{}, err
|
|
}
|
|
|
|
if cfg.Resources.Agents == nil || len(agents) == 0 {
|
|
return workspaceBuildsData{
|
|
jobs: jobs,
|
|
templateVersions: templateVersions,
|
|
resources: resources,
|
|
metadata: metadata,
|
|
provisionerDaemons: pendingJobProvisioners,
|
|
}, nil
|
|
}
|
|
agentsCfg := cfg.Resources.Agents
|
|
|
|
agentIDs := make([]uuid.UUID, 0)
|
|
for _, agent := range agents {
|
|
agentIDs = append(agentIDs, agent.ID)
|
|
}
|
|
|
|
var (
|
|
apps []database.WorkspaceApp
|
|
scripts []database.GetWorkspaceAgentScriptsByAgentIDsRow
|
|
logSources []database.WorkspaceAgentLogSource
|
|
)
|
|
|
|
var eg errgroup.Group
|
|
if agentsCfg.Apps != nil {
|
|
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
|
|
})
|
|
}
|
|
if agentsCfg.Scripts {
|
|
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
|
|
})
|
|
}
|
|
if agentsCfg.LogSources {
|
|
eg.Go(func() error {
|
|
var err 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
|
|
})
|
|
}
|
|
if err := eg.Wait(); err != nil {
|
|
return workspaceBuildsData{}, err
|
|
}
|
|
|
|
var statuses []database.WorkspaceAppStatus
|
|
if cfg.appStatuses() {
|
|
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
|
|
}
|
|
|
|
// workspaceBuildIndex groups build rows by the ID they are looked up with.
|
|
// Conversion reorders rows within a bucket in place, so an index must be read
|
|
// by a single goroutine.
|
|
type workspaceBuildIndex struct {
|
|
resourcesByJobID map[uuid.UUID][]database.WorkspaceResource
|
|
metadataByResourceID map[uuid.UUID][]database.WorkspaceResourceMetadatum
|
|
agentsByResourceID map[uuid.UUID][]database.WorkspaceAgent
|
|
appsByAgentID map[uuid.UUID][]database.WorkspaceApp
|
|
statusesByAgentID map[uuid.UUID][]database.WorkspaceAppStatus
|
|
scriptsByAgentID map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow
|
|
logSourcesByAgentID map[uuid.UUID][]database.WorkspaceAgentLogSource
|
|
daemonsByJobID map[uuid.UUID][]database.ProvisionerDaemon
|
|
}
|
|
|
|
// newWorkspaceBuildIndex makes one pass over each slice, copying rows into
|
|
// per-ID buckets. The input slices are neither modified nor aliased.
|
|
func newWorkspaceBuildIndex(
|
|
workspaceResources []database.WorkspaceResource,
|
|
resourceMetadata []database.WorkspaceResourceMetadatum,
|
|
resourceAgents []database.WorkspaceAgent,
|
|
agentApps []database.WorkspaceApp,
|
|
agentAppStatuses []database.WorkspaceAppStatus,
|
|
agentScripts []database.GetWorkspaceAgentScriptsByAgentIDsRow,
|
|
agentLogSources []database.WorkspaceAgentLogSource,
|
|
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow,
|
|
) *workspaceBuildIndex {
|
|
index := &workspaceBuildIndex{
|
|
resourcesByJobID: make(map[uuid.UUID][]database.WorkspaceResource),
|
|
metadataByResourceID: make(map[uuid.UUID][]database.WorkspaceResourceMetadatum),
|
|
agentsByResourceID: make(map[uuid.UUID][]database.WorkspaceAgent),
|
|
appsByAgentID: make(map[uuid.UUID][]database.WorkspaceApp),
|
|
statusesByAgentID: make(map[uuid.UUID][]database.WorkspaceAppStatus),
|
|
scriptsByAgentID: make(map[uuid.UUID][]database.GetWorkspaceAgentScriptsByAgentIDsRow),
|
|
logSourcesByAgentID: make(map[uuid.UUID][]database.WorkspaceAgentLogSource),
|
|
daemonsByJobID: make(map[uuid.UUID][]database.ProvisionerDaemon),
|
|
}
|
|
for _, resource := range workspaceResources {
|
|
index.resourcesByJobID[resource.JobID] = append(index.resourcesByJobID[resource.JobID], resource)
|
|
}
|
|
for _, metadata := range resourceMetadata {
|
|
index.metadataByResourceID[metadata.WorkspaceResourceID] = append(index.metadataByResourceID[metadata.WorkspaceResourceID], metadata)
|
|
}
|
|
for _, agent := range resourceAgents {
|
|
index.agentsByResourceID[agent.ResourceID] = append(index.agentsByResourceID[agent.ResourceID], agent)
|
|
}
|
|
for _, app := range agentApps {
|
|
index.appsByAgentID[app.AgentID] = append(index.appsByAgentID[app.AgentID], app)
|
|
}
|
|
for _, status := range agentAppStatuses {
|
|
index.statusesByAgentID[status.AgentID] = append(index.statusesByAgentID[status.AgentID], status)
|
|
}
|
|
for _, script := range agentScripts {
|
|
index.scriptsByAgentID[script.WorkspaceAgentID] = append(index.scriptsByAgentID[script.WorkspaceAgentID], script)
|
|
}
|
|
for _, logSource := range agentLogSources {
|
|
index.logSourcesByAgentID[logSource.WorkspaceAgentID] = append(index.logSourcesByAgentID[logSource.WorkspaceAgentID], logSource)
|
|
}
|
|
for _, daemon := range provisionerDaemons {
|
|
index.daemonsByJobID[daemon.JobID] = append(index.daemonsByJobID[daemon.JobID], daemon.ProvisionerDaemon)
|
|
}
|
|
// Agents within a resource are ordered by display order, then name.
|
|
for _, agents := range index.agentsByResourceID {
|
|
slices.SortFunc(agents, func(a, b database.WorkspaceAgent) int {
|
|
return cmp.Or(
|
|
cmp.Compare(a.DisplayOrder, b.DisplayOrder),
|
|
cmp.Compare(a.Name, b.Name),
|
|
)
|
|
})
|
|
}
|
|
return index
|
|
}
|
|
|
|
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.GetWorkspaceAgentScriptsByAgentIDsRow,
|
|
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
|
|
}
|
|
|
|
index := newWorkspaceBuildIndex(
|
|
workspaceResources,
|
|
resourceMetadata,
|
|
resourceAgents,
|
|
agentApps,
|
|
agentAppStatuses,
|
|
agentScripts,
|
|
agentLogSources,
|
|
provisionerDaemons,
|
|
)
|
|
|
|
// 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,
|
|
index,
|
|
templateVersion,
|
|
)
|
|
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,
|
|
index *workspaceBuildIndex,
|
|
templateVersion database.TemplateVersion,
|
|
) (codersdk.WorkspaceBuild, error) {
|
|
matchedProvisioners := db2sdk.MatchedProvisioners(index.daemonsByJobID[job.ProvisionerJob.ID], job.ProvisionerJob.CreatedAt, provisionerdserver.StaleInterval)
|
|
|
|
resources := index.resourcesByJobID[job.ProvisionerJob.ID]
|
|
apiResources := make([]codersdk.WorkspaceResource, 0)
|
|
resourceAgentsMinOrder := map[uuid.UUID]int32{} // map[resource.ID]minOrder
|
|
for _, resource := range resources {
|
|
agents := index.agentsByResourceID[resource.ID]
|
|
|
|
apiAgents := make([]codersdk.WorkspaceAgent, 0)
|
|
resourceAgentsMinOrder[resource.ID] = math.MaxInt32
|
|
|
|
for _, agent := range agents {
|
|
resourceAgentsMinOrder[resource.ID] = min(resourceAgentsMinOrder[resource.ID], agent.DisplayOrder)
|
|
|
|
apps := index.appsByAgentID[agent.ID]
|
|
scripts := index.scriptsByAgentID[agent.ID]
|
|
statuses := index.statusesByAgentID[agent.ID]
|
|
logSources := index.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), index.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
|
|
}
|