Files
coder/coderd/templates_meta_update_internal_test.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

568 lines
19 KiB
Go

package coderd
import (
"reflect"
"testing"
"github.com/google/uuid"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/codersdk"
"github.com/coder/serpent"
)
// baselineTemplate returns a database.Template populated with non-default
// values for every field that resolveTemplateMetaUpdate reads. Non-default
// values let single-field tests detect when a field is being silently
// overwritten with a zero value.
func baselineTemplate() database.Template {
orgID := uuid.MustParse("00000000-0000-0000-0000-000000000001")
return database.Template{
ID: uuid.MustParse("00000000-0000-0000-0000-000000000002"),
OrganizationID: orgID,
Name: "baseline",
DisplayName: "Baseline Template",
Description: "An existing description.",
Icon: "/baseline.svg",
AllowUserAutostart: false,
AllowUserAutostop: false,
AllowUserCancelWorkspaceJobs: false,
AgentsAllowed: true,
RequireActiveVersion: true,
DefaultTTL: int64(60 * 60 * 1000 * 1000 * 1000), // 1 hour in ns
ActivityBump: int64(30 * 60 * 1000 * 1000 * 1000), // 30 minutes in ns
TimeTilAutostopNotify: int64(10 * 60 * 1000 * 1000 * 1000), // 10 minutes in ns
FailureTTL: int64(120 * 60 * 1000 * 1000 * 1000), // 2 hours in ns
TimeTilDormant: int64(240 * 60 * 1000 * 1000 * 1000), // 4 hours in ns
TimeTilDormantAutoDelete: int64(480 * 60 * 1000 * 1000 * 1000), // 8 hours in ns
AutostopRequirementDaysOfWeek: 0b0000001, // Monday
AutostopRequirementWeeks: 2,
AutostartBlockDaysOfWeek: 0b1000000, // Sunday
Deprecated: "deprecated", // non-empty so the conversion is observable
MaxPortSharingLevel: database.AppSharingLevelOrganization,
UseClassicParameterFlow: true,
CorsBehavior: database.CorsBehaviorPassthru,
DisableModuleCache: true,
AllowWorkspaceRenames: true,
GroupACL: database.TemplateACL{
orgID.String(): {"read"},
},
}
}
// baselineScheduleOpts returns schedule options matching the baseline
// template above, so that nil request fields resolve to these values.
func baselineScheduleOpts() schedule.TemplateScheduleOptions {
return schedule.TemplateScheduleOptions{
AutostopRequirement: schedule.TemplateAutostopRequirement{
DaysOfWeek: 0b0000001,
Weeks: 2,
},
AutostartRequirement: schedule.TemplateAutostartRequirement{
DaysOfWeek: 0b1000000,
},
}
}
// baselineResolved returns the templateMetaUpdate that resolveTemplateMetaUpdate
// produces for an empty request against baselineTemplate / baselineScheduleOpts.
func baselineResolved() templateMetaUpdate {
tpl := baselineTemplate()
return templateMetaUpdate{
name: tpl.Name,
displayName: tpl.DisplayName,
description: tpl.Description,
icon: tpl.Icon,
defaultTTLMillis: tpl.DefaultTTL / 1e6,
activityBumpMillis: tpl.ActivityBump / 1e6,
timeTilAutostopNotifyMillis: tpl.TimeTilAutostopNotify / 1e6,
failureTTLMillis: tpl.FailureTTL / 1e6,
timeTilDormantMillis: tpl.TimeTilDormant / 1e6,
timeTilDormantAutoDeleteMillis: tpl.TimeTilDormantAutoDelete / 1e6,
allowUserAutostart: tpl.AllowUserAutostart,
allowUserAutostop: tpl.AllowUserAutostop,
allowUserCancelWorkspaceJobs: tpl.AllowUserCancelWorkspaceJobs,
agentsAllowed: tpl.AgentsAllowed,
requireActiveVersion: tpl.RequireActiveVersion,
deprecationMessage: tpl.Deprecated,
useClassicTemplateFlow: tpl.UseClassicParameterFlow,
disableModuleCache: tpl.DisableModuleCache,
allowWorkspaceRenames: tpl.AllowWorkspaceRenames,
corsBehavior: tpl.CorsBehavior,
autostopRequirementDaysOfWeekParsed: 0b0000001,
autostartRequirementDaysOfWeekParsed: 0b1000000,
autostopRequirementWeeks: tpl.AutostopRequirementWeeks,
groupACL: tpl.GroupACL,
}
}
func TestResolveTemplateMetaUpdate(t *testing.T) {
t.Parallel()
type expected struct {
// override is applied to baselineResolved to produce the expected
// templateMetaUpdate. Allows each case to express only its delta.
override func(*templateMetaUpdate)
base func(template *database.Template)
// validErrFields, if non-empty, asserts the resolver produced a
// validation error for each named field.
validErrFields []string
}
tests := []struct {
name string
req codersdk.UpdateTemplateMeta
expected expected
}{
// Sanity check: an empty PATCH preserves every field.
{
name: "EmptyRequestPreservesEverything",
req: codersdk.UpdateTemplateMeta{},
expected: expected{override: func(*templateMetaUpdate) {}},
},
// One case per pointer field: each case sends only that field
// and asserts only that field changed in the resolved struct.
{
name: "Name",
req: codersdk.UpdateTemplateMeta{Name: new("renamed")},
expected: expected{override: func(r *templateMetaUpdate) {
r.name = "renamed"
}},
},
{
name: "NameEmptyStringFallsBackToCurrent",
req: codersdk.UpdateTemplateMeta{Name: new("")},
// Empty string is treated as "do not clear" because the UI
// disallows clearing the name. Resolver must keep the
// existing name.
// This is a unique case to just the `name` field.
expected: expected{override: func(*templateMetaUpdate) {}},
},
{
name: "DisplayName",
req: codersdk.UpdateTemplateMeta{DisplayName: new("Renamed")},
expected: expected{override: func(r *templateMetaUpdate) {
r.displayName = "Renamed"
}},
},
{
name: "Description",
req: codersdk.UpdateTemplateMeta{Description: new("New description")},
expected: expected{override: func(r *templateMetaUpdate) {
r.description = "New description"
}},
},
{
name: "Icon",
req: codersdk.UpdateTemplateMeta{Icon: new("/new.svg")},
expected: expected{override: func(r *templateMetaUpdate) {
r.icon = "/new.svg"
}},
},
{
name: "DefaultTTLMillis",
req: codersdk.UpdateTemplateMeta{DefaultTTLMillis: new(int64(7200_000))},
expected: expected{override: func(r *templateMetaUpdate) {
r.defaultTTLMillis = 7200_000
}},
},
{
name: "DefaultTTLMillisZeroExplicit",
req: codersdk.UpdateTemplateMeta{DefaultTTLMillis: new(int64(0))},
expected: expected{override: func(r *templateMetaUpdate) {
r.defaultTTLMillis = 0
}},
},
{
name: "ActivityBumpMillis",
req: codersdk.UpdateTemplateMeta{ActivityBumpMillis: new(int64(900_000))},
expected: expected{override: func(r *templateMetaUpdate) {
r.activityBumpMillis = 900_000
}},
},
{
name: "TimeTilAutostopNotifyMillis",
req: codersdk.UpdateTemplateMeta{TimeTilAutostopNotifyMillis: new(int64(300_000))},
expected: expected{override: func(r *templateMetaUpdate) {
r.timeTilAutostopNotifyMillis = 300_000
}},
},
{
name: "TimeTilAutostopNotifyMillisZeroExplicit",
req: codersdk.UpdateTemplateMeta{TimeTilAutostopNotifyMillis: new(int64(0))},
expected: expected{override: func(r *templateMetaUpdate) {
r.timeTilAutostopNotifyMillis = 0
}},
},
{
name: "AllowUserAutostart",
req: codersdk.UpdateTemplateMeta{AllowUserAutostart: new(true)},
expected: expected{override: func(r *templateMetaUpdate) {
r.allowUserAutostart = true
}},
},
{
name: "AllowUserAutostop",
req: codersdk.UpdateTemplateMeta{AllowUserAutostop: new(true)},
expected: expected{override: func(r *templateMetaUpdate) {
r.allowUserAutostop = true
}},
},
{
name: "AllowUserAutostop/true",
req: codersdk.UpdateTemplateMeta{AllowUserAutostop: new(false)},
expected: expected{
base: func(update *database.Template) {
update.AllowUserAutostop = true
},
override: func(r *templateMetaUpdate) {
r.allowUserAutostop = false
},
},
},
{
name: "AllowUserCancelWorkspaceJobs",
req: codersdk.UpdateTemplateMeta{AllowUserCancelWorkspaceJobs: new(true)},
expected: expected{override: func(r *templateMetaUpdate) {
r.allowUserCancelWorkspaceJobs = true
}},
},
{
name: "AgentsAllowed",
req: codersdk.UpdateTemplateMeta{AgentsAllowed: new(false)},
expected: expected{override: func(r *templateMetaUpdate) {
r.agentsAllowed = false
}},
},
{
name: "FailureTTLMillis",
req: codersdk.UpdateTemplateMeta{FailureTTLMillis: new(int64(3_600_000))},
expected: expected{override: func(r *templateMetaUpdate) {
r.failureTTLMillis = 3_600_000
}},
},
{
name: "TimeTilDormantMillis",
req: codersdk.UpdateTemplateMeta{TimeTilDormantMillis: new(int64(7_200_000))},
expected: expected{override: func(r *templateMetaUpdate) {
r.timeTilDormantMillis = 7_200_000
}},
},
{
name: "TimeTilDormantAutoDeleteMillis",
req: codersdk.UpdateTemplateMeta{TimeTilDormantAutoDeleteMillis: new(int64(14_400_000))},
expected: expected{override: func(r *templateMetaUpdate) {
r.timeTilDormantAutoDeleteMillis = 14_400_000
}},
},
{
name: "RequireActiveVersion",
req: codersdk.UpdateTemplateMeta{RequireActiveVersion: new(false)},
expected: expected{override: func(r *templateMetaUpdate) {
r.requireActiveVersion = false
}},
},
{
name: "DeprecationMessage",
req: codersdk.UpdateTemplateMeta{DeprecationMessage: new("now deprecated")},
expected: expected{override: func(r *templateMetaUpdate) {
r.deprecationMessage = "now deprecated"
}},
},
{
name: "DeprecationMessageEmptyStringClears",
req: codersdk.UpdateTemplateMeta{DeprecationMessage: new("")},
expected: expected{override: func(r *templateMetaUpdate) {
r.deprecationMessage = ""
}},
},
{
name: "UseClassicParameterFlow",
req: codersdk.UpdateTemplateMeta{UseClassicParameterFlow: new(false)},
expected: expected{override: func(r *templateMetaUpdate) {
r.useClassicTemplateFlow = false
}},
},
{
name: "DisableModuleCache",
req: codersdk.UpdateTemplateMeta{DisableModuleCache: new(false)},
expected: expected{override: func(r *templateMetaUpdate) {
r.disableModuleCache = false
}},
},
{
name: "AllowWorkspaceRenames",
req: codersdk.UpdateTemplateMeta{AllowWorkspaceRenames: new(false)},
expected: expected{override: func(r *templateMetaUpdate) {
r.allowWorkspaceRenames = false
}},
},
// CORS behavior.
{
name: "CORSBehaviorChange",
req: codersdk.UpdateTemplateMeta{
CORSBehavior: new(codersdk.CORSBehavior(database.CorsBehaviorSimple)),
},
expected: expected{override: func(r *templateMetaUpdate) {
r.corsBehavior = database.CorsBehaviorSimple
}},
},
{
name: "CORSBehaviorEmptyStringPreserves",
req: codersdk.UpdateTemplateMeta{
CORSBehavior: new(codersdk.CORSBehavior("")),
},
// Empty string is treated as "do not change" for backwards
// compatibility with older clients that always send the
// field.
expected: expected{override: func(*templateMetaUpdate) {}},
},
{
name: "CORSBehaviorInvalid",
req: codersdk.UpdateTemplateMeta{
CORSBehavior: new(codersdk.CORSBehavior("not-a-real-value")),
},
expected: expected{
// Invalid value: keep current and surface a validation error.
override: func(*templateMetaUpdate) {},
validErrFields: []string{"cors_behavior"},
},
},
// Autostop / autostart requirement bitmaps.
{
name: "AutostopRequirementChange",
req: codersdk.UpdateTemplateMeta{
AutostopRequirement: &codersdk.TemplateAutostopRequirement{
DaysOfWeek: []string{"friday"},
Weeks: 4,
},
},
expected: expected{override: func(r *templateMetaUpdate) {
r.autostopRequirementDaysOfWeekParsed = 0b0010000
r.autostopRequirementWeeks = 4
}},
},
{
name: "AutostopRequirementWeeksZeroNormalizesToOne",
req: codersdk.UpdateTemplateMeta{
AutostopRequirement: &codersdk.TemplateAutostopRequirement{
DaysOfWeek: []string{"monday"},
Weeks: 0,
},
},
expected: expected{override: func(r *templateMetaUpdate) {
r.autostopRequirementDaysOfWeekParsed = 0b0000001
r.autostopRequirementWeeks = 1
}},
},
{
name: "AutostopRequirementInvalidDay",
req: codersdk.UpdateTemplateMeta{
AutostopRequirement: &codersdk.TemplateAutostopRequirement{
DaysOfWeek: []string{"funday"},
Weeks: 1,
},
},
expected: expected{
override: func(r *templateMetaUpdate) {
r.autostopRequirementDaysOfWeekParsed = 1
r.autostopRequirementWeeks = 2
},
validErrFields: []string{"autostop_requirement.days_of_week"},
},
},
{
name: "AutostartRequirementChange",
req: codersdk.UpdateTemplateMeta{
AutostartRequirement: &codersdk.TemplateAutostartRequirement{
DaysOfWeek: []string{"saturday"},
},
},
expected: expected{override: func(r *templateMetaUpdate) {
r.autostartRequirementDaysOfWeekParsed = 0b0100000
}},
},
{
name: "AutostartRequirementInvalidDay",
req: codersdk.UpdateTemplateMeta{
AutostartRequirement: &codersdk.TemplateAutostartRequirement{
DaysOfWeek: []string{"funday"},
},
},
expected: expected{
override: func(r *templateMetaUpdate) {
r.autostartRequirementDaysOfWeekParsed = 64
},
validErrFields: []string{"autostart_requirement.days_of_week"},
},
},
// One-shot intent flags. nil and false should both result in
// the corresponding *Intent field being false; only true triggers it.
{
name: "DisableEveryoneGroupAccessFalseIsNoop",
req: codersdk.UpdateTemplateMeta{DisableEveryoneGroupAccess: new(false)},
expected: expected{override: func(*templateMetaUpdate) {
// disableEveryoneIntent stays false.
}},
},
{
name: "DisableEveryoneGroupAccessTrueWithMembership",
req: codersdk.UpdateTemplateMeta{DisableEveryoneGroupAccess: new(true)},
expected: expected{override: func(r *templateMetaUpdate) {
r.groupACL = database.TemplateACL{}
}},
},
{
name: "UpdateWorkspaceLastUsedAtFalseIsNoop",
req: codersdk.UpdateTemplateMeta{UpdateWorkspaceLastUsedAt: new(false)},
expected: expected{override: func(*templateMetaUpdate) {}},
},
{
name: "UpdateWorkspaceLastUsedAtTrue",
req: codersdk.UpdateTemplateMeta{UpdateWorkspaceLastUsedAt: new(true)},
expected: expected{override: func(r *templateMetaUpdate) {
r.updateWorkspaceLastUsedAtIntent = true
}},
},
{
name: "UpdateWorkspaceDormantAtFalseIsNoop",
req: codersdk.UpdateTemplateMeta{UpdateWorkspaceDormantAt: new(false)},
expected: expected{override: func(*templateMetaUpdate) {}},
},
{
name: "UpdateWorkspaceDormantAtTrue",
req: codersdk.UpdateTemplateMeta{UpdateWorkspaceDormantAt: new(true)},
expected: expected{override: func(r *templateMetaUpdate) {
r.updateWorkspaceDormantAtIntent = true
}},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tpl := baselineTemplate()
if tc.expected.base != nil {
tc.expected.base(&tpl)
}
schedOpts := baselineScheduleOpts()
got, validErrs := resolveTemplateMetaUpdate(tpl, schedOpts, tc.req, nil)
want := baselineResolved()
tc.expected.override(&want)
if !reflect.DeepEqual(got, want) {
t.Fatalf("resolved mismatch\ngot: %+v\nwant: %+v", got, want)
}
if len(validErrs) != len(tc.expected.validErrFields) {
t.Fatalf("got %d validation errors, want %d: %+v",
len(validErrs), len(tc.expected.validErrFields), validErrs)
}
for i, field := range tc.expected.validErrFields {
if validErrs[i].Field != field {
t.Errorf("validation error %d: field = %q, want %q",
i, validErrs[i].Field, field)
}
}
})
}
}
// TestResolveTemplateMetaUpdate_NameClearedFallsBackToTemplateName covers the
// pre-existing rule that a template's name cannot be cleared from the UI.
// Even an explicit empty pointer must resolve to the existing template name.
func TestResolveTemplateMetaUpdate_NameClearedFallsBackToTemplateName(t *testing.T) {
t.Parallel()
tpl := baselineTemplate()
schedOpts := baselineScheduleOpts()
got, _ := resolveTemplateMetaUpdate(tpl, schedOpts, codersdk.UpdateTemplateMeta{
Name: new(""),
}, nil)
if got.name != tpl.Name {
t.Fatalf("got name = %q, want %q (preserved)", got.name, tpl.Name)
}
}
// TestResolveTemplateMetaUpdate_NilRequestUsesScheduleOptsForRequirements
// verifies that an entirely empty request returns the schedule store's
// current autostop/autostart requirement values, rather than zeros.
func TestResolveTemplateMetaUpdate_NilRequestUsesScheduleOptsForRequirements(t *testing.T) {
t.Parallel()
tpl := baselineTemplate()
schedOpts := schedule.TemplateScheduleOptions{
AutostopRequirement: schedule.TemplateAutostopRequirement{
DaysOfWeek: 0b0001100, // Wed + Thu
Weeks: 3,
},
AutostartRequirement: schedule.TemplateAutostartRequirement{
DaysOfWeek: 0b0010000, // Fri
},
}
got, validErrs := resolveTemplateMetaUpdate(tpl, schedOpts, codersdk.UpdateTemplateMeta{}, nil)
if len(validErrs) != 0 {
t.Fatalf("unexpected validation errors: %+v", validErrs)
}
if got.autostopRequirementDaysOfWeekParsed != schedOpts.AutostopRequirement.DaysOfWeek {
t.Errorf("autostop days = 0b%07b, want 0b%07b",
got.autostopRequirementDaysOfWeekParsed,
schedOpts.AutostopRequirement.DaysOfWeek)
}
if got.autostartRequirementDaysOfWeekParsed != schedOpts.AutostartRequirement.DaysOfWeek {
t.Errorf("autostart days = 0b%07b, want 0b%07b",
got.autostartRequirementDaysOfWeekParsed,
schedOpts.AutostartRequirement.DaysOfWeek)
}
if got.autostopRequirementWeeks != schedOpts.AutostopRequirement.Weeks {
t.Errorf("autostop weeks = %d, want %d",
got.autostopRequirementWeeks, schedOpts.AutostopRequirement.Weeks)
}
}
// TestResolveTemplateMetaUpdate_ModuleCacheDisabledByDeployment verifies that the
// per-template toggle is read-only while the deployment disables the module
// cache: the request value is discarded in both directions.
func TestResolveTemplateMetaUpdate_ModuleCacheDisabledByDeployment(t *testing.T) {
t.Parallel()
cases := []struct {
name string
stored bool
request *bool
expected bool
}{
{name: "IgnoresOptIn", stored: true, request: new(false), expected: true},
{name: "IgnoresOptOut", stored: false, request: new(true), expected: false},
{name: "PreservesStored", stored: true, request: nil, expected: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
tpl := baselineTemplate()
tpl.DisableModuleCache = tc.stored
dv := &codersdk.DeploymentValues{}
dv.Provisioner.DisableModuleCache = serpent.Bool(true)
got, validErrs := resolveTemplateMetaUpdate(tpl, baselineScheduleOpts(), codersdk.UpdateTemplateMeta{
DisableModuleCache: tc.request,
}, dv)
if len(validErrs) != 0 {
t.Fatalf("unexpected validation errors: %+v", validErrs)
}
if got.disableModuleCache != tc.expected {
t.Errorf("disableModuleCache = %t, want %t", got.disableModuleCache, tc.expected)
}
})
}
}