mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add user quiet hours schedule and restart requirement feature flag (#8115)
This commit is contained in:
+16
-13
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
[1mUser Quiet Hours Schedule Options[0m
|
||||
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).
|
||||
|
||||
[1m⚠️ Dangerous Options[0m
|
||||
--dangerous-allow-path-app-sharing bool, $CODER_DANGEROUS_ALLOW_PATH_APP_SHARING
|
||||
Allow workspace apps that are not served from subdomains to be shared.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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, ¶ms) {
|
||||
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())),
|
||||
})
|
||||
}
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user