Ctrl K
ComunidadGuías…

coderd/templates.go

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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)
	agentsAllowed := ptr.NilToDefault(createTemplate.AgentsAllowed, true)

	// 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,
		AgentsAllowed:           agentsAllowed,
	}

	_, 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
		timeTilAutostopNotify          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.TimeTilAutostopNotifyMillis != nil {
		timeTilAutostopNotify = time.Duration(*createTemplate.TimeTilAutostopNotifyMillis) * 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 timeTilAutostopNotify < 0 {
		validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_autostop_notify_ms", Detail: "Must be a positive integer."})
	} else if timeTilAutostopNotify != 0 && timeTilAutostopNotify < time.Minute {
		validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_autostop_notify_ms", Detail: "Must be 0 (disabled) or at least one minute."})
	}

	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)
		allowWorkspaceRenames        = ptr.NilToDefault(createTemplate.AllowWorkspaceRenames, false)
	)

	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,
			AgentsAllowed:                agentsAllowed,
			AllowWorkspaceRenames:        allowWorkspaceRenames,
		})
		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,
			TimeTilAutostopNotify: timeTilAutostopNotify,
			// 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
		}

		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,
			}
		}

		// GetTemplatesWithFilter authorizes the query itself, so we don't
		// prepare a SQL filter here.
		templates, err := api.Database.GetTemplatesWithFilter(ctx, args)
		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, api.DeploymentValues)

	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.timeTilAutostopNotifyMillis < 0 {
		validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_autostop_notify_ms", Detail: "Must be a positive integer."})
	} else if resolved.timeTilAutostopNotifyMillis != 0 && time.Duration(resolved.timeTilAutostopNotifyMillis)*time.Millisecond < time.Minute {
		validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_autostop_notify_ms", Detail: "Must be 0 (disabled) or at least one minute."})
	}
	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,
			AgentsAllowed:                resolved.agentsAllowed,
			AllowWorkspaceRenames:        resolved.allowWorkspaceRenames,
		})
		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
		timeTilAutostopNotify := time.Duration(resolved.timeTilAutostopNotifyMillis) * 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,
			TimeTilAutostopNotify: timeTilAutostopNotify,
			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(),
		TimeTilAutostopNotifyMillis:    time.Duration(template.TimeTilAutostopNotify).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,
		ModuleCacheDisabledByDeployment: codersdk.ModuleCacheDisabledByDeployment(api.DeploymentValues),
		AgentsAllowed:                   template.AgentsAllowed,
		AllowWorkspaceRenames:           template.AllowWorkspaceRenames,
	}
}

// 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
}