mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add server flag to disable user custom quiet hours (#11124)
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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())),
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user