Files
coder/coderd/templates_meta_update.go
T
Steven Masley 940f988cfb feat: add deployment-level option to disable Terraform module caching (#29187)
<!-- Generated by Coder Agents on behalf of @Emyrk. -->

<img width="762" height="239" alt="Screenshot From 2026-09-11 17-09-41"
src="https://github.com/user-attachments/assets/9f31fd1e-4cb2-47ab-8d37-4073df9f0a7c"
/>


Closes #22834.

Adds `CODER_PROVISIONER_DISABLE_MODULE_CACHE` so an admin can disable
Terraform module caching for every template at once instead of toggling
~100 templates by hand.

- The deployment setting wins. Templates cannot opt back in, and the
per-template toggle becomes read-only while the deployment setting is
on.
- Modules stay cached at template import, so dynamic parameter rendering
still resolves module-defined parameters. Only the workspace build is
denied the cached files.
- Templates report `module_cache_disabled_by_deployment`, and template
settings shows why the checkbox is locked.

<details><summary>Agent context</summary>

Decisions taken with @Emyrk before implementing:

1. **Precedence.** `deployment || template`, no per-template override,
so no migration and no nullable column. The rule lives in one place,
`codersdk.ModuleCacheDisabled`.
2. **Import-time caching untouched.** `dynamicparameters/render.go`
overlays `cached_module_files` at `.terraform/modules` to render
module-defined parameters. Skipping the cache there would reproduce the
`Module not loaded. Did you run terraform init?` failure reported by a
customer, so the deployment setting only affects what `AcquireJob` hands
the provisioner.
3. **Write path.** A PATCH carrying `disable_module_cache` is discarded
while the deployment setting is on, rather than persisted. Persisting it
would silently keep templates opted out after the deployment setting is
removed.
4. **API shape.** `disable_module_cache` keeps its meaning (this
template's stored value) and the new read-only
`module_cache_disabled_by_deployment` reports the deployment state.
Reading the effective value from `/deployment/config` was rejected
because template admins may lack deployment-config read permission.
5. **Naming.** `CODER_PROVISIONER_DISABLE_MODULE_CACHE` over the issue's
`CODER_DISABLE_TERRAFORM_MODULE_CACHE`, to match the provisioning option
group.

Note: `InsertTemplateParams` has no `disable_module_cache` binding, so
new templates always store `false`. With deployment precedence they
still behave as opted out, so the create path needed no change.

Out of scope and tracked elsewhere: the `coderd_template` Terraform
provider attribute (separate repo) and backporting the per-template
toggle to `release/2.30`.

</details>

---

<sub>Coder Agents on behalf of @Emyrk.</sub>
2026-09-11 18:13:16 -05:00

186 lines
8.3 KiB
Go

package coderd
import (
"strings"
"time"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/coderd/util/ptr"
"github.com/coder/coder/v2/coderd/util/slice"
"github.com/coder/coder/v2/codersdk"
)
// templateMetaUpdate is the resolved set of values to apply for a
// PATCH /templates/{template} request. Any field on
// codersdk.UpdateTemplateMeta that is nil falls back to the existing
// template's value so that omitted request fields are not modified.
type templateMetaUpdate struct {
name string
displayName string
description string
icon string
defaultTTLMillis int64
activityBumpMillis int64
timeTilAutostopNotifyMillis int64
failureTTLMillis int64
timeTilDormantMillis int64
timeTilDormantAutoDeleteMillis int64
allowUserAutostart bool
allowUserAutostop bool
allowUserCancelWorkspaceJobs bool
agentsAllowed bool
requireActiveVersion bool
deprecationMessage string
useClassicTemplateFlow bool
disableModuleCache bool
allowWorkspaceRenames bool
corsBehavior database.CorsBehavior
autostopRequirementDaysOfWeekParsed uint8
autostartRequirementDaysOfWeekParsed uint8
autostopRequirementWeeks int64
groupACL database.TemplateACL
// updateWorkspaceLastUsedAtIntent and updateWorkspaceDormantAtIntent are one-shot
// intents that trigger side effects only when the request explicitly
// sets the field to true. nil and false are no-ops.
updateWorkspaceLastUsedAtIntent bool
updateWorkspaceDormantAtIntent bool
}
// resolveTemplateMetaUpdate produces a templateMetaUpdate populated with
// either the request value (when present) or the existing template's
// value (when the request field is nil).
//
// This function validates shape, not contents: it parses the
// autostop/autostart day-of-week strings into bitmaps and ensures any
// non-empty CORS behavior is a recognized enum. Errors it returns are
// user-facing validation errors the caller must surface as 400 Bad
// Request.
//
// Range and content checks (e.g. activityBumpMillis >= 0,
// failureTTLMillis >= 1 minute, max port share level) and validation
// that depends on external interfaces (such as port-sharing licensure)
// are the caller's responsibility.
//
// The deployment values make disableModuleCache read-only when the deployment
// disables the Terraform module cache for every template: the request value is
// discarded rather than persisted.
func resolveTemplateMetaUpdate(
template database.Template,
scheduleOpts schedule.TemplateScheduleOptions,
req codersdk.UpdateTemplateMeta,
deploymentValues *codersdk.DeploymentValues,
) (templateMetaUpdate, []codersdk.ValidationError) {
var validErrs []codersdk.ValidationError
disableModuleCache := ptr.NilToDefault(req.DisableModuleCache, template.DisableModuleCache)
if codersdk.ModuleCacheDisabledByDeployment(deploymentValues) {
disableModuleCache = template.DisableModuleCache
}
out := templateMetaUpdate{
name: ptr.NilToDefault(req.Name, template.Name),
displayName: ptr.NilToDefault(req.DisplayName, template.DisplayName),
description: ptr.NilToDefault(req.Description, template.Description),
icon: ptr.NilToDefault(req.Icon, template.Icon),
defaultTTLMillis: ptr.NilToDefault(req.DefaultTTLMillis, time.Duration(template.DefaultTTL).Milliseconds()),
activityBumpMillis: ptr.NilToDefault(req.ActivityBumpMillis, time.Duration(template.ActivityBump).Milliseconds()),
timeTilAutostopNotifyMillis: ptr.NilToDefault(req.TimeTilAutostopNotifyMillis, time.Duration(template.TimeTilAutostopNotify).Milliseconds()),
failureTTLMillis: ptr.NilToDefault(req.FailureTTLMillis, time.Duration(template.FailureTTL).Milliseconds()),
timeTilDormantMillis: ptr.NilToDefault(req.TimeTilDormantMillis, time.Duration(template.TimeTilDormant).Milliseconds()),
timeTilDormantAutoDeleteMillis: ptr.NilToDefault(req.TimeTilDormantAutoDeleteMillis, time.Duration(template.TimeTilDormantAutoDelete).Milliseconds()),
allowUserAutostart: ptr.NilToDefault(req.AllowUserAutostart, template.AllowUserAutostart),
allowUserAutostop: ptr.NilToDefault(req.AllowUserAutostop, template.AllowUserAutostop),
allowUserCancelWorkspaceJobs: ptr.NilToDefault(req.AllowUserCancelWorkspaceJobs, template.AllowUserCancelWorkspaceJobs),
agentsAllowed: ptr.NilToDefault(req.AgentsAllowed, template.AgentsAllowed),
requireActiveVersion: ptr.NilToDefault(req.RequireActiveVersion, template.RequireActiveVersion),
deprecationMessage: ptr.NilToDefault(req.DeprecationMessage, template.Deprecated),
useClassicTemplateFlow: ptr.NilToDefault(req.UseClassicParameterFlow, template.UseClassicParameterFlow),
disableModuleCache: disableModuleCache,
allowWorkspaceRenames: ptr.NilToDefault(req.AllowWorkspaceRenames, template.AllowWorkspaceRenames),
groupACL: template.GroupACL,
// Default to the original values
corsBehavior: template.CorsBehavior,
autostopRequirementDaysOfWeekParsed: scheduleOpts.AutostopRequirement.DaysOfWeek,
autostopRequirementWeeks: scheduleOpts.AutostopRequirement.Weeks,
autostartRequirementDaysOfWeekParsed: scheduleOpts.AutostartRequirement.DaysOfWeek,
updateWorkspaceLastUsedAtIntent: false,
updateWorkspaceDormantAtIntent: false,
}
// Users should not be able to clear the template name. This is the only field
// that treats a zero value as omitted.
if out.name == "" {
out.name = template.Name
}
// Override autostop if provided is non-nil
if req.AutostopRequirement != nil {
bitmap, err := codersdk.WeekdaysToBitmap(req.AutostopRequirement.DaysOfWeek)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{
Field: "autostop_requirement.days_of_week",
Detail: err.Error(),
})
} else {
out.autostopRequirementDaysOfWeekParsed = bitmap
out.autostopRequirementWeeks = req.AutostopRequirement.Weeks
}
// Always force <= 0 -> 1
if out.autostopRequirementWeeks <= 0 {
out.autostopRequirementWeeks = defaultRequirementWeeks
}
}
// Override autostart if provided is non-nil
if req.AutostartRequirement != nil {
bitmap, err := codersdk.WeekdaysToBitmap(req.AutostartRequirement.DaysOfWeek)
if err != nil {
validErrs = append(validErrs, codersdk.ValidationError{
Field: "autostart_requirement.days_of_week",
Detail: err.Error(),
})
} else {
out.autostartRequirementDaysOfWeekParsed = bitmap
}
}
// Resolve CORS behavior. An empty string is treated as "do not
// change" because the existing UI-driven flow used to send empty
// strings for unset values. A non-empty invalid value is a
// validation error.
if req.CORSBehavior != nil && *req.CORSBehavior != "" {
val := database.CorsBehavior(*req.CORSBehavior)
if !val.Valid() {
validErrs = append(validErrs, codersdk.ValidationError{
Field: "cors_behavior",
Detail: "Invalid CORS behavior \"" + string(*req.CORSBehavior) +
"\". Must be one of [" + strings.Join(slice.ToStrings(database.AllCorsBehaviorValues()), ", ") + "]",
})
} else {
out.corsBehavior = val
}
}
if req.DisableEveryoneGroupAccess != nil && *req.DisableEveryoneGroupAccess {
// Remove the "everyone" group from the template. If this is set to false, the
// user needs to explicitly add the "everyone" group back to the ACL via the
// group ACL endpoints, so we don't treat false as a no-op.
delete(out.groupACL, template.OrganizationID.String())
}
// One-shot intent flags. nil and false are both no-ops; true is a
// trigger to run the side effect.
if req.UpdateWorkspaceLastUsedAt != nil && *req.UpdateWorkspaceLastUsedAt {
out.updateWorkspaceLastUsedAtIntent = true
}
if req.UpdateWorkspaceDormantAt != nil && *req.UpdateWorkspaceDormantAt {
out.updateWorkspaceDormantAtIntent = true
}
return out, validErrs
}