mirror of
https://github.com/coder/coder.git
synced 2025-07-03 16:13:58 +00:00
Just moved `rbac.Action` -> `policy.Action`. This is for the stacked PR to not have circular dependencies when doing autogen. Without this, the autogen can produce broken golang code, which prevents the autogen from compiling. So just avoiding circular dependencies. Doing this in it's own PR to reduce LoC diffs in the primary PR, since this has 0 functional changes.
901 lines
33 KiB
Go
901 lines
33 KiB
Go
package coderd
|
|
|
|
import (
|
|
"database/sql"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/audit"
|
|
"github.com/coder/coder/v2/coderd/database"
|
|
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
|
"github.com/coder/coder/v2/coderd/database/dbtime"
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/coderd/httpmw"
|
|
"github.com/coder/coder/v2/coderd/rbac"
|
|
"github.com/coder/coder/v2/coderd/rbac/policy"
|
|
"github.com/coder/coder/v2/coderd/schedule"
|
|
"github.com/coder/coder/v2/coderd/telemetry"
|
|
"github.com/coder/coder/v2/coderd/util/ptr"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/examples"
|
|
)
|
|
|
|
// Returns a single template.
|
|
//
|
|
// @Summary Get template metadata by ID
|
|
// @ID get-template-metadata-by-id
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Templates
|
|
// @Param template path string true "Template ID" format(uuid)
|
|
// @Success 200 {object} codersdk.Template
|
|
// @Router /templates/{template} [get]
|
|
func (api *API) template(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
template := httpmw.TemplateParam(r)
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplate(template))
|
|
}
|
|
|
|
// @Summary Delete template by ID
|
|
// @ID delete-template-by-id
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Templates
|
|
// @Param template path string true "Template ID" format(uuid)
|
|
// @Success 200 {object} codersdk.Response
|
|
// @Router /templates/{template} [delete]
|
|
func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
template = httpmw.TemplateParam(r)
|
|
auditor = *api.Auditor.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.Template](rw, &audit.RequestParams{
|
|
Audit: auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionDelete,
|
|
OrganizationID: template.OrganizationID,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
aReq.Old = template
|
|
|
|
// This is just to get the workspace count, so we use a system context to
|
|
// return ALL workspaces. Not just workspaces the user can view.
|
|
// nolint:gocritic
|
|
workspaces, err := api.Database.GetWorkspaces(dbauthz.AsSystemRestricted(ctx), database.GetWorkspacesParams{
|
|
TemplateIDs: []uuid.UUID{template.ID},
|
|
})
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching workspaces by template id.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if len(workspaces) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "All workspaces must be deleted before a template can be removed.",
|
|
})
|
|
return
|
|
}
|
|
err = api.Database.UpdateTemplateDeletedByID(ctx, database.UpdateTemplateDeletedByIDParams{
|
|
ID: template.ID,
|
|
Deleted: true,
|
|
UpdatedAt: dbtime.Now(),
|
|
})
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error deleting template.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{
|
|
Message: "Template has been deleted!",
|
|
})
|
|
}
|
|
|
|
// Create a new template in an organization.
|
|
// Returns a single template.
|
|
//
|
|
// @Summary Create template by organization
|
|
// @ID create-template-by-organization
|
|
// @Security CoderSessionToken
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Tags Templates
|
|
// @Param request body codersdk.CreateTemplateRequest true "Request body"
|
|
// @Param organization path string true "Organization ID"
|
|
// @Success 200 {object} codersdk.Template
|
|
// @Router /organizations/{organization}/templates [post]
|
|
func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
createTemplate codersdk.CreateTemplateRequest
|
|
organization = httpmw.OrganizationParam(r)
|
|
apiKey = httpmw.APIKey(r)
|
|
auditor = *api.Auditor.Load()
|
|
templateAudit, commitTemplateAudit = audit.InitRequest[database.Template](rw, &audit.RequestParams{
|
|
Audit: auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionCreate,
|
|
OrganizationID: organization.ID,
|
|
})
|
|
templateVersionAudit, commitTemplateVersionAudit = audit.InitRequest[database.TemplateVersion](rw, &audit.RequestParams{
|
|
Audit: auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
OrganizationID: organization.ID,
|
|
})
|
|
)
|
|
defer commitTemplateAudit()
|
|
defer commitTemplateVersionAudit()
|
|
|
|
if !httpapi.Read(ctx, rw, r, &createTemplate) {
|
|
return
|
|
}
|
|
|
|
// Make a temporary struct to represent the template. This is used for
|
|
// auditing if any of the following checks fail. It will be overwritten when
|
|
// the template is inserted into the db.
|
|
templateAudit.New = database.Template{
|
|
OrganizationID: organization.ID,
|
|
Name: createTemplate.Name,
|
|
Description: createTemplate.Description,
|
|
CreatedBy: apiKey.UserID,
|
|
Icon: createTemplate.Icon,
|
|
DisplayName: createTemplate.DisplayName,
|
|
}
|
|
|
|
_, err := api.Database.GetTemplateByOrganizationAndName(ctx, database.GetTemplateByOrganizationAndNameParams{
|
|
OrganizationID: organization.ID,
|
|
Name: createTemplate.Name,
|
|
})
|
|
if err == nil {
|
|
httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{
|
|
Message: fmt.Sprintf("Template with name %q already exists.", createTemplate.Name),
|
|
Validations: []codersdk.ValidationError{{
|
|
Field: "name",
|
|
Detail: "This value is already in use and should be unique.",
|
|
}},
|
|
})
|
|
return
|
|
}
|
|
if !errors.Is(err, sql.ErrNoRows) {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching template by name.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
templateVersion, err := api.Database.GetTemplateVersionByID(ctx, createTemplate.VersionID)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
|
|
Message: fmt.Sprintf("Template version %q does not exist.", createTemplate.VersionID),
|
|
Validations: []codersdk.ValidationError{
|
|
{Field: "template_version_id", Detail: "Template version does not exist"},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching template version.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
if templateVersion.Archived {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("Template version %s is archived.", createTemplate.VersionID),
|
|
Validations: []codersdk.ValidationError{
|
|
{Field: "template_version_id", Detail: "Template version is archived"},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
templateVersionAudit.Old = templateVersion
|
|
if templateVersion.TemplateID.Valid {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: fmt.Sprintf("Template version %s is already part of a template", createTemplate.VersionID),
|
|
Validations: []codersdk.ValidationError{
|
|
{Field: "template_version_id", Detail: "Template version is already part of a template"},
|
|
},
|
|
})
|
|
return
|
|
}
|
|
|
|
importJob, err := api.Database.GetProvisionerJobByID(ctx, templateVersion.JobID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching provisioner job.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
var (
|
|
defaultTTL time.Duration
|
|
activityBump = time.Hour // default
|
|
autostopRequirementDaysOfWeek []string
|
|
autostartRequirementDaysOfWeek []string
|
|
autostopRequirementWeeks int64
|
|
failureTTL time.Duration
|
|
dormantTTL time.Duration
|
|
dormantAutoDeletionTTL time.Duration
|
|
)
|
|
if createTemplate.DefaultTTLMillis != nil {
|
|
defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond
|
|
}
|
|
if createTemplate.ActivityBumpMillis != nil {
|
|
activityBump = time.Duration(*createTemplate.ActivityBumpMillis) * time.Millisecond
|
|
}
|
|
if createTemplate.AutostopRequirement != nil {
|
|
autostopRequirementDaysOfWeek = createTemplate.AutostopRequirement.DaysOfWeek
|
|
autostopRequirementWeeks = createTemplate.AutostopRequirement.Weeks
|
|
}
|
|
if createTemplate.AutostartRequirement != nil {
|
|
autostartRequirementDaysOfWeek = createTemplate.AutostartRequirement.DaysOfWeek
|
|
} else {
|
|
// By default, we want to allow all days of the week to be autostarted.
|
|
autostartRequirementDaysOfWeek = codersdk.BitmapToWeekdays(0b01111111)
|
|
}
|
|
if createTemplate.FailureTTLMillis != nil {
|
|
failureTTL = time.Duration(*createTemplate.FailureTTLMillis) * time.Millisecond
|
|
}
|
|
if createTemplate.TimeTilDormantMillis != nil {
|
|
dormantTTL = time.Duration(*createTemplate.TimeTilDormantMillis) * time.Millisecond
|
|
}
|
|
if createTemplate.TimeTilDormantAutoDeleteMillis != nil {
|
|
dormantAutoDeletionTTL = time.Duration(*createTemplate.TimeTilDormantAutoDeleteMillis) * time.Millisecond
|
|
}
|
|
|
|
var (
|
|
validErrs []codersdk.ValidationError
|
|
autostopRequirementDaysOfWeekParsed uint8
|
|
autostartRequirementDaysOfWeekParsed uint8
|
|
)
|
|
if defaultTTL < 0 {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."})
|
|
}
|
|
if activityBump < 0 {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "activity_bump_ms", Detail: "Must be a positive integer."})
|
|
}
|
|
|
|
if len(autostopRequirementDaysOfWeek) > 0 {
|
|
autostopRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(autostopRequirementDaysOfWeek)
|
|
if err != nil {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.days_of_week", Detail: err.Error()})
|
|
}
|
|
}
|
|
if len(autostartRequirementDaysOfWeek) > 0 {
|
|
autostartRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(autostartRequirementDaysOfWeek)
|
|
if err != nil {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostart_requirement.days_of_week", Detail: err.Error()})
|
|
}
|
|
}
|
|
|
|
if autostopRequirementWeeks < 0 {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: "Must be a positive integer."})
|
|
}
|
|
if autostopRequirementWeeks > schedule.MaxTemplateAutostopRequirementWeeks {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateAutostopRequirementWeeks)})
|
|
}
|
|
if failureTTL < 0 {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."})
|
|
}
|
|
if dormantTTL < 0 {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_autodeletion_ms", Detail: "Must be a positive integer."})
|
|
}
|
|
if dormantAutoDeletionTTL < 0 {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_autodeletion_ms", Detail: "Must be a positive integer."})
|
|
}
|
|
|
|
if len(validErrs) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid create template request.",
|
|
Validations: validErrs,
|
|
})
|
|
return
|
|
}
|
|
|
|
var (
|
|
dbTemplate database.Template
|
|
|
|
allowUserCancelWorkspaceJobs = ptr.NilToDefault(createTemplate.AllowUserCancelWorkspaceJobs, false)
|
|
allowUserAutostart = ptr.NilToDefault(createTemplate.AllowUserAutostart, true)
|
|
allowUserAutostop = ptr.NilToDefault(createTemplate.AllowUserAutostop, true)
|
|
)
|
|
|
|
defaultsGroups := database.TemplateACL{}
|
|
if !createTemplate.DisableEveryoneGroupAccess {
|
|
// The organization ID is used as the group ID for the everyone group
|
|
// in this organization.
|
|
defaultsGroups[organization.ID.String()] = []policy.Action{policy.ActionRead}
|
|
}
|
|
err = api.Database.InTx(func(tx database.Store) error {
|
|
now := dbtime.Now()
|
|
id := uuid.New()
|
|
err = tx.InsertTemplate(ctx, database.InsertTemplateParams{
|
|
ID: id,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
OrganizationID: organization.ID,
|
|
Name: createTemplate.Name,
|
|
Provisioner: importJob.Provisioner,
|
|
ActiveVersionID: templateVersion.ID,
|
|
Description: createTemplate.Description,
|
|
CreatedBy: apiKey.UserID,
|
|
UserACL: database.TemplateACL{},
|
|
GroupACL: defaultsGroups,
|
|
DisplayName: createTemplate.DisplayName,
|
|
Icon: createTemplate.Icon,
|
|
AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs,
|
|
MaxPortSharingLevel: database.AppSharingLevelOwner,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert template: %s", err)
|
|
}
|
|
|
|
if createTemplate.RequireActiveVersion {
|
|
err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, id, dbauthz.TemplateAccessControl{
|
|
RequireActiveVersion: createTemplate.RequireActiveVersion,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("set template access control: %w", err)
|
|
}
|
|
}
|
|
|
|
dbTemplate, err = tx.GetTemplateByID(ctx, id)
|
|
if err != nil {
|
|
return xerrors.Errorf("get template by id: %s", err)
|
|
}
|
|
|
|
dbTemplate, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{
|
|
UserAutostartEnabled: allowUserAutostart,
|
|
UserAutostopEnabled: allowUserAutostop,
|
|
DefaultTTL: defaultTTL,
|
|
ActivityBump: activityBump,
|
|
// Some of these values are enterprise-only, but the
|
|
// TemplateScheduleStore will handle avoiding setting them if
|
|
// unlicensed.
|
|
AutostopRequirement: schedule.TemplateAutostopRequirement{
|
|
DaysOfWeek: autostopRequirementDaysOfWeekParsed,
|
|
Weeks: autostopRequirementWeeks,
|
|
},
|
|
AutostartRequirement: schedule.TemplateAutostartRequirement{
|
|
DaysOfWeek: autostartRequirementDaysOfWeekParsed,
|
|
},
|
|
FailureTTL: failureTTL,
|
|
TimeTilDormant: dormantTTL,
|
|
TimeTilDormantAutoDelete: dormantAutoDeletionTTL,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("set template schedule options: %s", err)
|
|
}
|
|
|
|
templateAudit.New = dbTemplate
|
|
|
|
err = tx.UpdateTemplateVersionByID(ctx, database.UpdateTemplateVersionByIDParams{
|
|
ID: templateVersion.ID,
|
|
TemplateID: uuid.NullUUID{
|
|
UUID: dbTemplate.ID,
|
|
Valid: true,
|
|
},
|
|
UpdatedAt: dbtime.Now(),
|
|
Name: templateVersion.Name,
|
|
Message: templateVersion.Message,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("insert template version: %s", err)
|
|
}
|
|
newTemplateVersion := templateVersion
|
|
newTemplateVersion.TemplateID = uuid.NullUUID{
|
|
UUID: dbTemplate.ID,
|
|
Valid: true,
|
|
}
|
|
templateVersionAudit.New = newTemplateVersion
|
|
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error inserting template.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
api.Telemetry.Report(&telemetry.Snapshot{
|
|
Templates: []telemetry.Template{telemetry.ConvertTemplate(dbTemplate)},
|
|
TemplateVersions: []telemetry.TemplateVersion{telemetry.ConvertTemplateVersion(templateVersion)},
|
|
})
|
|
|
|
httpapi.Write(ctx, rw, http.StatusCreated, api.convertTemplate(dbTemplate))
|
|
}
|
|
|
|
// @Summary Get templates by organization
|
|
// @ID get-templates-by-organization
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Templates
|
|
// @Param organization path string true "Organization ID" format(uuid)
|
|
// @Success 200 {array} codersdk.Template
|
|
// @Router /organizations/{organization}/templates [get]
|
|
func (api *API) templatesByOrganization(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
organization := httpmw.OrganizationParam(r)
|
|
|
|
p := httpapi.NewQueryParamParser()
|
|
values := r.URL.Query()
|
|
|
|
deprecated := sql.NullBool{}
|
|
if values.Has("deprecated") {
|
|
deprecated = sql.NullBool{
|
|
Bool: p.Boolean(values, false, "deprecated"),
|
|
Valid: true,
|
|
}
|
|
}
|
|
if len(p.Errors) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid query params.",
|
|
Validations: p.Errors,
|
|
})
|
|
return
|
|
}
|
|
|
|
prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, policy.ActionRead, rbac.ResourceTemplate.Type)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error preparing sql filter.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
// Filter templates based on rbac permissions
|
|
templates, err := api.Database.GetAuthorizedTemplates(ctx, database.GetTemplatesWithFilterParams{
|
|
OrganizationID: organization.ID,
|
|
Deprecated: deprecated,
|
|
}, prepared)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
err = nil
|
|
}
|
|
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching templates in organization.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplates(templates))
|
|
}
|
|
|
|
// @Summary Get templates by organization and template name
|
|
// @ID get-templates-by-organization-and-template-name
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Templates
|
|
// @Param organization path string true "Organization ID" format(uuid)
|
|
// @Param templatename path string true "Template name"
|
|
// @Success 200 {object} codersdk.Template
|
|
// @Router /organizations/{organization}/templates/{templatename} [get]
|
|
func (api *API) templateByOrganizationAndName(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
organization := httpmw.OrganizationParam(r)
|
|
templateName := chi.URLParam(r, "templatename")
|
|
template, err := api.Database.GetTemplateByOrganizationAndName(ctx, database.GetTemplateByOrganizationAndNameParams{
|
|
OrganizationID: organization.ID,
|
|
Name: templateName,
|
|
})
|
|
if err != nil {
|
|
if httpapi.Is404Error(err) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching template.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplate(template))
|
|
}
|
|
|
|
// @Summary Update template metadata by ID
|
|
// @ID update-template-metadata-by-id
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Templates
|
|
// @Param template path string true "Template ID" format(uuid)
|
|
// @Success 200 {object} codersdk.Template
|
|
// @Router /templates/{template} [patch]
|
|
func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
template = httpmw.TemplateParam(r)
|
|
auditor = *api.Auditor.Load()
|
|
portSharer = *api.PortSharer.Load()
|
|
aReq, commitAudit = audit.InitRequest[database.Template](rw, &audit.RequestParams{
|
|
Audit: auditor,
|
|
Log: api.Logger,
|
|
Request: r,
|
|
Action: database.AuditActionWrite,
|
|
OrganizationID: template.OrganizationID,
|
|
})
|
|
)
|
|
defer commitAudit()
|
|
aReq.Old = template
|
|
|
|
scheduleOpts, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID)
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching template schedule options.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
var req codersdk.UpdateTemplateMeta
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
var (
|
|
validErrs []codersdk.ValidationError
|
|
autostopRequirementDaysOfWeekParsed uint8
|
|
autostartRequirementDaysOfWeekParsed uint8
|
|
)
|
|
if req.DefaultTTLMillis < 0 {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."})
|
|
}
|
|
if req.ActivityBumpMillis < 0 {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "activity_bump_ms", Detail: "Must be a positive integer."})
|
|
}
|
|
|
|
if req.AutostopRequirement == nil {
|
|
req.AutostopRequirement = &codersdk.TemplateAutostopRequirement{
|
|
DaysOfWeek: codersdk.BitmapToWeekdays(scheduleOpts.AutostopRequirement.DaysOfWeek),
|
|
Weeks: scheduleOpts.AutostopRequirement.Weeks,
|
|
}
|
|
}
|
|
if len(req.AutostopRequirement.DaysOfWeek) > 0 {
|
|
autostopRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(req.AutostopRequirement.DaysOfWeek)
|
|
if err != nil {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.days_of_week", Detail: err.Error()})
|
|
}
|
|
}
|
|
if req.AutostartRequirement == nil {
|
|
req.AutostartRequirement = &codersdk.TemplateAutostartRequirement{
|
|
DaysOfWeek: codersdk.BitmapToWeekdays(scheduleOpts.AutostartRequirement.DaysOfWeek),
|
|
}
|
|
}
|
|
if len(req.AutostartRequirement.DaysOfWeek) > 0 {
|
|
autostartRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(req.AutostartRequirement.DaysOfWeek)
|
|
if err != nil {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostart_requirement.days_of_week", Detail: err.Error()})
|
|
}
|
|
}
|
|
if req.AutostopRequirement.Weeks < 0 {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: "Must be a positive integer."})
|
|
}
|
|
if req.AutostopRequirement.Weeks == 0 {
|
|
req.AutostopRequirement.Weeks = 1
|
|
}
|
|
if template.AutostopRequirementWeeks <= 0 {
|
|
template.AutostopRequirementWeeks = 1
|
|
}
|
|
if req.AutostopRequirement.Weeks > schedule.MaxTemplateAutostopRequirementWeeks {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateAutostopRequirementWeeks)})
|
|
}
|
|
// Defaults to the existing.
|
|
deprecationMessage := template.Deprecated
|
|
if req.DeprecationMessage != nil {
|
|
deprecationMessage = *req.DeprecationMessage
|
|
}
|
|
|
|
// The minimum valid value for a dormant TTL is 1 minute. This is
|
|
// to ensure an uninformed user does not send an unintentionally
|
|
// small number resulting in potentially catastrophic consequences.
|
|
const minTTL = 1000 * 60
|
|
if req.FailureTTLMillis < 0 || (req.FailureTTLMillis > 0 && req.FailureTTLMillis < minTTL) {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Value must be at least one minute."})
|
|
}
|
|
if req.TimeTilDormantMillis < 0 || (req.TimeTilDormantMillis > 0 && req.TimeTilDormantMillis < minTTL) {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_ms", Detail: "Value must be at least one minute."})
|
|
}
|
|
if req.TimeTilDormantAutoDeleteMillis < 0 || (req.TimeTilDormantAutoDeleteMillis > 0 && req.TimeTilDormantAutoDeleteMillis < minTTL) {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_autodelete_ms", Detail: "Value must be at least one minute."})
|
|
}
|
|
maxPortShareLevel := template.MaxPortSharingLevel
|
|
if req.MaxPortShareLevel != nil && *req.MaxPortShareLevel != portSharer.ConvertMaxLevel(template.MaxPortSharingLevel) {
|
|
err := portSharer.ValidateTemplateMaxLevel(*req.MaxPortShareLevel)
|
|
if err != nil {
|
|
validErrs = append(validErrs, codersdk.ValidationError{Field: "max_port_sharing_level", Detail: err.Error()})
|
|
} else {
|
|
maxPortShareLevel = database.AppSharingLevel(*req.MaxPortShareLevel)
|
|
}
|
|
}
|
|
|
|
if len(validErrs) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Invalid request to update template metadata!",
|
|
Validations: validErrs,
|
|
})
|
|
return
|
|
}
|
|
|
|
var updated database.Template
|
|
err = api.Database.InTx(func(tx database.Store) error {
|
|
if req.Name == template.Name &&
|
|
req.Description == template.Description &&
|
|
req.DisplayName == template.DisplayName &&
|
|
req.Icon == template.Icon &&
|
|
req.AllowUserAutostart == template.AllowUserAutostart &&
|
|
req.AllowUserAutostop == template.AllowUserAutostop &&
|
|
req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs &&
|
|
req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() &&
|
|
req.ActivityBumpMillis == time.Duration(template.ActivityBump).Milliseconds() &&
|
|
autostopRequirementDaysOfWeekParsed == scheduleOpts.AutostopRequirement.DaysOfWeek &&
|
|
autostartRequirementDaysOfWeekParsed == scheduleOpts.AutostartRequirement.DaysOfWeek &&
|
|
req.AutostopRequirement.Weeks == scheduleOpts.AutostopRequirement.Weeks &&
|
|
req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() &&
|
|
req.TimeTilDormantMillis == time.Duration(template.TimeTilDormant).Milliseconds() &&
|
|
req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() &&
|
|
req.RequireActiveVersion == template.RequireActiveVersion &&
|
|
(deprecationMessage == template.Deprecated) &&
|
|
maxPortShareLevel == template.MaxPortSharingLevel {
|
|
return nil
|
|
}
|
|
|
|
// Users should not be able to clear the template name in the UI
|
|
name := req.Name
|
|
if name == "" {
|
|
name = template.Name
|
|
}
|
|
|
|
groupACL := template.GroupACL
|
|
if req.DisableEveryoneGroupAccess {
|
|
delete(groupACL, template.OrganizationID.String())
|
|
}
|
|
|
|
if template.MaxPortSharingLevel != maxPortShareLevel {
|
|
switch maxPortShareLevel {
|
|
case database.AppSharingLevelOwner:
|
|
err = tx.DeleteWorkspaceAgentPortSharesByTemplate(ctx, template.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("delete workspace agent port shares by template: %w", err)
|
|
}
|
|
case database.AppSharingLevelAuthenticated:
|
|
err = tx.ReduceWorkspaceAgentShareLevelToAuthenticatedByTemplate(ctx, template.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("reduce workspace agent share level to authenticated by template: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
var err error
|
|
err = tx.UpdateTemplateMetaByID(ctx, database.UpdateTemplateMetaByIDParams{
|
|
ID: template.ID,
|
|
UpdatedAt: dbtime.Now(),
|
|
Name: name,
|
|
DisplayName: req.DisplayName,
|
|
Description: req.Description,
|
|
Icon: req.Icon,
|
|
AllowUserCancelWorkspaceJobs: req.AllowUserCancelWorkspaceJobs,
|
|
GroupACL: groupACL,
|
|
MaxPortSharingLevel: maxPortShareLevel,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("update template metadata: %w", err)
|
|
}
|
|
|
|
if template.RequireActiveVersion != req.RequireActiveVersion || deprecationMessage != template.Deprecated {
|
|
err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, template.ID, dbauthz.TemplateAccessControl{
|
|
RequireActiveVersion: req.RequireActiveVersion,
|
|
Deprecated: deprecationMessage,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("set template access control: %w", err)
|
|
}
|
|
}
|
|
|
|
updated, err = tx.GetTemplateByID(ctx, template.ID)
|
|
if err != nil {
|
|
return xerrors.Errorf("fetch updated template metadata: %w", err)
|
|
}
|
|
|
|
defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond
|
|
activityBump := time.Duration(req.ActivityBumpMillis) * time.Millisecond
|
|
failureTTL := time.Duration(req.FailureTTLMillis) * time.Millisecond
|
|
inactivityTTL := time.Duration(req.TimeTilDormantMillis) * time.Millisecond
|
|
timeTilDormantAutoDelete := time.Duration(req.TimeTilDormantAutoDeleteMillis) * time.Millisecond
|
|
|
|
if defaultTTL != time.Duration(template.DefaultTTL) ||
|
|
activityBump != time.Duration(template.ActivityBump) ||
|
|
autostopRequirementDaysOfWeekParsed != scheduleOpts.AutostopRequirement.DaysOfWeek ||
|
|
autostartRequirementDaysOfWeekParsed != scheduleOpts.AutostartRequirement.DaysOfWeek ||
|
|
req.AutostopRequirement.Weeks != scheduleOpts.AutostopRequirement.Weeks ||
|
|
failureTTL != time.Duration(template.FailureTTL) ||
|
|
inactivityTTL != time.Duration(template.TimeTilDormant) ||
|
|
timeTilDormantAutoDelete != time.Duration(template.TimeTilDormantAutoDelete) ||
|
|
req.AllowUserAutostart != template.AllowUserAutostart ||
|
|
req.AllowUserAutostop != template.AllowUserAutostop {
|
|
updated, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, updated, schedule.TemplateScheduleOptions{
|
|
// Some of these values are enterprise-only, but the
|
|
// TemplateScheduleStore will handle avoiding setting them if
|
|
// unlicensed.
|
|
UserAutostartEnabled: req.AllowUserAutostart,
|
|
UserAutostopEnabled: req.AllowUserAutostop,
|
|
DefaultTTL: defaultTTL,
|
|
ActivityBump: activityBump,
|
|
AutostopRequirement: schedule.TemplateAutostopRequirement{
|
|
DaysOfWeek: autostopRequirementDaysOfWeekParsed,
|
|
Weeks: req.AutostopRequirement.Weeks,
|
|
},
|
|
AutostartRequirement: schedule.TemplateAutostartRequirement{
|
|
DaysOfWeek: autostartRequirementDaysOfWeekParsed,
|
|
},
|
|
FailureTTL: failureTTL,
|
|
TimeTilDormant: inactivityTTL,
|
|
TimeTilDormantAutoDelete: timeTilDormantAutoDelete,
|
|
UpdateWorkspaceLastUsedAt: req.UpdateWorkspaceLastUsedAt,
|
|
UpdateWorkspaceDormantAt: req.UpdateWorkspaceDormantAt,
|
|
})
|
|
if err != nil {
|
|
return xerrors.Errorf("set template schedule options: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}, nil)
|
|
if err != nil {
|
|
httpapi.InternalServerError(rw, err)
|
|
return
|
|
}
|
|
|
|
if updated.UpdatedAt.IsZero() {
|
|
aReq.New = template
|
|
httpapi.Write(ctx, rw, http.StatusNotModified, nil)
|
|
return
|
|
}
|
|
aReq.New = updated
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplate(updated))
|
|
}
|
|
|
|
// @Summary Get template DAUs by ID
|
|
// @ID get-template-daus-by-id
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Templates
|
|
// @Param template path string true "Template ID" format(uuid)
|
|
// @Success 200 {object} codersdk.DAUsResponse
|
|
// @Router /templates/{template}/daus [get]
|
|
func (api *API) templateDAUs(rw http.ResponseWriter, r *http.Request) {
|
|
template := httpmw.TemplateParam(r)
|
|
|
|
api.returnDAUsInternal(rw, r, []uuid.UUID{template.ID})
|
|
}
|
|
|
|
// @Summary Get template examples by organization
|
|
// @ID get-template-examples-by-organization
|
|
// @Security CoderSessionToken
|
|
// @Produce json
|
|
// @Tags Templates
|
|
// @Param organization path string true "Organization ID" format(uuid)
|
|
// @Success 200 {array} codersdk.TemplateExample
|
|
// @Router /organizations/{organization}/templates/examples [get]
|
|
func (api *API) templateExamples(rw http.ResponseWriter, r *http.Request) {
|
|
var (
|
|
ctx = r.Context()
|
|
organization = httpmw.OrganizationParam(r)
|
|
)
|
|
|
|
if !api.Authorize(r, policy.ActionRead, rbac.ResourceTemplate.InOrg(organization.ID)) {
|
|
httpapi.ResourceNotFound(rw)
|
|
return
|
|
}
|
|
|
|
ex, err := examples.List()
|
|
if err != nil {
|
|
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
|
Message: "Internal error fetching examples.",
|
|
Detail: err.Error(),
|
|
})
|
|
return
|
|
}
|
|
|
|
httpapi.Write(ctx, rw, http.StatusOK, ex)
|
|
}
|
|
|
|
func (api *API) convertTemplates(templates []database.Template) []codersdk.Template {
|
|
apiTemplates := make([]codersdk.Template, 0, len(templates))
|
|
|
|
for _, template := range templates {
|
|
apiTemplates = append(apiTemplates, api.convertTemplate(template))
|
|
}
|
|
|
|
// Sort templates by ActiveUserCount DESC
|
|
sort.SliceStable(apiTemplates, func(i, j int) bool {
|
|
return apiTemplates[i].ActiveUserCount > apiTemplates[j].ActiveUserCount
|
|
})
|
|
|
|
return apiTemplates
|
|
}
|
|
|
|
func (api *API) convertTemplate(
|
|
template database.Template,
|
|
) codersdk.Template {
|
|
templateAccessControl := (*(api.Options.AccessControlStore.Load())).GetTemplateAccessControl(template)
|
|
|
|
owners := 0
|
|
o, ok := api.metricsCache.TemplateWorkspaceOwners(template.ID)
|
|
if ok {
|
|
owners = o
|
|
}
|
|
|
|
buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID)
|
|
|
|
autostopRequirementWeeks := template.AutostopRequirementWeeks
|
|
if autostopRequirementWeeks < 1 {
|
|
autostopRequirementWeeks = 1
|
|
}
|
|
|
|
portSharer := *(api.PortSharer.Load())
|
|
maxPortShareLevel := portSharer.ConvertMaxLevel(template.MaxPortSharingLevel)
|
|
|
|
return codersdk.Template{
|
|
ID: template.ID,
|
|
CreatedAt: template.CreatedAt,
|
|
UpdatedAt: template.UpdatedAt,
|
|
OrganizationID: template.OrganizationID,
|
|
Name: template.Name,
|
|
DisplayName: template.DisplayName,
|
|
Provisioner: codersdk.ProvisionerType(template.Provisioner),
|
|
ActiveVersionID: template.ActiveVersionID,
|
|
ActiveUserCount: owners,
|
|
BuildTimeStats: buildTimeStats,
|
|
Description: template.Description,
|
|
Icon: template.Icon,
|
|
DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(),
|
|
ActivityBumpMillis: time.Duration(template.ActivityBump).Milliseconds(),
|
|
CreatedByID: template.CreatedBy,
|
|
CreatedByName: template.CreatedByUsername,
|
|
AllowUserAutostart: template.AllowUserAutostart,
|
|
AllowUserAutostop: template.AllowUserAutostop,
|
|
AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs,
|
|
FailureTTLMillis: time.Duration(template.FailureTTL).Milliseconds(),
|
|
TimeTilDormantMillis: time.Duration(template.TimeTilDormant).Milliseconds(),
|
|
TimeTilDormantAutoDeleteMillis: time.Duration(template.TimeTilDormantAutoDelete).Milliseconds(),
|
|
AutostopRequirement: codersdk.TemplateAutostopRequirement{
|
|
DaysOfWeek: codersdk.BitmapToWeekdays(uint8(template.AutostopRequirementDaysOfWeek)),
|
|
Weeks: autostopRequirementWeeks,
|
|
},
|
|
AutostartRequirement: codersdk.TemplateAutostartRequirement{
|
|
DaysOfWeek: codersdk.BitmapToWeekdays(template.AutostartAllowedDays()),
|
|
},
|
|
// These values depend on entitlements and come from the templateAccessControl
|
|
RequireActiveVersion: templateAccessControl.RequireActiveVersion,
|
|
Deprecated: templateAccessControl.IsDeprecated(),
|
|
DeprecationMessage: templateAccessControl.Deprecated,
|
|
MaxPortShareLevel: maxPortShareLevel,
|
|
}
|
|
}
|