bool
|
+| Environment | $CODER_ALLOW_CUSTOM_QUIET_HOURS
|
+| YAML | userQuietHoursSchedule.allowCustomQuietHours
|
+| Default | true
|
+
+Allow users to set their own quiet hours schedule for workspaces to stop in (depending on template autostop requirement settings). If false, users can't change their quiet hours schedule and the site default is always used.
+
### --block-direct-connections
| | |
diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden
index 230d07dbda..ad404185f0 100644
--- a/enterprise/cli/testdata/coder_server_--help.golden
+++ b/enterprise/cli/testdata/coder_server_--help.golden
@@ -448,6 +448,12 @@ 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.
+ --allow-custom-quiet-hours bool, $CODER_ALLOW_CUSTOM_QUIET_HOURS (default: true)
+ Allow users to set their own quiet hours schedule for workspaces to
+ stop in (depending on template autostop requirement settings). If
+ false, users can't change their quiet hours schedule and the site
+ default is always used.
+
--default-quiet-hours-schedule string, $CODER_QUIET_HOURS_DEFAULT_SCHEDULE (default: CRON_TZ=UTC 0 0 * * *)
The default daily cron schedule applied to users that haven't set a
custom quiet hours schedule themselves. The quiet hours schedule
diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go
index a4ac8733a6..d7d2705d4b 100644
--- a/enterprise/coderd/coderd.go
+++ b/enterprise/coderd/coderd.go
@@ -569,7 +569,7 @@ func (api *API) updateEntitlements(ctx context.Context) error {
api.Logger.Warn(ctx, "template autostop requirement will default to UTC midnight as the default user quiet hours schedule. Set a custom default quiet hours schedule using CODER_QUIET_HOURS_DEFAULT_SCHEDULE to avoid this warning")
api.DefaultQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *"
}
- quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule)
+ quietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(api.DefaultQuietHoursSchedule, api.DeploymentValues.UserQuietHoursSchedule.AllowUserCustom.Value())
if err != nil {
api.Logger.Error(ctx, "unable to set up enterprise user quiet hours schedule store, template autostop requirements will not be applied to workspace builds", slog.Error(err))
} else {
diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go
index 5045048061..ac158a795b 100644
--- a/enterprise/coderd/schedule/template_test.go
+++ b/enterprise/coderd/schedule/template_test.go
@@ -207,7 +207,7 @@ func TestTemplateUpdateBuildDeadlines(t *testing.T) {
wsBuild, err = db.GetWorkspaceBuildByID(ctx, wsBuild.ID)
require.NoError(t, err)
- userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule)
+ userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
require.NoError(t, err)
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
userQuietHoursStorePtr.Store(&userQuietHoursStore)
@@ -490,7 +490,7 @@ func TestTemplateUpdateBuildDeadlinesSkip(t *testing.T) {
require.NoError(t, err)
}
- userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule)
+ userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
require.NoError(t, err)
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
userQuietHoursStorePtr.Store(&userQuietHoursStore)
diff --git a/enterprise/coderd/schedule/user.go b/enterprise/coderd/schedule/user.go
index 49c2b61b30..c117427e4f 100644
--- a/enterprise/coderd/schedule/user.go
+++ b/enterprise/coderd/schedule/user.go
@@ -18,17 +18,19 @@ import (
// enterprise customers.
type enterpriseUserQuietHoursScheduleStore struct {
defaultSchedule string
+ userCanSet bool
}
var _ agpl.UserQuietHoursScheduleStore = &enterpriseUserQuietHoursScheduleStore{}
-func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string) (agpl.UserQuietHoursScheduleStore, error) {
+func NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule string, userCanSet bool) (agpl.UserQuietHoursScheduleStore, error) {
if defaultSchedule == "" {
return nil, xerrors.Errorf("default schedule must be set")
}
s := &enterpriseUserQuietHoursScheduleStore{
defaultSchedule: defaultSchedule,
+ userCanSet: userCanSet,
}
// The context is only used for tracing so using a background ctx is fine.
@@ -64,8 +66,9 @@ func (s *enterpriseUserQuietHoursScheduleStore) parseSchedule(ctx context.Contex
}
return agpl.UserQuietHoursScheduleOptions{
- Schedule: sched,
- UserSet: userSet,
+ Schedule: sched,
+ UserSet: userSet,
+ UserCanSet: s.userCanSet,
}, nil
}
@@ -73,6 +76,10 @@ func (s *enterpriseUserQuietHoursScheduleStore) Get(ctx context.Context, db data
ctx, span := tracing.StartSpan(ctx)
defer span.End()
+ if !s.userCanSet {
+ return s.parseSchedule(ctx, "")
+ }
+
user, err := db.GetUserByID(ctx, userID)
if err != nil {
return agpl.UserQuietHoursScheduleOptions{}, xerrors.Errorf("get user by ID: %w", err)
@@ -85,6 +92,10 @@ func (s *enterpriseUserQuietHoursScheduleStore) Set(ctx context.Context, db data
ctx, span := tracing.StartSpan(ctx)
defer span.End()
+ if !s.userCanSet {
+ return agpl.UserQuietHoursScheduleOptions{}, agpl.ErrUserCannotSetQuietHoursSchedule
+ }
+
opts, err := s.parseSchedule(ctx, rawSchedule)
if err != nil {
return opts, err
diff --git a/enterprise/coderd/schedule/user_test.go b/enterprise/coderd/schedule/user_test.go
new file mode 100644
index 0000000000..5e1685a42e
--- /dev/null
+++ b/enterprise/coderd/schedule/user_test.go
@@ -0,0 +1,131 @@
+package schedule_test
+
+import (
+ "context"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/google/uuid"
+ "github.com/stretchr/testify/require"
+
+ "github.com/coder/coder/v2/coderd/database"
+ "github.com/coder/coder/v2/coderd/database/dbmock"
+ agpl "github.com/coder/coder/v2/coderd/schedule"
+ "github.com/coder/coder/v2/enterprise/coderd/schedule"
+)
+
+func TestEnterpriseUserQuietHoursSchedule(t *testing.T) {
+ t.Parallel()
+
+ const (
+ defaultSchedule = "CRON_TZ=UTC 15 10 * * *"
+ userCustomSchedule1 = "CRON_TZ=Australia/Sydney 30 2 * * *"
+ userCustomSchedule2 = "CRON_TZ=Australia/Sydney 0 18 * * *"
+ )
+
+ t.Run("OK", func(t *testing.T) {
+ t.Parallel()
+
+ userID := uuid.New()
+ s, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule, true)
+ require.NoError(t, err)
+
+ mDB := dbmock.NewMockStore(gomock.NewController(t))
+
+ // User has no schedule set, use default.
+ mDB.EXPECT().GetUserByID(gomock.Any(), userID).Return(database.User{}, nil).Times(1)
+ opts, err := s.Get(context.Background(), mDB, userID)
+ require.NoError(t, err)
+ require.NotNil(t, opts.Schedule)
+ require.Equal(t, defaultSchedule, opts.Schedule.String())
+ require.False(t, opts.UserSet)
+ require.True(t, opts.UserCanSet)
+
+ // User has a custom schedule set.
+ mDB.EXPECT().GetUserByID(gomock.Any(), userID).Return(database.User{
+ QuietHoursSchedule: userCustomSchedule1,
+ }, nil).Times(1)
+ opts, err = s.Get(context.Background(), mDB, userID)
+ require.NoError(t, err)
+ require.NotNil(t, opts.Schedule)
+ require.Equal(t, userCustomSchedule1, opts.Schedule.String())
+ require.True(t, opts.UserSet)
+ require.True(t, opts.UserCanSet)
+
+ // Set user schedule.
+ mDB.EXPECT().UpdateUserQuietHoursSchedule(gomock.Any(), database.UpdateUserQuietHoursScheduleParams{
+ ID: userID,
+ QuietHoursSchedule: userCustomSchedule2,
+ }).Return(database.User{}, nil).Times(1)
+ opts, err = s.Set(context.Background(), mDB, userID, userCustomSchedule2)
+ require.NoError(t, err)
+ require.NotNil(t, opts.Schedule)
+ require.Equal(t, userCustomSchedule2, opts.Schedule.String())
+ require.True(t, opts.UserSet)
+ })
+
+ t.Run("BadDefaultSchedule", func(t *testing.T) {
+ t.Parallel()
+
+ _, err := schedule.NewEnterpriseUserQuietHoursScheduleStore("bad schedule", true)
+ require.Error(t, err)
+ require.ErrorContains(t, err, `parse daily schedule "bad schedule"`)
+ })
+
+ t.Run("BadGotSchedule", func(t *testing.T) {
+ t.Parallel()
+
+ userID := uuid.New()
+ s, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule, true)
+ require.NoError(t, err)
+
+ mDB := dbmock.NewMockStore(gomock.NewController(t))
+
+ // User has a custom schedule set.
+ mDB.EXPECT().GetUserByID(gomock.Any(), userID).Return(database.User{
+ QuietHoursSchedule: "bad schedule",
+ }, nil).Times(1)
+ _, err = s.Get(context.Background(), mDB, userID)
+ require.Error(t, err)
+ require.ErrorContains(t, err, `parse daily schedule "bad schedule"`)
+ })
+
+ t.Run("BadSetSchedule", func(t *testing.T) {
+ t.Parallel()
+
+ s, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule, true)
+ require.NoError(t, err)
+
+ // Use the mock DB here. It won't get used, but if it ever does it will
+ // fail the test.
+ mDB := dbmock.NewMockStore(gomock.NewController(t))
+ _, err = s.Set(context.Background(), mDB, uuid.New(), "bad schedule")
+ require.Error(t, err)
+ require.ErrorContains(t, err, `parse daily schedule "bad schedule"`)
+ })
+
+ t.Run("UserCannotSet", func(t *testing.T) {
+ t.Parallel()
+
+ userID := uuid.New()
+ s, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(defaultSchedule, false) // <---
+ require.NoError(t, err)
+
+ // Use the mock DB here. It won't get used, but if it ever does it will
+ // fail the test.
+ mDB := dbmock.NewMockStore(gomock.NewController(t))
+
+ // Should never reach out to DB to check user's custom schedule.
+ opts, err := s.Get(context.Background(), mDB, userID)
+ require.NoError(t, err)
+ require.NotNil(t, opts.Schedule)
+ require.Equal(t, defaultSchedule, opts.Schedule.String())
+ require.False(t, opts.UserSet)
+ require.False(t, opts.UserCanSet)
+
+ // Set user schedule should fail.
+ _, err = s.Set(context.Background(), mDB, userID, userCustomSchedule1)
+ require.Error(t, err)
+ require.ErrorIs(t, err, agpl.ErrUserCannotSetQuietHoursSchedule)
+ })
+}
diff --git a/enterprise/coderd/users.go b/enterprise/coderd/users.go
index 00c3ecaf0a..935eeb8f6e 100644
--- a/enterprise/coderd/users.go
+++ b/enterprise/coderd/users.go
@@ -4,10 +4,13 @@ import (
"net/http"
"time"
+ "golang.org/x/xerrors"
+
"github.com/coder/coder/v2/coderd/audit"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/httpapi"
"github.com/coder/coder/v2/coderd/httpmw"
+ "github.com/coder/coder/v2/coderd/schedule"
"github.com/coder/coder/v2/codersdk"
)
@@ -62,6 +65,7 @@ func (api *API) userQuietHoursSchedule(rw http.ResponseWriter, r *http.Request)
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{
RawSchedule: opts.Schedule.String(),
UserSet: opts.UserSet,
+ UserCanSet: opts.UserCanSet,
Time: opts.Schedule.TimeParsed().Format("15:40"),
Timezone: opts.Schedule.Location().String(),
Next: opts.Schedule.Next(time.Now().In(opts.Schedule.Location())),
@@ -98,7 +102,12 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques
}
opts, err := (*api.UserQuietHoursScheduleStore.Load()).Set(ctx, api.Database, user.ID, params.Schedule)
- if err != nil {
+ if xerrors.Is(err, schedule.ErrUserCannotSetQuietHoursSchedule) {
+ httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
+ Message: "Users cannot set custom quiet hours schedule due to deployment configuration.",
+ })
+ return
+ } else 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)
@@ -108,6 +117,7 @@ func (api *API) putUserQuietHoursSchedule(rw http.ResponseWriter, r *http.Reques
httpapi.Write(ctx, rw, http.StatusOK, codersdk.UserQuietHoursScheduleResponse{
RawSchedule: opts.Schedule.String(),
UserSet: opts.UserSet,
+ UserCanSet: opts.UserCanSet,
Time: opts.Schedule.TimeParsed().Format("15:40"),
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
index 40c06fbf33..05bfa80e87 100644
--- a/enterprise/coderd/users_test.go
+++ b/enterprise/coderd/users_test.go
@@ -176,4 +176,45 @@ func TestUserQuietHours(t *testing.T) {
require.ErrorAs(t, err, &sdkErr)
require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
})
+
+ t.Run("UserCannotSet", func(t *testing.T) {
+ t.Parallel()
+
+ dv := coderdtest.DeploymentValues(t)
+ dv.UserQuietHoursSchedule.DefaultSchedule.Set("CRON_TZ=America/Chicago 0 0 * * *")
+ dv.UserQuietHoursSchedule.AllowUserCustom.Set("false")
+
+ adminClient, adminUser := coderdenttest.New(t, &coderdenttest.Options{
+ Options: &coderdtest.Options{
+ DeploymentValues: dv,
+ },
+ LicenseOptions: &coderdenttest.LicenseOptions{
+ Features: license.Features{
+ codersdk.FeatureAdvancedTemplateScheduling: 1,
+ },
+ },
+ })
+
+ // Do it with another user to make sure that we're not hitting RBAC
+ // errors.
+ client, user := coderdtest.CreateAnotherUser(t, adminClient, adminUser.OrganizationID)
+
+ // Get the schedule
+ ctx := testutil.Context(t, testutil.WaitLong)
+ sched, err := client.UserQuietHoursSchedule(ctx, user.ID.String())
+ require.NoError(t, err)
+ require.Equal(t, "CRON_TZ=America/Chicago 0 0 * * *", sched.RawSchedule)
+ require.False(t, sched.UserSet)
+ require.False(t, sched.UserCanSet)
+
+ // Try to set
+ _, err = client.UpdateUserQuietHoursSchedule(ctx, user.ID.String(), codersdk.UpdateUserQuietHoursScheduleRequest{
+ Schedule: "CRON_TZ=America/Chicago 30 2 * * *",
+ })
+ require.Error(t, err)
+ var sdkErr *codersdk.Error
+ require.ErrorAs(t, err, &sdkErr)
+ require.Equal(t, http.StatusForbidden, sdkErr.StatusCode())
+ require.Contains(t, sdkErr.Message, "cannot set custom quiet hours schedule")
+ })
}
diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts
index c8887832c3..934f2681fd 100644
--- a/site/src/api/typesGenerated.ts
+++ b/site/src/api/typesGenerated.ts
@@ -1368,12 +1368,14 @@ export interface UserLoginType {
// From codersdk/deployment.go
export interface UserQuietHoursScheduleConfig {
readonly default_schedule: string;
+ readonly allow_user_custom: boolean;
}
// From codersdk/users.go
export interface UserQuietHoursScheduleResponse {
readonly raw_schedule: string;
readonly user_set: boolean;
+ readonly user_can_set: boolean;
readonly time: string;
readonly timezone: string;
readonly next: string;
diff --git a/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.stories.tsx b/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.stories.tsx
index db90165e45..7e3054212e 100644
--- a/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.stories.tsx
+++ b/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.stories.tsx
@@ -8,6 +8,7 @@ const defaultArgs = {
initialValues: {
raw_schedule: "CRON_TZ=Australia/Sydney 0 2 * * *",
user_set: false,
+ user_can_set: true,
time: "02:00",
timezone: "Australia/Sydney",
next: "2023-09-05T02:00:00+10:00",
@@ -33,6 +34,7 @@ export const ExampleUserSet: Story = {
initialValues: {
raw_schedule: "CRON_TZ=America/Chicago 0 2 * * *",
user_set: true,
+ user_can_set: true,
time: "02:00",
timezone: "America/Chicago",
next: "2023-09-05T02:00:00-05:00",
diff --git a/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx b/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx
index 3810457576..932dc4acd6 100644
--- a/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx
+++ b/site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx
@@ -93,17 +93,24 @@ export const ScheduleForm: FC