diff --git a/cli/create.go b/cli/create.go index 7fe25754847c3..602b7b40a45bc 100644 --- a/cli/create.go +++ b/cli/create.go @@ -149,8 +149,6 @@ func (r *RootCmd) create() *clibase.Cmd { var ttlMillis *int64 if stopAfter > 0 { ttlMillis = ptr.Ref(stopAfter.Milliseconds()) - } else if template.MaxTTLMillis > 0 { - ttlMillis = &template.MaxTTLMillis } workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, workspaceOwner, codersdk.CreateWorkspaceRequest{ diff --git a/cli/schedule_test.go b/cli/schedule_test.go index a3a3a781ff578..d1e6fe2da543f 100644 --- a/cli/schedule_test.go +++ b/cli/schedule_test.go @@ -316,7 +316,8 @@ func TestScheduleOverride(t *testing.T) { stdoutBuf = &bytes.Buffer{} ) require.Zero(t, template.DefaultTTLMillis) - require.Zero(t, template.MaxTTLMillis) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) // Unset the workspace TTL err = client.UpdateWorkspaceTTL(ctx, workspace.ID, codersdk.UpdateWorkspaceTTLRequest{TTLMillis: nil}) diff --git a/cli/server.go b/cli/server.go index ec9c049ecefaa..e0668868023a8 100644 --- a/cli/server.go +++ b/cli/server.go @@ -496,6 +496,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. FilesRateLimit: filesRateLimit, HTTPClient: httpClient, TemplateScheduleStore: &atomic.Pointer[schedule.TemplateScheduleStore]{}, + UserQuietHoursScheduleStore: &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{}, SSHConfig: codersdk.SSHConfigResponse{ HostnamePrefix: cfg.SSHConfig.DeploymentName.String(), SSHConfigOptions: configSSHOptions, diff --git a/cli/templateedit.go b/cli/templateedit.go index 53818e86b33b5..6c8173c452817 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -3,6 +3,7 @@ package cli import ( "fmt" "net/http" + "strings" "time" "golang.org/x/xerrors" @@ -20,6 +21,8 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { icon string defaultTTL time.Duration maxTTL time.Duration + restartRequirementDaysOfWeek []string + restartRequirementWeeks int64 failureTTL time.Duration inactivityTTL time.Duration allowUserCancelWorkspaceJobs bool @@ -48,7 +51,15 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { } } - if maxTTL != 0 || !allowUserAutostart || !allowUserAutostop || failureTTL != 0 || inactivityTTL != 0 { + unsetRestartRequirementDaysOfWeek := len(restartRequirementDaysOfWeek) == 1 && restartRequirementDaysOfWeek[0] == "none" + requiresEntitlement := (len(restartRequirementDaysOfWeek) > 0 && !unsetRestartRequirementDaysOfWeek) || + restartRequirementWeeks > 0 || + !allowUserAutostart || + !allowUserAutostop || + maxTTL != 0 || + failureTTL != 0 || + inactivityTTL != 0 + if requiresEntitlement { entitlements, err := client.Entitlements(inv.Context()) var sdkErr *codersdk.Error if xerrors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound { @@ -71,14 +82,27 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { return xerrors.Errorf("get workspace template: %w", err) } + // Copy the default value if the list is empty, or if the user + // specified the "none" value clear the list. + if len(restartRequirementDaysOfWeek) == 0 { + restartRequirementDaysOfWeek = template.RestartRequirement.DaysOfWeek + } + if unsetRestartRequirementDaysOfWeek { + restartRequirementDaysOfWeek = []string{} + } + // NOTE: coderd will ignore empty fields. req := codersdk.UpdateTemplateMeta{ - Name: name, - DisplayName: displayName, - Description: description, - Icon: icon, - DefaultTTLMillis: defaultTTL.Milliseconds(), - MaxTTLMillis: maxTTL.Milliseconds(), + Name: name, + DisplayName: displayName, + Description: description, + Icon: icon, + DefaultTTLMillis: defaultTTL.Milliseconds(), + MaxTTLMillis: maxTTL.Milliseconds(), + RestartRequirement: &codersdk.TemplateRestartRequirement{ + DaysOfWeek: restartRequirementDaysOfWeek, + Weeks: restartRequirementWeeks, + }, FailureTTLMillis: failureTTL.Milliseconds(), InactivityTTLMillis: inactivityTTL.Milliseconds(), AllowUserCancelWorkspaceJobs: allowUserCancelWorkspaceJobs, @@ -126,6 +150,30 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Description: "Edit the template maximum time before shutdown - workspaces created from this template must shutdown within the given duration after starting. This is an enterprise-only feature.", Value: clibase.DurationOf(&maxTTL), }, + { + Flag: "restart-requirement-weekdays", + Description: "Edit the template restart requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset this value for the template (and disable the restart requirement for the template), pass 'none'.", + // TODO(@dean): unhide when we delete max_ttl + Hidden: true, + Value: clibase.Validate(clibase.StringArrayOf(&restartRequirementDaysOfWeek), func(value *clibase.StringArray) error { + v := value.GetSlice() + if len(v) == 1 && v[0] == "none" { + return nil + } + _, err := codersdk.WeekdaysToBitmap(v) + if err != nil { + return xerrors.Errorf("invalid restart requirement days of week %q: %w", strings.Join(v, ","), err) + } + return nil + }), + }, + { + Flag: "restart-requirement-weeks", + Description: "Edit the template restart requirement weeks - workspaces created from this template must be restarted on an n-weekly basis.", + // TODO(@dean): unhide when we delete max_ttl + Hidden: true, + Value: clibase.Int64Of(&restartRequirementWeeks), + }, { Flag: "failure-ttl", Description: "Specify a failure TTL for workspaces created from this template. This licensed feature's default is 0h (off).", diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 384fc30e7e1ae..0548877e925f9 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -242,6 +242,292 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, "", updated.Icon) assert.Equal(t, "", updated.DisplayName) }) + t.Run("RestartRequirement", func(t *testing.T) { + t.Parallel() + t.Run("BlockedAGPL", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.RestartRequirement = nil + }) + + cases := []struct { + name string + flags []string + ok bool + }{ + { + name: "Weekdays", + flags: []string{ + "--restart-requirement-weekdays", "monday", + }, + }, + { + name: "WeekdaysNoneAllowed", + flags: []string{ + "--restart-requirement-weekdays", "none", + }, + ok: true, + }, + { + name: "Weeks", + flags: []string{ + "--restart-requirement-weeks", "1", + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + cmdArgs := []string{ + "templates", + "edit", + template.Name, + } + cmdArgs = append(cmdArgs, c.flags...) + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, client, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err := inv.WithContext(ctx).Run() + if c.ok { + require.NoError(t, err) + } else { + require.Error(t, err) + require.ErrorContains(t, err, "appears to be an AGPL deployment") + } + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + 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.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + }) + } + }) + + t.Run("BlockedNotEntitled", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.RestartRequirement = nil + }) + + // Make a proxy server that will return a valid entitlements + // response, but without advanced scheduling entitlement. + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/entitlements" { + res := codersdk.Entitlements{ + Features: map[codersdk.FeatureName]codersdk.Feature{}, + Warnings: []string{}, + Errors: []string{}, + HasLicense: true, + Trial: true, + RequireTelemetry: false, + } + for _, feature := range codersdk.FeatureNames { + res.Features[feature] = codersdk.Feature{ + Entitlement: codersdk.EntitlementNotEntitled, + Enabled: false, + Limit: nil, + Actual: nil, + } + } + httpapi.Write(r.Context(), w, http.StatusOK, res) + return + } + + // Otherwise, proxy the request to the real API server. + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) + })) + t.Cleanup(proxy.Close) + + // Create a new client that uses the proxy server. + proxyURL, err := url.Parse(proxy.URL) + require.NoError(t, err) + proxyClient := codersdk.New(proxyURL) + proxyClient.SetSessionToken(client.SessionToken()) + + cases := []struct { + name string + flags []string + ok bool + }{ + { + name: "Weekdays", + flags: []string{ + "--restart-requirement-weekdays", "monday", + }, + }, + { + name: "WeekdaysNoneAllowed", + flags: []string{ + "--restart-requirement-weekdays", "none", + }, + ok: true, + }, + { + name: "Weeks", + flags: []string{ + "--restart-requirement-weeks", "1", + }, + }, + } + + for _, c := range cases { + c := c + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + cmdArgs := []string{ + "templates", + "edit", + template.Name, + } + cmdArgs = append(cmdArgs, c.flags...) + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err = inv.WithContext(ctx).Run() + if c.ok { + require.NoError(t, err) + } else { + require.Error(t, err) + require.ErrorContains(t, err, "license is not entitled") + } + + // Assert that the template metadata did not change. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + 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.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + }) + } + }) + t.Run("Entitled", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.DefaultTTLMillis = nil + ctr.RestartRequirement = nil + }) + + // Make a proxy server that will return a valid entitlements + // response, including a valid advanced scheduling entitlement. + var updateTemplateCalled int64 + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v2/entitlements" { + res := codersdk.Entitlements{ + Features: map[codersdk.FeatureName]codersdk.Feature{}, + Warnings: []string{}, + Errors: []string{}, + HasLicense: true, + Trial: true, + RequireTelemetry: false, + } + for _, feature := range codersdk.FeatureNames { + var one int64 = 1 + res.Features[feature] = codersdk.Feature{ + Entitlement: codersdk.EntitlementNotEntitled, + Enabled: true, + Limit: &one, + Actual: &one, + } + } + httpapi.Write(r.Context(), w, http.StatusOK, res) + return + } + if strings.HasPrefix(r.URL.Path, "/api/v2/templates/") { + body, err := io.ReadAll(r.Body) + require.NoError(t, err) + _ = r.Body.Close() + + var req codersdk.UpdateTemplateMeta + err = json.Unmarshal(body, &req) + require.NoError(t, err) + assert.Equal(t, req.RestartRequirement.DaysOfWeek, []string{"monday", "tuesday"}) + assert.EqualValues(t, req.RestartRequirement.Weeks, 3) + + r.Body = io.NopCloser(bytes.NewReader(body)) + atomic.AddInt64(&updateTemplateCalled, 1) + // We still want to call the real route. + } + + // Otherwise, proxy the request to the real API server. + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) + })) + defer proxy.Close() + + // Create a new client that uses the proxy server. + proxyURL, err := url.Parse(proxy.URL) + require.NoError(t, err) + proxyClient := codersdk.New(proxyURL) + proxyClient.SetSessionToken(client.SessionToken()) + + // Test the cli command. + cmdArgs := []string{ + "templates", + "edit", + template.Name, + "--restart-requirement-weekdays", "monday,tuesday", + "--restart-requirement-weeks", "3", + } + inv, root := clitest.New(t, cmdArgs...) + clitest.SetupConfig(t, proxyClient, root) + + ctx := testutil.Context(t, testutil.WaitLong) + err = inv.WithContext(ctx).Run() + require.NoError(t, err) + + require.EqualValues(t, 1, atomic.LoadInt64(&updateTemplateCalled)) + + // Assert that the template metadata did not change. We verify the + // correct request gets sent to the server already. + updated, err := client.Template(context.Background(), template.ID) + require.NoError(t, err) + 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.DisplayName, updated.DisplayName) + assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) + }) + }) + // TODO(@dean): remove this test when we remove max_ttl t.Run("MaxTTL", func(t *testing.T) { t.Parallel() t.Run("BlockedAGPL", func(t *testing.T) { @@ -317,7 +603,11 @@ func TestTemplateEdit(t *testing.T) { } // Otherwise, proxy the request to the real API server. - httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) })) defer proxy.Close() @@ -404,7 +694,11 @@ func TestTemplateEdit(t *testing.T) { } // Otherwise, proxy the request to the real API server. - httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) })) defer proxy.Close() @@ -452,7 +746,7 @@ func TestTemplateEdit(t *testing.T) { _ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID) template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.DefaultTTLMillis = nil - ctr.MaxTTLMillis = nil + ctr.RestartRequirement = nil ctr.FailureTTLMillis = nil ctr.InactivityTTLMillis = nil }) @@ -495,7 +789,8 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) @@ -535,7 +830,11 @@ func TestTemplateEdit(t *testing.T) { } // Otherwise, proxy the request to the real API server. - httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) })) defer proxy.Close() @@ -583,7 +882,8 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) @@ -639,7 +939,11 @@ func TestTemplateEdit(t *testing.T) { } // Otherwise, proxy the request to the real API server. - httputil.NewSingleHostReverseProxy(client.URL).ServeHTTP(w, r) + rp := httputil.NewSingleHostReverseProxy(client.URL) + rp.Transport = &http.Transport{ + DisableKeepAlives: true, + } + rp.ServeHTTP(w, r) })) defer proxy.Close() @@ -675,7 +979,8 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, template.Icon, updated.Icon) assert.Equal(t, template.DisplayName, updated.DisplayName) assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) - assert.Equal(t, template.MaxTTLMillis, updated.MaxTTLMillis) + assert.Equal(t, template.RestartRequirement.DaysOfWeek, updated.RestartRequirement.DaysOfWeek) + assert.Equal(t, template.RestartRequirement.Weeks, updated.RestartRequirement.Weeks) assert.Equal(t, template.AllowUserAutostart, updated.AllowUserAutostart) assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) assert.Equal(t, template.FailureTTLMillis, updated.FailureTTLMillis) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index 66bd33d96b5c9..c779bd399945a 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -377,6 +377,20 @@ telemetrywhen required by your organization's security policy. anonymized application tracing to help improve our product. Disabling telemetry also disables this option. +User Quiet Hours Schedule Options +Allow users to set quiet hours schedules each day for workspaces to avoid +workspaces stopping during the day due to template max TTL. + + --default-quiet-hours-schedule string, $CODER_QUIET_HOURS_DEFAULT_SCHEDULE + The default daily cron schedule applied to users that haven't set a + custom quiet hours schedule themselves. The quiet hours schedule + determines when workspaces will be force stopped due to the template's + max TTL, and will round the max TTL up to be within the user's quiet + hours window (or default). The format is the same as the standard cron + format, but the day-of-month, month and day-of-week must be *. Only + one hour and minute can be specified (ranges or comma separated values + are not supported). + ⚠️ Dangerous Options --dangerous-allow-path-app-sharing bool, $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING Allow workspace apps that are not served from subdomains to be shared. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index e629ee8312a90..4ecfe0f071b73 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -369,3 +369,15 @@ supportLinks: [] # "tunnel.example.com". # (default: , type: string) wgtunnelHost: "" +# Allow users to set quiet hours schedules each day for workspaces to avoid +# workspaces stopping during the day due to template max TTL. +userQuietHoursSchedule: + # The default daily cron schedule applied to users that haven't set a custom quiet + # hours schedule themselves. The quiet hours schedule determines when workspaces + # will be force stopped due to the template's max TTL, and will round the max TTL + # up to be within the user's quiet hours window (or default). The format is the + # same as the standard cron format, but the day-of-month, month and day-of-week + # must be *. Only one hour and minute can be specified (ranges or comma separated + # values are not supported). + # (default: , type: string) + defaultQuietHoursSchedule: "" diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go index 6e5615fef4d45..46cb261b3501b 100644 --- a/coderd/activitybump_test.go +++ b/coderd/activitybump_test.go @@ -12,6 +12,7 @@ import ( "github.com/coder/coder/agent" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbtestutil" "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" "github.com/coder/coder/codersdk/agentsdk" @@ -25,14 +26,20 @@ func TestWorkspaceActivityBump(t *testing.T) { ctx := context.Background() - setupActivityTest := func(t *testing.T, maxDeadline ...time.Duration) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) { + // deadline allows you to forcibly set a max_deadline on the build. This + // doesn't use template restart requirements and instead edits the + // max_deadline on the build directly in the database. + setupActivityTest := func(t *testing.T, deadline ...time.Duration) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) { const ttl = time.Minute maxTTL := time.Duration(0) - if len(maxDeadline) > 0 { - maxTTL = maxDeadline[0] + if len(deadline) > 0 { + maxTTL = deadline[0] } + db, pubsub := dbtestutil.NewDB(t) client = coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pubsub, IncludeProvisionerDaemon: true, // Agent stats trigger the activity bump, so we want to report // very frequently in tests. @@ -42,7 +49,8 @@ func TestWorkspaceActivityBump(t *testing.T) { return schedule.TemplateScheduleOptions{ UserAutostopEnabled: true, DefaultTTL: ttl, - MaxTTL: maxTTL, + // We set max_deadline manually below. + RestartRequirement: schedule.TemplateRestartRequirement{}, }, nil }, }, @@ -79,6 +87,21 @@ func TestWorkspaceActivityBump(t *testing.T) { }) coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID) + // Update the max deadline. + if maxTTL != 0 { + dbBuild, err := db.GetWorkspaceBuildByID(ctx, workspace.LatestBuild.ID) + require.NoError(t, err) + + _, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ + ID: workspace.LatestBuild.ID, + UpdatedAt: database.Now(), + ProvisionerState: dbBuild.ProvisionerState, + Deadline: dbBuild.Deadline, + MaxDeadline: database.Now().Add(maxTTL), + }) + require.NoError(t, err) + } + agentClient := agentsdk.New(client.URL) agentClient.SetSessionToken(agentToken) agentCloser := agent.New(agent.Options{ diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e4edd51bc9cb1..4fcc4c035949e 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3829,6 +3829,92 @@ const docTemplate = `{ } } }, + "/users/{user}/quiet-hours": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Get user quiet hours schedule", + "operationId": "get-user-quiet-hours-schedule", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "User ID", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse" + } + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "Enterprise" + ], + "summary": "Update user quiet hours schedule", + "operationId": "update-user-quiet-hours-schedule", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "User ID", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Update schedule request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserQuietHoursScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse" + } + } + } + } + } + }, "/users/{user}/roles": { "get": { "security": [ @@ -7002,13 +7088,21 @@ const docTemplate = `{ "type": "integer" }, "max_ttl_ms": { - "description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.", + "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" }, "name": { "description": "Name is the name of the template.", "type": "string" }, + "restart_requirement": { + "description": "RestartRequirement allows optionally specifying the restart requirement\nfor workspaces created from this template. This is an enterprise feature.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateRestartRequirement" + } + ] + }, "template_version_id": { "description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.", "type": "string", @@ -7611,6 +7705,9 @@ const docTemplate = `{ "update_check": { "type": "boolean" }, + "user_quiet_hours_schedule": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleConfig" + }, "verbose": { "type": "boolean" }, @@ -7678,7 +7775,8 @@ const docTemplate = `{ "tailnet_ha_coordinator", "convert-to-oidc", "single_tailnet", - "workspace_build_logs_ui" + "workspace_build_logs_ui", + "template_restart_requirement" ], "x-enum-varnames": [ "ExperimentMoons", @@ -7686,7 +7784,8 @@ const docTemplate = `{ "ExperimentTailnetHACoordinator", "ExperimentConvertToOIDC", "ExperimentSingleTailnet", - "ExperimentWorkspaceBuildLogsUI" + "ExperimentWorkspaceBuildLogsUI", + "ExperimentTemplateRestartRequirement" ] }, "codersdk.Feature": { @@ -8996,7 +9095,7 @@ const docTemplate = `{ "type": "integer" }, "max_ttl_ms": { - "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.", + "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" }, "name": { @@ -9012,6 +9111,14 @@ const docTemplate = `{ "terraform" ] }, + "restart_requirement": { + "description": "RestartRequirement is an enterprise feature. Its value is only used if\nyour license is entitled to use the advanced template scheduling feature.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateRestartRequirement" + } + ] + }, "updated_at": { "type": "string", "format": "date-time" @@ -9054,6 +9161,31 @@ const docTemplate = `{ } } }, + "codersdk.TemplateRestartRequirement": { + "type": "object", + "properties": { + "days_of_week": { + "description": "DaysOfWeek is a list of days of the week on which restarts are required.\nRestarts happen within the user's quiet hours (in their configured\ntimezone). If no days are specified, restarts are not required. Weekdays\ncannot be specified twice.\n\nRestarts will only happen on weekdays in this list on weeks which line up\nwith Weeks.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ] + } + }, + "weeks": { + "description": "Weeks is the number of weeks between required restarts. Weeks are synced\nacross all workspaces (and Coder deployments) using modulo math on a\nhardcoded epoch week of January 2nd, 2023 (the first Monday of 2023).\nValues of 0 or 1 indicate weekly restarts. Values of 2 indicate\nfortnightly restarts, etc.", + "type": "integer" + } + } + }, "codersdk.TemplateRole": { "type": "string", "enum": [ @@ -9467,6 +9599,18 @@ const docTemplate = `{ } } }, + "codersdk.UpdateUserQuietHoursScheduleRequest": { + "type": "object", + "required": [ + "schedule" + ], + "properties": { + "schedule": { + "description": "Schedule is a cron expression that defines when the user's quiet hours\nwindow is. Schedule must not be empty. For new users, the schedule is set\nto 2am in their browser or computer's timezone. The schedule denotes the\nbeginning of a 4 hour window where the workspace is allowed to\nautomatically stop or restart due to maintenance or template max TTL.\n\nThe schedule must be daily with a single time, and should have a timezone\nspecified via a CRON_TZ prefix (otherwise UTC will be used).\n\nIf the schedule is empty, the user will be updated to use the default\nschedule.", + "type": "string" + } + } + }, "codersdk.UpdateWorkspaceAutostartRequest": { "type": "object", "properties": { @@ -9574,6 +9718,39 @@ const docTemplate = `{ } } }, + "codersdk.UserQuietHoursScheduleConfig": { + "type": "object", + "properties": { + "default_schedule": { + "type": "string" + } + } + }, + "codersdk.UserQuietHoursScheduleResponse": { + "type": "object", + "properties": { + "next": { + "description": "Next is the next time that the quiet hours window will start.", + "type": "string", + "format": "date-time" + }, + "raw_schedule": { + "type": "string" + }, + "time": { + "description": "Time is the time of day that the quiet hours window starts in the given\nTimezone each day.", + "type": "string" + }, + "timezone": { + "description": "raw format from the cron expression, UTC if unspecified", + "type": "string" + }, + "user_set": { + "description": "UserSet is true if the user has set their own quiet hours schedule. If\nfalse, the user is using the default schedule.", + "type": "boolean" + } + } + }, "codersdk.UserStatus": { "type": "string", "enum": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 283c2290c8437..2b0140f1004fe 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -3373,6 +3373,82 @@ } } }, + "/users/{user}/quiet-hours": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Get user quiet hours schedule", + "operationId": "get-user-quiet-hours-schedule", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "User ID", + "name": "user", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse" + } + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["Enterprise"], + "summary": "Update user quiet hours schedule", + "operationId": "update-user-quiet-hours-schedule", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "User ID", + "name": "user", + "in": "path", + "required": true + }, + { + "description": "Update schedule request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.UpdateUserQuietHoursScheduleRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleResponse" + } + } + } + } + } + }, "/users/{user}/roles": { "get": { "security": [ @@ -6239,13 +6315,21 @@ "type": "integer" }, "max_ttl_ms": { - "description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.", + "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" }, "name": { "description": "Name is the name of the template.", "type": "string" }, + "restart_requirement": { + "description": "RestartRequirement allows optionally specifying the restart requirement\nfor workspaces created from this template. This is an enterprise feature.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateRestartRequirement" + } + ] + }, "template_version_id": { "description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.", "type": "string", @@ -6810,6 +6894,9 @@ "update_check": { "type": "boolean" }, + "user_quiet_hours_schedule": { + "$ref": "#/definitions/codersdk.UserQuietHoursScheduleConfig" + }, "verbose": { "type": "boolean" }, @@ -6873,7 +6960,8 @@ "tailnet_ha_coordinator", "convert-to-oidc", "single_tailnet", - "workspace_build_logs_ui" + "workspace_build_logs_ui", + "template_restart_requirement" ], "x-enum-varnames": [ "ExperimentMoons", @@ -6881,7 +6969,8 @@ "ExperimentTailnetHACoordinator", "ExperimentConvertToOIDC", "ExperimentSingleTailnet", - "ExperimentWorkspaceBuildLogsUI" + "ExperimentWorkspaceBuildLogsUI", + "ExperimentTemplateRestartRequirement" ] }, "codersdk.Feature": { @@ -8123,7 +8212,7 @@ "type": "integer" }, "max_ttl_ms": { - "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.", + "description": "TODO(@dean): remove max_ttl once restart_requirement is matured", "type": "integer" }, "name": { @@ -8137,6 +8226,14 @@ "type": "string", "enum": ["terraform"] }, + "restart_requirement": { + "description": "RestartRequirement is an enterprise feature. Its value is only used if\nyour license is entitled to use the advanced template scheduling feature.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.TemplateRestartRequirement" + } + ] + }, "updated_at": { "type": "string", "format": "date-time" @@ -8179,6 +8276,31 @@ } } }, + "codersdk.TemplateRestartRequirement": { + "type": "object", + "properties": { + "days_of_week": { + "description": "DaysOfWeek is a list of days of the week on which restarts are required.\nRestarts happen within the user's quiet hours (in their configured\ntimezone). If no days are specified, restarts are not required. Weekdays\ncannot be specified twice.\n\nRestarts will only happen on weekdays in this list on weeks which line up\nwith Weeks.", + "type": "array", + "items": { + "type": "string", + "enum": [ + "monday", + "tuesday", + "wednesday", + "thursday", + "friday", + "saturday", + "sunday" + ] + } + }, + "weeks": { + "description": "Weeks is the number of weeks between required restarts. Weeks are synced\nacross all workspaces (and Coder deployments) using modulo math on a\nhardcoded epoch week of January 2nd, 2023 (the first Monday of 2023).\nValues of 0 or 1 indicate weekly restarts. Values of 2 indicate\nfortnightly restarts, etc.", + "type": "integer" + } + } + }, "codersdk.TemplateRole": { "type": "string", "enum": ["admin", "use", ""], @@ -8553,6 +8675,16 @@ } } }, + "codersdk.UpdateUserQuietHoursScheduleRequest": { + "type": "object", + "required": ["schedule"], + "properties": { + "schedule": { + "description": "Schedule is a cron expression that defines when the user's quiet hours\nwindow is. Schedule must not be empty. For new users, the schedule is set\nto 2am in their browser or computer's timezone. The schedule denotes the\nbeginning of a 4 hour window where the workspace is allowed to\nautomatically stop or restart due to maintenance or template max TTL.\n\nThe schedule must be daily with a single time, and should have a timezone\nspecified via a CRON_TZ prefix (otherwise UTC will be used).\n\nIf the schedule is empty, the user will be updated to use the default\nschedule.", + "type": "string" + } + } + }, "codersdk.UpdateWorkspaceAutostartRequest": { "type": "object", "properties": { @@ -8652,6 +8784,39 @@ } } }, + "codersdk.UserQuietHoursScheduleConfig": { + "type": "object", + "properties": { + "default_schedule": { + "type": "string" + } + } + }, + "codersdk.UserQuietHoursScheduleResponse": { + "type": "object", + "properties": { + "next": { + "description": "Next is the next time that the quiet hours window will start.", + "type": "string", + "format": "date-time" + }, + "raw_schedule": { + "type": "string" + }, + "time": { + "description": "Time is the time of day that the quiet hours window starts in the given\nTimezone each day.", + "type": "string" + }, + "timezone": { + "description": "raw format from the cron expression, UTC if unspecified", + "type": "string" + }, + "user_set": { + "description": "UserSet is true if the user has set their own quiet hours schedule. If\nfalse, the user is using the default schedule.", + "type": "boolean" + } + } + }, "codersdk.UserStatus": { "type": "string", "enum": ["active", "suspended"], diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index d3dc80814b2b8..48942ede42aa3 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -142,7 +142,7 @@ func (e *Executor) runOnce(t time.Time) Stats { log.Warn(e.ctx, "get latest workspace build", slog.Error(err)) return nil } - templateSchedule, err := (*(e.templateScheduleStore.Load())).GetTemplateScheduleOptions(e.ctx, tx, ws.TemplateID) + templateSchedule, err := (*(e.templateScheduleStore.Load())).Get(e.ctx, tx, ws.TemplateID) if err != nil { log.Warn(e.ctx, "get template schedule options", slog.Error(err)) return nil diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 548fffb937e29..7803c9fcd41da 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -623,7 +623,7 @@ func TestExecutorAutostartTemplateDisabled(t *testing.T) { UserAutostartEnabled: false, UserAutostopEnabled: true, DefaultTTL: 0, - MaxTTL: 0, + RestartRequirement: schedule.TemplateRestartRequirement{}, }, nil }, }, diff --git a/coderd/coderd.go b/coderd/coderd.go index 23a4a46ea2123..5ac7881ccdeb4 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -118,13 +118,14 @@ type Options struct { RealIPConfig *httpmw.RealIPConfig TrialGenerator func(ctx context.Context, email string) error // TLSCertificates is used to mesh DERP servers securely. - TLSCertificates []tls.Certificate - TailnetCoordinator tailnet.Coordinator - DERPServer *derp.Server - DERPMap *tailcfg.DERPMap - SwaggerEndpoint bool - SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error - TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + TLSCertificates []tls.Certificate + TailnetCoordinator tailnet.Coordinator + DERPServer *derp.Server + DERPMap *tailcfg.DERPMap + SwaggerEndpoint bool + SetUserGroups func(ctx context.Context, tx database.Store, userID uuid.UUID, groupNames []string) error + TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] // AppSecurityKey is the crypto key used to sign and encrypt tokens related to // workspace applications. It consists of both a signing and encryption key. AppSecurityKey workspaceapps.SecurityKey @@ -264,6 +265,13 @@ func New(options *Options) *API { v := schedule.NewAGPLTemplateScheduleStore() options.TemplateScheduleStore.Store(&v) } + if options.UserQuietHoursScheduleStore == nil { + options.UserQuietHoursScheduleStore = &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{} + } + if options.UserQuietHoursScheduleStore.Load() == nil { + v := schedule.NewAGPLUserQuietHoursScheduleStore() + options.UserQuietHoursScheduleStore.Store(&v) + } if options.HealthcheckFunc == nil { options.HealthcheckFunc = func(ctx context.Context, apiKey string) *healthcheck.Report { return healthcheck.Run(ctx, &healthcheck.ReportOptions{ @@ -345,11 +353,12 @@ func New(options *Options) *API { options.AgentInactiveDisconnectTimeout, options.AppSecurityKey, ), - metricsCache: metricsCache, - Auditor: atomic.Pointer[audit.Auditor]{}, - TemplateScheduleStore: options.TemplateScheduleStore, - Experiments: experiments, - healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{}, + metricsCache: metricsCache, + Auditor: atomic.Pointer[audit.Auditor]{}, + TemplateScheduleStore: options.TemplateScheduleStore, + UserQuietHoursScheduleStore: options.UserQuietHoursScheduleStore, + Experiments: experiments, + healthCheckGroup: &singleflight.Group[string, *healthcheck.Report]{}, } if options.UpdateCheckOptions != nil { api.updateChecker = updatecheck.New( @@ -930,6 +939,9 @@ type API struct { // TemplateScheduleStore is a pointer to an atomic pointer because this is // passed to another struct, and we want them all to be the same reference. TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + // UserQuietHoursScheduleStore is a pointer to an atomic pointer for the + // same reason as TemplateScheduleStore. + UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] HTTPAuth *HTTPAuthorizer @@ -1040,22 +1052,23 @@ func (api *API) CreateInMemoryProvisionerDaemon(ctx context.Context, debounce ti mux := drpcmux.New() err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{ - AccessURL: api.AccessURL, - ID: daemon.ID, - OIDCConfig: api.OIDCConfig, - Database: api.Database, - Pubsub: api.Pubsub, - Provisioners: daemon.Provisioners, - GitAuthConfigs: api.GitAuthConfigs, - Telemetry: api.Telemetry, - Tracer: tracer, - Tags: tags, - QuotaCommitter: &api.QuotaCommitter, - Auditor: &api.Auditor, - TemplateScheduleStore: api.TemplateScheduleStore, - AcquireJobDebounce: debounce, - Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), - DeploymentValues: api.DeploymentValues, + AccessURL: api.AccessURL, + ID: daemon.ID, + OIDCConfig: api.OIDCConfig, + Database: api.Database, + Pubsub: api.Pubsub, + Provisioners: daemon.Provisioners, + GitAuthConfigs: api.GitAuthConfigs, + Telemetry: api.Telemetry, + Tracer: tracer, + Tags: tags, + QuotaCommitter: &api.QuotaCommitter, + Auditor: &api.Auditor, + TemplateScheduleStore: api.TemplateScheduleStore, + UserQuietHoursScheduleStore: api.UserQuietHoursScheduleStore, + AcquireJobDebounce: debounce, + Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), + DeploymentValues: api.DeploymentValues, }) if err != nil { return nil, err diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 3cc557331d4a5..57bcd12c96354 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -2310,6 +2310,13 @@ func (q *querier) UpdateUserProfile(ctx context.Context, arg database.UpdateUser return q.db.UpdateUserProfile(ctx, arg) } +func (q *querier) UpdateUserQuietHoursSchedule(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { + fetch := func(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { + return q.db.GetUserByID(ctx, arg.ID) + } + return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateUserQuietHoursSchedule)(ctx, arg) +} + // UpdateUserRoles updates the site roles of a user. The validation for this function include more than // just a basic RBAC check. func (q *querier) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRolesParams) (database.User, error) { @@ -2509,13 +2516,6 @@ func (q *querier) UpdateWorkspaceTTL(ctx context.Context, arg database.UpdateWor return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceTTL)(ctx, arg) } -func (q *querier) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { - fetch := func(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) (database.Template, error) { - return q.db.GetTemplateByID(ctx, arg.TemplateID) - } - return fetchAndExec(q.log, q.auth, rbac.ActionUpdate, fetch, q.db.UpdateWorkspaceTTLToBeWithinTemplateMax)(ctx, arg) -} - func (q *querier) UpsertAppSecurityKey(ctx context.Context, data string) error { // No authz checks as this is done during startup return q.db.UpsertAppSecurityKey(ctx, data) diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 3813e535a8e57..b1ae1e05b4cb4 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -4296,6 +4296,8 @@ func (q *FakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database tpl.UpdatedAt = database.Now() tpl.DefaultTTL = arg.DefaultTTL tpl.MaxTTL = arg.MaxTTL + tpl.RestartRequirementDaysOfWeek = arg.RestartRequirementDaysOfWeek + tpl.RestartRequirementWeeks = arg.RestartRequirementWeeks tpl.FailureTTL = arg.FailureTTL tpl.InactivityTTL = arg.InactivityTTL tpl.LockedTTL = arg.LockedTTL @@ -4524,6 +4526,25 @@ func (q *FakeQuerier) UpdateUserProfile(_ context.Context, arg database.UpdateUs return database.User{}, sql.ErrNoRows } +func (q *FakeQuerier) UpdateUserQuietHoursSchedule(_ context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { + if err := validateDatabaseType(arg); err != nil { + return database.User{}, err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for index, user := range q.users { + if user.ID != arg.ID { + continue + } + user.QuietHoursSchedule = arg.QuietHoursSchedule + q.users[index] = user + return user, nil + } + return database.User{}, sql.ErrNoRows +} + func (q *FakeQuerier) UpdateUserRoles(_ context.Context, arg database.UpdateUserRolesParams) (database.User, error) { if err := validateDatabaseType(arg); err != nil { return database.User{}, err @@ -4911,26 +4932,6 @@ func (q *FakeQuerier) UpdateWorkspaceTTL(_ context.Context, arg database.UpdateW return sql.ErrNoRows } -func (q *FakeQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(_ context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { - if err := validateDatabaseType(arg); err != nil { - return err - } - - q.mutex.Lock() - defer q.mutex.Unlock() - - for index, workspace := range q.workspaces { - if workspace.TemplateID != arg.TemplateID || !workspace.Ttl.Valid || workspace.Ttl.Int64 < arg.TemplateMaxTTL { - continue - } - - workspace.Ttl = sql.NullInt64{Int64: arg.TemplateMaxTTL, Valid: true} - q.workspaces[index] = workspace - } - - return nil -} - func (q *FakeQuerier) UpsertAppSecurityKey(_ context.Context, data string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 934f27ebcbbb6..e202f7bbc2992 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -1411,6 +1411,13 @@ func (m metricsStore) UpdateUserProfile(ctx context.Context, arg database.Update return user, err } +func (m metricsStore) UpdateUserQuietHoursSchedule(ctx context.Context, arg database.UpdateUserQuietHoursScheduleParams) (database.User, error) { + start := time.Now() + r0, r1 := m.s.UpdateUserQuietHoursSchedule(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateUserQuietHoursSchedule").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) UpdateUserRoles(ctx context.Context, arg database.UpdateUserRolesParams) (database.User, error) { start := time.Now() user, err := m.s.UpdateUserRoles(ctx, arg) @@ -1537,13 +1544,6 @@ func (m metricsStore) UpdateWorkspaceTTL(ctx context.Context, arg database.Updat return r0 } -func (m metricsStore) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { - start := time.Now() - r0 := m.s.UpdateWorkspaceTTLToBeWithinTemplateMax(ctx, arg) - m.queryLatencies.WithLabelValues("UpdateWorkspaceTTLToBeWithinTemplateMax").Observe(time.Since(start).Seconds()) - return r0 -} - func (m metricsStore) UpsertAppSecurityKey(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertAppSecurityKey(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index fde51b9fead0d..2a63a4c83f2c4 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -2975,6 +2975,21 @@ func (mr *MockStoreMockRecorder) UpdateUserProfile(arg0, arg1 interface{}) *gomo return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserProfile", reflect.TypeOf((*MockStore)(nil).UpdateUserProfile), arg0, arg1) } +// UpdateUserQuietHoursSchedule mocks base method. +func (m *MockStore) UpdateUserQuietHoursSchedule(arg0 context.Context, arg1 database.UpdateUserQuietHoursScheduleParams) (database.User, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateUserQuietHoursSchedule", arg0, arg1) + ret0, _ := ret[0].(database.User) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// UpdateUserQuietHoursSchedule indicates an expected call of UpdateUserQuietHoursSchedule. +func (mr *MockStoreMockRecorder) UpdateUserQuietHoursSchedule(arg0, arg1 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateUserQuietHoursSchedule", reflect.TypeOf((*MockStore)(nil).UpdateUserQuietHoursSchedule), arg0, arg1) +} + // UpdateUserRoles mocks base method. func (m *MockStore) UpdateUserRoles(arg0 context.Context, arg1 database.UpdateUserRolesParams) (database.User, error) { m.ctrl.T.Helper() @@ -3233,20 +3248,6 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceTTL(arg0, arg1 interface{}) *gom return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTL", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTL), arg0, arg1) } -// UpdateWorkspaceTTLToBeWithinTemplateMax mocks base method. -func (m *MockStore) UpdateWorkspaceTTLToBeWithinTemplateMax(arg0 context.Context, arg1 database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "UpdateWorkspaceTTLToBeWithinTemplateMax", arg0, arg1) - ret0, _ := ret[0].(error) - return ret0 -} - -// UpdateWorkspaceTTLToBeWithinTemplateMax indicates an expected call of UpdateWorkspaceTTLToBeWithinTemplateMax. -func (mr *MockStoreMockRecorder) UpdateWorkspaceTTLToBeWithinTemplateMax(arg0, arg1 interface{}) *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceTTLToBeWithinTemplateMax", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceTTLToBeWithinTemplateMax), arg0, arg1) -} - // UpsertAppSecurityKey mocks base method. func (m *MockStore) UpsertAppSecurityKey(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 11d4bcb0caa8d..f486d162e15c4 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -566,7 +566,9 @@ CREATE TABLE templates ( allow_user_autostop boolean DEFAULT true NOT NULL, failure_ttl bigint DEFAULT 0 NOT NULL, inactivity_ttl bigint DEFAULT 0 NOT NULL, - locked_ttl bigint DEFAULT 0 NOT NULL + locked_ttl bigint DEFAULT 0 NOT NULL, + restart_requirement_days_of_week smallint DEFAULT 0 NOT NULL, + restart_requirement_weeks bigint DEFAULT 0 NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; @@ -579,6 +581,10 @@ COMMENT ON COLUMN templates.allow_user_autostart IS 'Allow users to specify an a COMMENT ON COLUMN templates.allow_user_autostop IS 'Allow users to specify custom autostop values for workspaces (enterprise).'; +COMMENT ON COLUMN templates.restart_requirement_days_of_week IS 'A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused.'; + +COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.'; + CREATE TABLE users ( id uuid NOT NULL, email text NOT NULL, @@ -591,9 +597,12 @@ CREATE TABLE users ( login_type login_type DEFAULT 'password'::login_type NOT NULL, avatar_url text, deleted boolean DEFAULT false NOT NULL, - last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL + last_seen_at timestamp without time zone DEFAULT '0001-01-01 00:00:00'::timestamp without time zone NOT NULL, + quiet_hours_schedule text DEFAULT ''::text NOT NULL ); +COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.'; + CREATE VIEW visible_users AS SELECT users.id, users.username, @@ -625,6 +634,8 @@ CREATE VIEW template_with_users AS templates.failure_ttl, templates.inactivity_ttl, templates.locked_ttl, + templates.restart_requirement_days_of_week, + templates.restart_requirement_weeks, COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url, COALESCE(visible_users.username, ''::text) AS created_by_username FROM (public.templates diff --git a/coderd/database/migrations/000139_template_restart_requirement.down.sql b/coderd/database/migrations/000139_template_restart_requirement.down.sql new file mode 100644 index 0000000000000..f882ada1fd1c1 --- /dev/null +++ b/coderd/database/migrations/000139_template_restart_requirement.down.sql @@ -0,0 +1,29 @@ +BEGIN; + +-- Delete the new version of the template_with_users view to remove the column +-- dependency. +DROP VIEW template_with_users; + +ALTER TABLE templates + DROP COLUMN restart_requirement_days_of_week, + DROP COLUMN restart_requirement_weeks; + +ALTER TABLE users DROP COLUMN quiet_hours_schedule; + +-- Restore the old version of the template_with_users view. +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/migrations/000139_template_restart_requirement.up.sql b/coderd/database/migrations/000139_template_restart_requirement.up.sql new file mode 100644 index 0000000000000..ec8f2f520aed5 --- /dev/null +++ b/coderd/database/migrations/000139_template_restart_requirement.up.sql @@ -0,0 +1,35 @@ +BEGIN; + +ALTER TABLE templates + -- The max_ttl column will be dropped eventually when the new "restart + -- requirement" feature flag is fully rolled out. + -- DROP COLUMN max_ttl, + ADD COLUMN restart_requirement_days_of_week smallint NOT NULL DEFAULT 0, + ADD COLUMN restart_requirement_weeks bigint NOT NULL DEFAULT 0; + +COMMENT ON COLUMN templates.restart_requirement_days_of_week IS 'A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused.'; +COMMENT ON COLUMN templates.restart_requirement_weeks IS 'The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles.'; + +ALTER TABLE users + ADD COLUMN quiet_hours_schedule text NOT NULL DEFAULT ''; + +COMMENT ON COLUMN users.quiet_hours_schedule IS 'Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user''s quiet hours. If empty, the default quiet hours on the instance is used instead.'; + +-- Update the template_with_users view by recreating it. +DROP VIEW template_with_users; +CREATE VIEW + template_with_users +AS + SELECT + templates.*, + coalesce(visible_users.avatar_url, '') AS created_by_avatar_url, + coalesce(visible_users.username, '') AS created_by_username + FROM + templates + LEFT JOIN + visible_users + ON + templates.created_by = visible_users.id; +COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.'; + +COMMIT; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index fd47cd8aaff18..0682b930baaf0 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -83,6 +83,8 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -304,6 +306,7 @@ func (q *sqlQuerier) GetAuthorizedUsers(ctx context.Context, arg GetUsersParams, &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, &i.Count, ); err != nil { return nil, err diff --git a/coderd/database/models.go b/coderd/database/models.go index 22704fd549fe8..2fb9fbb244abd 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1591,6 +1591,8 @@ type Template struct { FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` + RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` CreatedByAvatarURL string `db:"created_by_avatar_url" json:"created_by_avatar_url"` CreatedByUsername string `db:"created_by_username" json:"created_by_username"` } @@ -1623,6 +1625,10 @@ type TemplateTable struct { FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + // A bitmap of days of week to restart the workspace on, starting with Monday as the 0th bit, and Sunday as the 6th bit. The 7th bit is unused. + RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` + // The number of weeks between restarts. 0 or 1 weeks means "every week", 2 week means "every second week", etc. Weeks are counted from January 2, 2023, which is the first Monday of 2023. This is to ensure workspaces are started consistently for all customers on the same n-week cycles. + RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` } type TemplateVersion struct { @@ -1708,6 +1714,8 @@ type User struct { AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` Deleted bool `db:"deleted" json:"deleted"` LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` + // Daily (!) cron schedule (with optional CRON_TZ) signifying the start of the user's quiet hours. If empty, the default quiet hours on the instance is used instead. + QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` } type UserLink struct { diff --git a/coderd/database/models_test.go b/coderd/database/models_test.go index 54d11373f9253..0171433dae5aa 100644 --- a/coderd/database/models_test.go +++ b/coderd/database/models_test.go @@ -19,7 +19,7 @@ func TestViewSubsetTemplate(t *testing.T) { joinedFields := allFields(joined) if !assert.Subset(t, fieldNames(joinedFields), fieldNames(tableFields), "table is not subset") { t.Log("Some fields were added to the Template Table without updating the 'template_with_users' view.") - t.Log("See migration 000138_join_users_up.sql to create the view.") + t.Log("See migration 000138_join_users.up.sql to create the view.") } } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 31c5537cf862b..05b5a07acdf32 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -240,6 +240,7 @@ type sqlcQuerier interface { UpdateUserLinkedID(ctx context.Context, arg UpdateUserLinkedIDParams) (UserLink, error) UpdateUserLoginType(ctx context.Context, arg UpdateUserLoginTypeParams) (User, error) UpdateUserProfile(ctx context.Context, arg UpdateUserProfileParams) (User, error) + UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesParams) (User, error) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusParams) (User, error) UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (Workspace, error) @@ -259,7 +260,6 @@ type sqlcQuerier interface { UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error - UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error UpsertAppSecurityKey(ctx context.Context, value string) error // The default proxy is implied and not actually stored in the database. // So we need to store it's configuration here for display purposes. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b656ab3182c23..e5786aad31a63 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1066,7 +1066,7 @@ func (q *sqlQuerier) DeleteGroupMembersByOrgAndUser(ctx context.Context, arg Del const getGroupMembers = `-- name: GetGroupMembers :many SELECT - users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at + users.id, users.email, users.username, users.hashed_password, users.created_at, users.updated_at, users.status, users.rbac_roles, users.login_type, users.avatar_url, users.deleted, users.last_seen_at, users.quiet_hours_schedule FROM users JOIN @@ -1103,6 +1103,7 @@ func (q *sqlQuerier) GetGroupMembers(ctx context.Context, groupID uuid.UUID) ([] &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, ); err != nil { return nil, err } @@ -3637,7 +3638,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users WHERE @@ -3672,6 +3673,8 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -3680,7 +3683,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -3723,6 +3726,8 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, &i.CreatedByUsername, ) @@ -3730,7 +3735,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, created_by_avatar_url, created_by_username FROM template_with_users AS templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates ORDER BY (name, id) ASC ` @@ -3766,6 +3771,8 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -3784,7 +3791,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, created_by_avatar_url, created_by_username + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl, locked_ttl, restart_requirement_days_of_week, restart_requirement_weeks, created_by_avatar_url, created_by_username FROM template_with_users AS templates WHERE @@ -3857,6 +3864,8 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.FailureTTL, &i.InactivityTTL, &i.LockedTTL, + &i.RestartRequirementDaysOfWeek, + &i.RestartRequirementWeeks, &i.CreatedByAvatarURL, &i.CreatedByUsername, ); err != nil { @@ -4041,23 +4050,27 @@ SET allow_user_autostop = $4, default_ttl = $5, max_ttl = $6, - failure_ttl = $7, - inactivity_ttl = $8, - locked_ttl = $9 + restart_requirement_days_of_week = $7, + restart_requirement_weeks = $8, + failure_ttl = $9, + inactivity_ttl = $10, + locked_ttl = $11 WHERE id = $1 ` type UpdateTemplateScheduleByIDParams struct { - ID uuid.UUID `db:"id" json:"id"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` - AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` - DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` - MaxTTL int64 `db:"max_ttl" json:"max_ttl"` - FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` - InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` - LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` + ID uuid.UUID `db:"id" json:"id"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` + AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` + DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` + MaxTTL int64 `db:"max_ttl" json:"max_ttl"` + RestartRequirementDaysOfWeek int16 `db:"restart_requirement_days_of_week" json:"restart_requirement_days_of_week"` + RestartRequirementWeeks int64 `db:"restart_requirement_weeks" json:"restart_requirement_weeks"` + FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` + InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` + LockedTTL int64 `db:"locked_ttl" json:"locked_ttl"` } func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error { @@ -4068,6 +4081,8 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT arg.AllowUserAutostop, arg.DefaultTTL, arg.MaxTTL, + arg.RestartRequirementDaysOfWeek, + arg.RestartRequirementWeeks, arg.FailureTTL, arg.InactivityTTL, arg.LockedTTL, @@ -5012,7 +5027,7 @@ func (q *sqlQuerier) GetAuthorizationUserRoles(ctx context.Context, userID uuid. const getUserByEmailOrUsername = `-- name: GetUserByEmailOrUsername :one SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule FROM users WHERE @@ -5043,13 +5058,14 @@ func (q *sqlQuerier) GetUserByEmailOrUsername(ctx context.Context, arg GetUserBy &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, ) return i, err } const getUserByID = `-- name: GetUserByID :one SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule FROM users WHERE @@ -5074,6 +5090,7 @@ func (q *sqlQuerier) GetUserByID(ctx context.Context, id uuid.UUID) (User, error &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, ) return i, err } @@ -5096,7 +5113,7 @@ func (q *sqlQuerier) GetUserCount(ctx context.Context) (int64, error) { const getUsers = `-- name: GetUsers :many SELECT - id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, COUNT(*) OVER() AS count + id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule, COUNT(*) OVER() AS count FROM users WHERE @@ -5180,19 +5197,20 @@ type GetUsersParams struct { } type GetUsersRow struct { - ID uuid.UUID `db:"id" json:"id"` - Email string `db:"email" json:"email"` - Username string `db:"username" json:"username"` - HashedPassword []byte `db:"hashed_password" json:"hashed_password"` - CreatedAt time.Time `db:"created_at" json:"created_at"` - UpdatedAt time.Time `db:"updated_at" json:"updated_at"` - Status UserStatus `db:"status" json:"status"` - RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` - LoginType LoginType `db:"login_type" json:"login_type"` - AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` - Deleted bool `db:"deleted" json:"deleted"` - LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` - Count int64 `db:"count" json:"count"` + ID uuid.UUID `db:"id" json:"id"` + Email string `db:"email" json:"email"` + Username string `db:"username" json:"username"` + HashedPassword []byte `db:"hashed_password" json:"hashed_password"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + Status UserStatus `db:"status" json:"status"` + RBACRoles pq.StringArray `db:"rbac_roles" json:"rbac_roles"` + LoginType LoginType `db:"login_type" json:"login_type"` + AvatarURL sql.NullString `db:"avatar_url" json:"avatar_url"` + Deleted bool `db:"deleted" json:"deleted"` + LastSeenAt time.Time `db:"last_seen_at" json:"last_seen_at"` + QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` + Count int64 `db:"count" json:"count"` } // This will never return deleted users. @@ -5227,6 +5245,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, &i.Count, ); err != nil { return nil, err @@ -5243,7 +5262,7 @@ func (q *sqlQuerier) GetUsers(ctx context.Context, arg GetUsersParams) ([]GetUse } const getUsersByIDs = `-- name: GetUsersByIDs :many -SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at FROM users WHERE id = ANY($1 :: uuid [ ]) +SELECT id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule FROM users WHERE id = ANY($1 :: uuid [ ]) ` // This shouldn't check for deleted, because it's frequently used @@ -5271,6 +5290,7 @@ func (q *sqlQuerier) GetUsersByIDs(ctx context.Context, ids []uuid.UUID) ([]User &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, ); err != nil { return nil, err } @@ -5298,7 +5318,7 @@ INSERT INTO login_type ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` type InsertUserParams struct { @@ -5337,6 +5357,7 @@ func (q *sqlQuerier) InsertUser(ctx context.Context, arg InsertUserParams) (User &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, ) return i, err } @@ -5386,7 +5407,7 @@ SET last_seen_at = $2, updated_at = $3 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` type UpdateUserLastSeenAtParams struct { @@ -5411,6 +5432,7 @@ func (q *sqlQuerier) UpdateUserLastSeenAt(ctx context.Context, arg UpdateUserLas &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, ) return i, err } @@ -5428,7 +5450,7 @@ SET '':: bytea END WHERE - id = $2 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id = $2 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` type UpdateUserLoginTypeParams struct { @@ -5452,6 +5474,7 @@ func (q *sqlQuerier) UpdateUserLoginType(ctx context.Context, arg UpdateUserLogi &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, ) return i, err } @@ -5465,7 +5488,7 @@ SET avatar_url = $4, updated_at = $5 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` type UpdateUserProfileParams struct { @@ -5498,6 +5521,43 @@ func (q *sqlQuerier) UpdateUserProfile(ctx context.Context, arg UpdateUserProfil &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, + ) + return i, err +} + +const updateUserQuietHoursSchedule = `-- name: UpdateUserQuietHoursSchedule :one +UPDATE + users +SET + quiet_hours_schedule = $2 +WHERE + id = $1 +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule +` + +type UpdateUserQuietHoursScheduleParams struct { + ID uuid.UUID `db:"id" json:"id"` + QuietHoursSchedule string `db:"quiet_hours_schedule" json:"quiet_hours_schedule"` +} + +func (q *sqlQuerier) UpdateUserQuietHoursSchedule(ctx context.Context, arg UpdateUserQuietHoursScheduleParams) (User, error) { + row := q.db.QueryRowContext(ctx, updateUserQuietHoursSchedule, arg.ID, arg.QuietHoursSchedule) + var i User + err := row.Scan( + &i.ID, + &i.Email, + &i.Username, + &i.HashedPassword, + &i.CreatedAt, + &i.UpdatedAt, + &i.Status, + &i.RBACRoles, + &i.LoginType, + &i.AvatarURL, + &i.Deleted, + &i.LastSeenAt, + &i.QuietHoursSchedule, ) return i, err } @@ -5510,7 +5570,7 @@ SET rbac_roles = ARRAY(SELECT DISTINCT UNNEST($1 :: text[])) WHERE id = $2 -RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at +RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` type UpdateUserRolesParams struct { @@ -5534,6 +5594,7 @@ func (q *sqlQuerier) UpdateUserRoles(ctx context.Context, arg UpdateUserRolesPar &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, ) return i, err } @@ -5545,7 +5606,7 @@ SET status = $2, updated_at = $3 WHERE - id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at + id = $1 RETURNING id, email, username, hashed_password, created_at, updated_at, status, rbac_roles, login_type, avatar_url, deleted, last_seen_at, quiet_hours_schedule ` type UpdateUserStatusParams struct { @@ -5570,6 +5631,7 @@ func (q *sqlQuerier) UpdateUserStatus(ctx context.Context, arg UpdateUserStatusP &i.AvatarURL, &i.Deleted, &i.LastSeenAt, + &i.QuietHoursSchedule, ) return i, err } @@ -8844,28 +8906,3 @@ func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspace _, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl) return err } - -const updateWorkspaceTTLToBeWithinTemplateMax = `-- name: UpdateWorkspaceTTLToBeWithinTemplateMax :exec -UPDATE - workspaces -SET - ttl = LEAST(ttl, $1::bigint) -WHERE - template_id = $2 - -- LEAST() does not pick NULL, so filter it out as we don't want to set a - -- TTL on the workspace if it's unset. - -- - -- During build time, the template max TTL will still be used if the - -- workspace TTL is NULL. - AND ttl IS NOT NULL -` - -type UpdateWorkspaceTTLToBeWithinTemplateMaxParams struct { - TemplateMaxTTL int64 `db:"template_max_ttl" json:"template_max_ttl"` - TemplateID uuid.UUID `db:"template_id" json:"template_id"` -} - -func (q *sqlQuerier) UpdateWorkspaceTTLToBeWithinTemplateMax(ctx context.Context, arg UpdateWorkspaceTTLToBeWithinTemplateMaxParams) error { - _, err := q.db.ExecContext(ctx, updateWorkspaceTTLToBeWithinTemplateMax, arg.TemplateMaxTTL, arg.TemplateID) - return err -} diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 54ad458ce2f36..7f4c9ce5de4ab 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -118,9 +118,11 @@ SET allow_user_autostop = $4, default_ttl = $5, max_ttl = $6, - failure_ttl = $7, - inactivity_ttl = $8, - locked_ttl = $9 + restart_requirement_days_of_week = $7, + restart_requirement_weeks = $8, + failure_ttl = $9, + inactivity_ttl = $10, + locked_ttl = $11 WHERE id = $1 ; diff --git a/coderd/database/queries/users.sql b/coderd/database/queries/users.sql index cd5426e576ca5..f44994a3987cc 100644 --- a/coderd/database/queries/users.sql +++ b/coderd/database/queries/users.sql @@ -241,3 +241,12 @@ FROM users WHERE id = @user_id; + +-- name: UpdateUserQuietHoursSchedule :one +UPDATE + users +SET + quiet_hours_schedule = $2 +WHERE + id = $1 +RETURNING *; diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index ef3d84adebfec..2af26fd922402 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -347,20 +347,6 @@ SET WHERE id = $1; --- name: UpdateWorkspaceTTLToBeWithinTemplateMax :exec -UPDATE - workspaces -SET - ttl = LEAST(ttl, @template_max_ttl::bigint) -WHERE - template_id = @template_id - -- LEAST() does not pick NULL, so filter it out as we don't want to set a - -- TTL on the workspace if it's unset. - -- - -- During build time, the template max TTL will still be used if the - -- workspace TTL is NULL. - AND ttl IS NOT NULL; - -- name: GetDeploymentWorkspaceStats :one WITH workspaces_with_jobs AS ( SELECT diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index d47988b66303d..2f92e28e1a9fb 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -26,7 +26,6 @@ import ( protobuf "google.golang.org/protobuf/proto" "cdr.dev/slog" - "github.com/coder/coder/coderd/apikey" "github.com/coder/coder/coderd/audit" "github.com/coder/coder/coderd/database" @@ -50,23 +49,35 @@ var ( ) type Server struct { - AccessURL *url.URL - ID uuid.UUID - Logger slog.Logger - Provisioners []database.ProvisionerType - GitAuthConfigs []*gitauth.Config - Tags json.RawMessage - Database database.Store - Pubsub pubsub.Pubsub - Telemetry telemetry.Reporter - Tracer trace.Tracer - QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] - Auditor *atomic.Pointer[audit.Auditor] - TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] - DeploymentValues *codersdk.DeploymentValues + AccessURL *url.URL + ID uuid.UUID + Logger slog.Logger + Provisioners []database.ProvisionerType + GitAuthConfigs []*gitauth.Config + Tags json.RawMessage + Database database.Store + Pubsub pubsub.Pubsub + Telemetry telemetry.Reporter + Tracer trace.Tracer + QuotaCommitter *atomic.Pointer[proto.QuotaCommitter] + Auditor *atomic.Pointer[audit.Auditor] + TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] + UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] + DeploymentValues *codersdk.DeploymentValues AcquireJobDebounce time.Duration OIDCConfig httpmw.OAuth2Config + + TimeNowFn func() time.Time +} + +// timeNow should be used when trying to get the current time for math +// calculations regarding workspace start and stop time. +func (server *Server) timeNow() time.Time { + if server.TimeNowFn != nil { + return database.Time(server.TimeNowFn()) + } + return database.Now() } // AcquireJob queries the database to lock a job. @@ -101,7 +112,7 @@ func (server *Server) AcquireJob(ctx context.Context, _ *proto.Empty) (*proto.Ac // The provisioner daemon assumes no jobs are available if // an empty struct is returned. lastAcquireMutex.Lock() - lastAcquire = time.Now() + lastAcquire = database.Now() lastAcquireMutex.Unlock() return &proto.AcquiredJob{}, nil } @@ -894,15 +905,9 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete var getWorkspaceError error err = server.Database.InTx(func(db database.Store) error { - var ( - now = database.Now() - // deadline is the time when the workspace will be stopped. The - // value can be bumped by user activity or manually by the user - // via the UI. - deadline time.Time - // maxDeadline is the maximum value for deadline. - maxDeadline time.Time - ) + // It's important we use server.timeNow() here because we want to be + // able to customize the current time from within tests. + now := server.timeNow() workspace, getWorkspaceError = db.GetWorkspaceByID(ctx, workspaceBuild.WorkspaceID) if getWorkspaceError != nil { @@ -913,31 +918,16 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete ) return getWorkspaceError } - if workspace.Ttl.Valid { - deadline = now.Add(time.Duration(workspace.Ttl.Int64)) - } - templateSchedule, err := (*server.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, db, workspace.TemplateID) + autoStop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ + Database: db, + TemplateScheduleStore: *server.TemplateScheduleStore.Load(), + UserQuietHoursScheduleStore: *server.UserQuietHoursScheduleStore.Load(), + Now: now, + Workspace: workspace, + }) if err != nil { - return xerrors.Errorf("get template schedule options: %w", err) - } - if !templateSchedule.UserAutostopEnabled { - // The user is not permitted to set their own TTL, so use the - // template default. - deadline = time.Time{} - if templateSchedule.DefaultTTL > 0 { - deadline = now.Add(templateSchedule.DefaultTTL) - } - } - if templateSchedule.MaxTTL > 0 { - maxDeadline = now.Add(templateSchedule.MaxTTL) - - if deadline.IsZero() || maxDeadline.Before(deadline) { - // If the workspace doesn't have a deadline or the max - // deadline is sooner than the workspace deadline, use the - // max deadline as the actual deadline. - deadline = maxDeadline - } + return xerrors.Errorf("calculate auto stop: %w", err) } err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ @@ -953,8 +943,8 @@ func (server *Server) CompleteJob(ctx context.Context, completed *proto.Complete } _, err = db.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{ ID: workspaceBuild.ID, - Deadline: deadline, - MaxDeadline: maxDeadline, + Deadline: autoStop.Deadline, + MaxDeadline: autoStop.MaxDeadline, ProvisionerState: jobType.WorkspaceBuild.State, UpdatedAt: now, }) diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index b0183fd3b1604..8a1beddc0b1d4 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -11,6 +11,7 @@ import ( "time" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.opentelemetry.io/otel/trace" "golang.org/x/oauth2" @@ -47,6 +48,13 @@ func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore] return ptr } +func testUserQuietHoursScheduleStore() *atomic.Pointer[schedule.UserQuietHoursScheduleStore] { + ptr := &atomic.Pointer[schedule.UserQuietHoursScheduleStore]{} + store := schedule.NewAGPLUserQuietHoursScheduleStore() + ptr.Store(&store) + return ptr +} + func TestAcquireJob(t *testing.T) { t.Parallel() t.Run("Debounce", func(t *testing.T) { @@ -54,18 +62,19 @@ func TestAcquireJob(t *testing.T) { db := dbfake.New() ps := pubsub.NewInMemory() srv := &provisionerdserver.Server{ - ID: uuid.New(), - Logger: slogtest.Make(t, nil), - AccessURL: &url.URL{}, - Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, - Database: db, - Pubsub: ps, - Telemetry: telemetry.NewNoop(), - AcquireJobDebounce: time.Hour, - Auditor: mockAuditor(), - TemplateScheduleStore: testTemplateScheduleStore(), - Tracer: trace.NewNoopTracerProvider().Tracer("noop"), - DeploymentValues: &codersdk.DeploymentValues{}, + ID: uuid.New(), + Logger: slogtest.Make(t, nil), + AccessURL: &url.URL{}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Database: db, + Pubsub: ps, + Telemetry: telemetry.NewNoop(), + AcquireJobDebounce: time.Hour, + Auditor: mockAuditor(), + TemplateScheduleStore: testTemplateScheduleStore(), + UserQuietHoursScheduleStore: testUserQuietHoursScheduleStore(), + Tracer: trace.NewNoopTracerProvider().Tracer("noop"), + DeploymentValues: &codersdk.DeploymentValues{}, } job, err := srv.AcquireJob(context.Background(), nil) require.NoError(t, err) @@ -892,7 +901,8 @@ func TestCompleteJob(t *testing.T) { require.False(t, job.Error.Valid) }) - t.Run("WorkspaceBuild", func(t *testing.T) { + // TODO(@dean): remove this legacy test for MaxTTL + t.Run("WorkspaceBuildLegacy", func(t *testing.T) { t.Parallel() cases := []struct { @@ -1011,10 +1021,11 @@ func TestCompleteJob(t *testing.T) { var store schedule.TemplateScheduleStore = schedule.MockTemplateScheduleStore{ GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { return schedule.TemplateScheduleOptions{ - UserAutostartEnabled: false, - UserAutostopEnabled: c.templateAllowAutostop, - DefaultTTL: c.templateDefaultTTL, - MaxTTL: c.templateMaxTTL, + UserAutostartEnabled: false, + UserAutostopEnabled: c.templateAllowAutostop, + DefaultTTL: c.templateDefaultTTL, + MaxTTL: c.templateMaxTTL, + UseRestartRequirement: false, }, nil }, } @@ -1125,6 +1136,254 @@ func TestCompleteJob(t *testing.T) { }) } }) + + t.Run("WorkspaceBuild", func(t *testing.T) { + t.Parallel() + + now := time.Now() + + // NOTE: if you're looking for more in-depth deadline/max_deadline + // calculation testing, see the schedule package. The provsiionerdserver + // package calls `schedule.CalculateAutostop()` to generate the deadline + // and max_deadline. + + // Wednesday the 8th of February 2023 at midnight. This date was + // specifically chosen as it doesn't fall on a applicable week for both + // fortnightly and triweekly restart requirements. + wednesdayMidnightUTC := time.Date(2023, 2, 8, 0, 0, 0, 0, time.UTC) + + sydneyQuietHours := "CRON_TZ=Australia/Sydney 0 0 * * *" + sydneyLoc, err := time.LoadLocation("Australia/Sydney") + require.NoError(t, err) + // 12am on Saturday the 11th of February 2023 in Sydney. + saturdayMidnightSydney := time.Date(2023, 2, 11, 0, 0, 0, 0, sydneyLoc) + + t.Log("now", now) + t.Log("wednesdayMidnightUTC", wednesdayMidnightUTC) + t.Log("saturdayMidnightSydney", saturdayMidnightSydney) + + cases := []struct { + name string + now time.Time + workspaceTTL time.Duration + transition database.WorkspaceTransition + + // These fields are only used when testing max deadline. + userQuietHoursSchedule string + templateRestartRequirement schedule.TemplateRestartRequirement + + expectedDeadline time.Time + expectedMaxDeadline time.Time + }{ + { + name: "OK", + now: now, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, + }, + { + name: "Delete", + now: now, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + transition: database.WorkspaceTransitionDelete, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, + }, + { + name: "WorkspaceTTL", + now: now, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: time.Hour, + transition: database.WorkspaceTransitionStart, + expectedDeadline: now.Add(time.Hour), + expectedMaxDeadline: time.Time{}, + }, + { + name: "TemplateRestartRequirement", + now: wednesdayMidnightUTC, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + transition: database.WorkspaceTransitionStart, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + } + + for _, c := range cases { + c := c + + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + srv := setup(t, false) + + // Simulate the given time starting from now. + require.False(t, c.now.IsZero()) + start := time.Now() + srv.TimeNowFn = func() time.Time { + return c.now.Add(time.Since(start)) + } + + var templateScheduleStore schedule.TemplateScheduleStore = schedule.MockTemplateScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutostartEnabled: false, + UserAutostopEnabled: true, + DefaultTTL: 0, + UseRestartRequirement: true, + RestartRequirement: c.templateRestartRequirement, + }, nil + }, + } + srv.TemplateScheduleStore.Store(&templateScheduleStore) + + var userQuietHoursScheduleStore schedule.UserQuietHoursScheduleStore = schedule.MockUserQuietHoursScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.UserQuietHoursScheduleOptions, error) { + if c.userQuietHoursSchedule == "" { + return schedule.UserQuietHoursScheduleOptions{ + Schedule: nil, + }, nil + } + + sched, err := schedule.Daily(c.userQuietHoursSchedule) + if !assert.NoError(t, err) { + return schedule.UserQuietHoursScheduleOptions{}, err + } + + return schedule.UserQuietHoursScheduleOptions{ + Schedule: sched, + UserSet: false, + }, nil + }, + } + srv.UserQuietHoursScheduleStore.Store(&userQuietHoursScheduleStore) + + user := dbgen.User(t, srv.Database, database.User{ + QuietHoursSchedule: c.userQuietHoursSchedule, + }) + template := dbgen.Template(t, srv.Database, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + }) + err := srv.Database.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: false, + AllowUserAutostop: true, + DefaultTTL: 0, + RestartRequirementDaysOfWeek: int16(c.templateRestartRequirement.DaysOfWeek), + RestartRequirementWeeks: c.templateRestartRequirement.Weeks, + }) + require.NoError(t, err) + template, err = srv.Database.GetTemplateByID(ctx, template.ID) + require.NoError(t, err) + file := dbgen.File(t, srv.Database, database.File{CreatedBy: user.ID}) + workspaceTTL := sql.NullInt64{} + if c.workspaceTTL != 0 { + workspaceTTL = sql.NullInt64{ + Int64: int64(c.workspaceTTL), + Valid: true, + } + } + workspace := dbgen.Workspace(t, srv.Database, database.Workspace{ + TemplateID: template.ID, + Ttl: workspaceTTL, + OwnerID: user.ID, + }) + version := dbgen.TemplateVersion(t, srv.Database, database.TemplateVersion{ + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: uuid.New(), + }) + build := dbgen.WorkspaceBuild(t, srv.Database, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + Transition: c.transition, + Reason: database.BuildReasonInitiator, + }) + job := dbgen.ProvisionerJob(t, srv.Database, database.ProvisionerJob{ + FileID: file.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: build.ID, + })), + }) + _, err = srv.Database.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + WorkerID: uuid.NullUUID{ + UUID: srv.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + require.NoError(t, err) + + publishedWorkspace := make(chan struct{}) + closeWorkspaceSubscribe, err := srv.Pubsub.Subscribe(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), func(_ context.Context, _ []byte) { + close(publishedWorkspace) + }) + require.NoError(t, err) + defer closeWorkspaceSubscribe() + publishedLogs := make(chan struct{}) + closeLogsSubscribe, err := srv.Pubsub.Subscribe(provisionersdk.ProvisionerJobLogsNotifyChannel(job.ID), func(_ context.Context, _ []byte) { + close(publishedLogs) + }) + require.NoError(t, err) + defer closeLogsSubscribe() + + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ + State: []byte{}, + Resources: []*sdkproto.Resource{{ + Name: "example", + Type: "aws_instance", + }}, + }, + }, + }) + require.NoError(t, err) + + <-publishedWorkspace + <-publishedLogs + + workspace, err = srv.Database.GetWorkspaceByID(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, c.transition == database.WorkspaceTransitionDelete, workspace.Deleted) + + workspaceBuild, err := srv.Database.GetWorkspaceBuildByID(ctx, build.ID) + require.NoError(t, err) + + // If the max deadline is set, the deadline should also be set. + // Default to the max deadline if the deadline is not set. + if c.expectedDeadline.IsZero() { + c.expectedDeadline = c.expectedMaxDeadline + } + + if c.expectedDeadline.IsZero() { + require.True(t, workspaceBuild.Deadline.IsZero()) + } else { + require.WithinDuration(t, c.expectedDeadline, workspaceBuild.Deadline, 15*time.Second, "deadline does not match expected") + } + if c.expectedMaxDeadline.IsZero() { + require.True(t, workspaceBuild.MaxDeadline.IsZero()) + } else { + require.WithinDuration(t, c.expectedMaxDeadline, workspaceBuild.MaxDeadline, 15*time.Second, "max deadline does not match expected") + require.GreaterOrEqual(t, workspaceBuild.MaxDeadline.Unix(), workspaceBuild.Deadline.Unix(), "max deadline is smaller than deadline") + } + }) + } + }) t.Run("TemplateDryRun", func(t *testing.T) { t.Parallel() srv := setup(t, false) @@ -1260,18 +1519,23 @@ func setup(t *testing.T, ignoreLogErrors bool) *provisionerdserver.Server { ps := pubsub.NewInMemory() return &provisionerdserver.Server{ - ID: uuid.New(), - Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), - OIDCConfig: &oauth2.Config{}, - AccessURL: &url.URL{}, - Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, - Database: db, - Pubsub: ps, - Telemetry: telemetry.NewNoop(), - Auditor: mockAuditor(), - TemplateScheduleStore: testTemplateScheduleStore(), - Tracer: trace.NewNoopTracerProvider().Tracer("noop"), - DeploymentValues: &codersdk.DeploymentValues{}, + ID: uuid.New(), + Logger: slogtest.Make(t, &slogtest.Options{IgnoreErrors: ignoreLogErrors}), + OIDCConfig: &oauth2.Config{}, + AccessURL: &url.URL{}, + Provisioners: []database.ProvisionerType{database.ProvisionerTypeEcho}, + Database: db, + Pubsub: ps, + Telemetry: telemetry.NewNoop(), + Auditor: mockAuditor(), + TemplateScheduleStore: testTemplateScheduleStore(), + UserQuietHoursScheduleStore: testUserQuietHoursScheduleStore(), + Tracer: trace.NewNoopTracerProvider().Tracer("noop"), + DeploymentValues: &codersdk.DeploymentValues{}, + + // Negative values cause the debounce to never kick in. Tests that want + // to test debounce can override this value. + AcquireJobDebounce: -time.Minute, } } diff --git a/coderd/schedule/autostop.go b/coderd/schedule/autostop.go new file mode 100644 index 0000000000000..6934640045506 --- /dev/null +++ b/coderd/schedule/autostop.go @@ -0,0 +1,332 @@ +package schedule + +import ( + "context" + "time" + + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" +) + +const ( + // restartRequirementLeeway is the duration of time before a restart + // requirement where we skip the requirement and fall back to the next + // scheduled restart. This avoids workspaces being restarted too soon. + // + // E.g. If the workspace is started within an hour of the quiet hours, we + // will skip the restart requirement and use the next scheduled restart + // requirement. + restartRequirementLeeway = 1 * time.Hour + + // restartRequirementBuffer is the duration of time we subtract from the + // time when calculating the next scheduled restart time. This avoids issues + // where autostart happens on the hour and the scheduled quiet hours are + // also on the hour. + // + // E.g. If the workspace is started at 12am (perhaps due to scheduled + // autostart) and the quiet hours is also 12am, the workspace will skip + // the day it's supposed to stop and use the next day instead. This is + // because getting the next cron schedule time will never include the + // time fed to the calculation (i.e. it's not inclusive). This happens + // because we always check for the next cron time by rounding down to + // midnight. + // + // This resolves that problem by subtracting 15 minutes from midnight + // when we check the next cron time. + restartRequirementBuffer = -15 * time.Minute +) + +type CalculateAutostopParams struct { + Database database.Store + TemplateScheduleStore TemplateScheduleStore + UserQuietHoursScheduleStore UserQuietHoursScheduleStore + + Now time.Time + Workspace database.Workspace +} + +type AutostopTime struct { + // Deadline is the time when the workspace will be stopped. The value can be + // bumped by user activity or manually by the user via the UI. + Deadline time.Time + // MaxDeadline is the maximum value for deadline. + MaxDeadline time.Time +} + +// CalculateAutostop calculates the deadline and max_deadline for a workspace +// build. +// +// Deadline is the time when the workspace will be stopped, as long as it +// doesn't see any new activity (such as SSH, app requests, etc.). When activity +// is detected the deadline is bumped by the workspace's TTL (this only happens +// when activity is detected and more than 20% of the TTL has passed to save +// database queries). +// +// MaxDeadline is the maximum value for deadline. The deadline cannot be bumped +// past this value, so it denotes the absolute deadline that the workspace build +// must be stopped by. MaxDeadline is calculated using the template's "restart +// requirement" settings and the user's "quiet hours" settings to pick a time +// outside of working hours. +// +// Deadline is a cost saving measure, while max deadline is a +// compliance/updating measure. +func CalculateAutostop(ctx context.Context, params CalculateAutostopParams) (AutostopTime, error) { + var ( + db = params.Database + workspace = params.Workspace + now = params.Now + + autostop AutostopTime + ) + + if workspace.Ttl.Valid { + // When the workspace is made it copies the template's TTL, and the user + // can unset it to disable it (unless the template has + // UserAutoStopEnabled set to false, see below). + autostop.Deadline = now.Add(time.Duration(workspace.Ttl.Int64)) + } + + templateSchedule, err := params.TemplateScheduleStore.Get(ctx, db, workspace.TemplateID) + if err != nil { + return autostop, xerrors.Errorf("get template schedule options: %w", err) + } + if !templateSchedule.UserAutostopEnabled { + // The user is not permitted to set their own TTL, so use the template + // default. + autostop.Deadline = time.Time{} + if templateSchedule.DefaultTTL > 0 { + autostop.Deadline = now.Add(templateSchedule.DefaultTTL) + } + } + + // Use the old algorithm for calculating max_deadline if the instance isn't + // configured or entitled to use the new feature flag yet. + // TODO(@dean): remove this once the feature flag is enabled for all + if !templateSchedule.UseRestartRequirement && templateSchedule.MaxTTL > 0 { + autostop.MaxDeadline = now.Add(templateSchedule.MaxTTL) + } + + // TODO(@dean): remove extra conditional + if templateSchedule.UseRestartRequirement && templateSchedule.RestartRequirement.DaysOfWeek != 0 { + // The template has a restart requirement, so determine the max deadline + // of this workspace build. + + // First, get the user's quiet hours schedule (this will return the + // default if the user has not set their own schedule). + userQuietHoursSchedule, err := params.UserQuietHoursScheduleStore.Get(ctx, db, workspace.OwnerID) + if err != nil { + return autostop, xerrors.Errorf("get user quiet hours schedule options: %w", err) + } + + // If the schedule is nil, that means the deployment isn't entitled to + // use quiet hours or the default schedule has not been set. In this + // case, do not set a max deadline on the workspace. + if userQuietHoursSchedule.Schedule != nil { + loc := userQuietHoursSchedule.Schedule.Location() + now := now.In(loc) + // Add the leeway here so we avoid checking today's quiet hours if + // the workspace was started <1h before midnight. + startOfStopDay := truncateMidnight(now.Add(restartRequirementLeeway)) + + // If the template schedule wants to only restart on n-th weeks then + // change the startOfDay to be the Monday of the next applicable + // week. + if templateSchedule.RestartRequirement.Weeks > 1 { + startOfStopDay, err = GetNextApplicableMondayOfNWeeks(startOfStopDay, templateSchedule.RestartRequirement.Weeks) + if err != nil { + return autostop, xerrors.Errorf("determine start of stop week: %w", err) + } + } + + // Determine if we should skip the first day because the schedule is + // too near or has already passed. + // + // Allow an hour of leeway (i.e. any workspaces started within an + // hour of the scheduled stop time will always bounce to the next + // stop window). + checkSchedule := userQuietHoursSchedule.Schedule.Next(startOfStopDay.Add(restartRequirementBuffer)) + if checkSchedule.Before(now.Add(restartRequirementLeeway)) { + // Set the first stop day we try to tomorrow because today's + // schedule is too close to now or has already passed. + startOfStopDay = nextDayMidnight(startOfStopDay) + } + + // Iterate from 0 to 7, check if the current startOfDay is in the + // restart requirement. If it isn't then add a day and try again. + requirementDays := templateSchedule.RestartRequirement.DaysMap() + for i := 0; i < len(DaysOfWeek)+1; i++ { + if i == len(DaysOfWeek) { + // We've wrapped, so somehow we couldn't find a day in the + // restart requirement in the next week. + // + // This shouldn't be able to happen, as we've already + // checked that there is a day in the restart requirement + // above with the + // `if templateSchedule.RestartRequirement.DaysOfWeek != 0` + // check. + // + // The eighth bit shouldn't be set, as we validate the + // bitmap in the enterprise TemplateScheduleStore. + return autostop, xerrors.New("could not find suitable day for template restart requirement in the next 7 days") + } + if requirementDays[startOfStopDay.Weekday()] { + break + } + startOfStopDay = nextDayMidnight(startOfStopDay) + } + + // If the startOfDay is within an hour of now, then we add an hour. + checkTime := startOfStopDay + if checkTime.Before(now.Add(time.Hour)) { + checkTime = now.Add(time.Hour) + } else { + // If it's not within an hour of now, subtract 15 minutes to + // give a little leeway. This prevents skipped stop events + // because autostart perfectly lines up with autostop. + checkTime = checkTime.Add(restartRequirementBuffer) + } + + // Get the next occurrence of the restart schedule. + autostop.MaxDeadline = userQuietHoursSchedule.Schedule.Next(checkTime) + if autostop.MaxDeadline.IsZero() { + return autostop, xerrors.New("could not find next occurrence of template restart requirement in user quiet hours schedule") + } + } + } + + // If the workspace doesn't have a deadline or the max deadline is sooner + // than the workspace deadline, use the max deadline as the actual deadline. + if !autostop.MaxDeadline.IsZero() && (autostop.Deadline.IsZero() || autostop.MaxDeadline.Before(autostop.Deadline)) { + autostop.Deadline = autostop.MaxDeadline + } + + if (!autostop.Deadline.IsZero() && autostop.Deadline.Before(now)) || (!autostop.MaxDeadline.IsZero() && autostop.MaxDeadline.Before(now)) { + // Something went wrong with the deadline calculation, so we should + // bail. + return autostop, xerrors.Errorf("deadline calculation error, computed deadline or max deadline is in the past for workspace build: deadline=%q maxDeadline=%q now=%q", autostop.Deadline, autostop.MaxDeadline, now) + } + + return autostop, nil +} + +// truncateMidnight truncates a time to midnight in the time object's timezone. +// t.Truncate(24 * time.Hour) truncates based on the internal time and doesn't +// factor daylight savings properly. +// +// See: https://github.com/golang/go/issues/10894 +func truncateMidnight(t time.Time) time.Time { + yy, mm, dd := t.Date() + return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) +} + +// nextDayMidnight returns the next midnight in the time object's timezone. +func nextDayMidnight(t time.Time) time.Time { + yy, mm, dd := t.Date() + // time.Date will correctly normalize the date if it's past the end of the + // month. E.g. October 32nd will be November 1st. + dd++ + return time.Date(yy, mm, dd, 0, 0, 0, 0, t.Location()) +} + +// WeeksSinceEpoch gets the weeks since the epoch for a given time. This is a +// 0-indexed number of weeks since the epoch (Monday). +// +// The timezone embedded in the time object is used to determine the epoch. +func WeeksSinceEpoch(now time.Time) (int64, error) { + epoch := TemplateRestartRequirementEpoch(now.Location()) + if now.Before(epoch) { + return 0, xerrors.New("coder server system clock is incorrect, cannot calculate template restart requirement") + } + + // This calculation needs to be done using YearDay, as dividing by the + // amount of hours is impacted by daylight savings. Even though daylight + // savings is usually only an hour difference, this calculation is used to + // get the current week number and could result in an entire week getting + // skipped if the calculation is off by an hour. + // + // Old naive algorithm: weeksSinceEpoch := int64(since.Hours() / (24 * 7)) + + // Get days since epoch. Start with a negative number of days, as we want to + // subtract the YearDay() of the epoch itself. + days := -epoch.YearDay() + for i := epoch.Year(); i < now.Year(); i++ { + startOfNextYear := time.Date(i+1, 1, 1, 0, 0, 0, 0, now.Location()) + if startOfNextYear.Year() != i+1 { + return 0, xerrors.New("overflow calculating weeks since epoch") + } + endOfThisYear := startOfNextYear.AddDate(0, 0, -1) + if endOfThisYear.Year() != i { + return 0, xerrors.New("overflow calculating weeks since epoch") + } + + days += endOfThisYear.YearDay() + } + // Add this year's days. + days += now.YearDay() + + // Ensure that the number of days is positive. + if days < 0 { + return 0, xerrors.New("overflow calculating weeks since epoch") + } + + // Divide by 7 to get the number of weeks. + weeksSinceEpoch := int64(days / 7) + return weeksSinceEpoch, nil +} + +// GetMondayOfWeek gets the Monday (0:00) of the n-th week since epoch. +func GetMondayOfWeek(loc *time.Location, n int64) (time.Time, error) { + if n < 0 { + return time.Time{}, xerrors.New("weeks since epoch must be positive") + } + epoch := TemplateRestartRequirementEpoch(loc) + monday := epoch.AddDate(0, 0, int(n*7)) + + y, m, d := monday.Date() + monday = time.Date(y, m, d, 0, 0, 0, 0, loc) + if monday.Weekday() != time.Monday { + // This condition should never be hit, but we have a check for it just + // in case. + return time.Time{}, xerrors.Errorf("calculated incorrect Monday for week %v since epoch (actual weekday %q)", n, monday.Weekday()) + } + return monday, nil +} + +// GetNextApplicableMondayOfNWeeks gets the next Monday (0:00) of the next week +// divisible by n since epoch. If the next applicable week is invalid for any +// reason, the week after will be used instead (up to 2 attempts). +// +// If the current week is divisible by n, then the provided time is returned as +// is. +// +// The timezone embedded in the time object is used to determine the epoch. +func GetNextApplicableMondayOfNWeeks(now time.Time, n int64) (time.Time, error) { + // Get the current week number. + weeksSinceEpoch, err := WeeksSinceEpoch(now) + if err != nil { + return time.Time{}, xerrors.Errorf("get current week number: %w", err) + } + + // Get the next week divisible by n. + remainder := weeksSinceEpoch % n + week := weeksSinceEpoch + (n - remainder) + if remainder == 0 { + return now, nil + } + + // Loop until we find a week that doesn't fail. This should never loop, but + // we account for failures just in case. + var lastErr error + for i := int64(0); i < 3; i++ { + monday, err := GetMondayOfWeek(now.Location(), week+i) + if err != nil { + lastErr = err + continue + } + + return monday, nil + } + + return time.Time{}, xerrors.Errorf("get next applicable Monday of %v weeks: %w", n, lastErr) +} diff --git a/coderd/schedule/autostop_test.go b/coderd/schedule/autostop_test.go new file mode 100644 index 0000000000000..6be5c5eaf81d4 --- /dev/null +++ b/coderd/schedule/autostop_test.go @@ -0,0 +1,594 @@ +package schedule_test + +import ( + "context" + "database/sql" + "fmt" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/database/dbgen" + "github.com/coder/coder/coderd/database/dbtestutil" + "github.com/coder/coder/coderd/schedule" + "github.com/coder/coder/testutil" +) + +func TestCalculateAutoStop(t *testing.T) { + t.Parallel() + + now := time.Now() + + // Wednesday the 8th of February 2023 at midnight. This date was + // specifically chosen as it doesn't fall on a applicable week for both + // fortnightly and triweekly restart requirements. + wednesdayMidnightUTC := time.Date(2023, 2, 8, 0, 0, 0, 0, time.UTC) + + sydneyQuietHours := "CRON_TZ=Australia/Sydney 0 0 * * *" + sydneyLoc, err := time.LoadLocation("Australia/Sydney") + require.NoError(t, err) + // 10pm on Friday the 10th of February 2023 in Sydney. + fridayEveningSydney := time.Date(2023, 2, 10, 22, 0, 0, 0, sydneyLoc) + // 12am on Saturday the 11th of February2023 in Sydney. + saturdayMidnightSydney := time.Date(2023, 2, 11, 0, 0, 0, 0, sydneyLoc) + + t.Log("now", now) + t.Log("wednesdayMidnightUTC", wednesdayMidnightUTC) + t.Log("fridayEveningSydney", fridayEveningSydney) + t.Log("saturdayMidnightSydney", saturdayMidnightSydney) + + dstIn := time.Date(2023, 10, 1, 2, 0, 0, 0, sydneyLoc) // 1 hour backward + dstInQuietHours := "CRON_TZ=Australia/Sydney 30 2 * * *" // never + // The expected behavior is that we will pick the next time that falls on + // quiet hours after the DST transition. In this case, it will be the same + // time the next day. + dstInQuietHoursExpectedTime := time.Date(2023, 10, 2, 2, 30, 0, 0, sydneyLoc) + beforeDstIn := time.Date(2023, 10, 1, 0, 0, 0, 0, sydneyLoc) + saturdayMidnightAfterDstIn := time.Date(2023, 10, 7, 0, 0, 0, 0, sydneyLoc) + + // Wednesday after DST starts. + duringDst := time.Date(2023, 10, 4, 0, 0, 0, 0, sydneyLoc) + saturdayMidnightAfterDuringDst := saturdayMidnightAfterDstIn + + dstOut := time.Date(2024, 4, 7, 3, 0, 0, 0, sydneyLoc) // 1 hour forward + dstOutQuietHours := "CRON_TZ=Australia/Sydney 30 3 * * *" // twice + dstOutQuietHoursExpectedTime := time.Date(2024, 4, 7, 3, 30, 0, 0, sydneyLoc) // in reality, this is the first occurrence + beforeDstOut := time.Date(2024, 4, 7, 0, 0, 0, 0, sydneyLoc) + saturdayMidnightAfterDstOut := time.Date(2024, 4, 13, 0, 0, 0, 0, sydneyLoc) + + t.Log("dstIn", dstIn) + t.Log("beforeDstIn", beforeDstIn) + t.Log("saturdayMidnightAfterDstIn", saturdayMidnightAfterDstIn) + t.Log("dstOut", dstOut) + t.Log("beforeDstOut", beforeDstOut) + t.Log("saturdayMidnightAfterDstOut", saturdayMidnightAfterDstOut) + + cases := []struct { + name string + now time.Time + templateAllowAutostop bool + templateDefaultTTL time.Duration + // TODO(@dean): remove max_ttl tests + useMaxTTL bool + templateMaxTTL time.Duration + templateRestartRequirement schedule.TemplateRestartRequirement + userQuietHoursSchedule string + // workspaceTTL is usually copied from the template's TTL when the + // workspace is made, so it takes precedence unless + // templateAllowAutostop is false. + workspaceTTL time.Duration + + // expectedDeadline is copied from expectedMaxDeadline if unset. + expectedDeadline time.Time + expectedMaxDeadline time.Time + errContains string + }{ + { + name: "OK", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, + }, + { + name: "Delete", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, + }, + { + name: "WorkspaceTTL", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 0, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: time.Hour, + expectedDeadline: now.Add(time.Hour), + expectedMaxDeadline: time.Time{}, + }, + { + name: "TemplateDefaultTTLIgnored", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: time.Hour, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 0, + expectedDeadline: time.Time{}, + expectedMaxDeadline: time.Time{}, + }, + { + name: "WorkspaceTTLOverridesTemplateDefaultTTL", + now: now, + templateAllowAutostop: true, + templateDefaultTTL: 2 * time.Hour, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: time.Hour, + expectedDeadline: now.Add(time.Hour), + expectedMaxDeadline: time.Time{}, + }, + { + name: "TemplateBlockWorkspaceTTL", + now: now, + templateAllowAutostop: false, + templateDefaultTTL: 3 * time.Hour, + templateRestartRequirement: schedule.TemplateRestartRequirement{}, + workspaceTTL: 4 * time.Hour, + expectedDeadline: now.Add(3 * time.Hour), + expectedMaxDeadline: time.Time{}, + }, + { + name: "TemplateRestartRequirement", + now: wednesdayMidnightUTC, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "TemplateRestartRequirement1HourSkip", + now: saturdayMidnightSydney.Add(-59 * time.Minute), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 1, // 1 also means weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.Add(7 * 24 * time.Hour).In(time.UTC), + }, + { + // The next restart requirement should be skipped if the + // workspace is started within 1 hour of it. + name: "TemplateRestartRequirementDaily", + now: fridayEveningSydney, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b01111111, // daily + Weeks: 0, // all weeks + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "TemplateRestartRequirementFortnightly/Skip", + now: wednesdayMidnightUTC, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 2, // every 2 weeks + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementFortnightly/NoSkip", + now: wednesdayMidnightUTC.AddDate(0, 0, 7), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 2, // every 2 weeks + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementTriweekly/Skip", + now: wednesdayMidnightUTC, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 3, // every 3 weeks + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + // The next triweekly restart requirement happens next week + // according to the epoch. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementTriweekly/NoSkip", + now: wednesdayMidnightUTC.AddDate(0, 0, 7), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 3, // every 3 weeks + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.AddDate(0, 0, 7).In(time.UTC), + }, + { + name: "TemplateRestartRequirementOverridesWorkspaceTTL", + // now doesn't have to be UTC, but it helps us ensure that + // timezones are compared correctly in this test. + now: fridayEveningSydney.In(time.UTC), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 3 * time.Hour, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "TemplateRestartRequirementOverridesTemplateDefaultTTL", + now: fridayEveningSydney.In(time.UTC), + templateAllowAutostop: true, + templateDefaultTTL: 3 * time.Hour, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "TimeBeforeEpoch", + // The epoch is 2023-01-02 in each timezone. We set the time to + // 1 second before 11pm the previous day, as this is the latest time + // we allow due to our 1h leeway logic. + now: time.Date(2023, 1, 1, 22, 59, 59, 0, sydneyLoc), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 2, // every fortnight + }, + workspaceTTL: 0, + errContains: "coder server system clock is incorrect", + }, + { + name: "DaylightSavings/OK", + now: duringDst, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 1, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightAfterDuringDst, + }, + { + name: "DaylightSavings/SwitchMidWeek/In", + now: beforeDstIn, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 1, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightAfterDstIn, + }, + { + name: "DaylightSavings/SwitchMidWeek/Out", + now: beforeDstOut, + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 1, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightAfterDstOut, + }, + { + name: "DaylightSavings/QuietHoursFallsOnDstSwitch/In", + now: beforeDstIn.Add(-24 * time.Hour), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: dstInQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b01000000, // Sunday + Weeks: 1, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: dstInQuietHoursExpectedTime, + }, + { + name: "DaylightSavings/QuietHoursFallsOnDstSwitch/Out", + now: beforeDstOut.Add(-24 * time.Hour), + templateAllowAutostop: true, + templateDefaultTTL: 0, + userQuietHoursSchedule: dstOutQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b01000000, // Sunday + Weeks: 1, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: dstOutQuietHoursExpectedTime, + }, + + // TODO(@dean): remove max_ttl tests + { + name: "RestartRequirementIgnoresMaxTTL", + now: fridayEveningSydney.In(time.UTC), + templateAllowAutostop: false, + templateDefaultTTL: 0, + useMaxTTL: false, + templateMaxTTL: time.Hour, // should be ignored + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: saturdayMidnightSydney.In(time.UTC), + }, + { + name: "MaxTTLIgnoresRestartRequirement", + now: fridayEveningSydney.In(time.UTC), + templateAllowAutostop: false, + templateDefaultTTL: 0, + useMaxTTL: true, + templateMaxTTL: time.Hour, // should NOT be ignored + userQuietHoursSchedule: sydneyQuietHours, + templateRestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: 0b00100000, // Saturday + Weeks: 0, // weekly + }, + workspaceTTL: 0, + // expectedDeadline is copied from expectedMaxDeadline. + expectedMaxDeadline: fridayEveningSydney.Add(time.Hour).In(time.UTC), + }, + } + + for _, c := range cases { + c := c + + t.Run(c.name, func(t *testing.T) { + t.Parallel() + + db, _ := dbtestutil.NewDB(t) + ctx := testutil.Context(t, testutil.WaitLong) + + templateScheduleStore := schedule.MockTemplateScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutostartEnabled: false, + UserAutostopEnabled: c.templateAllowAutostop, + DefaultTTL: c.templateDefaultTTL, + MaxTTL: c.templateMaxTTL, + UseRestartRequirement: !c.useMaxTTL, + RestartRequirement: c.templateRestartRequirement, + }, nil + }, + } + + userQuietHoursScheduleStore := schedule.MockUserQuietHoursScheduleStore{ + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.UserQuietHoursScheduleOptions, error) { + if c.userQuietHoursSchedule == "" { + return schedule.UserQuietHoursScheduleOptions{ + Schedule: nil, + }, nil + } + + sched, err := schedule.Daily(c.userQuietHoursSchedule) + if !assert.NoError(t, err) { + return schedule.UserQuietHoursScheduleOptions{}, err + } + + return schedule.UserQuietHoursScheduleOptions{ + Schedule: sched, + UserSet: false, + }, nil + }, + } + + org := dbgen.Organization(t, db, database.Organization{}) + user := dbgen.User(t, db, database.User{ + QuietHoursSchedule: c.userQuietHoursSchedule, + }) + template := dbgen.Template(t, db, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + OrganizationID: org.ID, + CreatedBy: user.ID, + }) + err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: c.templateAllowAutostop, + RestartRequirementDaysOfWeek: int16(c.templateRestartRequirement.DaysOfWeek), + RestartRequirementWeeks: c.templateRestartRequirement.Weeks, + }) + require.NoError(t, err) + template, err = db.GetTemplateByID(ctx, template.ID) + require.NoError(t, err) + workspaceTTL := sql.NullInt64{} + if c.workspaceTTL != 0 { + workspaceTTL = sql.NullInt64{ + Int64: int64(c.workspaceTTL), + Valid: true, + } + } + workspace := dbgen.Workspace(t, db, database.Workspace{ + TemplateID: template.ID, + OrganizationID: org.ID, + OwnerID: user.ID, + Ttl: workspaceTTL, + }) + + autostop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ + Database: db, + TemplateScheduleStore: templateScheduleStore, + UserQuietHoursScheduleStore: userQuietHoursScheduleStore, + Now: c.now, + Workspace: workspace, + }) + if c.errContains != "" { + require.Error(t, err) + require.ErrorContains(t, err, c.errContains) + return + } + require.NoError(t, err) + + // If the max deadline is set, the deadline should also be set. + // Default to the max deadline if the deadline is not set. + if c.expectedDeadline.IsZero() { + c.expectedDeadline = c.expectedMaxDeadline + } + + if c.expectedDeadline.IsZero() { + require.True(t, autostop.Deadline.IsZero()) + } else { + require.WithinDuration(t, c.expectedDeadline, autostop.Deadline, 15*time.Second, "deadline does not match expected") + } + if c.expectedMaxDeadline.IsZero() { + require.True(t, autostop.MaxDeadline.IsZero()) + } else { + require.WithinDuration(t, c.expectedMaxDeadline, autostop.MaxDeadline, 15*time.Second, "max deadline does not match expected") + require.GreaterOrEqual(t, autostop.MaxDeadline.Unix(), autostop.Deadline.Unix(), "max deadline is smaller than deadline") + } + }) + } +} + +func TestFindWeek(t *testing.T) { + t.Parallel() + + timezones := []string{ + "UTC", + "America/Los_Angeles", + "America/New_York", + "Europe/Dublin", + "Europe/London", + "Europe/Paris", + "Asia/Kolkata", // India (UTC+5:30) + "Asia/Tokyo", + "Australia/Sydney", + "Australia/Brisbane", + } + + for _, tz := range timezones { + tz := tz + t.Run("Loc/"+tz, func(t *testing.T) { + t.Parallel() + + loc, err := time.LoadLocation(tz) + require.NoError(t, err) + + now := time.Now().In(loc) + currentWeek, err := schedule.WeeksSinceEpoch(now) + require.NoError(t, err) + + diffMonday := now.Weekday() - time.Monday + if now.Weekday() == time.Sunday { + // Sunday is 0, but Monday is the first day of the week in the + // code. + diffMonday = 6 + } + currentWeekMondayExpected := now.AddDate(0, 0, -int(diffMonday)) + require.Equal(t, time.Monday, currentWeekMondayExpected.Weekday()) + y, m, d := currentWeekMondayExpected.Date() + // Change to midnight. + currentWeekMondayExpected = time.Date(y, m, d, 0, 0, 0, 0, loc) + + currentWeekMonday, err := schedule.GetMondayOfWeek(now.Location(), currentWeek) + require.NoError(t, err) + require.Equal(t, time.Monday, currentWeekMonday.Weekday()) + require.Equal(t, currentWeekMondayExpected, currentWeekMonday) + + t.Log("now", now) + t.Log("currentWeek", currentWeek) + t.Log("currentMonday", currentWeekMonday) + + // Loop through every single Monday and Sunday for the next 100 + // years and make sure the week calculations are correct. + for i := int64(1); i < 52*100; i++ { + msg := fmt.Sprintf("week %d", i) + + monday := currentWeekMonday.AddDate(0, 0, int(i*7)) + y, m, d := monday.Date() + monday = time.Date(y, m, d, 0, 0, 0, 0, loc) + require.Equal(t, monday.Weekday(), time.Monday, msg) + t.Log(msg, "monday", monday) + + week, err := schedule.WeeksSinceEpoch(monday) + require.NoError(t, err, msg) + require.Equal(t, currentWeek+i, week, msg) + + gotMonday, err := schedule.GetMondayOfWeek(monday.Location(), week) + require.NoError(t, err, msg) + require.Equal(t, monday, gotMonday, msg) + + // Check that we get the same week number for late Sunday. + sunday := time.Date(y, m, d+6, 23, 59, 59, 0, loc) + require.Equal(t, sunday.Weekday(), time.Sunday, msg) + t.Log(msg, "sunday", sunday) + + week, err = schedule.WeeksSinceEpoch(sunday) + require.NoError(t, err, msg) + require.Equal(t, currentWeek+i, week, msg) + } + }) + } +} diff --git a/coderd/schedule/cron.go b/coderd/schedule/cron.go index affe9d63db27d..7a86b130d24bc 100644 --- a/coderd/schedule/cron.go +++ b/coderd/schedule/cron.go @@ -42,6 +42,36 @@ func Weekly(raw string) (*Schedule, error) { return nil, xerrors.Errorf("validate weekly schedule: %w", err) } + return parse(raw) +} + +// Daily parses a Schedule from spec scoped to a recurring daily event. +// Spec consists of the following space-delimited fields, in the following order: +// - timezone e.g. CRON_TZ=US/Central (optional) +// - minutes of hour e.g. 30 (required) +// - hour of day e.g. 9 (required) +// - day of month (must be *) +// - month (must be *) +// - day of week (must be *) +// +// Example Usage: +// +// local_sched, _ := schedule.Weekly("59 23 * * *") +// fmt.Println(sched.Next(time.Now().Format(time.RFC3339))) +// // Output: 2022-04-04T23:59:00Z +// +// us_sched, _ := schedule.Weekly("CRON_TZ=US/Central 30 9 * * *") +// fmt.Println(sched.Next(time.Now()).Format(time.RFC3339)) +// // Output: 2022-04-04T14:30:00Z +func Daily(raw string) (*Schedule, error) { + if err := validateDailySpec(raw); err != nil { + return nil, xerrors.Errorf("validate daily schedule: %w", err) + } + + return parse(raw) +} + +func parse(raw string) (*Schedule, error) { // If schedule does not specify a timezone, default to UTC. Otherwise, // the library will default to time.Local which we want to avoid. if !strings.HasPrefix(raw, "CRON_TZ=") { @@ -187,7 +217,23 @@ func validateWeeklySpec(spec string) error { parts = parts[1:] } if parts[2] != "*" || parts[3] != "*" { - return xerrors.Errorf("expected month and dom to be *") + return xerrors.Errorf("expected day-of-month and month to be *") + } + return nil +} + +// validateDailySpec ensures that the day-of-month, month and day-of-week +// options of spec are all set to * +func validateDailySpec(spec string) error { + parts := strings.Fields(spec) + if len(parts) < 5 { + return xerrors.Errorf("expected schedule to consist of 5 fields with an optional CRON_TZ= prefix") + } + if len(parts) == 6 { + parts = parts[1:] + } + if parts[2] != "*" || parts[3] != "*" || parts[4] != "*" { + return xerrors.Errorf("expected day-of-month, month and day-of-week to be *") } return nil } diff --git a/coderd/schedule/cron_test.go b/coderd/schedule/cron_test.go index 9437cff4eed04..d09feb5578b20 100644 --- a/coderd/schedule/cron_test.go +++ b/coderd/schedule/cron_test.go @@ -129,14 +129,14 @@ func Test_Weekly(t *testing.T) { spec: "30 9 1 1 1-5", at: time.Time{}, expectedNext: time.Time{}, - expectedError: "validate weekly schedule: expected month and dom to be *", + expectedError: "validate weekly schedule: expected day-of-month and month to be *", }, { name: "valid schedule with 5 fields and timezone but month and dom not set to *", spec: "CRON_TZ=Europe/Dublin 30 9 1 1 1-5", at: time.Time{}, expectedNext: time.Time{}, - expectedError: "validate weekly schedule: expected month and dom to be *", + expectedError: "validate weekly schedule: expected day-of-month and month to be *", }, } diff --git a/coderd/schedule/mock.go b/coderd/schedule/mock.go index 5c3c1e77ed803..4a22197b57dc4 100644 --- a/coderd/schedule/mock.go +++ b/coderd/schedule/mock.go @@ -15,18 +15,41 @@ type MockTemplateScheduleStore struct { var _ TemplateScheduleStore = MockTemplateScheduleStore{} -func (m MockTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { +func (m MockTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { if m.GetFn != nil { return m.GetFn(ctx, db, templateID) } - return NewAGPLTemplateScheduleStore().GetTemplateScheduleOptions(ctx, db, templateID) + return NewAGPLTemplateScheduleStore().Get(ctx, db, templateID) } -func (m MockTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error) { +func (m MockTemplateScheduleStore) Set(ctx context.Context, db database.Store, template database.Template, options TemplateScheduleOptions) (database.Template, error) { if m.SetFn != nil { return m.SetFn(ctx, db, template, options) } - return NewAGPLTemplateScheduleStore().SetTemplateScheduleOptions(ctx, db, template, options) + return NewAGPLTemplateScheduleStore().Set(ctx, db, template, options) +} + +type MockUserQuietHoursScheduleStore struct { + GetFn func(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) + SetFn func(ctx context.Context, db database.Store, userID uuid.UUID, schedule string) (UserQuietHoursScheduleOptions, error) +} + +var _ UserQuietHoursScheduleStore = MockUserQuietHoursScheduleStore{} + +func (m MockUserQuietHoursScheduleStore) Get(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) { + if m.GetFn != nil { + return m.GetFn(ctx, db, userID) + } + + return NewAGPLUserQuietHoursScheduleStore().Get(ctx, db, userID) +} + +func (m MockUserQuietHoursScheduleStore) Set(ctx context.Context, db database.Store, userID uuid.UUID, schedule string) (UserQuietHoursScheduleOptions, error) { + if m.SetFn != nil { + return m.SetFn(ctx, db, userID, schedule) + } + + return NewAGPLUserQuietHoursScheduleStore().Set(ctx, db, userID, schedule) } diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index d1782871fea44..de97dffc9ac2a 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -4,35 +4,113 @@ import ( "context" "time" - "golang.org/x/xerrors" - "github.com/google/uuid" + "golang.org/x/xerrors" "github.com/coder/coder/coderd/database" ) +const MaxTemplateRestartRequirementWeeks = 16 + +func TemplateRestartRequirementEpoch(loc *time.Location) time.Time { + // The "first week" starts on January 2nd, 2023, which is the first Monday + // of 2023. All other weeks are counted using modulo arithmetic from that + // date. + return time.Date(2023, time.January, 2, 0, 0, 0, 0, loc) +} + +// DaysOfWeek intentionally starts on Monday as opposed to Sunday so the weekend +// days are contiguous in the bitmap. This matters greatly when doing restarts +// every second week or more to avoid workspaces restarting "at the start" of +// the week rather than "at the end" of the week. +var DaysOfWeek = []time.Weekday{ + time.Monday, + time.Tuesday, + time.Wednesday, + time.Thursday, + time.Friday, + time.Saturday, + time.Sunday, +} + +type TemplateRestartRequirement struct { + // DaysOfWeek is a bitmap of which days of the week the workspace must be + // restarted. If fully zero, the workspace is not required to be restarted + // ever. + // + // First bit is Monday, ..., seventh bit is Sunday, eighth bit is unused. + DaysOfWeek uint8 + // Weeks is the amount of weeks between restarts. If 0 or 1, the workspace + // is restarted weekly in accordance with DaysOfWeek. If 2, the workspace is + // restarted every other week. And so forth. + // + // The limit for this value is 16, which is roughly 4 months. + // + // The "first week" starts on January 2nd, 2023, which is the first Monday + // of 2023. All other weeks are counted using modulo arithmetic from that + // date. + Weeks int64 +} + +// DaysMap returns a map of the days of the week that the workspace must be +// restarted. +func (r TemplateRestartRequirement) DaysMap() map[time.Weekday]bool { + days := make(map[time.Weekday]bool) + for i, day := range DaysOfWeek { + days[day] = r.DaysOfWeek&(1< 0b11111111 { + return xerrors.New("invalid restart requirement days, too large") + } + if weeks < 0 { + return xerrors.New("invalid restart requirement weeks, negative") + } + if weeks > MaxTemplateRestartRequirementWeeks { + return xerrors.New("invalid restart requirement weeks, too large") + } + return nil +} + type TemplateScheduleOptions struct { UserAutostartEnabled bool `json:"user_autostart_enabled"` UserAutostopEnabled bool `json:"user_autostop_enabled"` DefaultTTL time.Duration `json:"default_ttl"` - // If MaxTTL is set, the workspace must be stopped before this time or it - // will be stopped automatically. - // - // If set, users cannot disable automatic workspace shutdown. + // TODO(@dean): remove MaxTTL once restart_requirement is matured and the + // default MaxTTL time.Duration `json:"max_ttl"` - // FailureTTL dictates the duration after which failed workspaces will be stopped automatically. + // UseRestartRequirement dictates whether the restart requirement should be + // used instead of MaxTTL. This is governed by the feature flag and + // licensing. + // TODO(@dean): remove this when we remove max_tll + UseRestartRequirement bool + // RestartRequirement dictates when the workspace must be restarted. This + // used to be handled by MaxTTL. + RestartRequirement TemplateRestartRequirement `json:"restart_requirement"` + // FailureTTL dictates the duration after which failed workspaces will be + // stopped automatically. FailureTTL time.Duration `json:"failure_ttl"` - // InactivityTTL dictates the duration after which inactive workspaces will be locked. + // InactivityTTL dictates the duration after which inactive workspaces will + // be locked. InactivityTTL time.Duration `json:"inactivity_ttl"` - // LockedTTL dictates the duration after which locked workspaces will be permanently deleted. + // LockedTTL dictates the duration after which locked workspaces will be + // permanently deleted. LockedTTL time.Duration `json:"locked_ttl"` } // TemplateScheduleStore provides an interface for retrieving template // scheduling options set by the template/site admin. type TemplateScheduleStore interface { - GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) - SetTemplateScheduleOptions(ctx context.Context, db database.Store, template database.Template, opts TemplateScheduleOptions) (database.Template, error) + Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) + Set(ctx context.Context, db database.Store, template database.Template, opts TemplateScheduleOptions) (database.Template, error) } type agplTemplateScheduleStore struct{} @@ -43,7 +121,7 @@ func NewAGPLTemplateScheduleStore() TemplateScheduleStore { return &agplTemplateScheduleStore{} } -func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { +func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (TemplateScheduleOptions, error) { tpl, err := db.GetTemplateByID(ctx, templateID) if err != nil { return TemplateScheduleOptions{}, err @@ -55,16 +133,21 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context UserAutostartEnabled: true, UserAutostopEnabled: true, DefaultTTL: time.Duration(tpl.DefaultTTL), - // Disregard the values in the database, since MaxTTL, FailureTTL, InactivityTTL, and LockedTTL are enterprise - // features. - MaxTTL: 0, + // Disregard the values in the database, since RestartRequirement, + // FailureTTL, InactivityTTL, and LockedTTL are enterprise features. + UseRestartRequirement: false, + MaxTTL: 0, + RestartRequirement: TemplateRestartRequirement{ + DaysOfWeek: 0, + Weeks: 0, + }, FailureTTL: 0, InactivityTTL: 0, LockedTTL: 0, }, nil } -func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) { +func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts TemplateScheduleOptions) (database.Template, error) { if int64(opts.DefaultTTL) == tpl.DefaultTTL { // Avoid updating the UpdatedAt timestamp if nothing will be changed. return tpl, nil @@ -76,14 +159,16 @@ func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context ID: tpl.ID, UpdatedAt: database.Now(), DefaultTTL: int64(opts.DefaultTTL), - // Don't allow changing it, but keep the value in the DB (to avoid - // clearing settings if the license has an issue). - AllowUserAutostart: tpl.AllowUserAutostart, - AllowUserAutostop: tpl.AllowUserAutostop, - MaxTTL: tpl.MaxTTL, - FailureTTL: tpl.FailureTTL, - InactivityTTL: tpl.InactivityTTL, - LockedTTL: tpl.LockedTTL, + // Don't allow changing these settings, but keep the value in the DB (to + // avoid clearing settings if the license has an issue). + MaxTTL: tpl.MaxTTL, + RestartRequirementDaysOfWeek: tpl.RestartRequirementDaysOfWeek, + RestartRequirementWeeks: tpl.RestartRequirementWeeks, + AllowUserAutostart: tpl.AllowUserAutostart, + AllowUserAutostop: tpl.AllowUserAutostop, + FailureTTL: tpl.FailureTTL, + InactivityTTL: tpl.InactivityTTL, + LockedTTL: tpl.LockedTTL, }) if err != nil { return xerrors.Errorf("update template schedule: %w", err) diff --git a/coderd/schedule/user.go b/coderd/schedule/user.go new file mode 100644 index 0000000000000..967a430fcccd2 --- /dev/null +++ b/coderd/schedule/user.go @@ -0,0 +1,60 @@ +package schedule + +import ( + "context" + + "github.com/google/uuid" + + "github.com/coder/coder/coderd/database" +) + +type UserQuietHoursScheduleOptions struct { + // Schedule is the cron schedule to use for quiet hours windows for all + // workspaces owned by the user. + // + // This value will be set to the parsed custom schedule of the user. If the + // user doesn't have a custom schedule set, it will be set to the default + // schedule (and UserSet will be false). If quiet hours schedules are not + // entitled or disabled instance-wide, this value will be nil to denote that + // quiet hours windows should not be used. + Schedule *Schedule + UserSet bool +} + +type UserQuietHoursScheduleStore interface { + // Get retrieves the quiet hours schedule for the given user. If the user + // has not set a custom schedule, the default schedule will be returned. If + // quiet hours schedules are not entitled or disabled instance-wide, this + // will return a nil schedule. + Get(ctx context.Context, db database.Store, userID uuid.UUID) (UserQuietHoursScheduleOptions, error) + // Set sets the quiet hours schedule for the given user. If the given + // schedule is an empty string, the user's custom schedule will be cleared + // and the default schedule will be used from now on. If quiet hours + // schedules are not entitled or disabled instance-wide, this will do + // nothing and return a nil schedule. + Set(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (UserQuietHoursScheduleOptions, error) +} + +type agplUserQuietHoursScheduleStore struct{} + +var _ UserQuietHoursScheduleStore = &agplUserQuietHoursScheduleStore{} + +func NewAGPLUserQuietHoursScheduleStore() UserQuietHoursScheduleStore { + return &agplUserQuietHoursScheduleStore{} +} + +func (*agplUserQuietHoursScheduleStore) Get(_ context.Context, _ database.Store, _ uuid.UUID) (UserQuietHoursScheduleOptions, error) { + // User quiet hours windows are not supported in AGPL. + return UserQuietHoursScheduleOptions{ + Schedule: nil, + UserSet: false, + }, nil +} + +func (*agplUserQuietHoursScheduleStore) Set(_ context.Context, _ database.Store, _ uuid.UUID, _ string) (UserQuietHoursScheduleOptions, error) { + // User quiet hours windows are not supported in AGPL. + return UserQuietHoursScheduleOptions{ + Schedule: nil, + UserSet: false, + }, nil +} diff --git a/coderd/templates.go b/coderd/templates.go index 1238303aa04f5..b53d2a65041e4 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -213,17 +213,21 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque } var ( - defaultTTL time.Duration - maxTTL time.Duration - failureTTL time.Duration - inactivityTTL time.Duration - lockedTTL time.Duration + defaultTTL time.Duration + // TODO(@dean): remove max_ttl once restart_requirement is ready + maxTTL time.Duration + restartRequirementDaysOfWeek []string + restartRequirementWeeks int64 + failureTTL time.Duration + inactivityTTL time.Duration + lockedTTL time.Duration ) if createTemplate.DefaultTTLMillis != nil { defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond } - if createTemplate.MaxTTLMillis != nil { - maxTTL = time.Duration(*createTemplate.MaxTTLMillis) * time.Millisecond + if createTemplate.RestartRequirement != nil { + restartRequirementDaysOfWeek = createTemplate.RestartRequirement.DaysOfWeek + restartRequirementWeeks = createTemplate.RestartRequirement.Weeks } if createTemplate.FailureTTLMillis != nil { failureTTL = time.Duration(*createTemplate.FailureTTLMillis) * time.Millisecond @@ -235,7 +239,10 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque lockedTTL = time.Duration(*createTemplate.LockedTTLMillis) * time.Millisecond } - var validErrs []codersdk.ValidationError + var ( + validErrs []codersdk.ValidationError + restartRequirementDaysOfWeekParsed uint8 + ) if defaultTTL < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } @@ -245,6 +252,21 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if maxTTL != 0 && defaultTTL > maxTTL { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) } + if len(restartRequirementDaysOfWeek) > 0 { + restartRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(restartRequirementDaysOfWeek) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.days_of_week", Detail: err.Error()}) + } + } + if createTemplate.MaxTTLMillis != nil { + maxTTL = time.Duration(*createTemplate.MaxTTLMillis) * time.Millisecond + } + if restartRequirementWeeks < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: "Must be a positive integer."}) + } + if restartRequirementWeeks > schedule.MaxTemplateRestartRequirementWeeks { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateRestartRequirementWeeks)}) + } if failureTTL < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."}) } @@ -306,14 +328,18 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque return xerrors.Errorf("get template by id: %s", err) } - dbTemplate, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{ + dbTemplate, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{ UserAutostartEnabled: allowUserAutostart, UserAutostopEnabled: allowUserAutostop, DefaultTTL: defaultTTL, + MaxTTL: maxTTL, // Some of these values are enterprise-only, but the // TemplateScheduleStore will handle avoiding setting them if // unlicensed. - MaxTTL: maxTTL, + RestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: restartRequirementDaysOfWeekParsed, + Weeks: restartRequirementWeeks, + }, FailureTTL: failureTTL, InactivityTTL: inactivityTTL, LockedTTL: lockedTTL, @@ -459,12 +485,24 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { defer commitAudit() aReq.Old = template + scheduleOpts, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching template schedule options.", + Detail: err.Error(), + }) + return + } + var req codersdk.UpdateTemplateMeta if !httpapi.Read(ctx, rw, r, &req) { return } - var validErrs []codersdk.ValidationError + var ( + validErrs []codersdk.ValidationError + restartRequirementDaysOfWeekParsed uint8 + ) if req.DefaultTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be a positive integer."}) } @@ -474,6 +512,24 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.MaxTTLMillis != 0 && req.DefaultTTLMillis > req.MaxTTLMillis { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) } + if req.RestartRequirement == nil { + req.RestartRequirement = &codersdk.TemplateRestartRequirement{ + DaysOfWeek: codersdk.BitmapToWeekdays(scheduleOpts.RestartRequirement.DaysOfWeek), + Weeks: scheduleOpts.RestartRequirement.Weeks, + } + } + if len(req.RestartRequirement.DaysOfWeek) > 0 { + restartRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(req.RestartRequirement.DaysOfWeek) + if err != nil { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.days_of_week", Detail: err.Error()}) + } + } + if req.RestartRequirement.Weeks < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: "Must be a positive integer."}) + } + if req.RestartRequirement.Weeks > schedule.MaxTemplateRestartRequirementWeeks { + validErrs = append(validErrs, codersdk.ValidationError{Field: "restart_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateRestartRequirementWeeks)}) + } if req.FailureTTLMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."}) } @@ -496,7 +552,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { } var updated database.Template - err := api.Database.InTx(func(tx database.Store) error { + err = api.Database.InTx(func(tx database.Store) error { if req.Name == template.Name && req.Description == template.Description && req.DisplayName == template.DisplayName && @@ -506,6 +562,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() && req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() && + restartRequirementDaysOfWeekParsed == scheduleOpts.RestartRequirement.DaysOfWeek && + req.RestartRequirement.Weeks == scheduleOpts.RestartRequirement.Weeks && req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() && req.InactivityTTLMillis == time.Duration(template.InactivityTTL).Milliseconds() && req.LockedTTLMillis == time.Duration(template.LockedTTL).Milliseconds() { @@ -545,12 +603,14 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if defaultTTL != time.Duration(template.DefaultTTL) || maxTTL != time.Duration(template.MaxTTL) || + restartRequirementDaysOfWeekParsed != scheduleOpts.RestartRequirement.DaysOfWeek || + req.RestartRequirement.Weeks != scheduleOpts.RestartRequirement.Weeks || failureTTL != time.Duration(template.FailureTTL) || inactivityTTL != time.Duration(template.InactivityTTL) || lockedTTL != time.Duration(template.LockedTTL) || req.AllowUserAutostart != template.AllowUserAutostart || req.AllowUserAutostop != template.AllowUserAutostop { - updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, schedule.TemplateScheduleOptions{ + 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. @@ -558,9 +618,13 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { UserAutostopEnabled: req.AllowUserAutostop, DefaultTTL: defaultTTL, MaxTTL: maxTTL, - FailureTTL: failureTTL, - InactivityTTL: inactivityTTL, - LockedTTL: lockedTTL, + RestartRequirement: schedule.TemplateRestartRequirement{ + DaysOfWeek: restartRequirementDaysOfWeekParsed, + Weeks: req.RestartRequirement.Weeks, + }, + FailureTTL: failureTTL, + InactivityTTL: inactivityTTL, + LockedTTL: lockedTTL, }) if err != nil { return xerrors.Errorf("set template schedule options: %w", err) @@ -694,5 +758,9 @@ func (api *API) convertTemplate( FailureTTLMillis: time.Duration(template.FailureTTL).Milliseconds(), InactivityTTLMillis: time.Duration(template.InactivityTTL).Milliseconds(), LockedTTLMillis: time.Duration(template.LockedTTL).Milliseconds(), + RestartRequirement: codersdk.TemplateRestartRequirement{ + DaysOfWeek: codersdk.BitmapToWeekdays(uint8(template.RestartRequirementDaysOfWeek)), + Weeks: template.RestartRequirementWeeks, + }, } } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 4bd65387dbfd1..0989e93287c11 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -168,14 +168,9 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Contains(t, err.Error(), "Try logging in using 'coder login '.") }) - t.Run("MaxTTL", func(t *testing.T) { + t.Run("AllowUserScheduling", func(t *testing.T) { t.Parallel() - const ( - defaultTTL = 1 * time.Hour - maxTTL = 24 * time.Hour - ) - t.Run("OK", func(t *testing.T) { t.Parallel() @@ -184,9 +179,10 @@ func TestPostTemplateByOrganization(t *testing.T) { TemplateScheduleStore: schedule.MockTemplateScheduleStore{ SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { atomic.AddInt64(&setCalled, 1) - require.Equal(t, maxTTL, options.MaxTTL) - template.DefaultTTL = int64(options.DefaultTTL) - template.MaxTTL = int64(options.MaxTTL) + require.False(t, options.UserAutostartEnabled) + require.False(t, options.UserAutostopEnabled) + template.AllowUserAutostart = options.UserAutostartEnabled + template.AllowUserAutostop = options.UserAutostopEnabled return template, nil }, }, @@ -198,19 +194,19 @@ func TestPostTemplateByOrganization(t *testing.T) { defer cancel() got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "testing", - VersionID: version.ID, - DefaultTTLMillis: ptr.Ref(int64(0)), - MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + Name: "testing", + VersionID: version.ID, + AllowUserAutostart: ptr.Ref(false), + AllowUserAutostop: ptr.Ref(false), }) require.NoError(t, err) require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) - require.EqualValues(t, 0, got.DefaultTTLMillis) - require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis) + require.False(t, got.AllowUserAutostart) + require.False(t, got.AllowUserAutostop) }) - t.Run("DefaultTTLBigger", func(t *testing.T) { + t.Run("IgnoredUnlicensed", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) @@ -220,25 +216,71 @@ func TestPostTemplateByOrganization(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "testing", - VersionID: version.ID, - DefaultTTLMillis: ptr.Ref((maxTTL * 2).Milliseconds()), - MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + AllowUserAutostart: ptr.Ref(false), + AllowUserAutostop: ptr.Ref(false), }) - require.Error(t, err) - var sdkErr *codersdk.Error - require.ErrorAs(t, err, &sdkErr) - require.Equal(t, http.StatusBadRequest, sdkErr.StatusCode()) - require.Len(t, sdkErr.Validations, 1) - require.Equal(t, "default_ttl_ms", sdkErr.Validations[0].Field) - require.Contains(t, sdkErr.Validations[0].Detail, "Must be less than or equal to max_ttl_ms") + require.NoError(t, err) + // ignored and use AGPL defaults + require.True(t, got.AllowUserAutostart) + require.True(t, got.AllowUserAutostop) }) + }) - t.Run("IgnoredUnlicensed", func(t *testing.T) { + t.Run("NoVersion", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "test", + VersionID: uuid.New(), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) + }) + + t.Run("RestartRequirement", func(t *testing.T) { + t.Parallel() + + t.Run("None", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + atomic.AddInt64(&setCalled, 1) + assert.Zero(t, options.RestartRequirement.DaysOfWeek) + assert.Zero(t, options.RestartRequirement.Weeks) + + err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: options.UserAutostartEnabled, + AllowUserAutostop: options.UserAutostopEnabled, + DefaultTTL: int64(options.DefaultTTL), + MaxTTL: int64(options.MaxTTL), + RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: options.RestartRequirement.Weeks, + FailureTTL: int64(options.FailureTTL), + InactivityTTL: int64(options.InactivityTTL), + LockedTTL: int64(options.LockedTTL), + }) + if !assert.NoError(t, err) { + return database.Template{}, err + } + + return db.GetTemplateByID(ctx, template.ID) + }, + }, + }) user := coderdtest.CreateFirstUser(t, client) version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) @@ -246,19 +288,16 @@ func TestPostTemplateByOrganization(t *testing.T) { defer cancel() got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "testing", - VersionID: version.ID, - DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), - MaxTTLMillis: ptr.Ref(maxTTL.Milliseconds()), + Name: "testing", + VersionID: version.ID, + RestartRequirement: nil, }) require.NoError(t, err) - require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis) - require.Zero(t, got.MaxTTLMillis) - }) - }) - t.Run("AllowUserScheduling", func(t *testing.T) { - t.Parallel() + require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) + require.Empty(t, got.RestartRequirement.DaysOfWeek) + require.Zero(t, got.RestartRequirement.Weeks) + }) t.Run("OK", func(t *testing.T) { t.Parallel() @@ -268,11 +307,27 @@ func TestPostTemplateByOrganization(t *testing.T) { TemplateScheduleStore: schedule.MockTemplateScheduleStore{ SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { atomic.AddInt64(&setCalled, 1) - require.False(t, options.UserAutostartEnabled) - require.False(t, options.UserAutostopEnabled) - template.AllowUserAutostart = options.UserAutostartEnabled - template.AllowUserAutostop = options.UserAutostopEnabled - return template, nil + assert.EqualValues(t, 0b00110000, options.RestartRequirement.DaysOfWeek) + assert.EqualValues(t, 2, options.RestartRequirement.Weeks) + + err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: options.UserAutostartEnabled, + AllowUserAutostop: options.UserAutostopEnabled, + DefaultTTL: int64(options.DefaultTTL), + MaxTTL: int64(options.MaxTTL), + RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: options.RestartRequirement.Weeks, + FailureTTL: int64(options.FailureTTL), + InactivityTTL: int64(options.InactivityTTL), + LockedTTL: int64(options.LockedTTL), + }) + if !assert.NoError(t, err) { + return database.Template{}, err + } + + return db.GetTemplateByID(ctx, template.ID) }, }, }) @@ -283,16 +338,24 @@ func TestPostTemplateByOrganization(t *testing.T) { defer cancel() got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "testing", - VersionID: version.ID, - AllowUserAutostart: ptr.Ref(false), - AllowUserAutostop: ptr.Ref(false), + Name: "testing", + VersionID: version.ID, + RestartRequirement: &codersdk.TemplateRestartRequirement{ + // wrong order + DaysOfWeek: []string{"saturday", "friday"}, + Weeks: 2, + }, }) require.NoError(t, err) require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) - require.False(t, got.AllowUserAutostart) - require.False(t, got.AllowUserAutostop) + require.Equal(t, []string{"friday", "saturday"}, got.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 2, got.RestartRequirement.Weeks) + + got, err = client.Template(ctx, got.ID) + require.NoError(t, err) + require.Equal(t, []string{"friday", "saturday"}, got.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 2, got.RestartRequirement.Weeks) }) t.Run("IgnoredUnlicensed", func(t *testing.T) { @@ -306,33 +369,18 @@ func TestPostTemplateByOrganization(t *testing.T) { defer cancel() got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "testing", - VersionID: version.ID, - AllowUserAutostart: ptr.Ref(false), - AllowUserAutostop: ptr.Ref(false), + Name: "testing", + VersionID: version.ID, + RestartRequirement: &codersdk.TemplateRestartRequirement{ + DaysOfWeek: []string{"friday", "saturday"}, + Weeks: 2, + }, }) require.NoError(t, err) // ignored and use AGPL defaults - require.True(t, got.AllowUserAutostart) - require.True(t, got.AllowUserAutostop) - }) - }) - - t.Run("NoVersion", func(t *testing.T) { - t.Parallel() - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() - - _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ - Name: "test", - VersionID: uuid.New(), + require.Empty(t, got.RestartRequirement.DaysOfWeek) + require.Zero(t, got.RestartRequirement.Weeks) }) - var apiErr *codersdk.Error - require.ErrorAs(t, err, &apiErr) - require.Equal(t, http.StatusNotFound, apiErr.StatusCode()) }) } @@ -539,9 +587,25 @@ func TestPatchTemplateMeta(t *testing.T) { if atomic.AddInt64(&setCalled, 1) == 2 { require.Equal(t, maxTTL, options.MaxTTL) } - template.DefaultTTL = int64(options.DefaultTTL) - template.MaxTTL = int64(options.MaxTTL) - return template, nil + + err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: options.UserAutostartEnabled, + AllowUserAutostop: options.UserAutostopEnabled, + DefaultTTL: int64(options.DefaultTTL), + MaxTTL: int64(options.MaxTTL), + RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: options.RestartRequirement.Weeks, + FailureTTL: int64(options.FailureTTL), + InactivityTTL: int64(options.InactivityTTL), + LockedTTL: int64(options.LockedTTL), + }) + if !assert.NoError(t, err) { + return database.Template{}, err + } + + return db.GetTemplateByID(ctx, template.ID) }, }, }) @@ -674,7 +738,7 @@ func TestPatchTemplateMeta(t *testing.T) { Description: template.Description, Icon: template.Icon, DefaultTTLMillis: 0, - MaxTTLMillis: 0, + RestartRequirement: &template.RestartRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, FailureTTLMillis: failureTTL.Milliseconds(), InactivityTTLMillis: inactivityTTL.Milliseconds(), @@ -709,7 +773,7 @@ func TestPatchTemplateMeta(t *testing.T) { Description: template.Description, Icon: template.Icon, DefaultTTLMillis: template.DefaultTTLMillis, - MaxTTLMillis: template.MaxTTLMillis, + RestartRequirement: &template.RestartRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, FailureTTLMillis: failureTTL.Milliseconds(), InactivityTTLMillis: inactivityTTL.Milliseconds(), @@ -743,7 +807,6 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, allowAutostop.Load(), options.UserAutostopEnabled) template.DefaultTTL = int64(options.DefaultTTL) - template.MaxTTL = int64(options.MaxTTL) template.AllowUserAutostart = options.UserAutostartEnabled template.AllowUserAutostop = options.UserAutostopEnabled return template, nil @@ -769,7 +832,7 @@ func TestPatchTemplateMeta(t *testing.T) { Description: template.Description, Icon: template.Icon, DefaultTTLMillis: template.DefaultTTLMillis, - MaxTTLMillis: template.MaxTTLMillis, + RestartRequirement: &template.RestartRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, AllowUserAutostart: allowAutostart.Load(), AllowUserAutostop: allowAutostop.Load(), @@ -801,7 +864,7 @@ func TestPatchTemplateMeta(t *testing.T) { Icon: template.Icon, // Increase the default TTL to avoid error "not modified". DefaultTTLMillis: template.DefaultTTLMillis + 1, - MaxTTLMillis: template.MaxTTLMillis, + RestartRequirement: &template.RestartRequirement, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, AllowUserAutostart: false, AllowUserAutostop: false, @@ -832,6 +895,7 @@ func TestPatchTemplateMeta(t *testing.T) { Description: template.Description, Icon: template.Icon, DefaultTTLMillis: template.DefaultTTLMillis, + RestartRequirement: nil, AllowUserAutostart: template.AllowUserAutostart, AllowUserAutostop: template.AllowUserAutostop, } @@ -900,34 +964,187 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, updated.Icon, "") }) - t.Run("MaxTTLEnterpriseOnly", func(t *testing.T) { + t.Run("RestartRequirement", func(t *testing.T) { t.Parallel() - client := coderdtest.New(t, nil) - user := coderdtest.CreateFirstUser(t, client) - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - require.EqualValues(t, 0, template.MaxTTLMillis) - req := codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - DefaultTTLMillis: time.Hour.Milliseconds(), - MaxTTLMillis: (2 * time.Hour).Milliseconds(), - } + t.Run("OK", func(t *testing.T) { + t.Parallel() - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - defer cancel() + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + if atomic.AddInt64(&setCalled, 1) == 2 { + assert.EqualValues(t, 0b0110000, options.RestartRequirement.DaysOfWeek) + assert.EqualValues(t, 2, options.RestartRequirement.Weeks) + } - updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) - require.NoError(t, err) - require.EqualValues(t, 0, updated.MaxTTLMillis) + err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: options.UserAutostartEnabled, + AllowUserAutostop: options.UserAutostopEnabled, + DefaultTTL: int64(options.DefaultTTL), + MaxTTL: int64(options.MaxTTL), + RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: options.RestartRequirement.Weeks, + FailureTTL: int64(options.FailureTTL), + InactivityTTL: int64(options.InactivityTTL), + LockedTTL: int64(options.LockedTTL), + }) + if !assert.NoError(t, err) { + return database.Template{}, err + } - template, err = client.Template(ctx, template.ID) - require.NoError(t, err) - require.EqualValues(t, 0, template.MaxTTLMillis) + return db.GetTemplateByID(ctx, template.ID) + }, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) + req := codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: time.Hour.Milliseconds(), + RestartRequirement: &codersdk.TemplateRestartRequirement{ + // wrong order + DaysOfWeek: []string{"saturday", "friday"}, + Weeks: 2, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) + require.Equal(t, []string{"friday", "saturday"}, updated.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 2, updated.RestartRequirement.Weeks) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Equal(t, []string{"friday", "saturday"}, template.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 2, template.RestartRequirement.Weeks) + }) + + t.Run("Unset", func(t *testing.T) { + t.Parallel() + + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + if atomic.AddInt64(&setCalled, 1) == 2 { + assert.EqualValues(t, 0, options.RestartRequirement.DaysOfWeek) + assert.EqualValues(t, 0, options.RestartRequirement.Weeks) + } + + err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: template.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: options.UserAutostartEnabled, + AllowUserAutostop: options.UserAutostopEnabled, + DefaultTTL: int64(options.DefaultTTL), + MaxTTL: int64(options.MaxTTL), + RestartRequirementDaysOfWeek: int16(options.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: options.RestartRequirement.Weeks, + FailureTTL: int64(options.FailureTTL), + InactivityTTL: int64(options.InactivityTTL), + LockedTTL: int64(options.LockedTTL), + }) + if !assert.NoError(t, err) { + return database.Template{}, err + } + + return db.GetTemplateByID(ctx, template.ID) + }, + }, + }) + user := coderdtest.CreateFirstUser(t, client) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.RestartRequirement = &codersdk.TemplateRestartRequirement{ + // wrong order + DaysOfWeek: []string{"sunday", "saturday", "friday", "thursday", "wednesday", "tuesday", "monday"}, + Weeks: 2, + } + }) + require.EqualValues(t, 1, atomic.LoadInt64(&setCalled)) + require.Equal(t, []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}, template.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 2, template.RestartRequirement.Weeks) + req := codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: time.Hour.Milliseconds(), + RestartRequirement: &codersdk.TemplateRestartRequirement{ + DaysOfWeek: []string{}, + Weeks: 0, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) + require.Empty(t, updated.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 0, updated.RestartRequirement.Weeks) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 0, template.RestartRequirement.Weeks) + }) + + t.Run("EnterpriseOnly", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) + req := codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: time.Hour.Milliseconds(), + RestartRequirement: &codersdk.TemplateRestartRequirement{ + DaysOfWeek: []string{"monday"}, + Weeks: 2, + }, + } + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + require.Empty(t, updated.RestartRequirement.DaysOfWeek) + require.Zero(t, updated.RestartRequirement.Weeks) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Empty(t, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) + }) }) } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index ff7cef0f6dfa9..8ac07b5ddd51e 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -369,7 +369,7 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, template.ID) + templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, template.ID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error fetching template schedule.", @@ -378,7 +378,13 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req return } - dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL, templateSchedule.MaxTTL) + maxTTL := templateSchedule.MaxTTL + if templateSchedule.UseRestartRequirement { + // If we're using restart requirements, there isn't a max TTL. + maxTTL = 0 + } + + dbTTL, err := validWorkspaceTTLMillis(createWorkspace.TTLMillis, templateSchedule.DefaultTTL, maxTTL) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ Message: "Invalid Workspace Time to Shutdown.", @@ -634,7 +640,7 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { } // Check if the template allows users to configure autostart. - templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, api.Database, workspace.TemplateID) + templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, api.Database, workspace.TemplateID) if err != nil { httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ Message: "Internal error getting template schedule options.", @@ -701,7 +707,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { var dbTTL sql.NullInt64 err := api.Database.InTx(func(s database.Store) error { - templateSchedule, err := (*api.TemplateScheduleStore.Load()).GetTemplateScheduleOptions(ctx, s, workspace.TemplateID) + templateSchedule, err := (*api.TemplateScheduleStore.Load()).Get(ctx, s, workspace.TemplateID) if err != nil { return xerrors.Errorf("get template schedule: %w", err) } @@ -709,10 +715,16 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return codersdk.ValidationError{Field: "ttl_ms", Detail: "Custom autostop TTL is not allowed for workspaces using this template."} } + maxTTL := templateSchedule.MaxTTL + if templateSchedule.UseRestartRequirement { + // If we're using restart requirements, there isn't a max TTL. + maxTTL = 0 + } + // don't override 0 ttl with template default here because it indicates // disabled autostop var validityErr error - dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, templateSchedule.MaxTTL) + dbTTL, validityErr = validWorkspaceTTLMillis(req.TTLMillis, 0, maxTTL) if validityErr != nil { return codersdk.ValidationError{Field: "ttl_ms", Detail: validityErr.Error()} } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 6f87d4ba22978..d0609efcba355 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1741,7 +1741,7 @@ func TestWorkspaceUpdateAutostart(t *testing.T) { UserAutostartEnabled: false, UserAutostopEnabled: false, DefaultTTL: 0, - MaxTTL: 0, + RestartRequirement: schedule.TemplateRestartRequirement{}, }, nil }, SetFn: func(_ context.Context, _ database.Store, tpl database.Template, _ schedule.TemplateScheduleOptions) (database.Template, error) { @@ -1908,7 +1908,7 @@ func TestWorkspaceUpdateTTL(t *testing.T) { UserAutostartEnabled: false, UserAutostopEnabled: false, DefaultTTL: 0, - MaxTTL: 0, + RestartRequirement: schedule.TemplateRestartRequirement{}, }, nil }, SetFn: func(_ context.Context, _ database.Store, tpl database.Template, _ schedule.TemplateScheduleOptions) (database.Template, error) { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 73e3c2dd5d450..d56131c12ee78 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -45,6 +45,7 @@ const ( FeatureExternalProvisionerDaemons FeatureName = "external_provisioner_daemons" FeatureAppearance FeatureName = "appearance" FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling" + FeatureTemplateRestartRequirement FeatureName = "template_restart_requirement" FeatureWorkspaceProxy FeatureName = "workspace_proxy" ) @@ -167,6 +168,7 @@ type DeploymentValues struct { DisableOwnerWorkspaceExec clibase.Bool `json:"disable_owner_workspace_exec,omitempty" typescript:",notnull"` ProxyHealthStatusInterval clibase.Duration `json:"proxy_health_status_interval,omitempty" typescript:",notnull"` EnableTerraformDebugMode clibase.Bool `json:"enable_terraform_debug_mode,omitempty" typescript:",notnull"` + UserQuietHoursSchedule UserQuietHoursScheduleConfig `json:"user_quiet_hours_schedule,omitempty" typescript:",notnull"` Config clibase.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig clibase.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -344,6 +346,13 @@ type DangerousConfig struct { AllowAllCors clibase.Bool `json:"allow_all_cors" typescript:",notnull"` } +type UserQuietHoursScheduleConfig struct { + DefaultSchedule clibase.String `json:"default_schedule" typescript:",notnull"` + // TODO: add WindowDuration and the ability to postpone max_deadline by this + // amount + // WindowDuration clibase.Duration `json:"window_duration" typescript:",notnull"` +} + const ( annotationEnterpriseKey = "enterprise" annotationSecretKey = "secret" @@ -467,6 +476,11 @@ when required by your organization's security policy.`, Description: `Tune the behavior of the provisioner, which is responsible for creating, updating, and deleting workspace resources.`, YAML: "provisioning", } + deploymentGroupUserQuietHoursSchedule = clibase.Group{ + Name: "User Quiet Hours Schedule", + Description: "Allow users to set quiet hours schedules each day for workspaces to avoid workspaces stopping during the day due to template max TTL.", + YAML: "userQuietHoursSchedule", + } deploymentGroupDangerous = clibase.Group{ Name: "⚠️ Dangerous", YAML: "dangerous", @@ -1581,6 +1595,16 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupNetworkingHTTP, YAML: "proxyHealthInterval", }, + { + Name: "Default Quiet Hours Schedule", + Description: "The default daily cron schedule applied to users that haven't set a custom quiet hours schedule themselves. The quiet hours schedule determines when workspaces will be force stopped due to the template's max TTL, and will round the max TTL up to be within the user's quiet hours window (or default). The format is the same as the standard cron format, but the day-of-month, month and day-of-week must be *. Only one hour and minute can be specified (ranges or comma separated values are not supported).", + Flag: "default-quiet-hours-schedule", + Env: "CODER_QUIET_HOURS_DEFAULT_SCHEDULE", + Default: "", + Value: &c.UserQuietHoursSchedule.DefaultSchedule, + Group: &deploymentGroupUserQuietHoursSchedule, + YAML: "defaultQuietHoursSchedule", + }, } return opts } @@ -1782,6 +1806,19 @@ const ( ExperimentSingleTailnet Experiment = "single_tailnet" ExperimentWorkspaceBuildLogsUI Experiment = "workspace_build_logs_ui" + + // ExperimentTemplateRestartRequirement allows template admins to have more + // control over when workspaces created on a template are required to + // restart, and allows users to ensure these restarts never happen during + // their business hours. + // + // Enables: + // - User quiet hours schedule settings + // - Template restart requirement settings + // - Changes the max_deadline algorithm to use restart requirement and user + // quiet hours instead of max_ttl. + ExperimentTemplateRestartRequirement Experiment = "template_restart_requirement" + // Add new experiments here! // ExperimentExample Experiment = "example" ) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index e1a0d2f1e2c4c..26290fd4f4761 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -84,9 +84,11 @@ type CreateTemplateRequest struct { // DefaultTTLMillis allows optionally specifying the default TTL // for all workspaces created from this template. DefaultTTLMillis *int64 `json:"default_ttl_ms,omitempty"` - // MaxTTLMillis allows optionally specifying the max lifetime for - // workspaces created from this template. + // TODO(@dean): remove max_ttl once restart_requirement is matured MaxTTLMillis *int64 `json:"max_ttl_ms,omitempty"` + // RestartRequirement allows optionally specifying the restart requirement + // for workspaces created from this template. This is an enterprise feature. + RestartRequirement *TemplateRestartRequirement `json:"restart_requirement,omitempty"` // Allow users to cancel in-progress workspace jobs. // *bool as the default value is "true". diff --git a/codersdk/templates.go b/codersdk/templates.go index b9ed4422763db..91feb51b59eba 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "time" "github.com/google/uuid" @@ -28,11 +29,13 @@ type Template struct { Description string `json:"description"` Icon string `json:"icon"` DefaultTTLMillis int64 `json:"default_ttl_ms"` - // MaxTTLMillis is an enterprise feature. It's value is only used if your - // license is entitled to use the advanced template scheduling feature. - MaxTTLMillis int64 `json:"max_ttl_ms"` - CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"` - CreatedByName string `json:"created_by_name"` + // TODO(@dean): remove max_ttl once restart_requirement is matured + MaxTTLMillis int64 `json:"max_ttl_ms"` + // RestartRequirement is an enterprise feature. Its value is only used if + // your license is entitled to use the advanced template scheduling feature. + RestartRequirement TemplateRestartRequirement `json:"restart_requirement"` + CreatedByID uuid.UUID `json:"created_by_id" format:"uuid"` + CreatedByName string `json:"created_by_name"` // AllowUserAutostart and AllowUserAutostop are enterprise-only. Their // values are only used if your license is entitled to use the advanced @@ -49,6 +52,78 @@ type Template struct { LockedTTLMillis int64 `json:"locked_ttl_ms"` } +// WeekdaysToBitmap converts a list of weekdays to a bitmap in accordance with +// the schedule package's rules. The 0th bit is Monday, ..., the 6th bit is +// Sunday. The 7th bit is unused. +func WeekdaysToBitmap(days []string) (uint8, error) { + var bitmap uint8 + for _, day := range days { + switch strings.ToLower(day) { + case "monday": + bitmap |= 1 << 0 + case "tuesday": + bitmap |= 1 << 1 + case "wednesday": + bitmap |= 1 << 2 + case "thursday": + bitmap |= 1 << 3 + case "friday": + bitmap |= 1 << 4 + case "saturday": + bitmap |= 1 << 5 + case "sunday": + bitmap |= 1 << 6 + default: + return 0, xerrors.Errorf("invalid weekday %q", day) + } + } + return bitmap, nil +} + +// BitmapToWeekdays converts a bitmap to a list of weekdays in accordance with +// the schedule package's rules (see above). +func BitmapToWeekdays(bitmap uint8) []string { + var days []string + for i := 0; i < 7; i++ { + if bitmap&(1< -| Resource | | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
locked_attrue
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
locked_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
restart_requirement_days_of_weektrue
restart_requirement_weekstrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
locked_attrue
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/enterprise.md b/docs/api/enterprise.md index e459d0ecdf2fa..4ed43c12ce770 100644 --- a/docs/api/enterprise.md +++ b/docs/api/enterprise.md @@ -1122,6 +1122,128 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template}/acl \ To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get user quiet hours schedule + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/users/{user}/quiet-hours \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /users/{user}/quiet-hours` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------------ | -------- | ----------- | +| `user` | path | string(uuid) | true | User ID | + +### Example responses + +> 200 Response + +```json +[ + { + "next": "2019-08-24T14:15:22Z", + "raw_schedule": "string", + "time": "string", + "timezone": "string", + "user_set": true + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserQuietHoursScheduleResponse](schemas.md#codersdkuserquiethoursscheduleresponse) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ----------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» next` | string(date-time) | false | | Next is the next time that the quiet hours window will start. | +| `» raw_schedule` | string | false | | | +| `» time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | +| `» timezone` | string | false | | raw format from the cron expression, UTC if unspecified | +| `» user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update user quiet hours schedule + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/users/{user}/quiet-hours \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /users/{user}/quiet-hours` + +> Body parameter + +```json +{ + "schedule": "string" +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | ------------------------------------------------------------------------------------------------------ | -------- | ----------------------- | +| `user` | path | string(uuid) | true | User ID | +| `body` | body | [codersdk.UpdateUserQuietHoursScheduleRequest](schemas.md#codersdkupdateuserquiethoursschedulerequest) | true | Update schedule request | + +### Example responses + +> 200 Response + +```json +[ + { + "next": "2019-08-24T14:15:22Z", + "raw_schedule": "string", + "time": "string", + "timezone": "string", + "user_set": true + } +] +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | array of [codersdk.UserQuietHoursScheduleResponse](schemas.md#codersdkuserquiethoursscheduleresponse) | + +

Response Schema

+ +Status Code **200** + +| Name | Type | Required | Restrictions | Description | +| ---------------- | ----------------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» next` | string(date-time) | false | | Next is the next time that the quiet hours window will start. | +| `» raw_schedule` | string | false | | | +| `» time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | +| `» timezone` | string | false | | raw format from the cron expression, UTC if unspecified | +| `» user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Get workspace quota by user ### Code samples diff --git a/docs/api/general.md b/docs/api/general.md index 4b19ecd4f87e2..be2d6c9ee2d7c 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -370,6 +370,9 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "honeycomb_api_key": "string" }, "update_check": true, + "user_quiet_hours_schedule": { + "default_schedule": "string" + }, "verbose": true, "wgtunnel_host": "string", "wildcard_access_url": { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index c7860c4d22a0a..7af780fc5bbf9 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1382,28 +1382,33 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "locked_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `allow_user_autostart` | boolean | false | | Allow user autostart allows users to set a schedule for autostarting their workspace. By default this is true. This can only be disabled when using an enterprise license. | -| `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. | -| `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. \*bool as the default value is "true". | -| `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. | -| `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. | -| `disable_everyone_group_access` | boolean | false | | Disable everyone group access allows optionally disabling the default behavior of granting the 'everyone' group access to use the template. 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. | -| `display_name` | string | false | | Display name is the displayed name of the template. | -| `failure_ttl_ms` | integer | false | | Failure ttl ms allows optionally specifying the max lifetime before Coder stops all resources for failed workspaces created from this template. | -| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | -| `inactivity_ttl_ms` | integer | false | | Inactivity ttl ms allows optionally specifying the max lifetime before Coder locks inactive workspaces created from this template. | -| `locked_ttl_ms` | integer | false | | Locked ttl ms allows optionally specifying the max lifetime before Coder permanently deletes locked workspaces created from this template. | -| `max_ttl_ms` | integer | false | | Max ttl ms allows optionally specifying the max lifetime for workspaces created from this template. | -| `name` | string | true | | Name is the name of the template. | -| `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. | +| Name | Type | Required | Restrictions | Description | +| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `allow_user_autostart` | boolean | false | | Allow user autostart allows users to set a schedule for autostarting their workspace. By default this is true. This can only be disabled when using an enterprise license. | +| `allow_user_autostop` | boolean | false | | Allow user autostop allows users to set a custom workspace TTL to use in place of the template's DefaultTTL field. By default this is true. If false, the DefaultTTL will always be used. This can only be disabled when using an enterprise license. | +| `allow_user_cancel_workspace_jobs` | boolean | false | | Allow users to cancel in-progress workspace jobs. \*bool as the default value is "true". | +| `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. | +| `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. | +| `disable_everyone_group_access` | boolean | false | | Disable everyone group access allows optionally disabling the default behavior of granting the 'everyone' group access to use the template. 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. | +| `display_name` | string | false | | Display name is the displayed name of the template. | +| `failure_ttl_ms` | integer | false | | Failure ttl ms allows optionally specifying the max lifetime before Coder stops all resources for failed workspaces created from this template. | +| `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | +| `inactivity_ttl_ms` | integer | false | | Inactivity ttl ms allows optionally specifying the max lifetime before Coder locks inactive workspaces created from this template. | +| `locked_ttl_ms` | integer | false | | Locked ttl ms allows optionally specifying the max lifetime before Coder permanently deletes locked workspaces created from this template. | +| `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured | +| `name` | string | true | | Name is the name of the template. | +| `restart_requirement` | [codersdk.TemplateRestartRequirement](#codersdktemplaterestartrequirement) | false | | Restart requirement allows optionally specifying the restart requirement for workspaces created from this template. This is an enterprise feature. | +| `template_version_id` | string | true | | Template version ID is an in-progress or completed job to use as an initial version of the template. | | This is required on creation to enable a user-flow of validating a template works. There is no reason the data-model cannot support empty templates, but it doesn't make sense for users. | ## codersdk.CreateTemplateVersionDryRunRequest @@ -2076,6 +2081,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "honeycomb_api_key": "string" }, "update_check": true, + "user_quiet_hours_schedule": { + "default_schedule": "string" + }, "verbose": true, "wgtunnel_host": "string", "wildcard_access_url": { @@ -2426,6 +2434,9 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "honeycomb_api_key": "string" }, "update_check": true, + "user_quiet_hours_schedule": { + "default_schedule": "string" + }, "verbose": true, "wgtunnel_host": "string", "wildcard_access_url": { @@ -2497,6 +2508,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `tls` | [codersdk.TLSConfig](#codersdktlsconfig) | false | | | | `trace` | [codersdk.TraceConfig](#codersdktraceconfig) | false | | | | `update_check` | boolean | false | | | +| `user_quiet_hours_schedule` | [codersdk.UserQuietHoursScheduleConfig](#codersdkuserquiethoursscheduleconfig) | false | | | | `verbose` | boolean | false | | | | `wgtunnel_host` | string | false | | | | `wildcard_access_url` | [clibase.URL](#clibaseurl) | false | | | @@ -2566,14 +2578,15 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in #### Enumerated Values -| Value | -| ------------------------- | -| `moons` | -| `workspace_actions` | -| `tailnet_ha_coordinator` | -| `convert-to-oidc` | -| `single_tailnet` | -| `workspace_build_logs_ui` | +| Value | +| ------------------------------ | +| `moons` | +| `workspace_actions` | +| `tailnet_ha_coordinator` | +| `convert-to-oidc` | +| `single_tailnet` | +| `workspace_build_logs_ui` | +| `template_restart_requirement` | ## codersdk.Feature @@ -4001,36 +4014,41 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ``` ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------------------------------- | ------------------------------------------------------------------ | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `active_user_count` | integer | false | | Active user count is set to -1 when loading. | -| `active_version_id` | string | false | | | -| `allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | -| `allow_user_autostop` | boolean | false | | | -| `allow_user_cancel_workspace_jobs` | boolean | false | | | -| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | | -| `created_at` | string | false | | | -| `created_by_id` | string | false | | | -| `created_by_name` | string | false | | | -| `default_ttl_ms` | integer | false | | | -| `description` | string | false | | | -| `display_name` | string | false | | | -| `failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | -| `icon` | string | false | | | -| `id` | string | false | | | -| `inactivity_ttl_ms` | integer | false | | | -| `locked_ttl_ms` | integer | false | | | -| `max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. | -| `name` | string | false | | | -| `organization_id` | string | false | | | -| `provisioner` | string | false | | | -| `updated_at` | string | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------------------------------- | -------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `active_user_count` | integer | false | | Active user count is set to -1 when loading. | +| `active_version_id` | string | false | | | +| `allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | +| `allow_user_autostop` | boolean | false | | | +| `allow_user_cancel_workspace_jobs` | boolean | false | | | +| `build_time_stats` | [codersdk.TemplateBuildTimeStats](#codersdktemplatebuildtimestats) | false | | | +| `created_at` | string | false | | | +| `created_by_id` | string | false | | | +| `created_by_name` | string | false | | | +| `default_ttl_ms` | integer | false | | | +| `description` | string | false | | | +| `display_name` | string | false | | | +| `failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | +| `icon` | string | false | | | +| `id` | string | false | | | +| `inactivity_ttl_ms` | integer | false | | | +| `locked_ttl_ms` | integer | false | | | +| `max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured | +| `name` | string | false | | | +| `organization_id` | string | false | | | +| `provisioner` | string | false | | | +| `restart_requirement` | [codersdk.TemplateRestartRequirement](#codersdktemplaterestartrequirement) | false | | Restart requirement is an enterprise feature. Its value is only used if your license is entitled to use the advanced template scheduling feature. | +| `updated_at` | string | false | | | #### Enumerated Values @@ -4085,6 +4103,23 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `tags` | array of string | false | | | | `url` | string | false | | | +## codersdk.TemplateRestartRequirement + +```json +{ + "days_of_week": ["monday"], + "weeks": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------------------------------------------------------------------------- | --------------- | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `days_of_week` | array of string | false | | Days of week is a list of days of the week on which restarts are required. Restarts happen within the user's quiet hours (in their configured timezone). If no days are specified, restarts are not required. Weekdays cannot be specified twice. | +| Restarts will only happen on weekdays in this list on weeks which line up with Weeks. | +| `weeks` | integer | false | | Weeks is the number of weeks between required restarts. Weeks are synced across all workspaces (and Coder deployments) using modulo math on a hardcoded epoch week of January 2nd, 2023 (the first Monday of 2023). Values of 0 or 1 indicate weekly restarts. Values of 2 indicate fortnightly restarts, etc. | + ## codersdk.TemplateRole ```json @@ -4531,6 +4566,23 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | ---------- | ------ | -------- | ------------ | ----------- | | `username` | string | true | | | +## codersdk.UpdateUserQuietHoursScheduleRequest + +```json +{ + "schedule": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------- | ------ | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `schedule` | string | true | | Schedule is a cron expression that defines when the user's quiet hours window is. Schedule must not be empty. For new users, the schedule is set to 2am in their browser or computer's timezone. The schedule denotes the beginning of a 4 hour window where the workspace is allowed to automatically stop or restart due to maintenance or template max TTL. | + +The schedule must be daily with a single time, and should have a timezone specified via a CRON_TZ prefix (otherwise UTC will be used). +If the schedule is empty, the user will be updated to use the default schedule.| + ## codersdk.UpdateWorkspaceAutostartRequest ```json @@ -4657,6 +4709,42 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | ------------ | ---------------------------------------- | -------- | ------------ | ----------- | | `login_type` | [codersdk.LoginType](#codersdklogintype) | false | | | +## codersdk.UserQuietHoursScheduleConfig + +```json +{ + "default_schedule": "string" +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------ | ------ | -------- | ------------ | ----------- | +| `default_schedule` | string | false | | | + +## codersdk.UserQuietHoursScheduleResponse + +```json +{ + "next": "2019-08-24T14:15:22Z", + "raw_schedule": "string", + "time": "string", + "timezone": "string", + "user_set": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| -------------- | ------- | -------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | +| `next` | string | false | | Next is the next time that the quiet hours window will start. | +| `raw_schedule` | string | false | | | +| `time` | string | false | | Time is the time of day that the quiet hours window starts in the given Timezone each day. | +| `timezone` | string | false | | raw format from the cron expression, UTC if unspecified | +| `user_set` | boolean | false | | User set is true if the user has set their own quiet hours schedule. If false, the user is using the default schedule. | + ## codersdk.UserStatus ```json diff --git a/docs/api/templates.md b/docs/api/templates.md index 032c620900834..06b11216727ae 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -56,6 +56,10 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ] @@ -71,34 +75,38 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat Status Code **200** -| Name | Type | Required | Restrictions | Description | -| ------------------------------------ | ---------------------------------------------------------------------------- | -------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `[array item]` | array | false | | | -| `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | -| `» active_version_id` | string(uuid) | false | | | -| `» allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | -| `» allow_user_autostop` | boolean | false | | | -| `» allow_user_cancel_workspace_jobs` | boolean | false | | | -| `» build_time_stats` | [codersdk.TemplateBuildTimeStats](schemas.md#codersdktemplatebuildtimestats) | false | | | -| `»» [any property]` | [codersdk.TransitionStats](schemas.md#codersdktransitionstats) | false | | | -| `»»» p50` | integer | false | | | -| `»»» p95` | integer | false | | | -| `» created_at` | string(date-time) | false | | | -| `» created_by_id` | string(uuid) | false | | | -| `» created_by_name` | string | false | | | -| `» default_ttl_ms` | integer | false | | | -| `» description` | string | false | | | -| `» display_name` | string | false | | | -| `» failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | -| `» icon` | string | false | | | -| `» id` | string(uuid) | false | | | -| `» inactivity_ttl_ms` | integer | false | | | -| `» locked_ttl_ms` | integer | false | | | -| `» max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. | -| `» name` | string | false | | | -| `» organization_id` | string(uuid) | false | | | -| `» provisioner` | string | false | | | -| `» updated_at` | string(date-time) | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ | -------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `[array item]` | array | false | | | +| `» active_user_count` | integer | false | | Active user count is set to -1 when loading. | +| `» active_version_id` | string(uuid) | false | | | +| `» allow_user_autostart` | boolean | false | | Allow user autostart and AllowUserAutostop are enterprise-only. Their values are only used if your license is entitled to use the advanced template scheduling feature. | +| `» allow_user_autostop` | boolean | false | | | +| `» allow_user_cancel_workspace_jobs` | boolean | false | | | +| `» build_time_stats` | [codersdk.TemplateBuildTimeStats](schemas.md#codersdktemplatebuildtimestats) | false | | | +| `»» [any property]` | [codersdk.TransitionStats](schemas.md#codersdktransitionstats) | false | | | +| `»»» p50` | integer | false | | | +| `»»» p95` | integer | false | | | +| `» created_at` | string(date-time) | false | | | +| `» created_by_id` | string(uuid) | false | | | +| `» created_by_name` | string | false | | | +| `» default_ttl_ms` | integer | false | | | +| `» description` | string | false | | | +| `» display_name` | string | false | | | +| `» failure_ttl_ms` | integer | false | | Failure ttl ms InactivityTTLMillis, and LockedTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | +| `» icon` | string | false | | | +| `» id` | string(uuid) | false | | | +| `» inactivity_ttl_ms` | integer | false | | | +| `» locked_ttl_ms` | integer | false | | | +| `» max_ttl_ms` | integer | false | | Max ttl ms remove max_ttl once restart_requirement is matured | +| `» name` | string | false | | | +| `» organization_id` | string(uuid) | false | | | +| `» provisioner` | string | false | | | +| `» restart_requirement` | [codersdk.TemplateRestartRequirement](schemas.md#codersdktemplaterestartrequirement) | false | | Restart requirement is an enterprise feature. Its value is only used if your license is entitled to use the advanced template scheduling feature. | +| `»» days_of_week` | array | false | | »days of week is a list of days of the week on which restarts are required. Restarts happen within the user's quiet hours (in their configured timezone). If no days are specified, restarts are not required. Weekdays cannot be specified twice. | +| Restarts will only happen on weekdays in this list on weeks which line up with Weeks. | +| `»» weeks` | integer | false | | Weeks is the number of weeks between required restarts. Weeks are synced across all workspaces (and Coder deployments) using modulo math on a hardcoded epoch week of January 2nd, 2023 (the first Monday of 2023). Values of 0 or 1 indicate weekly restarts. Values of 2 indicate fortnightly restarts, etc. | +| `» updated_at` | string(date-time) | false | | | #### Enumerated Values @@ -139,6 +147,10 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "locked_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1" } ``` @@ -186,6 +198,10 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -314,6 +330,10 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -648,6 +668,10 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ``` @@ -759,6 +783,10 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", "provisioner": "terraform", + "restart_requirement": { + "days_of_week": ["monday"], + "weeks": 0 + }, "updated_at": "2019-08-24T14:15:22Z" } ``` diff --git a/docs/cli/server.md b/docs/cli/server.md index 10ce138ea4d4c..617399fbb6af4 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -183,6 +183,16 @@ An HTTP URL that is accessible by other replicas to relay DERP traffic. Required Addresses for STUN servers to establish P2P connections. Use special value 'disable' to turn off STUN. +### --default-quiet-hours-schedule + +| | | +| ----------- | ------------------------------------------------------------- | +| Type | string | +| Environment | $CODER_QUIET_HOURS_DEFAULT_SCHEDULE | +| YAML | userQuietHoursSchedule.defaultQuietHoursSchedule | + +The default daily cron schedule applied to users that haven't set a custom quiet hours schedule themselves. The quiet hours schedule determines when workspaces will be force stopped due to the template's max TTL, and will round the max TTL up to be within the user's quiet hours window (or default). The format is the same as the standard cron format, but the day-of-month, month and day-of-week must be \*. Only one hour and minute can be specified (ranges or comma separated values are not supported). + ### --disable-owner-workspace-access | | | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index d75776a437183..1ae7648456eef 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -70,6 +70,9 @@ var auditableResourcesTypes = map[any]map[string]Action{ "description": ActionTrack, "icon": ActionTrack, "default_ttl": ActionTrack, + "max_ttl": ActionTrack, + "restart_requirement_days_of_week": ActionTrack, + "restart_requirement_weeks": ActionTrack, "created_by": ActionTrack, "created_by_username": ActionIgnore, "created_by_avatar_url": ActionIgnore, @@ -78,7 +81,6 @@ var auditableResourcesTypes = map[any]map[string]Action{ "allow_user_autostart": ActionTrack, "allow_user_autostop": ActionTrack, "allow_user_cancel_workspace_jobs": ActionTrack, - "max_ttl": ActionTrack, "failure_ttl": ActionTrack, "inactivity_ttl": ActionTrack, "locked_ttl": ActionTrack, @@ -97,18 +99,19 @@ var auditableResourcesTypes = map[any]map[string]Action{ "git_auth_providers": ActionIgnore, // Not helpful because this can only change when new versions are added. }, &database.User{}: { - "id": ActionTrack, - "email": ActionTrack, - "username": ActionTrack, - "hashed_password": ActionSecret, // Do not expose a users hashed password. - "created_at": ActionIgnore, // Never changes. - "updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff. - "status": ActionTrack, - "rbac_roles": ActionTrack, - "login_type": ActionTrack, - "avatar_url": ActionIgnore, - "last_seen_at": ActionIgnore, - "deleted": ActionTrack, + "id": ActionTrack, + "email": ActionTrack, + "username": ActionTrack, + "hashed_password": ActionSecret, // Do not expose a users hashed password. + "created_at": ActionIgnore, // Never changes. + "updated_at": ActionIgnore, // Changes, but is implicit and not helpful in a diff. + "status": ActionTrack, + "rbac_roles": ActionTrack, + "login_type": ActionTrack, + "avatar_url": ActionIgnore, + "last_seen_at": ActionIgnore, + "deleted": ActionTrack, + "quiet_hours_schedule": ActionTrack, }, &database.Workspace{}: { "id": ActionTrack, diff --git a/enterprise/cli/server.go b/enterprise/cli/server.go index 215e9026cfb8b..b0561a0de1850 100644 --- a/enterprise/cli/server.go +++ b/enterprise/cli/server.go @@ -57,14 +57,15 @@ func (r *RootCmd) server() *clibase.Cmd { options.TrialGenerator = trialer.New(options.Database, "https://v2-licensor.coder.com/trial", coderd.Keys) o := &coderd.Options{ - AuditLogging: true, - BrowserOnly: options.DeploymentValues.BrowserOnly.Value(), - SCIMAPIKey: []byte(options.DeploymentValues.SCIMAPIKey.Value()), - RBAC: true, - DERPServerRelayAddress: options.DeploymentValues.DERP.Server.RelayURL.String(), - DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()), - Options: options, - ProxyHealthInterval: options.DeploymentValues.ProxyHealthStatusInterval.Value(), + Options: options, + AuditLogging: true, + BrowserOnly: options.DeploymentValues.BrowserOnly.Value(), + SCIMAPIKey: []byte(options.DeploymentValues.SCIMAPIKey.Value()), + RBAC: true, + DERPServerRelayAddress: options.DeploymentValues.DERP.Server.RelayURL.String(), + DERPServerRegionID: int(options.DeploymentValues.DERP.Server.RegionID.Value()), + ProxyHealthInterval: options.DeploymentValues.ProxyHealthStatusInterval.Value(), + DefaultQuietHoursSchedule: options.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), } api, err := coderd.New(ctx, o) diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 66bd33d96b5c9..c779bd399945a 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -377,6 +377,20 @@ telemetrywhen required by your organization's security policy. anonymized application tracing to help improve our product. Disabling telemetry also disables this option. +User Quiet Hours Schedule Options +Allow users to set quiet hours schedules each day for workspaces to avoid +workspaces stopping during the day due to template max TTL. + + --default-quiet-hours-schedule string, $CODER_QUIET_HOURS_DEFAULT_SCHEDULE + The default daily cron schedule applied to users that haven't set a + custom quiet hours schedule themselves. The quiet hours schedule + determines when workspaces will be force stopped due to the template's + max TTL, and will round the max TTL up to be within the user's quiet + hours window (or default). The format is the same as the standard cron + format, but the day-of-month, month and day-of-week must be *. Only + one hour and minute can be specified (ranges or comma separated values + are not supported). + ⚠️ Dangerous Options --dangerous-allow-path-app-sharing bool, $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING Allow workspace apps that are not served from subdomains to be shared. diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index d815100af7f25..4833b296fa9f5 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -22,10 +22,11 @@ import ( "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/schedule" + agplschedule "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" "github.com/coder/coder/enterprise/coderd/license" "github.com/coder/coder/enterprise/coderd/proxyhealth" + "github.com/coder/coder/enterprise/coderd/schedule" "github.com/coder/coder/enterprise/derpmesh" "github.com/coder/coder/enterprise/replicasync" "github.com/coder/coder/enterprise/tailnet" @@ -52,6 +53,7 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { if options.Options.Authorizer == nil { options.Options.Authorizer = rbac.NewCachingAuthorizer(options.PrometheusRegistry) } + ctx, cancelFunc := context.WithCancel(ctx) api := &API{ ctx: ctx, @@ -240,6 +242,16 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { r.Put("/", api.putAppearance) }) }) + r.Route("/users/{user}/quiet-hours", func(r chi.Router) { + r.Use( + api.restartRequirementEnabledMW, + apiKeyMiddleware, + httpmw.ExtractUserParam(options.Database, false), + ) + + r.Get("/", api.userQuietHoursSchedule) + r.Put("/", api.putUserQuietHoursSchedule) + }) }) if len(options.SCIMAPIKey) != 0 { @@ -334,6 +346,9 @@ type Options struct { DERPServerRelayAddress string DERPServerRegionID int + // Used for user quiet hours schedules. + DefaultQuietHoursSchedule string // cron schedule, if empty user quiet hours schedules are disabled + EntitlementsUpdateInterval time.Duration ProxyHealthInterval time.Duration Keys map[string]ed25519.PublicKey @@ -386,6 +401,9 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureTemplateRBAC: api.RBAC, codersdk.FeatureExternalProvisionerDaemons: true, codersdk.FeatureAdvancedTemplateScheduling: true, + // FeatureTemplateRestartRequirement depends on + // FeatureAdvancedTemplateScheduling. + codersdk.FeatureTemplateRestartRequirement: api.DefaultQuietHoursSchedule != "", codersdk.FeatureWorkspaceProxy: true, }) if err != nil { @@ -405,6 +423,18 @@ func (api *API) updateEntitlements(ctx context.Context) error { return nil } + if entitlements.Features[codersdk.FeatureTemplateRestartRequirement].Enabled && !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { + api.entitlements.Errors = []string{ + `Your license is entitled to the feature "template restart ` + + `requirement" (and you have it enabled by setting the ` + + "default quiet hours schedule), but you are not entitled to " + + `the dependency feature "advanced template scheduling". ` + + "Please contact support for a new license.", + } + api.Logger.Error(ctx, "license is entitled to template restart requirement but not advanced template scheduling") + return nil + } + featureChanged := func(featureName codersdk.FeatureName) (initial, changed, enabled bool) { if api.entitlements.Features == nil { return true, false, entitlements.Features[featureName].Enabled @@ -450,12 +480,43 @@ func (api *API) updateEntitlements(ctx context.Context) error { if initial, changed, enabled := featureChanged(codersdk.FeatureAdvancedTemplateScheduling); shouldUpdate(initial, changed, enabled) { if enabled { - store := &EnterpriseTemplateScheduleStore{} - ptr := schedule.TemplateScheduleStore(store) - api.AGPL.TemplateScheduleStore.Store(&ptr) + templateStore := schedule.NewEnterpriseTemplateScheduleStore() + templateStoreInterface := agplschedule.TemplateScheduleStore(templateStore) + api.AGPL.TemplateScheduleStore.Store(&templateStoreInterface) + } else { + templateStore := agplschedule.NewAGPLTemplateScheduleStore() + api.AGPL.TemplateScheduleStore.Store(&templateStore) + } + } + + if initial, changed, enabled := featureChanged(codersdk.FeatureTemplateRestartRequirement); shouldUpdate(initial, changed, enabled) { + if enabled { + templateStore := *(api.AGPL.TemplateScheduleStore.Load()) + enterpriseTemplateStore, ok := templateStore.(*schedule.EnterpriseTemplateScheduleStore) + if !ok { + api.Logger.Error(ctx, "unable to set up enterprise template schedule store, template restart requirements will not be applied to workspace builds") + } + enterpriseTemplateStore.UseRestartRequirement.Store(true) + + quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule) + if err != nil { + api.Logger.Error(ctx, "unable to set up enterprise user quiet hours schedule store, template restart requirements will not be applied to workspace builds", slog.Error(err)) + } else { + api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore) + } } else { - store := schedule.NewAGPLTemplateScheduleStore() - api.AGPL.TemplateScheduleStore.Store(&store) + if api.DefaultQuietHoursSchedule != "" { + api.Logger.Warn(ctx, "template restart requirements are not enabled (due to setting default quiet hours schedule) as your license is not entitled to this feature") + } + + templateStore := *(api.AGPL.TemplateScheduleStore.Load()) + enterpriseTemplateStore, ok := templateStore.(*schedule.EnterpriseTemplateScheduleStore) + if ok { + enterpriseTemplateStore.UseRestartRequirement.Store(false) + } + + quietHoursStore := agplschedule.NewAGPLUserQuietHoursScheduleStore() + api.AGPL.UserQuietHoursScheduleStore.Store(&quietHoursStore) } } diff --git a/enterprise/coderd/coderdenttest/coderdenttest.go b/enterprise/coderd/coderdenttest/coderdenttest.go index 3311689d98653..e8eed329a29d0 100644 --- a/enterprise/coderd/coderdenttest/coderdenttest.go +++ b/enterprise/coderd/coderdenttest/coderdenttest.go @@ -45,15 +45,16 @@ func init() { type Options struct { *coderdtest.Options - AuditLogging bool - BrowserOnly bool - EntitlementsUpdateInterval time.Duration - SCIMAPIKey []byte - UserWorkspaceQuota int - ProxyHealthInterval time.Duration - LicenseOptions *LicenseOptions - DontAddLicense bool - DontAddFirstUser bool + AuditLogging bool + BrowserOnly bool + EntitlementsUpdateInterval time.Duration + SCIMAPIKey []byte + UserWorkspaceQuota int + ProxyHealthInterval time.Duration + LicenseOptions *LicenseOptions + NoDefaultQuietHoursSchedule bool + DontAddLicense bool + DontAddFirstUser bool } // New constructs a codersdk client connected to an in-memory Enterprise API instance. @@ -75,6 +76,10 @@ func NewWithAPI(t *testing.T, options *Options) ( } require.False(t, options.DontAddFirstUser && !options.DontAddLicense, "DontAddFirstUser requires DontAddLicense") setHandler, cancelFunc, serverURL, oop := coderdtest.NewOptions(t, options.Options) + if !options.NoDefaultQuietHoursSchedule && oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value() == "" { + err := oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Set("0 0 * * *") + require.NoError(t, err) + } coderAPI, err := coderd.New(context.Background(), &coderd.Options{ RBAC: true, AuditLogging: options.AuditLogging, @@ -86,6 +91,7 @@ func NewWithAPI(t *testing.T, options *Options) ( EntitlementsUpdateInterval: options.EntitlementsUpdateInterval, Keys: Keys, ProxyHealthInterval: options.ProxyHealthInterval, + DefaultQuietHoursSchedule: oop.DeploymentValues.UserQuietHoursSchedule.DefaultSchedule.Value(), }) require.NoError(t, err) setHandler(coderAPI.AGPL.RootHandler) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index c02d5a1779fe6..055704a6bcb11 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -10,7 +10,6 @@ import ( "net" "net/http" "strings" - "time" "github.com/google/uuid" "github.com/hashicorp/yamux" @@ -28,7 +27,6 @@ import ( "github.com/coder/coder/coderd/httpmw" "github.com/coder/coder/coderd/provisionerdserver" "github.com/coder/coder/coderd/rbac" - "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/codersdk" "github.com/coder/coder/provisionerd/proto" ) @@ -219,20 +217,21 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) } mux := drpcmux.New() err = proto.DRPCRegisterProvisionerDaemon(mux, &provisionerdserver.Server{ - AccessURL: api.AccessURL, - GitAuthConfigs: api.GitAuthConfigs, - OIDCConfig: api.OIDCConfig, - ID: daemon.ID, - Database: api.Database, - Pubsub: api.Pubsub, - Provisioners: daemon.Provisioners, - Telemetry: api.Telemetry, - Auditor: &api.AGPL.Auditor, - TemplateScheduleStore: api.AGPL.TemplateScheduleStore, - Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), - Tags: rawTags, - Tracer: trace.NewNoopTracerProvider().Tracer("noop"), - DeploymentValues: api.DeploymentValues, + AccessURL: api.AccessURL, + GitAuthConfigs: api.GitAuthConfigs, + OIDCConfig: api.OIDCConfig, + ID: daemon.ID, + Database: api.Database, + Pubsub: api.Pubsub, + Provisioners: daemon.Provisioners, + Telemetry: api.Telemetry, + Auditor: &api.AGPL.Auditor, + TemplateScheduleStore: api.AGPL.TemplateScheduleStore, + UserQuietHoursScheduleStore: api.AGPL.UserQuietHoursScheduleStore, + Logger: api.Logger.Named(fmt.Sprintf("provisionerd-%s", daemon.Name)), + Tags: rawTags, + Tracer: trace.NewNoopTracerProvider().Tracer("noop"), + DeploymentValues: api.DeploymentValues, }) if err != nil { _ = conn.Close(websocket.StatusInternalError, httpapi.WebsocketCloseSprintf("drpc register provisioner daemon: %s", err)) @@ -309,87 +308,3 @@ func websocketNetConn(ctx context.Context, conn *websocket.Conn, msgType websock Conn: nc, } } - -type EnterpriseTemplateScheduleStore struct{} - -var _ schedule.TemplateScheduleStore = &EnterpriseTemplateScheduleStore{} - -func (*EnterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context, db database.Store, templateID uuid.UUID) (schedule.TemplateScheduleOptions, error) { - tpl, err := db.GetTemplateByID(ctx, templateID) - if err != nil { - return schedule.TemplateScheduleOptions{}, err - } - - return schedule.TemplateScheduleOptions{ - UserAutostartEnabled: tpl.AllowUserAutostart, - UserAutostopEnabled: tpl.AllowUserAutostop, - DefaultTTL: time.Duration(tpl.DefaultTTL), - MaxTTL: time.Duration(tpl.MaxTTL), - FailureTTL: time.Duration(tpl.FailureTTL), - InactivityTTL: time.Duration(tpl.InactivityTTL), - LockedTTL: time.Duration(tpl.LockedTTL), - }, nil -} - -func (*EnterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) { - if int64(opts.DefaultTTL) == tpl.DefaultTTL && - int64(opts.MaxTTL) == tpl.MaxTTL && - int64(opts.FailureTTL) == tpl.FailureTTL && - int64(opts.InactivityTTL) == tpl.InactivityTTL && - int64(opts.LockedTTL) == tpl.LockedTTL && - opts.UserAutostartEnabled == tpl.AllowUserAutostart && - opts.UserAutostopEnabled == tpl.AllowUserAutostop { - // Avoid updating the UpdatedAt timestamp if nothing will be changed. - return tpl, nil - } - - var template database.Template - err := db.InTx(func(db database.Store) error { - err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ - ID: tpl.ID, - UpdatedAt: database.Now(), - AllowUserAutostart: opts.UserAutostartEnabled, - AllowUserAutostop: opts.UserAutostopEnabled, - DefaultTTL: int64(opts.DefaultTTL), - MaxTTL: int64(opts.MaxTTL), - FailureTTL: int64(opts.FailureTTL), - InactivityTTL: int64(opts.InactivityTTL), - LockedTTL: int64(opts.LockedTTL), - }) - if err != nil { - return xerrors.Errorf("update template schedule: %w", err) - } - - // Update all workspaces using the template to set the user defined schedule - // to be within the new bounds. This essentially does the following for each - // workspace using the template. - // if (template.ttl != NULL) { - // workspace.ttl = min(workspace.ttl, template.ttl) - // } - // - // NOTE: this does not apply to currently running workspaces as their - // schedule information is committed to the workspace_build during start. - // This limitation is displayed to the user while editing the template. - if opts.MaxTTL > 0 { - err = db.UpdateWorkspaceTTLToBeWithinTemplateMax(ctx, database.UpdateWorkspaceTTLToBeWithinTemplateMaxParams{ - TemplateID: tpl.ID, - TemplateMaxTTL: int64(opts.MaxTTL), - }) - if err != nil { - return xerrors.Errorf("update TTL of all workspaces on template to be within new template max TTL: %w", err) - } - } - - template, err = db.GetTemplateByID(ctx, tpl.ID) - if err != nil { - return xerrors.Errorf("get updated template schedule: %w", err) - } - - return nil - }, nil) - if err != nil { - return database.Template{}, err - } - - return template, nil -} diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go new file mode 100644 index 0000000000000..d65d436020b8f --- /dev/null +++ b/enterprise/coderd/schedule/template.go @@ -0,0 +1,120 @@ +package schedule + +import ( + "context" + "sync/atomic" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" + agpl "github.com/coder/coder/coderd/schedule" +) + +// EnterpriseTemplateScheduleStore provides an agpl.TemplateScheduleStore that +// has all fields implemented for enterprise customers. +type EnterpriseTemplateScheduleStore struct { + // UseRestartRequirement decides whether the RestartRequirement field should + // be used instead of the MaxTTL field for determining the max deadline of a + // workspace build. This value is determined by a feature flag, licensing, + // and whether a default user quiet hours schedule is set. + UseRestartRequirement atomic.Bool +} + +var _ agpl.TemplateScheduleStore = &EnterpriseTemplateScheduleStore{} + +func NewEnterpriseTemplateScheduleStore() *EnterpriseTemplateScheduleStore { + return &EnterpriseTemplateScheduleStore{} +} + +// Get implements agpl.TemplateScheduleStore. +func (s *EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.Store, templateID uuid.UUID) (agpl.TemplateScheduleOptions, error) { + tpl, err := db.GetTemplateByID(ctx, templateID) + if err != nil { + return agpl.TemplateScheduleOptions{}, err + } + + // These extra checks have to be done before the conversion because we lose + // precision and signs when converting to the agpl types from the database. + if tpl.RestartRequirementDaysOfWeek < 0 { + return agpl.TemplateScheduleOptions{}, xerrors.New("invalid restart requirement days, negative") + } + if tpl.RestartRequirementDaysOfWeek > 0b11111111 { + return agpl.TemplateScheduleOptions{}, xerrors.New("invalid restart requirement days, too large") + } + err = agpl.VerifyTemplateRestartRequirement(uint8(tpl.RestartRequirementDaysOfWeek), tpl.RestartRequirementWeeks) + if err != nil { + return agpl.TemplateScheduleOptions{}, err + } + + return agpl.TemplateScheduleOptions{ + UserAutostartEnabled: tpl.AllowUserAutostart, + UserAutostopEnabled: tpl.AllowUserAutostop, + DefaultTTL: time.Duration(tpl.DefaultTTL), + MaxTTL: time.Duration(tpl.MaxTTL), + UseRestartRequirement: s.UseRestartRequirement.Load(), + RestartRequirement: agpl.TemplateRestartRequirement{ + DaysOfWeek: uint8(tpl.RestartRequirementDaysOfWeek), + Weeks: tpl.RestartRequirementWeeks, + }, + FailureTTL: time.Duration(tpl.FailureTTL), + InactivityTTL: time.Duration(tpl.InactivityTTL), + LockedTTL: time.Duration(tpl.LockedTTL), + }, nil +} + +// Set implements agpl.TemplateScheduleStore. +func (*EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.Store, tpl database.Template, opts agpl.TemplateScheduleOptions) (database.Template, error) { + if int64(opts.DefaultTTL) == tpl.DefaultTTL && + int64(opts.MaxTTL) == tpl.MaxTTL && + int16(opts.RestartRequirement.DaysOfWeek) == tpl.RestartRequirementDaysOfWeek && + opts.RestartRequirement.Weeks == tpl.RestartRequirementWeeks && + int64(opts.FailureTTL) == tpl.FailureTTL && + int64(opts.InactivityTTL) == tpl.InactivityTTL && + int64(opts.LockedTTL) == tpl.LockedTTL && + opts.UserAutostartEnabled == tpl.AllowUserAutostart && + opts.UserAutostopEnabled == tpl.AllowUserAutostop { + // Avoid updating the UpdatedAt timestamp if nothing will be changed. + return tpl, nil + } + + err := agpl.VerifyTemplateRestartRequirement(opts.RestartRequirement.DaysOfWeek, opts.RestartRequirement.Weeks) + if err != nil { + return database.Template{}, err + } + + var template database.Template + err = db.InTx(func(db database.Store) error { + err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ + ID: tpl.ID, + UpdatedAt: database.Now(), + AllowUserAutostart: opts.UserAutostartEnabled, + AllowUserAutostop: opts.UserAutostopEnabled, + DefaultTTL: int64(opts.DefaultTTL), + MaxTTL: int64(opts.MaxTTL), + RestartRequirementDaysOfWeek: int16(opts.RestartRequirement.DaysOfWeek), + RestartRequirementWeeks: opts.RestartRequirement.Weeks, + FailureTTL: int64(opts.FailureTTL), + InactivityTTL: int64(opts.InactivityTTL), + LockedTTL: int64(opts.LockedTTL), + }) + if err != nil { + return xerrors.Errorf("update template schedule: %w", err) + } + + // TODO: update all workspace max_deadlines to be within new bounds + + template, err = db.GetTemplateByID(ctx, tpl.ID) + if err != nil { + return xerrors.Errorf("get updated template schedule: %w", err) + } + + return nil + }, nil) + if err != nil { + return database.Template{}, err + } + + return template, nil +} diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go new file mode 100644 index 0000000000000..c7d76f86119c8 --- /dev/null +++ b/enterprise/coderd/schedule/user.go @@ -0,0 +1,98 @@ +package schedule + +import ( + "context" + "strings" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/coderd/database" + agpl "github.com/coder/coder/coderd/schedule" +) + +// enterpriseUserQuietHoursScheduleStore provides an +// agpl.UserQuietHoursScheduleStore that has all fields implemented for +// enterprise customers. +type enterpriseUserQuietHoursScheduleStore struct { + defaultSchedule string +} + +var _ agpl.UserQuietHoursScheduleStore = &enterpriseUserQuietHoursScheduleStore{} + +func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string) (agpl.UserQuietHoursScheduleStore, error) { + if defaultSchedule == "" { + return nil, xerrors.Errorf("default schedule must be set") + } + + s := &enterpriseUserQuietHoursScheduleStore{ + defaultSchedule: defaultSchedule, + } + + _, err := s.parseSchedule(defaultSchedule) + if err != nil { + return nil, xerrors.Errorf("parse default schedule: %w", err) + } + + return s, nil +} + +func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) { + userSet := true + if strings.TrimSpace(rawSchedule) == "" { + userSet = false + rawSchedule = s.defaultSchedule + } + + sched, err := agpl.Daily(rawSchedule) + if err != nil { + // This shouldn't get hit during Gets, only Sets. + return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("parse daily schedule %q: %w", rawSchedule, err) + } + if strings.HasPrefix(sched.Time(), "cron(") { + // Times starting with "cron(" mean it isn't a single time and probably + // a range or a list of times as a cron expression. We only support + // single times for user quiet hours schedules. + // This shouldn't get hit during Gets, only Sets. + return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("daily schedule %q has more than one time: %v", rawSchedule, sched.Time()) + } + + return agpl.UserQuietHoursScheduleOptions{ + Schedule: sched, + UserSet: userSet, + }, nil +} + +func (s *enterpriseUserQuietHoursScheduleStore) Get(ctx context.Context, db database.Store, userID uuid.UUID) (agpl.UserQuietHoursScheduleOptions, error) { + user, err := db.GetUserByID(ctx, userID) + if err != nil { + return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("get user by ID: %w", err) + } + + return s.parseSchedule(user.QuietHoursSchedule) +} + +func (s *enterpriseUserQuietHoursScheduleStore) Set(ctx context.Context, db database.Store, userID uuid.UUID, rawSchedule string) (agpl.UserQuietHoursScheduleOptions, error) { + opts, err := s.parseSchedule(rawSchedule) + if err != nil { + return opts, err + } + + // Use the tidy version when storing in the database. + rawSchedule = "" + if opts.UserSet { + rawSchedule = opts.Schedule.String() + } + _, err = db.UpdateUserQuietHoursSchedule(ctx, database.UpdateUserQuietHoursScheduleParams{ + ID: userID, + QuietHoursSchedule: rawSchedule, + }) + if err != nil { + return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("update user quiet hours schedule: %w", err) + } + + // TODO(@dean): update max_deadline for all active builds for this user to clamp to + // the new schedule. + + return opts, nil +} diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index c0f9a3592f169..ec5033d4f690e 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -25,73 +25,7 @@ import ( func TestTemplates(t *testing.T) { t.Parallel() - t.Run("SetMaxTTL", func(t *testing.T) { - t.Parallel() - - client, user := coderdenttest.New(t, &coderdenttest.Options{ - Options: &coderdtest.Options{ - IncludeProvisionerDaemon: true, - }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureAdvancedTemplateScheduling: 1, - }, - }, - }) - - version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) - coderdtest.AwaitTemplateVersionJob(t, client, version.ID) - template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - require.EqualValues(t, 0, template.MaxTTLMillis) - - // Create some workspaces to test propagation to user-defined TTLs. - workspace1 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - ttl := (24 * time.Hour).Milliseconds() - cwr.TTLMillis = &ttl - }) - workspace2TTL := (1 * time.Hour).Milliseconds() - workspace2 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { - cwr.TTLMillis = &workspace2TTL - }) - workspace3 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - // To unset TTL you have to update, as setting a nil TTL on create - // copies the template default TTL. - ctx := testutil.Context(t, testutil.WaitLong) - err := client.UpdateWorkspaceTTL(ctx, workspace3.ID, codersdk.UpdateWorkspaceTTLRequest{ - TTLMillis: nil, - }) - require.NoError(t, err) - - updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - Name: template.Name, - DisplayName: template.DisplayName, - Description: template.Description, - Icon: template.Icon, - AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, - DefaultTTLMillis: time.Hour.Milliseconds(), - MaxTTLMillis: (2 * time.Hour).Milliseconds(), - }) - require.NoError(t, err) - require.Equal(t, 2*time.Hour, time.Duration(updated.MaxTTLMillis)*time.Millisecond) - - template, err = client.Template(ctx, template.ID) - require.NoError(t, err) - require.Equal(t, 2*time.Hour, time.Duration(template.MaxTTLMillis)*time.Millisecond) - - // Verify that only the first workspace has been updated. - workspace1, err = client.Workspace(ctx, workspace1.ID) - require.NoError(t, err) - require.Equal(t, &template.MaxTTLMillis, workspace1.TTLMillis) - - workspace2, err = client.Workspace(ctx, workspace2.ID) - require.NoError(t, err) - require.Equal(t, &workspace2TTL, workspace2.TTLMillis) - - workspace3, err = client.Workspace(ctx, workspace3.ID) - require.NoError(t, err) - require.Nil(t, workspace3.TTLMillis) - }) - + // TODO(@dean): remove legacy max_ttl tests t.Run("CreateUpdateWorkspaceMaxTTL", func(t *testing.T) { t.Parallel() client, user := coderdenttest.New(t, &coderdenttest.Options{ @@ -123,6 +57,7 @@ func TestTemplates(t *testing.T) { } ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) require.NoError(t, err) + require.NotNil(t, ws.TTLMillis) require.EqualValues(t, exp, *ws.TTLMillis) // Editing a workspace to have a higher TTL than the template's max @@ -182,6 +117,7 @@ func TestTemplates(t *testing.T) { } ws, err := client.CreateWorkspace(ctx, template.OrganizationID, codersdk.Me, req) require.NoError(t, err) + require.NotNil(t, ws.TTLMillis) require.EqualValues(t, exp, *ws.TTLMillis) // Editing a workspace to disable the TTL should do nothing @@ -204,6 +140,50 @@ func TestTemplates(t *testing.T) { require.EqualValues(t, exp, *ws.TTLMillis) }) + t.Run("SetRestartRequirement", func(t *testing.T) { + t.Parallel() + + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }, + }) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + require.Empty(t, 0, template.RestartRequirement.DaysOfWeek) + require.Zero(t, template.RestartRequirement.Weeks) + + // ctx := testutil.Context(t, testutil.WaitLong) + ctx := context.Background() + updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + DefaultTTLMillis: time.Hour.Milliseconds(), + RestartRequirement: &codersdk.TemplateRestartRequirement{ + DaysOfWeek: []string{"monday", "saturday"}, + Weeks: 3, + }, + }) + require.NoError(t, err) + require.Equal(t, []string{"monday", "saturday"}, updated.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 3, updated.RestartRequirement.Weeks) + + template, err = client.Template(ctx, template.ID) + require.NoError(t, err) + require.Equal(t, []string{"monday", "saturday"}, template.RestartRequirement.DaysOfWeek) + require.EqualValues(t, 3, template.RestartRequirement.Weeks) + }) + t.Run("CleanupTTLs", func(t *testing.T) { t.Parallel() diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go new file mode 100644 index 0000000000000..3f1e61fddb10d --- /dev/null +++ b/enterprise/coderd/users.go @@ -0,0 +1,121 @@ +package coderd + +import ( + "net/http" + "time" + + "github.com/coder/coder/coderd/audit" + "github.com/coder/coder/coderd/database" + "github.com/coder/coder/coderd/httpapi" + "github.com/coder/coder/coderd/httpmw" + "github.com/coder/coder/codersdk" +) + +func (api *API) restartRequirementEnabledMW(next http.Handler) http.Handler { + return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // The experiment must be enabled. + if !api.AGPL.Experiments.Enabled(codersdk.ExperimentTemplateRestartRequirement) { + httpapi.RouteNotFound(rw) + return + } + + // Entitlement must be enabled. + api.entitlementsMu.RLock() + entitled := api.entitlements.Features[codersdk.FeatureTemplateRestartRequirement].Entitlement != codersdk.EntitlementNotEntitled + enabled := api.entitlements.Features[codersdk.FeatureTemplateRestartRequirement].Enabled + api.entitlementsMu.RUnlock() + if !entitled { + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "Template restart requirement is an Enterprise feature. Contact sales!", + }) + return + } + if !enabled { + httpapi.Write(r.Context(), rw, http.StatusForbidden, codersdk.Response{ + Message: "Template restart requirement feature is not enabled. Please specify a default user quiet hours schedule to use this feature.", + }) + return + } + + next.ServeHTTP(rw, r) + }) +} + +// @Summary Get user quiet hours schedule +// @ID get-user-quiet-hours-schedule +// @Security CoderSessionToken +// @Produce json +// @Tags Enterprise +// @Param user path string true "User ID" format(uuid) +// @Success 200 {array} codersdk.UserQuietHoursScheduleResponse +// @Router /users/{user}/quiet-hours [get] +func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + ) + + opts, err := (*api.UserQuietHoursScheduleStore.Load()).Get(ctx, api.Database, user.ID) + if err != nil { + httpapi.InternalServerError(rw, err) + return + } + if opts.Schedule == nil { + httpapi.ResourceNotFound(rw) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{ + RawSchedule: opts.Schedule.String(), + UserSet: opts.UserSet, + Time: opts.Schedule.Time(), + Timezone: opts.Schedule.Location().String(), + Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), + }) +} + +// @Summary Update user quiet hours schedule +// @ID update-user-quiet-hours-schedule +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags Enterprise +// @Param user path string true "User ID" format(uuid) +// @Param request body codersdk.UpdateUserQuietHoursScheduleRequest true "Update schedule request" +// @Success 200 {array} codersdk.UserQuietHoursScheduleResponse +// @Router /users/{user}/quiet-hours [put] +func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + user = httpmw.UserParam(r) + params codersdk.UpdateUserQuietHoursScheduleRequest + aReq, commitAudit = audit.InitRequest[database.User](rw, &audit.RequestParams{ + Audit: api.Auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + ) + defer commitAudit() + aReq.Old = user + + if !httpapi.Read(ctx, rw, r, ¶ms) { + return + } + + opts, err := (*api.UserQuietHoursScheduleStore.Load()).Set(ctx, api.Database, user.ID, params.Schedule) + if err != nil { + // TODO(@dean): some of these errors are related to bad syntax, so it + // would be nice to 400 instead + httpapi.InternalServerError(rw, err) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{ + RawSchedule: opts.Schedule.String(), + UserSet: opts.UserSet, + Time: opts.Schedule.Time(), + Timezone: opts.Schedule.Location().String(), + Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())), + }) +} diff --git a/enterprise/coderd/users_test.go b/enterprise/coderd/users_test.go new file mode 100644 index 0000000000000..b908dfd9600bf --- /dev/null +++ b/enterprise/coderd/users_test.go @@ -0,0 +1,213 @@ +package coderd_test + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/coderd/coderdtest" + "github.com/coder/coder/coderd/schedule" + "github.com/coder/coder/codersdk" + "github.com/coder/coder/enterprise/coderd/coderdenttest" + "github.com/coder/coder/enterprise/coderd/license" + "github.com/coder/coder/testutil" +) + +func TestUserQuietHours(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + defaultQuietHoursSchedule := "CRON_TZ=America/Chicago 0 0 * * *" + defaultScheduleParsed, err := schedule.Daily(defaultQuietHoursSchedule) + require.NoError(t, err) + nextTime := defaultScheduleParsed.Next(time.Now().In(defaultScheduleParsed.Location())) + if time.Until(nextTime) < time.Hour { + // Use a different default schedule instead, because we want to avoid + // the schedule "ticking over" during this test run. + defaultQuietHoursSchedule = "CRON_TZ=America/Chicago 0 12 * * *" + defaultScheduleParsed, err = schedule.Daily(defaultQuietHoursSchedule) + require.NoError(t, err) + } + + dv := coderdtest.DeploymentValues(t) + dv.UserQuietHoursSchedule.DefaultSchedule.Set(defaultQuietHoursSchedule) + dv.Experiments.Set(string(codersdk.ExperimentTemplateRestartRequirement)) + + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + codersdk.FeatureTemplateRestartRequirement: 1, + }, + }, + }) + + // Get quiet hours for a user that doesn't have them set. + ctx := testutil.Context(t, testutil.WaitLong) + sched1, err := client.UserQuietHoursSchedule(ctx, codersdk.Me) + require.NoError(t, err) + require.Equal(t, defaultScheduleParsed.String(), sched1.RawSchedule) + require.False(t, sched1.UserSet) + require.Equal(t, defaultScheduleParsed.Time(), sched1.Time) + require.Equal(t, defaultScheduleParsed.Location().String(), sched1.Timezone) + require.WithinDuration(t, defaultScheduleParsed.Next(time.Now()), sched1.Next, 15*time.Second) + + // Set their quiet hours. + customQuietHoursSchedule := "CRON_TZ=Australia/Sydney 0 0 * * *" + customScheduleParsed, err := schedule.Daily(customQuietHoursSchedule) + require.NoError(t, err) + nextTime = customScheduleParsed.Next(time.Now().In(customScheduleParsed.Location())) + if time.Until(nextTime) < time.Hour { + // Use a different default schedule instead, because we want to avoid + // the schedule "ticking over" during this test run. + customQuietHoursSchedule = "CRON_TZ=Australia/Sydney 0 12 * * *" + customScheduleParsed, err = schedule.Daily(customQuietHoursSchedule) + require.NoError(t, err) + } + + sched2, err := client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{ + Schedule: customQuietHoursSchedule, + }) + require.NoError(t, err) + require.Equal(t, customScheduleParsed.String(), sched2.RawSchedule) + require.True(t, sched2.UserSet) + require.Equal(t, customScheduleParsed.Time(), sched2.Time) + require.Equal(t, customScheduleParsed.Location().String(), sched2.Timezone) + require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched2.Next, 15*time.Second) + + // Get quiet hours for a user that has them set. + sched3, err := client.UserQuietHoursSchedule(ctx, user.UserID.String()) + require.NoError(t, err) + require.Equal(t, customScheduleParsed.String(), sched3.RawSchedule) + require.True(t, sched3.UserSet) + require.Equal(t, customScheduleParsed.Time(), sched3.Time) + require.Equal(t, customScheduleParsed.Location().String(), sched3.Timezone) + require.WithinDuration(t, customScheduleParsed.Next(time.Now()), sched3.Next, 15*time.Second) + + // Try setting a garbage schedule. + _, err = client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{ + Schedule: "garbage", + }) + require.Error(t, err) + require.ErrorContains(t, err, "parse daily schedule") + + // Try setting a non-daily schedule. + _, err = client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{ + Schedule: "CRON_TZ=America/Chicago 0 0 * * 1", + }) + require.Error(t, err) + require.ErrorContains(t, err, "parse daily schedule") + + // Try setting a schedule with a timezone that doesn't exist. + _, err = client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{ + Schedule: "CRON_TZ=Deans/House 0 0 * * *", + }) + require.Error(t, err) + require.ErrorContains(t, err, "parse daily schedule") + + // Try setting a schedule with more than one time. + _, err = client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{ + Schedule: "CRON_TZ=America/Chicago 0 0,12 * * *", + }) + require.Error(t, err) + require.ErrorContains(t, err, "more than one time") + _, err = client.UpdateUserQuietHoursSchedule(ctx, user.UserID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{ + Schedule: "CRON_TZ=America/Chicago 0-30 0 * * *", + }) + require.Error(t, err) + require.ErrorContains(t, err, "more than one time") + + // We don't allow unsetting the custom schedule so we don't need to worry + // about it in this test. + }) + + t.Run("NotEntitled", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *") + dv.Experiments.Set(string(codersdk.ExperimentTemplateRestartRequirement)) + + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + // Not entitled. + // codersdk.FeatureTemplateRestartRequirement: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.UserQuietHoursSchedule(ctx, user.UserID.String()) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + + t.Run("NotEnabled", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.UserQuietHoursSchedule.DefaultSchedule.Set("") + dv.Experiments.Set(string(codersdk.ExperimentTemplateRestartRequirement)) + + client, user := coderdenttest.New(t, &coderdenttest.Options{ + NoDefaultQuietHoursSchedule: true, + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + codersdk.FeatureTemplateRestartRequirement: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.UserQuietHoursSchedule(ctx, user.UserID.String()) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusForbidden, sdkErr.StatusCode()) + }) + + t.Run("NoFeatureFlag", func(t *testing.T) { + t.Parallel() + + dv := coderdtest.DeploymentValues(t) + dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *") + dv.UserQuietHoursSchedule.DefaultSchedule.Set("") + + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + DeploymentValues: dv, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + codersdk.FeatureTemplateRestartRequirement: 1, + }, + }, + }) + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.UserQuietHoursSchedule(ctx, user.UserID.String()) + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, http.StatusNotFound, sdkErr.StatusCode()) + }) +} diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 5aa36ec1691a0..8220678333d19 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -15,12 +15,12 @@ import ( "github.com/coder/coder/coderd/autobuild" "github.com/coder/coder/coderd/coderdtest" "github.com/coder/coder/coderd/database" - "github.com/coder/coder/coderd/schedule" + agplschedule "github.com/coder/coder/coderd/schedule" "github.com/coder/coder/coderd/util/ptr" "github.com/coder/coder/codersdk" - "github.com/coder/coder/enterprise/coderd" "github.com/coder/coder/enterprise/coderd/coderdenttest" "github.com/coder/coder/enterprise/coderd/license" + "github.com/coder/coder/enterprise/coderd/schedule" "github.com/coder/coder/provisioner/echo" "github.com/coder/coder/testutil" ) @@ -101,7 +101,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{}, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -148,7 +148,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{}, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -194,7 +194,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{}, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -236,7 +236,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{}, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -293,7 +293,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{}, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -335,7 +335,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{}, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -377,7 +377,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{}, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -428,7 +428,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{}, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -498,7 +498,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: ticker, IncludeProvisionerDaemon: true, AutobuildStats: statCh, - TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{}, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -559,7 +559,7 @@ func TestWorkspaceAutobuild(t *testing.T) { AutobuildTicker: tickCh, IncludeProvisionerDaemon: true, AutobuildStats: statsCh, - TemplateScheduleStore: &coderd.EnterpriseTemplateScheduleStore{}, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore(), }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, @@ -575,7 +575,7 @@ func TestWorkspaceAutobuild(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) - sched, err := schedule.Weekly("CRON_TZ=UTC 0 * * * *") + sched, err := agplschedule.Weekly("CRON_TZ=UTC 0 * * * *") require.NoError(t, err) ws := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, func(cwr *codersdk.CreateWorkspaceRequest) { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 26ca40b60aad4..707dcfcf57b2b 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -181,6 +181,7 @@ export interface CreateTemplateRequest { readonly template_version_id: string readonly default_ttl_ms?: number readonly max_ttl_ms?: number + readonly restart_requirement?: TemplateRestartRequirement readonly allow_user_cancel_workspace_jobs?: boolean readonly allow_user_autostart?: boolean readonly allow_user_autostop?: boolean @@ -384,6 +385,7 @@ export interface DeploymentValues { readonly disable_owner_workspace_exec?: boolean readonly proxy_health_status_interval?: number readonly enable_terraform_debug_mode?: boolean + readonly user_quiet_hours_schedule?: UserQuietHoursScheduleConfig // This is likely an enum in an external package ("github.com/coder/coder/cli/clibase.YAMLConfigPath") readonly config?: string readonly write_config?: boolean @@ -867,6 +869,7 @@ export interface Template { readonly icon: string readonly default_ttl_ms: number readonly max_ttl_ms: number + readonly restart_requirement: TemplateRestartRequirement readonly created_by_id: string readonly created_by_name: string readonly allow_user_autostart: boolean @@ -905,6 +908,12 @@ export interface TemplateGroup extends Group { readonly role: TemplateRole } +// From codersdk/templates.go +export interface TemplateRestartRequirement { + readonly days_of_week: string[] + readonly weeks: number +} + // From codersdk/templates.go export interface TemplateUser extends User { readonly role: TemplateRole @@ -1038,6 +1047,7 @@ export interface UpdateTemplateMeta { readonly icon?: string readonly default_ttl_ms?: number readonly max_ttl_ms?: number + readonly restart_requirement?: TemplateRestartRequirement readonly allow_user_autostart?: boolean readonly allow_user_autostop?: boolean readonly allow_user_cancel_workspace_jobs?: boolean @@ -1057,6 +1067,11 @@ export interface UpdateUserProfileRequest { readonly username: string } +// From codersdk/users.go +export interface UpdateUserQuietHoursScheduleRequest { + readonly schedule: string +} + // From codersdk/workspaces.go export interface UpdateWorkspaceAutostartRequest { readonly schedule?: string @@ -1106,6 +1121,20 @@ export interface UserLoginType { readonly login_type: LoginType } +// From codersdk/deployment.go +export interface UserQuietHoursScheduleConfig { + readonly default_schedule: string +} + +// From codersdk/users.go +export interface UserQuietHoursScheduleResponse { + readonly raw_schedule: string + readonly user_set: boolean + readonly time: string + readonly timezone: string + readonly next: string +} + // From codersdk/users.go export interface UserRoles { readonly roles: string[] @@ -1435,6 +1464,7 @@ export type Experiment = | "moons" | "single_tailnet" | "tailnet_ha_coordinator" + | "template_restart_requirement" | "workspace_actions" | "workspace_build_logs_ui" export const Experiments: Experiment[] = [ @@ -1442,6 +1472,7 @@ export const Experiments: Experiment[] = [ "moons", "single_tailnet", "tailnet_ha_coordinator", + "template_restart_requirement", "workspace_actions", "workspace_build_logs_ui", ] @@ -1457,6 +1488,7 @@ export type FeatureName = | "multiple_git_auth" | "scim" | "template_rbac" + | "template_restart_requirement" | "user_limit" | "workspace_proxy" export const FeatureNames: FeatureName[] = [ @@ -1469,6 +1501,7 @@ export const FeatureNames: FeatureName[] = [ "multiple_git_auth", "scim", "template_rbac", + "template_restart_requirement", "user_limit", "workspace_proxy", ] diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index 922fef150a469..597ad0a15387e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -26,6 +26,10 @@ const validFormValues: FormValues = { allow_user_cancel_workspace_jobs: false, allow_user_autostart: false, allow_user_autostop: false, + restart_requirement: { + days_of_week: [], + weeks: 1, + }, failure_ttl_ms: 0, inactivity_ttl_ms: 0, locked_ttl_ms: 0, diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx index 3d3c5bd80031b..598f701338f1e 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm/TemplateScheduleForm.tsx @@ -71,6 +71,11 @@ export const TemplateScheduleForm: FC = ({ ? template.locked_ttl_ms / MS_DAY_CONVERSION : 0, + restart_requirement: { + days_of_week: template.restart_requirement.days_of_week, + weeks: template.restart_requirement.weeks, + }, + allow_user_autostart: template.allow_user_autostart, allow_user_autostop: template.allow_user_autostop, failure_cleanup_enabled: diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 87c30ee22495e..4b830d547cca0 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -424,6 +424,10 @@ export const MockTemplate: TypesGen.Template = { description: "This is a test description.", default_ttl_ms: 24 * 60 * 60 * 1000, max_ttl_ms: 2 * 24 * 60 * 60 * 1000, + restart_requirement: { + days_of_week: [], + weeks: 1, + }, created_by_id: "test-creator-id", created_by_name: "test_creator", icon: "/icon/code.svg",