package coderd import ( "context" "database/sql" "errors" "fmt" "net/http" "sort" "strings" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "golang.org/x/xerrors" "cdr.dev/slog/v3" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/notifications" "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/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/workspacestats" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/examples" ) const defaultRequirementWeeks = 1 // Returns a single template. // // @Summary Get template settings by ID // @ID get-template-settings-by-id // @Security CoderSessionToken // @Produce json // @Tags Templates // @Param template path string true "Template ID" format(uuid) // @Success 200 {object} codersdk.Template // @Router /api/v2/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 /api/v2/templates/{template} [delete] func (api *API) deleteTemplate(rw http.ResponseWriter, r *http.Request) { var ( apiKey = httpmw.APIKey(r) 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 } // Allow deletion when only prebuild workspaces remain. Prebuilds // are owned by the system user and will be cleaned up // asynchronously by the prebuilds reconciler once the template's // deleted flag is set. for _, ws := range workspaces { if ws.OwnerID != database.PrebuildsSystemUserID { 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 dbauthz.IsNotAuthorizedError(err) { httpapi.Forbidden(rw) return } if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error deleting template.", Detail: err.Error(), }) return } admins, err := findTemplateAdmins(ctx, api.Database) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching template admins.", Detail: err.Error(), }) return } for _, admin := range admins { // Don't send notification to user which initiated the event. if admin.ID == apiKey.UserID { continue } api.notifyTemplateDeleted(ctx, template, apiKey.UserID, admin.ID) } httpapi.Write(ctx, rw, http.StatusOK, codersdk.Response{ Message: "Template has been deleted!", }) } func (api *API) notifyTemplateDeleted(ctx context.Context, template database.Template, initiatorID uuid.UUID, receiverID uuid.UUID) { initiator, err := api.Database.GetUserByID(ctx, initiatorID) if err != nil { api.Logger.Warn(ctx, "failed to fetch initiator for template deletion notification", slog.F("initiator_id", initiatorID), slog.Error(err)) return } templateNameLabel := template.DisplayName if templateNameLabel == "" { templateNameLabel = template.Name } // nolint:gocritic // Need notifier actor to enqueue notifications if _, err := api.NotificationsEnqueuer.Enqueue(dbauthz.AsNotifier(ctx), receiverID, notifications.TemplateTemplateDeleted, map[string]string{ "name": templateNameLabel, "initiator": initiator.Username, }, "api-templates-delete", // Associate this notification with all the related entities. template.ID, template.OrganizationID, ); err != nil { api.Logger.Warn(ctx, "failed to notify of template deletion", slog.F("deleted_template_id", template.ID), slog.Error(err)) } } // 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 /api/v2/organizations/{organization}/templates [post] func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Request) { var ( ctx = r.Context() portSharer = *api.PortSharer.Load() 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 } // Default is false as dynamic parameters are now the preferred approach. useClassicParameterFlow := ptr.NilToDefault(createTemplate.UseClassicParameterFlow, false) // 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, UseClassicParameterFlow: useClassicParameterFlow, } _, 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 maxPortShareLevel = database.AppSharingLevelOwner // default corsBehavior = database.CorsBehaviorSimple // default ) 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 createTemplate.MaxPortShareLevel != nil { err = portSharer.ValidateTemplateMaxLevel(*createTemplate.MaxPortShareLevel) if err != nil { validErrs = append(validErrs, codersdk.ValidationError{Field: "max_port_share_level", Detail: err.Error()}) } else { maxPortShareLevel = database.AppSharingLevel(*createTemplate.MaxPortShareLevel) } } // Default the CORS behavior here to Simple so we don't break all existing templates. val := database.CorsBehaviorSimple if createTemplate.CORSBehavior != nil { val = database.CorsBehavior(*createTemplate.CORSBehavior) } if !val.Valid() { validErrs = append(validErrs, codersdk.ValidationError{ Field: "cors_behavior", Detail: fmt.Sprintf("Invalid CORS behavior %q. Must be one of [%s]", *createTemplate.CORSBehavior, strings.Join(slice.ToStrings(database.AllCorsBehaviorValues()), ", ")), }) } else { corsBehavior = val } 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()] = db2sdk.TemplateRoleActions(codersdk.TemplateRoleUse) } 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: maxPortShareLevel, UseClassicParameterFlow: useClassicParameterFlow, CorsBehavior: corsBehavior, }) 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 }, database.DefaultTXOptions().WithID("postTemplate")) 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 // @Description Returns a list of templates for the specified organization. // @Description By default, only non-deprecated templates are returned. // @Description To include deprecated templates, specify `deprecated:true` in the search query. // @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 /api/v2/organizations/{organization}/templates [get] func (api *API) templatesByOrganization() http.HandlerFunc { // TODO: Should deprecate this endpoint and make it akin to /workspaces with // a filter. There isn't a need to make the organization filter argument // part of the query url. // mutate the filter to only include templates from the given organization. return api.fetchTemplates(func(r *http.Request, arg *database.GetTemplatesWithFilterParams) { organization := httpmw.OrganizationParam(r) arg.OrganizationID = organization.ID }) } // @Summary Get all templates // @Description Returns a list of templates. // @Description By default, only non-deprecated templates are returned. // @Description To include deprecated templates, specify `deprecated:true` in the search query. // @ID get-all-templates // @Security CoderSessionToken // @Produce json // @Tags Templates // @Success 200 {array} codersdk.Template // @Router /api/v2/templates [get] func (api *API) fetchTemplates(mutate func(r *http.Request, arg *database.GetTemplatesWithFilterParams)) http.HandlerFunc { return func(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() key := httpmw.APIKey(r) queryStr := r.URL.Query().Get("q") filter, errs := searchquery.Templates(ctx, api.Database, key.UserID, queryStr) if len(errs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid template search query.", Validations: errs, }) 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 } args := filter if mutate != nil { mutate(r, &args) } // By default, deprecated templates are excluded unless explicitly requested if !args.Deprecated.Valid { args.Deprecated = sql.NullBool{ Bool: false, Valid: true, } } // Filter templates based on rbac permissions templates, err := api.Database.GetAuthorizedTemplates(ctx, args, 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 /api/v2/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 settings by ID // @ID update-template-settings-by-id // @Security CoderSessionToken // @Accept json // @Produce json // @Tags Templates // @Param template path string true "Template ID" format(uuid) // @Param request body codersdk.UpdateTemplateMeta true "Patch template settings request" // @Success 200 {object} codersdk.Template // @Router /api/v2/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 } // resolveTemplateMetaUpdate falls back to the existing template's // values for any pointer field that is nil in the request, so that // omitted fields are preserved instead of being overwritten with // Go zero values. resolved, validErrs := resolveTemplateMetaUpdate(template, scheduleOpts, req) if resolved.defaultTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } if resolved.activityBumpMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "activity_bump_ms", Detail: "Must be a positive integer."}) } if resolved.autostopRequirementWeeks > schedule.MaxTemplateAutostopRequirementWeeks { validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateAutostopRequirementWeeks)}) } // AutostopRequirement.Weeks is allowed to be negative on input but is // surfaced as a validation error. resolveTemplateMetaUpdate normalizes // 0 -> 1 but preserves negatives so the caller can reject them. if req.AutostopRequirement != nil && req.AutostopRequirement.Weeks < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: "Must be a positive integer."}) } if template.AutostopRequirementWeeks <= 0 { template.AutostopRequirementWeeks = defaultRequirementWeeks } // 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 resolved.failureTTLMillis < 0 || (resolved.failureTTLMillis > 0 && resolved.failureTTLMillis < minTTL) { validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Value must be at least one minute."}) } if resolved.timeTilDormantMillis < 0 || (resolved.timeTilDormantMillis > 0 && resolved.timeTilDormantMillis < minTTL) { validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_ms", Detail: "Value must be at least one minute."}) } if resolved.timeTilDormantAutoDeleteMillis < 0 || (resolved.timeTilDormantAutoDeleteMillis > 0 && resolved.timeTilDormantAutoDeleteMillis < minTTL) { validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_dormant_autodelete_ms", Detail: "Value must be at least one minute."}) } // MaxPortShareLevel resolution depends on the (potentially licensed) // PortSharer interface, so it stays out of the pure resolver. 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 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: resolved.name, DisplayName: resolved.displayName, Description: resolved.description, Icon: resolved.icon, AllowUserCancelWorkspaceJobs: resolved.allowUserCancelWorkspaceJobs, GroupACL: resolved.groupACL, MaxPortSharingLevel: maxPortShareLevel, UseClassicParameterFlow: resolved.useClassicTemplateFlow, CorsBehavior: resolved.corsBehavior, DisableModuleCache: resolved.disableModuleCache, }) if err != nil { return xerrors.Errorf("update template metadata: %w", err) } if template.RequireActiveVersion != resolved.requireActiveVersion || resolved.deprecationMessage != template.Deprecated { err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, template.ID, dbauthz.TemplateAccessControl{ RequireActiveVersion: resolved.requireActiveVersion, Deprecated: resolved.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(resolved.defaultTTLMillis) * time.Millisecond activityBump := time.Duration(resolved.activityBumpMillis) * time.Millisecond failureTTL := time.Duration(resolved.failureTTLMillis) * time.Millisecond inactivityTTL := time.Duration(resolved.timeTilDormantMillis) * time.Millisecond timeTilDormantAutoDelete := time.Duration(resolved.timeTilDormantAutoDeleteMillis) * time.Millisecond // updateWorkspaceLastUsedAtIntent is a one-shot intent: only run the // side effect when the field was explicitly set to true. var updateWorkspaceLastUsedAt workspacestats.UpdateTemplateWorkspacesLastUsedAtFunc if resolved.updateWorkspaceLastUsedAtIntent { updateWorkspaceLastUsedAt = workspacestats.UpdateTemplateWorkspacesLastUsedAt } 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: resolved.allowUserAutostart, UserAutostopEnabled: resolved.allowUserAutostop, DefaultTTL: defaultTTL, ActivityBump: activityBump, AutostopRequirement: schedule.TemplateAutostopRequirement{ DaysOfWeek: resolved.autostopRequirementDaysOfWeekParsed, Weeks: resolved.autostopRequirementWeeks, }, AutostartRequirement: schedule.TemplateAutostartRequirement{ DaysOfWeek: resolved.autostartRequirementDaysOfWeekParsed, }, FailureTTL: failureTTL, TimeTilDormant: inactivityTTL, TimeTilDormantAutoDelete: timeTilDormantAutoDelete, UpdateWorkspaceLastUsedAt: updateWorkspaceLastUsedAt, UpdateWorkspaceDormantAt: resolved.updateWorkspaceDormantAtIntent, }) if err != nil { return xerrors.Errorf("set template schedule options: %w", err) } return nil }, nil) if err != nil { if database.IsUniqueViolation(err, database.UniqueTemplatesOrganizationIDNameIndex) { httpapi.Write(ctx, rw, http.StatusConflict, codersdk.Response{ Message: fmt.Sprintf("Template with name %q already exists.", resolved.name), Validations: []codersdk.ValidationError{{ Field: "name", Detail: "This value is already in use and should be unique.", }}, }) } else { httpapi.InternalServerError(rw, err) } return } if template.Deprecated != updated.Deprecated && updated.Deprecated != "" { if err := api.notifyUsersOfTemplateDeprecation(ctx, updated); err != nil { api.Logger.Error(ctx, "failed to notify users of template deprecation", slog.Error(err)) } } aReq.New = updated httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplate(updated)) } func (api *API) notifyUsersOfTemplateDeprecation(ctx context.Context, template database.Template) error { workspaces, err := api.Database.GetWorkspaces(ctx, database.GetWorkspacesParams{ TemplateIDs: []uuid.UUID{template.ID}, }) if err != nil { return xerrors.Errorf("get workspaces by template id: %w", err) } users := make(map[uuid.UUID]struct{}) for _, workspace := range workspaces { users[workspace.OwnerID] = struct{}{} } errs := []error{} for userID := range users { _, err = api.NotificationsEnqueuer.Enqueue( //nolint:gocritic // We need the notifier auth context to be able to send the deprecation notification. dbauthz.AsNotifier(ctx), userID, notifications.TemplateTemplateDeprecated, map[string]string{ "template": template.Name, "message": template.Deprecated, "organization": template.OrganizationName, }, "notify-users-of-template-deprecation", ) if err != nil { errs = append(errs, xerrors.Errorf("enqueue notification: %w", err)) } } return errors.Join(errs...) } // @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 /api/v2/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 /api/v2/organizations/{organization}/templates/examples [get] // @Deprecated Use /templates/examples instead func (api *API) templateExamplesByOrganization(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) } // @Summary Get template examples // @ID get-template-examples // @Security CoderSessionToken // @Produce json // @Tags Templates // @Success 200 {array} codersdk.TemplateExample // @Router /api/v2/templates/examples [get] func (api *API) templateExamples(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() if !api.Authorize(r, policy.ActionRead, rbac.ResourceTemplate.AnyOrganization()) { 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, OrganizationName: template.OrganizationName, OrganizationDisplayName: template.OrganizationDisplayName, OrganizationIcon: template.OrganizationIcon, 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)), // #nosec G115 - Safe conversion as AutostopRequirementDaysOfWeek is a 7-bit bitmap 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, Deleted: template.Deleted, MaxPortShareLevel: maxPortShareLevel, UseClassicParameterFlow: template.UseClassicParameterFlow, CORSBehavior: codersdk.CORSBehavior(template.CorsBehavior), DisableModuleCache: template.DisableModuleCache, } } // findTemplateAdmins fetches all users with template admin permission including owners. func findTemplateAdmins(ctx context.Context, store database.Store) ([]database.GetUsersRow, error) { templateAdmins, err := store.GetUsers(ctx, database.GetUsersParams{ RbacRole: []string{codersdk.RoleTemplateAdmin, codersdk.RoleOwner}, }) if err != nil { return nil, xerrors.Errorf("get owners: %w", err) } return templateAdmins, nil }