feat: add user quiet hours schedule and restart requirement feature flag (#8115)

This commit is contained in:
Dean Sheather
2023-07-20 23:35:41 +10:00
committed by GitHub
parent 4821e2e6d8
commit dc8b73168e
67 changed files with 4340 additions and 767 deletions
+16 -13
View File
@@ -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,
+9 -8
View File
@@ -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)
+14
View File
@@ -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.
+67 -6
View File
@@ -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 {
store := schedule.NewAGPLTemplateScheduleStore()
api.AGPL.TemplateScheduleStore.Store(&store)
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 {
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)
}
}
@@ -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)
+15 -100
View File
@@ -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
}
+120
View File
@@ -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
}
+98
View File
@@ -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
}
+47 -67
View File
@@ -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()
+121
View File
@@ -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, &params) {
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())),
})
}
+213
View File
@@ -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())
})
}
+13 -13
View File
@@ -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) {