feat: add server flag to disable user custom quiet hours (#11124)

This commit is contained in:
Dean Sheather 2023-12-15 01:33:51 -08:00 committed by GitHub
parent a58e4febb9
commit 1e49190e12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 358 additions and 46 deletions

View File

@ -447,6 +447,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

View File

@ -457,3 +457,8 @@ userQuietHoursSchedule:
# comma separated values are not supported).
# (default: CRON_TZ=UTC 0 0 * * *, type: string)
defaultQuietHoursSchedule: CRON_TZ=UTC 0 0 * * *
# 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: true, type: bool)
allowCustomQuietHours: true

7
coderd/apidoc/docs.go generated
View File

@ -11353,6 +11353,9 @@ const docTemplate = `{
"codersdk.UserQuietHoursScheduleConfig": {
"type": "object",
"properties": {
"allow_user_custom": {
"type": "boolean"
},
"default_schedule": {
"type": "string"
}
@ -11377,6 +11380,10 @@ const docTemplate = `{
"description": "raw format from the cron expression, UTC if unspecified",
"type": "string"
},
"user_can_set": {
"description": "UserCanSet is true if the user is allowed to set their own quiet hours\nschedule. If false, the user cannot set a custom schedule and the default\nschedule will always be used.",
"type": "boolean"
},
"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"

View File

@ -10292,6 +10292,9 @@
"codersdk.UserQuietHoursScheduleConfig": {
"type": "object",
"properties": {
"allow_user_custom": {
"type": "boolean"
},
"default_schedule": {
"type": "string"
}
@ -10316,6 +10319,10 @@
"description": "raw format from the cron expression, UTC if unspecified",
"type": "string"
},
"user_can_set": {
"description": "UserCanSet is true if the user is allowed to set their own quiet hours\nschedule. If false, the user cannot set a custom schedule and the default\nschedule will always be used.",
"type": "boolean"
},
"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"

View File

@ -4,11 +4,14 @@ import (
"context"
"github.com/google/uuid"
"golang.org/x/xerrors"
"github.com/coder/coder/v2/coderd/database"
"github.com/coder/coder/v2/coderd/schedule/cron"
)
var ErrUserCannotSetQuietHoursSchedule = xerrors.New("user cannot set custom quiet hours schedule due to deployment configuration")
type UserQuietHoursScheduleOptions struct {
// Schedule is the cron schedule to use for quiet hours windows for all
// workspaces owned by the user.
@ -19,7 +22,13 @@ type UserQuietHoursScheduleOptions struct {
// entitled or disabled instance-wide, this value will be nil to denote that
// quiet hours windows should not be used.
Schedule *cron.Schedule
UserSet bool
// UserSet is true if the user has set a custom schedule, false if the
// default schedule is being used.
UserSet bool
// UserCanSet is true if the user is allowed to set a custom schedule. If
// false, the user cannot set a custom schedule and the default schedule
// will always be used.
UserCanSet bool
}
type UserQuietHoursScheduleStore interface {
@ -47,15 +56,12 @@ func NewAGPLUserQuietHoursScheduleStore() UserQuietHoursScheduleStore {
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,
Schedule: nil,
UserSet: false,
UserCanSet: 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
return UserQuietHoursScheduleOptions{}, ErrUserCannotSetQuietHoursSchedule
}

View File

@ -390,6 +390,7 @@ type DangerousConfig struct {
type UserQuietHoursScheduleConfig struct {
DefaultSchedule clibase.String `json:"default_schedule" typescript:",notnull"`
AllowUserCustom clibase.Bool `json:"allow_user_custom" typescript:",notnull"`
// TODO: add WindowDuration and the ability to postpone max_deadline by this
// amount
// WindowDuration clibase.Duration `json:"window_duration" typescript:",notnull"`
@ -1821,6 +1822,16 @@ Write out the current server config as YAML to stdout.`,
Group: &deploymentGroupUserQuietHoursSchedule,
YAML: "defaultQuietHoursSchedule",
},
{
Name: "Allow Custom Quiet Hours",
Description: "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.",
Flag: "allow-custom-quiet-hours",
Env: "CODER_ALLOW_CUSTOM_QUIET_HOURS",
Default: "true",
Value: &c.UserQuietHoursSchedule.AllowUserCustom,
Group: &deploymentGroupUserQuietHoursSchedule,
YAML: "allowCustomQuietHours",
},
{
Name: "Web Terminal Renderer",
Description: "The renderer to use when opening a web terminal. Valid values are 'canvas', 'webgl', or 'dom'.",

View File

@ -107,6 +107,10 @@ type UserQuietHoursScheduleResponse struct {
// UserSet is true if the user has set their own quiet hours schedule. If
// false, the user is using the default schedule.
UserSet bool `json:"user_set"`
// UserCanSet is true if the user is allowed to set their own quiet hours
// schedule. If false, the user cannot set a custom schedule and the default
// schedule will always be used.
UserCanSet bool `json:"user_can_set"`
// Time is the time of day that the quiet hours window starts in the given
// Timezone each day.
Time string `json:"time"` // HH:mm (24-hour)

36
docs/api/enterprise.md generated
View File

@ -1352,6 +1352,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/quiet-hours \
"raw_schedule": "string",
"time": "string",
"timezone": "string",
"user_can_set": true,
"user_set": true
}
]
@ -1367,14 +1368,15 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/quiet-hours \
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. |
| 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_can_set` | boolean | false | | User can set is true if the user is allowed to set their own quiet hours schedule. If false, the user cannot set a custom schedule and the default schedule will always be used. |
| `» 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).
@ -1418,6 +1420,7 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/quiet-hours \
"raw_schedule": "string",
"time": "string",
"timezone": "string",
"user_can_set": true,
"user_set": true
}
]
@ -1433,14 +1436,15 @@ curl -X PUT http://coder-server:8080/api/v2/users/{user}/quiet-hours \
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. |
| 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_can_set` | boolean | false | | User can set is true if the user is allowed to set their own quiet hours schedule. If false, the user cannot set a custom schedule and the default schedule will always be used. |
| `» 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).

1
docs/api/general.md generated
View File

@ -394,6 +394,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \
},
"update_check": true,
"user_quiet_hours_schedule": {
"allow_user_custom": true,
"default_schedule": "string"
},
"verbose": true,

26
docs/api/schemas.md generated
View File

@ -2324,6 +2324,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
},
"update_check": true,
"user_quiet_hours_schedule": {
"allow_user_custom": true,
"default_schedule": "string"
},
"verbose": true,
@ -2700,6 +2701,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
},
"update_check": true,
"user_quiet_hours_schedule": {
"allow_user_custom": true,
"default_schedule": "string"
},
"verbose": true,
@ -5659,15 +5661,17 @@ If the schedule is empty, the user will be updated to use the default schedule.|
```json
{
"allow_user_custom": true,
"default_schedule": "string"
}
```
### Properties
| Name | Type | Required | Restrictions | Description |
| ------------------ | ------ | -------- | ------------ | ----------- |
| `default_schedule` | string | false | | |
| Name | Type | Required | Restrictions | Description |
| ------------------- | ------- | -------- | ------------ | ----------- |
| `allow_user_custom` | boolean | false | | |
| `default_schedule` | string | false | | |
## codersdk.UserQuietHoursScheduleResponse
@ -5677,19 +5681,21 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"raw_schedule": "string",
"time": "string",
"timezone": "string",
"user_can_set": true,
"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. |
| 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_can_set` | boolean | false | | User can set is true if the user is allowed to set their own quiet hours schedule. If false, the user cannot set a custom schedule and the default schedule will always be used. |
| `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

11
docs/cli/server.md generated
View File

@ -31,6 +31,17 @@ coder server [flags]
The URL that users will use to access the Coder deployment.
### --allow-custom-quiet-hours
| | |
| ----------- | --------------------------------------------------------- |
| Type | <code>bool</code> |
| Environment | <code>$CODER_ALLOW_CUSTOM_QUIET_HOURS</code> |
| YAML | <code>userQuietHoursSchedule.allowCustomQuietHours</code> |
| Default | <code>true</code> |
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
| | |

View File

@ -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

View File

@ -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 {

View File

@ -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)

View File

@ -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

View File

@ -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)
})
}

View File

@ -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())),

View File

@ -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")
})
}

View File

@ -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;

View File

@ -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",

View File

@ -93,17 +93,24 @@ export const ScheduleForm: FC<React.PropsWithChildren<ScheduleFormProps>> = ({
</Alert>
)}
{!initialValues.user_can_set && (
<Alert severity="error">
Your administrator has disabled the ability to set a custom quiet
hours schedule.
</Alert>
)}
<Stack direction="row">
<TextField
{...getFieldHelpers("time")}
disabled={isLoading}
disabled={isLoading || !initialValues.user_can_set}
label="Start time"
type="time"
fullWidth
/>
<TextField
{...getFieldHelpers("timezone")}
disabled={isLoading}
disabled={isLoading || !initialValues.user_can_set}
label="Timezone"
select
fullWidth
@ -126,7 +133,7 @@ export const ScheduleForm: FC<React.PropsWithChildren<ScheduleFormProps>> = ({
<div>
<LoadingButton
loading={isLoading}
disabled={isLoading}
disabled={isLoading || !initialValues.user_can_set}
type="submit"
variant="contained"
>

View File

@ -37,6 +37,7 @@ const submitForm = async () => {
const defaultQuietHoursResponse = {
raw_schedule: "CRON_TZ=America/Chicago 0 0 * * *",
user_set: false,
user_can_set: true,
time: "00:00",
timezone: "America/Chicago",
next: "", // not consumed by the frontend
@ -52,7 +53,6 @@ const cronTests = [
describe("SchedulePage", () => {
beforeEach(() => {
// appear logged out
server.use(
rest.get(`/api/v2/users/${MockUser.id}/quiet-hours`, (req, res, ctx) => {
return res(ctx.status(200), ctx.json(defaultQuietHoursResponse));
@ -72,7 +72,6 @@ describe("SchedulePage", () => {
return res(
ctx.status(200),
ctx.json({
response: {},
raw_schedule: data.schedule,
user_set: true,
time: `${test.hour.toString().padStart(2, "0")}:${test.minute
@ -121,4 +120,39 @@ describe("SchedulePage", () => {
expect(errorMessage).toBeDefined();
});
});
describe("when user custom schedule is disabled", () => {
it("shows a warning and disables the form", async () => {
server.use(
rest.get(
`/api/v2/users/${MockUser.id}/quiet-hours`,
(req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
raw_schedule: "CRON_TZ=America/Chicago 0 0 * * *",
user_can_set: false,
user_set: false,
time: "00:00",
timezone: "America/Chicago",
next: "", // not consumed by the frontend
}),
);
},
),
);
renderWithAuth(<SchedulePage />);
await screen.findByText(
"Your administrator has disabled the ability to set a custom quiet hours schedule.",
);
const timeInput = screen.getByLabelText("Start time");
expect(timeInput).toBeDisabled();
const timezoneDropdown = screen.getByLabelText("Timezone");
expect(timezoneDropdown).toHaveClass("Mui-disabled");
const updateButton = screen.getByText("Update schedule");
expect(updateButton).toBeDisabled();
});
});
});