diff --git a/cli/templateedit.go b/cli/templateedit.go index 242e009918..5871e82e9e 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -8,6 +8,7 @@ import ( "golang.org/x/xerrors" "github.com/coder/coder/v2/cli/cliui" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/pretty" "github.com/coder/serpent" @@ -88,6 +89,10 @@ func (r *RootCmd) templateEdit() *serpent.Command { } // Default values + if !userSetOption(inv, "name") { + name = template.Name + } + if !userSetOption(inv, "description") { description = template.Description } @@ -169,12 +174,12 @@ func (r *RootCmd) templateEdit() *serpent.Command { } req := codersdk.UpdateTemplateMeta{ - Name: name, + Name: &name, DisplayName: &displayName, Description: &description, Icon: &icon, - DefaultTTLMillis: defaultTTL.Milliseconds(), - ActivityBumpMillis: activityBump.Milliseconds(), + DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), + ActivityBumpMillis: ptr.Ref(activityBump.Milliseconds()), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ DaysOfWeek: autostopRequirementDaysOfWeek, Weeks: autostopRequirementWeeks, @@ -182,15 +187,19 @@ func (r *RootCmd) templateEdit() *serpent.Command { AutostartRequirement: &codersdk.TemplateAutostartRequirement{ DaysOfWeek: autostartRequirementDaysOfWeek, }, - FailureTTLMillis: failureTTL.Milliseconds(), - TimeTilDormantMillis: dormancyThreshold.Milliseconds(), - TimeTilDormantAutoDeleteMillis: dormancyAutoDeletion.Milliseconds(), - AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, - AllowUserAutostart: allowUserAutostart, - AllowUserAutostop: allowUserAutostop, - RequireActiveVersion: requireActiveVersion, + FailureTTLMillis: ptr.Ref(failureTTL.Milliseconds()), + TimeTilDormantMillis: ptr.Ref(dormancyThreshold.Milliseconds()), + TimeTilDormantAutoDeleteMillis: ptr.Ref(dormancyAutoDeletion.Milliseconds()), + AllowUserCancelWorkspaceJobs: &allowUserCancelWorkspaceJobs, + AllowUserAutostart: &allowUserAutostart, + AllowUserAutostop: &allowUserAutostop, + RequireActiveVersion: &requireActiveVersion, DeprecationMessage: deprecated, - DisableEveryoneGroupAccess: disableEveryoneGroup, + DisableEveryoneGroupAccess: &disableEveryoneGroup, + // TODO(Emyrk): now that the API accepts partial updates, + // rewrite this CLI to only set pointers for flags the user + // explicitly provided via userSetOption. The current + // fetch-then-resend-everything dance is no longer required. } _, err = client.UpdateTemplateMeta(inv.Context(), template.ID, req) diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 743a2ce9de..5d5cab0e12 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -101,8 +101,7 @@ func TestTemplateEdit(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) err := inv.WithContext(ctx).Run() - - require.ErrorContains(t, err, "not modified") + require.NoError(t, err) // Assert that the template metadata did not change. updated, err := client.Template(context.Background(), template.ID) @@ -751,8 +750,10 @@ func TestTemplateEdit(t *testing.T) { var req codersdk.UpdateTemplateMeta err = json.Unmarshal(body, &req) require.NoError(t, err) - assert.False(t, req.AllowUserAutostart) - assert.False(t, req.AllowUserAutostop) + require.NotNil(t, req.AllowUserAutostart) + assert.False(t, *req.AllowUserAutostart) + require.NotNil(t, req.AllowUserAutostop) + assert.False(t, *req.AllowUserAutostop) r.Body = io.NopCloser(bytes.NewReader(body)) updateTemplateCalled.Add(1) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index ffd0f9b1ce..ba155d7992 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -23367,7 +23367,7 @@ const docTemplate = `{ "type": "integer" }, "update_workspace_dormant_at": { - "description": "UpdateWorkspaceDormant updates the dormant_at field of workspaces spawned\nfrom the template. This is useful for preventing dormant workspaces being immediately\ndeleted when updating the dormant_ttl field to a new, shorter value.", + "description": "UpdateWorkspaceDormantAt updates the dormant_at field of workspaces spawned\nfrom the template. This is useful for preventing dormant workspaces being\nimmediately deleted when updating the dormant_ttl field to a new, shorter\nvalue.", "type": "boolean" }, "update_workspace_last_used_at": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 83c1b1b577..56b4e46414 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -21512,7 +21512,7 @@ "type": "integer" }, "update_workspace_dormant_at": { - "description": "UpdateWorkspaceDormant updates the dormant_at field of workspaces spawned\nfrom the template. This is useful for preventing dormant workspaces being immediately\ndeleted when updating the dormant_ttl field to a new, shorter value.", + "description": "UpdateWorkspaceDormantAt updates the dormant_at field of workspaces spawned\nfrom the template. This is useful for preventing dormant workspaces being\nimmediately deleted when updating the dormant_ttl field to a new, shorter\nvalue.", "type": "boolean" }, "update_workspace_last_used_at": { diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 497b41c026..345647977d 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -550,8 +550,8 @@ func TestExecutorAutostopAIAgentActivity(t *testing.T) { // Given: template has activity bump enabled. _, err := client.UpdateTemplateMeta(ctx, r.Template.ID, codersdk.UpdateTemplateMeta{ - DefaultTTLMillis: (2 * time.Hour).Milliseconds(), - ActivityBumpMillis: time.Hour.Milliseconds(), + DefaultTTLMillis: ptr.Ref((2 * time.Hour).Milliseconds()), + ActivityBumpMillis: ptr.Ref(time.Hour.Milliseconds()), }) require.NoError(t, err) @@ -1905,7 +1905,7 @@ func TestExecutorTaskWorkspace(t *testing.T) { if defaultTTL > 0 { _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - DefaultTTLMillis: defaultTTL.Milliseconds(), + DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), }) require.NoError(t, err) } diff --git a/coderd/templates.go b/coderd/templates.go index 4f6ba77f43..9817382da0 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -35,6 +35,8 @@ import ( "github.com/coder/coder/v2/examples" ) +const defaultRequirementWeeks = 1 + // Returns a single template. // // @Summary Get template settings by ID @@ -679,72 +681,47 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { return } - var ( - validErrs []codersdk.ValidationError - autostopRequirementDaysOfWeekParsed uint8 - autostartRequirementDaysOfWeekParsed uint8 - ) - if req.DefaultTTLMillis < 0 { + // 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 req.ActivityBumpMillis < 0 { + if resolved.activityBumpMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "activity_bump_ms", Detail: "Must be a positive integer."}) } - - if req.AutostopRequirement == nil { - req.AutostopRequirement = &codersdk.TemplateAutostopRequirement{ - DaysOfWeek: codersdk.BitmapToWeekdays(scheduleOpts.AutostopRequirement.DaysOfWeek), - Weeks: scheduleOpts.AutostopRequirement.Weeks, - } - } - if len(req.AutostopRequirement.DaysOfWeek) > 0 { - autostopRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(req.AutostopRequirement.DaysOfWeek) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.days_of_week", Detail: err.Error()}) - } - } - if req.AutostartRequirement == nil { - req.AutostartRequirement = &codersdk.TemplateAutostartRequirement{ - DaysOfWeek: codersdk.BitmapToWeekdays(scheduleOpts.AutostartRequirement.DaysOfWeek), - } - } - if len(req.AutostartRequirement.DaysOfWeek) > 0 { - autostartRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(req.AutostartRequirement.DaysOfWeek) - if err != nil { - validErrs = append(validErrs, codersdk.ValidationError{Field: "autostart_requirement.days_of_week", Detail: err.Error()}) - } - } - if req.AutostopRequirement.Weeks < 0 { - validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: "Must be a positive integer."}) - } - if req.AutostopRequirement.Weeks == 0 { - req.AutostopRequirement.Weeks = 1 - } - if template.AutostopRequirementWeeks <= 0 { - template.AutostopRequirementWeeks = 1 - } - if req.AutostopRequirement.Weeks > schedule.MaxTemplateAutostopRequirementWeeks { + if resolved.autostopRequirementWeeks > schedule.MaxTemplateAutostopRequirementWeeks { validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateAutostopRequirementWeeks)}) } - // Defaults to the existing. - deprecationMessage := template.Deprecated - if req.DeprecationMessage != nil { - deprecationMessage = *req.DeprecationMessage + // 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 req.FailureTTLMillis < 0 || (req.FailureTTLMillis > 0 && req.FailureTTLMillis < minTTL) { + 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 req.TimeTilDormantMillis < 0 || (req.TimeTilDormantMillis > 0 && req.TimeTilDormantMillis < minTTL) { + 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 req.TimeTilDormantAutoDeleteMillis < 0 || (req.TimeTilDormantAutoDeleteMillis > 0 && req.TimeTilDormantAutoDeleteMillis < minTTL) { + 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) @@ -755,19 +732,6 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } } - corsBehavior := template.CorsBehavior - if req.CORSBehavior != nil && *req.CORSBehavior != "" { - val := database.CorsBehavior(*req.CORSBehavior) - if !val.Valid() { - validErrs = append(validErrs, codersdk.ValidationError{ - Field: "cors_behavior", - Detail: fmt.Sprintf("Invalid CORS behavior %q. Must be one of [%s]", *req.CORSBehavior, strings.Join(slice.ToStrings(database.AllCorsBehaviorValues()), ", ")), - }) - } else { - corsBehavior = val - } - } - if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid request to update template metadata!", @@ -776,57 +740,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { return } - // Defaults to the existing. - classicTemplateFlow := template.UseClassicParameterFlow - if req.UseClassicParameterFlow != nil { - classicTemplateFlow = *req.UseClassicParameterFlow - } - disableModuleCache := template.DisableModuleCache - if req.DisableModuleCache != nil { - disableModuleCache = *req.DisableModuleCache - } - - displayName := ptr.NilToDefault(req.DisplayName, template.DisplayName) - description := ptr.NilToDefault(req.Description, template.Description) - icon := ptr.NilToDefault(req.Icon, template.Icon) - var updated database.Template err = api.Database.InTx(func(tx database.Store) error { - if req.Name == template.Name && - description == template.Description && - displayName == template.DisplayName && - icon == template.Icon && - req.AllowUserAutostart == template.AllowUserAutostart && - req.AllowUserAutostop == template.AllowUserAutostop && - req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && - req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() && - req.ActivityBumpMillis == time.Duration(template.ActivityBump).Milliseconds() && - autostopRequirementDaysOfWeekParsed == scheduleOpts.AutostopRequirement.DaysOfWeek && - autostartRequirementDaysOfWeekParsed == scheduleOpts.AutostartRequirement.DaysOfWeek && - req.AutostopRequirement.Weeks == scheduleOpts.AutostopRequirement.Weeks && - req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() && - req.TimeTilDormantMillis == time.Duration(template.TimeTilDormant).Milliseconds() && - req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() && - req.RequireActiveVersion == template.RequireActiveVersion && - (deprecationMessage == template.Deprecated) && - (classicTemplateFlow == template.UseClassicParameterFlow) && - (disableModuleCache == template.DisableModuleCache) && - maxPortShareLevel == template.MaxPortSharingLevel && - corsBehavior == template.CorsBehavior { - return nil - } - - // Users should not be able to clear the template name in the UI - name := req.Name - if name == "" { - name = template.Name - } - - groupACL := template.GroupACL - if req.DisableEveryoneGroupAccess { - delete(groupACL, template.OrganizationID.String()) - } - if template.MaxPortSharingLevel != maxPortShareLevel { switch maxPortShareLevel { case database.AppSharingLevelOwner: @@ -846,25 +761,25 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { err = tx.UpdateTemplateMetaByID(ctx, database.UpdateTemplateMetaByIDParams{ ID: template.ID, UpdatedAt: dbtime.Now(), - Name: name, - DisplayName: displayName, - Description: description, - Icon: icon, - AllowUserCancelWorkspaceJobs: req.AllowUserCancelWorkspaceJobs, - GroupACL: groupACL, + Name: resolved.name, + DisplayName: resolved.displayName, + Description: resolved.description, + Icon: resolved.icon, + AllowUserCancelWorkspaceJobs: resolved.allowUserCancelWorkspaceJobs, + GroupACL: resolved.groupACL, MaxPortSharingLevel: maxPortShareLevel, - UseClassicParameterFlow: classicTemplateFlow, - CorsBehavior: corsBehavior, - DisableModuleCache: disableModuleCache, + UseClassicParameterFlow: resolved.useClassicTemplateFlow, + CorsBehavior: resolved.corsBehavior, + DisableModuleCache: resolved.disableModuleCache, }) if err != nil { return xerrors.Errorf("update template metadata: %w", err) } - if template.RequireActiveVersion != req.RequireActiveVersion || deprecationMessage != template.Deprecated { + if template.RequireActiveVersion != resolved.requireActiveVersion || resolved.deprecationMessage != template.Deprecated { err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, template.ID, dbauthz.TemplateAccessControl{ - RequireActiveVersion: req.RequireActiveVersion, - Deprecated: deprecationMessage, + RequireActiveVersion: resolved.requireActiveVersion, + Deprecated: resolved.deprecationMessage, }) if err != nil { return xerrors.Errorf("set template access control: %w", err) @@ -876,50 +791,42 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { return xerrors.Errorf("fetch updated template metadata: %w", err) } - defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond - activityBump := time.Duration(req.ActivityBumpMillis) * time.Millisecond - failureTTL := time.Duration(req.FailureTTLMillis) * time.Millisecond - inactivityTTL := time.Duration(req.TimeTilDormantMillis) * time.Millisecond - timeTilDormantAutoDelete := time.Duration(req.TimeTilDormantAutoDeleteMillis) * time.Millisecond + 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 req.UpdateWorkspaceLastUsedAt { + if resolved.updateWorkspaceLastUsedAtIntent { updateWorkspaceLastUsedAt = workspacestats.UpdateTemplateWorkspacesLastUsedAt } - if defaultTTL != time.Duration(template.DefaultTTL) || - activityBump != time.Duration(template.ActivityBump) || - autostopRequirementDaysOfWeekParsed != scheduleOpts.AutostopRequirement.DaysOfWeek || - autostartRequirementDaysOfWeekParsed != scheduleOpts.AutostartRequirement.DaysOfWeek || - req.AutostopRequirement.Weeks != scheduleOpts.AutostopRequirement.Weeks || - failureTTL != time.Duration(template.FailureTTL) || - inactivityTTL != time.Duration(template.TimeTilDormant) || - timeTilDormantAutoDelete != time.Duration(template.TimeTilDormantAutoDelete) || - req.AllowUserAutostart != template.AllowUserAutostart || - req.AllowUserAutostop != template.AllowUserAutostop { - updated, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, updated, schedule.TemplateScheduleOptions{ - // Some of these values are enterprise-only, but the - // TemplateScheduleStore will handle avoiding setting them if - // unlicensed. - UserAutostartEnabled: req.AllowUserAutostart, - UserAutostopEnabled: req.AllowUserAutostop, - DefaultTTL: defaultTTL, - ActivityBump: activityBump, - AutostopRequirement: schedule.TemplateAutostopRequirement{ - DaysOfWeek: autostopRequirementDaysOfWeekParsed, - Weeks: req.AutostopRequirement.Weeks, - }, - AutostartRequirement: schedule.TemplateAutostartRequirement{ - DaysOfWeek: autostartRequirementDaysOfWeekParsed, - }, - FailureTTL: failureTTL, - TimeTilDormant: inactivityTTL, - TimeTilDormantAutoDelete: timeTilDormantAutoDelete, - UpdateWorkspaceLastUsedAt: updateWorkspaceLastUsedAt, - UpdateWorkspaceDormantAt: req.UpdateWorkspaceDormantAt, - }) - if err != nil { - return xerrors.Errorf("set template schedule options: %w", err) - } + 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 @@ -927,7 +834,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { 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.", req.Name), + 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.", @@ -945,11 +852,6 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } } - if updated.UpdatedAt.IsZero() { - aReq.New = template - rw.WriteHeader(http.StatusNotModified) - return - } aReq.New = updated httpapi.Write(ctx, rw, http.StatusOK, api.convertTemplate(updated)) diff --git a/coderd/templates_meta_update.go b/coderd/templates_meta_update.go new file mode 100644 index 0000000000..8dfc55eb61 --- /dev/null +++ b/coderd/templates_meta_update.go @@ -0,0 +1,169 @@ +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 + failureTTLMillis int64 + timeTilDormantMillis int64 + timeTilDormantAutoDeleteMillis int64 + allowUserAutostart bool + allowUserAutostop bool + allowUserCancelWorkspaceJobs bool + requireActiveVersion bool + deprecationMessage string + useClassicTemplateFlow bool + disableModuleCache 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. +func resolveTemplateMetaUpdate( + template database.Template, + scheduleOpts schedule.TemplateScheduleOptions, + req codersdk.UpdateTemplateMeta, +) (templateMetaUpdate, []codersdk.ValidationError) { + var validErrs []codersdk.ValidationError + + 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()), + 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), + requireActiveVersion: ptr.NilToDefault(req.RequireActiveVersion, template.RequireActiveVersion), + deprecationMessage: ptr.NilToDefault(req.DeprecationMessage, template.Deprecated), + useClassicTemplateFlow: ptr.NilToDefault(req.UseClassicParameterFlow, template.UseClassicParameterFlow), + disableModuleCache: ptr.NilToDefault(req.DisableModuleCache, template.DisableModuleCache), + 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 +} diff --git a/coderd/templates_meta_update_internal_test.go b/coderd/templates_meta_update_internal_test.go new file mode 100644 index 0000000000..e7b00afa3e --- /dev/null +++ b/coderd/templates_meta_update_internal_test.go @@ -0,0 +1,496 @@ +package coderd //nolint:testpackage // Tests the unexported resolveTemplateMetaUpdate helper. + +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/coderd/util/ptr" + "github.com/coder/coder/v2/codersdk" +) + +// 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, + RequireActiveVersion: true, + DefaultTTL: int64(60 * 60 * 1000 * 1000 * 1000), // 1 hour in ns + ActivityBump: int64(30 * 60 * 1000 * 1000 * 1000), // 30 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, + 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, + failureTTLMillis: tpl.FailureTTL / 1e6, + timeTilDormantMillis: tpl.TimeTilDormant / 1e6, + timeTilDormantAutoDeleteMillis: tpl.TimeTilDormantAutoDelete / 1e6, + allowUserAutostart: tpl.AllowUserAutostart, + allowUserAutostop: tpl.AllowUserAutostop, + allowUserCancelWorkspaceJobs: tpl.AllowUserCancelWorkspaceJobs, + requireActiveVersion: tpl.RequireActiveVersion, + deprecationMessage: tpl.Deprecated, + useClassicTemplateFlow: tpl.UseClassicParameterFlow, + disableModuleCache: tpl.DisableModuleCache, + 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: ptr.Ref("renamed")}, + expected: expected{override: func(r *templateMetaUpdate) { + r.name = "renamed" + }}, + }, + { + name: "NameEmptyStringFallsBackToCurrent", + req: codersdk.UpdateTemplateMeta{Name: ptr.Ref("")}, + // 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: ptr.Ref("Renamed")}, + expected: expected{override: func(r *templateMetaUpdate) { + r.displayName = "Renamed" + }}, + }, + { + name: "Description", + req: codersdk.UpdateTemplateMeta{Description: ptr.Ref("New description")}, + expected: expected{override: func(r *templateMetaUpdate) { + r.description = "New description" + }}, + }, + { + name: "Icon", + req: codersdk.UpdateTemplateMeta{Icon: ptr.Ref("/new.svg")}, + expected: expected{override: func(r *templateMetaUpdate) { + r.icon = "/new.svg" + }}, + }, + { + name: "DefaultTTLMillis", + req: codersdk.UpdateTemplateMeta{DefaultTTLMillis: ptr.Ref(int64(7200_000))}, + expected: expected{override: func(r *templateMetaUpdate) { + r.defaultTTLMillis = 7200_000 + }}, + }, + { + name: "DefaultTTLMillisZeroExplicit", + req: codersdk.UpdateTemplateMeta{DefaultTTLMillis: ptr.Ref(int64(0))}, + expected: expected{override: func(r *templateMetaUpdate) { + r.defaultTTLMillis = 0 + }}, + }, + { + name: "ActivityBumpMillis", + req: codersdk.UpdateTemplateMeta{ActivityBumpMillis: ptr.Ref(int64(900_000))}, + expected: expected{override: func(r *templateMetaUpdate) { + r.activityBumpMillis = 900_000 + }}, + }, + { + name: "AllowUserAutostart", + req: codersdk.UpdateTemplateMeta{AllowUserAutostart: ptr.Ref(true)}, + expected: expected{override: func(r *templateMetaUpdate) { + r.allowUserAutostart = true + }}, + }, + { + name: "AllowUserAutostop", + req: codersdk.UpdateTemplateMeta{AllowUserAutostop: ptr.Ref(true)}, + expected: expected{override: func(r *templateMetaUpdate) { + r.allowUserAutostop = true + }}, + }, + { + name: "AllowUserAutostop/true", + req: codersdk.UpdateTemplateMeta{AllowUserAutostop: ptr.Ref(false)}, + expected: expected{ + base: func(update *database.Template) { + update.AllowUserAutostop = true + }, + override: func(r *templateMetaUpdate) { + r.allowUserAutostop = false + }, + }, + }, + { + name: "AllowUserCancelWorkspaceJobs", + req: codersdk.UpdateTemplateMeta{AllowUserCancelWorkspaceJobs: ptr.Ref(true)}, + expected: expected{override: func(r *templateMetaUpdate) { + r.allowUserCancelWorkspaceJobs = true + }}, + }, + { + name: "FailureTTLMillis", + req: codersdk.UpdateTemplateMeta{FailureTTLMillis: ptr.Ref(int64(3_600_000))}, + expected: expected{override: func(r *templateMetaUpdate) { + r.failureTTLMillis = 3_600_000 + }}, + }, + { + name: "TimeTilDormantMillis", + req: codersdk.UpdateTemplateMeta{TimeTilDormantMillis: ptr.Ref(int64(7_200_000))}, + expected: expected{override: func(r *templateMetaUpdate) { + r.timeTilDormantMillis = 7_200_000 + }}, + }, + { + name: "TimeTilDormantAutoDeleteMillis", + req: codersdk.UpdateTemplateMeta{TimeTilDormantAutoDeleteMillis: ptr.Ref(int64(14_400_000))}, + expected: expected{override: func(r *templateMetaUpdate) { + r.timeTilDormantAutoDeleteMillis = 14_400_000 + }}, + }, + { + name: "RequireActiveVersion", + req: codersdk.UpdateTemplateMeta{RequireActiveVersion: ptr.Ref(false)}, + expected: expected{override: func(r *templateMetaUpdate) { + r.requireActiveVersion = false + }}, + }, + { + name: "DeprecationMessage", + req: codersdk.UpdateTemplateMeta{DeprecationMessage: ptr.Ref("now deprecated")}, + expected: expected{override: func(r *templateMetaUpdate) { + r.deprecationMessage = "now deprecated" + }}, + }, + { + name: "DeprecationMessageEmptyStringClears", + req: codersdk.UpdateTemplateMeta{DeprecationMessage: ptr.Ref("")}, + expected: expected{override: func(r *templateMetaUpdate) { + r.deprecationMessage = "" + }}, + }, + { + name: "UseClassicParameterFlow", + req: codersdk.UpdateTemplateMeta{UseClassicParameterFlow: ptr.Ref(false)}, + expected: expected{override: func(r *templateMetaUpdate) { + r.useClassicTemplateFlow = false + }}, + }, + { + name: "DisableModuleCache", + req: codersdk.UpdateTemplateMeta{DisableModuleCache: ptr.Ref(false)}, + expected: expected{override: func(r *templateMetaUpdate) { + r.disableModuleCache = false + }}, + }, + + // CORS behavior. + { + name: "CORSBehaviorChange", + req: codersdk.UpdateTemplateMeta{ + CORSBehavior: ptr.Ref(codersdk.CORSBehavior(database.CorsBehaviorSimple)), + }, + expected: expected{override: func(r *templateMetaUpdate) { + r.corsBehavior = database.CorsBehaviorSimple + }}, + }, + { + name: "CORSBehaviorEmptyStringPreserves", + req: codersdk.UpdateTemplateMeta{ + CORSBehavior: ptr.Ref(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: ptr.Ref(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: ptr.Ref(false)}, + expected: expected{override: func(*templateMetaUpdate) { + // disableEveryoneIntent stays false. + }}, + }, + { + name: "DisableEveryoneGroupAccessTrueWithMembership", + req: codersdk.UpdateTemplateMeta{DisableEveryoneGroupAccess: ptr.Ref(true)}, + expected: expected{override: func(r *templateMetaUpdate) { + r.groupACL = database.TemplateACL{} + }}, + }, + { + name: "UpdateWorkspaceLastUsedAtFalseIsNoop", + req: codersdk.UpdateTemplateMeta{UpdateWorkspaceLastUsedAt: ptr.Ref(false)}, + expected: expected{override: func(*templateMetaUpdate) {}}, + }, + { + name: "UpdateWorkspaceLastUsedAtTrue", + req: codersdk.UpdateTemplateMeta{UpdateWorkspaceLastUsedAt: ptr.Ref(true)}, + expected: expected{override: func(r *templateMetaUpdate) { + r.updateWorkspaceLastUsedAtIntent = true + }}, + }, + { + name: "UpdateWorkspaceDormantAtFalseIsNoop", + req: codersdk.UpdateTemplateMeta{UpdateWorkspaceDormantAt: ptr.Ref(false)}, + expected: expected{override: func(*templateMetaUpdate) {}}, + }, + { + name: "UpdateWorkspaceDormantAtTrue", + req: codersdk.UpdateTemplateMeta{UpdateWorkspaceDormantAt: ptr.Ref(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) + + 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: ptr.Ref(""), + }) + 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{}) + 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) + } +} diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 16fa11940a..da7f660cf0 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -901,13 +901,13 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, (1 * time.Hour).Milliseconds(), template.ActivityBumpMillis) req := codersdk.UpdateTemplateMeta{ - Name: "new-template-name", + Name: ptr.Ref("new-template-name"), DisplayName: ptr.Ref("Displayed Name 456"), Description: ptr.Ref("lorem ipsum dolor sit amet et cetera"), Icon: ptr.Ref("/icon/new-icon.png"), - DefaultTTLMillis: 12 * time.Hour.Milliseconds(), - ActivityBumpMillis: 3 * time.Hour.Milliseconds(), - AllowUserCancelWorkspaceJobs: false, + DefaultTTLMillis: ptr.Ref(12 * time.Hour.Milliseconds()), + ActivityBumpMillis: ptr.Ref(3 * time.Hour.Milliseconds()), + AllowUserCancelWorkspaceJobs: ptr.Ref(false), } // It is unfortunate we need to sleep, but the test can fail if the // updatedAt is too close together. @@ -918,25 +918,25 @@ func TestPatchTemplateMeta(t *testing.T) { updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) require.NoError(t, err) assert.Greater(t, updated.UpdatedAt, template.UpdatedAt) - assert.Equal(t, req.Name, updated.Name) + assert.Equal(t, *req.Name, updated.Name) assert.Equal(t, *req.DisplayName, updated.DisplayName) assert.Equal(t, *req.Description, updated.Description) assert.Equal(t, *req.Icon, updated.Icon) - assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, req.ActivityBumpMillis, updated.ActivityBumpMillis) - assert.False(t, req.AllowUserCancelWorkspaceJobs) + assert.Equal(t, *req.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, *req.ActivityBumpMillis, updated.ActivityBumpMillis) + assert.False(t, *req.AllowUserCancelWorkspaceJobs) // Extra paranoid: did it _really_ happen? updated, err = client.Template(ctx, template.ID) require.NoError(t, err) assert.Greater(t, updated.UpdatedAt, template.UpdatedAt) - assert.Equal(t, req.Name, updated.Name) + assert.Equal(t, *req.Name, updated.Name) assert.Equal(t, *req.DisplayName, updated.DisplayName) assert.Equal(t, *req.Description, updated.Description) assert.Equal(t, *req.Icon, updated.Icon) - assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, req.ActivityBumpMillis, updated.ActivityBumpMillis) - assert.False(t, req.AllowUserCancelWorkspaceJobs) + assert.Equal(t, *req.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, *req.ActivityBumpMillis, updated.ActivityBumpMillis) + assert.False(t, *req.AllowUserCancelWorkspaceJobs) require.Len(t, auditor.AuditLogs(), 5) assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[4].Action) @@ -957,7 +957,7 @@ func TestPatchTemplateMeta(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template2.Name, + Name: &template2.Name, }) var apiErr *codersdk.Error require.ErrorAs(t, err, &apiErr) @@ -1052,7 +1052,7 @@ func TestPatchTemplateMeta(t *testing.T) { // Ensure the same value port share level is a no-op level = codersdk.WorkspaceAgentPortShareLevelPublic _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: coderdtest.RandomUsername(t), + Name: ptr.Ref(coderdtest.RandomUsername(t)), MaxPortShareLevel: &level, }) require.NoError(t, err) @@ -1072,7 +1072,7 @@ func TestPatchTemplateMeta(t *testing.T) { time.Sleep(time.Millisecond * 5) req := codersdk.UpdateTemplateMeta{ - DefaultTTLMillis: 0, + DefaultTTLMillis: ptr.Ref(int64(0)), } // We're too fast! Sleep so we can be sure that updatedAt is greater @@ -1087,7 +1087,7 @@ func TestPatchTemplateMeta(t *testing.T) { updated, err := client.Template(ctx, template.ID) require.NoError(t, err) assert.Greater(t, updated.UpdatedAt, template.UpdatedAt) - assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, *req.DefaultTTLMillis, updated.DefaultTTLMillis) assert.Empty(t, updated.DeprecationMessage) assert.False(t, updated.Deprecated) }) @@ -1106,7 +1106,7 @@ func TestPatchTemplateMeta(t *testing.T) { time.Sleep(time.Millisecond * 5) req := codersdk.UpdateTemplateMeta{ - DefaultTTLMillis: -1, + DefaultTTLMillis: ptr.Ref(int64(-1)), } ctx := testutil.Context(t, testutil.WaitLong) @@ -1163,16 +1163,16 @@ func TestPatchTemplateMeta(t *testing.T) { defer cancel() got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: &template.Name, DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, - DefaultTTLMillis: 0, + DefaultTTLMillis: ptr.Ref(int64(0)), AutostopRequirement: &template.AutostopRequirement, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - FailureTTLMillis: failureTTL.Milliseconds(), - TimeTilDormantMillis: inactivityTTL.Milliseconds(), - TimeTilDormantAutoDeleteMillis: timeTilDormantAutoDelete.Milliseconds(), + AllowUserCancelWorkspaceJobs: &template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: ptr.Ref(failureTTL.Milliseconds()), + TimeTilDormantMillis: ptr.Ref(inactivityTTL.Milliseconds()), + TimeTilDormantAutoDeleteMillis: ptr.Ref(timeTilDormantAutoDelete.Milliseconds()), }) require.NoError(t, err) @@ -1198,16 +1198,16 @@ func TestPatchTemplateMeta(t *testing.T) { defer cancel() got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: &template.Name, DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, - DefaultTTLMillis: template.DefaultTTLMillis, + DefaultTTLMillis: &template.DefaultTTLMillis, AutostopRequirement: &template.AutostopRequirement, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - FailureTTLMillis: failureTTL.Milliseconds(), - TimeTilDormantMillis: inactivityTTL.Milliseconds(), - TimeTilDormantAutoDeleteMillis: timeTilDormantAutoDelete.Milliseconds(), + AllowUserCancelWorkspaceJobs: &template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: ptr.Ref(failureTTL.Milliseconds()), + TimeTilDormantMillis: ptr.Ref(inactivityTTL.Milliseconds()), + TimeTilDormantAutoDeleteMillis: ptr.Ref(timeTilDormantAutoDelete.Milliseconds()), }) require.NoError(t, err) require.Zero(t, got.FailureTTLMillis) @@ -1259,15 +1259,15 @@ func TestPatchTemplateMeta(t *testing.T) { allowAutostart.Store(false) allowAutostop.Store(false) got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: &template.Name, DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, - DefaultTTLMillis: template.DefaultTTLMillis, + DefaultTTLMillis: &template.DefaultTTLMillis, AutostopRequirement: &template.AutostopRequirement, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - AllowUserAutostart: allowAutostart.Load(), - AllowUserAutostop: allowAutostop.Load(), + AllowUserCancelWorkspaceJobs: &template.AllowUserCancelWorkspaceJobs, + AllowUserAutostart: ptr.Ref(allowAutostart.Load()), + AllowUserAutostop: ptr.Ref(allowAutostop.Load()), }) require.NoError(t, err) @@ -1290,16 +1290,15 @@ func TestPatchTemplateMeta(t *testing.T) { defer cancel() got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: &template.DisplayName, - Description: &template.Description, - Icon: &template.Icon, - // Increase the default TTL to avoid error "not modified". - DefaultTTLMillis: template.DefaultTTLMillis + 1, + Name: &template.Name, + DisplayName: &template.DisplayName, + Description: &template.Description, + Icon: &template.Icon, + DefaultTTLMillis: ptr.Ref(template.DefaultTTLMillis + 1), AutostopRequirement: &template.AutostopRequirement, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - AllowUserAutostart: false, - AllowUserAutostop: false, + AllowUserCancelWorkspaceJobs: &template.AllowUserCancelWorkspaceJobs, + AllowUserAutostart: ptr.Ref(false), + AllowUserAutostop: ptr.Ref(false), }) require.NoError(t, err) require.True(t, got.AllowUserAutostart) @@ -1322,24 +1321,26 @@ func TestPatchTemplateMeta(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) req := codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: &template.Name, Description: &template.Description, Icon: &template.Icon, - DefaultTTLMillis: template.DefaultTTLMillis, - ActivityBumpMillis: template.ActivityBumpMillis, + DefaultTTLMillis: &template.DefaultTTLMillis, + ActivityBumpMillis: &template.ActivityBumpMillis, AutostopRequirement: nil, - AllowUserAutostart: template.AllowUserAutostart, - AllowUserAutostop: template.AllowUserAutostop, + AllowUserAutostart: &template.AllowUserAutostart, + AllowUserAutostop: &template.AllowUserAutostop, } _, err := client.UpdateTemplateMeta(ctx, template.ID, req) - require.ErrorContains(t, err, "not modified") + require.NoError(t, err) updated, err := client.Template(ctx, template.ID) require.NoError(t, err) - assert.Equal(t, updated.UpdatedAt, template.UpdatedAt) assert.Equal(t, template.Name, updated.Name) assert.Equal(t, template.Description, updated.Description) assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.ActivityBumpMillis, updated.ActivityBumpMillis) + assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) + assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) }) t.Run("Invalid", func(t *testing.T) { @@ -1356,7 +1357,7 @@ func TestPatchTemplateMeta(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) req := codersdk.UpdateTemplateMeta{ - DefaultTTLMillis: -int64(time.Hour), + DefaultTTLMillis: ptr.Ref(-int64(time.Hour)), } _, err := client.UpdateTemplateMeta(ctx, template.ID, req) var apiErr *codersdk.Error @@ -1438,12 +1439,12 @@ func TestPatchTemplateMeta(t *testing.T) { require.Empty(t, template.AutostopRequirement.DaysOfWeek) require.EqualValues(t, 1, template.AutostopRequirement.Weeks) req := codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: &template.Name, DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - DefaultTTLMillis: time.Hour.Milliseconds(), + AllowUserCancelWorkspaceJobs: &template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: ptr.Ref(time.Hour.Milliseconds()), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ // wrong order DaysOfWeek: []string{"saturday", "friday"}, @@ -1515,12 +1516,12 @@ func TestPatchTemplateMeta(t *testing.T) { require.Equal(t, []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}, template.AutostopRequirement.DaysOfWeek) require.EqualValues(t, 2, template.AutostopRequirement.Weeks) req := codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: &template.Name, DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - DefaultTTLMillis: time.Hour.Milliseconds(), + AllowUserCancelWorkspaceJobs: &template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: ptr.Ref(time.Hour.Milliseconds()), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ DaysOfWeek: []string{}, Weeks: 0, @@ -1552,12 +1553,12 @@ func TestPatchTemplateMeta(t *testing.T) { require.Empty(t, template.AutostopRequirement.DaysOfWeek) require.EqualValues(t, 1, template.AutostopRequirement.Weeks) req := codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: &template.Name, DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - DefaultTTLMillis: time.Hour.Milliseconds(), + AllowUserCancelWorkspaceJobs: &template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: ptr.Ref(time.Hour.Milliseconds()), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ DaysOfWeek: []string{"monday"}, Weeks: 2, @@ -1603,9 +1604,11 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.True(t, updated.UseClassicParameterFlow, "expected true") - // noop req.UseClassicParameterFlow = nil - updated, err = client.UpdateTemplateMeta(ctx, template.ID, req) + _, err = client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + + updated, err = client.Template(ctx, template.ID) require.NoError(t, err) assert.True(t, updated.UseClassicParameterFlow, "expected true") @@ -1636,9 +1639,13 @@ func TestPatchTemplateMeta(t *testing.T) { require.NoError(t, err) assert.True(t, updated.DisableModuleCache, "expected true") - // noop - should stay true when not specified + // Sending DisableModuleCache: nil with no other changes is a true + // no-op and produces a 304 Not Modified (surfaced as an error by the + // SDK). req.DisableModuleCache = nil - updated, err = client.UpdateTemplateMeta(ctx, template.ID, req) + _, err = client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + updated, err = client.Template(ctx, template.ID) require.NoError(t, err) assert.True(t, updated.DisableModuleCache, "expected true") @@ -1675,7 +1682,7 @@ func TestPatchTemplateMeta(t *testing.T) { DisplayName: &displayName, Description: &description, Icon: &icon, - DefaultTTLMillis: defaultTTLMillis, + DefaultTTLMillis: ptr.Ref(defaultTTLMillis), } type expected struct { @@ -1694,38 +1701,41 @@ func TestPatchTemplateMeta(t *testing.T) { tests := []testCase{ { name: "Only update default_ttl_ms", - req: codersdk.UpdateTemplateMeta{DefaultTTLMillis: 99 * time.Hour.Milliseconds()}, + req: codersdk.UpdateTemplateMeta{DefaultTTLMillis: ptr.Ref(99 * time.Hour.Milliseconds())}, expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: 99 * time.Hour.Milliseconds()}, }, { name: "Clear display name", req: codersdk.UpdateTemplateMeta{DisplayName: ptr.Ref("")}, - expected: expected{displayName: "", description: reference.Description, icon: reference.Icon, defaultTTLMillis: 0}, + expected: expected{displayName: "", description: reference.Description, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, }, { name: "Clear description", req: codersdk.UpdateTemplateMeta{Description: ptr.Ref("")}, - expected: expected{displayName: reference.DisplayName, description: "", icon: reference.Icon, defaultTTLMillis: 0}, + expected: expected{displayName: reference.DisplayName, description: "", icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, }, { name: "Clear icon", req: codersdk.UpdateTemplateMeta{Icon: ptr.Ref("")}, - expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: "", defaultTTLMillis: 0}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: "", defaultTTLMillis: defaultTTLMillis}, }, + // A request whose only field is nil is a true no-op under the new + // PATCH semantics; the handler returns 304 Not Modified and the + // template values are preserved. { - name: "Nil display name defaults to reference display name", + name: "Nil display name is a no-op", req: codersdk.UpdateTemplateMeta{DisplayName: nil}, - expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: 0}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, }, { - name: "Nil description defaults to reference description", + name: "Nil description is a no-op", req: codersdk.UpdateTemplateMeta{Description: nil}, - expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: 0}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, }, { - name: "Nil icon defaults to reference icon", + name: "Nil icon is a no-op", req: codersdk.UpdateTemplateMeta{Icon: nil}, - expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: 0}, + expected: expected{displayName: reference.DisplayName, description: reference.Description, icon: reference.Icon, defaultTTLMillis: defaultTTLMillis}, }, } @@ -1734,12 +1744,16 @@ func TestPatchTemplateMeta(t *testing.T) { t.Run(tc.name, func(t *testing.T) { defer func() { ctx := testutil.Context(t, testutil.WaitLong) - // Restore reference after each test case - _, err := client.UpdateTemplateMeta(ctx, reference.ID, restoreReq) - require.NoError(t, err) + // Restore reference after each test case. The restore + // itself can be a no-op (and return an error) when the + // previous test case was already a no-op; that is + // expected, so we ignore the error here. + _, _ = client.UpdateTemplateMeta(ctx, reference.ID, restoreReq) }() ctx := testutil.Context(t, testutil.WaitLong) - updated, err := client.UpdateTemplateMeta(ctx, reference.ID, tc.req) + _, err := client.UpdateTemplateMeta(ctx, reference.ID, tc.req) + require.NoError(t, err) + updated, err := client.Template(ctx, reference.ID) require.NoError(t, err) assert.Equal(t, tc.expected.displayName, updated.DisplayName) assert.Equal(t, tc.expected.description, updated.Description) @@ -1748,6 +1762,78 @@ func TestPatchTemplateMeta(t *testing.T) { }) } }) + + // EmptyBodyPreservesAllFields ensures the PATCH endpoint treats an empty + // body as a no-op so that omitted fields do not overwrite existing values. + t.Run("EmptyBodyPreservesAllFields", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DisplayName = "Original Display" + ctr.Description = "Original description" + ctr.Icon = "/icon/original.png" + ctr.DefaultTTLMillis = ptr.Ref((24 * time.Hour).Milliseconds()) + ctr.AllowUserCancelWorkspaceJobs = ptr.Ref(true) + }) + + ctx := testutil.Context(t, testutil.WaitLong) + + _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{}) + require.NoError(t, err) + + updated, err := client.Template(ctx, template.ID) + require.NoError(t, err) + assert.Equal(t, template.Name, updated.Name) + assert.Equal(t, template.DisplayName, updated.DisplayName) + assert.Equal(t, template.Description, updated.Description) + assert.Equal(t, template.Icon, updated.Icon) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.AllowUserCancelWorkspaceJobs, updated.AllowUserCancelWorkspaceJobs) + assert.Equal(t, template.RequireActiveVersion, updated.RequireActiveVersion) + }) + + // PartialUpdatePreservesOtherFields ensures sending a single field on the + // PATCH body changes only that field and leaves the others alone. This is + // the headline behavior PLAT-184 enables: previously, omitted booleans + // were silently overwritten with false because the SDK type used + // non-pointer booleans. + t.Run("PartialUpdatePreservesOtherFields", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + owner := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.AllowUserCancelWorkspaceJobs = ptr.Ref(true) + ctr.DefaultTTLMillis = ptr.Ref((24 * time.Hour).Milliseconds()) + }) + require.True(t, template.AllowUserCancelWorkspaceJobs) + require.Equal(t, (24 * time.Hour).Milliseconds(), template.DefaultTTLMillis) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Sending only DefaultTTLMillis must not flip AllowUserCancelWorkspaceJobs + // to false. + newTTL := (12 * time.Hour).Milliseconds() + updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + DefaultTTLMillis: &newTTL, + }) + require.NoError(t, err) + assert.Equal(t, newTTL, updated.DefaultTTLMillis) + assert.True(t, updated.AllowUserCancelWorkspaceJobs, "omitted bool field must not be overwritten") + + // Conversely, sending only AllowUserCancelWorkspaceJobs must not zero + // out DefaultTTLMillis. + updated, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + AllowUserCancelWorkspaceJobs: ptr.Ref(false), + }) + require.NoError(t, err) + assert.False(t, updated.AllowUserCancelWorkspaceJobs) + assert.Equal(t, newTTL, updated.DefaultTTLMillis, "omitted int64 field must not be overwritten") + }) } func TestDeleteTemplate(t *testing.T) { diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 74a41769b0..0fff131f5a 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -667,7 +667,7 @@ func TestWorkspaceAgentAppStatus_ActivityBump(t *testing.T) { // Configure template with activity_bump to enable deadline bumping. _, err := client.UpdateTemplateMeta(ctx, r.Template.ID, codersdk.UpdateTemplateMeta{ - ActivityBumpMillis: time.Hour.Milliseconds(), + ActivityBumpMillis: ptr.Ref(time.Hour.Milliseconds()), }) require.NoError(t, err) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 255b9e2128..b03253b76b 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4756,7 +4756,7 @@ func TestWorkspaceUsageTracking(t *testing.T) { DefaultTTL: int64(8 * time.Hour), }) _, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - ActivityBumpMillis: 8 * time.Hour.Milliseconds(), + ActivityBumpMillis: ptr.Ref(8 * time.Hour.Milliseconds()), }) require.NoError(t, err) r := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ diff --git a/codersdk/templates.go b/codersdk/templates.go index 21c922025d..d0c5276118 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -215,40 +215,43 @@ type ACLAvailable struct { Groups []Group `json:"groups"` } +// UpdateTemplateMeta is the request body for the PATCH /templates/{template} +// endpoint. All fields are optional. Fields that are nil are not modified. type UpdateTemplateMeta struct { - Name string `json:"name,omitempty" validate:"omitempty,template_name"` + Name *string `json:"name,omitempty" validate:"omitempty,template_name"` DisplayName *string `json:"display_name,omitempty" validate:"omitempty,template_display_name"` Description *string `json:"description,omitempty"` Icon *string `json:"icon,omitempty"` - DefaultTTLMillis int64 `json:"default_ttl_ms,omitempty"` + DefaultTTLMillis *int64 `json:"default_ttl_ms,omitempty"` // ActivityBumpMillis allows optionally specifying the activity bump // duration for all workspaces created from this template. Defaults to 1h // but can be set to 0 to disable activity bumping. - ActivityBumpMillis int64 `json:"activity_bump_ms,omitempty"` + ActivityBumpMillis *int64 `json:"activity_bump_ms,omitempty"` // AutostopRequirement and AutostartRequirement can only be set if your license // includes the advanced template scheduling feature. If you attempt to set this // value while unlicensed, it will be ignored. AutostopRequirement *TemplateAutostopRequirement `json:"autostop_requirement,omitempty"` AutostartRequirement *TemplateAutostartRequirement `json:"autostart_requirement,omitempty"` - AllowUserAutostart bool `json:"allow_user_autostart,omitempty"` - AllowUserAutostop bool `json:"allow_user_autostop,omitempty"` - AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` - FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"` - TimeTilDormantMillis int64 `json:"time_til_dormant_ms,omitempty"` - TimeTilDormantAutoDeleteMillis int64 `json:"time_til_dormant_autodelete_ms,omitempty"` + AllowUserAutostart *bool `json:"allow_user_autostart,omitempty"` + AllowUserAutostop *bool `json:"allow_user_autostop,omitempty"` + AllowUserCancelWorkspaceJobs *bool `json:"allow_user_cancel_workspace_jobs,omitempty"` + FailureTTLMillis *int64 `json:"failure_ttl_ms,omitempty"` + TimeTilDormantMillis *int64 `json:"time_til_dormant_ms,omitempty"` + TimeTilDormantAutoDeleteMillis *int64 `json:"time_til_dormant_autodelete_ms,omitempty"` // UpdateWorkspaceLastUsedAt updates the last_used_at field of workspaces // spawned from the template. This is useful for preventing workspaces being // immediately locked when updating the inactivity_ttl field to a new, shorter // value. - UpdateWorkspaceLastUsedAt bool `json:"update_workspace_last_used_at"` - // UpdateWorkspaceDormant updates the dormant_at field of workspaces spawned - // from the template. This is useful for preventing dormant workspaces being immediately - // deleted when updating the dormant_ttl field to a new, shorter value. - UpdateWorkspaceDormantAt bool `json:"update_workspace_dormant_at"` + UpdateWorkspaceLastUsedAt *bool `json:"update_workspace_last_used_at,omitempty"` + // UpdateWorkspaceDormantAt updates the dormant_at field of workspaces spawned + // from the template. This is useful for preventing dormant workspaces being + // immediately deleted when updating the dormant_ttl field to a new, shorter + // value. + UpdateWorkspaceDormantAt *bool `json:"update_workspace_dormant_at,omitempty"` // RequireActiveVersion mandates workspaces built using this template // use the active version of the template. This option has no // effect on template admins. - RequireActiveVersion bool `json:"require_active_version,omitempty"` + RequireActiveVersion *bool `json:"require_active_version,omitempty"` // DeprecationMessage if set, will mark the template as deprecated and block // any new workspaces from using this template. // If passed an empty string, will remove the deprecated message, making @@ -259,7 +262,7 @@ type UpdateTemplateMeta struct { // If this is set to true, the template will not be available to all users, // and must be explicitly granted to users or groups in the permissions settings // of the template. - DisableEveryoneGroupAccess bool `json:"disable_everyone_group_access"` + DisableEveryoneGroupAccess *bool `json:"disable_everyone_group_access,omitempty"` MaxPortShareLevel *WorkspaceAgentPortShareLevel `json:"max_port_share_level,omitempty"` CORSBehavior *CORSBehavior `json:"cors_behavior,omitempty"` // UseClassicParameterFlow is a flag that switches the default behavior to use the classic @@ -353,9 +356,6 @@ func (c *Client) UpdateTemplateMeta(ctx context.Context, templateID uuid.UUID, r return Template{}, err } defer res.Body.Close() - if res.StatusCode == http.StatusNotModified { - return Template{}, xerrors.New("template metadata not modified") - } if res.StatusCode != http.StatusOK { return Template{}, ReadBodyAsError(res) } diff --git a/enterprise/cli/start_test.go b/enterprise/cli/start_test.go index 3dfd277e3c..eff0e13317 100644 --- a/enterprise/cli/start_test.go +++ b/enterprise/cli/start_test.go @@ -10,6 +10,7 @@ import ( "github.com/coder/coder/v2/cli/clitest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" @@ -46,7 +47,7 @@ func TestStart(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, templateAdminClient, oldVersion.ID) require.Equal(t, oldVersion.ID, template.ActiveVersionID) template = coderdtest.UpdateTemplateMeta(t, templateAdminClient, template.ID, codersdk.UpdateTemplateMeta{ - RequireActiveVersion: true, + RequireActiveVersion: ptr.Ref(true), }) require.True(t, template.RequireActiveVersion) diff --git a/enterprise/cli/templateedit_test.go b/enterprise/cli/templateedit_test.go index 01d4784fd3..4b97a0ad76 100644 --- a/enterprise/cli/templateedit_test.go +++ b/enterprise/cli/templateedit_test.go @@ -218,20 +218,20 @@ func TestTemplateEdit(t *testing.T) { } template, err := ownerClient.UpdateTemplateMeta(ctx, dbtemplate.ID, codersdk.UpdateTemplateMeta{ - Name: expectedName, + Name: ptr.Ref(expectedName), DisplayName: &expectedDisplayName, Description: &expectedDescription, Icon: &expectedIcon, - DefaultTTLMillis: expectedDefaultTTLMillis, - AllowUserAutostop: expectedAllowAutostop, - AllowUserAutostart: expectedAllowAutostart, - FailureTTLMillis: expectedFailureTTLMillis, - TimeTilDormantMillis: expectedDormancyMillis, - TimeTilDormantAutoDeleteMillis: expectedAutoDeleteMillis, - RequireActiveVersion: expectedRequireActiveVersion, + DefaultTTLMillis: ptr.Ref(expectedDefaultTTLMillis), + AllowUserAutostop: ptr.Ref(expectedAllowAutostop), + AllowUserAutostart: ptr.Ref(expectedAllowAutostart), + FailureTTLMillis: ptr.Ref(expectedFailureTTLMillis), + TimeTilDormantMillis: ptr.Ref(expectedDormancyMillis), + TimeTilDormantAutoDeleteMillis: ptr.Ref(expectedAutoDeleteMillis), + RequireActiveVersion: ptr.Ref(expectedRequireActiveVersion), DeprecationMessage: ptr.Ref(deprecationMessage), - DisableEveryoneGroupAccess: expectedDisableEveryone, - AllowUserCancelWorkspaceJobs: expectedAllowCancelJobs, + DisableEveryoneGroupAccess: ptr.Ref(expectedDisableEveryone), + AllowUserCancelWorkspaceJobs: ptr.Ref(expectedAllowCancelJobs), AutostartRequirement: &codersdk.TemplateAutostartRequirement{ DaysOfWeek: expectedAutostartDaysOfWeek, }, @@ -266,20 +266,20 @@ func TestTemplateEdit(t *testing.T) { expectedAutoStopWeeks = 2 template, err = ownerClient.UpdateTemplateMeta(ctx, dbtemplate.ID, codersdk.UpdateTemplateMeta{ - Name: expectedName, + Name: ptr.Ref(expectedName), DisplayName: &expectedDisplayName, Description: &expectedDescription, Icon: &expectedIcon, - DefaultTTLMillis: expectedDefaultTTLMillis, - AllowUserAutostop: expectedAllowAutostop, - AllowUserAutostart: expectedAllowAutostart, - FailureTTLMillis: expectedFailureTTLMillis, - TimeTilDormantMillis: expectedDormancyMillis, - TimeTilDormantAutoDeleteMillis: expectedAutoDeleteMillis, - RequireActiveVersion: expectedRequireActiveVersion, + DefaultTTLMillis: ptr.Ref(expectedDefaultTTLMillis), + AllowUserAutostop: ptr.Ref(expectedAllowAutostop), + AllowUserAutostart: ptr.Ref(expectedAllowAutostart), + FailureTTLMillis: ptr.Ref(expectedFailureTTLMillis), + TimeTilDormantMillis: ptr.Ref(expectedDormancyMillis), + TimeTilDormantAutoDeleteMillis: ptr.Ref(expectedAutoDeleteMillis), + RequireActiveVersion: ptr.Ref(expectedRequireActiveVersion), DeprecationMessage: ptr.Ref(deprecationMessage), - DisableEveryoneGroupAccess: expectedDisableEveryone, - AllowUserCancelWorkspaceJobs: expectedAllowCancelJobs, + DisableEveryoneGroupAccess: ptr.Ref(expectedDisableEveryone), + AllowUserCancelWorkspaceJobs: ptr.Ref(expectedAllowCancelJobs), AutostartRequirement: &codersdk.TemplateAutostartRequirement{ DaysOfWeek: expectedAutostartDaysOfWeek, }, diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 5073223488..57c0977be1 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -186,14 +186,16 @@ func TestTemplates(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) - // OK + // OK: setting the same level is a no-op under the new PATCH semantics + // (304 Not Modified) but must not be a server error. var level codersdk.WorkspaceAgentPortShareLevel = codersdk.WorkspaceAgentPortShareLevelPublic - updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ MaxPortShareLevel: &level, }) require.NoError(t, err) - assert.Equal(t, level, updated.MaxPortShareLevel) - + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + assert.Equal(t, level, template.MaxPortShareLevel) // Invalid level level = "invalid" _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ @@ -258,7 +260,7 @@ func TestTemplates(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) updated, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: ptr.Ref(template.Name), DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, @@ -275,7 +277,7 @@ func TestTemplates(t *testing.T) { // Ensure a missing field is a noop updated, err = anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: ptr.Ref(template.Name), DisplayName: &template.DisplayName, Description: &template.Description, Icon: ptr.Ref(template.Icon + "something"), @@ -312,7 +314,7 @@ func TestTemplates(t *testing.T) { ctx := testutil.Context(t, testutil.WaitLong) _, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: ptr.Ref(template.Name), DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, @@ -348,12 +350,12 @@ func TestTemplates(t *testing.T) { ctx := context.Background() updated, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: ptr.Ref(template.Name), DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - DefaultTTLMillis: time.Hour.Milliseconds(), + AllowUserCancelWorkspaceJobs: ptr.Ref(template.AllowUserCancelWorkspaceJobs), + DefaultTTLMillis: ptr.Ref(time.Hour.Milliseconds()), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ DaysOfWeek: []string{"monday", "saturday"}, Weeks: 3, @@ -402,14 +404,14 @@ func TestTemplates(t *testing.T) { ) updated, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: ptr.Ref(template.Name), DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - TimeTilDormantMillis: inactivityTTL.Milliseconds(), - FailureTTLMillis: failureTTL.Milliseconds(), - TimeTilDormantAutoDeleteMillis: dormantTTL.Milliseconds(), + AllowUserCancelWorkspaceJobs: ptr.Ref(template.AllowUserCancelWorkspaceJobs), + TimeTilDormantMillis: ptr.Ref(inactivityTTL.Milliseconds()), + FailureTTLMillis: ptr.Ref(failureTTL.Milliseconds()), + TimeTilDormantAutoDeleteMillis: ptr.Ref(dormantTTL.Milliseconds()), }) require.NoError(t, err) require.Equal(t, failureTTL.Milliseconds(), updated.FailureTTLMillis) @@ -471,14 +473,14 @@ func TestTemplates(t *testing.T) { // nolint: paralleltest // context is from parent t.Run t.Run(c.Name, func(t *testing.T) { _, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: ptr.Ref(template.Name), DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - TimeTilDormantMillis: c.TimeTilDormantMS, - FailureTTLMillis: c.FailureTTLMS, - TimeTilDormantAutoDeleteMillis: c.DormantAutoDeleteMS, + AllowUserCancelWorkspaceJobs: ptr.Ref(template.AllowUserCancelWorkspaceJobs), + TimeTilDormantMillis: ptr.Ref(c.TimeTilDormantMS), + FailureTTLMillis: ptr.Ref(c.FailureTTLMS), + TimeTilDormantAutoDeleteMillis: ptr.Ref(c.DormantAutoDeleteMS), }) require.Error(t, err) cerr, ok := codersdk.AsError(err) @@ -529,7 +531,7 @@ func TestTemplates(t *testing.T) { dormantTTL := time.Minute updated, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - TimeTilDormantAutoDeleteMillis: dormantTTL.Milliseconds(), + TimeTilDormantAutoDeleteMillis: ptr.Ref(dormantTTL.Milliseconds()), }) require.NoError(t, err) require.Equal(t, dormantTTL.Milliseconds(), updated.TimeTilDormantAutoDeleteMillis) @@ -547,7 +549,7 @@ func TestTemplates(t *testing.T) { // Disable the time_til_dormant_auto_delete on the template, then we can assert that the workspaces // no longer have a deleting_at field. updated, err = anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - TimeTilDormantAutoDeleteMillis: 0, + TimeTilDormantAutoDeleteMillis: ptr.Ref[int64](0), }) require.NoError(t, err) require.EqualValues(t, 0, updated.TimeTilDormantAutoDeleteMillis) @@ -604,8 +606,8 @@ func TestTemplates(t *testing.T) { dormantTTL := time.Minute //nolint:gocritic // non-template-admin cannot update template meta updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - TimeTilDormantAutoDeleteMillis: dormantTTL.Milliseconds(), - UpdateWorkspaceDormantAt: true, + TimeTilDormantAutoDeleteMillis: ptr.Ref(dormantTTL.Milliseconds()), + UpdateWorkspaceDormantAt: ptr.Ref(true), }) require.NoError(t, err) require.Equal(t, dormantTTL.Milliseconds(), updated.TimeTilDormantAutoDeleteMillis) @@ -661,8 +663,8 @@ func TestTemplates(t *testing.T) { inactivityTTL := time.Minute updated, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - TimeTilDormantMillis: inactivityTTL.Milliseconds(), - UpdateWorkspaceLastUsedAt: true, + TimeTilDormantMillis: ptr.Ref(inactivityTTL.Milliseconds()), + UpdateWorkspaceLastUsedAt: ptr.Ref(true), }) require.NoError(t, err) require.Equal(t, inactivityTTL.Milliseconds(), updated.TimeTilDormantMillis) @@ -706,14 +708,14 @@ func TestTemplates(t *testing.T) { // Update the field and assert it persists. updatedTemplate, err := anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - RequireActiveVersion: false, + RequireActiveVersion: ptr.Ref(false), }) require.NoError(t, err) require.False(t, updatedTemplate.RequireActiveVersion) // Flip it back to ensure we aren't hardcoding to a default value. updatedTemplate, err = anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - RequireActiveVersion: true, + RequireActiveVersion: ptr.Ref(true), }) require.NoError(t, err) require.True(t, updatedTemplate.RequireActiveVersion) @@ -1003,12 +1005,12 @@ func TestTemplateACL(t *testing.T) { require.NoError(t, err) require.Equal(t, 1, len(acl.Groups)) _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, + Name: ptr.Ref(template.Name), DisplayName: &template.DisplayName, Description: &template.Description, Icon: &template.Icon, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - DisableEveryoneGroupAccess: true, + AllowUserCancelWorkspaceJobs: ptr.Ref(template.AllowUserCancelWorkspaceJobs), + DisableEveryoneGroupAccess: ptr.Ref(true), }) require.NoError(t, err) diff --git a/enterprise/coderd/workspacebuilds_test.go b/enterprise/coderd/workspacebuilds_test.go index d20eb4ed86..8c392dfb8d 100644 --- a/enterprise/coderd/workspacebuilds_test.go +++ b/enterprise/coderd/workspacebuilds_test.go @@ -8,6 +8,7 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/util/ptr" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/enterprise/coderd/coderdenttest" "github.com/coder/coder/v2/enterprise/coderd/license" @@ -43,7 +44,7 @@ func TestWorkspaceBuild(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, tplAv1.ID) require.Equal(t, tplAv1.ID, tplA.ActiveVersionID) tplA = coderdtest.UpdateTemplateMeta(t, ownerClient, tplA.ID, codersdk.UpdateTemplateMeta{ - RequireActiveVersion: true, + RequireActiveVersion: ptr.Ref(true), }) require.True(t, tplA.RequireActiveVersion) tplAv2 := coderdtest.CreateTemplateVersion(t, ownerClient, owner.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { @@ -57,7 +58,7 @@ func TestWorkspaceBuild(t *testing.T) { coderdtest.AwaitTemplateVersionJobCompleted(t, ownerClient, tplBv1.ID) require.Equal(t, tplBv1.ID, tplB.ActiveVersionID) tplB = coderdtest.UpdateTemplateMeta(t, ownerClient, tplB.ID, codersdk.UpdateTemplateMeta{ - RequireActiveVersion: true, + RequireActiveVersion: ptr.Ref(true), }) require.True(t, tplB.RequireActiveVersion) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index d565939919..95bf50e74f 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -784,7 +784,7 @@ func TestWorkspaceAutobuild(t *testing.T) { }).Do().Template template := coderdtest.UpdateTemplateMeta(t, client, tpl.ID, codersdk.UpdateTemplateMeta{ - TimeTilDormantMillis: inactiveTTL.Milliseconds(), + TimeTilDormantMillis: ptr.Ref(inactiveTTL.Milliseconds()), }) resp := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ @@ -1260,7 +1260,7 @@ func TestWorkspaceAutobuild(t *testing.T) { require.Len(t, stats.Transitions, 0) _, err = anotherClient.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - TimeTilDormantAutoDeleteMillis: dormantTTL.Milliseconds(), + TimeTilDormantAutoDeleteMillis: ptr.Ref(dormantTTL.Milliseconds()), }) require.NoError(t, err) @@ -1334,7 +1334,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Now that we've validated that the workspace is eligible for autostart // lets cause it to become dormant. _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - TimeTilDormantMillis: inactiveTTL.Milliseconds(), + TimeTilDormantMillis: ptr.Ref(inactiveTTL.Milliseconds()), }) require.NoError(t, err) @@ -1433,7 +1433,7 @@ func TestWorkspaceAutobuild(t *testing.T) { // Enable auto-deletion for the template. _, err = templateAdmin.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - TimeTilDormantAutoDeleteMillis: transitionTTL.Milliseconds(), + TimeTilDormantAutoDeleteMillis: ptr.Ref(transitionTTL.Milliseconds()), }) require.NoError(t, err) @@ -1538,8 +1538,8 @@ func TestWorkspaceAutobuild(t *testing.T) { // Update the template to require the promoted version. _, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - RequireActiveVersion: true, - AllowUserAutostart: true, + RequireActiveVersion: ptr.Ref(true), + AllowUserAutostart: ptr.Ref(true), }) require.NoError(t, err) @@ -1832,7 +1832,7 @@ func TestTemplateDoesNotAllowUserAutostop(t *testing.T) { templateTTL = 72 * time.Hour.Milliseconds() ctx := testutil.Context(t, testutil.WaitShort) template = coderdtest.UpdateTemplateMeta(t, client, template.ID, codersdk.UpdateTemplateMeta{ - DefaultTTLMillis: templateTTL, + DefaultTTLMillis: ptr.Ref(templateTTL), }) workspace, err := client.Workspace(ctx, workspace.ID) require.NoError(t, err) @@ -4068,7 +4068,7 @@ func TestResolveAutostart(t *testing.T) { defer cancel() _, err := ownerClient.UpdateTemplateMeta(ctx, version1.Template.ID, codersdk.UpdateTemplateMeta{ - RequireActiveVersion: true, + RequireActiveVersion: ptr.Ref(true), }) require.NoError(t, err) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index b1720054d6..ebd7da790d 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -8259,6 +8259,10 @@ export interface UpdateTemplateACL { } // From codersdk/templates.go +/** + * UpdateTemplateMeta is the request body for the PATCH /templates/{template} + * endpoint. All fields are optional. Fields that are nil are not modified. + */ export interface UpdateTemplateMeta { readonly name?: string; readonly display_name?: string; @@ -8290,13 +8294,14 @@ export interface UpdateTemplateMeta { * immediately locked when updating the inactivity_ttl field to a new, shorter * value. */ - readonly update_workspace_last_used_at: boolean; + readonly update_workspace_last_used_at?: boolean; /** - * UpdateWorkspaceDormant updates the dormant_at field of workspaces spawned - * from the template. This is useful for preventing dormant workspaces being immediately - * deleted when updating the dormant_ttl field to a new, shorter value. + * UpdateWorkspaceDormantAt updates the dormant_at field of workspaces spawned + * from the template. This is useful for preventing dormant workspaces being + * immediately deleted when updating the dormant_ttl field to a new, shorter + * value. */ - readonly update_workspace_dormant_at: boolean; + readonly update_workspace_dormant_at?: boolean; /** * RequireActiveVersion mandates workspaces built using this template * use the active version of the template. This option has no @@ -8317,7 +8322,7 @@ export interface UpdateTemplateMeta { * and must be explicitly granted to users or groups in the permissions settings * of the template. */ - readonly disable_everyone_group_access: boolean; + readonly disable_everyone_group_access?: boolean; readonly max_port_share_level?: WorkspaceAgentPortShareLevel; readonly cors_behavior?: CORSBehavior; /**