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

This commit is contained in:
Dean Sheather
2023-12-15 19:33:51 +10:00
committed by GitHub
parent a58e4febb9
commit 1e49190e12
22 changed files with 358 additions and 46 deletions
+1 -1
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 {
+2 -2
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)
+14 -3
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
+131
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)
})
}
+11 -1
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())),
+41
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")
})
}