package coderd import ( "database/sql" "encoding/json" "errors" "fmt" "net/http" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" "github.com/coder/coder/codersdk" ) func (api *API) workspaceBuild(rw http.ResponseWriter, r *http.Request) { workspaceBuild := httpmw.WorkspaceBuildParam(r) workspace := httpmw.WorkspaceParam(r) if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace. InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { return } job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching provisioner job.", Detail: err.Error(), }) return } owner, err := api.Database.GetUserByID(r.Context(), workspace.OwnerID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching user", Detail: err.Error(), }) return } httpapi.Write(rw, http.StatusOK, convertWorkspaceBuild(owner, workspace, workspaceBuild, job)) } func (api *API) workspaceBuilds(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace. InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { return } paginationParams, ok := parsePagination(rw, r) if !ok { return } var builds []database.WorkspaceBuild // Ensure all db calls happen in the same tx err := api.Database.InTx(func(store database.Store) error { var err error if paginationParams.AfterID != uuid.Nil { // See if the record exists first. If the record does not exist, the pagination // query will not work. _, err := store.GetWorkspaceBuildByID(r.Context(), paginationParams.AfterID) if err != nil && xerrors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: fmt.Sprintf("Record at \"after_id\" (%q) does not exist.", paginationParams.AfterID.String()), }) return err } else if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching workspace build at \"after_id\".", Detail: err.Error(), }) return err } } req := database.GetWorkspaceBuildByWorkspaceIDParams{ WorkspaceID: workspace.ID, AfterID: paginationParams.AfterID, OffsetOpt: int32(paginationParams.Offset), LimitOpt: int32(paginationParams.Limit), } builds, err = store.GetWorkspaceBuildByWorkspaceID(r.Context(), req) if xerrors.Is(err, sql.ErrNoRows) { err = nil } if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching workspace build.", Detail: err.Error(), }) return err } return nil }) if err != nil { return } jobIDs := make([]uuid.UUID, 0, len(builds)) for _, build := range builds { jobIDs = append(jobIDs, build.JobID) } jobs, err := api.Database.GetProvisionerJobsByIDs(r.Context(), jobIDs) if errors.Is(err, sql.ErrNoRows) { err = nil } if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching provisioner jobs.", Detail: err.Error(), }) return } jobByID := map[string]database.ProvisionerJob{} for _, job := range jobs { jobByID[job.ID.String()] = job } owner, err := api.Database.GetUserByID(r.Context(), workspace.OwnerID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching user", Detail: err.Error(), }) return } apiBuilds := make([]codersdk.WorkspaceBuild, 0) for _, build := range builds { job, exists := jobByID[build.JobID.String()] if !exists { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("Job %q doesn't exist for build %q.", build.JobID, build.ID), }) return } apiBuilds = append(apiBuilds, convertWorkspaceBuild(owner, workspace, build, job)) } httpapi.Write(rw, http.StatusOK, apiBuilds) } func (api *API) workspaceBuildByName(rw http.ResponseWriter, r *http.Request) { workspace := httpmw.WorkspaceParam(r) if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace. InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { return } workspaceBuildName := chi.URLParam(r, "workspacebuildname") workspaceBuild, err := api.Database.GetWorkspaceBuildByWorkspaceIDAndName(r.Context(), database.GetWorkspaceBuildByWorkspaceIDAndNameParams{ WorkspaceID: workspace.ID, Name: workspaceBuildName, }) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusNotFound, httpapi.Response{ Message: fmt.Sprintf("No workspace build found by name %q.", workspaceBuildName), }) return } if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching workspace build by name.", Detail: err.Error(), }) return } job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching provisioner job.", Detail: err.Error(), }) return } owner, err := api.Database.GetUserByID(r.Context(), workspace.OwnerID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error getting user", Detail: err.Error(), }) return } httpapi.Write(rw, http.StatusOK, convertWorkspaceBuild(owner, workspace, workspaceBuild, job)) } func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) workspace := httpmw.WorkspaceParam(r) var createBuild codersdk.CreateWorkspaceBuildRequest if !httpapi.Read(rw, r, &createBuild) { return } // Rbac action depends on the transition var action rbac.Action switch createBuild.Transition { case codersdk.WorkspaceTransitionDelete: action = rbac.ActionDelete case codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop: action = rbac.ActionUpdate default: httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: fmt.Sprintf("Transition %q not supported.", createBuild.Transition), }) return } if !api.Authorize(rw, r, action, rbac.ResourceWorkspace. InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { return } if createBuild.TemplateVersionID == uuid.Nil { latestBuild, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching the latest workspace build.", Detail: err.Error(), }) return } createBuild.TemplateVersionID = latestBuild.TemplateVersionID } templateVersion, err := api.Database.GetTemplateVersionByID(r.Context(), createBuild.TemplateVersionID) if errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusBadRequest, httpapi.Response{ Message: "Template version not found.", Validations: []httpapi.Error{{ Field: "template_version_id", Detail: "template version not found", }}, }) return } if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching template version.", Detail: err.Error(), }) return } templateVersionJob, err := api.Database.GetProvisionerJobByID(r.Context(), templateVersion.JobID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching provisioner job.", Detail: err.Error(), }) return } templateVersionJobStatus := convertProvisionerJob(templateVersionJob).Status switch templateVersionJobStatus { case codersdk.ProvisionerJobPending, codersdk.ProvisionerJobRunning: httpapi.Write(rw, http.StatusNotAcceptable, httpapi.Response{ Message: fmt.Sprintf("The provided template version is %s. Wait for it to complete importing!", templateVersionJobStatus), }) return case codersdk.ProvisionerJobFailed: httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{ Message: fmt.Sprintf("The provided template version %q has failed to import: %q. You cannot build workspaces with it!", templateVersion.Name, templateVersionJob.Error.String), }) return case codersdk.ProvisionerJobCanceled: httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{ Message: "The provided template version was canceled during import. You cannot builds workspaces with it!", }) return } template, err := api.Database.GetTemplateByID(r.Context(), templateVersion.TemplateID.UUID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching template job.", Detail: err.Error(), }) return } // Store prior build number to compute new build number var priorBuildNum int32 priorHistory, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) if err == nil { priorJob, err := api.Database.GetProvisionerJobByID(r.Context(), priorHistory.JobID) if err == nil && convertProvisionerJob(priorJob).Status.Active() { httpapi.Write(rw, http.StatusConflict, httpapi.Response{ Message: "A workspace build is already active.", }) return } priorBuildNum = priorHistory.BuildNumber } else if !errors.Is(err, sql.ErrNoRows) { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching prior workspace build.", Detail: err.Error(), }) return } var workspaceBuild database.WorkspaceBuild var provisionerJob database.ProvisionerJob // This must happen in a transaction to ensure history can be inserted, and // the prior history can update it's "after" column to point at the new. err = api.Database.InTx(func(db database.Store) error { workspaceBuildID := uuid.New() input, err := json.Marshal(workspaceProvisionJob{ WorkspaceBuildID: workspaceBuildID, }) if err != nil { return xerrors.Errorf("marshal provision job: %w", err) } provisionerJob, err = db.InsertProvisionerJob(r.Context(), database.InsertProvisionerJobParams{ ID: uuid.New(), CreatedAt: database.Now(), UpdatedAt: database.Now(), InitiatorID: apiKey.UserID, OrganizationID: template.OrganizationID, Provisioner: template.Provisioner, Type: database.ProvisionerJobTypeWorkspaceBuild, StorageMethod: templateVersionJob.StorageMethod, StorageSource: templateVersionJob.StorageSource, Input: input, }) if err != nil { return xerrors.Errorf("insert provisioner job: %w", err) } state := createBuild.ProvisionerState if len(state) == 0 { state = priorHistory.ProvisionerState } workspaceBuild, err = db.InsertWorkspaceBuild(r.Context(), database.InsertWorkspaceBuildParams{ ID: workspaceBuildID, CreatedAt: database.Now(), UpdatedAt: database.Now(), WorkspaceID: workspace.ID, TemplateVersionID: templateVersion.ID, BuildNumber: priorBuildNum + 1, Name: namesgenerator.GetRandomName(1), ProvisionerState: state, InitiatorID: apiKey.UserID, Transition: database.WorkspaceTransition(createBuild.Transition), JobID: provisionerJob.ID, }) if err != nil { return xerrors.Errorf("insert workspace build: %w", err) } return nil }) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error inserting workspace build.", Detail: err.Error(), }) return } owner, err := api.Database.GetUserByID(r.Context(), workspace.OwnerID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error getting user", Detail: err.Error(), }) return } httpapi.Write(rw, http.StatusCreated, convertWorkspaceBuild(owner, workspace, workspaceBuild, provisionerJob)) } func (api *API) patchCancelWorkspaceBuild(rw http.ResponseWriter, r *http.Request) { workspaceBuild := httpmw.WorkspaceBuildParam(r) workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspaceBuild.WorkspaceID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "No workspace exists for this job.", }) return } if !api.Authorize(rw, r, rbac.ActionUpdate, rbac.ResourceWorkspace. InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { return } job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching provisioner job.", Detail: err.Error(), }) return } if job.CompletedAt.Valid { httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{ Message: "Job has already completed!", }) return } if job.CanceledAt.Valid { httpapi.Write(rw, http.StatusPreconditionFailed, httpapi.Response{ Message: "Job has already been marked as canceled!", }) return } err = api.Database.UpdateProvisionerJobWithCancelByID(r.Context(), database.UpdateProvisionerJobWithCancelByIDParams{ ID: job.ID, CanceledAt: sql.NullTime{ Time: database.Now(), Valid: true, }, }) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error updating provisioner job.", Detail: err.Error(), }) return } httpapi.Write(rw, http.StatusOK, httpapi.Response{ Message: "Job has been marked as canceled...", }) } func (api *API) workspaceBuildResources(rw http.ResponseWriter, r *http.Request) { workspaceBuild := httpmw.WorkspaceBuildParam(r) workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspaceBuild.WorkspaceID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "No workspace exists for this job.", }) return } if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace. InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { return } job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching provisioner job.", Detail: err.Error(), }) return } api.provisionerJobResources(rw, r, job) } func (api *API) workspaceBuildLogs(rw http.ResponseWriter, r *http.Request) { workspaceBuild := httpmw.WorkspaceBuildParam(r) workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspaceBuild.WorkspaceID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "No workspace exists for this job.", }) return } if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace. InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { return } job, err := api.Database.GetProvisionerJobByID(r.Context(), workspaceBuild.JobID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "Internal error fetching provisioner job.", Detail: err.Error(), }) return } api.provisionerJobLogs(rw, r, job) } func (api *API) workspaceBuildState(rw http.ResponseWriter, r *http.Request) { workspaceBuild := httpmw.WorkspaceBuildParam(r) workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspaceBuild.WorkspaceID) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ Message: "No workspace exists for this job.", }) return } if !api.Authorize(rw, r, rbac.ActionRead, rbac.ResourceWorkspace. InOrg(workspace.OrganizationID).WithOwner(workspace.OwnerID.String()).WithID(workspace.ID.String())) { return } rw.Header().Set("Content-Type", "application/json") rw.WriteHeader(http.StatusOK) _, _ = rw.Write(workspaceBuild.ProvisionerState) } func convertWorkspaceBuild( workspaceOwner database.User, workspace database.Workspace, workspaceBuild database.WorkspaceBuild, job database.ProvisionerJob) codersdk.WorkspaceBuild { //nolint:unconvert if workspace.ID != workspaceBuild.WorkspaceID { panic("workspace and build do not match") } return codersdk.WorkspaceBuild{ ID: workspaceBuild.ID, CreatedAt: workspaceBuild.CreatedAt, UpdatedAt: workspaceBuild.UpdatedAt, WorkspaceOwnerID: workspace.OwnerID, WorkspaceOwnerName: workspaceOwner.Username, WorkspaceID: workspaceBuild.WorkspaceID, WorkspaceName: workspace.Name, TemplateVersionID: workspaceBuild.TemplateVersionID, BuildNumber: workspaceBuild.BuildNumber, Name: workspaceBuild.Name, Transition: codersdk.WorkspaceTransition(workspaceBuild.Transition), InitiatorID: workspaceBuild.InitiatorID, Job: convertProvisionerJob(job), Deadline: workspaceBuild.Deadline, } } func convertWorkspaceResource(resource database.WorkspaceResource, agents []codersdk.WorkspaceAgent) codersdk.WorkspaceResource { return codersdk.WorkspaceResource{ ID: resource.ID, CreatedAt: resource.CreatedAt, JobID: resource.JobID, Transition: codersdk.WorkspaceTransition(resource.Transition), Type: resource.Type, Name: resource.Name, Agents: agents, } }