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.*
3188 lines
103 KiB
Go
3188 lines
103 KiB
Go
package coderd
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/json"
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/dustin/go-humanize"
|
||
"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/agent/proto"
|
||
"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/prebuilds"
|
||
"github.com/coder/coder/v2/coderd/rbac"
|
||
"github.com/coder/coder/v2/coderd/rbac/acl"
|
||
"github.com/coder/coder/v2/coderd/rbac/policy"
|
||
"github.com/coder/coder/v2/coderd/schedule"
|
||
"github.com/coder/coder/v2/coderd/schedule/cron"
|
||
"github.com/coder/coder/v2/coderd/searchquery"
|
||
"github.com/coder/coder/v2/coderd/telemetry"
|
||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||
"github.com/coder/coder/v2/coderd/util/slice"
|
||
"github.com/coder/coder/v2/coderd/wsbuilder"
|
||
"github.com/coder/coder/v2/coderd/wspubsub"
|
||
"github.com/coder/coder/v2/codersdk"
|
||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||
"github.com/coder/coder/v2/codersdk/wsjson"
|
||
"github.com/coder/websocket"
|
||
)
|
||
|
||
var (
|
||
ttlMinimum = time.Minute
|
||
ttlMaximum = 30 * 24 * time.Hour
|
||
|
||
errTTLMin = xerrors.New("time until shutdown must be at least one minute")
|
||
errTTLMax = xerrors.New("time until shutdown must be less than 30 days")
|
||
errDeadlineTooSoon = xerrors.New("new deadline must be at least 30 minutes in the future")
|
||
errDeadlineBeforeStart = xerrors.New("new deadline must be before workspace start time")
|
||
)
|
||
|
||
// @Summary Get workspace metadata by ID
|
||
// @ID get-workspace-metadata-by-id
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param include_deleted query bool false "Return data instead of HTTP 404 if the workspace is deleted"
|
||
// @Success 200 {object} codersdk.Workspace
|
||
// @Router /api/v2/workspaces/{workspace} [get]
|
||
func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
workspace := httpmw.WorkspaceParam(r)
|
||
apiKey := httpmw.APIKey(r)
|
||
|
||
var (
|
||
deletedStr = r.URL.Query().Get("include_deleted")
|
||
showDeleted = false
|
||
)
|
||
if deletedStr != "" {
|
||
var err error
|
||
showDeleted, err = strconv.ParseBool(deletedStr)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: fmt.Sprintf("Invalid boolean value %q for \"include_deleted\" query param.", deletedStr),
|
||
Validations: []codersdk.ValidationError{
|
||
{Field: "deleted", Detail: "Must be a valid boolean"},
|
||
},
|
||
})
|
||
return
|
||
}
|
||
}
|
||
if workspace.Deleted && !showDeleted {
|
||
httpapi.Write(ctx, rw, http.StatusGone, codersdk.Response{
|
||
Message: fmt.Sprintf("Workspace %q was deleted, you can view this workspace by specifying '?include_deleted=true' and trying again.", workspace.ID.String()),
|
||
})
|
||
return
|
||
}
|
||
|
||
data, err := api.workspaceData(ctx, []database.Workspace{workspace}, allWorkspaceRelated())
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace resources.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
if len(data.templates) == 0 {
|
||
httpapi.Forbidden(rw)
|
||
return
|
||
}
|
||
|
||
appStatus := codersdk.WorkspaceAppStatus{}
|
||
if len(data.appStatuses) > 0 {
|
||
appStatus = data.appStatuses[0]
|
||
}
|
||
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
api.Logger,
|
||
apiKey.UserID,
|
||
workspace,
|
||
data.builds[0],
|
||
data.templates[0],
|
||
api.Options.AllowWorkspaceRenames,
|
||
appStatus,
|
||
)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
httpapi.Write(ctx, rw, http.StatusOK, w)
|
||
}
|
||
|
||
// workspaces returns all workspaces a user can read.
|
||
// Optional filters with query params
|
||
//
|
||
// @Summary List workspaces
|
||
// @ID list-workspaces
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param q query string false "Search query in the format `key:value`. Available keys are: owner, template, name, status, has-agent, dormant, last_used_after, last_used_before, has-ai-task, has_external_agent, healthy, include_agent_metadata (expands each agent with the named metadata keys rather than filtering; repeat the key for multiple items)."
|
||
// @Param limit query int false "Page limit"
|
||
// @Param offset query int false "Page offset"
|
||
// @Success 200 {object} codersdk.WorkspacesResponse
|
||
// @Router /api/v2/workspaces [get]
|
||
func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
apiKey := httpmw.APIKey(r)
|
||
|
||
page, ok := ParsePagination(rw, r)
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
queryStr := r.URL.Query().Get("q")
|
||
filter, errs := searchquery.Workspaces(ctx, api.Database, queryStr, page, api.AgentInactiveDisconnectTimeout, apiKey.UserID)
|
||
if len(errs) > 0 {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid workspace search query.",
|
||
Validations: errs,
|
||
})
|
||
return
|
||
}
|
||
|
||
if filter.OwnerUsername == "me" {
|
||
filter.OwnerID = apiKey.UserID
|
||
filter.OwnerUsername = ""
|
||
}
|
||
|
||
// To show the requester's favorite workspaces first, we pass their userID and compare it to
|
||
// the workspace owner_id when ordering the rows.
|
||
filter.RequesterID = apiKey.UserID
|
||
|
||
// We need the technical row to present the correct count on every page.
|
||
filter.WithSummary = true
|
||
|
||
// GetWorkspaces authorizes the query itself, so we don't prepare a SQL
|
||
// filter here.
|
||
workspaceRows, err := api.Database.GetWorkspaces(ctx, filter)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspaces.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
if len(workspaceRows) == 0 {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspaces.",
|
||
Detail: "Workspace summary row is missing.",
|
||
})
|
||
return
|
||
}
|
||
if len(workspaceRows) == 1 {
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
|
||
Workspaces: []codersdk.Workspace{},
|
||
Count: int(workspaceRows[0].Count),
|
||
})
|
||
return
|
||
}
|
||
// Skip technical summary row
|
||
workspaceRows = workspaceRows[:len(workspaceRows)-1]
|
||
|
||
if len(workspaceRows) == 0 {
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
|
||
Workspaces: []codersdk.Workspace{},
|
||
Count: 0,
|
||
})
|
||
return
|
||
}
|
||
|
||
workspaces, err := database.ConvertWorkspaceRows(workspaceRows)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace rows.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
data, err := api.workspaceData(ctx, workspaces, allWorkspaceRelated())
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace resources.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
wss, err := convertWorkspaces(
|
||
ctx,
|
||
api.Logger,
|
||
apiKey.UserID,
|
||
workspaces,
|
||
data,
|
||
)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspaces.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
if len(filter.IncludeAgentMetadata) > 0 {
|
||
attachAgentMetadata(ctx, api.Logger, wss, workspaceRows)
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
|
||
Workspaces: wss,
|
||
Count: int(workspaceRows[0].Count),
|
||
})
|
||
}
|
||
|
||
// @Summary Get workspace metadata by user and workspace name
|
||
// @ID get-workspace-metadata-by-user-and-workspace-name
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param user path string true "User ID, name, or me"
|
||
// @Param workspacename path string true "Workspace name"
|
||
// @Param include_deleted query bool false "Return data instead of HTTP 404 if the workspace is deleted"
|
||
// @Success 200 {object} codersdk.Workspace
|
||
// @Router /api/v2/users/{user}/workspace/{workspacename} [get]
|
||
func (api *API) workspaceByOwnerAndName(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
|
||
mems := httpmw.OrganizationMembersParam(r)
|
||
workspaceName := chi.URLParam(r, "workspacename")
|
||
apiKey := httpmw.APIKey(r)
|
||
|
||
includeDeleted := false
|
||
if s := r.URL.Query().Get("include_deleted"); s != "" {
|
||
var err error
|
||
includeDeleted, err = strconv.ParseBool(s)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: fmt.Sprintf("Invalid boolean value %q for \"include_deleted\" query param.", s),
|
||
Validations: []codersdk.ValidationError{
|
||
{Field: "include_deleted", Detail: "Must be a valid boolean"},
|
||
},
|
||
})
|
||
return
|
||
}
|
||
}
|
||
|
||
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
|
||
OwnerID: mems.UserID(),
|
||
Name: workspaceName,
|
||
})
|
||
if includeDeleted && errors.Is(err, sql.ErrNoRows) {
|
||
workspace, err = api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
|
||
OwnerID: mems.UserID(),
|
||
Name: workspaceName,
|
||
Deleted: includeDeleted,
|
||
})
|
||
}
|
||
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
|
||
}
|
||
|
||
data, err := api.workspaceData(ctx, []database.Workspace{workspace}, allWorkspaceRelated())
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace resources.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
if len(data.builds) == 0 || len(data.templates) == 0 {
|
||
httpapi.ResourceNotFound(rw)
|
||
return
|
||
}
|
||
|
||
appStatus := codersdk.WorkspaceAppStatus{}
|
||
if len(data.appStatuses) > 0 {
|
||
appStatus = data.appStatuses[0]
|
||
}
|
||
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
api.Logger,
|
||
apiKey.UserID,
|
||
workspace,
|
||
data.builds[0],
|
||
data.templates[0],
|
||
api.Options.AllowWorkspaceRenames,
|
||
appStatus,
|
||
)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
httpapi.Write(ctx, rw, http.StatusOK, w)
|
||
}
|
||
|
||
// Create a new workspace for the currently authenticated user.
|
||
//
|
||
// @Summary Create user workspace by organization
|
||
// @Description Create a new workspace using a template. The request must
|
||
// @Description specify either the Template ID or the Template Version ID,
|
||
// @Description not both. If the Template ID is specified, the active version
|
||
// @Description of the template will be used.
|
||
// @Deprecated Use /users/{user}/workspaces instead.
|
||
// @ID create-user-workspace-by-organization
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param organization path string true "Organization ID" format(uuid)
|
||
// @Param user path string true "Username, UUID, or me"
|
||
// @Param request body codersdk.CreateWorkspaceRequest true "Create workspace request"
|
||
// @Success 201 {object} codersdk.Workspace
|
||
// @Router /api/v2/organizations/{organization}/members/{user}/workspaces [post]
|
||
func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
apiKey = httpmw.APIKey(r)
|
||
auditor = api.Auditor.Load()
|
||
organization = httpmw.OrganizationParam(r)
|
||
member = httpmw.OrganizationMemberParam(r)
|
||
workspaceResourceInfo = audit.AdditionalFields{
|
||
WorkspaceOwner: member.Username,
|
||
}
|
||
)
|
||
|
||
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionCreate,
|
||
AdditionalFields: workspaceResourceInfo,
|
||
OrganizationID: organization.ID,
|
||
})
|
||
|
||
defer commitAudit()
|
||
|
||
var req codersdk.CreateWorkspaceRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
owner := workspaceOwner{
|
||
ID: member.UserID,
|
||
Username: member.Username,
|
||
AvatarURL: member.AvatarURL,
|
||
}
|
||
|
||
w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, &createWorkspaceOptions{
|
||
remoteAddr: r.RemoteAddr,
|
||
})
|
||
if err != nil {
|
||
httperror.WriteResponseError(ctx, rw, err)
|
||
return
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusCreated, w)
|
||
}
|
||
|
||
// Create a new workspace for the currently authenticated user.
|
||
//
|
||
// @Summary Create user workspace
|
||
// @Description Create a new workspace using a template. The request must
|
||
// @Description specify either the Template ID or the Template Version ID,
|
||
// @Description not both. If the Template ID is specified, the active version
|
||
// @Description of the template will be used.
|
||
// @ID create-user-workspace
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param user path string true "Username, UUID, or me"
|
||
// @Param request body codersdk.CreateWorkspaceRequest true "Create workspace request"
|
||
// @Success 201 {object} codersdk.Workspace
|
||
// @Router /api/v2/users/{user}/workspaces [post]
|
||
func (api *API) postUserWorkspaces(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
apiKey = httpmw.APIKey(r)
|
||
auditor = api.Auditor.Load()
|
||
mems = httpmw.OrganizationMembersParam(r)
|
||
)
|
||
|
||
var req codersdk.CreateWorkspaceRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
var owner workspaceOwner
|
||
if mems.User != nil {
|
||
// This user fetch is an optimization path for the most common case of creating a
|
||
// workspace for 'Me'.
|
||
//
|
||
// This is also required to allow `owners` to create workspaces for users
|
||
// that are not in an organization.
|
||
owner = workspaceOwner{
|
||
ID: mems.User.ID,
|
||
Username: mems.User.Username,
|
||
AvatarURL: mems.User.AvatarURL,
|
||
}
|
||
} else {
|
||
// A workspace can still be created if the caller can read the organization
|
||
// member. The organization is required, which can be sourced from the
|
||
// template.
|
||
//
|
||
// TODO: This code gets called twice for each workspace build request.
|
||
// This is inefficient and costs at most 2 extra RTTs to the DB.
|
||
// This can be optimized. It exists as it is now for code simplicity.
|
||
// The most common case is to create a workspace for 'Me'. Which does
|
||
// not enter this code branch.
|
||
template, err := requestTemplate(ctx, req, api.Database)
|
||
if err != nil {
|
||
httperror.WriteResponseError(ctx, rw, err)
|
||
return
|
||
}
|
||
|
||
// If the caller can find the organization membership in the same org
|
||
// as the template, then they can continue.
|
||
orgIndex := slices.IndexFunc(mems.Memberships, func(mem httpmw.OrganizationMember) bool {
|
||
return mem.OrganizationID == template.OrganizationID
|
||
})
|
||
if orgIndex == -1 {
|
||
httpapi.ResourceNotFound(rw)
|
||
return
|
||
}
|
||
|
||
member := mems.Memberships[orgIndex]
|
||
owner = workspaceOwner{
|
||
ID: member.UserID,
|
||
Username: member.Username,
|
||
AvatarURL: member.AvatarURL,
|
||
}
|
||
}
|
||
|
||
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionCreate,
|
||
AdditionalFields: audit.AdditionalFields{
|
||
WorkspaceOwner: owner.Username,
|
||
},
|
||
})
|
||
|
||
defer commitAudit()
|
||
|
||
w, err := createWorkspace(ctx, aReq, apiKey.UserID, api, owner, req, &createWorkspaceOptions{
|
||
remoteAddr: r.RemoteAddr,
|
||
})
|
||
if err != nil {
|
||
httperror.WriteResponseError(ctx, rw, err)
|
||
return
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusCreated, w)
|
||
}
|
||
|
||
type workspaceOwner struct {
|
||
ID uuid.UUID
|
||
Username string
|
||
AvatarURL string
|
||
}
|
||
|
||
type createWorkspaceOptions struct {
|
||
// preCreateInTX is a function that is called within the transaction, before
|
||
// the workspace is created.
|
||
preCreateInTX func(ctx context.Context, tx database.Store) error
|
||
// postCreateInTX is a function that is called within the transaction, after
|
||
// the workspace is created but before the workspace build is created.
|
||
postCreateInTX func(ctx context.Context, tx database.Store, workspace database.Workspace) error
|
||
// remoteAddr is the IP address of the request initiator, used for
|
||
// audit logging. HTTP handlers should pass r.RemoteAddr;
|
||
// programmatic callers may leave it empty.
|
||
remoteAddr string
|
||
}
|
||
|
||
func createWorkspace(
|
||
ctx context.Context,
|
||
auditReq *audit.Request[database.WorkspaceTable],
|
||
initiatorID uuid.UUID,
|
||
api *API,
|
||
owner workspaceOwner,
|
||
req codersdk.CreateWorkspaceRequest,
|
||
opts *createWorkspaceOptions,
|
||
) (codersdk.Workspace, error) {
|
||
if opts == nil {
|
||
opts = &createWorkspaceOptions{}
|
||
}
|
||
|
||
template, err := api.preflightWorkspaceCreate(ctx, owner.ID, req)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, err
|
||
}
|
||
|
||
// Update audit log's organization
|
||
auditReq.UpdateOrganizationID(template.OrganizationID)
|
||
|
||
// Required external auth is otherwise only enforced by client-side preflight
|
||
// checks in the CLI and UI, so API-created workspaces must be validated here
|
||
// before any workspace row is inserted or prebuilt workspace is claimed.
|
||
templateVersionID := req.TemplateVersionID
|
||
if templateVersionID == uuid.Nil {
|
||
templateVersionID = template.ActiveVersionID
|
||
}
|
||
templateVersion, err := api.Database.GetTemplateVersionByID(ctx, templateVersionID)
|
||
if err != nil {
|
||
if httpapi.Is404Error(err) {
|
||
return codersdk.Workspace{}, httperror.ErrResourceNotFound
|
||
}
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template version.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
if err := api.requireWorkspaceOwnerExternalAuth(ctx, templateVersion, owner.ID); err != nil {
|
||
return codersdk.Workspace{}, err
|
||
}
|
||
|
||
dbAutostartSchedule, err := validWorkspaceSchedule(req.AutostartSchedule)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid Autostart Schedule.",
|
||
Validations: []codersdk.ValidationError{{Field: "schedule", Detail: err.Error()}},
|
||
})
|
||
}
|
||
|
||
templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template schedule.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
|
||
nextStartAt := sql.NullTime{}
|
||
if dbAutostartSchedule.Valid {
|
||
next, err := schedule.NextAllowedAutostart(dbtime.Now(), dbAutostartSchedule.String, templateSchedule)
|
||
if err == nil {
|
||
nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
|
||
}
|
||
}
|
||
|
||
dbTTL, err := validWorkspaceTTLMillis(req.TTLMillis, templateSchedule.DefaultTTL)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid Workspace Time to Shutdown.",
|
||
Validations: []codersdk.ValidationError{{Field: "ttl_ms", Detail: err.Error()}},
|
||
})
|
||
}
|
||
|
||
// back-compatibility: default to "never" if not included.
|
||
dbAU := database.AutomaticUpdatesNever
|
||
if req.AutomaticUpdates != "" {
|
||
dbAU, err = validWorkspaceAutomaticUpdates(req.AutomaticUpdates)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid Workspace Automatic Updates setting.",
|
||
Validations: []codersdk.ValidationError{{Field: "automatic_updates", Detail: err.Error()}},
|
||
})
|
||
}
|
||
}
|
||
|
||
// TODO: This should be a system call as the actor might not be able to
|
||
// read other workspaces. Ideally we check the error on create and look for
|
||
// a postgres conflict error.
|
||
workspace, err := api.Database.GetWorkspaceByOwnerIDAndName(ctx, database.GetWorkspaceByOwnerIDAndNameParams{
|
||
OwnerID: owner.ID,
|
||
Name: req.Name,
|
||
})
|
||
if err == nil {
|
||
// If the workspace already exists, don't allow creation.
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusConflict, codersdk.Response{
|
||
Message: fmt.Sprintf("Workspace %q already exists.", req.Name),
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "name",
|
||
Detail: "This value is already in use and should be unique.",
|
||
}},
|
||
})
|
||
} else if !errors.Is(err, sql.ErrNoRows) {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: fmt.Sprintf("Internal error fetching workspace by name %q.", req.Name),
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
|
||
var (
|
||
provisionerJob *database.ProvisionerJob
|
||
workspaceBuild *database.WorkspaceBuild
|
||
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
|
||
)
|
||
|
||
err = api.Database.InTx(func(db database.Store) error {
|
||
var (
|
||
prebuildsClaimer = *api.PrebuildsClaimer.Load()
|
||
workspaceID uuid.UUID
|
||
claimedWorkspace *database.Workspace
|
||
)
|
||
|
||
// If a preCreate hook is provided, execute it before creating or
|
||
// claiming the workspace. This can be used to perform additional
|
||
// setup or validation before the workspace is created (e.g. task
|
||
// creation).
|
||
if opts.preCreateInTX != nil {
|
||
if err := opts.preCreateInTX(ctx, db); err != nil {
|
||
return xerrors.Errorf("workspace preCreate failed: %w", err)
|
||
}
|
||
}
|
||
|
||
// Use injected Clock to allow time mocking in tests
|
||
now := dbtime.Time(api.Clock.Now())
|
||
|
||
templateVersionPresetID := req.TemplateVersionPresetID
|
||
|
||
// If no preset was chosen, look for a matching preset by parameter values.
|
||
if templateVersionPresetID == uuid.Nil {
|
||
parameterNames := make([]string, len(req.RichParameterValues))
|
||
parameterValues := make([]string, len(req.RichParameterValues))
|
||
for i, parameter := range req.RichParameterValues {
|
||
parameterNames[i] = parameter.Name
|
||
parameterValues[i] = parameter.Value
|
||
}
|
||
var err error
|
||
templateVersionID := req.TemplateVersionID
|
||
if templateVersionID == uuid.Nil {
|
||
templateVersionID = template.ActiveVersionID
|
||
}
|
||
templateVersionPresetID, err = prebuilds.FindMatchingPresetID(ctx, db, templateVersionID, parameterNames, parameterValues)
|
||
if err != nil {
|
||
return xerrors.Errorf("find matching preset: %w", err)
|
||
}
|
||
}
|
||
|
||
// Try to claim a prebuilt workspace.
|
||
if templateVersionPresetID != uuid.Nil {
|
||
// Try and claim an eligible prebuild, if available.
|
||
// On successful claim, initialize all lifecycle fields from template and workspace-level config
|
||
// so the newly claimed workspace is properly managed by the lifecycle executor.
|
||
claimedWorkspace, err = claimPrebuild(
|
||
ctx, prebuildsClaimer, db, api.Logger, now, req.Name, owner,
|
||
templateVersionPresetID, dbAutostartSchedule, nextStartAt, dbTTL)
|
||
// If claiming fails with an expected error (no claimable prebuilds or AGPL does not support prebuilds),
|
||
// we fall back to creating a new workspace. Otherwise, propagate the unexpected error.
|
||
if err != nil {
|
||
isExpectedError := errors.Is(err, prebuilds.ErrNoClaimablePrebuiltWorkspaces) ||
|
||
errors.Is(err, prebuilds.ErrAGPLDoesNotSupportPrebuiltWorkspaces)
|
||
fields := []slog.Field{
|
||
slog.Error(err),
|
||
slog.F("workspace_name", req.Name),
|
||
slog.F("template_version_preset_id", templateVersionPresetID),
|
||
}
|
||
|
||
if !isExpectedError {
|
||
// if it's an unexpected error - use error log level
|
||
api.Logger.Error(ctx, "failed to claim prebuilt workspace", fields...)
|
||
|
||
return xerrors.Errorf("failed to claim prebuilt workspace: %w", err)
|
||
}
|
||
|
||
// if it's an expected error - use warn log level
|
||
api.Logger.Warn(ctx, "failed to claim prebuilt workspace", fields...)
|
||
|
||
// fall back to creating a new workspace
|
||
}
|
||
}
|
||
|
||
// No prebuild found; regular flow.
|
||
if claimedWorkspace == nil {
|
||
// Workspaces are created without any versions.
|
||
minimumWorkspace, err := db.InsertWorkspace(ctx, database.InsertWorkspaceParams{
|
||
ID: uuid.New(),
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
OwnerID: owner.ID,
|
||
OrganizationID: template.OrganizationID,
|
||
TemplateID: template.ID,
|
||
Name: req.Name,
|
||
AutostartSchedule: dbAutostartSchedule,
|
||
NextStartAt: nextStartAt,
|
||
Ttl: dbTTL,
|
||
// The workspaces page will sort by last used at, and it's useful to
|
||
// have the newly created workspace at the top of the list!
|
||
LastUsedAt: now,
|
||
AutomaticUpdates: dbAU,
|
||
})
|
||
if err != nil {
|
||
return xerrors.Errorf("insert workspace: %w", err)
|
||
}
|
||
workspaceID = minimumWorkspace.ID
|
||
} else {
|
||
// Prebuild found!
|
||
workspaceID = claimedWorkspace.ID
|
||
}
|
||
|
||
// We have to refetch the workspace for the joined in fields.
|
||
// TODO: We can use WorkspaceTable for the builder to not require
|
||
// this extra fetch.
|
||
workspace, err = db.GetWorkspaceByID(ctx, workspaceID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get workspace by ID: %w", err)
|
||
}
|
||
|
||
// If the postCreate hook is provided, execute it. This can be used to
|
||
// perform additional actions after the workspace has been created, like
|
||
// linking the workspace to a task.
|
||
if opts.postCreateInTX != nil {
|
||
if err := opts.postCreateInTX(ctx, db, workspace); err != nil {
|
||
return xerrors.Errorf("workspace postCreate failed: %w", err)
|
||
}
|
||
}
|
||
|
||
builder := wsbuilder.New(workspace, database.WorkspaceTransitionStart, *api.BuildUsageChecker.Load()).
|
||
Reason(database.BuildReasonInitiator).
|
||
Initiator(initiatorID).
|
||
ActiveVersion().
|
||
Experiments(api.Experiments).
|
||
DeploymentValues(api.DeploymentValues).
|
||
RichParameterValues(req.RichParameterValues).
|
||
BuildMetrics(api.WorkspaceBuilderMetrics)
|
||
if req.TemplateVersionID != uuid.Nil {
|
||
builder = builder.VersionID(req.TemplateVersionID)
|
||
}
|
||
if templateVersionPresetID != uuid.Nil {
|
||
builder = builder.TemplateVersionPresetID(templateVersionPresetID)
|
||
}
|
||
if claimedWorkspace != nil {
|
||
builder = builder.MarkPrebuiltWorkspaceClaim()
|
||
}
|
||
|
||
workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build(
|
||
ctx,
|
||
db,
|
||
api.FileCache,
|
||
func(action policy.Action, object rbac.Objecter) bool {
|
||
return api.HTTPAuth.AuthorizeContext(ctx, action, object)
|
||
},
|
||
audit.WorkspaceBuildBaggage{IP: opts.remoteAddr},
|
||
)
|
||
return err
|
||
}, nil)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, err
|
||
}
|
||
|
||
err = provisionerjobs.PostJob(api.Pubsub, *provisionerJob)
|
||
if 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))
|
||
}
|
||
|
||
// 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 == initiatorID {
|
||
continue
|
||
}
|
||
|
||
api.notifyWorkspaceCreated(ctx, admin.ID, workspace, req.RichParameterValues)
|
||
}
|
||
}
|
||
|
||
auditReq.New = workspace.WorkspaceTable()
|
||
|
||
api.Telemetry.Report(&telemetry.Snapshot{
|
||
Workspaces: []telemetry.Workspace{telemetry.ConvertWorkspace(workspace)},
|
||
WorkspaceBuilds: []telemetry.WorkspaceBuild{telemetry.ConvertWorkspaceBuild(*workspaceBuild)},
|
||
})
|
||
|
||
apiBuild, err := api.convertWorkspaceBuild(
|
||
*workspaceBuild,
|
||
workspace,
|
||
database.GetProvisionerJobsByIDsWithQueuePositionRow{
|
||
ProvisionerJob: *provisionerJob,
|
||
QueuePosition: 0,
|
||
},
|
||
newWorkspaceBuildIndex(nil, nil, nil, nil, nil, nil, nil, provisionerDaemons),
|
||
database.TemplateVersion{},
|
||
)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace build.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
api.Logger,
|
||
initiatorID,
|
||
workspace,
|
||
apiBuild,
|
||
template,
|
||
api.Options.AllowWorkspaceRenames,
|
||
codersdk.WorkspaceAppStatus{},
|
||
)
|
||
if err != nil {
|
||
return codersdk.Workspace{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
|
||
return w, nil
|
||
}
|
||
|
||
// requireWorkspaceOwnerExternalAuth returns a 403 response error when the
|
||
// workspace owner has not authenticated with every required (non-optional)
|
||
// external auth provider referenced by the template version. Token injection
|
||
// at build time uses the owner's external auth links, so the owner is the
|
||
// subject of the check even when another user initiates the build.
|
||
func (api *API) requireWorkspaceOwnerExternalAuth(ctx context.Context, templateVersion database.TemplateVersion, ownerID uuid.UUID) error {
|
||
//nolint:gocritic // Reads/refreshes the external auth links. Necessary when admins create workspaces for other users.
|
||
providers, err := api.templateVersionExternalAuthForUser(dbauthz.AsExternalAuthCoordinator(ctx), templateVersion, ownerID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
|
||
var (
|
||
missingNames []string
|
||
validations []codersdk.ValidationError
|
||
)
|
||
for _, provider := range providers {
|
||
if provider.Optional || provider.Authenticated {
|
||
continue
|
||
}
|
||
name := provider.DisplayName
|
||
if name == "" {
|
||
name = provider.ID
|
||
}
|
||
missingNames = append(missingNames, name)
|
||
validations = append(validations, codersdk.ValidationError{
|
||
Field: "external_auth",
|
||
Detail: provider.ID,
|
||
})
|
||
}
|
||
if len(missingNames) == 0 {
|
||
return nil
|
||
}
|
||
|
||
return httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
|
||
Message: "External authentication is required to create a workspace with this template.",
|
||
Detail: fmt.Sprintf(
|
||
"The workspace owner must authenticate with the following external auth providers: %s.",
|
||
strings.Join(missingNames, ", "),
|
||
),
|
||
Validations: validations,
|
||
})
|
||
}
|
||
|
||
// preflightWorkspaceCreate resolves the template targeted by req and verifies
|
||
// that the caller may create a workspace owned by ownerID using it. It performs
|
||
// only side-effect-free authorization, so it is safe to call as an early gate:
|
||
//
|
||
// - resolve the template (requestTemplate)
|
||
// - ActionCreate on a workspace in the template's organization for the owner
|
||
// - ActionUse on the template
|
||
// - reject deprecated templates
|
||
//
|
||
// It deliberately does not validate required external auth (that mutates the
|
||
// owner's external auth links and is enforced separately) and does not touch
|
||
// the audit request, so callers retain control over audit-organization
|
||
// assignment and external-auth ordering. Both createWorkspace and tasksCreate
|
||
// call it so these authorization gates cannot diverge.
|
||
func (api *API) preflightWorkspaceCreate(ctx context.Context, ownerID uuid.UUID, req codersdk.CreateWorkspaceRequest) (database.Template, error) {
|
||
template, err := requestTemplate(ctx, req, api.Database)
|
||
if err != nil {
|
||
return database.Template{}, err
|
||
}
|
||
|
||
// This is a premature auth check to avoid doing unnecessary work if the
|
||
// user doesn't have permission to create a workspace.
|
||
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionCreate,
|
||
rbac.ResourceWorkspace.InOrg(template.OrganizationID).WithOwner(ownerID.String())) {
|
||
// If this check fails, return a proper unauthorized error to the user
|
||
// to indicate what is going on.
|
||
return database.Template{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
|
||
Message: "Unauthorized to create workspace.",
|
||
Detail: "You are unable to create a workspace in this organization. " +
|
||
"It is possible to have access to the template, but not be able to create a workspace. " +
|
||
"Please contact an administrator about your permissions if you feel this is an error.",
|
||
})
|
||
}
|
||
|
||
// The user also needs permission to use the template. At this point they
|
||
// have read perms, but not necessarily "use". This is also checked in
|
||
// `db.InsertWorkspace`. Doing this up front can save some work below if the
|
||
// user doesn't have permission.
|
||
if !api.HTTPAuth.AuthorizeContext(ctx, policy.ActionUse, template) {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusForbidden, codersdk.Response{
|
||
Message: fmt.Sprintf("Unauthorized access to use the template %q.", template.Name),
|
||
Detail: "Although you are able to view the template, you are unable to create a workspace using it. " +
|
||
"Please contact an administrator about your permissions if you feel this is an error.",
|
||
})
|
||
}
|
||
|
||
templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template)
|
||
if templateAccessControl.IsDeprecated() {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: fmt.Sprintf("Template %q has been deprecated, and cannot be used to create a new workspace.", template.Name),
|
||
// Pass the deprecated message to the user.
|
||
Detail: templateAccessControl.Deprecated,
|
||
})
|
||
}
|
||
|
||
return template, nil
|
||
}
|
||
|
||
func requestTemplate(ctx context.Context, req codersdk.CreateWorkspaceRequest, db database.Store) (database.Template, error) {
|
||
// If we were given a `TemplateVersionID`, we need to determine the `TemplateID` from it.
|
||
templateID := req.TemplateID
|
||
|
||
if templateID == uuid.Nil {
|
||
templateVersion, err := db.GetTemplateVersionByID(ctx, req.TemplateVersionID)
|
||
if httpapi.Is404Error(err) {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: fmt.Sprintf("Template version %q doesn't exist.", req.TemplateVersionID),
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "template_version_id",
|
||
Detail: "template not found",
|
||
}},
|
||
})
|
||
}
|
||
if err != nil {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template version.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
if templateVersion.Archived {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Archived template versions cannot be used to make a workspace.",
|
||
Validations: []codersdk.ValidationError{
|
||
{
|
||
Field: "template_version_id",
|
||
Detail: "template version archived",
|
||
},
|
||
},
|
||
})
|
||
}
|
||
|
||
templateID = templateVersion.TemplateID.UUID
|
||
}
|
||
|
||
template, err := db.GetTemplateByID(ctx, templateID)
|
||
if httpapi.Is404Error(err) {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusBadRequest, codersdk.Response{
|
||
Message: fmt.Sprintf("Template %q doesn't exist.", templateID),
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "template_id",
|
||
Detail: "template not found",
|
||
}},
|
||
})
|
||
}
|
||
if err != nil {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template.",
|
||
Detail: err.Error(),
|
||
})
|
||
}
|
||
if template.Deleted {
|
||
return database.Template{}, httperror.NewResponseError(http.StatusNotFound, codersdk.Response{
|
||
Message: fmt.Sprintf("Template %q has been deleted!", template.Name),
|
||
})
|
||
}
|
||
return template, nil
|
||
}
|
||
|
||
func claimPrebuild(
|
||
ctx context.Context,
|
||
claimer prebuilds.Claimer,
|
||
db database.Store,
|
||
logger slog.Logger,
|
||
now time.Time,
|
||
name string,
|
||
owner workspaceOwner,
|
||
templateVersionPresetID uuid.UUID,
|
||
autostartSchedule sql.NullString,
|
||
nextStartAt sql.NullTime,
|
||
ttl sql.NullInt64,
|
||
) (*database.Workspace, error) {
|
||
claimedID, err := claimer.Claim(ctx, db, now, owner.ID, name, templateVersionPresetID, autostartSchedule, nextStartAt, ttl)
|
||
if err != nil {
|
||
// TODO: enhance this by clarifying whether this *specific* prebuild failed or whether there are none to claim.
|
||
return nil, xerrors.Errorf("claim prebuild: %w", err)
|
||
}
|
||
|
||
lookup, err := db.GetWorkspaceByID(ctx, *claimedID)
|
||
if err != nil {
|
||
logger.Error(ctx, "unable to find claimed workspace by ID", slog.Error(err), slog.F("claimed_prebuild_id", claimedID.String()))
|
||
return nil, xerrors.Errorf("find claimed workspace by ID %q: %w", claimedID.String(), err)
|
||
}
|
||
return &lookup, nil
|
||
}
|
||
|
||
func (api *API) notifyWorkspaceCreated(
|
||
ctx context.Context,
|
||
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
|
||
}
|
||
|
||
owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
|
||
if err != nil {
|
||
log.Warn(ctx, "failed to fetch user for workspace creation notification", slog.F("owner_id", workspace.OwnerID), 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_version_id", template.ActiveVersionID), 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.TemplateWorkspaceCreated,
|
||
map[string]string{
|
||
"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-create",
|
||
// 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 creation", slog.Error(err))
|
||
}
|
||
}
|
||
|
||
// @Summary Update workspace metadata by ID
|
||
// @ID update-workspace-metadata-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceRequest true "Metadata update request"
|
||
// @Success 204
|
||
// @Router /api/v2/workspaces/{workspace} [patch]
|
||
func (api *API) patchWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
var req codersdk.UpdateWorkspaceRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
if req.Name == "" || req.Name == workspace.Name {
|
||
aReq.New = workspace.WorkspaceTable()
|
||
// Nothing changed, optionally this could be an error.
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
// The reason we double check here is in case more fields can be
|
||
// patched in the future, it's enough if one changes.
|
||
name := workspace.Name
|
||
if req.Name != "" || req.Name != workspace.Name {
|
||
if !api.Options.AllowWorkspaceRenames {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Workspace renames are not allowed.",
|
||
})
|
||
return
|
||
}
|
||
name = req.Name
|
||
}
|
||
|
||
newWorkspace, err := api.Database.UpdateWorkspace(ctx, database.UpdateWorkspaceParams{
|
||
ID: workspace.ID,
|
||
Name: name,
|
||
})
|
||
if err != nil {
|
||
// The query protects against updating deleted workspaces and
|
||
// the existence of the workspace is checked in the request,
|
||
// if we get ErrNoRows it means the workspace was deleted.
|
||
//
|
||
// We could do this check earlier but we'd need to start a
|
||
// transaction.
|
||
if errors.Is(err, sql.ErrNoRows) {
|
||
httpapi.Write(ctx, rw, http.StatusMethodNotAllowed, codersdk.Response{
|
||
Message: fmt.Sprintf("Workspace %q is deleted and cannot be updated.", workspace.Name),
|
||
})
|
||
return
|
||
}
|
||
// Check if the name was already in use.
|
||
if database.IsUniqueViolation(err) {
|
||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||
Message: fmt.Sprintf("Workspace %q already exists.", req.Name),
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "name",
|
||
Detail: "This value is already in use and should be unique.",
|
||
}},
|
||
})
|
||
return
|
||
}
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error updating workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
|
||
Kind: wspubsub.WorkspaceEventKindMetadataUpdate,
|
||
WorkspaceID: workspace.ID,
|
||
})
|
||
|
||
aReq.New = newWorkspace
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Update workspace autostart schedule by ID
|
||
// @ID update-workspace-autostart-schedule-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceAutostartRequest true "Schedule update request"
|
||
// @Success 204
|
||
// @Router /api/v2/workspaces/{workspace}/autostart [put]
|
||
func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
var req codersdk.UpdateWorkspaceAutostartRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
// Autostart configuration is not supported for prebuilt workspaces.
|
||
// Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior
|
||
// defined per preset at the template level, not per workspace.
|
||
if workspace.IsPrebuild() {
|
||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||
Message: "Autostart is not supported for prebuilt workspaces",
|
||
Detail: "Prebuilt workspace scheduling is configured per preset at the template level. Workspace-level overrides are not supported.",
|
||
})
|
||
return
|
||
}
|
||
|
||
dbSched, err := validWorkspaceSchedule(req.Schedule)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid autostart schedule.",
|
||
Validations: []codersdk.ValidationError{{Field: "schedule", Detail: err.Error()}},
|
||
})
|
||
return
|
||
}
|
||
|
||
// Check if the template allows users to configure autostart.
|
||
templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, workspace.TemplateID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error getting template schedule options.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
if !templateSchedule.UserAutostartEnabled {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Autostart is not allowed for workspaces using this template.",
|
||
Validations: []codersdk.ValidationError{{Field: "schedule", Detail: "Autostart is not allowed for workspaces using this template."}},
|
||
})
|
||
return
|
||
}
|
||
|
||
// Use injected Clock to allow time mocking in tests
|
||
now := api.Clock.Now()
|
||
|
||
nextStartAt := sql.NullTime{}
|
||
if dbSched.Valid {
|
||
next, err := schedule.NextAllowedAutostart(now, dbSched.String, templateSchedule)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error calculating workspace autostart schedule.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
nextStartAt = sql.NullTime{Valid: true, Time: dbtime.Time(next.UTC())}
|
||
}
|
||
|
||
err = api.Database.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{
|
||
ID: workspace.ID,
|
||
AutostartSchedule: dbSched,
|
||
NextStartAt: nextStartAt,
|
||
})
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error updating workspace autostart schedule.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
newWorkspace := workspace
|
||
newWorkspace.AutostartSchedule = dbSched
|
||
aReq.New = newWorkspace.WorkspaceTable()
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Update workspace TTL by ID
|
||
// @ID update-workspace-ttl-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceTTLRequest true "Workspace TTL update request"
|
||
// @Success 204
|
||
// @Router /api/v2/workspaces/{workspace}/ttl [put]
|
||
func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
var req codersdk.UpdateWorkspaceTTLRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
// TTL updates are not supported for prebuilt workspaces.
|
||
// Prebuild lifecycle is managed by the reconciliation loop, with TTL behavior
|
||
// defined per preset at the template level, not per workspace.
|
||
if workspace.IsPrebuild() {
|
||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||
Message: "TTL updates are not supported for prebuilt workspaces",
|
||
Detail: "Prebuilt workspace TTL is configured per preset at the template level. Workspace-level overrides are not supported.",
|
||
})
|
||
return
|
||
}
|
||
|
||
var dbTTL sql.NullInt64
|
||
|
||
err := api.Database.InTx(func(s database.Store) error {
|
||
templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, s, workspace.TemplateID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get template schedule: %w", err)
|
||
}
|
||
if !templateSchedule.UserAutostopEnabled {
|
||
return codersdk.ValidationError{Field: "ttl_ms", Detail: "Custom autostop TTL is not allowed for workspaces using this template."}
|
||
}
|
||
|
||
// don't override 0 ttl with template default here because it indicates
|
||
// disabled autostop
|
||
var validityErr error
|
||
dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0)
|
||
if validityErr != nil {
|
||
return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()}
|
||
}
|
||
if err := s.UpdateWorkspaceTTL(ctx, database.UpdateWorkspaceTTLParams{
|
||
ID: workspace.ID,
|
||
Ttl: dbTTL,
|
||
}); err != nil {
|
||
return xerrors.Errorf("update workspace time until shutdown: %w", err)
|
||
}
|
||
|
||
// Use injected Clock to allow time mocking in tests
|
||
now := api.Clock.Now()
|
||
|
||
// If autostop has been disabled, we want to remove the deadline from the
|
||
// existing workspace build (if there is one).
|
||
if !dbTTL.Valid {
|
||
build, err := s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get latest workspace build: %w", err)
|
||
}
|
||
|
||
if build.Transition == database.WorkspaceTransitionStart {
|
||
if err = s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
|
||
ID: build.ID,
|
||
// Use the max_deadline as the new build deadline. It will
|
||
// either be zero (our target), or a non-zero value that we
|
||
// need to abide by anyway due to template policy.
|
||
//
|
||
// Previously, we would always set the deadline to zero,
|
||
// which was incorrect behavior. When max_deadline is
|
||
// non-zero, deadline must be set to a non-zero value that
|
||
// is less than max_deadline.
|
||
//
|
||
// Disabling TTL autostop (at a workspace or template level)
|
||
// does not trump the template's autostop requirement.
|
||
//
|
||
// Refer to the comments on schedule.CalculateAutostop for
|
||
// more information.
|
||
Deadline: build.MaxDeadline,
|
||
MaxDeadline: build.MaxDeadline,
|
||
UpdatedAt: dbtime.Time(now),
|
||
}); err != nil {
|
||
return xerrors.Errorf("update workspace build deadline: %w", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
return nil
|
||
}, nil)
|
||
if err != nil {
|
||
resp := codersdk.Response{
|
||
Message: "Error updating workspace time until shutdown.",
|
||
}
|
||
var validErr codersdk.ValidationError
|
||
if errors.As(err, &validErr) {
|
||
resp.Validations = []codersdk.ValidationError{validErr}
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, resp)
|
||
return
|
||
}
|
||
|
||
resp.Detail = err.Error()
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, resp)
|
||
return
|
||
}
|
||
|
||
newWorkspace := workspace
|
||
newWorkspace.Ttl = dbTTL
|
||
aReq.New = newWorkspace.WorkspaceTable()
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Update workspace dormancy status by id.
|
||
// @ID update-workspace-dormancy-status-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceDormancy true "Make a workspace dormant or active"
|
||
// @Success 200 {object} codersdk.Workspace
|
||
// @Router /api/v2/workspaces/{workspace}/dormant [put]
|
||
func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
oldWorkspace = httpmw.WorkspaceParam(r)
|
||
apiKey = httpmw.APIKey(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: oldWorkspace.OrganizationID,
|
||
})
|
||
)
|
||
aReq.Old = oldWorkspace.WorkspaceTable()
|
||
defer commitAudit()
|
||
|
||
var req codersdk.UpdateWorkspaceDormancy
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
// Dormancy configuration is not supported for prebuilt workspaces.
|
||
// Prebuilds are managed by the reconciliation loop and are not subject to dormancy.
|
||
if oldWorkspace.IsPrebuild() {
|
||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||
Message: "Dormancy updates are not supported for prebuilt workspaces",
|
||
Detail: "Prebuilt workspaces are not subject to dormancy. Dormancy behavior is only applicable to regular workspaces",
|
||
})
|
||
return
|
||
}
|
||
|
||
// If the workspace is already in the desired state do nothing!
|
||
if oldWorkspace.DormantAt.Valid == req.Dormant {
|
||
rw.WriteHeader(http.StatusNotModified)
|
||
return
|
||
}
|
||
|
||
// Use injected Clock to allow time mocking in tests
|
||
now := api.Clock.Now()
|
||
|
||
dormantAt := sql.NullTime{
|
||
Valid: req.Dormant,
|
||
}
|
||
if req.Dormant {
|
||
dormantAt.Time = dbtime.Time(now)
|
||
}
|
||
|
||
newWorkspace, err := api.Database.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
|
||
ID: oldWorkspace.ID,
|
||
DormantAt: dormantAt,
|
||
})
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error updating workspace locked status.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
// We don't need to notify the owner if they are the one making the request.
|
||
if req.Dormant && apiKey.UserID != newWorkspace.OwnerID {
|
||
initiator, initiatorErr := api.Database.GetUserByID(ctx, apiKey.UserID)
|
||
if initiatorErr != nil {
|
||
api.Logger.Warn(
|
||
ctx,
|
||
"failed to fetch the user that marked the workspace as dormant",
|
||
slog.Error(err),
|
||
slog.F("workspace_id", newWorkspace.ID),
|
||
slog.F("user_id", apiKey.UserID),
|
||
)
|
||
}
|
||
|
||
if initiatorErr == nil {
|
||
labels := map[string]string{
|
||
"name": newWorkspace.Name,
|
||
"reason": "a " + initiator.Username + " request",
|
||
}
|
||
// DeletingAt is set by the UPDATE only when the template's
|
||
// time_til_dormant_autodelete is non-zero, so skip the label when
|
||
// auto-delete is disabled so the body omits the deletion
|
||
// timeline.
|
||
if newWorkspace.DeletingAt.Valid {
|
||
labels["timeTilDelete"] = humanize.Time(newWorkspace.DeletingAt.Time)
|
||
}
|
||
_, err = api.NotificationsEnqueuer.Enqueue(
|
||
// nolint:gocritic // Need notifier actor to enqueue notifications
|
||
dbauthz.AsNotifier(ctx),
|
||
newWorkspace.OwnerID,
|
||
notifications.TemplateWorkspaceDormant,
|
||
labels,
|
||
"api",
|
||
newWorkspace.ID,
|
||
newWorkspace.OwnerID,
|
||
newWorkspace.TemplateID,
|
||
newWorkspace.OrganizationID,
|
||
)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "failed to notify of workspace marked as dormant", slog.Error(err))
|
||
}
|
||
}
|
||
}
|
||
|
||
// We have to refetch the workspace to get the joined in fields.
|
||
workspace, err := api.Database.GetWorkspaceByID(ctx, newWorkspace.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
data, err := api.workspaceData(ctx, []database.Workspace{workspace}, allWorkspaceRelated())
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace resources.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
// TODO: This is a strange error since it occurs after the mutation.
|
||
// An example of why we should join in fields to prevent this forbidden error
|
||
// from being sent, when the action did succeed.
|
||
if len(data.templates) == 0 {
|
||
httpapi.Forbidden(rw)
|
||
return
|
||
}
|
||
|
||
aReq.New = newWorkspace
|
||
|
||
appStatus := codersdk.WorkspaceAppStatus{}
|
||
if len(data.appStatuses) > 0 {
|
||
appStatus = data.appStatuses[0]
|
||
}
|
||
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
api.Logger,
|
||
apiKey.UserID,
|
||
workspace,
|
||
data.builds[0],
|
||
data.templates[0],
|
||
api.Options.AllowWorkspaceRenames,
|
||
appStatus,
|
||
)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting workspace.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
httpapi.Write(ctx, rw, http.StatusOK, w)
|
||
}
|
||
|
||
// @Summary Extend workspace deadline by ID
|
||
// @ID extend-workspace-deadline-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.PutExtendWorkspaceRequest true "Extend deadline update request"
|
||
// @Success 200 {object} codersdk.Response
|
||
// @Router /api/v2/workspaces/{workspace}/extend [put]
|
||
func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
workspace := httpmw.WorkspaceParam(r)
|
||
|
||
var req codersdk.PutExtendWorkspaceRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
// Deadline extensions are not supported for prebuilt workspaces.
|
||
// Prebuilds are managed by the reconciliation loop and must always have
|
||
// Deadline and MaxDeadline unset.
|
||
if workspace.IsPrebuild() {
|
||
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
||
Message: "Deadline extension is not supported for prebuilt workspaces",
|
||
Detail: "Prebuilt workspaces do not support user deadline modifications. Deadline extension is only applicable to regular workspaces",
|
||
})
|
||
return
|
||
}
|
||
|
||
code := http.StatusOK
|
||
resp := codersdk.Response{}
|
||
|
||
err := api.Database.InTx(func(s database.Store) error {
|
||
build, err := s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||
if err != nil {
|
||
code = http.StatusInternalServerError
|
||
resp.Message = "Error fetching workspace build."
|
||
return xerrors.Errorf("get latest workspace build: %w", err)
|
||
}
|
||
|
||
job, err := s.GetProvisionerJobByID(ctx, build.JobID)
|
||
if err != nil {
|
||
code = http.StatusInternalServerError
|
||
resp.Message = "Error fetching workspace provisioner job."
|
||
return xerrors.Errorf("get provisioner job: %w", err)
|
||
}
|
||
|
||
if build.Transition != database.WorkspaceTransitionStart {
|
||
code = http.StatusConflict
|
||
resp.Message = "Workspace must be started, current status: " + string(build.Transition)
|
||
return xerrors.Errorf("workspace must be started, current status: %s", build.Transition)
|
||
}
|
||
|
||
if !job.CompletedAt.Valid {
|
||
code = http.StatusConflict
|
||
resp.Message = "Workspace is still building!"
|
||
return xerrors.Errorf("workspace is still building")
|
||
}
|
||
|
||
if build.Deadline.IsZero() {
|
||
code = http.StatusConflict
|
||
resp.Message = "Workspace shutdown is manual."
|
||
return xerrors.Errorf("workspace shutdown is manual")
|
||
}
|
||
|
||
// Use injected Clock to allow time mocking in tests
|
||
now := api.Clock.Now()
|
||
|
||
newDeadline := req.Deadline.UTC()
|
||
if err := validWorkspaceDeadline(now, job.CompletedAt.Time, newDeadline); err != nil {
|
||
// NOTE(Cian): Putting the error in the Message field on request from the FE folks.
|
||
// Normally, we would put the validation error in Validations, but this endpoint is
|
||
// not tied to a form or specific named user input on the FE.
|
||
code = http.StatusBadRequest
|
||
resp.Message = "Cannot extend workspace: " + err.Error()
|
||
return err
|
||
}
|
||
if !build.MaxDeadline.IsZero() && newDeadline.After(build.MaxDeadline) {
|
||
code = http.StatusBadRequest
|
||
resp.Message = "Cannot extend workspace beyond max deadline."
|
||
return xerrors.New("Cannot extend workspace: deadline is beyond max deadline imposed by template")
|
||
}
|
||
|
||
if err := s.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{
|
||
ID: build.ID,
|
||
UpdatedAt: dbtime.Time(now),
|
||
Deadline: newDeadline,
|
||
MaxDeadline: build.MaxDeadline,
|
||
}); err != nil {
|
||
code = http.StatusInternalServerError
|
||
resp.Message = "Failed to extend workspace deadline."
|
||
return xerrors.Errorf("update workspace build: %w", err)
|
||
}
|
||
resp.Message = "Deadline updated to " + newDeadline.Format(time.RFC3339) + "."
|
||
|
||
return nil
|
||
}, nil)
|
||
if err != nil {
|
||
api.Logger.Info(ctx, "extending workspace", slog.Error(err))
|
||
}
|
||
|
||
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
|
||
Kind: wspubsub.WorkspaceEventKindMetadataUpdate,
|
||
WorkspaceID: workspace.ID,
|
||
})
|
||
httpapi.Write(ctx, rw, code, resp)
|
||
}
|
||
|
||
// @Summary Post Workspace Usage by ID
|
||
// @ID post-workspace-usage-by-id
|
||
// @Security CoderSessionToken
|
||
// @Tags Workspaces
|
||
// @Accept json
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.PostWorkspaceUsageRequest false "Post workspace usage request"
|
||
// @Success 204
|
||
// @Router /api/v2/workspaces/{workspace}/usage [post]
|
||
func (api *API) postWorkspaceUsage(rw http.ResponseWriter, r *http.Request) {
|
||
workspace := httpmw.WorkspaceParam(r)
|
||
if !api.Authorize(r, policy.ActionUpdate, workspace) {
|
||
httpapi.Forbidden(rw)
|
||
return
|
||
}
|
||
|
||
api.statsReporter.TrackUsage(workspace.ID)
|
||
|
||
if !api.Experiments.Enabled(codersdk.ExperimentWorkspaceUsage) {
|
||
// Continue previous behavior if the experiment is not enabled.
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
if r.Body == http.NoBody {
|
||
// Continue previous behavior if no body is present.
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
|
||
ctx := r.Context()
|
||
var req codersdk.PostWorkspaceUsageRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
if req.AgentID == uuid.Nil && req.AppName == "" {
|
||
// Continue previous behavior if body is empty.
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
return
|
||
}
|
||
if req.AgentID == uuid.Nil {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid request",
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "agent_id",
|
||
Detail: "must be set when app_name is set",
|
||
}},
|
||
})
|
||
return
|
||
}
|
||
if req.AppName == "" {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid request",
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "app_name",
|
||
Detail: "must be set when agent_id is set",
|
||
}},
|
||
})
|
||
return
|
||
}
|
||
if !slices.Contains(codersdk.AllowedAppNames, req.AppName) {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid request",
|
||
Validations: []codersdk.ValidationError{{
|
||
Field: "app_name",
|
||
Detail: fmt.Sprintf("must be one of %v", codersdk.AllowedAppNames),
|
||
}},
|
||
})
|
||
return
|
||
}
|
||
|
||
stat := &proto.Stats{
|
||
ConnectionCount: 1,
|
||
}
|
||
switch req.AppName {
|
||
case codersdk.UsageAppNameVscode:
|
||
stat.SessionCountVscode = 1
|
||
case codersdk.UsageAppNameJetbrains:
|
||
stat.SessionCountJetbrains = 1
|
||
case codersdk.UsageAppNameReconnectingPty:
|
||
stat.SessionCountReconnectingPty = 1
|
||
case codersdk.UsageAppNameSSH:
|
||
stat.SessionCountSsh = 1
|
||
default:
|
||
// This means the app_name is in the codersdk.AllowedAppNames but not being
|
||
// handled by this switch statement.
|
||
httpapi.InternalServerError(rw, xerrors.Errorf("unknown app_name %q", req.AppName))
|
||
return
|
||
}
|
||
|
||
agent, err := api.Database.GetWorkspaceAgentByID(ctx, req.AgentID)
|
||
if err != nil {
|
||
if httpapi.Is404Error(err) {
|
||
httpapi.ResourceNotFound(rw)
|
||
return
|
||
}
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
// template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
||
// if err != nil {
|
||
// httpapi.InternalServerError(rw, err)
|
||
// return
|
||
// }
|
||
|
||
err = api.statsReporter.ReportAgentStats(ctx, dbtime.Now(), database.WorkspaceIdentityFromWorkspace(workspace), agent.ID, agent.Name, stat, true)
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Favorite workspace by ID.
|
||
// @ID favorite-workspace-by-id
|
||
// @Security CoderSessionToken
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 204
|
||
// @Router /api/v2/workspaces/{workspace}/favorite [put]
|
||
func (api *API) putFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
apiKey = httpmw.APIKey(r)
|
||
auditor = api.Auditor.Load()
|
||
)
|
||
|
||
if apiKey.UserID != workspace.OwnerID {
|
||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||
Message: "You can only favorite workspaces that you own.",
|
||
})
|
||
return
|
||
}
|
||
|
||
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
err := api.Database.FavoriteWorkspace(ctx, workspace.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error setting workspace as favorite",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
aReq.New = workspace.WorkspaceTable()
|
||
aReq.New.Favorite = true
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Unfavorite workspace by ID.
|
||
// @ID unfavorite-workspace-by-id
|
||
// @Security CoderSessionToken
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 204
|
||
// @Router /api/v2/workspaces/{workspace}/favorite [delete]
|
||
func (api *API) deleteFavoriteWorkspace(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
apiKey = httpmw.APIKey(r)
|
||
auditor = api.Auditor.Load()
|
||
)
|
||
|
||
if apiKey.UserID != workspace.OwnerID {
|
||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||
Message: "You can only un-favorite workspaces that you own.",
|
||
})
|
||
return
|
||
}
|
||
|
||
aReq, commitAudit := audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
err := api.Database.UnfavoriteWorkspace(ctx, workspace.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error unsetting workspace as favorite",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
aReq.New = workspace.WorkspaceTable()
|
||
aReq.New.Favorite = false
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Update workspace automatic updates by ID
|
||
// @ID update-workspace-automatic-updates-by-id
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceAutomaticUpdatesRequest true "Automatic updates request"
|
||
// @Success 204
|
||
// @Router /api/v2/workspaces/{workspace}/autoupdates [put]
|
||
func (api *API) putWorkspaceAutoupdates(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
var req codersdk.UpdateWorkspaceAutomaticUpdatesRequest
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
if !database.AutomaticUpdates(req.AutomaticUpdates).Valid() {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid request",
|
||
Validations: []codersdk.ValidationError{{Field: "automatic_updates", Detail: "must be always or never"}},
|
||
})
|
||
return
|
||
}
|
||
|
||
err := api.Database.UpdateWorkspaceAutomaticUpdates(ctx, database.UpdateWorkspaceAutomaticUpdatesParams{
|
||
ID: workspace.ID,
|
||
AutomaticUpdates: database.AutomaticUpdates(req.AutomaticUpdates),
|
||
})
|
||
if httpapi.Is404Error(err) {
|
||
httpapi.ResourceNotFound(rw)
|
||
return
|
||
}
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error updating workspace automatic updates setting",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
newWorkspace := workspace
|
||
newWorkspace.AutomaticUpdates = database.AutomaticUpdates(req.AutomaticUpdates)
|
||
aReq.New = newWorkspace.WorkspaceTable()
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
// @Summary Resolve workspace autostart by id.
|
||
// @ID resolve-workspace-autostart-by-id
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 200 {object} codersdk.ResolveAutostartResponse
|
||
// @Router /api/v2/workspaces/{workspace}/resolve-autostart [get]
|
||
func (api *API) resolveAutostart(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
)
|
||
|
||
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template)
|
||
useActiveVersion := templateAccessControl.RequireActiveVersion || workspace.AutomaticUpdates == database.AutomaticUpdatesAlways
|
||
if !useActiveVersion {
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ResolveAutostartResponse{})
|
||
return
|
||
}
|
||
|
||
build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching latest workspace build.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
if build.TemplateVersionID == template.ActiveVersionID {
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.ResolveAutostartResponse{})
|
||
return
|
||
}
|
||
|
||
version, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template version.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
dbVersionParams, err := api.Database.GetTemplateVersionParameters(ctx, version.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching template version parameters.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
dbBuildParams, err := api.Database.GetWorkspaceBuildParameters(ctx, build.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching latest workspace build parameters.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
versionParams, err := db2sdk.TemplateVersionParameters(dbVersionParams)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error converting template version parameters.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
resolver := codersdk.ParameterResolver{
|
||
Rich: db2sdk.WorkspaceBuildParameters(dbBuildParams),
|
||
}
|
||
|
||
var response codersdk.ResolveAutostartResponse
|
||
for _, param := range versionParams {
|
||
_, err := resolver.ValidateResolve(param, nil)
|
||
// There's a parameter mismatch if we get an error back from the
|
||
// resolver.
|
||
response.ParameterMismatch = err != nil
|
||
if response.ParameterMismatch {
|
||
break
|
||
}
|
||
}
|
||
httpapi.Write(ctx, rw, http.StatusOK, response)
|
||
}
|
||
|
||
// @Summary Watch workspace by ID
|
||
// @ID watch-workspace-by-id
|
||
// @Security CoderSessionToken
|
||
// @Produce text/event-stream
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 200 {object} codersdk.Response
|
||
// @Router /api/v2/workspaces/{workspace}/watch [get]
|
||
// @Deprecated Use /workspaces/{workspace}/watch-ws instead
|
||
func (api *API) watchWorkspaceSSE(rw http.ResponseWriter, r *http.Request) {
|
||
api.watchWorkspace(rw, r, httpapi.ServerSentEventSender)
|
||
}
|
||
|
||
// @Summary Watch workspace by ID via WebSockets
|
||
// @ID watch-workspace-by-id-via-websockets
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 200 {object} codersdk.ServerSentEvent
|
||
// @Router /api/v2/workspaces/{workspace}/watch-ws [get]
|
||
func (api *API) watchWorkspaceWS(rw http.ResponseWriter, r *http.Request) {
|
||
api.watchWorkspace(rw, r, httpapi.OneWayWebSocketEventSender(api.Logger, api.wsWatcher))
|
||
}
|
||
|
||
func (api *API) watchWorkspace(
|
||
rw http.ResponseWriter,
|
||
r *http.Request,
|
||
connect httpapi.EventSender,
|
||
) {
|
||
ctx := r.Context()
|
||
workspace := httpmw.WorkspaceParam(r)
|
||
apiKey := httpmw.APIKey(r)
|
||
|
||
sendEvent, senderClosed, err := connect(rw, r)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error setting up server-sent events.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
// Prevent handler from returning until the sender is closed.
|
||
defer func() {
|
||
<-senderClosed
|
||
}()
|
||
|
||
sendUpdate := func(_ context.Context, _ []byte) {
|
||
workspace, err := api.Database.GetWorkspaceByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Internal error fetching workspace.",
|
||
Detail: err.Error(),
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
data, err := api.workspaceData(ctx, []database.Workspace{workspace}, allWorkspaceRelated())
|
||
if err != nil {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Internal error fetching workspace data.",
|
||
Detail: err.Error(),
|
||
},
|
||
})
|
||
return
|
||
}
|
||
if len(data.templates) == 0 {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Forbidden reading template of selected workspace.",
|
||
},
|
||
})
|
||
return
|
||
}
|
||
|
||
appStatus := codersdk.WorkspaceAppStatus{}
|
||
if len(data.appStatuses) > 0 {
|
||
appStatus = data.appStatuses[0]
|
||
}
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
api.Logger,
|
||
apiKey.UserID,
|
||
workspace,
|
||
data.builds[0],
|
||
data.templates[0],
|
||
api.Options.AllowWorkspaceRenames,
|
||
appStatus,
|
||
)
|
||
if err != nil {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Internal error converting workspace.",
|
||
Detail: err.Error(),
|
||
},
|
||
})
|
||
}
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeData,
|
||
Data: w,
|
||
})
|
||
}
|
||
|
||
cancelWorkspaceSubscribe, err := api.Pubsub.SubscribeWithErr(wspubsub.WorkspaceEventChannel(workspace.OwnerID),
|
||
wspubsub.HandleWorkspaceEvent(
|
||
func(ctx context.Context, payload wspubsub.WorkspaceEvent, err error) {
|
||
if err != nil {
|
||
return
|
||
}
|
||
if payload.WorkspaceID != workspace.ID {
|
||
return
|
||
}
|
||
sendUpdate(ctx, nil)
|
||
}))
|
||
if err != nil {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Internal error subscribing to workspace events.",
|
||
Detail: err.Error(),
|
||
},
|
||
})
|
||
return
|
||
}
|
||
defer cancelWorkspaceSubscribe()
|
||
|
||
// This is required to show whether the workspace is up-to-date.
|
||
cancelTemplateSubscribe, err := api.Pubsub.Subscribe(watchTemplateChannel(workspace.TemplateID), sendUpdate)
|
||
if err != nil {
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypeError,
|
||
Data: codersdk.Response{
|
||
Message: "Internal error subscribing to template events.",
|
||
Detail: err.Error(),
|
||
},
|
||
})
|
||
return
|
||
}
|
||
defer cancelTemplateSubscribe()
|
||
|
||
// An initial ping signals to the request that the server is now ready
|
||
// and the client can begin servicing a channel with data.
|
||
_ = sendEvent(codersdk.ServerSentEvent{
|
||
Type: codersdk.ServerSentEventTypePing,
|
||
})
|
||
// Send updated workspace info after connection is established. This avoids
|
||
// missing updates if the client connects after an update.
|
||
sendUpdate(ctx, nil)
|
||
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case <-senderClosed:
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// @Summary Watch all workspace builds
|
||
// @ID watch-all-workspace-builds
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Success 101
|
||
// @Router /api/experimental/watch-all-workspacebuilds [get]
|
||
// @x-apidocgen {"skip": true}
|
||
func (api *API) watchAllWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
|
||
// This endpoint streams build events for every workspace in the deployment
|
||
// with no per-item filtering, so require deployment-wide read on workspaces.
|
||
// Without this, any authenticated user could enumerate all workspace names
|
||
// and observe build activity across organizations they do not belong to.
|
||
if !api.Authorize(r, policy.ActionRead, rbac.ResourceWorkspace.All()) {
|
||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||
Message: "You are not authorized to watch all workspace builds.",
|
||
Detail: "This requires permission to read all workspaces, which is granted by the " +
|
||
"Owner or Template Admin role. Please contact an administrator about your " +
|
||
"permissions if you feel this is an error.",
|
||
})
|
||
return
|
||
}
|
||
|
||
// Buffer enough updates to avoid blocking the pubsub callback while we're
|
||
// accepting the WebSocket connection. Accepting the connection signals to
|
||
// the client that the server is subscribed and ready to forward events.
|
||
updates := make(chan codersdk.WorkspaceBuildUpdate, 256)
|
||
|
||
cancelSubscribe, err := api.Pubsub.SubscribeWithErr(wspubsub.AllWorkspaceEventChannel,
|
||
wspubsub.HandleWorkspaceBuildUpdate(
|
||
func(_ context.Context, update codersdk.WorkspaceBuildUpdate, err error) {
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "workspace build update subscription error", slog.Error(err))
|
||
return
|
||
}
|
||
select {
|
||
case updates <- update:
|
||
default:
|
||
api.Logger.Warn(ctx, "workspace build update dropped, client too slow")
|
||
}
|
||
}))
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error subscribing to workspace build events.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
defer cancelSubscribe()
|
||
|
||
conn, err := websocket.Accept(rw, r, nil)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Failed to accept WebSocket.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
defer conn.Close(websocket.StatusNormalClosure, "done")
|
||
|
||
// CloseRead starts a goroutine to read and discard messages from the client,
|
||
// including Pong messages sent in response to our Ping heartbeats.
|
||
_ = conn.CloseRead(context.Background())
|
||
|
||
ctx, cancel := context.WithCancel(ctx)
|
||
ctx = api.wsWatcher.Watch(ctx, api.Logger, conn)
|
||
defer cancel()
|
||
|
||
enc := wsjson.NewEncoder[codersdk.WorkspaceBuildUpdate](conn, websocket.MessageText)
|
||
for {
|
||
select {
|
||
case <-ctx.Done():
|
||
return
|
||
case update, ok := <-updates:
|
||
if !ok {
|
||
return
|
||
}
|
||
if err := enc.Encode(update); err != nil {
|
||
return
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// @Summary Get workspace timings by ID
|
||
// @ID get-workspace-timings-by-id
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 200 {object} codersdk.WorkspaceBuildTimings
|
||
// @Router /api/v2/workspaces/{workspace}/timings [get]
|
||
func (api *API) workspaceTimings(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
)
|
||
|
||
build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
|
||
if err != nil {
|
||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||
Message: "Internal error fetching workspace build.",
|
||
Detail: err.Error(),
|
||
})
|
||
return
|
||
}
|
||
|
||
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)
|
||
}
|
||
|
||
// @Summary Get workspace ACLs
|
||
// @ID get-workspace-acls
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 200 {object} codersdk.WorkspaceACL
|
||
// @Router /api/v2/workspaces/{workspace}/acl [get]
|
||
func (api *API) workspaceACL(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
)
|
||
|
||
// Fetch the ACL data.
|
||
workspaceACL, err := api.Database.GetWorkspaceACLByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
// Callers are authorized to read this workspace, not necessarily the
|
||
// users and groups on its ACL. We deliberately use the System context to
|
||
// look up that data, but only return minimal identity information that is
|
||
// safe to expose to anyone who can read the ACL: MinimalUser for ACL users
|
||
// (no email or other PII) and group identity plus a member count for ACL
|
||
// groups (no member roster). This mirrors the chat ACL and template
|
||
// available-ACL endpoints.
|
||
|
||
// Fetch all of the users and their organization memberships
|
||
userIDs := make([]uuid.UUID, 0, len(workspaceACL.Users))
|
||
for userID := range workspaceACL.Users {
|
||
id, err := uuid.Parse(userID)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
|
||
continue
|
||
}
|
||
userIDs = append(userIDs, id)
|
||
}
|
||
// ACL users are returned as MinimalUser, which contains no PII, so it is
|
||
// safe to fetch them under the System context.
|
||
// nolint:gocritic
|
||
dbUsers, err := api.Database.GetUsersByIDs(dbauthz.AsSystemRestricted(ctx), userIDs)
|
||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
// Convert the db types to the codersdk.WorkspaceUser type
|
||
users := make([]codersdk.WorkspaceUser, 0, len(dbUsers))
|
||
for _, it := range dbUsers {
|
||
users = append(users, codersdk.WorkspaceUser{
|
||
MinimalUser: db2sdk.MinimalUser(it),
|
||
Role: convertToWorkspaceRole(workspaceACL.Users[it.ID.String()].Permissions),
|
||
})
|
||
}
|
||
|
||
// Fetch all of the groups
|
||
groupIDs := make([]uuid.UUID, 0, len(workspaceACL.Groups))
|
||
for groupID := range workspaceACL.Groups {
|
||
id, err := uuid.Parse(groupID)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
|
||
continue
|
||
}
|
||
groupIDs = append(groupIDs, id)
|
||
}
|
||
|
||
// `GetGroups` returns all groups if `GroupIds` is empty so we check the length
|
||
// before making the DB call.
|
||
dbGroups := make([]database.GetGroupsRow, 0)
|
||
if len(groupIDs) > 0 {
|
||
// Group identity must be visible to anyone who can read the ACL so
|
||
// that owners and shared users can see and manage entries.
|
||
// nolint:gocritic
|
||
dbGroups, err = api.Database.GetGroups(dbauthz.AsSystemRestricted(ctx), database.GetGroupsParams{GroupIds: groupIDs})
|
||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
}
|
||
|
||
// Fetch member counts for all groups in a single query to avoid an N+1
|
||
// lookup. We intentionally do not populate the per-group member rosters:
|
||
// callers authorized to read the ACL are not necessarily authorized to
|
||
// read group membership, and the roster includes member PII. Only the
|
||
// total member count is returned (see Group.TotalMemberCount).
|
||
// nolint:gocritic
|
||
countRows, err := api.Database.GetGroupMembersCountByGroupIDs(dbauthz.AsSystemRestricted(ctx), database.GetGroupMembersCountByGroupIDsParams{
|
||
GroupIds: groupIDs,
|
||
IncludeSystem: false,
|
||
})
|
||
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
countByGroup := make(map[uuid.UUID]int64, len(countRows))
|
||
for _, row := range countRows {
|
||
countByGroup[row.GroupID] = row.MemberCount
|
||
}
|
||
|
||
groups := make([]codersdk.WorkspaceGroup, 0, len(dbGroups))
|
||
for _, it := range dbGroups {
|
||
groups = append(groups, codersdk.WorkspaceGroup{
|
||
Group: db2sdk.Group(it, nil, int(countByGroup[it.Group.ID])),
|
||
Role: convertToWorkspaceRole(workspaceACL.Groups[it.Group.ID.String()].Permissions),
|
||
})
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspaceACL{
|
||
Users: users,
|
||
Groups: groups,
|
||
})
|
||
}
|
||
|
||
// @Summary Update workspace ACL
|
||
// @ID update-workspace-acl
|
||
// @Security CoderSessionToken
|
||
// @Accept json
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Param request body codersdk.UpdateWorkspaceACL true "Update workspace ACL request"
|
||
// @Success 204
|
||
// @Router /api/v2/workspaces/{workspace}/acl [patch]
|
||
func (api *API) patchWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAudit = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
defer commitAudit()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
if !api.allowWorkspaceSharing(ctx, rw, workspace.OrganizationID) {
|
||
return
|
||
}
|
||
|
||
var req codersdk.UpdateWorkspaceACL
|
||
if !httpapi.Read(ctx, rw, r, &req) {
|
||
return
|
||
}
|
||
|
||
apiKey := httpmw.APIKey(r)
|
||
if _, ok := req.UserRoles[apiKey.UserID.String()]; ok {
|
||
// Block changing your own sharing role unless you can share any
|
||
// workspace in the organization. This keeps a user whose only access is
|
||
// this share from destructively demoting themselves, while letting org
|
||
// and deployment admins manage their own access.
|
||
if !api.Authorize(r, policy.ActionShare, rbac.ResourceWorkspace.InOrg(workspace.OrganizationID)) {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "You cannot change your own workspace sharing role.",
|
||
})
|
||
return
|
||
}
|
||
}
|
||
|
||
validErrs := acl.Validate(ctx, api.Database, WorkspaceACLUpdateValidator(req))
|
||
if len(validErrs) > 0 {
|
||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||
Message: "Invalid request to update workspace ACL",
|
||
Validations: validErrs,
|
||
})
|
||
return
|
||
}
|
||
|
||
err := api.Database.InTx(func(tx database.Store) error {
|
||
var err error
|
||
workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get template by ID: %w", err)
|
||
}
|
||
|
||
for id, role := range req.UserRoles {
|
||
if role == codersdk.WorkspaceRoleDeleted {
|
||
delete(workspace.UserACL, id)
|
||
continue
|
||
}
|
||
workspace.UserACL[id] = database.WorkspaceACLEntry{
|
||
Permissions: db2sdk.WorkspaceRoleActions(role),
|
||
}
|
||
}
|
||
|
||
for id, role := range req.GroupRoles {
|
||
if role == codersdk.WorkspaceRoleDeleted {
|
||
delete(workspace.GroupACL, id)
|
||
continue
|
||
}
|
||
workspace.GroupACL[id] = database.WorkspaceACLEntry{
|
||
Permissions: db2sdk.WorkspaceRoleActions(role),
|
||
}
|
||
}
|
||
|
||
err = tx.UpdateWorkspaceACLByID(ctx, database.UpdateWorkspaceACLByIDParams{
|
||
ID: workspace.ID,
|
||
UserACL: workspace.UserACL,
|
||
GroupACL: workspace.GroupACL,
|
||
})
|
||
if err != nil {
|
||
return xerrors.Errorf("update workspace ACL by ID: %w", err)
|
||
}
|
||
workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get updated workspace by ID: %w", err)
|
||
}
|
||
return nil
|
||
}, nil)
|
||
if err != nil {
|
||
if dbauthz.IsNotAuthorizedError(err) {
|
||
httpapi.Forbidden(rw)
|
||
} else {
|
||
httpapi.InternalServerError(rw, err)
|
||
}
|
||
return
|
||
}
|
||
|
||
aReq.New = workspace.WorkspaceTable()
|
||
|
||
rw.WriteHeader(http.StatusNoContent)
|
||
}
|
||
|
||
type workspaceData struct {
|
||
templates []database.Template
|
||
builds []codersdk.WorkspaceBuild
|
||
appStatuses []codersdk.WorkspaceAppStatus
|
||
allowRenames bool
|
||
}
|
||
|
||
// @Summary Completely clears the workspace's user and group ACLs.
|
||
// @ID completely-clears-the-workspaces-user-and-group-acls
|
||
// @Security CoderSessionToken
|
||
// @Tags Workspaces
|
||
// @Param workspace path string true "Workspace ID" format(uuid)
|
||
// @Success 204
|
||
// @Router /api/v2/workspaces/{workspace}/acl [delete]
|
||
func (api *API) deleteWorkspaceACL(rw http.ResponseWriter, r *http.Request) {
|
||
var (
|
||
ctx = r.Context()
|
||
workspace = httpmw.WorkspaceParam(r)
|
||
auditor = api.Auditor.Load()
|
||
aReq, commitAuditor = audit.InitRequest[database.WorkspaceTable](rw, &audit.RequestParams{
|
||
Audit: *auditor,
|
||
Log: api.Logger,
|
||
Request: r,
|
||
Action: database.AuditActionWrite,
|
||
OrganizationID: workspace.OrganizationID,
|
||
})
|
||
)
|
||
|
||
defer commitAuditor()
|
||
aReq.Old = workspace.WorkspaceTable()
|
||
|
||
if !api.allowWorkspaceSharing(ctx, rw, workspace.OrganizationID) {
|
||
return
|
||
}
|
||
|
||
err := api.Database.InTx(func(tx database.Store) error {
|
||
err := tx.DeleteWorkspaceACLByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
return xerrors.Errorf("delete workspace by ID: %w", err)
|
||
}
|
||
|
||
workspace, err = tx.GetWorkspaceByID(ctx, workspace.ID)
|
||
if err != nil {
|
||
return xerrors.Errorf("get updated workspace by ID: %w", err)
|
||
}
|
||
|
||
return nil
|
||
}, nil)
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return
|
||
}
|
||
|
||
aReq.New = workspace.WorkspaceTable()
|
||
|
||
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
|
||
}
|
||
|
||
// allowWorkspaceSharing enforces the workspace-sharing gate for an
|
||
// organization. It writes an HTTP error response and returns false if
|
||
// sharing is disabled or the org lookup fails; otherwise it returns
|
||
// true.
|
||
func (api *API) allowWorkspaceSharing(ctx context.Context, rw http.ResponseWriter, organizationID uuid.UUID) bool {
|
||
//nolint:gocritic // Use system context so this check doesn’t
|
||
// depend on the caller having organization:read.
|
||
org, err := api.Database.GetOrganizationByID(dbauthz.AsSystemRestricted(ctx), organizationID)
|
||
if err != nil {
|
||
httpapi.InternalServerError(rw, err)
|
||
return false
|
||
}
|
||
if org.ShareableWorkspaceOwners == database.ShareableWorkspaceOwnersNone {
|
||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||
Message: "Workspace sharing is disabled for this organization.",
|
||
})
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// workspacesData only returns the data the caller can access. If the caller
|
||
// does not have the correct perms to read a given template, the template will
|
||
// not be returned.
|
||
// So the caller must check the templates & users exist before using them.
|
||
func (api *API) workspaceData(ctx context.Context, workspaces []database.Workspace, cfg workspaceRelated) (workspaceData, error) {
|
||
workspaceIDs := make([]uuid.UUID, 0, len(workspaces))
|
||
templateIDs := make([]uuid.UUID, 0, len(workspaces))
|
||
for _, workspace := range workspaces {
|
||
workspaceIDs = append(workspaceIDs, workspace.ID)
|
||
templateIDs = append(templateIDs, workspace.TemplateID)
|
||
}
|
||
|
||
var (
|
||
templates []database.Template
|
||
builds []database.WorkspaceBuild
|
||
appStatuses []database.WorkspaceAppStatus
|
||
eg errgroup.Group
|
||
)
|
||
if cfg.Template {
|
||
eg.Go(func() (err error) {
|
||
templates, err = api.Database.GetTemplatesWithFilter(ctx, database.GetTemplatesWithFilterParams{
|
||
IDs: templateIDs,
|
||
})
|
||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||
return xerrors.Errorf("get templates: %w", err)
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
if cfg.LatestBuild != nil {
|
||
eg.Go(func() (err error) {
|
||
// This query must be run as system restricted to be efficient.
|
||
// nolint:gocritic
|
||
builds, err = api.Database.GetLatestWorkspaceBuildsByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs)
|
||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||
return xerrors.Errorf("get workspace builds: %w", err)
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
if cfg.LatestBuild.appStatuses() {
|
||
eg.Go(func() (err error) {
|
||
// This query must be run as system restricted to be efficient.
|
||
// nolint:gocritic
|
||
appStatuses, err = api.Database.GetLatestWorkspaceAppStatusesByWorkspaceIDs(dbauthz.AsSystemRestricted(ctx), workspaceIDs)
|
||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||
return xerrors.Errorf("get workspace app statuses: %w", err)
|
||
}
|
||
return nil
|
||
})
|
||
}
|
||
err := eg.Wait()
|
||
if err != nil {
|
||
return workspaceData{}, err
|
||
}
|
||
|
||
var data workspaceBuildsData
|
||
if cfg.LatestBuild != nil {
|
||
data, err = api.workspaceBuildsData(ctx, builds, *cfg.LatestBuild)
|
||
if err != nil {
|
||
return workspaceData{}, xerrors.Errorf("get workspace builds data: %w", err)
|
||
}
|
||
}
|
||
|
||
apiBuilds, err := api.convertWorkspaceBuilds(
|
||
builds,
|
||
workspaces,
|
||
data.jobs,
|
||
data.resources,
|
||
data.metadata,
|
||
data.agents,
|
||
data.apps,
|
||
data.appStatuses,
|
||
data.scripts,
|
||
data.logSources,
|
||
data.templateVersions,
|
||
data.provisionerDaemons,
|
||
)
|
||
if err != nil {
|
||
return workspaceData{}, xerrors.Errorf("convert workspace builds: %w", err)
|
||
}
|
||
|
||
return workspaceData{
|
||
templates: templates,
|
||
appStatuses: db2sdk.WorkspaceAppStatuses(appStatuses),
|
||
builds: apiBuilds,
|
||
allowRenames: api.Options.AllowWorkspaceRenames,
|
||
}, nil
|
||
}
|
||
|
||
// attachAgentMetadata maps the agent metadata the workspaces query
|
||
// aggregated per workspace onto the agents in the converted response.
|
||
// Each aggregated datum carries its workspace_agent_id. An unparsable
|
||
// aggregate degrades to missing metadata for that workspace rather
|
||
// than failing the page: the expansion is best-effort decoration on
|
||
// top of the list.
|
||
func attachAgentMetadata(ctx context.Context, logger slog.Logger, workspaces []codersdk.Workspace, rows []database.GetWorkspacesRow) {
|
||
byAgent := map[uuid.UUID][]database.WorkspaceAgentMetadatum{}
|
||
for _, row := range rows {
|
||
if len(row.AgentMetadata) == 0 {
|
||
continue
|
||
}
|
||
var metadata database.AgentMetadataAggregate
|
||
err := metadata.Scan(row.AgentMetadata)
|
||
if err != nil {
|
||
logger.Warn(ctx, "scan agent metadata, omitting it for the workspace",
|
||
slog.F("workspace_id", row.ID),
|
||
slog.Error(err),
|
||
)
|
||
continue
|
||
}
|
||
for _, datum := range metadata {
|
||
byAgent[datum.WorkspaceAgentID] = append(byAgent[datum.WorkspaceAgentID], datum)
|
||
}
|
||
}
|
||
for wi := range workspaces {
|
||
resources := workspaces[wi].LatestBuild.Resources
|
||
for ri := range resources {
|
||
for ai := range resources[ri].Agents {
|
||
agent := &resources[ri].Agents[ai]
|
||
if metadata, ok := byAgent[agent.ID]; ok {
|
||
agent.Metadata = convertWorkspaceAgentMetadata(metadata)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
func convertWorkspaces(
|
||
ctx context.Context,
|
||
logger slog.Logger,
|
||
requesterID uuid.UUID,
|
||
workspaces []database.Workspace,
|
||
data workspaceData,
|
||
) ([]codersdk.Workspace, error) {
|
||
buildByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceBuild{}
|
||
for _, workspaceBuild := range data.builds {
|
||
buildByWorkspaceID[workspaceBuild.WorkspaceID] = workspaceBuild
|
||
}
|
||
templateByID := map[uuid.UUID]database.Template{}
|
||
for _, template := range data.templates {
|
||
templateByID[template.ID] = template
|
||
}
|
||
appStatusesByWorkspaceID := map[uuid.UUID]codersdk.WorkspaceAppStatus{}
|
||
for _, appStatus := range data.appStatuses {
|
||
appStatusesByWorkspaceID[appStatus.WorkspaceID] = appStatus
|
||
}
|
||
|
||
apiWorkspaces := make([]codersdk.Workspace, 0, len(workspaces))
|
||
for _, workspace := range workspaces {
|
||
// If any data is missing from the workspace, just skip returning
|
||
// this workspace. This is not ideal, but the user cannot read
|
||
// all the workspace's data, so do not show them.
|
||
// Ideally we could just return some sort of "unknown" for the missing
|
||
// fields?
|
||
build, exists := buildByWorkspaceID[workspace.ID]
|
||
if !exists {
|
||
continue
|
||
}
|
||
template, exists := templateByID[workspace.TemplateID]
|
||
if !exists {
|
||
continue
|
||
}
|
||
appStatus := appStatusesByWorkspaceID[workspace.ID]
|
||
|
||
w, err := convertWorkspace(
|
||
ctx,
|
||
logger,
|
||
requesterID,
|
||
workspace,
|
||
build,
|
||
template,
|
||
data.allowRenames,
|
||
appStatus,
|
||
)
|
||
if err != nil {
|
||
return nil, xerrors.Errorf("convert workspace: %w", err)
|
||
}
|
||
|
||
apiWorkspaces = append(apiWorkspaces, w)
|
||
}
|
||
return apiWorkspaces, nil
|
||
}
|
||
|
||
func convertWorkspace(
|
||
ctx context.Context,
|
||
logger slog.Logger,
|
||
requesterID uuid.UUID,
|
||
workspace database.Workspace,
|
||
workspaceBuild codersdk.WorkspaceBuild,
|
||
template database.Template,
|
||
allowRenames bool,
|
||
latestAppStatus codersdk.WorkspaceAppStatus,
|
||
) (codersdk.Workspace, error) {
|
||
if requesterID == uuid.Nil {
|
||
return codersdk.Workspace{}, xerrors.Errorf("developer error: requesterID cannot be uuid.Nil!")
|
||
}
|
||
var autostartSchedule *string
|
||
if workspace.AutostartSchedule.Valid {
|
||
autostartSchedule = &workspace.AutostartSchedule.String
|
||
}
|
||
|
||
var dormantAt *time.Time
|
||
if workspace.DormantAt.Valid {
|
||
dormantAt = &workspace.DormantAt.Time
|
||
}
|
||
|
||
var deletingAt *time.Time
|
||
if workspace.DeletingAt.Valid {
|
||
deletingAt = &workspace.DeletingAt.Time
|
||
}
|
||
|
||
var nextStartAt *time.Time
|
||
if workspace.NextStartAt.Valid {
|
||
nextStartAt = &workspace.NextStartAt.Time
|
||
}
|
||
|
||
failingAgents := []uuid.UUID{}
|
||
for _, resource := range workspaceBuild.Resources {
|
||
for _, agent := range resource.Agents {
|
||
// Sub-agents (e.g., devcontainer agents) are excluded from the
|
||
// workspace health calculation. Their health is managed by
|
||
// their parent agent, and temporary disconnections during
|
||
// devcontainer rebuilds should not affect workspace health.
|
||
if agent.ParentID.Valid {
|
||
continue
|
||
}
|
||
if !agent.Health.Healthy {
|
||
failingAgents = append(failingAgents, agent.ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
ttlMillis := convertWorkspaceTTLMillis(workspace.Ttl)
|
||
// If the template doesn't allow a workspace-configured value, then report the
|
||
// template value instead.
|
||
if !template.AllowUserAutostop {
|
||
ttlMillis = convertWorkspaceTTLMillis(sql.NullInt64{Valid: true, Int64: template.DefaultTTL})
|
||
}
|
||
|
||
// Only show favorite status if you own the workspace.
|
||
requesterFavorite := workspace.OwnerID == requesterID && workspace.Favorite
|
||
|
||
appStatus := &latestAppStatus
|
||
if latestAppStatus.ID == uuid.Nil {
|
||
appStatus = nil
|
||
}
|
||
|
||
return codersdk.Workspace{
|
||
ID: workspace.ID,
|
||
CreatedAt: workspace.CreatedAt,
|
||
UpdatedAt: workspace.UpdatedAt,
|
||
OwnerID: workspace.OwnerID,
|
||
OwnerName: workspace.OwnerUsername,
|
||
OwnerAvatarURL: workspace.OwnerAvatarUrl,
|
||
OrganizationID: workspace.OrganizationID,
|
||
OrganizationName: workspace.OrganizationName,
|
||
TemplateID: workspace.TemplateID,
|
||
LatestBuild: workspaceBuild,
|
||
LatestAppStatus: appStatus,
|
||
TemplateName: workspace.TemplateName,
|
||
TemplateIcon: workspace.TemplateIcon,
|
||
TemplateDisplayName: workspace.TemplateDisplayName,
|
||
TemplateAllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
||
TemplateActiveVersionID: template.ActiveVersionID,
|
||
TemplateRequireActiveVersion: template.RequireActiveVersion,
|
||
TemplateUseClassicParameterFlow: template.UseClassicParameterFlow,
|
||
Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(),
|
||
Name: workspace.Name,
|
||
AutostartSchedule: autostartSchedule,
|
||
TTLMillis: ttlMillis,
|
||
LastUsedAt: workspace.LastUsedAt,
|
||
DeletingAt: deletingAt,
|
||
DormantAt: dormantAt,
|
||
Health: codersdk.WorkspaceHealth{
|
||
Healthy: len(failingAgents) == 0,
|
||
FailingAgents: failingAgents,
|
||
},
|
||
AutomaticUpdates: codersdk.AutomaticUpdates(workspace.AutomaticUpdates),
|
||
AllowRenames: allowRenames,
|
||
Favorite: requesterFavorite,
|
||
NextStartAt: nextStartAt,
|
||
IsPrebuild: workspace.IsPrebuild(),
|
||
TaskID: workspace.TaskID,
|
||
SharedWith: sharedWorkspaceActors(ctx, logger, workspace),
|
||
}, nil
|
||
}
|
||
|
||
func sharedWorkspaceActors(
|
||
ctx context.Context,
|
||
logger slog.Logger,
|
||
workspace database.Workspace,
|
||
) []codersdk.SharedWorkspaceActor {
|
||
out := make([]codersdk.SharedWorkspaceActor, 0, len(workspace.UserACL)+len(workspace.GroupACL))
|
||
|
||
// Users
|
||
for id, aclEntry := range workspace.UserACL {
|
||
userID, err := uuid.Parse(id)
|
||
if err != nil {
|
||
logger.Warn(ctx, "found invalid user uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
|
||
continue
|
||
}
|
||
|
||
out = append(out, codersdk.SharedWorkspaceActor{
|
||
ID: userID,
|
||
ActorType: codersdk.SharedWorkspaceActorTypeUser,
|
||
Roles: []codersdk.WorkspaceRole{convertToWorkspaceRole(aclEntry.Permissions)},
|
||
Name: workspace.UserACLDisplayInfo[id].Name,
|
||
AvatarURL: workspace.UserACLDisplayInfo[id].AvatarURL,
|
||
})
|
||
}
|
||
|
||
// Groups
|
||
for id, aclEntry := range workspace.GroupACL {
|
||
groupID, err := uuid.Parse(id)
|
||
if err != nil {
|
||
logger.Warn(ctx, "found invalid group uuid in workspace acl", slog.Error(err), slog.F("workspace_id", workspace.ID))
|
||
continue
|
||
}
|
||
|
||
out = append(out, codersdk.SharedWorkspaceActor{
|
||
ID: groupID,
|
||
ActorType: codersdk.SharedWorkspaceActorTypeGroup,
|
||
Roles: []codersdk.WorkspaceRole{convertToWorkspaceRole(aclEntry.Permissions)},
|
||
Name: workspace.GroupACLDisplayInfo[id].Name,
|
||
AvatarURL: workspace.GroupACLDisplayInfo[id].AvatarURL,
|
||
})
|
||
}
|
||
|
||
return out
|
||
}
|
||
|
||
func convertWorkspaceTTLMillis(i sql.NullInt64) *int64 {
|
||
if !i.Valid {
|
||
return nil
|
||
}
|
||
|
||
millis := time.Duration(i.Int64).Milliseconds()
|
||
return &millis
|
||
}
|
||
|
||
func validWorkspaceTTLMillis(millis *int64, templateDefault time.Duration) (sql.NullInt64, error) {
|
||
if ptr.NilOrZero(millis) {
|
||
if templateDefault == 0 {
|
||
return sql.NullInt64{}, nil
|
||
}
|
||
|
||
return sql.NullInt64{
|
||
Int64: int64(templateDefault),
|
||
Valid: true,
|
||
}, nil
|
||
}
|
||
|
||
dur := time.Duration(*millis) * time.Millisecond
|
||
truncated := dur.Truncate(time.Minute)
|
||
if truncated < ttlMinimum {
|
||
return sql.NullInt64{}, errTTLMin
|
||
}
|
||
|
||
if truncated > ttlMaximum {
|
||
return sql.NullInt64{}, errTTLMax
|
||
}
|
||
|
||
return sql.NullInt64{
|
||
Valid: true,
|
||
Int64: int64(truncated),
|
||
}, nil
|
||
}
|
||
|
||
func validWorkspaceAutomaticUpdates(updates codersdk.AutomaticUpdates) (database.AutomaticUpdates, error) {
|
||
if updates == "" {
|
||
return database.AutomaticUpdatesNever, nil
|
||
}
|
||
dbAU := database.AutomaticUpdates(updates)
|
||
if !dbAU.Valid() {
|
||
return "", xerrors.New("Automatic updates must be always or never")
|
||
}
|
||
return dbAU, nil
|
||
}
|
||
|
||
func validWorkspaceDeadline(now, startedAt, newDeadline time.Time) error {
|
||
soon := now.Add(29 * time.Minute)
|
||
if newDeadline.Before(soon) {
|
||
return errDeadlineTooSoon
|
||
}
|
||
|
||
// No idea how this could happen.
|
||
if newDeadline.Before(startedAt) {
|
||
return errDeadlineBeforeStart
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func validWorkspaceSchedule(s *string) (sql.NullString, error) {
|
||
if ptr.NilOrEmpty(s) {
|
||
return sql.NullString{}, nil
|
||
}
|
||
|
||
_, err := cron.Weekly(*s)
|
||
if err != nil {
|
||
return sql.NullString{}, err
|
||
}
|
||
|
||
return sql.NullString{
|
||
Valid: true,
|
||
String: *s,
|
||
}, nil
|
||
}
|
||
|
||
func (api *API) publishWorkspaceUpdate(ctx context.Context, ownerID uuid.UUID, event wspubsub.WorkspaceEvent) {
|
||
err := wspubsub.PublishWorkspaceEvent(ctx, api.Pubsub, ownerID, event)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "failed to publish workspace update",
|
||
slog.F("workspace_id", event.WorkspaceID), slog.Error(err))
|
||
}
|
||
}
|
||
|
||
func (api *API) publishWorkspaceAgentLogsUpdate(ctx context.Context, workspaceAgentID uuid.UUID, m agentsdk.LogsNotifyMessage) {
|
||
b, err := json.Marshal(m)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "failed to marshal logs notify message", slog.F("workspace_agent_id", workspaceAgentID), slog.Error(err))
|
||
}
|
||
err = api.Pubsub.Publish(agentsdk.LogsNotifyChannel(workspaceAgentID), b)
|
||
if err != nil {
|
||
api.Logger.Warn(ctx, "failed to publish workspace agent logs update", slog.F("workspace_agent_id", workspaceAgentID), slog.Error(err))
|
||
}
|
||
}
|
||
|
||
type WorkspaceACLUpdateValidator codersdk.UpdateWorkspaceACL
|
||
|
||
var (
|
||
workspaceACLUpdateUsersFieldName = "user_roles"
|
||
workspaceACLUpdateGroupsFieldName = "group_roles"
|
||
)
|
||
|
||
// WorkspaceACLUpdateValidator implements acl.UpdateValidator[codersdk.WorkspaceRole]
|
||
var _ acl.UpdateValidator[codersdk.WorkspaceRole] = WorkspaceACLUpdateValidator{}
|
||
|
||
func (w WorkspaceACLUpdateValidator) Users() (map[string]codersdk.WorkspaceRole, string) {
|
||
return w.UserRoles, workspaceACLUpdateUsersFieldName
|
||
}
|
||
|
||
func (w WorkspaceACLUpdateValidator) Groups() (map[string]codersdk.WorkspaceRole, string) {
|
||
return w.GroupRoles, workspaceACLUpdateGroupsFieldName
|
||
}
|
||
|
||
func (WorkspaceACLUpdateValidator) ValidateRole(role codersdk.WorkspaceRole) error {
|
||
actions := db2sdk.WorkspaceRoleActions(role)
|
||
if len(actions) == 0 && role != codersdk.WorkspaceRoleDeleted {
|
||
return xerrors.Errorf("role %q is not a valid workspace role", role)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
func convertToWorkspaceRole(actions []policy.Action) codersdk.WorkspaceRole {
|
||
switch {
|
||
case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleAdmin)):
|
||
return codersdk.WorkspaceRoleAdmin
|
||
case slice.SameElements(actions, db2sdk.WorkspaceRoleActions(codersdk.WorkspaceRoleUse)):
|
||
return codersdk.WorkspaceRoleUse
|
||
}
|
||
|
||
return codersdk.WorkspaceRoleDeleted
|
||
}
|
||
|
||
// @Summary Get users available for workspace creation
|
||
// @ID get-users-available-for-workspace-creation
|
||
// @Security CoderSessionToken
|
||
// @Produce json
|
||
// @Tags Workspaces
|
||
// @Param organization path string true "Organization ID" format(uuid)
|
||
// @Param user path string true "User ID, name, or me"
|
||
// @Param q query string false "Search query"
|
||
// @Param limit query int false "Limit results"
|
||
// @Param offset query int false "Offset for pagination"
|
||
// @Success 200 {array} codersdk.MinimalUser
|
||
// @Router /api/v2/organizations/{organization}/members/{user}/workspaces/available-users [get]
|
||
func (api *API) workspaceAvailableUsers(rw http.ResponseWriter, r *http.Request) {
|
||
ctx := r.Context()
|
||
organization := httpmw.OrganizationParam(r)
|
||
|
||
// This endpoint requires the user to be able to create workspaces for other
|
||
// users in this organization. We check if they can create a workspace with
|
||
// a wildcard owner.
|
||
if !api.Authorize(r, policy.ActionCreate, rbac.ResourceWorkspace.InOrg(organization.ID).WithOwner(policy.WildcardSymbol)) {
|
||
httpapi.Forbidden(rw)
|
||
return
|
||
}
|
||
|
||
// Use system context to list all users. The authorization check above
|
||
// ensures only users who can create workspaces for others can access this.
|
||
//nolint:gocritic // System context needed to list users for workspace owner selection.
|
||
users, _, ok := api.GetUsers(rw, r.WithContext(dbauthz.AsSystemRestricted(ctx)))
|
||
if !ok {
|
||
return
|
||
}
|
||
|
||
minimalUsers := make([]codersdk.MinimalUser, 0, len(users))
|
||
for _, user := range users {
|
||
minimalUsers = append(minimalUsers, codersdk.MinimalUser{
|
||
ID: user.ID,
|
||
Username: user.Username,
|
||
Name: user.Name,
|
||
AvatarURL: user.AvatarURL,
|
||
})
|
||
}
|
||
|
||
httpapi.Write(ctx, rw, http.StatusOK, minimalUsers)
|
||
}
|